개인 블로그 API 서버를 NestJS로 직접 만든 이유
Fastify + Prisma + R2로 블로그·포트폴리오 백엔드를 구축한 경험
프론트엔드 개발자가 백엔드를 직접 만들 일은 사실 많지 않습니다.
블로그는 MDX 파일을 로컬에 두고 Next.js SSG로 빌드하면 그만이었고, 포트폴리오는 JSON 파일 하나면 충분했습니다. 근데 어드민 페이지에서 글을 쓰고, 포트폴리오 데이터를 관리하고, 이미지를 업로드하고, 다국어까지 지원하려니까 — 정적 파일로는 한계가 명확했습니다.
결론부터 말하면, NestJS + Fastify + Prisma + PostgreSQL 조합으로 API 서버를 만들었고, 맥미니 홈서버에 Docker로 배포하고 있습니다.
왜 NestJS인가
Express로 빠르게 만들 수도 있었지만, 혼자 개발하더라도 구조가 잡혀있는 편이 낫다고 생각했습니다. NestJS는 모듈 시스템이 강제되기 때문에, 나중에 기능을 추가해도 코드가 흩어지지 않습니다.
HTTP 어댑터는 Fastify를 선택했습니다. Express 대비 벤치마크가 빠르기도 하지만, 솔직히 개인 블로그 트래픽에서 그 차이가 체감되진 않습니다. Fastify를 선택한 진짜 이유는 스키마 기반 검증이랑 플러그인 시스템이 깔끔해서입니다.
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ logger: process.env.NODE_ENV !== 'production', trustProxy: true }),
);프로젝트 구조
src/
├── blog/ # 포스트, 카테고리, 태그 CRUD
├── portfolio/ # 프로필, 경력, 프로젝트, 스킬, 교육, Works
├── auth/ # JWT 로그인, 토큰 리프레시, 2FA
├── storage/ # Cloudflare R2 이미지 관리
├── analytics/ # 페이지뷰, 어드민 로그
├── revalidation/ # 프론트엔드 ISR 트리거
├── prisma/ # PrismaService
└── common/ # 가드 체인, 데코레이터, 예외 필터
도메인별로 모듈을 분리했습니다. 블로그와 포트폴리오가 완전히 독립적이라, 한쪽을 수정해도 다른 쪽에 영향이 없습니다.
멀티스키마 DB 설계
단일 PostgreSQL 데이터베이스 안에서 4개 스키마로 도메인을 분리했습니다.
datasource db {
provider = "postgresql"
schemas = ["analytics", "auth", "blog", "portfolio"]
}auth.refresh_tokens, blog.posts, portfolio.works, analytics.page_views — 이렇게 스키마 이름만 봐도 어디 소속인지 바로 알 수 있습니다.
인증 구조
단일 어드민 시스템이라 OAuth나 세션 기반 인증은 과했습니다. JWT access token + refresh token 조합을 선택했습니다.
function extractFromCookieOrHeader(req: RequestWithCookies): string | null {
// cookie 우선, 없으면 Bearer 헤더에서 추출
const cookieToken = req.cookies?.[ACCESS_TOKEN_COOKIE];
if (cookieToken) return cookieToken;
return ExtractJwt.fromAuthHeaderAsBearerToken()(req);
}프론트엔드(어드민)에서는 httpOnly cookie로 인증하고, Swagger나 외부 스크립트에서는 Bearer 토큰으로 테스트할 수 있게 했습니다.
가드 체인
NestJS의 글로벌 가드를 4단계로 구성했습니다.
providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard }, // 1. Rate limit (100req/60s)
{ provide: APP_GUARD, useClass: ApiKeyGuard }, // 2. 공개 API: x-api-key 검증
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // 3. 어드민 API: JWT 검증
{ provide: APP_GUARD, useClass: AdminIpGuard }, // 4. IP 화이트리스트
]공개 API(@Public() 데코레이터)는 API Key만 검증하고, 어드민 API는 JWT + IP까지 확인합니다. 순서가 중요합니다 — rate limit을 맨 앞에 두어서 인증 로직이 실행되기 전에 과도한 요청을 차단합니다.
TOTP 2FA
오픈소스 프로젝트라 인증 구조가 공개되어 있습니다. 비밀번호만으로는 부족하다고 판단해서 TOTP(Time-based One-Time Password) 2단계 인증을 추가했습니다. Google Authenticator나 1Password에서 쓰는 그 6자리 코드입니다.
로그인 플로우가 두 단계로 나뉩니다.
1단계: username + password → 검증 성공 → twoFactorToken 발급 (JWT 미발급)
2단계: twoFactorToken + 6자리 TOTP 코드 → 검증 성공 → JWT 발급
1단계에서 비밀번호가 맞아도 바로 로그인되지 않습니다. 임시 토큰만 발급하고, Authenticator 앱에서 생성한 코드까지 맞아야 최종 인증이 완료됩니다.
TOTP secret은 DB에 AES-256-GCM으로 암호화해서 저장합니다. DB가 유출되어도 secret 원문은 노출되지 않습니다.
셋업 플로우도 2단계입니다.
1. POST /2fa/setup → QR 코드 발급 (pending 상태로 DB 저장)
2. Authenticator 앱에 QR 등록
3. POST /2fa/enable → TOTP 코드 검증 → active로 승격
setup만 호출하고 enable을 안 하면 2FA가 활성화되지 않습니다. QR을 스캔하지 않은 채 활성화되어 잠기는 상황을 방지하기 위해서입니다.
async enableTwoFactor(code: string) {
const pendingSecret = /* pending에서 복호화 */;
const isValid = verifySync({ token: code, secret: pendingSecret });
if (!isValid) throw new UnauthorizedException('Invalid code');
// pending → active로 승격
await this.prisma.$transaction([
this.prisma.adminSetting.upsert({ /* active에 암호화 저장 */ }),
this.prisma.adminSetting.delete({ /* pending 삭제 */ }),
]);
}비활성화할 때도 TOTP 코드가 필요합니다. JWT 토큰이 탈취되더라도 Authenticator 앱 없이는 2FA를 끌 수 없습니다.
Contact 메일 알림
포트폴리오 Contact Form으로 메시지가 오면 DB에 저장하면서 동시에 Gmail로 알림 메일을 보냅니다. nodemailer + Gmail SMTP(앱 비밀번호) 조합입니다.
await this.transporter.sendMail({
from: `"chahyunwoo.dev" <${this.adminEmail}>`,
to: this.adminEmail,
replyTo: contact.email, // 답장하면 보낸 사람에게 직접 전달
subject: `[Portfolio Contact] ${contact.subject || 'New Message'} — ${contact.name}`,
html: `...`, // XSS 방지: escapeHtml 적용
});이미지 파이프라인
블로그 글에 이미지를 넣으면 Cloudflare R2에 저장됩니다. 근데 글 작성 중에 업로드한 이미지를 바로 확정 경로에 넣으면, 글을 저장하지 않고 나갔을 때 고아 파일이 생깁니다.
그래서 2단계로 나눴습니다.
1. 업로드 → blog/temp/uuid-image.png (임시)
2. 글 저장 → blog/posts/{slug}/uuid-image.png (확정)
// 글 저장 성공 후 이미지를 확정 경로로 이동
try {
const finalized = await this.finalizeImages(slug, post.content, post.thumbnailUrl);
updated = await this.prisma.post.update({
where: { slug },
data: { content: finalized.content, thumbnailUrl: finalized.thumbnailUrl },
});
} catch (moveError) {
// 이미지 이동 실패 시 포스트 롤백
await this.prisma.post.delete({ where: { slug } }).catch(deleteErr =>
this.logger.error('Rollback failed', deleteErr),
);
throw moveError;
}DB 저장이 성공한 후에만 이미지를 이동합니다. 이동에 실패하면 포스트를 롤백합니다. 그리고 매일 새벽 3시에 크론 작업이 24시간 이상된 temp 파일을 정리합니다.
캐싱 전략
인메모리 캐시를 네임스페이스로 분리했습니다. blog:posts:1:10:Frontend 같은 키로 저장하고, 글을 쓰거나 수정하면 blog:* 전체를 무효화합니다.
this.cache = new NamespacedCache(rawCache, BLOG_CACHE_PREFIX);
// 읽기: 캐시 히트 시 즉시 반환
const cached = await this.cache.get(key);
if (cached) return cached;
// 쓰기: 네임스페이스 전체 무효화
await this.cache.invalidate();블로그는 TTL 60초, 포트폴리오는 5분입니다. 블로그가 더 짧은 이유는 포스트 작성 중에 프리뷰로 바로 확인해야 하기 때문입니다.
포트폴리오 Work 모델
경력(Experience)과 프로젝트(Project)를 별도 모델로 관리하다가, 결국 하나로 합쳤습니다. 둘 다 구조가 같았거든요 — 기간, 기술스택, 역할, 상세 설명.
const works = await this.prisma.work.findMany({
where: type ? { type } : undefined, // 'business' | 'personal' 필터
include: { translations: { where: { locale } } },
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }],
});type: 'business'는 회사 프로젝트, 'personal'은 사이드 프로젝트입니다. 리스트 API에서 content(마크다운 전문)까지 전부 내려줍니다. 데이터가 15건 정도라 분리할 필요가 없었습니다.
그라디언트 색상 자동 생성
프로젝트 카드에 스크린샷 대신 그라디언트 배경을 쓰고 있습니다. 프로젝트 제목을 해시해서 hue 값을 추출하면 항상 같은 색상이 나옵니다.
export function generateGradientColors(title: string, featured: boolean): [string, string] {
const hue = hashToHue(title);
const saturation = featured ? 80 : 40; // featured는 선명하게
const lightness = featured ? 60 : 50; // 아닌 건 차분하게
const color1 = hslToHex(hue, saturation, lightness / 100);
const color2 = hslToHex((hue + 30) % 360, saturation, lightness / 100);
return [color1, color2];
}프론트에서는 gradientColors[0]과 [1]로 linear-gradient를 만들면 됩니다. 별도 이미지 관리 없이 시각적으로 구분되는 카드를 만들 수 있었습니다.
다국어 지원
한국어, 영어, 일본어 3개 언어를 지원합니다. 번역이 필요한 모델은 Translation 테이블을 별도로 두고, locale 파라미터로 필터링합니다.
Work → WorkTranslation (locale, title, role, summary, content, highlights)
Experience → ExperienceTranslation (locale, title, role, responsibilities)
공개 API는 ?locale=ko로 해당 언어만, 어드민 개별 조회(/:id)는 translations 전체를 반환합니다. 어드민에서 3개 언어를 한 화면에서 편집할 수 있게 하기 위해서입니다.
배포
맥미니에 Docker로 배포합니다. GitHub Actions에서 main 브랜치에 push하면 Tailscale SSH로 맥미니에 접속해서 배포합니다.
- name: Deploy to Mac mini
script: |
cd ~/api-server
git pull origin main
docker compose --env-file .env.prod build --no-cache api
docker compose --env-file .env.prod up -d
# 헬스체크 (3초 간격, 최대 60초)
for i in $(seq 1 20); do
curl -sf http://localhost:4000/health && exit 0
sleep 3
done
exit 1Dockerfile은 4단계 멀티스테이지 빌드입니다. 최종 이미지에는 빌드 도구 없이 런타임만 들어갑니다. 컨테이너 시작 시 prisma migrate deploy가 자동 실행되어서 스키마 변경도 배포 한 번으로 끝납니다.
헤맸던 부분들
Docker compose에서 bcrypt 해시가 깨지는 문제
.env.prod에 bcrypt 해시를 넣었는데, Docker compose가 $ 문자를 변수로 해석해서 해시가 깨졌습니다. $2b$12$e4JP...에서 $e4JP를 변수로 치환하려다 빈 문자열이 된 겁니다.
해결: $를 $$로 이스케이프합니다.
# 틀림
ADMIN_PASSWORD_HASH=$2b$12$e4JP.zLUr...
# 맞음
ADMIN_PASSWORD_HASH=$$2b$$12$$e4JP.zLUr...
cross-origin cookie 전송
어드민(admin.chahyunwoo.dev)에서 API(api.chahyunwoo.dev)로 요청할 때 cookie가 안 날아갔습니다. 두 가지를 설정해야 했습니다.
sameSite: 'none'+secure: true— cross-site cookie 허용domain: '.chahyunwoo.dev'— 서브도메인 간 cookie 공유
const cookieBase = {
secure: this.isProduction,
sameSite: this.isProduction ? 'none' : 'strict',
...(this.cookieDomain && { domain: this.cookieDomain }),
};로컬에서는 sameSite: 'strict' (같은 origin이니까), 프로덕션에서는 none + domain을 설정합니다.
프로필 이미지 자기 삭제 버그
프로필 업데이트 시 이전 이미지를 R2에서 삭제하는 로직이 있는데, 같은 URL로 업데이트하면 방금 올린 파일을 삭제해버리는 버그가 있었습니다.
// 수정 전: old URL을 무조건 삭제 → 같은 URL이면 자기 삭제
if (oldImageUrl) this.storage.delete(oldImageUrl);
// 수정 후: old URL과 new URL이 다를 때만 삭제
if (oldImageUrl && oldImageUrl !== dto.imageUrl) {
this.storage.delete(oldImageUrl);
}정리
좋은 점
- NestJS 모듈 시스템 덕분에 기능 추가가 깔끔합니다. 새 도메인은 모듈 하나 만들면 끝입니다.
- Prisma 멀티스키마로 테이블이 깔끔하게 분리됩니다.
- 이미지 temp → 확정 파이프라인 덕분에 고아 파일 걱정이 없습니다.
- Tailscale + Docker 조합으로 포트포워딩 없이 배포가 됩니다.
- 2FA를 DB 기반 + AES-256-GCM 암호화로 구현해서, env 의존 없이 관리할 수 있습니다.
- Contact Form 메시지가 오면 Gmail로 실시간 알림이 와서 놓치지 않습니다.
아쉬운 점
- 인메모리 캐시라 배포 시 초기화됩니다. 데이터가 날아가는 건 아니고 첫 요청만 DB에서 다시 읽어옵니다. Redis를 붙이면 해결되지만 개인 블로그에 Redis까지 띄우긴 과합니다.
- Prisma의 raw query가 필요한 순간이 종종 있습니다. analytics 쪽 통계 쿼리는 결국
$queryRaw를 썼습니다. - 프론트엔드와 백엔드를 혼자 만드니까 API 스펙 변경할 때 양쪽 다 수정해야 합니다. openapi-typescript로 타입 자동 생성을 붙이면 나아질 것 같습니다.
백엔드를 직접 만들어보니 확실히 시야가 넓어집니다. API 설계, DB 인덱싱, 캐시 전략, 보안 — 프론트에서 "당연히 되겠지" 하고 넘기던 것들이 생각보다 많은 고민이 필요한 영역이었습니다.
참고 자료
연관 게시글