NestJS Review Notes from a Todo API

프로젝트 폴더 구조를 기능 단위로 나누기

예시 구조

bash
src/
  main.ts
  app.module.ts
  modules/
    app/
      app.module.ts
      app.controller.ts
      app.service.ts
    health/
      health.module.ts
      health.controller.ts
    todo/
      todo.module.ts
      todo.controller.ts
      todo.service.ts
      dto/
        create-todo.dto.ts
        update-todo.dto.ts
      entities/
        todo.entity.ts

체크 포인트

  • modules/<기능명>: 기능별로 묶는 영역
  • controller: HTTP 요청 라우팅/입력 추출 담당
  • service(provider): 비즈니스 로직 담당
  • module: controller/provider를 Nest 컨테이너에 등록하는 조립 단위
  • dto: 요청/응답 데이터 형태 정의
  • entities: 도메인 모델 정의

2) Controller는 무엇을 하는가

공식 문서 핵심

  • Controller는 들어오는 요청을 처리하고 응답을 반환하는 계층이다.
  • @Controller('prefix')로 라우트 프리픽스를 정의한다.
  • @Get, @Post, @Patch, @Delete 같은 메서드 데코레이터로 HTTP 메서드를 매핑한다.
  • @Param(), @Body(), @Query()로 요청 데이터를 꺼낸다.

예시 코드

tsx
@ApiTags('todos')
@Controller('todos')
export class TodoController {
  constructor(private readonly todoService: TodoService) {}

  @Get()
  findAll(): TodoEntity[] {
    return this.todoService.findAll();
  }

  @Get(':id')
  findOne(@Param('id', new ParseUUIDPipe()) id: string): TodoEntity {
    return this.todoService.findOne(id);
  }
}

확인해야 할 포인트

  • @Get(':id')를 쓰면 @Param('id')로 값을 받는지 확인
  • 경로 충돌 방지: 정적 라우트와 파라미터 라우트 선언 순서 확인
  • 기본 상태코드: POST 기본 201, 그 외 일반적으로 200
  • 필요 시 @HttpCode(...)로 상태코드 명시

3) Service(@Injectable)는 무엇을 하는가

공식 문서 핵심

  • Provider는 Nest DI 컨테이너가 관리하는 객체다.
  • @Injectable()은 해당 클래스를 provider로 사용 가능하게 표시한다.
  • 생성자 주입(constructor injection)으로 다른 provider를 주입받는다.

예시 코드

tsx
@Injectable()
export class TodoService {
  private readonly todos: TodoEntity[] = [];

  create(title: string, description?: string): TodoEntity {
    const todo: TodoEntity = {
      id: randomUUID(),
      title,
      description,
      status: TodoStatus.TODO,
      createdAt: new Date(),
      updatedAt: new Date(),
    };

    this.todos.push(todo);
    return todo;
  }
}

Provider 스코프(공식 문서 기준)

  • 기본(DEFAULT)은 싱글톤: 앱 생명주기 동안 인스턴스 1개 공유
  • REQUEST: 요청마다 새 인스턴스
  • TRANSIENT: 주입받는 곳마다 새 인스턴스

확인해야 할 포인트

  • service는 라우팅 데코레이터(@Get 등)를 쓰지 않는다.
  • service는 데이터 변경 책임을 가진다(push, update, splice 등).
  • 인메모리 배열은 프로세스 재시작 시 초기화된다.

4) Module은 무엇을 하는가

공식 문서 핵심

  • @Module() 메타데이터 주요 키:
    • controllers: 이 모듈의 컨트롤러
    • providers: 이 모듈의 프로바이더
    • imports: 다른 모듈에서 export한 provider를 가져옴
    • exports: 현재 모듈 provider를 외부 모듈에 공개
  • 모듈은 provider를 기본적으로 캡슐화한다.

예시 코드

tsx
// todo.module.ts
@Module({
  controllers: [TodoController],
  providers: [TodoService],
})
export class TodoFeatureModule {}
tsx
// app.module.ts
@Module({
  imports: [AppFeatureModule, HealthModule, TodoFeatureModule],
})
export class AppModule {}

확인해야 할 포인트

  • 기능 모듈을 만들었으면 AppModule.imports에 연결했는지 확인
  • 연결 누락 시 라우트가 앱에 노출되지 않음

5) API를 만들고 앱에 연결하는 순서 (Todo 기준)

Step 1) Entity 정의

  • TodoEntity에 필드 정의 (id, title, status, createdAt 등)

Step 2) DTO 정의

  • CreateTodoDto: 생성 입력
  • UpdateTodoDto: 수정 입력(부분 갱신)

Step 3) Service 구현

  • create, findAll, findOne, update, remove 구현

Step 4) Controller 라우트 구현

  • GET /todos
  • GET /todos/:id
  • POST /todos
  • PATCH /todos/:id
  • DELETE /todos/:id

Step 5) Module 등록 + AppModule imports 연결

  • TodoFeatureModule 등록 후 AppModule.imports에 추가

6) Swagger 문서와 UI 붙이기

