REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성

📚 교육용 풀스택 SaaS 개발기 시리즈 (11편)

DDD 3계층 위에 REST API Controllers를 올리는 과정. Use Case 단위로 6개 컨트롤러를 분리하고, ApplicationModule로 조립하고, 27개 단위 테스트로 검증한 실전기록.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • DDD 3계층(Repository → Domain → Application)이 완성되었지만, 외부에서 호출할 수 없었다. HTTP 진입점인 Controller가 없었다
  • Use Case 단위로 Controller를 분리했다. 유저 온보딩(UC-0105), 태스크 관리(UC-0610) 등 6개 Controller에 21개 엔드포인트
  • Controller는 얇게, Application Service에 위임하는 것이 핵심이다. Controller에 비즈니스 로직을 넣으면 테스트가 어려워진다
  • ApplicationModule 하나에 Controller + Service + 의존성을 모두 등록했다. NestJS의 DI 컨테이너가 나머지를 처리한다
  • Controller 단위 테스트 27개 추가로 전체 358개 테스트 달성. Mock 기반이라 3초 내에 전부 통과한다

🤔 발단 — Application Service는 있는데 호출할 수가 없다

이전 편까지 E2E 테스트를 Cloud SQL 위에서 돌리는 데 성공했다. 그런데 한 발 뒤로 물러서서 보니, 이 프로젝트에는 근본적인 빈 곳이 있었다.

Application Service 6개가 Use Case별로 깔끔하게 정리되어 있었다. 유저 등록, 배치고사, 등급 배치, 태스크 관리, 활동 기록 체크, 배치 프로세스. 하지만 이걸 HTTP로 호출할 방법이 없었다.

src/application/
├── services/
│   ├── student-onboarding.application.service.ts   ← 있음
│   ├── assignment.application.service.ts           ← 있음
│   ├── level-adjustment.application.service.ts     ← 있음
│   ├── attendance.application.service.ts           ← 있음
│   ├── batch-process.application.service.ts        ← 있음
│   └── free-learning.application.service.ts        ← 있음
├── controllers/                                     ← 없음 ❌
└── dtos/

테스트에서는 Service를 직접 주입받아서 호출했지만, 실제 클라이언트(프론트엔드, 모바일 앱)는 HTTP 엔드포인트가 필요하다. REST API Controller가 없으면 서비스가 아무리 잘 만들어져 있어도 쓸 수가 없다.

📌 핵심: DDD에서 Controller는 “인프라 계층”에 속한다. 비즈니스 로직은 모르고, HTTP 요청을 받아서 Application Service에 위임하는 게 전부다. 이 경계를 지키는 게 중요하다.


🏗️ 설계 — Use Case 기반 Controller 분리

Controller를 어떻게 나눌지 고민했다. 선택지는 두 가지였다.

방식예시장점단점
엔티티 기반UsersController, TasksControllerREST 관례에 맞음Use Case와 1:1 매핑 안 됨
Use Case 기반StudentOnboardingController, AssignmentControllerApplication Service와 1:1엔드포인트 경로가 다소 복잡

Use Case 기반을 선택했다. 이유는 단순하다. Application Service가 이미 Use Case 단위로 분리되어 있으니까. Controller도 같은 단위로 만들면 한 Controller가 한 Service만 의존하는 깔끔한 구조가 나온다.

StudentOnboardingController → StudentOnboardingApplicationService
AssignmentController        → AssignmentApplicationService
LevelAdjustmentController   → LevelAdjustmentApplicationService
AttendanceController        → AttendanceApplicationService
BatchProcessController      → BatchProcessApplicationService
FreeLearningController      → FreeLearningApplicationService

📌 핵심: Controller와 Application Service를 1:1로 매핑하면, Controller가 두 개 이상의 Service에 의존하는 경우가 사라진다. 의존성이 단순해지면 테스트도 단순해진다.


🛠️ 구현 — 6개 Controller, 21개 엔드포인트

