2편. API 서버 아키텍처 설계하기 : 프로젝트 구조, ER 설계, Prisma 모델링
📚 목차
1. 공통 개발 환경과 서비스 분리형 모노레포 구조 설계
2. 게시판 DB 설계 : ER 모델링
3. Prisma 스키마 및 인덱스 설계
4. Fastify + Prisma 기반 계층형 모듈 아키텍처 구성
5. 초기화 및 운영 명령어 정리
📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/fastify-api-rest
1. 공통 개발 환경과 서비스 분리형 모노레포 구조 설계
프로젝트를 단일 Node 프로젝트가 아닌, 계층형 구조의 이중 Node 프로젝트로 구성합니다.
nodejs-tutorials/ (공통 설정 레벨)
├─ package.
├─ node_modules/
├─ eslint.config.js
├─ .prettierrc
├─ tsconfig.
└─ fastify-api/ (실제 API 서버)
├─ package.
├─ tsconfig.
├─ vite.config.ts
├─ prisma.config.ts
├─ prisma/
├─ src/
└─ tests/
1. 루트 프로젝트 생성 (공통 개발 환경)
# 루트 프로젝트 생성
cd /nodejs-tutorials
npm init -y
#2. 패키지 설치
npm install -D \
eslint@9.39.2 \
@eslint/js@9.39.2 \
@eslint/ @0.14.0 \
eslint-config-prettier@10.1.8 \
@types/node@25.0.3
#3 설치 목록
PS D:\NodejsDevelope\workspace\nodejs-tutorials> npm list
nodejs-tutorials@1.0.0 D:\NodejsDevelope\workspace\nodejs-tutorials
├── @eslint/js@9.39.2
├── @eslint/ @0.14.0
├── @types/node@25.0.3
├── eslint-config-prettier@10.1.8
├── eslint@9.39.2
2. API 서버 프로젝트 생성 (fastify-api)
루트 하위에 실제 서버 프로젝트를 생성한다.
#1. API 서버 프로젝트 생성
cd /nodejs-tutorials/fastify-api
npm init -y
#2. 패키지 설치
npm install fastify@5.7.1 fastify-plugin@5.1.0
npm install -D prisma@7.2.0
npm install @prisma/client@7.2.0 @prisma/adapter-pg@7.2.0
npm install pg@8.17.1
npm install -D @types/pg@8.16.0
npm install -D vitest@4.0.17 @vitest/ui@4.0.17 @vitest/coverage-v8@4.0.17
#3. 패키지 설치 결과
PS D:\NodejsDevelope\workspace\nodejs-tutorials\fastify-api> npm list
nodejs-tutorials@1.0.0 D:\NodejsDevelope\workspace\nodejs-tutorials
└─┬ fastify-api@1.0.0 -> .\fastify-api
├── @prisma/adapter-pg@7.2.0
├── @prisma/client@7.2.0
├── @types/pg@8.16.0
├── @vitest/coverage-v8@4.0.17
├── @vitest/ui@4.0.17
├── fastify-plugin@5.1.0
├── fastify@5.7.1
├── pg@8.17.1
├── prisma@7.2.0
└── vitest@4.0.17
2. 게시판 DB 설계 : ER 모델링

