Nodejs

 

NestJS 마이크로서비스 #24 — User 로직을 Gateway로 옮기기 (Auth → Gateway & Guard)

 

 

목표

  • 토큰 검증은 더 이상 각 마이크로서비스에서 직접 하지 않고 Gateway에서 통합 수행.

  • Gateway가 User 마이크로서비스에 토큰을 검증 요청 → 검증 통과 시 req.user에 페이로드(UserPayload) 저장.

  • 컨트롤러에서는 가드(TokenGuard)커스텀 데코레이터(@userPayload) 로 사용자 정보를 안전하게 획득.

  • Gateway가 Order 마이크로서비스 호출 시, meta.user 형태로 인증된 사용자 컨텍스트를 전달.

 

 

전체 구조 한눈에 보기

[Client] --HTTP(Authorization: Bearer xxx)--> [Gateway]
   ① BearerTokenMiddleware: 토큰 추출/검증 (User MS에 위임)
   ② req.user 주입
   ③ TokenGuard: req.user 존재 확인
   ④ Controller 핸들러: @userPayload()로 user 가져옴
   ⑤ Gateway → Order MS: create_order(cmd) 요청 + meta.user 포함
   ⑥ Order MS: meta.user.sub로 User 정보 조회, 주문 생성/결제 진행

 

 

1) Gateway 환경 및 마이크로서비스 클라이언트 설정

Gateway에서 User/Product/Order 서비스에 TCP로 연결합니다. 환경변수를 Joi로 검증하여 누락/오입력 시 부팅 단계에서 실패하도록 합니다.

// app.module.ts (요지)
imports: [
  ConfigModule.forRoot({
    isGlobal: true,
    validationSchema: Joi.object({
      HTTP_PORT: Joi.number().required(),
      USER_HOST: Joi.string().required(),
      USER_TCP_PORT: Joi.number().required(),
      PRODUCT_HOST: Joi.string().required(),
      PRODUCT_TCP_PORT: Joi.number().required(),
      ORDER_HOST: Joi.string().required(),
      ORDER_TCP_PORT: Joi.number().required(),
    }),
  }),
  ClientsModule.registerAsync({
    isGlobal: true,
    clients: [
      { name: USER_SERVICE, useFactory: (cfg) => ({ transport: Transport.TCP, options: { host: cfg.get('USER_HOST'), port: Number(cfg.get('USER_TCP_PORT')) } }), inject: [ConfigService] },
      { name: PRODUCT_SERVICE, useFactory: (cfg) => ({ transport: Transport.TCP, options: { host: cfg.get('PRODUCT_HOST'), port: Number(cfg.get('PRODUCT_TCP_PORT')) } }), inject: [ConfigService] },
      { name: ORDER_SERVICE, useFactory: (cfg) => ({ transport: Transport.TCP, options: { host: cfg.get('ORDER_HOST'), port: Number(cfg.get('ORDER_TCP_PORT')) } }), inject: [ConfigService] },
    ],
  }),
]

.env 예시

HTTP_PORT=3000
USER_HOST=localhost
USER_TCP_PORT=4001
PRODUCT_HOST=localhost
PRODUCT_TCP_PORT=4002
ORDER_HOST=localhost
ORDER_TCP_PORT=4003

포인트: ConfigService.getOrThrow<number>를 쓰더라도 실제 반환은 문자열일 수 있으므로 Number(...) 캐스팅을 권장합니다.

 

2) Bearer 토큰 미들웨어 (Gateway)

Gateway에서 모든 /order 경로에 미들웨어를 적용하여,

  • Authorization: Bearer <JWT> 헤더에서 토큰 추출

  • User MS에 { cmd: 'parse_bearer_token' }로 토큰 파싱/검증 위임

  • 성공 시 req.user = payload

// bearer-token.middleware.ts (개선 버전)
@Injectable()
export class BearerTokenMiddleware implements NestMiddleware {
  constructor(@Inject(USER_SERVICE) private readonly userMS: ClientProxy) {}

  async use(req: any, _res: any, next: (err?: any) => void) {
    const token = this.extractBearer(req.headers?.authorization);
    if (!token) return next(); // 비인증 요청 허용(가드에서 차단)

    const result = await lastValueFrom(
      this.userMS.send({ cmd: 'parse_bearer_token' }, { token })
    );

    if (result?.status === 'error') {
      // 미들웨어에서 바로 던지면 전역필터에서 처리
      throw new UnauthorizedException('유효하지 않은 토큰입니다.');
    }

    req.user = result.data; // { sub, type, iat, exp }
    next();
  }