공식 문서 핵심

  • main.ts에서 DocumentBuilder로 문서 메타정보 생성
  • SwaggerModule.createDocument(...)로 OpenAPI 문서 생성
  • SwaggerModule.setup(path, app, document)으로 Swagger UI 경로 마운트

예시 코드

tsx
const swaggerConfig = new DocumentBuilder()
  .setTitle('Todo API')
  .setDescription('Todo API 문서')
  .setVersion('1.0.0')
  .addTag('health')
  .addTag('todos')
  .build();

const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('docs', app, swaggerDocument, {
  customSiteTitle: 'Todo API Swagger',
});

7) 어노테이션(데코레이터) 해석

예시 코드

tsx
@ApiOperation({ summary: '투두 수정' })
@ApiParam({ name: 'id', description: 'Todo UUID' })
@ApiOkResponse({ description: '투두 수정 완료', type: TodoEntity })
@Patch(':id')
update(
  @Param('id', new ParseUUIDPipe()) id: string,
  @Body() dto: UpdateTodoDto,
): TodoEntity {
  return this.todoService.update(id, dto);
}

각 항목 의미

  • @Patch(':id'): HTTP PATCH를 /todos/:id에 매핑
  • @Param('id', new ParseUUIDPipe()): path param id 추출 + UUID 형식 검증
  • @Body(): 요청 본문(JSON)을 DTO로 바인딩
  • @ApiOperation({ summary: ... }): Swagger 문서 operation summary 설정
  • @ApiParam(...): Swagger 문서 path parameter 메타데이터 추가
  • @ApiOkResponse(...): 200 응답 스키마/설명 문서화

같이 자주 쓰는 응답 데코레이터

  • @ApiCreatedResponse(...): 201 생성 응답 문서화
  • @ApiNoContentResponse(...): 204 무본문 응답 문서화

검증 결과

  • 실습 앱 /docs-json에서 PATCH /todos/{id}의 summary/parameters/200 응답 반영 확인
  • DELETE /todos/{id}의 204 응답 반영 확인

8) DTO와 Entity를 왜 분리하는가

DTO가 의미하는 것

  • DTO(Data Transfer Object)는 "요청/응답으로 주고받는 데이터 형식"을 명시하는 클래스다.
  • 실습 코드 기준 DTO 파일:
    • src/modules/todo/dto/create-todo.dto.ts
    • src/modules/todo/dto/update-todo.dto.ts
  • Controller에서 @Body()로 DTO를 받을 때, "이 API가 어떤 입력을 받는지"가 코드로 고정된다.

DTO를 어디에 쓰는가

  • POST /todos의 본문 형식 정의: CreateTodoDto
  • PATCH /todos/:id의 본문 형식 정의: UpdateTodoDto
  • Swagger 문서에서 요청 스키마를 생성할 때 사용됨 (requestBody 스키마로 노출)

Entity가 의미하는 것

  • Entity는 "서비스 내부에서 다루는 도메인 데이터 구조"다.
  • 실습 코드 기준 Entity 파일:
    • src/modules/todo/entities/todo.entity.ts
  • Todo의 실제 상태를 담는 필드(id, title, status, createdAt, updatedAt)를 가진다.

Entity를 어디에 쓰는가

  • Service 내부 저장소(todos 배열)의 타입
  • Service 메서드 반환 타입 (findAll, findOne, create, update)
  • Swagger 응답 스키마 타입 (@ApiOkResponse({ type: TodoEntity }))

DTO와 Entity를 분리하는 이유

  • 입력 계약과 내부 모델의 책임을 분리할 수 있다.
  • 예를 들어 UpdateTodoDto는 모든 필드를 optional로 둘 수 있지만, TodoEntity는 서비스 내부에서 항상 완성된 상태를 유지할 수 있다.
  • 요청 스펙 변경(예: 입력 필드 추가)과 내부 데이터 구조 변경(예: 상태 필드 확장)을 독립적으로 관리하기 쉽다.

DTO와 Entity에 넣을 것/넣지 말 것

  • DTO에 넣을 것:
    • 클라이언트가 보낼 수 있는 입력 필드
    • 요청 스키마 설명(@ApiProperty, @ApiPropertyOptional)
  • DTO에 넣지 말 것:
    • DB 내부 메타데이터(createdAt 자동 생성 로직 등)
    • 비즈니스 로직
  • Entity에 넣을 것:
    • 서비스/도메인에서 유지해야 하는 실제 데이터 구조
    • 내부 상태 필드와 타입(예: TodoStatus)
  • Entity에 넣지 말 것:
    • HTTP 라우팅 정보
    • 요청 추출 로직(@Body, @Param)

Todo API에서의 데이터 흐름

  1. 클라이언트가 JSON 본문을 보낸다.
  2. Controller가 @Body()로 DTO를 받는다.
  3. Service가 DTO 데이터를 사용해 Entity를 생성/수정한다.
  4. Service가 Entity를 반환한다.
  5. Controller는 반환된 Entity를 응답으로 전달한다.

참고 링크