NestJS 완전정복: 미들웨어, 가드, 인터셉터, 파이프의 모든 것
1. 미들웨어(Middleware)
1.1. 개념 및 역할
미들웨어는 Express와 마찬가지로 요청(Request)과 응답(Response) 사이에서 실행되는 함수입니다. HTTP 요청이 컨트롤러의 핸들러에 도달하기 전에 실행되는 함수로, 요청(request)과 응답(response) 객체, 그리고 next() 함수를 통해 다음 미들웨어로 제어를 전달할 수 있습니다. 주로 요청의 전처리(로깅, 인증, body 파싱 등)나 후처리, 특정 조건에 따른 요청 차단 등에 사용됩니다. NestJS의 미들웨어는 Express 미들웨어와 거의 동일하게 동작하지만, Nest의 DI 시스템과 모듈 시스템에 통합되어 더 구조적으로 관리할 수 있습니다.
1.2. 사용법
1) 클래스형 미들웨어 작성 방법 및 적용 방법
기본적으로 클래스형으로 많이 작성하며, 클래스형의 경우 @Injectable() 데코레이터와 NestMiddleware 인터페이스를 사용합니다.
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class LoggerMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
console.log(`[${req.method}] ${req.originalUrl}`);
next();
}
}
모듈에서 NestModule 인터페이스를 구현하고, configure()
메서드를 통해 MiddlewareConsumer를 사용하여 미들웨어를 apply()
를 통해, 특정 라우트에 적용합니다.
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { LoggerMiddleware } from './common/middleware/logger.middleware';
import { CatsModule } from './cats/cats.module';
@Module({
imports: [CatsModule],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(LoggerMiddleware)
.forRoutes('cats');
}
}
forRoutes('cats')
로 지정하면, 특정 경로에만 적용할 수도 있습니다.- 만약 특정 HTTP 메서드(GET 등)에만 미들웨어를 적용하고 싶다면, forRoutes()에 객체를 넘기고 RequestMethod를 지정할 수 있습니다.
- 여러 미들웨어를 배열로 적용할 수 있습니다.
- configure() 메서드는 async/await를 사용할 수 있습니다. (비동기 작업 가능)
2) 함수형 미들웨어 작성 방법 및 적용 방법
클래스가 아닌 함수로도 미들웨어를 정의할 수 있습니다.
import { Request, Response, NextFunction } from 'express';
export function logger(req: Request, res: Response, next: NextFunction) {
console.log(`Request...`);
next();
};
사용법은 동일합니다:
consumer
.apply(logger)
.forRoutes(CatsController);
3) 고급 기능
- 여러 미들웨어 적용: apply() 메서드에 여러 미들웨어를 나열하여 순차적으로 적용할 수 있습니다.
apply()에 여러 미들웨어를 나열하면, 순서대로 실행됩니다.
consumer.apply(cors(), helmet(), logger).forRoutes(CatsController);
- 특정 경로 제외: exclude() 메서드를 사용하여 특정 경로에서 미들웨어를 제외할 수 있습니다.
exclude() 메서드를 사용하면 특정 경로나 메서드에서 미들웨어를 제외할 수 있습니다.
consumer
.apply(LoggerMiddleware)
.exclude(
{ path: 'cats', method: RequestMethod.GET },
{ path: 'cats', method: RequestMethod.POST },
'cats/{*splat}',
)
.forRoutes(CatsController);
위 예시에서는 CatsController의 모든 라우트 중 GET, POST, cats/{*splat} 경로는 LoggerMiddleware가 적용되지 않습니다.
Hint: exclude()도 와일드카드 경로를 지원합니다(path-to-regexp 사용).
- 와일드카드 경로: forRoutes() 메서드에서 와일드카드를 사용하여 경로 패턴을 지정할 수 있습니다.
forRoutes()에서 패턴 기반 경로도 지원합니다. 예를 들어, 'abcd/*splat'은 abcd/로 시작하는 모든 경로에 미들웨어를 적용합니다.
forRoutes({
path: 'abcd/*splat',
method: RequestMethod.ALL,
});
'splat'은 단순히 와일드카드 파라미터 이름일 뿐, 아무 이름이나 사용 가능합니다. 'abcd/'는 abcd/1, abcd/abc 등 abcd/로 시작하는 모든 경로에 매칭됩니다. abcd/만 매칭하려면 'abcd/{splat}'처럼 중괄호로 감싸야 합니다.
글로벌 미들웨어(Global Middleware)
모든 라우트에 미들웨어를 적용하려면, main.ts에서 app.use()를 사용합니다.
const app = await NestFactory.create(AppModule);
app.use(logger);
await app.listen(process.env.PORT ?? 3000);
Hint: app.use()로 등록한 글로벌 미들웨어는 DI 컨테이너를 사용할 수 없습니다. DI가 필요한 경우 forRoutes('*')로 등록하세요.
이처럼 NestJS의 미들웨어 시스템은 다양한 경로, 메서드, 컨트롤러 단위로 유연하게 미들웨어를 적용/제외할 수 있으며, 함수형/클래스형, 글로벌/로컬 등 다양한 패턴을 지원합니다. 공식문서의 예제와 옵션을 적극적으로 활용하면, 복잡한 요구사항도 손쉽게 구현할 수 있습니다.
1.3. 특징 및 주의점
- 미들웨어는 DI 컨테이너에 등록할 수 있으므로, 서비스 주입이 가능합니다.
- 미들웨어는 라우트 핸들러보다 먼저 실행됩니다.
- 미들웨어에서 응답을 끝내면(
res.send()
등) 이후 핸들러가 실행되지 않습니다.
1.4. Spring과의 비교
- Spring의
Filter
와 유사합니다. - Spring Filter는 서블릿 레벨에서 동작, Nest 미들웨어는 Express 레벨에서 동작합니다.
2. 가드(Guard)
2.1. 개념 및 역할
가드는 요청이 라우트 핸들러에 도달하기 전에 실행되어, 접근 허용 여부(인증/인가 등)를 결정합니다.
즉, 인증(Authentication), 권한(Authorization) 체크에 주로 사용됩니다.
가드는 다음과 같은 상황에서 주로 사용됩니다:
- 인증: 사용자가 로그인되어 있는지 확인
- 권한: 사용자가 특정 리소스에 접근할 권한이 있는지 확인
- 역할 기반 접근 제어: 사용자의 역할에 따른 접근 제어
- API 키 검증: API 요청의 유효성 검증
- 요청 제한: 특정 조건에 따른 요청 차단
2.2. 기본 사용법
1) 가드 정의
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
// ExecutionContext는 현재 실행 컨텍스트에 대한 정보를 제공
const request = context.switchToHttp().getRequest();
// 인증 로직 구현
return this.validateRequest(request);
}
private validateRequest(request: any): boolean {
// 실제 인증 로직 구현
return true; // 또는 false
}
}
2) 가드 적용 방법
- 전역 적용
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalGuards(new AuthGuard());
await app.listen(3000);
}
- 컨트롤러 레벨 적용
@Controller('cats')
@UseGuards(AuthGuard)
export class CatsController {
@Get()
findAll() {
return 'This action returns all cats';
}
}
- 메서드 레벨 적용
@Controller('cats')
export class CatsController {
@Get()
@UseGuards(AuthGuard)
findAll() {
return 'This action returns all cats';
}
}
2.3. 고급 사용법
1) 역할 기반 가드
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
// 역할 데코레이터 정의
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 메타데이터에서 역할 정보 추출
const requiredRoles = this.reflector.get<string[]>('roles', context.getHandler());
if (!requiredRoles) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// 사용자의 역할과 요구되는 역할 비교
return requiredRoles.some(role => user.roles?.includes(role));
}
}
2) JWT 인증 가드
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException();
}
try {
// JWT 토큰 검증
const payload = await this.jwtService.verifyAsync(token);
request['user'] = payload;
return true;
} catch {
throw new UnauthorizedException();
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
3) API 키 가드
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class ApiKeyGuard implements CanActivate {
constructor(private configService: ConfigService) {}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const apiKey = request.headers['x-api-key'];
if (!apiKey) {
throw new UnauthorizedException('API key is missing');
}
// API 키 검증
const isValid = this.validateApiKey(apiKey);
if (!isValid) {
throw new UnauthorizedException('Invalid API key');
}
return true;
}
private validateApiKey(apiKey: string): boolean {
const validApiKey = this.configService.get<string>('API_KEY');
return apiKey === validApiKey;
}
}
2.4. Spring과의 비교
1) 공통점
- 인증/인가 로직의 분리
- 선언적 프로그래밍 지원
- DI 컨테이너와의 통합
2) 차이점
- 구현 방식
// NestJS - 가드 기반
@Injectable()
export class AuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
// 인증 로직
return true;
}
}
// Spring - 인터셉터 기반
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
// 인증 로직
return true;
}
}
- 적용 범위
// NestJS - 더 세밀한 적용 가능
@Controller('cats')
export class CatsController {
@Get()
@UseGuards(AuthGuard, RolesGuard)
findAll() {
// 여러 가드 조합 가능
}
}
// Spring - 주로 메서드 레벨에서 적용
@RestController
public class CatsController {
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
public List<Cat> findAll() {
// 주로 단일 보안 어노테이션 사용
}
}
- 예외 처리
// NestJS - 가드 내부에서 예외 처리
@Injectable()
export class CustomGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
if (!this.isValid()) {
throw new UnauthorizedException('Invalid request');
}
return true;
}
}
// Spring - 별도의 예외 처리기 필요
@ControllerAdvice
public class SecurityExceptionHandler {
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<?> handleAccessDeniedException(
AccessDeniedException ex) {
// 예외 처리 로직
}
}
2.5. NestJS 가드의 특성
- 컨텍스트 활용
@Injectable()
export class ContextAwareGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
// HTTP 컨텍스트
if (context.getType() === 'http') {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
// HTTP 관련 로직
}
// WebSocket 컨텍스트
if (context.getType() === 'ws') {
const client = context.switchToWs().getClient();
const data = context.switchToWs().getData();
// WebSocket 관련 로직
}
return true;
}
}
- 메타데이터 활용
@Injectable()
export class MetadataGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
// 메타데이터에서 필요한 정보 추출
const roles = this.reflector.get<string[]>('roles', context.getHandler());
const isPublic = this.reflector.get<boolean>('isPublic', context.getHandler());
// public 엔드포인트는 검증 건너뛰기
if (isPublic) {
return true;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// 역할 기반 접근 제어
return roles.some(role => user.roles?.includes(role));
}
}
2.6. 모범 사례
- 가드 조합
@Injectable()
export class CombinedGuard implements CanActivate {
constructor(
private readonly authGuard: AuthGuard,
private readonly rolesGuard: RolesGuard,
private readonly apiKeyGuard: ApiKeyGuard,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 순차적으로 가드 적용
const isAuthenticated = await this.authGuard.canActivate(context);
if (!isAuthenticated) {
return false;
}
const hasRole = await this.rolesGuard.canActivate(context);
if (!hasRole) {
return false;
}
return this.apiKeyGuard.canActivate(context);
}
}
- 조건부 가드
@Injectable()
export class ConditionalGuard implements CanActivate {
constructor(
private readonly condition: (context: ExecutionContext) => boolean,
private readonly guard: CanActivate,
) {}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
if (this.condition(context)) {
return this.guard.canActivate(context);
}
return true;
}
}
- 캐싱 가드
@Injectable()
export class CachingGuard implements CanActivate {
private cache = new Map<string, boolean>();
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const cacheKey = this.generateCacheKey(request);
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
const result = this.validateRequest(request);
this.cache.set(cacheKey, result);
return result;
}
private generateCacheKey(request: any): string {
return `${request.method}:${request.url}:${request.headers.authorization}`;
}
private validateRequest(request: any): boolean {
// 요청 검증 로직
return true;
}
}
- 로깅 가드
@Injectable()
export class LoggingGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
console.log('Guard Input:', {
method: request.method,
url: request.url,
headers: request.headers,
user: request.user,
});
const result = this.validateRequest(request);
console.log('Guard Output:', {
allowed: result,
timestamp: new Date().toISOString(),
});
return result;
}
private validateRequest(request: any): boolean {
// 요청 검증 로직
return true;
}
}
- 에러 처리 가드
@Injectable()
export class ErrorHandlingGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
try {
return this.validateRequest(context);
} catch (error) {
// 에러 로깅
console.error('Guard Error:', error);
// 에러 타입에 따른 처리
if (error instanceof UnauthorizedException) {
throw new UnauthorizedException('인증이 필요합니다.');
}
if (error instanceof ForbiddenException) {
throw new ForbiddenException('접근 권한이 없습니다.');
}
// 기타 예상치 못한 에러
throw new InternalServerErrorException('서버 에러가 발생했습니다.');
}
}
private validateRequest(context: ExecutionContext): boolean {
// 요청 검증 로직
return true;
}
}
- 성능 모니터링 가드
@Injectable()
export class PerformanceGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const start = performance.now();
const result = this.validateRequest(context);
const end = performance.now();
const duration = end - start;
// 성능 메트릭 기록
this.recordMetrics(context, duration);
return result;
}
private validateRequest(context: ExecutionContext): boolean {
// 요청 검증 로직
return true;
}
private recordMetrics(context: ExecutionContext, duration: number): void {
const request = context.switchToHttp().getRequest();
console.log('Performance Metrics:', {
endpoint: request.url,
method: request.method,
duration: `${duration.toFixed(2)}ms`,
timestamp: new Date().toISOString(),
});
}
}
3. 인터셉터(Interceptor)
3.1. 개념 및 역할
인터셉터는 요청-응답 흐름을 가로채서 추가 작업(로깅, 응답 변환, 캐싱, 예외 변환 등)을 수행합니다.
AOP(관점 지향 프로그래밍)와 유사하게, 메서드 실행 전/후, 예외 발생 시, 응답 반환 직전 등 다양한 시점에 개입할 수 있습니다.
인터셉터는 다음과 같은 기능을 제공합니다.
- 메서드 실행 전/후 추가 로직 바인딩
- 함수에서 반환된 결과 변환
- 함수에서 던져진 예외 변환
- 기본 함수 동작 확장
- 특정 조건에 따라 함수 동작 완전 재정의
3.2. 기본 사용법
1) 인터셉터 정의
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// 요청 처리 전 로깅
console.log('Before...');
const now = Date.now();
// next.handle()을 호출하여 다음 인터셉터나 컨트롤러로 제어를 전달
return next
.handle()
.pipe(
// tap 연산자를 사용하여 응답 처리 후 로깅
tap(() => console.log(`After... ${Date.now() - now}ms`)),
);
}
}
2) 인터셉터 적용 방법
- 전역 적용
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor());
await app.listen(3000);
}
- 컨트롤러 레벨 적용
@Controller('cats')
@UseInterceptors(LoggingInterceptor)
export class CatsController {
@Get()
findAll() {
return 'This action returns all cats';
}
}
- 메서드 레벨 적용
@Controller('cats')
export class CatsController {
@Get()
@UseInterceptors(LoggingInterceptor)
findAll() {
return 'This action returns all cats';
}
}
3.3. 고급 사용법
1) 응답 데이터 변환
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response<T> {
data: T;
timestamp: string;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(
map(data => ({
data,
timestamp: new Date().toISOString(),
})),
);
}
}
2) 예외 처리
import { Injectable, NestInterceptor, ExecutionContext, CallHandler, HttpException } from '@nestjs/common';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Injectable()
export class ErrorsInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
catchError(err => {
if (err instanceof HttpException) {
// HTTP 예외를 커스텀 응답으로 변환
return throwError(() => ({
statusCode: err.getStatus(),
message: err.message,
timestamp: new Date().toISOString(),
}));
}
return throwError(() => err);
}),
);
}
}
3) 캐싱 구현
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class CacheInterceptor implements NestInterceptor {
private cache = new Map<string, any>();
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const cacheKey = request.url;
// 캐시된 응답이 있는지 확인
if (this.cache.has(cacheKey)) {
return of(this.cache.get(cacheKey));
}
// 캐시된 응답이 없으면 요청 처리 후 캐시에 저장
return next.handle().pipe(
tap(response => {
this.cache.set(cacheKey, response);
}),
);
}
}
3.4. Spring AOP와의 비교
1) 공통점
- AOP 개념: 두 프레임워크 모두 관점 지향 프로그래밍의 개념을 따릅니다.
- 횡단 관심사 분리: 비즈니스 로직과 횡단 관심사(로깅, 캐싱 등)를 분리합니다.
- 데코레이터 패턴: 어노테이션/데코레이터를 통한 선언적 프로그래밍을 지원합니다.
2) 차이점
- 실행 시점
// NestJS 인터셉터
@Injectable()
export class TimingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const now = Date.now();
return next.handle().pipe(
tap(() => console.log(`Execution time: ${Date.now() - now}ms`)),
);
}
}
// Spring AOP
@Aspect
@Component
public class TimingAspect {
@Around("execution(* com.example.service.*.*(..))")
public Object timeMethod(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object result = joinPoint.proceed();
long end = System.currentTimeMillis();
System.out.println("Execution time: " + (end - start) + "ms");
return result;
}
}
- 비동기 처리
// NestJS - RxJS Observable 기반
@Injectable()
export class AsyncInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
// 비동기 스트림 처리
map(data => this.transformData(data)),
catchError(err => this.handleError(err))
);
}
}
// Spring - CompletableFuture 기반
@Aspect
@Component
public class AsyncAspect {
@Around("execution(* com.example.service.*.*(..))")
public CompletableFuture<Object> asyncMethod(ProceedingJoinPoint joinPoint) {
return CompletableFuture.supplyAsync(() -> {
try {
return joinPoint.proceed();
} catch (Throwable e) {
throw new CompletionException(e);
}
});
}
}
- DI 통합
// NestJS - DI 컨테이너와 완벽한 통합
@Injectable()
export class ServiceAwareInterceptor implements NestInterceptor {
constructor(private readonly someService: SomeService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// 서비스 주입 및 사용
return next.handle().pipe(
map(data => this.someService.process(data))
);
}
}
// Spring - DI 컨테이너와 통합
@Aspect
@Component
public class ServiceAwareAspect {
@Autowired
private SomeService someService;
@Around("execution(* com.example.service.*.*(..))")
public Object serviceAwareMethod(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
return someService.process(result);
}
}
3.5. NestJS 인터셉터의 특성
- RxJS 기반
- Observable 스트림을 통한 비동기 처리
- 강력한 스트림 조작 연산자 제공
- 메모리 누수 방지를 위한 자동 구독 해제
- 실행 순서
// 인터셉터 체인
@Injectable()
export class ChainInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log('First interceptor');
return next.handle().pipe(
tap(() => console.log('First interceptor after')),
);
}
}
@Injectable()
export class SecondInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log('Second interceptor');
return next.handle().pipe(
tap(() => console.log('Second interceptor after')),
);
}
}
// 적용 순서
@UseInterceptors(ChainInterceptor, SecondInterceptor)
@Get()
findAll() {
return 'This action returns all cats';
}
- 컨텍스트 활용
@Injectable()
export class ContextAwareInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
// 요청 컨텍스트 활용
const user = request.user;
const method = request.method;
return next.handle().pipe(
tap(() => {
// 응답 컨텍스트 활용
response.setHeader('X-Custom-Header', 'value');
}),
);
}
}
- 메타데이터 활용
@Injectable()
export class MetadataInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const handler = context.getHandler();
const metadata = Reflect.getMetadata('custom:metadata', handler);
return next.handle().pipe(
map(data => ({
...data,
metadata,
})),
);
}
}
3.6. 모범 사례
- 인터셉터 조합
@Injectable()
export class CombinedInterceptor implements NestInterceptor {
constructor(
private readonly loggingInterceptor: LoggingInterceptor,
private readonly transformInterceptor: TransformInterceptor,
private readonly cacheInterceptor: CacheInterceptor,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return this.loggingInterceptor
.intercept(context, {
handle: () => this.transformInterceptor
.intercept(context, {
handle: () => this.cacheInterceptor
.intercept(context, next),
}),
});
}
}
- 조건부 인터셉터
@Injectable()
export class ConditionalInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
// 특정 조건에 따라 인터셉터 적용
if (request.headers['x-skip-interceptor']) {
return next.handle();
}
return next.handle().pipe(
map(data => this.transformData(data)),
);
}
}
4. 파이프(Pipe)
4.1. 개념 및 역할
파이프는 요청 데이터의 유효성 검사(Validation)와 변환(Transformation) 을 담당합니다.
컨트롤러 핸들러에 전달되기 전에 데이터를 가공하거나, 유효하지 않은 데이터는 예외를 발생시켜 요청을 차단할 수 있습니다.
파이프는 다음과 같은 상황에서 주로 사용됩니다.
- 데이터 변환: 문자열을 정수로 변환
- 데이터 유효성 검사: 입력값이 유효한지 검증
- 기본값 설정: 누락된 파라미터에 기본값 할당
- 데이터 정제: 불필요한 공백 제거, 특수문자 처리 등
4.2. 기본 사용법
1) 파이프 정의
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
@Injectable()
export class ParseIntPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
// value: 변환할 입력값
// metadata: 파이프가 적용된 파라미터의 메타데이터
const val = parseInt(value, 10);
if (isNaN(val)) {
throw new BadRequestException('Validation failed');
}
return val;
}
}
2) 파이프 적용 방법
- 전역 적용
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
- 컨트롤러 레벨 적용
@Controller('cats')
@UsePipes(ValidationPipe)
export class CatsController {
@Get()
findAll() {
return 'This action returns all cats';
}
}
- 메서드 레벨 적용
@Controller('cats')
export class CatsController {
@Get()
@UsePipes(ValidationPipe)
findAll() {
return 'This action returns all cats';
}
}
- 파라미터 레벨 적용
@Controller('cats')
export class CatsController {
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// id는 number 타입으로 변환됨
return `This action returns cat #${id}`;
}
}
4.3. 고급 사용법
1) 커스텀 유효성 검사 파이프
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
@Injectable()
export class CustomValidationPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
// 메타데이터에서 타입 정보 추출
const { metatype } = metadata;
// 타입이 없거나 기본 타입인 경우 검증 건너뛰기
if (!metatype || !this.toValidate(metatype)) {
return value;
}
// 값이 비어있는 경우
if (!value) {
throw new BadRequestException('Value is required');
}
// 타입 검증
if (typeof value !== typeof new metatype()) {
throw new BadRequestException('Type validation failed');
}
return value;
}
private toValidate(metatype: Function): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
}
2) DTO 기반 유효성 검사
// create-cat.dto.ts
import { IsString, IsInt, Min, Max } from 'class-validator';
export class CreateCatDto {
@IsString()
name: string;
@IsInt()
@Min(0)
@Max(20)
age: number;
}
// cats.controller.ts
@Controller('cats')
export class CatsController {
@Post()
@UsePipes(new ValidationPipe({ transform: true }))
create(@Body() createCatDto: CreateCatDto) {
return 'This action adds a new cat';
}
}
3) 비동기 파이프
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class AsyncValidationPipe implements PipeTransform {
async transform(value: any, metadata: ArgumentMetadata): Promise<any> {
try {
// 비동기 검증 로직
const isValid = await this.validateAsync(value);
if (!isValid) {
throw new BadRequestException('Validation failed');
}
return value;
} catch (error) {
throw new BadRequestException('Validation failed');
}
}
private async validateAsync(value: any): Promise<boolean> {
// 비동기 검증 로직 구현
return new Promise(resolve => {
setTimeout(() => {
resolve(true);
}, 1000);
});
}
}
4.4. Spring과의 비교
1) 공통점
- 데이터 검증과 변환의 책임 분리
- 선언적 프로그래밍 지원
- DI 컨테이너와의 통합
2) 차이점
- 구현 방식
// NestJS - 파이프 기반
@Injectable()
export class ValidationPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
// 검증 로직
return value;
}
}
// Spring - Validator 기반
@Component
public class CustomValidator implements Validator {
@Override
public boolean supports(Class<?> clazz) {
return User.class.equals(clazz);
}
@Override
public void validate(Object target, Errors errors) {
// 검증 로직
}
}
- 적용 범위
// NestJS - 더 세밀한 적용 가능
@Controller('cats')
export class CatsController {
@Get(':id')
findOne(
@Param('id', ParseIntPipe) id: number,
@Query('age', ParseIntPipe) age: number,
@Body(ValidationPipe) createCatDto: CreateCatDto
) {
// 각 파라미터별로 다른 파이프 적용 가능
}
}
// Spring - 주로 DTO 레벨에서 적용
@RestController
public class CatsController {
@PostMapping
public ResponseEntity<?> create(@Valid @RequestBody CreateCatDto dto) {
// DTO 레벨에서 검증
}
}
- 예외 처리
// NestJS - 파이프 내부에서 예외 처리
@Injectable()
export class CustomPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
if (!value) {
throw new BadRequestException('Value is required');
}
return value;
}
}
// Spring - 별도의 예외 처리기 필요
@ControllerAdvice
public class ValidationExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidationExceptions(
MethodArgumentNotValidException ex) {
// 예외 처리 로직
}
}
4.5. NestJS 파이프의 특성
- 타입 변환
@Injectable()
export class TypeConversionPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
const { type } = metadata;
switch (type) {
case 'param':
return parseInt(value, 10);
case 'query':
return value.toLowerCase();
case 'body':
return JSON.parse(value);
default:
return value;
}
}
}
- 데이터 정제
@Injectable()
export class SanitizationPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
if (typeof value === 'string') {
// 공백 제거
value = value.trim();
// 특수문자 이스케이프
value = this.escapeHtml(value);
}
return value;
}
private escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
}
- 기본값 설정
@Injectable()
export class DefaultValuePipe implements PipeTransform {
constructor(private defaultValue: any) {}
transform(value: any, metadata: ArgumentMetadata) {
if (value === undefined || value === null) {
return this.defaultValue;
}
return value;
}
}
4.6. 모범 사례
- 파이프 조합
@Injectable()
export class CombinedPipe implements PipeTransform {
constructor(
private readonly validationPipe: ValidationPipe,
private readonly sanitizationPipe: SanitizationPipe,
) {}
transform(value: any, metadata: ArgumentMetadata) {
// 순차적으로 파이프 적용
const sanitized = this.sanitizationPipe.transform(value, metadata);
return this.validationPipe.transform(sanitized, metadata);
}
}
- 조건부 파이프
@Injectable()
export class ConditionalPipe implements PipeTransform {
constructor(
private readonly condition: (value: any) => boolean,
private readonly pipe: PipeTransform,
) {}
transform(value: any, metadata: ArgumentMetadata) {
if (this.condition(value)) {
return this.pipe.transform(value, metadata);
}
return value;
}
}
- 캐싱 파이프
@Injectable()
export class CachingPipe implements PipeTransform {
private cache = new Map<string, any>();
transform(value: any, metadata: ArgumentMetadata) {
const key = JSON.stringify(value);
if (this.cache.has(key)) {
return this.cache.get(key);
}
const result = this.processValue(value);
this.cache.set(key, result);
return result;
}
private processValue(value: any): any {
// 값 처리 로직
return value;
}
}
- 로깅 파이프
@Injectable()
export class LoggingPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
console.log(`Pipe Input:`, {
value,
type: metadata.type,
metatype: metadata.metatype?.name,
});
const result = this.processValue(value);
console.log(`Pipe Output:`, {
input: value,
output: result,
});
return result;
}
private processValue(value: any): any {
// 값 처리 로직
return value;
}
}
5. 종합 비교 및 활용 전략
NestJS 개념 | Spring 대응 개념 | 주요 역할/특징 |
---|---|---|
Middleware | Filter | 요청/응답 전후 처리, 로깅, 인증 등 |
Guard | Security Filter, AOP | 인증/인가, 접근 제어 |
Interceptor | HandlerInterceptor, AOP | 로깅, 응답 변환, 캐싱, 예외 변환 등 |
Pipe | Validator, @InitBinder | 유효성 검사, 데이터 변환 |
- 미들웨어: 요청 흐름의 가장 앞단에서 공통 로직 처리(로깅, 인증 등)
- 가드: 인증/인가 등 접근 제어(핸들러 진입 전)
- 인터셉터: 요청/응답 흐름 가로채기(로깅, 응답 변환, 캐싱 등)
- 파이프: 데이터 유효성 검사 및 변환(핸들러 파라미터 직전)
NestJS는 이 네 가지 계층을 통해 관심사의 분리(Separation of Concerns)를 극대화하며,
각 계층별로 DI, 데코레이터, 모듈 시스템과 결합해 대규모 애플리케이션에서도
유지보수성과 확장성을 보장합니다.
6. 공식 문서 참고
공식 문서의 예제와 설명을 직접 따라해보면, 각 개념의 동작 원리와 실전 적용법을
더 깊이 이해할 수 있습니다.
특히, Spring 경험자라면 각 계층의 대응 개념을 비교하며 학습하면 전환이 훨씬 쉽습니다.