온보딩 Controller — UC-01~05

유저 등록부터 배치고사, 등급 배치, 운영자 승인, 초기 태스크 생성까지. 온보딩 플로우 전체를 하나의 Controller가 담당한다.

@Controller('students')
export class StudentOnboardingController {
  constructor(
    private readonly studentOnboardingService: StudentOnboardingApplicationService,
  ) {}

  // UC-01: 유저 등록
  @Post()
  @HttpCode(HttpStatus.CREATED)
  async registerStudent(
    @Body() dto: RegisterStudentDto,
  ): Promise<RegisterStudentResultDto> {
    return this.studentOnboardingService.registerStudent(dto);
  }

  // UC-02: 배치고사 응시
  @Post(':studentId/diagnostic-assessment')
  @HttpCode(HttpStatus.OK)
  async takeDiagnosticAssessment(
    @Param('studentId') studentId: string,
    @Body() dto: Omit<TakeDiagnosticAssessmentDto, 'studentId'>,
  ): Promise<DiagnosticAssessmentResultDto> {
    return this.studentOnboardingService.takeDiagnosticAssessment({
      ...dto,
      studentId,
    });
  }

  // UC-04: 대기중인 승인 목록 조회
  @Get('pending-approvals')
  @HttpCode(HttpStatus.OK)
  async getPendingApprovals(
    @Query('classId') classId?: number,
  ): Promise<PendingApprovalDto[]> {
    return this.studentOnboardingService.getPendingApprovals(
      classId ? Number(classId) : undefined,
    );
  }
  ...
}

Controller의 역할이 뭔지 잘 보인다. HTTP 데코레이터로 라우팅을 정의하고, 파라미터를 조합해서 Service에 넘기는 게 전부. 비즈니스 로직은 한 줄도 없다.

⚠️ 주의: @Param('studentId')로 받은 Path Parameter를 Body DTO와 합칠 때 Omit<DTO, 'studentId'> 패턴을 사용했다. 클라이언트가 Body에 studentId를 중복으로 보내는 실수를 타입 레벨에서 막아준다.

태스크 Controller — UC-06~10

태스크 관리는 엔드포인트가 가장 많았다. 롤링 발행, 블록 생성, 시작, 제출, 평가, 재도전까지 8개.

@Controller()
export class AssignmentController {
  constructor(
    private readonly assignmentService: AssignmentApplicationService,
  ) {}

  // UC-06: 롤링 태스크 생성
  @Post('assignments/rolling')
  @HttpCode(HttpStatus.CREATED)
  async generateRollingAssignment(
    @Body() dto: GenerateRollingAssignmentDto,
  ): Promise<AssignmentCreatedResultDto> {
    return this.assignmentService.generateRollingAssignment(dto);
  }

  // UC-07: 다음 블록 생성
  @Post('assignments/:assignmentId/blocks')
  @HttpCode(HttpStatus.CREATED)
  async generateNextBlock(
    @Param('assignmentId', ParseIntPipe) assignmentId: number,
    @Body() dto: Omit<GenerateNextBlockDto, 'assignmentId'>,
  ): Promise<BlockGenerationResultDto> {
    return this.assignmentService.generateNextBlock({
      ...dto,
      assignmentId,
    });
  }
  ...
}

여기서 ParseIntPipe가 등장한다. Path Parameter는 기본적으로 string인데, assignmentIdnumber여야 한다. NestJS의 내장 Pipe가 자동으로 변환 + 검증을 해준다. 숫자가 아닌 값이 들어오면 400 Bad Request를 자동으로 던진다.

나머지 Controller들 — 단일 엔드포인트의 미학

등급 조정, 활동 기록, 배치 프로세스, 자유 활동 Controller는 엔드포인트가 1~2개뿐이다.

// 활동 기록 — 엔드포인트 1개
@Controller('attendance')
export class AttendanceController {
  constructor(private readonly attendanceService: AttendanceApplicationService) {}

