NestJS Custom Parameter Decorator
NestJS 컨트롤러 메서드를 작성하다 보면 @Req() 데코레이터로 request 객체를 통째로 받아서 필요한 값을 꺼내는 코드가 반복된다.
@Get('profile')
getProfile(@Req() req: Request) {
const user = req.user;
const lang = req.headers['accept-language'];
// ...
}
이 패턴의 문제는 세 가지다. 첫째, 컨트롤러가 Express/Fastify의 request 객체 구조에 직접 의존하게 된다. 플랫폼을 바꾸면 모든 컨트롤러를 수정해야 한다. 둘째, req.user가 어떤 타입인지 TypeScript가 알 수 없어서 타입 안전성이 깨진다. 셋째, 같은 추출 로직이 여러 컨트롤러에 흩어지면서 중복이 발생한다.
NestJS의 createParamDecorator는 이 문제를 해결한다. request 객체에서 값을 꺼내는 로직을 데코레이터로 캡슐화해서, 컨트롤러 메서드의 파라미터에 선언적으로 주입할 수 있게 만든다. @Body(), @Param(), @Query() 같은 빌트인 데코레이터와 동일한 메커니즘이다.
createParamDecorator 기본 구조
createParamDecorator는 팩토리 함수를 인자로 받아서 파라미터 데코레이터를 반환한다.
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);
두 개의 인자가 팩토리 함수에 전달된다:
data: 데코레이터를 사용할 때 전달하는 인자.@CurrentUser('email')이면data는'email'이다.ctx:ExecutionContext객체. 현재 실행 컨텍스트(HTTP, WebSocket, gRPC 등)에 접근할 수 있다.
이렇게 만든 데코레이터는 컨트롤러에서 바로 사용한다:
@Get('profile')
getProfile(@CurrentUser() user: User) {
return user;
}
@Req()를 쓸 때보다 의도가 명확하고, request 객체의 내부 구조에 대한 의존이 데코레이터 안에 격리된다.
ExecutionContext 이해하기
ExecutionContext는 NestJS의 실행 파이프라인 전체에서 사용되는 핵심 객체다. Guards, Interceptors, Pipes, 그리고 커스텀 데코레이터 모두 이 객체를 통해 현재 요청의 컨텍스트에 접근한다.
export interface ExecutionContext extends ArgumentsHost {
getClass<T = any>(): Type<T>;
getHandler(): Function;
}
ExecutionContext는 ArgumentsHost를 확장한다. ArgumentsHost는 프로토콜별 요청/응답 객체에 접근하는 메서드를 제공한다:
// HTTP 컨텍스트
const request = ctx.switchToHttp().getRequest();
const response = ctx.switchToHttp().getResponse();
// WebSocket 컨텍스트
const client = ctx.switchToWs().getClient();
const data = ctx.switchToWs().getData();
// gRPC(마이크로서비스) 컨텍스트
const data = ctx.switchToRpc().getData();
const context = ctx.switchToRpc().getContext();
getClass()는 현재 요청을 처리하는 컨트롤러 클래스를, getHandler()는 라우트 핸들러 메서드를 반환한다. 이 두 메서드는 주로 Guard나 Interceptor에서 리플렉션 메타데이터를 읽을 때 사용하지만, 커스텀 데코레이터에서도 활용할 수 있다.
이 구조 덕분에 하나의 커스텀 데코레이터가 HTTP뿐 아니라 WebSocket이나 gRPC에서도 동작하도록 만들 수 있다:
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const type = ctx.getType(); // 'http' | 'ws' | 'rpc'
if (type === 'http') {
return ctx.switchToHttp().getRequest().user;
}
if (type === 'ws') {
return ctx.switchToWs().getClient().user;
}
},
);
data 인자 활용하기
data 인자를 사용하면 하나의 데코레이터로 다양한 값을 추출할 수 있다. 가장 대표적인 패턴은 객체에서 특정 프로퍼티만 꺼내는 것이다.
export const CurrentUser = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
},
);
이제 전체 user 객체를 받을 수도 있고, 특정 필드만 받을 수도 있다:
@Get('profile')
getProfile(
@CurrentUser() user: User, // 전체 user 객체
@CurrentUser('email') email: string, // user.email만
@CurrentUser('id') userId: number, // user.id만
) {
// ...
}
이 패턴은 NestJS 빌트인 데코레이터와 동일한 방식이다. @Body('name')이 req.body.name을 반환하는 것과 같은 원리다.
data에 문자열 외에 객체를 전달할 수도 있다:
interface HeaderOptions {
name: string;
required?: boolean;
}
export const Header = createParamDecorator(
(data: HeaderOptions, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const value = request.headers[data.name.toLowerCase()];
if (data.required && !value) {
throw new BadRequestException(`Header '${data.name}' is required`);
}
return value;
},
);
// 사용
@Get()
handler(@Header({ name: 'X-Api-Key', required: true }) apiKey: string) {
// ...
}
Pipes와 함께 사용하기
커스텀 파라미터 데코레이터도 빌트인 데코레이터처럼 Pipe를 적용할 수 있다. 이건 중요한 기능이다. 데코레이터가 반환한 값에 대해 검증이나 변환을 수행할 수 있기 때문이다.
인라인 Pipe 적용
데코레이터를 사용할 때 두 번째 인자로 Pipe를 전달한다:
@Get('profile')
getProfile(@CurrentUser(new ValidationPipe()) user: User) {
// user가 ValidationPipe를 거쳐서 검증된 상태
}
@Body()에 ValidationPipe를 적용하는 것과 동일한 방식이다.
데코레이터 정의에 Pipe 내장
매번 Pipe를 인라인으로 전달하기 번거롭다면, 데코레이터 정의 자체에 Pipe를 포함시킬 수 있다. applyDecorators 대신 더 간단한 방법이 있다:
import { createParamDecorator, ExecutionContext, ParseIntPipe } from '@nestjs/common';
// Pipe가 내장된 커스텀 데코레이터
export const UserId = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user?.id;
},
);
// 사용할 때 Pipe를 함께 전달
@Get()
handler(@UserId(ParseIntPipe) id: number) {
// id는 반드시 number
}
혹은 Pipe 적용까지 래핑한 함수를 만들 수도 있다:
export function ValidatedCurrentUser() {
return applyDecorators(
// 여러 데코레이터를 합성할 때 유용
);
}
하지만 파라미터 데코레이터의 Pipe 적용은 보통 사용하는 쪽에서 명시적으로 하는 게 더 낫다. 어떤 변환이 일어나는지 호출부에서 바로 보이기 때문이다.
실전 패턴
1. 인증된 사용자 추출 (@CurrentUser)
가장 흔한 패턴이다. JWT Guard가 request.user에 사용자 정보를 넣어주면, 커스텀 데코레이터가 이를 타입 안전하게 추출한다.
// decorators/current-user.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { User } from '../entities/user.entity';
export const CurrentUser = createParamDecorator(
(data: keyof User | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user: User = request.user;
if (!user) {
return null;
}
return data ? user[data] : user;
},
);
data의 타입을 keyof User로 지정하면 존재하지 않는 프로퍼티 이름을 전달했을 때 TypeScript가 잡아준다:
@CurrentUser('email') email: string // ✅ 정상
@CurrentUser('foo') foo: string // ❌ 타입 에러: 'foo'는 keyof User가 아님
2. 요청 언어/로케일 추출
다국어 API에서 클라이언트가 보낸 Accept-Language 헤더를 파싱하는 패턴:
export const Lang = createParamDecorator(
(fallback: string = 'ko', ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
const acceptLang = request.headers['accept-language'];
if (!acceptLang) return fallback;
// "ko-KR,ko;q=0.9,en;q=0.8" → "ko"
const primary = acceptLang.split(',')[0].split('-')[0].trim();
return primary || fallback;
},
);
// 사용
@Get('greeting')
greet(@Lang() lang: string) {
// lang = 'ko', 'en', 등
}
@Get('greeting')
greetWithFallback(@Lang('en') lang: string) {
// Accept-Language 없으면 'en' 반환
}
3. 페이지네이션 파라미터 추출
여러 쿼리 파라미터를 하나의 객체로 묶어서 추출하는 패턴:
interface PaginationParams {
page: number;
limit: number;
offset: number;
}
export const Pagination = createParamDecorator(
(defaults: Partial<PaginationParams> | undefined, ctx: ExecutionContext): PaginationParams => {
const request = ctx.switchToHttp().getRequest();
const query = request.query;
const page = Math.max(1, parseInt(query.page, 10) || defaults?.page || 1);
const limit = Math.min(
100,
Math.max(1, parseInt(query.limit, 10) || defaults?.limit || 20),
);
const offset = (page - 1) * limit;
return { page, limit, offset };
},
);
// 사용
@Get('posts')
findAll(@Pagination() pagination: PaginationParams) {
return this.postsService.findAll(pagination);
}
@Get('comments')
findComments(@Pagination({ limit: 50 }) pagination: PaginationParams) {
// 기본 limit이 50
}
이 패턴의 장점은 페이지네이션 로직이 데코레이터 안에 격리된다는 것이다. page가 0 이하일 때의 처리, limit 상한 제한, offset 계산 — 이런 로직이 모든 컨트롤러에 흩어지는 대신 한 곳에 모인다.
4. 클라이언트 IP 추출
프록시 환경에서 실제 클라이언트 IP를 정확하게 가져오는 건 생각보다 까다롭다:
export const ClientIp = createParamDecorator(
(data: unknown, ctx: ExecutionContext): string => {
const request = ctx.switchToHttp().getRequest();
// 프록시/로드밸런서 뒤에 있을 때
const forwarded = request.headers['x-forwarded-for'];
if (forwarded) {
// "client, proxy1, proxy2" 형태에서 첫 번째가 실제 클라이언트
return (typeof forwarded === 'string' ? forwarded : forwarded[0])
.split(',')[0]
.trim();
}
// X-Real-IP (nginx 등)
const realIp = request.headers['x-real-ip'];
if (realIp) {
return typeof realIp === 'string' ? realIp : realIp[0];
}
// 직접 연결
return request.ip || request.socket?.remoteAddress || 'unknown';
},
);
5. 커스텀 데코레이터 합성 (applyDecorators)
메서드 레벨 데코레이터와 파라미터 데코레이터를 구분해야 한다. createParamDecorator는 파라미터 데코레이터만 만든다. 메서드 레벨에서 여러 데코레이터를 합성하려면 applyDecorators를 사용한다:
import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiUnauthorizedResponse } from '@nestjs/swagger';
export function Auth(...roles: string[]) {
return applyDecorators(
SetMetadata('roles', roles),
UseGuards(JwtAuthGuard, RolesGuard),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'Unauthorized' }),
);
}
// 사용 — 4개의 데코레이터가 1줄로
@Auth('admin')
@Get('admin/users')
getUsers() {
// ...
}
applyDecorators는 클래스/메서드 데코레이터를 합성하는 것이고, createParamDecorator는 파라미터에 값을 주입하는 것이다. 용도가 다르니 혼동하지 않아야 한다.
테스트
커스텀 파라미터 데코레이터는 결국 함수이므로 단위 테스트가 가능하다. 핵심은 ExecutionContext를 모킹하는 것이다.
import { ExecutionContext } from '@nestjs/common';
describe('CurrentUser', () => {
const mockUser = { id: 1, email: 'test@test.com', name: 'Test' };
function createMockContext(user: any): ExecutionContext {
return {
switchToHttp: () => ({
getRequest: () => ({ user }),
}),
} as ExecutionContext;
}
it('should return the full user object', () => {
const ctx = createMockContext(mockUser);
// createParamDecorator 내부 팩토리 함수를 직접 호출
// NestJS 내부적으로는 ROUTE_ARGS_METADATA에서 팩토리를 꺼내서 호출함
const factory = (data: any, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
};
expect(factory(undefined, ctx)).toEqual(mockUser);
});
it('should return a specific property', () => {
const ctx = createMockContext(mockUser);
const factory = (data: any, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
return data ? user?.[data] : user;
};
expect(factory('email', ctx)).toBe('test@test.com');
});
it('should return null when no user', () => {
const ctx = createMockContext(null);
const factory = (data: any, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
if (!user) return null;
return data ? user[data] : user;
};
expect(factory(undefined, ctx)).toBeNull();
});
});
실제 프로젝트에서는 팩토리 함수를 별도로 export해서 테스트하는 것이 더 깔끔하다:
// decorators/current-user.decorator.ts
export const currentUserFactory = (
data: string | undefined,
ctx: ExecutionContext,
) => {
const request = ctx.switchToHttp().getRequest();
const user = request.user;
if (!user) return null;
return data ? user[data] : user;
};
export const CurrentUser = createParamDecorator(currentUserFactory);
// 테스트에서
import { currentUserFactory } from './current-user.decorator';
it('should return user', () => {
expect(currentUserFactory(undefined, mockCtx)).toEqual(mockUser);
});
빌트인 데코레이터와의 관계
NestJS의 빌트인 파라미터 데코레이터들(@Body, @Param, @Query, @Headers 등)도 내부적으로 같은 메커니즘을 사용한다. @Body('name')은 개념적으로 다음과 동일하다:
export const Body = createParamDecorator(
(data: string | undefined, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return data ? request.body?.[data] : request.body;
},
);
실제 구현은 ROUTE_ARGS_METADATA라는 메타데이터 키에 팩토리 함수를 등록하고, 라우터가 요청을 처리할 때 이 메타데이터를 읽어서 팩토리를 실행하는 방식이다. 커스텀 데코레이터든 빌트인이든 같은 파이프라인을 탄다.
이 구조를 이해하면 NestJS의 데코레이터가 "마법"이 아니라 "메타데이터 등록 + 런타임 호출"이라는 단순한 패턴임을 알 수 있다.
정리
- createParamDecorator로 request 객체 접근 로직을 캡슐화하면 플랫폼 의존성이 데코레이터 안에 격리되고, 컨트롤러는 선언적 파라미터 주입만 사용한다
- data 인자로 프로퍼티 추출이나 기본값 설정을 유연하게 처리할 수 있고, Pipe와 조합하면 검증/변환까지 파이프라인에 태울 수 있다
- DI 컨테이너 외부에서 실행되므로 서비스 주입이 불가능하고, Guard에서 request에 데이터를 붙인 뒤 데코레이터에서 꺼내는 패턴을 사용해야 한다
주의사항
request 객체 의존
커스텀 데코레이터가 Express 전용 속성(예: req.ip, req.cookies)에 의존하면 Fastify로 전환할 때 깨진다. 가능하면 플랫폼 중립적인 속성을 사용하거나, ctx.getType()으로 분기하는 것이 좋다.
순환 의존
createParamDecorator 팩토리 안에서 NestJS 서비스를 직접 주입할 수 없다. DI 컨테이너 외부에서 실행되기 때문이다. 서비스 로직이 필요하면 Guard나 Interceptor에서 처리하고 결과를 request 객체에 붙이는 방식을 사용해야 한다:
// ❌ 이렇게 하면 안 됨 — DI가 동작하지 않음
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
const userService = ???; // 서비스를 어떻게 가져올 수 없음
return userService.findById(request.userId);
},
);
// ✅ 올바른 방식 — Guard에서 처리 후 데코레이터에서 꺼냄
@Injectable()
export class UserGuard implements CanActivate {
constructor(private userService: UserService) {}
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const request = ctx.switchToHttp().getRequest();
request.user = await this.userService.findById(request.userId);
return true;
}
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext) => {
return ctx.switchToHttp().getRequest().user; // Guard가 넣어준 값
},
);
Swagger 통합
커스텀 데코레이터를 사용하면 Swagger가 해당 파라미터의 타입을 자동으로 감지하지 못한다. @ApiQuery(), @ApiParam() 등으로 명시적으로 문서화해야 한다:
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@Get('posts')
findAll(@Pagination() pagination: PaginationParams) {
// Swagger에서 page, limit 쿼리 파라미터가 표시됨
}
관련 문서
- NestJS Guards - Guard에서 request에 데이터를 붙이는 패턴
- NestJS Pipes and Validation - 파라미터 데코레이터와 Pipe의 연동
- NestJS Controller - 파라미터 데코레이터가 사용되는 컨트롤러 구조