🌐 언어: English · Tiếng Việt · 中文 · 한국어
Prisma로 데이터베이스 스키마 확장 및 서비스 계층 연결 가이드.
- 로컬 설정 완료
.env에DATABASE_URL및DIRECT_URL설정 (또는 스키마만 작업용SKIP_DB=true)
apps/api/prisma/schema.prisma 편집:
model Post {
id Int @id @default(autoincrement())
title String
body String
published Boolean @default(false)
authorId Int // User 모델 존재 시 FK
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 선택사항: User로의 relation
model User {
id Int @id @default(autoincrement())
email String @unique
name String
posts Post[] // 역 relation
}cd /repo/root
pnpm --filter @mobile-boilerplate/api prisma:migrate dev --name add_posts이것은:
apps/api/prisma/migrations/<timestamp>-add_posts/migration.sql생성- dev 데이터베이스에 SQL 실행
node_modules/.prisma/client재생성
출력:
✔ Your database has been successfully migrated to <timestamp>-add_posts
✔ Generated Prisma Client to ./node_modules/@prisma/client in XXms
pnpm --filter @mobile-boilerplate/api prisma:generate이제 서비스는 this.prisma.post.create(...), this.prisma.post.findUnique(...) 등 사용 가능 — 타입 안전.
apps/api/src/modules/posts/posts.service.ts:
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infra/prisma/prisma.service';
import { CreatePostDto } from './dto/create-post.dto';
import { PostResponseDto } from './dto/post-response.dto';
@Injectable()
export class PostsService {
constructor(private prisma: PrismaService) {}
async create(dto: CreatePostDto, authorId: number): Promise<PostResponseDto> {
const post = await this.prisma.post.create({
data: {
title: dto.title,
body: dto.body,
published: dto.published || false,
authorId,
},
});
return post;
}
async findById(id: number): Promise<PostResponseDto> {
const post = await this.prisma.post.findUnique({
where: { id },
});
if (!post) {
throw new NotFoundException(`Post ${id} not found`);
}
return post;
}
async findAll(published?: boolean): Promise<PostResponseDto[]> {
return this.prisma.post.findMany({
where: published !== undefined ? { published } : undefined,
orderBy: { createdAt: 'desc' },
});
}
async update(id: number, dto: CreatePostDto): Promise<PostResponseDto> {
const post = await this.prisma.post.update({
where: { id },
data: {
title: dto.title,
body: dto.body,
published: dto.published,
},
});
if (!post) {
throw new NotFoundException(`Post ${id} not found`);
}
return post;
}
async delete(id: number): Promise<void> {
await this.prisma.post.delete({
where: { id },
});
}
}apps/api/src/modules/posts/dto/create-post.dto.ts:
import { IsString, MinLength, IsOptional, IsBoolean } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreatePostDto {
@ApiProperty({ example: 'My First Post' })
@IsString()
@MinLength(3)
title: string;
@ApiProperty({ example: 'This is the post body...' })
@IsString()
@MinLength(5)
body: string;
@ApiProperty({ example: true, required: false })
@IsOptional()
@IsBoolean()
published?: boolean;
}apps/api/src/modules/posts/dto/post-response.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
export class PostResponseDto {
@ApiProperty()
id: number;
@ApiProperty()
title: string;
@ApiProperty()
body: string;
@ApiProperty()
published: boolean;
@ApiProperty()
authorId: number;
@ApiProperty()
createdAt: Date;
@ApiProperty()
updatedAt: Date;
}apps/api/src/modules/posts/posts.controller.ts:
import { Controller, Get, Post, Body, Param, Put, Delete } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { PostsService } from './posts.service';
import { CreatePostDto } from './dto/create-post.dto';
import { PostResponseDto } from './dto/post-response.dto';
import { RequireAuth } from '../../common/auth/decorators/require-auth.decorator';
import { Public } from '../../common/auth/decorators/public.decorator';
import { ApiStandardResponse } from '../../common/decorators/api-standard-response.decorator';
@ApiTags('posts')
@Controller('posts')
export class PostsController {
constructor(private readonly postsService: PostsService) {}
@Post()
@RequireAuth()
@ApiOperation({ operationId: 'createPost' })
@ApiStandardResponse(PostResponseDto)
async create(@Body() dto: CreatePostDto): Promise<PostResponseDto> {
// 실제 앱은 JWT에서 userId 추출
return this.postsService.create(dto, 1);
}
@Get()
@Public()
@ApiOperation({ operationId: 'listPosts' })
@ApiStandardResponse(PostResponseDto)
async findAll(): Promise<PostResponseDto[]> {
return this.postsService.findAll(true); // 공개 posts만
}
@Get(':id')
@Public()
@ApiOperation({ operationId: 'getPost' })
@ApiStandardResponse(PostResponseDto)
async findById(@Param('id') id: string): Promise<PostResponseDto> {
return this.postsService.findById(parseInt(id, 10));
}
@Put(':id')
@RequireAuth()
@ApiOperation({ operationId: 'updatePost' })
@ApiStandardResponse(PostResponseDto)
async update(
@Param('id') id: string,
@Body() dto: CreatePostDto,
): Promise<PostResponseDto> {
return this.postsService.update(parseInt(id, 10), dto);
}
@Delete(':id')
@RequireAuth()
@ApiOperation({ operationId: 'deletePost' })
async delete(@Param('id') id: string): Promise<void> {
return this.postsService.delete(parseInt(id, 10));
}
}apps/api/src/modules/posts/posts.module.ts:
import { Module } from '@nestjs/common';
import { PostsController } from './posts.controller';
import { PostsService } from './posts.service';
@Module({
controllers: [PostsController],
providers: [PostsService],
exports: [PostsService],
})
export class PostsModule {}apps/api/src/app.module.ts:
import { PostsModule } from './modules/posts/posts.module';
@Module({
imports: [
AppConfigModule,
LoggerModule,
PrismaModule,
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
HelloModule,
HealthModule,
PostsModule, // ← 여기 추가
],
// ...
})
export class AppModule {}apps/api/test/posts.e2e-spec.ts:
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Posts (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('POST /posts', () => {
it('should create a post', () => {
return request(app.getHttpServer())
.post('/posts')
.send({ title: 'Test Post', body: 'Body content' })
.expect(201)
.expect((res) => {
expect(res.body.data.title).toBe('Test Post');
});
});
});
describe('GET /posts', () => {
it('should list posts', () => {
return request(app.getHttpServer())
.get('/posts')
.expect(200)
.expect((res) => {
expect(Array.isArray(res.body.data)).toBe(true);
});
});
});
});중요: DB에 영향주는 E2E 테스트는 실제 DB 필요.
옵션:
-
로컬 PostgreSQL (docker-compose):
docker-compose up -d postgres pnpm --filter @mobile-boilerplate/api test:e2e
-
Supabase staging 프로젝트:
- Supabase에서 staging 데이터베이스 생성
.env에DATABASE_URL+DIRECT_URL설정- 마이그레이션 실행:
pnpm prisma:migrate deploy - 테스트 실행:
pnpm test:e2e
-
Test container (고급):
- testcontainers 라이브러리로 테스트당 Postgres 스핀
pnpm codegen:api생성된 Dart 클라이언트는:
createPost(body: CreatePostDto)listPosts()getPost(id: int)updatePost(id: int, body: CreatePostDto)deletePost(id: int)
flutter-feature.md를 보고 이 엔드포인트를 호출하는 Flutter 기능 생성.
필요하면 마이그레이션 실행취소:
# 이전 상태로 되돌리기
pnpm --filter @mobile-boilerplate/api prisma:migrate resolve --rolled-back <migration-name>
# 그 후 schema.prisma 편집 후 새 마이그레이션 생성
pnpm prisma:migrate dev --name fix_<issue>참고: Production의 롤백은 데이터 백업 + 신중한 계획 필요.
- 서비스 코드 작성 전에 마이그레이션 항상 생성 — 스키마가 진실의 근원
- 마이그레이션을 로컬에서 먼저 테스트 — CI 푸시 전
- Relations 신중히 사용 — 편하지만 쿼리 추가; 필요시 비정규화
- DTO 계층에서 검증 — ValidationPipe가 서비스 전 나쁜 입력 잡기
- 빈번한 쿼리에 인덱스 사용 —
@db.Index([field])를 스키마에
다음 단계:
- 새로운 백엔드 모듈 추가 — 인증/엔벨로프와 함께 완전 레시피
- API 계약 워크플로우 — DTO 변경 시