🌐 언어: English · Tiếng Việt · 中文 · 한국어
데이터베이스 통합, 인증, API 문서를 갖춘 완전한 NestJS 모듈 생성 가이드.
- 보일러플레이트 로컬 설정 (getting-started.md)
- NestJS CLI 사용 가능:
pnpm --filter @mobile-boilerplate/api exec nest
cd /repo/root
pnpm --filter @mobile-boilerplate/api exec nest generate module modules/users출력:
CREATE apps/api/src/modules/users/users.module.ts
apps/api/src/modules/users/dto/ 폴더 생성:
create-user.dto.ts:
import { IsEmail, IsString, MinLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CreateUserDto {
@ApiProperty({ example: 'alice@example.com' })
@IsEmail()
email: string;
@ApiProperty({ example: 'Alice' })
@IsString()
@MinLength(3)
name: string;
}user-response.dto.ts:
import { ApiProperty } from '@nestjs/swagger';
export class UserResponseDto {
@ApiProperty({ example: 1 })
id: number;
@ApiProperty({ example: 'alice@example.com' })
email: string;
@ApiProperty({ example: 'Alice' })
name: string;
}users.service.ts:
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../infra/prisma/prisma.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
@Injectable()
export class UsersService {
constructor(private prisma: PrismaService) {}
async create(dto: CreateUserDto): Promise<UserResponseDto> {
const user = await this.prisma.user.create({
data: dto,
});
return user;
}
async findById(id: number): Promise<UserResponseDto> {
const user = await this.prisma.user.findUnique({
where: { id },
});
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
return user;
}
async findAll(): Promise<UserResponseDto[]> {
return this.prisma.user.findMany();
}
}users.controller.ts:
import { Controller, Get, Post, Body, Param, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { UsersService } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UserResponseDto } from './dto/user-response.dto';
import { Public } from '../../common/auth/decorators/public.decorator';
import { RequireAuth } from '../../common/auth/decorators/require-auth.decorator';
import { ApiStandardResponse } from '../../common/decorators/api-standard-response.decorator';
/**
* 사용자 관리 엔드포인트.
*
* POST /users (공개) — 새 사용자 생성
* GET /users/:id (비공개) — id로 사용자 조회
*/
@ApiTags('users')
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Post()
@Public() // 등록은 공개; 인증 불필요
@ApiOperation({ operationId: 'createUser', summary: 'Create a new user' })
@ApiStandardResponse(UserResponseDto)
async create(@Body() dto: CreateUserDto): Promise<UserResponseDto> {
return this.usersService.create(dto);
}
@Get(':id')
@RequireAuth() // 인증 필요
@ApiOperation({ operationId: 'getUser', summary: 'Get user by ID' })
@ApiStandardResponse(UserResponseDto)
async findById(@Param('id') id: string): Promise<UserResponseDto> {
return this.usersService.findById(parseInt(id, 10));
}
@Get()
@RequireAuth()
@ApiOperation({ operationId: 'listUsers', summary: 'List all users' })
@ApiStandardResponse(UserResponseDto)
async findAll(): Promise<UserResponseDto[]> {
return this.usersService.findAll();
}
}| 데코레이터 | 의미 | 사용처 |
|---|---|---|
@Public() |
라우트는 공개 (인증 불필요) | /login, /register, /health, /hello |
@RequireAuth() |
마커 데코레이터 (메타데이터 설정 + Swagger에 Bearer 인증 추가). 전역 가드가 마커를 읽음; 라우트 레벨에서 가드를 바인딩하지 않습니다. | /users/:id, /profile, admin 엔드포인트 |
| None | 기본: 금지됨 (app.module은 NotImplementedAuthGuard 설정) | 데코레이션 없으면 501 Not Implemented |
하나 선택:
// ✓ 좋음: 공개 등록
@Post('register')
@Public()
async register(@Body() dto: RegisterDto) { }
// ✓ 좋음: 비공개 사용자 조회
@Get(':id')
@RequireAuth()
async getUser(@Param('id') id: string) { }
// ✗ 나쁨: 데코레이터 없음 (기본 가드가 거부함)
@Get('me')
async getCurrentUser() { } // 501 Not Implemented 발생users.module.ts:
import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
@Module({
controllers: [UsersController],
providers: [UsersService],
exports: [UsersService], // 다른 모듈에서 UsersService 임포트 허용
})
export class UsersModule {}app.module.ts: import 배열에 추가:
import { UsersModule } from './modules/users/users.module';
@Module({
imports: [
AppConfigModule,
LoggerModule,
PrismaModule,
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
UsersModule, // ← 여기 추가
HelloModule,
HealthModule,
],
// ... providers 생략
})
export class AppModule {}apps/api/prisma/schema.prisma:
model User {
id Int @id @default(autoincrement())
email String @unique
name String
}마이그레이션 실행:
cd /repo/root
pnpm --filter @mobile-boilerplate/api prisma:migrate dev --name add_usersPrisma 클라이언트 생성:
pnpm --filter @mobile-boilerplate/api prisma:generateusers.service.spec.ts:
import { Test, TestingModule } from '@nestjs/testing';
import { NotFoundException } from '@nestjs/common';
import { UsersService } from './users.service';
import { PrismaService } from '../../infra/prisma/prisma.service';
describe('UsersService', () => {
let service: UsersService;
let prisma: PrismaService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UsersService,
{
provide: PrismaService,
useValue: {
user: {
create: jest.fn(),
findUnique: jest.fn(),
findMany: jest.fn(),
},
},
},
],
}).compile();
service = module.get<UsersService>(UsersService);
prisma = module.get<PrismaService>(PrismaService);
});
describe('create', () => {
it('should create a user', async () => {
const dto = { email: 'test@ex.com', name: 'Test' };
const mockUser = { id: 1, ...dto };
jest.spyOn(prisma.user, 'create').mockResolvedValue(mockUser);
const result = await service.create(dto);
expect(result).toEqual(mockUser);
});
});
describe('findById', () => {
it('should return user if found', async () => {
const mockUser = { id: 1, email: 'test@ex.com', name: 'Test' };
jest.spyOn(prisma.user, 'findUnique').mockResolvedValue(mockUser);
const result = await service.findById(1);
expect(result).toEqual(mockUser);
});
it('should throw NotFoundException if not found', async () => {
jest.spyOn(prisma.user, 'findUnique').mockResolvedValue(null);
await expect(service.findById(999)).rejects.toThrow(
NotFoundException,
);
});
});
});apps/api/test/users.e2e-spec.ts:
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import * as request from 'supertest';
import { AppModule } from '../src/app.module';
describe('Users (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
}),
);
await app.init();
});
afterAll(async () => {
await app.close();
});
describe('POST /users (create)', () => {
it('should create a user', () => {
return request(app.getHttpServer())
.post('/users')
.send({ email: 'newuser@ex.com', name: 'New User' })
.expect(201)
.expect((res) => {
expect(res.body.data).toHaveProperty('id');
expect(res.body.data.email).toBe('newuser@ex.com');
});
});
it('should reject invalid email', () => {
return request(app.getHttpServer())
.post('/users')
.send({ email: 'not-an-email', name: 'Test' })
.expect(400);
});
});
describe('GET /users/:id (fetch)', () => {
it('should require authentication', () => {
return request(app.getHttpServer())
.get('/users/1')
.expect(401); // 또는 NotImplementedAuthGuard가 활성이면 501
});
});
});pnpm codegen:api이제 packages/api_client/에서 Dart 메서드가 생성:
createUser(body: CreateUserDto)→ POST /usersgetUser(id: int)→ GET /users/{id}listUsers()→ GET /users
operationId는 중요:
operationId: 'createUser'→ 메서드 명createUser()- operationId 없으면, 생성 명이 못생김 (예:
postuserscreateUserDtoPostResponse)
백엔드 시작:
pnpm --filter @mobile-boilerplate/api dev엔드포인트 테스트:
# 공개: 사용자 생성 (인증 헤더 불필요)
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"email":"alice@ex.com","name":"Alice"}'
# 응답:
# {
# "data": { "id": 1, "email": "alice@ex.com", "name": "Alice" },
# "meta": { "requestId": "...", "timestamp": "...", "version": "1.0" },
# "error": null
# }
# 비공개: 사용자 조회 (인증 필요, NotImplementedAuthGuard 교체 후)
curl -X GET http://localhost:3000/users/1 \
-H "Authorization: Bearer <token>"Swagger 확인:
http://localhost:3000/api-docs (개발 모드)
실제 인증을 구현할 준비가 되면:
-
JwtAuthGuard구현 (IS_PUBLIC_KEY + REQUIRES_AUTH_KEY 메타데이터 준수):// apps/api/src/common/auth/jwt-auth.guard.ts import { ExecutionContext, Injectable } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { IS_PUBLIC_KEY } from './decorators/public.decorator'; @Injectable() export class JwtAuthGuard extends AuthGuard('jwt') { constructor(private readonly reflector: Reflector) { super(); } canActivate(context: ExecutionContext) { const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ context.getHandler(), context.getClass(), ]); if (isPublic) return true; return super.canActivate(context); // passport JWT } }
-
apps/api/src/app.module.ts에서useClass: NotImplementedAuthGuard→useClass: JwtAuthGuard로 교체. -
기존
@Public()및@RequireAuth()데코레이션은 변경 없이 작동. 데코레이터 없는 라우트는 이제 유효한 JWT 필요 (passport가 401로 거부).
| 이슈 | 해결 |
|---|---|
| "No controller found" | 모듈을 app.module.ts에서 import 확인 |
| "Prisma model not found" | prisma:migrate dev + prisma:generate 실행 |
| "Swagger endpoint missing" | Controller 메서드에서 raw @ApiOkResponse 대신 @ApiStandardResponse(Dto) 검증 |
| "Generated client has ugly method names" | Controller 메서드에 @ApiOperation({ operationId: 'verbNoun' }) 추가 |
| "Validation fails unexpectedly" | ValidationPipe에서 forbidNonWhitelisted: true 확인 — 추가 필드 거부됨 |
| 이슈 | 해결 |
|---|---|
| "No controller found" | 모듈을 app.module.ts에서 import 확인 |
| "Prisma model not found" | prisma:migrate dev + prisma:generate 실행 |
| "Swagger endpoint missing" | Controller 메서드에서 raw @ApiOkResponse 대신 @ApiStandardResponse(Dto) 검증 |
| "Generated client has ugly method names" | Controller 메서드에 @ApiOperation({ operationId: 'verbNoun' }) 추가 |
| "Validation fails unexpectedly" | ValidationPipe에서 forbidNonWhitelisted: true 확인 — 추가 필드 거부됨 |
다음 단계: