사이트 둘이 한 서버를 나눠 쓰게

2025.12 ~ 2026.04 (운영 중)

블로그와 포트폴리오 두 사이트가 함께 쓰는 콘텐츠·인증·통계 API 서버입니다.

과제

  • 성격이 다른 두 프론트가 하나의 백엔드를 소비해야 함
  • 글·이미지·다국어 이력·통계·어드민 인증을 따로 관리하면 운영 불가

담당 범위

  • REST API 설계·구현: NestJS 11 + Fastify, Prisma (단독 개발)
  • 인증: JWT + Refresh Token Rotation, TOTP 2FA, 전역 Guard 체인
  • 배포·CI: Docker 멀티스테이지, OpenAPI drift 게이트

설계 판단

  • 다국어를 엔티티별 translation 테이블로 분리 → 언어 추가 시 스키마 변경 불필요
  • OpenAPI 스펙을 파일로 커밋하고 CI에서 drift 검사 → 서버 없이 프론트 타입 생성
  • 이미지 이동 부분 실패 시 반영분을 기록하고 500 반환 → 다음 날 깨지는 이미지 방지

결과 지표

  • API 오퍼레이션 72개
  • 72개 중 60개가 200/201 JSON 스키마 선언, 12개가 204 → 미선언 0건
  • Prisma 모델 21개, 스키마 4개, 마이그레이션 14회
원문 자세히 보기

과제

블로그와 포트폴리오라는 성격이 다른 두 프론트(별도 저장소)가 하나의 백엔드를 소비해야 했고, 글·이미지·다국어 이력·방문 통계·어드민 인증이 각각 따로 관리되면 운영이 불가능했다. 한 서버에서 콘텐츠 CRUD와 자산 저장, 다국어 조회, 접속 통계, 어드민 보안을 모두 처리하면서, 정적 생성 기반 프론트가 변경을 즉시 반영할 수 있도록 갱신 신호까지 밀어 주는 것이 목표였다.

담당 범위

오퍼레이션 72개 · Prisma 모델 21개 · 스키마 4개 · 소스 7,634줄

백엔드 단독 개발. 구체적으로 (1) REST API 설계 — 5개 도메인(blog/portfolio/auth/analytics/health) 72개 오퍼레이션의 라우트·쿼리·DTO 계약 설계, (2) DB 모델링 — PostgreSQL 멀티스키마(auth/blog/portfolio/analytics) 21개 모델과 마이그레이션 14회 운영, (3) 인증·인가 — JWT 액세스 토큰 + Refresh Token Rotation, TOTP 2FA, 공개 API용 API Key, 어드민 IP 화이트리스트, 전역 Guard 체인 구성, (4) 파일 스토리지 — Cloudflare R2(S3 호환) 업로드·이동·삭제와 임시 파일 크론 정리, (5) 배포 — Dockerfile 멀티스테이지, Compose 환경 분리(base/local/prod), GitHub Actions CI(lint·typecheck·OpenAPI drift·build·test) 및 사설 VPN 경유 SSH를 통한 자체 호스팅 서버 자동 배포, (6) 프론트와의 타입 계약 — openapi.json 생성 파이프라인과 CI 게이트, (7) 테스트 기반 구축 — 운영 DB 접속 차단 가드와 순수 로직 단위 테스트 도입. 프론트엔드 코드는 이 저장소에 없다(별도 저장소).

보기

선택안 · 대안 · 근거 · 비용

다국어를 title_ko/title_en 같은 컬럼 접미사나 JSONB 한 컬럼이 아니라, 엔티티마다 별도 translation 테이블(profile_translations, experience_translations, project_translations, work_translations, education_translations)로 분리하고 (entityId, locale) 유니크 제약을 걸었다. 지원 언어 자체도 portfolio.locales 테이블로 관리해 파이프에서 검증한다.

