NestJS Service Layer
NestJS 애플리케이션에서 Service는 비즈니스 로직을 담는 계층이다. Controller가 HTTP 요청/응답을 처리하고, Repository가 데이터베이스에 접근한다면, Service는 그 사이에서 "실제로 무엇을 해야 하는지"를 결정한다.
왜 Service가 필요한가
Controller에 비즈니스 로직을 직접 넣으면 안 되는 걸까? 작은 프로젝트에서는 문제가 없지만, 규모가 커지면 세 가지 문제가 발생한다.
첫째, 재사용이 안 된다. "매장 생성" 로직이 Controller에 있으면, 관리자 API에서만 사용할 수 있다. 배치 작업이나 다른 Service에서 같은 로직이 필요하면 코드를 복사해야 한다.
둘째, 테스트가 어려워진다. Controller를 테스트하려면 HTTP 요청을 시뮬레이션해야 한다. 비즈니스 로직만 검증하고 싶어도 HTTP 레이어를 거쳐야 하니 테스트가 무거워진다.
셋째, 관심사가 섞인다. "요청 데이터를 파싱한다"와 "매장 이름이 중복되는지 검사한다"는 서로 다른 관심사다. 한 곳에 섞이면 코드가 복잡해지고 변경의 영향 범위가 커진다.
Service의 기본 구조
@Injectable() 데코레이터로 DI 컨테이너에 등록하고, 생성자에서 필요한 Repository를 주입받는다.
@Injectable()
export class AdminStoresService {
constructor(private readonly storesRepository: StoresRepository) {}
async createStore(createStoreDto: CreateStoreDto): Promise<AdminStoreResponseDto> {
const store = new Store(createStoreDto.name, createStoreDto.address);
if (createStoreDto.phone) {
store.phone = createStoreDto.phone;
}
await this.storesRepository.create(store);
return this.toResponseDto(store);
}
async findAllStores(): Promise<AdminStoreResponseDto[]> {
const stores = await this.storesRepository.findAll();
return stores.map((store) => this.toResponseDto(store));
}
async updateStore(
storeId: string,
updateStoreDto: UpdateStoreDto,
): Promise<AdminStoreResponseDto> {
const store = await this.storesRepository.findById(storeId);
if (updateStoreDto.name) store.name = updateStoreDto.name;
if (updateStoreDto.address) store.address = updateStoreDto.address;
if (updateStoreDto.phone) store.phone = updateStoreDto.phone;
if (updateStoreDto.status) store.status = updateStoreDto.status;
await this.storesRepository.update(store);
return this.toResponseDto(store);
}
}
이 Service가 하는 일은 세 가지다.
- Entity를 생성하거나 조회한다 (Repository에 위임)
- 비즈니스 규칙을 적용한다 (DTO에서 Entity로 변환, 조건부 업데이트)
- 응답 형태로 변환한다 (Entity → DTO)
DTO 변환
Service의 중요한 역할 중 하나가 Entity와 DTO(Data Transfer Object) 사이의 변환이다.
Entity는 데이터베이스 구조를 반영한 내부 객체다. 관계, 상태, 감사 필드(createdAt, updatedAt) 등 모든 정보를 담고 있다. 이걸 그대로 API 응답으로 보내면 내부 구현이 노출되고, 클라이언트가 필요 없는 데이터까지 전송된다.
DTO는 외부와 소통하는 계약(contract)이다. 클라이언트가 보내는 요청(CreateStoreDto)과 클라이언트가 받는 응답(AdminStoreResponseDto)의 형태를 정의한다.
private toResponseDto(store: Store, includeDevices = false): AdminStoreResponseDto {
const response: AdminStoreResponseDto = {
storeId: store.storeId,
name: store.name,
address: store.address,
phone: store.phone,
status: store.status,
deviceCount: store.devices.isInitialized() ? store.devices.length : 0,
manager: { name: '담당자 미배정', phone: '-' },
createdAt: store.createdAt,
updatedAt: store.updatedAt,
};
if (includeDevices && store.devices.isInitialized()) {
response.devices = store.devices.getItems().map((device) => ({
deviceId: device.deviceId,
name: device.name,
serialNumber: device.serialNumber,
status: device.status,
lastHeartbeat: device.lastHeartbeat,
}));
}
return response;
}
toResponseDto는 Store Entity에서 API 응답에 필요한 필드만 추출한다. store.devices.isInitialized()로 관계가 로드되었는지 확인한 후 접근하는 것도 주목할 점이다. MikroORM에서 populate 없이 조회하면 관계가 로드되지 않기 때문이다.
여러 Repository 조합
복잡한 비즈니스 로직은 여러 Repository의 데이터를 조합해야 한다.
@Injectable()
export class AdminDevicesService {
constructor(
private readonly devicesRepository: DevicesRepository,
private readonly deviceServicesRepository: DeviceServicesRepository,
) {}
async preRegisterDevice(dto: PreRegisterDeviceDto): Promise<DeviceResponseDto> {
// 1. 매장 존재 여부 확인
const store = await this.devicesRepository.findStoreById(dto.storeId);
// 2. 시리얼 번호 중복 검사
const existing = await this.devicesRepository.findBySerialNumber(dto.serialNumber);
if (existing) {
throw new BadRequestException(
`Device with serial number ${dto.serialNumber} already exists`,
);
}
// 3. 디바이스 생성
const device = new Device(dto.serialNumber, store, dto.deviceInfo.name);
await this.devicesRepository.createDevice(device);
return this.toResponseDto(device);
}
}
이 Service는 두 개의 Repository를 주입받는다. 디바이스를 등록하기 전에 매장이 존재하는지, 시리얼 번호가 중복되는지 검사한다. 이런 교차 검증 로직이 Controller에 있으면 Controller가 Repository에 직접 의존하게 되고, 같은 검증이 필요한 다른 곳에서 재사용할 수 없다.
설정 주입
Service에서 환경 변수나 설정 값이 필요할 때는 ConfigModule을 통해 주입받는다.
@Injectable()
export class AdminAIModelsService {
private readonly assetsCdnUrl?: string;
constructor(
private readonly aiModelsRepository: AIModelsRepository,
@Inject(s3Config.KEY)
private readonly s3ConfigValue: ConfigType<typeof s3Config>,
) {
this.assetsCdnUrl = this.s3ConfigValue.assetsCdnUrl;
}
private getAssetUrl(s3Key?: string): string | undefined {
if (!s3Key) return undefined;
return this.assetsCdnUrl ? `${this.assetsCdnUrl}/${s3Key}` : undefined;
}
}
@Inject(s3Config.KEY)로 S3 설정을 주입받고, CDN URL을 private 필드에 캐싱한다. 이렇게 하면 설정 값이 변경되어도 Service 코드를 수정할 필요 없이 환경 변수만 바꾸면 된다.
Service 설계 원칙
Controller와의 경계
Controller는 HTTP 관련 처리(라우팅, 요청 파싱, 응답 포맷), Service는 비즈니스 로직을 담당한다.
| Controller의 역할 | Service의 역할 |
|---|---|
| URL 파라미터 추출 | 데이터 검증 (비즈니스 규칙) |
| 요청 본문 파싱 | Entity 생성/수정/삭제 |
| 파일 업로드 필수 여부 확인 | Repository 호출 |
| HTTP 상태 코드 결정 | DTO 변환 |
| Swagger 문서화 | 트랜잭션 관리 |
파일이 첨부되었는지 확인하는 것은 HTTP 요청 형식에 관한 문제이므로 Controller에 둔다. 반면 "이 세션 상태에서 사진을 완료할 수 있는가"는 비즈니스 규칙이므로 Service에 둔다.
Service 간 의존
Service가 다른 Service를 주입받아 사용할 수도 있다. 단, 순환 의존이 발생하지 않도록 주의해야 한다. A Service가 B Service를 사용하고, B Service가 A Service를 사용하면 NestJS가 어느 것을 먼저 만들어야 할지 결정할 수 없다.
순환 의존이 발생하면 공통 로직을 별도의 Service로 분리하는 것이 올바른 해결 방법이다.
에러 처리
Service에서 비즈니스 규칙 위반을 감지하면 NestJS의 내장 예외를 던진다.
if (existing) {
throw new BadRequestException('Device already exists');
}
| 예외 | HTTP 상태 | 사용 시점 |
|---|---|---|
BadRequestException | 400 | 잘못된 요청 데이터 |
NotFoundException | 404 | 리소스를 찾을 수 없음 |
ConflictException | 409 | 중복 데이터 |
ForbiddenException | 403 | 권한 없음 |
NestJS의 전역 예외 필터가 이 예외들을 자동으로 HTTP 응답으로 변환한다. Service가 HTTP 상태 코드를 직접 다룰 필요가 없다. 예외를 던지면 프레임워크가 적절한 응답을 만들어준다.
정리
- Controller는 HTTP 입출력, Service는 비즈니스 로직, Repository는 데이터 접근으로 관심사를 분리하면 재사용과 테스트가 쉬워진다
- Entity ↔ DTO 변환은 Service가 담당하며, 내부 구현이 API 응답으로 노출되는 것을 차단한다
- 여러 Repository 조합이나 교차 검증 같은 복잡한 로직이 Service 계층의 존재 이유이며, 순환 의존이 발생하면 공통 로직을 별도 Service로 분리한다