모든 응답을 같은 형태로 감싸려고 컨트롤러마다 복붙하던 코드를 정리한 방법
문제 발생
응답 형식을 { data: ... }로 통일하기로 하고 컨트롤러마다 이렇게 감쌌습니다.
@Get()
async findAll() {
return { data: await this.service.findAll() }; // 30곳에 반복
}느린 요청에 타임아웃을 거는 요구가 추가되자, 같은 코드를 또 30곳에 넣어야 했습니다.
원인 분석
횡단 관심사를 핸들러 안에 두고 있었습니다. NestJS 문서는 인터셉터가 할 수 있는 일을 이렇게 나열합니다 — 메서드 실행 전후에 추가 로직을 바인딩하고, 함수가 반환한 결과를 변형하고, 함수가 던진 예외를 변형하고, 기본 동작을 확장하고, 특정 조건에 따라 함수를 완전히 오버라이드합니다.
핵심 계약은 CallHandler입니다. 문서가 못박습니다 — intercept() 구현에서 handle() 메서드를 호출하지 않으면 라우트 핸들러 메서드는 아예 실행되지 않습니다. 즉 인터셉터는 핸들러의 앞뒤를 감싸는 것이 아니라 실행 자체를 소유합니다. 캐시 히트 시 핸들러를 건너뛰는 구현이 가능한 것도 이 때문입니다.
응답 변형과 타임아웃은 문서가 직접 예로 드는 사용처이고, RxJS 연산자(tap, map, timeout, catchError)로 표현합니다.
해결 방안
- 응답 래핑을 인터셉터로 옮깁니다.
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, { data: T }> {
intercept(_: ExecutionContext, next: CallHandler): Observable<{ data: T }> {
return next.handle().pipe(map((data) => ({ data })));
}
}- 타임아웃도 같은 자리에서 처리합니다. 문서의 패턴 그대로입니다.
return next.handle().pipe(
timeout(5000),
catchError((err) =>
err instanceof TimeoutError ? throwError(() => new RequestTimeoutException()) : throwError(() => err),
),
);-
관측은
tap으로 합니다. 스트림을 건드리지 않고 로깅·메트릭만 남길 때 씁니다. -
적용 범위를 의식해서 고릅니다. 컨트롤러·핸들러 단위는
@UseInterceptors(), 전역은 provider 등록(APP_INTERCEPTOR)입니다 — 전역인데 DI가 필요하면 provider 방식이어야 한다는 점은 가드·필터와 같습니다. -
응답 형식을 바꾸는 것은 API 계약 변경입니다. 전역 래핑을 도입하면 기존 클라이언트가 전부 영향을 받습니다. 새 버전부터 적용하거나 마이그레이션 계획과 함께 켭니다.
-
파이프·가드와 역할을 섞지 않습니다. 입력 검증은 파이프, 인가는 가드, 응답 변형은 인터셉터입니다. 인터셉터에서 인가를 하면 그 판단이 요청 파이프라인의 잘못된 위치에 놓입니다.
댓글0
댓글을 남기려면 로그인이 필요해요. 로그인
아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.