1. User & Profile: 책임 분리와 확장성 중심 설계
단순히 정보를 저장하는 것을 넘어, 데이터의 성격과 변경 주기에 따라 테이블을 분리했습니다.
1) 1:1 관계 분리 (Separation of Concerns)
▸ User 테이블: 인증(Auth) 및 식별(Identity)의 주체. 보안 및 핵심 로직에 집중.
▸ Profile 테이블: 표현 계층(소개, 아바타 등)의 부가 정보.
▸ 분리 이유(조회 최적화): 인증 과정에서 무거운 프로필 필드(자기소개 등)를 제외하여 메모리 효율 증대.
▸ 유연한 확장: 향후 '다국어 프로필'이나 '프로필 히스토리' 도입 시 기존 User 스키마 변경 없이 대응 가능.
2) 아바타(Avatar) 메타데이터 전략
▸ 이미지 파일을 DB에 직접 저장하지 않고 메타데이터(Key, MimeType, Size, Dimension)만 관리합니다.
▸ 외부 스토리지(S3 등) 교체 시 DB 스키마 변경이 불필요하며, 이미지 리사이징 및 CDN 전략을 독립적으로 운영할 수 있습니다.
2. 운영의 핵심: Soft Delete 전략
▸ 모든 주요 엔티티에 deletedAt 컬럼을 추가하고 Soft Delete를 기본으로 채택했습니다.
▸ 데이터 복구: 운영 중 발생하는 실수나 고객 문의(CS)에 즉각적인 복구 대응 가능.
▸ 참조 무결성: 물리적 삭제로 인해 관계된 게시글이나 댓글 데이터가 깨지는 현상 방지.
▸ 인덱스 최적화: @@index([deletedAt])를 설정하여 삭제되지 않은 유효 데이터 조회 성능을 확보했습니다.
3. Post / Reply / Like: 성능과 정합성의 조화
많은 트래픽이 몰리는 게시판 도메인에서는 읽기 성능 향상을 위한 전략적 설계를 적용했습니다.
▸ 명시적 M:N 관계 (PostLike)
User와 Post 사이의 좋아요 관계를 별도 테이블로 선언하여 생성 시간(createdAt) 관리 및 **복합 PK(userId, postId)**를 통한 중복 방지를 달성했습니다.
▸ 카운터 캐시(Counter Cache) 컬럼
viewCount, likeCount, replyCount를 Post 테이블에 직접 유지합니다.
의도적 중복: 목록 조회 시 매번 COUNT 쿼리를 날리는 비용을 절감하기 위해 쓰기 시점에 비용을 지불하고 읽기 성능을 극대화했습니다.
▸ 쿼리 기반 인덱스 설계
단순 PK 조회를 넘어, 실제 서비스 UI/UX에서 자주 사용될 패턴을 인덱스에 반영했습니다.
최신순/공개여부: published + createdAt
인기순: published + viewCount + createdAt
4. 통계 설계: PostViewStat 버킷 패턴
조회수 통계는 단순 누적이 아닌 시계열 분석이 가능하도록 설계되었습니다.
▸ 버킷(Bucket) 기반 저장
시간(Hourly), 일(Daily), 월(Monthly) 단위로 데이터를 쪼개어 저장합니다.
장점: 특정 시간대 트래픽 분석, 일간 인기글 추출 등 복잡한 통계 요구사항을 쿼리 한 번으로 해결 가능합니다.
▸ 이원화 전략 (Real-time vs. History)
| 구분 | Post.viewCount | PostViewStat |
| 목적 | 실시간 노출용 (Fast Read) | 분석 및 통계용 (Deep Analysis) |
| 저장 방식 | 하나의 컬럼에 누적 | 시간 버킷 단위 레코드 |
| 데이터 성격 | 현재 시점의 최종 합계 | 시간대별 변화 이력 |
| 조회 비용 | 매우 낮음 | 상대적으로 높음 |
| 정합성 | 근사치 허용 | 분석 기준의 정확성 중시 |
| 대표 예시 | “조회수 1,234” | “어제 대비 +12%, 시간대별 트래픽” |
3. Prisma 스키마 및 인덱스 설계
// =========================
// User
// =========================
// 서비스 사용자 기본 계정 테이블
// Soft delete 패턴 사용 (deleted_at)
// 로그인/식별 최소 필드만 포함, 확장 정보는 Profile로 분리
model User {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
// 로그인 및 식별용 유니크 필드
email String @unique
phoneNumber String @unique @map("phone_number") // E.164 포맷 권장 (+821012345678)
displayName String? @map("display_name")
// 인증용-초기 암호 없음
passwordHash String? @map("password_hash")
// Relations
profile Profile?
posts Post[]
replies Reply[]
likes PostLike[]
// Soft delete 조회 최적화
@@index([deletedAt])
@@map("users")
}
// =========================
// Profile (User 확장 정보 + Avatar 메타데이터)
// =========================
// User와 1:1 관계
// 파일 자체는 Object Storage(S3 등)에 있고 DB에는 메타데이터만 저장
model Profile {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
bio String?
// Avatar 파일 메타데이터
avatarKey String? @map("avatar_key") // Object Storage key
avatarFileName String? @map("avatar_file_name") // 원본 파일명
avatarMimeType String? @map("avatar_mime_type")
avatarFileSize Int? @map("avatar_file_size")
avatarWidth Int?
avatarHeight Int?
// User 1:1 관계 (User 먼저 삭제 시 Profile 자동 삭제)
userId Int @unique @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([deletedAt])
@@map("profiles")
}
// =========================
// Post
// =========================
// 게시글 본문 테이블
// 트래픽이 높은 컬럼은 cache column 형태로 유지 (view/like/reply count)
model Post {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
title String
content String?
published Boolean @default(false)
// 집계 캐시 컬럼 (실시간 리스트/정렬 성능용)
viewCount Int @default(0) @map("view_count")
likeCount Int @default(0) @map("like_count")
replyCount Int @default(0) @map("reply_count")
// 작성자
authorId Int @map("author_id")
author User @relation(fields: [authorId], references: [id], onDelete: Restrict)
// Relations
replies Reply[]
likes PostLike[]
files PostFile[]
viewStats PostViewStat[]
// createdAt + id 복합 unique → cursor pagination 안정성 확보
@@unique([createdAt, id])
// 주요 조회 패턴 대응 인덱스
@@index([authorId, createdAt]) // 사용자별 게시글
@@index([published, createdAt]) // 공개 게시글 최신순
@@index([published, viewCount, createdAt]) // 인기글 정렬
@@index([deletedAt])
@@map("posts")
}
// =========================
// Reply (Post ↔ Reply)
// =========================
// 게시글 댓글 테이블
// Soft delete 지원, 작성자 제한 삭제 정책
model Reply {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
content String
// 게시글 FK
postId Int @map("post_id")
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
// 작성자 FK
authorId Int @map("author_id")
author User @relation(fields: [authorId], references: [id], onDelete: Restrict)
// 게시글별 최신 댓글 조회 최적화
@@index([postId, createdAt])
// 삭제 포함/제외 조회 패턴 대응
@@index([postId, deletedAt, createdAt])
@@index([deletedAt])
@@map("replies")
}
// =========================
// PostLike (Explicit M:N)
// =========================
// 좋아요 M:N 관계를 명시적 테이블로 관리
// soft delete 가능 → 좋아요 취소 이력 추적 가능
model PostLike {
userId Int @map("user_id")
postId Int @map("post_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
// 한 사용자는 한 게시글에 한 번만 좋아요
@@id([userId, postId])
@@index([postId, createdAt]) // 게시글 좋아요 목록/카운트
@@index([deletedAt])
@@map("post_likes")
}
// =========================
// PostFile (첨부파일 메타데이터)
// =========================
// 실제 파일은 외부 스토리지, DB에는 메타데이터만 저장
// sortOrder로 사용자 업로드 순서 보장
model PostFile {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
fileKey String @map("file_key") // Object Storage key
fileName String @map("file_name")
contentType String @map("content_type")
fileSize Int @map("file_size")
downloadCount Int @default(0) @map("download_count")
// 게시글 내 표시 순서
sortOrder Int @default(0) @map("sort_order")
postId Int @map("post_id")
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
// 동일 게시글 내 순서 중복 방지
@@unique([postId, sortOrder])
// 정렬 조회 최적화
@@index([postId, sortOrder])
@@index([deletedAt])
@@map("post_files")
}
// =========================
// PostViewStat (Bucket-based View History)
// =========================
// 시간 단위 집계 통계 테이블 (hour/day/month bucket)
// 대용량 누적 대비용 → Post.viewCount와 별도 관리
model PostViewStat {
id Int @id @default(autoincrement())
postId Int @map("post_id")
// DB ENUM 미사용 → TEXT + 서비스 레벨 enum 매핑 권장
// 확장 시 migration 부담 최소화
bucketType String @map("bucket_type") // 'HOURLY' | 'DAILY' | 'MONTHLY'
// bucket 기준 시각 (UTC 정규화 필수)
bucketAt DateTime @map("bucket_at")
// 해당 bucket 내 조회수 누적값
viewCount Int @map("view_count")
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
// bucket 단위 중복 방지 + UPSERT 키
@@unique([postId, bucketType, bucketAt])
// 기간별 통계 조회 최적화
@@index([bucketType, bucketAt])
@@map("post_view_stats")
}
4. Fastify + Prisma 기반 계층형 모듈 아키텍처 구성 (예)
.
├─ prisma/
│ ├─ schema.prisma # Prisma 스키마 정의
│ └─ migrations/ # 마이그레이션 히스토리
│
├─ src/
│ ├─ app.ts # Fastify 인스턴스 구성
│ ├─ server.ts # 서버 실행 엔트리포인트
│ │
│ ├─ config/ # 환경 및 앱 설정
│ │ └─ env.ts
│ │
│ ├─ plugins/ # Fastify 플러그인
│ │
│ ├─ common/ # 전역 공통 모듈
│ │ ├─ errors/
│ │ ├─ utils/
│ │ └─ constants/
│ │
│ ├─ modules/ # 도메인별 모듈
│ │ ├─ user/
│ │ │ ├─ user.route.ts
│ │ │ ├─ user.controller.ts
│ │ │ ├─ user.service.ts
│ │ │ ├─ user.repository.ts
│ │ │ └─ user.schema.ts
│ │ │
│ │ ├─ profile/
│ │ │ ├─ profile.route.ts
│ │ │ ├─ profile.service.ts
│ │ │ └─ profile.repository.ts
│ │ │
│ │ ├─ post/
│ │ │ ├─ post.route.ts
│ │ │ ├─ post.controller.ts
│ │ │ ├─ post.service.ts
│ │ │ ├─ post.repository.ts
│ │ │ ├─ post.schema.ts
│ │ │ ├─ post.types.ts
│ │ │ └─ post.view-stat.service.ts
│ │ │
│ │ ├─ reply/
│ │ │ ├─ reply.route.ts
│ │ │ ├─ reply.service.ts
│ │ │ └─ reply.repository.ts
│ │ │
│ │ ├─ like/
│ │ │ ├─ post-like.service.ts
│ │ │ └─ post-like.repository.ts
│ │ │
│ │ ├─ file/
│ │ │ ├─ post-file.service.ts
│ │ │ └─ post-file.repository.ts
│ │ │
│ │ └─ health/
│ │ └─ health.route.ts
│ │
│ └─ routes.ts # 모든 모듈 라우트 등록
│
├─ tests/
│ ├─ unit/ # 단위 테스트
│ │ ├─ services/
│ │ │ ├─ post.service.spec.ts
│ │ │ └─ user.service.spec.ts
│ │ │
│ │ └─ utils/
│ │ └─ cursor.util.spec.ts
│ │
│ ├─ integration/ # 통합 테스트
│ ├─ e2e/ # E2E 테스트
│ ├─ fixtures/ # 테스트 픽스처
│ ├─ helpers/ # 테스트 헬퍼
│ └─ setup.ts # 테스트 전역 설정
│
├─ .env # 로컬 환경 변수
├─ .env.test # 테스트 환경 변수
├─ package.
├─ tsconfig.
└─ vitest.config.ts
5. 초기화 및 운영 명령어 정리
# Node 프로젝트 초기화
npm init -y
# TypeScript + tsx 실행 환경 초기화
# tsconfig 생성
npx tsc --init
# Prisma 초기화
npx prisma init
# 최초 마이그레이션
npx prisma migrate dev --name init
# Prisma Client 생성
npx prisma generate
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > 실무익히기' 카테고리의 다른 글
| [REST API] 1편. Fastify 구조 및 요청 생명주기 이해하기 (0) | 2026.03.06 |
|---|