대안번역 대상 컬럼마다 언어 접미사를 붙이는 방식, 또는 번역 전체를 JSONB 한 컬럼에 넣는 방식.근거언어를 추가할 때 스키마 변경이 필요 없고(행만 추가), 번역 누락을 DB 제약과 조회로 확인할 수 있다. 지원 언어를 코드 상수가 아니라 테이블로 두면 ?locale= 검증이 하드코딩 목록에 묶이지 않는다.비용모든 조회에 include: { translations: { where: { locale } } }가 붙어 쿼리와 매핑 코드가 늘었고(portfolio.service.ts 815줄 중 상당량이 이 매핑), 번역이 없으면 빈 문자열/빈 배열로 떨어질 뿐 기본 언어로의 폴백이 없다. 또 locale 테이블이 검증 경로에 들어오면서 캐시가 빈 결과로 굳으면 포트폴리오 조회 전체가 400이 되는 실패 모드를 새로 만들었다(실제로 발생, 커밋 51a899b에서 수정).

OpenAPI 스펙을 런타임에만 노출하지 않고 openapi.json으로 추출해 저장소에 커밋하고, CI에서 재생성 후 git diff --exit-code로 drift를 막았다. 여기에 더해 '모든 오퍼레이션은 200/201 JSON 스키마를 갖거나 204를 선언한다'는 불변식을 Jest 테스트(src/dto/openapi-coverage.spec.ts)로 고정했다.

대안프론트가 빌드 시점에 실행 중인 서버의 /docs-json을 fetch해 타입을 생성하는 방식, 또는 DTO 타입을 공유 npm 패키지로 배포하는 방식.근거프론트가 별도 저장소라 빌드 타임에 API 서버가 떠 있다고 보장할 수 없다. 파일로 커밋해 두면 서버 없이도 타입 생성이 되고, 스펙 변경이 diff로 리뷰에 드러난다. Swagger 설정을 swagger.config.ts 한 곳에서만 만들어 /docs와 추출본이 갈라지지 않게 했다.비용DTO를 건드릴 때마다 pnpm openapi:generate 후 함께 커밋해야 하고, 잊으면 CI가 빨개진다(의도한 마찰이지만 마찰은 마찰이다). 생성물이 커밋에 섞여 diff 노이즈가 생기고, 스펙 파일을 lint 대상에서 제외(biome.json의 !openapi.json)해야 했다. 또 커버리지 테스트는 '스키마가 선언돼 있는가'만 보장할 뿐 '그 스키마가 실제 응답과 같은가'는 보장하지 않아, 도메인별 DTO 테스트를 따로 둬야 했다.

본문 이미지를 blog/temp/에 먼저 올려 두고 글 저장 시 확정 경로로 옮기는 2단계 플로우에서, 순서를 'DB 저장 → R2 이동 → 이동 결과 DB 반영'으로 두고, 이동이 중간에 실패하면 그때까지 옮겨진 만큼을 DB에 반영한 뒤 500을 던진다. 실패를 삼키고 200을 반환하지 않는다.

대안R2 이동을 먼저 끝내고 확정 URL로 DB를 한 번만 쓰는 방식, 또는 이동 실패를 로그만 남기고 삼켜 200을 반환하는 방식.근거R2 이동은 Copy→Head→Delete라 성공한 파일의 원본 temp가 이미 사라진다. 이동을 먼저 하고 DB 쓰기가 실패하면 파일은 옮겨졌는데 DB는 없어진 temp URL을 가리켜 이미지가 즉시 깨진다. 반대로 실패를 삼키면 temp URL이 남은 채 200이 나가고, 매일 3시 크론이 24시간 지난 temp를 지우므로 저장 당시엔 멀쩡하다가 다음 날 깨진다 — 원인 추적이 가장 어려운 형태다.비용부분 성공 상태를 정상 응답이 아니라 500으로 알리므로 사용자는 '저장은 됐는데 이미지를 다시 저장하라'는 애매한 상태를 마주한다. 트랜잭션으로 묶을 수 없는 외부 스토리지라 완전한 원자성은 포기했고, 태그 정리·revalidation 같은 부수 효과의 실행 순서를 손으로 관리해야 해서 실제로 병합 과정에서 순서가 뒤집혀 회귀가 한 번 났다(커밋 70b00e4).

사용 기술

TypeScriptNestJSFastifyPrismaPostgreSQLPassport-JWT