Next.js + MySQL을 활용해서 개인 웹 사이트에 글 포스팅하기
2025년 10월 26일 오후 11:11
1. 개인 웹 사이트를 다시 디자인해보자
기존 웹 사이트에 접속하면 맨 처음에 내 사진과 자기소개가 뜬다. 그리고 프로젝트와 내가 좋아하는 노래들을 소개하는 페이지가 있다. 사이트를 둘러보다가, 문득 전부 갈아엎어야겠다는 다짐을 했다. 일단 무엇보다 처음에 들어가자마자 내 사진이 뜨는게 좀 부담스러웠고, 블로그에 올린 글을 개인 사이트에도 보여주고 싶었다. 그리고 데이터들을 HTML에 하드코딩해서 표시하지 않고, 데이터베이스에 저장해서 API를 통해서 보여주고 싶었다.
나는 개인 웹 사이트를 Next.js를 활용해서 구현했다. 서버 컴포넌트와 SSG를 활용하긴 했지만 Next.js의 라우팅 기능을 활용한 data fetching은 활용하지 않았기 때문에, 이번 기회에 데이터베이스를 만들어서 data fetching을 해보기로 했다.
2. MySQL DB를 AWS와 연결하자
먼저 MySQL과 MySQL WorkBench를 설치하고 SQL문을 입력해 사용법을 익혔다. 그리고 웹 사이트에서 데이터베이스를 접근해야 하니 AWS RDS에 MySQL DB 인스턴스를 생성했다.
그리고 MySQL WorkBench에서 AWS RDS 인스턴스에 접근을 할 수 있어야하므로
AWS 홈페이지의 ‘Aurora and RDS’ → DB 인스턴스 클릭 → 연결 및 보안에서 ‘VPC 보안 그룹’ 클릭 (EC2 페이지의 보안그룹으로 접속) → 보안 그룹 ID 클릭 → 인바운드 규칙에서 ‘인바운드 규칙 편집’을 클릭 → 소스 유형을 ‘내 IP’로 선택하고 규칙 저장 클릭의 과정을 진행해주었다.
장소가 바뀌고, 와이파이가 바뀌어 IP 주소가 바뀔 때마다 위 과정을 다시 해줘야 MySQL WorkBench에 접속이 가능했다. 이 과정이 조금 귀찮아서 그냥 소스를 0.0.0.0/0으로 설정하면 되지 않을까? 고민을 했었는데, 지인들한테 물어보니 그러면 해킹 당할 수 있는 위험성이 있다고 해서 그냥 위 과정을 반복해주었다.
그리고 Next.js에서 먼저 lib/db.js를 생성해 mysql2 library를 활용하여 pool을 생성해주었다.
import mysql from 'mysql2/promise';
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT,
});
export default pool;
(당연히 환경변수를 활용해야한다)
앞서 언급했듯이 개인 웹 사이트에 글을 포스팅하고 싶었으므로 /post와 /post/:id에 해당하는 page.tsx를 생성해주었다. (Next.js 15를 활용하므로 App Routing 방식을 적용했다)
그리고 app/api/posts/route.ts를 생성해서 posts 테이블에서 데이터를 가져오고,
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
export async function GET(req: NextRequest) {
try {
const connection = await pool.getConnection();
const [rows] = await connection.query('SELECT * FROM posts');
connection.release();
return NextResponse.json(rows);
} catch (error) {
console.error('DB 에러:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
app/api/posts/[id]/route.ts를 생성해서 해당 글을 클릭했을 때 해당 id에 해당하는 정보를 data fetching 하도록 했다.
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket } from 'mysql2/promise';
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const connection = await pool.getConnection();
const [rows] = await connection.query<RowDataPacket[]>(
'SELECT * FROM posts WHERE id = ?',
[params.id]
);
connection.release();
if (rows.length === 0) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
return NextResponse.json(rows[0]);
} catch (error) {
console.error('DB 에러:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
3. 스크립트를 활용하여 .md 파일의 내용을 업로드하자
이제 블로그에 올린 글들을 DB에 저장해보자. 나는 글을 티스토리에 올렸었는데, 안타깝게도 티스토리의 open API 서비스는 종료되었다. 다행히도 나는 글을 전부 노션에 작성하고 티스토리에 옮겨왔고, 지금까지 작성된 글들이 노션에 저장되어 있었다. 그러면 노션에서 md 파일을 export하고, 그 파일을 올리면 되겠구나.
그래서 프로젝트의 data/posts에 md 파일을 업로드하고, scripts/importPosts.cjs를 생성해 해당 스크립트를 통해 md 파일의 내용들을 DB에 업로드하기로 했다. (당연히 이 파일들은 git에서 Tracking하지 않는다.)
문제는 이미지였다. md 파일 안에는 이미지가 있었고, 이 이미지들은 로컬에 저장되어 있었으므로 클라이언트에서 이미지를 인식하지 못하는 문제가 생겼다. 결국 md 파일 안에 포함되어 있는 이미지들을 CDN에 올려서 링크로 변환해야 하는데, 글을 올릴 때마다 이러면 너무 귀찮았기 때문에 어떻게 해야 이 과정을 자동화할 수 있을지 고민하였고, 스크립트에 아래 내용을 추가해서 문자를 해결했다.
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
dotenv.config({ path: '.env.local' });
const postsDir = path.join(__dirname, '../data/posts');
// UUID 간단 버전 생성 (처음 8자리)
function generateShortId() {
return crypto.randomBytes(4).toString('hex');
}
(...)
// 마크다운에서 이미지 경로를 CloudFront URL로 변환
function transformImageUrls(mdContent, imageId) {
let imageCount = 0;
//  패턴 찾기
return mdContent.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, () => {
imageCount++;
const cloudFrontUrl = `https://(...).cloudfront.net/posts/${imageId}-${imageCount}.png`;
return ``;
});
}
async function importPostsFromMarkdown() {
try {
const files = fs.readdirSync(postsDir).filter(f => f.endsWith('.md'));
for (const file of files) {
const filePath = path.join(postsDir, file);
const mdContent = fs.readFileSync(filePath, 'utf-8');
// UUID 기반 이미지 ID 생성
const imageId = generateShortId();
(...)
// 이미지 URL 변환 (UUID 기반)
body = transformImageUrls(body, imageId);
if (body.length > 0) {
await insertPost(title, body);
console.log(` 이미지 파일명: ${imageId}-1.png, ${imageId}-2.png 등`);
}
}
(...)
console.log('파일명 형식: {UUID}-{순서번호}.png');
console.log(' 예: a1b2c3d4-1.png, a1b2c3d4-2.png');
} catch (error) {
console.error('❌ 임포트 실패:', error.message);
}
}
importPostsFromMarkdown();
먼저 스크립트가 시작되면 먼저 UUID 기반 랜덤 문자열을 생성한다.
그리고 이미지를 발견하면 해당 이미지의 링크를 ‘나의 cloudfront의 링크/posts/랜덤 문자열-{이미지 인덱스}.png’로 생성하였다.
그리고 변환이 완료하면 터미널에 어떤 랜덤 문자열이 생성되었는지 출력한다.
그러면 나는 해당 랜덤 문자열을 확인하고 이미지들을 s3에 랜덤 문자열-1.png, 랜덤 문자열-2.png 순으로 업로드한다.
생성한 이미지를 s3에 업로드하는 과정이 필요하긴 했지만 꽤 간편하게 md 파일의 이미지들을 링크로 변환하고 매칭할 수 있었다.
4. created_at 기반 순서 인덱스는 백엔드에서 생성한다
블로그에서 글을 옮길 때, 노션 파일에는 블로그 업로드 시간이 기록되어 있지 않으므로 일단 DB에 업로드하고 블로그에서 업로드 시간을 확인하고 created_at 칼럼을 직접 업데이트해주었다. 그러면 DB의 id는 데이터를 삽입한 순으로 증가하지만(auto_increment), 나는 created_at을 기준으로 정렬을 해주고 싶었다. 그런데 프론트엔드에서는 id를 기준으로 정렬하였기 때문에, 처음에는 SQL문을 통하여 id를 created_at을 기준으로 정렬해주었다.
// 1. 임시로 새 테이블 생성
CREATE TABLE posts_new LIKE posts;
// 2. 새 테이블에 기존 테이블 내용을 삽입하고 created_at 기준으로 정렬
INSERT INTO posts_new (title, body, tags, created_at, updated_at)
SELECT title, body, tags, created_at, updated_at
FROM posts
ORDER BY created_at ASC;
// 3. 기존 테이블을 삭제하고 새 테이블 이름을 기존 테이블로 변경
DROP TABLE posts;
RENAME TABLE posts_new TO posts;`
id 속성을 created_at 정렬 기준으로 정렬할 수는 있을까? 방법을 찾아보니 별로 권장되는 방법은 아니었다. 그래서 Next.js에서 데이터를 불러올 때 created_at을 정렬해서 데이터를 불러왔다. 그리고 정렬된 데이터들에 새로운 인덱스를 부여해서 정렬을 해주었다.
이러면 생기는 문제가, 기존 글들을 /post/1처럼 id 기반으로 라우팅을 해주었는데, 우리는 새로 받아온 데이터들을 created_at을 기준으로 정렬해주었기 때문에 맨 밑에 있는 글의 id가 1이 아닐 수도 있다. 즉, 데이터를 불러온 백엔드에서 생성한 인덱스를 기준으로 라우팅의 기준을 바꿔줘야한다.
먼저, app/api/posts/route.ts의 data fetching SQL문에 created_at 기준 정렬 문구를 삽입하고, 백엔드에서 displayId라는 새로운 인덱스를 생성해준다.
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket } from 'mysql2/promise';
export async function GET(req: NextRequest) {
try {
const connection = await pool.getConnection();
const [rows] = await connection.query<RowDataPacket[]>(
'SELECT * FROM posts ORDER BY created_at DESC'
);
connection.release();
// displayId 추가
const postsWithDisplayId = (rows as any[]).map((post, index) => ({
...post,
displayId: rows.length - index
}));
return NextResponse.json(postsWithDisplayId);
} catch (error) {
console.error('DB 에러:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
그리고 app/api/posts/[id]/route.ts를 app/api/posts/[displayId]/route.ts로 변경하고, params도 id에서 displayId로 수정해준다.
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
import { RowDataPacket } from 'mysql2/promise';
export async function GET(
req: NextRequest,
{ params }: { params: { displayId: string } }
) {
try {
const connection = await pool.getConnection();
const [rows] = await connection.query<RowDataPacket[]>(
'SELECT * FROM posts ORDER BY created_at DESC'
);
connection.release();
const displayId = parseInt(params.displayId);
const postIndex = rows.length - displayId;
if (postIndex < 0 || postIndex >= rows.length) {
return NextResponse.json({ error: 'Post not found' }, { status: 404 });
}
return NextResponse.json(rows[postIndex]);
} catch (error) {
console.error('DB 에러:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
}
물론 page도 [id]/page.tsx에서 [displayId]/page.tsx로 변경하고, 표시되는 숫자도 id에서 displayId로 수정해주면 된다.
이렇게 created_at을 기준으로 글들을 정렬해서 표시할 수 있었고, 정렬 후 백엔드에서 생성한 인덱스를 기준으로 라우팅을 구현할 수 있었다.
5. 그런데 배포에 실패했다
글을 다쓰고 DB에 위 내용을 업로드했다. 그리고 vercel에 환경변수를 넣고 배포를 했는데, 이상하게 /post로 넘어가지 못하고 에러가 발생했다. 생각해보니 배포 후 RDS 인바운드 규칙에 ip 주소를 업데이트하지 않았다. vercel은 어떤 ip 주소를 가지고 있을까? 그런데 찾아보니 vercel은 서버리스라서 유동적인 ip 주소를 가지고 있었다. 즉, vercel의 고정 ip 주소를 넣어서 RDS와 연결할수 없다!
이제 어떻게 해야할까 찾아보니
vercel 유료 버전을 결제해서 고정 ip 주소를 부여받거나,
AWS Lambda 함수를 생성해서 API Gateway 엔드포인트 만들면 배포가 가능하다고 한다. (복잡)
planetscale로 마이그레이션을 하는 방법도 추천받아서 회원가입까지 했는데 무료 버전이 없어서 한탄을 하기도 했다.
아니면 vercel이 아닌 다른 배포 방법을 생각해봐도 좋을 것 같다. 어떻게 해야할지 더 고민해보자.