  private extractBearer(raw?: string | string[]): string | null {
    if (!raw) return null;
    const header = Array.isArray(raw) ? raw[0] : raw;
    const [scheme, value] = header.split(' ');
    if (!scheme || !value) return null;
    return scheme.toLowerCase() === 'bearer' ? value.trim() : null;
  }
}

적용

export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(BearerTokenMiddleware).forRoutes('order');
  }
}

왜 미들웨어인가? — 가드 이전 단계에서 req.user를 만들어 두면, 이후 여러 가드/인터셉터/핸들러에서 공통 컨텍스트로 재사용이 간편합니다.

 

 

3) TokenGuard — 인증 필수 라우트 보호

req.user가 존재하는지 확인합니다. 미들웨어에서 주입에 실패했거나 헤더가 없으면 401로 차단됩니다.

export class TokenGuard implements CanActivate {
  canActivate(ctx: ExecutionContext): boolean {
    const req = ctx.switchToHttp().getRequest();
    return !!req.user;
  }
}

 

 

4) @userPayload 커스텀 데코레이터 — 사용자 페이로드 추출

핸들러에서는 아래처럼 간결하게 사용자 정보를 받을 수 있습니다.

export const userPayload = createParamDecorator((data: unknown, ctx: ExecutionContext) => {
  const { user } = ctx.switchToHttp().getRequest();
  if (!user) {
    // 500(InternalServerError) 대신 401(Unauthorized)이 더 적절
    throw new UnauthorizedException('인증 정보가 없습니다.');
  }
  return user; // { sub, type, iat, exp }
});

 

 

5) Gateway 컨트롤러/서비스 — Order 생성 요청

컨트롤러는 가드로 보호하고, 데코레이터로 받은 userPayload와 바디를 서비스로 전달합니다.

// gateway/apps/gateway/src/order/order.controller.ts
@Controller('order')
export class OrderController {
  constructor(private readonly orderService: OrderService) {}

  @Post()
  @UseGuards(TokenGuard)
  async createOrder(
    @userPayload() user: UserPayloadDto,
    @Body() dto: CreateOrderDto,
  ) {
    return this.orderService.createOrder(dto, user);
  }
}

Gateway 서비스에서는 Order MS에 RPC로 create_order를 요청할 때, **meta.user**에 인증 정보를 넣어 전달합니다.

// gateway/apps/gateway/src/order/order.service.ts
async createOrder(dto: CreateOrderDto, user: UserPayloadDto) {
  return this.orderMS.send(
    { cmd: 'create_order' },
    { ...dto, meta: { user } }, // 핵심: 인증 컨텍스트 전달
  );
}

요청 바디에는 토큰을 넣지 않습니다. 클라이언트는 헤더에만 토큰을 담고, Gateway가 이를 해석해 meta.user로 넘기는 것이 새 구조의 핵심입니다.

 

 

6) Order 마이크로서비스 변경점

기존에는 Order MS가 직접 토큰을 파싱했을 수 있습니다. 이제는 Gateway가 인증을 책임지므로, Order MS는 meta.user.sub만 사용해 User 정보를 조회하면 됩니다.

DTO 동기화

// apps/order/src/order/dto/create-order.dto.ts (핵심 필드)
export class CreateOrderDto implements UserMeta {
  @ValidateNested()
  @IsNotEmpty()
  meta: { user: UserPayloadDto };

  @IsArray()
  @IsString({ each: true })
  @IsNotEmpty({ each: true })
  productIds: string[];

  @ValidateNested()
  @Type(() => AddressDto)
  @IsNotEmpty()
  address: AddressDto;

  @ValidateNested()
  @Type(() => PaymentDto)
  @IsNotEmpty()
  payment: PaymentDto;
}

불필요한 token 필드는 제거하세요. 이제 사용하지 않습니다.

서비스 로직 핵심

