[Nest] [클린코드 4편] Service와 Repository 분리하고 의존성 주입하기(DI)
[Nest] [클린코드 4편] Service와 Repository 분리하고 의존성 주입하기(DI)
[Nest] [클린코드 3편] DTO와 전역 pipe로 요청 데이터 깔끔하게 검증하기 [Nest] [클린코드 3편] DTO와 전역 pipe로 요청 데이터 깔끔하게 검증하기[Nest] [클린코드 2편] Guard와 Decorator로 요청 처리 깔끔하
blog.juyear.dev
이전 글 읽으러 가기!
👋 소개
안녕하세요! 대학생 개발자 주이어입니다.
오늘은 1월 2일로, 드디어 2026년 새해가 밝았습니다.
이 글을 읽는 분들 모두 새해 복 많이 받으시고, 좋은 일만 있으시길 바랍니다.
이번 글의 주제는 Nest 클린코드 5편으로, Interceptor와 Filter를 사용하여 반환 값을 통일하는 방법에 대해서 정리해보려고 합니다. 추가로 Interceptor를 활용하여 응답 속도 측정까지 해보도록 하겠습니다.
그럼 본론으로 들어가도록 하겠습니다.
⚙️ Interceptor와 Filter의 개념
구현하고 실습하는 단계로 넘어가기 전에 간단하게 Interceptor와 Filter의 개념에 대해서 소개하도록 하겠습니다.
Interceptor 개념
- Controller 실행 전과 후에 개입할 수 있는 기능을 제공합니다.
- 요청이 Controller에 도달하기 전에 로직을 실행할 수 있고,
- Controller가 반환한 응답 값을 가로채 가공하는 등의 처리를 할 수 있습니다.
Interceptor 용도
- 응답 데이터 형식을 통일하고 싶을 때 사용합니다.
- 응답 속도를 측정하고 싶을 때 사용합니다.
- 요청/응답을 로깅하고 싶을 때 사용합니다.
- 공통 후처리 로직을 분리하고 싶을 때 사용합니다.
Filter 개념
- 요청 처리 중 예외(Exception)가 발생했을 때 실행됩니다.
- 오류가 발생하면, 오류 반환 값을 가로채 가공하는 등의 처리를 할 수 있습니다.
Filter 용도
- 오류 응답 형식을 통일하고 싶을 때 사용합니다.
- 예외를 로깅하고 싶을 때 사용합니다.
이렇게 간단하게 Interceptor와 Filter 개념에 대해서 정리해 보았습니다.
중요한건 Interceptor와 Filter를 왜 사용하는지와 언제 사용하는지를 익히시면 될 것 같습니다.
⚙️ Interceptor로 응답 형식 통일하기
먼저 Interceptor 코드를 작성해줄 파일을 생성해야 합니다.
src -> common -> interceptors -> transform.interceptor.ts
저는 위 경로에 파일을 만들어주었고, 파일명은 transform.interceptor.ts로 해주었습니다.
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { ApiResponse } from '../types/api-response';
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
success: true,
timestamp: new Date().toISOString(),
data,
})),
);
}
}
위 코드는 응답 형식을 통일시켜주는 Interceptor를 구현한 코드입니다.
Nest에서 Interceptor를 구현하기 위해서는 기본적으로 제공하는 NestInterceptor 클래스를 상속받아 intercept 함수만 작성해주시면 됩니다.
위 코드에서도 개발자가 직접 작성해야 하는 부분은 intercept 함수 안에 있는 return문 정도 입니다.
추가적으로 코드를 나눠서 설명해보도록 하겠습니다.
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
NestInterceptor<타입1, 타입2> 부분에서
타입1은 Controller가 반환하는 값의 타입을 의미하고,
타입2는 Interceptor가 반환하는 값의 타입을 의미합니다.
기본적으로는 <T, any> 형태로 많이 사용되지만, Interceptor에서 반환하는 응답 구조를 명확히 하기 위해
저는 ApiResponse라는 타입을 만들어 넣어주었습니다.
export interface ApiResponse<T> {
success: boolean;
timestamp: string;
data: T;
}
ApiResponse 타입은 위와 같이 구현해 주었으며, Controller의 반환 값을 감싸 일관된 응답 구조를 만들기 위해 사용하였습니다.
다음으로는, intercept 함수의 매개변수 부분입니다.
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<ApiResponse<T>> {
intercept 함수는 기본적으로 ExecutionContext와 CallHandler를 매개변수로 입력받습니다.
이 매개변수는 개발자가 직접 넣어주는 것이 아니고, Controller에 요청이 들어왔을 때 자동으로 입력받습니다.
각각의 매개변수에 대해서 설명하자면,
- ExecutionContext : 현재 요청이 처리되는 실행 환경에 대한 메타 정보를 담고 있습니다. switchToHttp()를 통해 request/response 객체에 접근할 수 있게 해줍니다.
- CallHandler : Controller 로직을 실행시키는 함수를 담당합니다. Controller가 값을 반환하면, Observable 형태로 흘러나옵니다. Interceptor는 이 Observable을 가공해서 최종 응답을 바꿀 수 있습니다.
ExecutionContext의 경우 Guard, Decorator 등 이미 자주 사용해봤던 부분이기 때문에,
CallHandler에 대해서만 익히고 가시면 될 것 같습니다.
마지막으로 return 부분입니다.
return next.handle().pipe(
map((data) => ({
success: true,
timestamp: new Date().toISOString(),
data,
})),
);
이 부분이 interceptor가 응답 형식을 가공하여 최종적으로 값을 반환하는 부분입니다.
위에서 설명했던 CallHandler를 사용하여 Controller 로직을 실행시키고 있고,
Controller가 반환한 Observable 형태의 값을 pipe로 받아와
최종적으로 map을 사용하여 응답 형식을 가공하는 것을 알 수 있습니다.
저의 경우에는 응답 형식을
- success : 요청 성공 여부
- timestamp : 응답 반환 시간
- data : Controller의 반환 값이 담긴 부분
으로 가공시켜 주었습니다.
import { Observable, map } from 'rxjs';
추가로 여기서 사용된 map은 일반적으로 배열에서 사용되는 map함수가 아니며,
rxjs에서 제공하는 map함수라는 점을 유의하셔야 합니다.
⚙️ Interceptor 적용하기
Interceptor를 적용하기 위해서는 크게 2가지 방법이 있습니다.
첫 번째는 Controller에 개별적으로 직접 적용하는 방법이고,
두 번째는 전역으로 등록해 모든 Controller에 적용시키는 방법입니다.
먼저 첫 번째 방법부터 살펴보도록 하겠습니다.
@UseGuards(JwtAuthGuard)
@UseInterceptors(TransformInterceptor)
@Get('profile')
getProfile(@UserDecorator() user: GetUserRequest): GetUserResponse {
const res = this.userService.findById(user.id);
const dto = plainToInstance(GetUserResponse, res, {
excludeExtraneousValues: true,
});
return dto;
}
첫 번째는 기존에 UseGuards를 사용해 Guard를 적용했던 것 처럼 UseInterceptors를 사용해 적용시켜주는 방법입니다.
이렇게 한 후 postman으로 테스트를 진행해보시면, 응답 형식이 가공되어 반환되는 것을 확인할 수 있을 겁니다.
(테스트 결과는 아래에 넣어두었습니다.)
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalInterceptors(new TransformInterceptor()); // 이 코드
두 번째 방법은 main.ts에 useGlobalInterceptors를 사용하여 전역으로 등록하는 방법입니다.
두 가지 방법 중 상황에 맞게 사용하시면 되고 저 같은 경우에는 모든 응답 값의 형식을 통일하고 싶었기 때문에 전역으로 적용시켜 주었습니다.
✅ Interceptor 테스트


첫 번째 사진은 토큰을 발급받는 auth/login의 응답 형식이고,
두 번째 사진은 유저 프로필을 조회하는 user/profile의 응답 형식입니다.
보이시는 것과 같이 두 가지의 응답 형식이 모두 통일되어(success, timestamp, data 형식으로) 반환되는 것을 알 수 있습니다.
⚙️ Filter로 오류 응답 형식 통일하기
Filter의 경우 Interceptor보다는 간단한 로직을 가지고 있기 때문에 빠르게 진행해보도록 하겠습니다.
src -> common -> filters -> http-exception.filter.ts
먼저 위와 같은 경로에 파일을 만들어주었습니다.
import {
ArgumentsHost,
ExceptionFilter,
HttpException,
Injectable,
} from '@nestjs/common';
import { Response, Request } from 'express';
@Injectable()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const message = exception.getResponse();
response.status(status).json({
success: false,
statusCode: status,
path: request.url,
timestamp: new Date().toISOString(),
message,
});
}
}
위 코드는 http 오류를 처리하는 HttpExceptionFilter의 코드입니다.
Filter를 만들기 위해서는 Nest에서 지원하는 ExceptionFilter를 상속받아 catch 함수를 구현시켜주면 됩니다.
catch(exception: HttpException, host: ArgumentsHost) {
catch 함수는 HttpException과 ArgumentHost를 매개변수로 입력받습니다.
HttpException은 이름에서 알 수 있듯이 오류 정보를 담고 있습니다.
ArgumentHost는 처음 보실 수 있지만, 간단하게 설명하면 Interceptor에서 사용했던 ExecutionContext의 Filter 버전이라고 보시면 됩니다.
즉, 오류 발생 시 실행 환경에 접근하기 위해 사용됩니다.
response.status(status).json({
success: false,
statusCode: status,
path: request.url,
timestamp: new Date().toISOString(),
message,
});
오류 응답 형식을 통일하기 위해서는 위와 같이 response.status(응답 상태).json({응답 형식})을 사용합니다.
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
response의 경우 context를 사용하여 가져올 수 있고, status의 경우 exception 매개변수를 통해 가져올 수 있습니다.
응답 형식은 Interceptor와 비슷하게 만들어주었고, 추가적으로 statusCode(오류 코드)와 path(오류가 발생한 API 경로)를 넣어주었습니다.
이렇게 해주면 간단하게 Filter를 구현하실 수 있습니다.
⚙️ Filter 적용하기
Filter도 여러가지 방법으로 적용시킬 수 있지만, 이 글에서는 전역으로 적용시키는 방법으로 보여드리도록 하겠습니다.
(개별 적용은 Interceptor와 똑같은 방법으로 UseFilters를 사용하시면 됩니다)
app.useGlobalInterceptors(new TransformInterceptor());
app.useGlobalFilters(new HttpExceptionFilter()); // 이 코드
전역으로 적용시키는 방법도 Interceptor와 거의 동일합니다.
main.ts에서 useGlobalFilters를 사용해 적용시켜주면 끝 입니다.
✅ Filter 테스트


이번에도 두 개의 사진을 준비해 보았습니다.
첫 번째 사진은 로그인 시 비밀번호를 틀렸을 때 반환되는 오류이고,
두 번째 사진은 프로필 조회 시 토큰 값이 잘못되었을 때 반환되는 오류입니다.
두 사진을 통해 오류 응답 값이 통일되어(success, statusCode, path, timestamp, message 형식으로) 반환되는 것을 확인할 수 있습니다.
⚙️ Interceptor로 응답 속도 측정하기
* 이 부분은 번외 부분으로 필요하신 분만 읽으시면 될 것 같습니다.
백엔드를 개발하다 보면, 응답 속도 측정이 필요한 경우가 가끔 있습니다.
코드를 개선하거나, 성능을 개선하기 위해서는 이러한 측정이 꼭 필요하기도 하죠.
이러한 측정을 Interceptor로 구현할 수가 있는데요.
Interceptor는 사용자에게 반환되지 않는 추가적인 로직도 구현할 수 있도록 제공하고 있습니다.
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';
import { Response } from 'express';
@Injectable()
export class LoggingInterceptor<T> implements NestInterceptor<T, any> {
intercept(context: ExecutionContext, next: CallHandler<T>): Observable<any> {
const ctx = context.switchToHttp();
const response = ctx.getResponse<Response>();
const start = Date.now();
return next.handle().pipe(
tap(() => {
console.log(`${response.statusCode} - ${Date.now() - start}ms`);
}),
);
}
}
위 코드는 Interceptor를 사용하여 응답 속도를 측정하는 코드입니다.
기본 구조는 위에서 설명했던 Interceptor 코드와 거의 동일하지만,
return 부분에서 사용하는 연산자가 map()이 아닌 tap()이라는 점이 핵심적인 차이입니다.
tap()은 응답 데이터를 건드리지 않고, 로깅, 시간 측정과 같은 부가적인 작업을 수행할 때 사용됩니다.
Interceptor는 Controller가 실행되기 전과 후에 모두 개입할 수 있는 구조를 가지고 있습니다.
위 코드에서 next.handle() 전까지는 Controller가 실행되기 전에 실행되는 코드이고,
pipe() 부터는 Controller가 실행된 후에 실행되는 코드입니다.
따라서 요청이 들어오는 시점에 Date.now()로 시작 시간을 기록하고, Controller 로직이 끝난 이후 tap()을 통해 다시 시간을 측정한 뒤 두 시간의 차이를 계산하면 응답 속도를 구할 수 있게 되는 것이죠.
✅ 응답 속도 측정 테스트

postman으로 보낸 요청이 성공적으로 반환되었다면, console 창에 위와 같이 응답 속도가 기록되는 것을 확인할 수 있습니다.
이를 통해 Interceptor로 응답 속도 측정까지 구현한 것 입니다.
🛠️ 현재 폴더 구조

😊 마무리
이렇게 오늘 Interceptor와 Filter를 사용하여 응답 값을 통일하는 방법에 대해서 정리해보았고, 추가로 응답 속도를 측정하는 방법에 대해서까지 정리해보았습니다.
Interceptor는 기존에 Controller가 담당하던 응답 형식 통일과 같은 공통 로직을 대신 처리하여, 코드의 중복을 줄이고 가독성과 유지보수성을 높여주는 역할을 하고,
Filter는 예외 처리를 Controller마다 작성하는 대신, 한 곳에서 예외를 처리할 수 있도록 하여 코드의 중복을 줄이고 가독성과 유지보수성을 높여주는 역할을 합니다.
즉, Interceptor와 Filter는 Controller가 비즈니스 로직에만 집중할 수 있게하여, 시스템의 구조를 더 깔끔하게 만들어주는 역할을 하니 여러분들도 한 번씩 적용해보시는 것을 추천드립니다.
현재까지 진행된 로직을 확인해보면,
로그인 구현(JWT 발급) -> API 요청 -> Guard로 JWT 검증 -> Interceptor(before) -> Decorator로 데이터 추출 -> DTO로 데이터 검증 -> Interceptor(after)
+ Service와 Repository 분리
+ 의존성 주입
+ Filter 오류 응답 통일
+ 응답 속도 측정(Interceptor tap)
까지 진행이 되었습니다.
5편까지 진행하면서 정말 많은 것을 배우고 구현해봤는데요.
간단한 예제로 진행하긴 했지만, 백엔드 로직의 한 사이클을 익힌 것 같아 정말 의미 있는 시간이었습니다.
클린코드는 앞으로도 계속해서 글을 올릴 예정이며 많은 관심 부탁드립니다.
그럼 지금까지 읽어주셔서 감사드리며, 다음에 더 유익한 글로 찾아오도록 하겠습니다.
KYT CODING COMMUNITY Discord 서버에 가입하세요!
Discord에서 KYT CODING COMMUNITY 커뮤니티를 확인하세요. 27명과 어울리며 무료 음성 및 텍스트 채팅을 즐기세요.
discord.com
KYT CODING COMMUNITY 가입하기!
'[Nest]' 카테고리의 다른 글
| [Nest] [클린코드 4편] Service와 Repository 분리하고 의존성 주입하기(DI) (1) | 2025.12.31 |
|---|---|
| [Nest] [클린코드 3편] DTO와 전역 pipe로 요청 데이터 깔끔하게 검증하기 (1) | 2025.11.17 |
| [Nest] [클린코드 2편] Guard와 Decorator로 요청 처리 깔끔하게 구현하기 (1) | 2025.11.07 |
| [Nest] [클린코드 1편] JWT 발급 및 인증 구조 깔끔하게 구현하기 (Postman API 테스트) (1) | 2025.11.03 |