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-01
05), 태스크 관리(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, TasksController | REST 관례에 맞음 | Use Case와 1:1 매핑 안 됨 |
| Use Case 기반 | StudentOnboardingController, AssignmentController | Application 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인데, assignmentId는 number여야 한다. 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개 | — |
🗺️ 전체 구조 — 요청이 흐르는 경로

하나의 HTTP 요청이 어떤 경로로 흘러가는지 정리하면 이렇다.
HTTP POST /students
→ StudentOnboardingController.registerStudent()
→ StudentOnboardingApplicationService.registerStudent()
→ PrismaService (DB 쿼리)
→ EventEmitter2 (도메인 이벤트 발행)
← RegisterStudentResultDto
← HTTP 201 Created
각 계층의 책임이 명확하다.
| 계층 | 책임 | 알아야 하는 것 |
|---|---|---|
| Controller | HTTP 라우팅, 파라미터 파싱 | HTTP, NestJS 데코레이터 |
| Application Service | 오케스트레이션, 트랜잭션 | Use Case 흐름 |
| Domain Service | 비즈니스 규칙, 계산 | 도메인 로직만 |
| Repository | 영속성, 쿼리 | Prisma, SQL |
📋 정리 — 핵심 요약
477줄의 Controller 코드와 1,043줄의 테스트. 하루 만에 REST API 전체를 완성했다. DDD 3계층이 잘 잡혀 있었기 때문에 가능한 속도였다.
| 항목 | 수치 |
|---|---|
| Controller | 6개 |
| 엔드포인트 | 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을 완성하고, 그걸 통째로 갈아엎기로 결심한 이야기를 다룬다.
📚 교육용 풀스택 SaaS 개발기 시리즈 (11편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성