junyeokk
Blog
NestJS·2025. 11. 15

NestJS Custom Parameter Decorator

NestJS 컨트롤러 메서드를 작성하다 보면 @Req() 데코레이터로 request 객체를 통째로 받아서 필요한 값을 꺼내는 코드가 반복된다.

typescript
@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는 팩토리 함수를 인자로 받아서 파라미터 데코레이터를 반환한다.

typescript
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 등)에 접근할 수 있다.

이렇게 만든 데코레이터는 컨트롤러에서 바로 사용한다:

typescript
@Get('profile')
getProfile(@CurrentUser() user: User) {
  return user;
}

@Req()를 쓸 때보다 의도가 명확하고, request 객체의 내부 구조에 대한 의존이 데코레이터 안에 격리된다.


ExecutionContext 이해하기

ExecutionContext는 NestJS의 실행 파이프라인 전체에서 사용되는 핵심 객체다. Guards, Interceptors, Pipes, 그리고 커스텀 데코레이터 모두 이 객체를 통해 현재 요청의 컨텍스트에 접근한다.

typescript
export interface ExecutionContext extends ArgumentsHost {
  getClass<T = any>(): Type<T>;
  getHandler(): Function;
}

ExecutionContextArgumentsHost를 확장한다. ArgumentsHost는 프로토콜별 요청/응답 객체에 접근하는 메서드를 제공한다:

typescript
// 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에서도 동작하도록 만들 수 있다:

typescript
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 인자를 사용하면 하나의 데코레이터로 다양한 값을 추출할 수 있다. 가장 대표적인 패턴은 객체에서 특정 프로퍼티만 꺼내는 것이다.

typescript
export const CurrentUser = createParamDecorator(
  (data: string | undefined, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const user = request.user;
    return data ? user?.[data] : user;
  },
);

이제 전체 user 객체를 받을 수도 있고, 특정 필드만 받을 수도 있다:

typescript
@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에 문자열 외에 객체를 전달할 수도 있다:

typescript
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를 전달한다:

typescript
@Get('profile')
getProfile(@CurrentUser(new ValidationPipe()) user: User) {
  // user가 ValidationPipe를 거쳐서 검증된 상태
}

@Body()ValidationPipe를 적용하는 것과 동일한 방식이다.

데코레이터 정의에 Pipe 내장

매번 Pipe를 인라인으로 전달하기 번거롭다면, 데코레이터 정의 자체에 Pipe를 포함시킬 수 있다. applyDecorators 대신 더 간단한 방법이 있다:

typescript
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 적용까지 래핑한 함수를 만들 수도 있다:

typescript
export function ValidatedCurrentUser() {
  return applyDecorators(
    // 여러 데코레이터를 합성할 때 유용
  );
}

하지만 파라미터 데코레이터의 Pipe 적용은 보통 사용하는 쪽에서 명시적으로 하는 게 더 낫다. 어떤 변환이 일어나는지 호출부에서 바로 보이기 때문이다.


실전 패턴

1. 인증된 사용자 추출 (@CurrentUser)

가장 흔한 패턴이다. JWT Guard가 request.user에 사용자 정보를 넣어주면, 커스텀 데코레이터가 이를 타입 안전하게 추출한다.

typescript
// 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가 잡아준다:

typescript
@CurrentUser('email') email: string   // ✅ 정상
@CurrentUser('foo') foo: string       // ❌ 타입 에러: 'foo'는 keyof User가 아님

2. 요청 언어/로케일 추출

다국어 API에서 클라이언트가 보낸 Accept-Language 헤더를 파싱하는 패턴:

typescript
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. 페이지네이션 파라미터 추출

여러 쿼리 파라미터를 하나의 객체로 묶어서 추출하는 패턴:

typescript
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를 정확하게 가져오는 건 생각보다 까다롭다:

typescript
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를 사용한다:

typescript
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를 모킹하는 것이다.

typescript
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해서 테스트하는 것이 더 깔끔하다:

typescript
// 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')은 개념적으로 다음과 동일하다:

typescript
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 객체에 붙이는 방식을 사용해야 한다:

typescript
// ❌ 이렇게 하면 안 됨 — 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() 등으로 명시적으로 문서화해야 한다:

typescript
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@Get('posts')
findAll(@Pagination() pagination: PaginationParams) {
  // Swagger에서 page, limit 쿼리 파라미터가 표시됨
}

관련 문서