  @Post('check-in')
  @HttpCode(HttpStatus.OK)
  async checkIn(@Body() dto: CheckInDto): Promise<CheckInResultDto> {
    return this.attendanceService.checkIn(dto);
  }
}

// 배치 프로세스 — 엔드포인트 2개 (수동 실행용)
@Controller('batch')
export class BatchProcessController {
  constructor(private readonly batchProcessService: BatchProcessApplicationService) {}

  @Post('curriculum-evaluation')
  @HttpCode(HttpStatus.OK)
  async runCurriculumEvaluation(): Promise<CurriculumEvaluationResultDto> {
    return this.batchProcessService.runCurriculumDailyEvaluation();
  }

  @Post('metric-aggregation')
  @HttpCode(HttpStatus.OK)
  async runMetricAggregation(): Promise<MetricAggregationResultDto> {
    return this.batchProcessService.runMetricAggregation();
  }
}

“Controller가 이렇게 작아도 되나?” 싶을 수 있다. 된다. Controller의 크기는 Use Case의 복잡도가 결정하는 거지, 코드량으로 판단하는 게 아니다. 활동 기록 체크는 POST /attendance/check-in 하나면 충분하다.

📌 핵심: “Controller 하나에 엔드포인트 하나”라도 분리하는 게 맞다. Use Case가 다르면 Controller도 다르다. 나중에 인증 Guard나 Rate Limiting을 Use Case별로 다르게 적용할 때 이 구조가 빛을 발한다.


🧩 조립 — ApplicationModule

Controller 6개와 Application Service 6개를 만들었으니, 이걸 NestJS 모듈로 조립해야 한다. ApplicationModule을 만들어서 한곳에 등록했다.

@Module({
  imports: [
    DomainModule,
    ScheduleModule.forRoot(),
  ],
  controllers: [
    StudentOnboardingController,
    AssignmentController,
    LevelAdjustmentController,
    AttendanceController,
    BatchProcessController,
    FreeLearningController,
  ],
  providers: [
    // Domain Services
    BlockGenerationService,
    LevelAdjustmentDecisionService,
    CurriculumEvaluationService,
    MetricAggregationService,
    ContentCooldownService,

    // Application Services
    StudentOnboardingApplicationService,
    AssignmentApplicationService,
    LevelAdjustmentApplicationService,
    AttendanceApplicationService,
    BatchProcessApplicationService,
    FreeLearningApplicationService,
  ],
  exports: [
    StudentOnboardingApplicationService,
    AssignmentApplicationService,
    ...
  ],
})
export class ApplicationModule {}

이 모듈 하나가 전체 애플리케이션의 “진입점”을 관리한다. AppModule에는 ApplicationModule을 import하는 한 줄만 추가하면 끝이다.

// ❌ Before — Application Layer 없음
@Module({
  imports: [
    DatabaseModule,
    CommonModule,
    EventModule,
    // 11개 Bounded Context 모듈...
    UserModule,
    DomainModule,
  ],
})
export class AppModule {}
// ✅ After — ApplicationModule 추가
@Module({
  imports: [
    DatabaseModule,
    CommonModule,
    EventModule,
    // 11개 Bounded Context 모듈...
    ApplicationModule,  // ← 이 한 줄
    UserModule,
    DomainModule,
  ],
})
export class AppModule {}

🔍 단서: ScheduleModule.forRoot()가 ApplicationModule에 들어간 이유가 있다. 배치 프로세스 Controller에서 @Cron() 데코레이터를 나중에 추가할 수 있도록 미리 등록해둔 것이다. 배치 작업은 HTTP 호출과 Cron 스케줄 두 가지 방식으로 실행할 수 있어야 한다.


✅ 검증 — Controller 단위 테스트 27개

Controller 테스트는 단순하다. Mock Service를 주입하고, Controller 메서드를 호출해서 Service에 올바른 인자가 전달되었는지 확인하면 끝이다.