async createOrder(dto: CreateOrderDto) {
  const { productIds, address, payment, meta } = dto;

  // 1) 사용자 정보
  const user = await this.getUserFromId(meta.user.sub);

  // 2) 상품 정보
  const products = await this.getProductsByIds(productIds);

  // 3) 금액 계산 & 검증
  const totalAmount = this.sum(products);
  this.assertAmount(totalAmount, payment.amount);

  // 4) 주문 생성 → 5) 결제 시도 → 6) 결과 업데이트
  const order = await this.createNewOrder(user, products, address, payment);
  const result = await this.processPayment(order._id.toString(), payment, user.email);
  return this.orderModel.findById(order._id);
}

 

 

7) 요청–응답 시퀀스 (요약)

  1. 클라이언트: POST /order + Authorization: Bearer <JWT> 헤더 + 주문 바디 전송

  2. Gateway 미들웨어: 토큰 파싱(→ User MS) 성공 시 req.user 주입

  3. TokenGuard: req.user 없으면 401

  4. 컨트롤러: @userPayload()로 사용자 페이로드 받음

  5. Gateway → Order MS: { cmd: 'create_order' }+ 바디 + meta.user

  6. Order MS: meta.user.sub 기반 User 정보 조회 → 상품 조회/검증 → 결제 시도 → 주문 반환

 

 

 

8) 유효성 검증 & 보안 포인트

  • 미들웨어에서 정확히 Bearer 스킴만 허용하고, 그 외 포맷은 401 처리.

  • @userPayload에서 401(Unauthorized) 를 사용(500 아님).

  • DTO는 공통 패키지(@app/common) 로 공유하면 타입 불일치 위험 감소.

  • UserPayloadDto의 iat/exp는 number 타입 권장(Unix time).

  • 결제 금액은 서버 계산(total)과 프론트 입력(amount)을 반드시 비교.

 

 

 

9) 흔한 실수 & 개선(오류 정정)

  1. Authorization 전체 문자열을 그대로 토큰으로 사용 ➜ Bearer 분리 파싱 필요.

  2. 데코레이터에서 InternalServerErrorException 사용 ➜ UnauthorizedException 으로 교체.

  3. Order MS의 CreateOrderDto에 token 필드 남아있음 ➜ 제거.

  4. PaymentMethod는 @IsEnum(PaymentMethod)로 검증 강화.

  5. ConfigService에서 포트가 문자열로 들어오는 문제 ➜ Number(...)로 변환.

  6. 미들웨어 적용 경로가 잘못되어 인증이 누락 ➜ forRoutes('order')(또는 필요한 범위) 재확인.

 

 

 

10) 요청 예제 (테스트)

HTTP 요청

POST /order
Authorization: Bearer <JWT>
Content-Type: application/json

{
  "productIds": ["8cc2...","39a9..."],
  "address": { "name":"홍길동","street":"도산대로 14","city":"서울","postalCode":"123123","country":"대한민국" },
  "payment": { "paymentMethod":"CreditCard", "paymentName":"법인카드", "cardNumber":"123123123123", "expiryYear":"26", "expiryMonth":"12", "birthOrRegistration":"9", "passwordTwoDigits":"12", "amount":3000 }
}

기대 동작

  • 토큰 유효 ➜ req.user 주입 ➜ 가드 통과 ➜ Order MS에 meta.user 포함 전달 ➜ 주문/결제 처리.

  • 토큰 누락/무효 ➜ 401 응답.

 

 

11) 미들웨어 vs 가드 — 언제 무엇을 쓸까?

  • 미들웨어: 요청 가공/전처리(토큰 파싱, 로깅, 트레이싱). 라우트 핸들러와 무관한 공통 관심사 처리에 적합.

  • 가드: 권한 부여(Authorization). 핸들러 실행 여부 자체를 결정.

  • 본 사례: 토큰 파싱은 미들웨어, 인증 필수 여부는 가드가 담당.

 

 

이번 단계의 핵심은 인증의 단일화입니다.

Gateway에서 인증을 책임지고, 서비스 간에는 신뢰 가능한 meta.user 컨텍스트만 전달하세요.

이렇게 하면 각 마이크로서비스는 비즈니스 로직에 집중할 수 있고, 보안·정책 변경도 Gateway에서 일괄 반영할 수 있습니다.

 

 

 

 

about author

PHRASE

Level 60  라이트

참외를 버리고 호박을 먹는다 , 좋은 것을 버리고 나쁜 것을 가진다는 말. / 착한 아내를 버리고 우둔한 첩을 좋아한다는 말.

댓글 ( 0)

댓글 남기기

작성