describe('AttendanceController', () => {
  let controller: AttendanceController;
  let service: jest.Mocked<AttendanceApplicationService>;

  beforeEach(async () => {
    const mockService = {
      checkIn: jest.fn(),
    };

    const module: TestingModule = await Test.createTestingModule({
      controllers: [AttendanceController],
      providers: [
        {
          provide: AttendanceApplicationService,
          useValue: mockService,
        },
      ],
    }).compile();

    controller = module.get<AttendanceController>(AttendanceController);
    service = module.get(AttendanceApplicationService);
  });

  it('should check in student successfully', async () => {
    const dto: CheckInDto = { studentId: 'student-123' };
    const expectedResult: CheckInResultDto = {
      success: true,
      message: '활동 기록 완료!',
      attendedAt: new Date(),
      consecutiveDays: 5,
    };

    service.checkIn.mockResolvedValue(expectedResult);
    const result = await controller.checkIn(dto);

    expect(service.checkIn).toHaveBeenCalledWith(dto);
    expect(result).toEqual(expectedResult);
  });
});

핵심은 Test.createTestingModule이다. NestJS의 테스팅 유틸리티가 DI 컨테이너를 똑같이 구성해준다. 실제 Service 대신 Mock을 주입하니까 DB 연결 없이 순수 로직만 검증할 수 있다.

27개 테스트 전부 작성해서 전체 테스트 현황은 이렇게 됐다.

종류개수대상
단위 테스트257개Application Service, Domain Service
Controller 테스트27개REST API Controller 6개
통합 테스트101개Repository (실제 DB)
합계358개

🗺️ 전체 구조 — 요청이 흐르는 경로

직접 정리한 NestJS DDD 아키텍처 요청 흐름도
직접 정리한 NestJS DDD 아키텍처 요청 흐름도

하나의 HTTP 요청이 어떤 경로로 흘러가는지 정리하면 이렇다.

HTTP POST /students
  → StudentOnboardingController.registerStudent()
    → StudentOnboardingApplicationService.registerStudent()
      → PrismaService (DB 쿼리)
      → EventEmitter2 (도메인 이벤트 발행)
    ← RegisterStudentResultDto
  ← HTTP 201 Created

각 계층의 책임이 명확하다.

계층책임알아야 하는 것
ControllerHTTP 라우팅, 파라미터 파싱HTTP, NestJS 데코레이터
Application Service오케스트레이션, 트랜잭션Use Case 흐름
Domain Service비즈니스 규칙, 계산도메인 로직만
Repository영속성, 쿼리Prisma, SQL

📋 정리 — 핵심 요약

477줄의 Controller 코드와 1,043줄의 테스트. 하루 만에 REST API 전체를 완성했다. DDD 3계층이 잘 잡혀 있었기 때문에 가능한 속도였다.

항목수치
Controller6개
엔드포인트21개
Controller 코드477줄
Controller 테스트27개 (1,043줄)
전체 테스트358개
소요 시간1일
상황안티패턴권장 패턴
Controller 분리❌ 엔티티 기반 (UsersController에 모든 유저 관련 기능)✅ Use Case 기반 (Service와 1:1 매핑)
비즈니스 로직❌ Controller 안에서 직접 처리✅ Application Service에 위임
Path Parameter❌ Body DTO에 중복 포함Omit<DTO, 'id'> + spread로 합성
모듈 등록❌ AppModule에 Controller 직접 등록✅ ApplicationModule로 묶어서 import
테스트❌ HTTP 요청으로 테스트 (느림)✅ Mock Service 주입 + 메서드 직접 호출

📌 핵심: Controller를 얇게 유지하면 비즈니스 로직 변경이 Controller에 영향을 주지 않는다. 테스트도 빠르고, 나중에 GraphQL이나 gRPC로 진입점을 바꿀 때도 Application Service를 그대로 재활용할 수 있다.

다음 편에서는 v1.0을 완성하고, 그걸 통째로 갈아엎기로 결심한 이야기를 다룬다.