본문 바로가기

API 게이트웨이 패턴 구현

API 게이트웨이를 단일 진입점으로 두고 요청 라우팅·응답 합성·공통 인증을 NestJS 서비스에 적용합니다.

앞 글에서는 NestJS와 Kafka로 서비스 사이의 비동기 통신과 이벤트 기반 아키텍처를 구현했습니다. 7장의 마지막인 이 글에서는 마이크로서비스 아키텍처에서 중요한 역할을 하는 API 게이트웨이(API Gateway) 패턴을 알아보고, NestJS로 구현해 봅니다.

마이크로서비스 아키텍처는 백엔드를 여러 작은 서비스로 나누어 독립적으로 개발·배포·확장할 수 있게 합니다. 하지만 웹 애플리케이션이나 모바일 앱 같은 클라이언트 입장에서는 수많은 서비스와 직접 통신하는 일이 복잡하고 비효율적일 수 있습니다. 이럴 때 필요한 것이 API 게이트웨이입니다.

Nest Gateway · 책임과 배치

외부 HTTP 계약을 모으고 도메인 데이터 책임을 남깁니다

학습 예제는 Orders와 Gateway를 함께 실행해 Users gRPC와 결과를 조합합니다. CORS 외의 인증·권한·rate limit·request ID는 별도로 구성할 공통 정책입니다.

Gateway 외부 계약과 서비스 데이터 책임 학습용 Orders 앱 안에 Gateway HTTP route와 Orders provider가 함께 있고 Users는 별도 gRPC 프로세스다. Users 직접 route는 원격 호출, Orders 생성 route는 로컬 처리이다. details는 저장된 Order를 먼저 찾은 뒤 Users를 조회해 조합하므로 단순병렬호출이 아니다. 점선은 details가 필요한 원격조회로 이어가는 관계다. 도메인 데이터 책임은 각서비스에 남고 이 코드의 공통edge구현은CORS뿐이다. Orders · 학습용 HTTP 앱 Users route Orders route Users 결과 로컬 결과 details 조회 외부 HTTP client /api 아래 route Gateway route 예제: CORS 인증 · 제한 별도 Users · 별도 프로세스 gRPC 조회 · 생성 Orders provider 같은 학습 앱 주문 데이터 책임 외부 응답 구성 직접 route / details 실패 계약은 route별
route가 로컬 주문과 원격 Users를 선택한다 학습용 Orders 앱 안에 Gateway HTTP route와 Orders provider가 함께 있고 Users는 별도 gRPC 프로세스다. Users 직접 route는 원격 호출, Orders 생성 route는 로컬 처리이다. details는 저장된 Order를 먼저 찾은 뒤 Users를 조회해 조합하므로 단순병렬호출이 아니다. 점선은 details가 필요한 원격조회로 이어가는 관계다. 도메인 데이터 책임은 각서비스에 남고 이 코드의 공통edge구현은CORS뿐이다. 외부 HTTP client /api 아래 경로 Gateway route 예제 공통 정책: CORS 인증 · 제한은 별도 Users route gRPC 호출 원격 Users Orders route 로컬 provider 주문 책임 외부 응답 구성 직접 route / details 실패 계약은 route별
single entry

HTTP 진입부터 내부 호출까지 경계를 따라간다

  1. 클라이언트는 외부 API만 호출한다

    웹·모바일은 http://localhost:3000/api 아래의 route를 사용하며 Users gRPC 주소나 Orders 저장 방식을 알지 않습니다.

  2. Gateway가 edge 공통 정책을 적용한다

    신원 확인, route·scope 권한, CORS, 요청 제한, 외부 DTO와 응답 모양, request ID처럼 여러 route에 공통인 edge 정책을 진입 경계에서 일관되게 적용합니다.

  3. route가 도메인 처리 대상을 고른다

    /api/users는 UserService gRPC로 위임하고, /api/orders는 이 학습용 앱의 Orders 로직을 호출합니다. details route는 두 결과를 외부 응답으로 조합합니다.

  4. 내부 결과를 외부 계약으로 번역한다

    Gateway는 deadline과 gRPC status를 관측해 route별 HTTP 상태와 부분 응답 모양을 선택합니다. 내부 protocol 오류를 그대로 노출하지 않습니다.

Gateway owns

외부 계약과 공통 edge 정책

Route 선택, 신원 확인과 공통 scope 권한, rate limit, protocol 변환, 응답 조합, 실패 매핑과 요청 관측을 맡습니다. 리소스 소유자처럼 데이터가 필요한 도메인 권한은 각 서비스가 판단합니다.

Service owns

도메인 규칙과 데이터 소유권

Users는 사용자 조회·생성 규칙과 데이터를, Orders는 주문 생성·조회 규칙과 데이터를 소유합니다. Gateway가 응답을 조합해도 이 업무 판단을 가져오지 않습니다.

teaching topology

orders-service와 Gateway의 동거는 학습용이다

예제는 한 프로세스에서 HTTP route, Orders 로컬 처리, Users gRPC 호출과 composition을 모두 실행해 흐름을 작게 보여 줍니다. 운영 배치에서 둘을 같은 서비스로 묶으라는 권장이 아니며, 변경 주기·확장·권한·팀 소유권이 다르면 Gateway를 별도 애플리케이션으로 분리합니다.

route contract

각 route의 실제 처리 경로

GET /api/users/:userId

Gateway 동작: getUserById unary gRPC 호출
도메인 소유자: Users Service

POST /api/users

Gateway 동작: createUser unary gRPC 호출
도메인 소유자: Users Service

POST /api/orders

Gateway 동작: 이 학습용 앱의 Orders provider 호출
도메인 소유자: Orders 도메인

GET …/:orderId/details

Gateway 동작: Order 조회 후 Users gRPC 결과를 조합
도메인 소유자: 각 서비스가 원본을 소유하고 Gateway가 외부 모양만 구성

판정 기준: Gateway는 외부 계약과 공통 edge 권한을 안정화하지만, 도메인 서비스의 리소스 권한·업무 규칙·데이터 경계를 대신하지 않습니다.

위 그림은 이 글의 학습용 구성을 보여 줍니다. 하나의 orders-service 앱 안에 게이트웨이 HTTP route와 Orders provider가 함께 있고, Users는 별도의 gRPC 프로세스입니다. /api/users route는 Users로 원격 호출하고, /api/orders route는 같은 앱의 Orders 로직이 처리하며, details route는 저장된 주문을 먼저 찾은 뒤 Users를 조회해 두 결과를 합칩니다. 그림 아래쪽은 게이트웨이가 맡는 외부 약속과 공통 정책, 각 서비스가 맡는 도메인 규칙과 데이터를 나누어 정리하며, 이 예제 코드에서 실제로 켠 공통 정책은 CORS뿐이라는 점도 적혀 있습니다.


게이트웨이 패턴이란

API 게이트웨이는 클라이언트의 API 요청을 하나의 진입점에서 받아, 그 요청을 처리할 마이크로서비스로 보내는 서비스입니다. 클라이언트와 백엔드 서비스 사이에서 중개자 역할을 합니다.

API 게이트웨이의 주요 역할은 다음과 같습니다.

  • 요청 라우팅(request routing): 요청 URL이나 헤더를 보고, 그 요청을 처리할 서비스로 전달합니다.
  • 요청 합성(request composition): 여러 서비스의 응답을 받아 클라이언트에 필요한 모양으로 합쳐 하나의 응답으로 돌려줍니다. BFF(Backend For Frontend) 패턴과도 관련이 있습니다.
  • 인증과 인가: 신원 확인과 route·scope 같은 진입 단계의 권한을 일관되게 적용합니다. 주문 소유자 확인처럼 도메인 데이터가 필요한 세부 권한 판단은 해당 서비스에 남깁니다.
  • 속도 제한(rate limiting): 특정 클라이언트나 사용자의 요청 속도를 제한해 서비스 과부하를 막습니다.
  • 캐싱: 자주 요청되는 데이터를 캐시해 백엔드 부하를 줄이고 응답을 빠르게 합니다.
  • 로깅과 모니터링: 모든 API 요청을 한곳에서 기록하고 지켜봅니다.
  • 프로토콜 변환: 클라이언트는 HTTP/REST로 요청하고, 게이트웨이가 이를 gRPC나 Kafka 이벤트로 바꾸어 백엔드와 통신합니다.
  • 장애 처리: 백엔드 장애를 감지하고 서킷 브레이커나 재시도 같은 패턴으로 클라이언트에 안정적인 응답을 돌려줍니다.

장점으로는 클라이언트가 여러 서비스 주소를 알 필요 없이 진입점 하나만 호출하면 되는 단순함, 신원 확인과 공통 접근 정책을 한곳에서 처리하는 보안상의 이점, 백엔드 변경이 클라이언트에 주는 영향을 줄이는 유연성, 캐싱과 압축으로 응답을 개선하는 성능상의 이점이 있습니다. 다만 게이트웨이가 공통 정책을 처리하더라도, 각 서비스는 자기 자원에 대한 도메인 권한을 다시 확인해야 합니다.

단점도 있습니다. 게이트웨이가 멈추면 전체 시스템이 멈출 수 있는 단일 실패 지점(single point of failure)이 되므로 고가용성을 확보해야 하고, 모든 요청이 지나가므로 성능 병목이 될 수 있어 확장성을 고려해야 하며, 게이트웨이 자체의 개발·배포·유지보수 부담이 더해집니다.

게이트웨이에 모을 공통 정책과 각 서비스에 남길 도메인 규칙을 분리해 두면, 인증·속도 제한·응답 합성을 어디서 처리할지가 분명해집니다.


NestJS에서 API 게이트웨이 구현하기

NestJS에서는 HTTP 라우팅과 마이크로서비스 클라이언트를 한 애플리케이션 안에 함께 구성할 수 있습니다. 여기서는 orders-service가 API 게이트웨이 역할도 함께 맡도록 확장해, 앞 글들의 users-service와 통신하는 예시를 봅니다.

이 배치는 한 장 안에서 HTTP 라우팅, 로컬 주문 처리, gRPC 호출과 응답 합성을 모두 실행해 보기 위한 학습용입니다. 실제 시스템에서 게이트웨이와 Orders 도메인의 배포·확장·권한을 합치라고 권하는 구조가 아니며, 운영에서는 변경 주기와 소유권에 따라 별도 애플리케이션으로 나눌 수 있습니다.

시나리오는 다음과 같습니다. 웹 클라이언트가 orders-service(API 게이트웨이)에 HTTP 요청을 보내면, orders-service는 내부적으로 users-service(gRPC)를 호출해 데이터를 합친 뒤 클라이언트에 응답합니다. 외부 클라이언트는 HTTP API만 알고, 게이트웨이는 route에 따라 Orders 로컬 로직과 Users gRPC 호출 가운데 하나를 고릅니다.

먼저 준비할 것이 있습니다.

  • 7장 두 번째 글의 users-service(gRPC 서버)가 localhost:50051에서 실행 중이어야 합니다. 앞 글에서 같은 프로젝트를 HTTP 서버로 바꾸었다면 main.ts, UsersController, UsersModule을 gRPC 구성으로 되돌린 뒤 시작합니다. 앞 글의 Users HTTP 프로세스는 종료해 게이트웨이의 3000번 포트와 겹치지 않게 합니다.
  • orders-service는 7장에서 쓰던 프로젝트를 그대로 씁니다. 7장 두 번째 글에서 @grpc/grpc-js와 @grpc/proto-loader를 설치하고 nest-cli.json의 assets를 설정했다면 추가로 설치할 패키지는 없습니다.

Orders 서비스를 게이트웨이로 설정하기

orders-service는 클라이언트의 HTTP 요청을 받아야 하므로 일반 NestJS HTTP 서버로 구성합니다. 7장 첫 글의 main.ts와 달라진 곳은 app.enableCors() 한 줄과 로그 문구입니다. enableCors()를 옵션 없이 호출하면 모든 출처의 요청을 허용하므로, 운영에서는 허용할 출처를 옵션으로 지정합니다.

orders-service/src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 브라우저 클라이언트가 다른 origin에서 호출할 때 필요한 CORS 설정
  app.enableCors();
  await app.listen(3000); // 클라이언트가 접근할 API 게이트웨이 포트
  console.log('API Gateway (Orders Service) is listening on port 3000');
}

void bootstrap();

이 최소 코드에서 실제로 켠 공통 정책은 CORS뿐입니다. 인증·인가, rate limit, 요청 ID와 접근 로그는 게이트웨이라는 이름만으로 생기지 않으며, guard, middleware, interceptor나 앞단 프록시에서 따로 구성하고 테스트해야 합니다.

orders-service/src/proto/users.proto는 7장 두 번째 글과 같은 내용이어야 합니다. 파일 이름뿐 아니라 package, service, RPC, 메시지 필드 번호까지 users-service의 proto와 같은지 확인합니다.

orders-service/src/proto/users.proto
syntax = "proto3";

package users;

service UserService {
  rpc GetUserById (UserByIdRequest) returns (User);
  rpc CreateUser (CreateUserRequest) returns (User);
}

message UserByIdRequest {
  int32 id = 1;
}

message CreateUserRequest {
  string name = 1;
  string email = 2;
}

message User {
  int32 id = 1;
  string name = 2;
  string email = 3;
}

orders-service가 users-service와 gRPC로 통신하도록 클라이언트를 등록합니다. 이 모듈은 빌드 뒤 dist/orders/에서 실행되므로 ../proto/users.proto가 dist/proto/users.proto를 가리킵니다.

orders-service/src/orders/orders.module.ts (수정)
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { join } from 'path';

@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'USERS_SERVICE', // gRPC 클라이언트 토큰
        transport: Transport.GRPC,
        options: {
          package: 'users',
          protoPath: join(__dirname, '../proto/users.proto'), // dist/proto/users.proto
          url: 'localhost:50051', // Users gRPC 서비스 주소
          loader: {
            keepCase: true,
            longs: String,
            enums: String,
            defaults: true,
            oneofs: true,
          },
        },
      },
    ]),
  ],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}

다음 controller는 클라이언트의 HTTP 요청을 받아 내부적으로 gRPC USERS_SERVICE를 호출하고, 그 결과를 HTTP 응답으로 바꿉니다. 7장 두 번째 글의 orders.controller.ts와 비슷하지만, 게이트웨이의 라우팅과 응답 합성을 더 분명하게 보여 줍니다.

orders-service/src/orders/orders.controller.ts (수정)
import {
  BadGatewayException,
  BadRequestException,
  Body,
  Controller,
  GatewayTimeoutException,
  Get,
  HttpException,
  Inject,
  Logger,
  NotFoundException,
  OnModuleInit,
  Param,
  ParseIntPipe,
  Post,
  ServiceUnavailableException,
} from '@nestjs/common';
import { status, type CallOptions } from '@grpc/grpc-js';
import type { ClientGrpc } from '@nestjs/microservices';
import { OrdersService } from './orders.service';
import { lastValueFrom, Observable } from 'rxjs';

// 동적 proto client가 노출하는 lower camel-case 메서드 형식
interface UserService {
  getUserById(data: { id: number }, options?: CallOptions): Observable<User>;
  createUser(
    data: { name: string; email: string },
    options?: CallOptions,
  ): Observable<User>;
}

interface User {
  id: number;
  name: string;
  email: string;
}

interface Order {
  id: number;
  userId: number;
  item: string;
  createdAt: Date;
}

type DependencyStatus = 'ok' | 'not-found' | 'timeout' | 'unavailable';

interface OrderDetails {
  order: Order;
  user: User | null;
  dependencyStatus: DependencyStatus;
}

interface GrpcErrorShape {
  code?: number;
}

function readGrpcError(error: unknown): GrpcErrorShape {
  return typeof error === 'object' && error !== null
    ? (error as GrpcErrorShape)
    : {};
}

// 이 예제 Gateway가 정한 외부 HTTP 규칙입니다. 보편적인 표준표가 아닙니다.
function mapGrpcError(error: unknown): HttpException {
  switch (readGrpcError(error).code) {
    case status.INVALID_ARGUMENT:
      return new BadRequestException('Invalid Users Service request');
    case status.NOT_FOUND:
      return new NotFoundException('User not found');
    case status.DEADLINE_EXCEEDED:
      return new GatewayTimeoutException('Users Service timed out');
    case status.UNAVAILABLE:
      return new ServiceUnavailableException(
        'Users Service is temporarily unavailable',
      );
    default:
      return new BadGatewayException('Users Service request failed');
  }
}

@Controller('api')
export class OrdersController implements OnModuleInit {
  private userService!: UserService;
  private readonly logger = new Logger(OrdersController.name);

  constructor(
    private readonly ordersService: OrdersService,
    @Inject('USERS_SERVICE') private readonly client: ClientGrpc,
  ) {}

  onModuleInit(): void {
    this.userService = this.client.getService<UserService>('UserService');
  }

  private logUpstreamFailure(route: string, error: unknown): void {
    const { code } = readGrpcError(error);
    this.logger.warn(
      `route=${route} upstream=users-service grpcCode=${code ?? 'UNKNOWN'}`,
    );
  }

  @Get('users/:userId')
  async getUser(
    @Param('userId', ParseIntPipe) userId: number,
  ): Promise<User> {
    try {
      return await lastValueFrom(
        this.userService.getUserById(
          { id: userId },
          { deadline: Date.now() + 1_500 },
        ),
      );
    } catch (error) {
      this.logUpstreamFailure('GET /api/users/:userId', error);
      throw mapGrpcError(error);
    }
  }

  @Post('users')
  async createUser(@Body() userDto: { name: string; email: string }): Promise<User> {
    try {
      return await lastValueFrom(
        this.userService.createUser(
          userDto,
          { deadline: Date.now() + 1_500 },
        ),
      );
    } catch (error) {
      this.logUpstreamFailure('POST /api/users', error);
      throw mapGrpcError(error);
    }
  }

  @Post('orders')
  createOrder(@Body() orderDto: { userId: number; item: string }): Order {
    return this.ordersService.createOrder(orderDto.userId, orderDto.item);
  }

  @Get('orders/:orderId/details')
  async getOrderDetails(
    @Param('orderId', ParseIntPipe) orderId: number,
  ): Promise<OrderDetails> {
    const order = this.ordersService.findOne(orderId);
    if (!order) {
      throw new NotFoundException('Order not found');
    }

    try {
      const user = await lastValueFrom(
        this.userService.getUserById(
          { id: order.userId },
          { deadline: Date.now() + 1_500 },
        ),
      );
      return { order, user, dependencyStatus: 'ok' };
    } catch (error) {
      const { code } = readGrpcError(error);
      this.logUpstreamFailure('GET /api/orders/:orderId/details', error);

      if (code === status.NOT_FOUND) {
        return { order, user: null, dependencyStatus: 'not-found' };
      }
      if (code === status.DEADLINE_EXCEEDED) {
        return { order, user: null, dependencyStatus: 'timeout' };
      }
      if (code === status.UNAVAILABLE) {
        return { order, user: null, dependencyStatus: 'unavailable' };
      }
      throw new BadGatewayException('Users Service request failed');
    }
  }
}
  • @Controller('api'): 모든 엔드포인트에 /api 접두사를 붙여 외부 HTTP 표면을 한곳에 모읍니다.
  • 라우팅: /api/users 경로는 users-service에 맡기고, /api/orders는 이 학습용 앱의 Orders 로직이 처리합니다.
  • 클라이언트 메서드 이름: proto의 GetUserById, CreateUser는 Nest gRPC 클라이언트 프록시에서 getUserById, createUser로 노출됩니다. unary 응답은 Observable이므로 lastValueFrom()으로 완료된 응답 하나를 기다립니다.
  • 시간 예산: gRPC에는 기본 deadline이 없으므로 예제는 각 RPC에 유한한 deadline을 넘깁니다. 1_500ms는 학습용 값이며, 실제 값은 상위 HTTP 요청의 시간 예산, 네트워크, 부하 측정으로 정합니다.
  • 오류 처리 위치: ParseIntPipe의 400과 로컬 주문의 404는 gRPC try 블록 밖에서 결정됩니다. 그래서 이미 만든 HttpException이 같은 catch에 잡혀 500이나 502로 다시 포장되지 않습니다.
  • 실패 대응: 이 예제의 직접 Users route는 INVALID_ARGUMENT를 400, NOT_FOUND를 404, DEADLINE_EXCEEDED를 504, UNAVAILABLE을 503, 그 밖의 예상하지 못한 실패를 502로 바꿉니다. 이것은 이 게이트웨이가 정한 외부 규칙의 예시이며 gRPC와 HTTP 사이의 보편적인 표준이 아닙니다. 다른 API는 UNAVAILABLE을 502로 정할 수도 있으므로, 문서와 테스트를 일치시키는 것이 중요합니다.
  • 부분 실패: 합성 route는 주문이 있으면 Users의 not found, timeout, unavailable을 user: null과 명시적인 dependencyStatus로 돌려줍니다. 저장된 주문으로 만든 상류 요청의 INVALID_ARGUMENT는 현재 HTTP 요청의 잘못이 아니므로 400으로 돌리지 않고, 허용하지 않은 다른 실패와 함께 502로 처리합니다. 모든 엔드포인트가 부분 성공을 써야 한다는 뜻은 아니며, 소비자가 이 응답 모양을 이해하기로 약속했을 때만 씁니다.
  • 재시도: createUser처럼 멱등하지 않은 호출은 deadline이 지난 뒤에도 서버에서 이미 완료되었을 수 있습니다. idempotency key와 중복 방지 장치 없이 애플리케이션이나 gRPC 설정에 재시도를 추가하지 않습니다. gRPC 전송 계층은 애플리케이션이 호출을 보기 전의 제한된 상황에서 투명하게 재시도할 수 있으므로, 이것도 정확히 한 번 실행을 뜻하지 않습니다.
  • 관측 범위: 위 최소 코드는 실패한 route와 gRPC code만 로그로 남깁니다. 요청·추적 ID, 처리 시간, 최종 HTTP 상태, 부분 응답 여부를 연결하려면 middleware나 interceptor, telemetry 계층을 따로 구성하고 성공·실패 경로 모두에서 테스트해야 합니다.

게이트웨이의 품질은 성공 응답보다 실패 응답에서 더 잘 드러납니다. 내부 오류 문자열을 외부 응답에 그대로 내보내지 않고, route마다 문서화한 상태 코드와 응답 모양으로 바꿉니다.

주문 데이터를 관리하는 OrdersService에 findOne()을 추가합니다.

orders-service/src/orders/orders.service.ts (수정)
import { Injectable } from '@nestjs/common';

interface Order {
  id: number;
  userId: number;
  item: string;
  createdAt: Date;
}

@Injectable()
export class OrdersService {
  private orders: Order[] = [];
  private nextId = 1;

  createOrder(userId: number, item: string): Order {
    const newOrder = {
      id: this.nextId++,
      userId,
      item,
      createdAt: new Date(),
    };
    this.orders.push(newOrder);
    return newOrder;
  }

  findOne(id: number): Order | undefined {
    return this.orders.find(order => order.id === id);
  }
}

findOne()은 주문이 없으면 undefined를 돌려주고, controller의 details route는 이 값을 보고 gRPC를 호출하기 전에 404로 끝냅니다. 주문은 메모리 배열에 있으므로 서버를 다시 시작하면 사라지며, details route를 시험하려면 먼저 주문을 다시 만들어야 합니다. AppModule은 앞 글과 같습니다. 게이트웨이 route도 OrdersModule에 등록된 OrdersController에 있으므로, 루트 모듈은 여전히 OrdersModule 하나만 가져옵니다.

orders-service/src/app.module.ts
import { Module } from '@nestjs/common';
import { OrdersModule } from './orders/orders.module';

@Module({
  imports: [OrdersModule],
  controllers: [],
  providers: [],
})
export class AppModule {}

게이트웨이 동작 확인

성공 응답 하나만 확인하지 말고 route 선택, 실제 상류 호출, deadline, 외부 실패 대응과 부분 응답을 함께 확인합니다.

Gateway 라우팅·부분 실패·외부 응답 검증

입력 검사·로컬 Order 확인·유한한 deadline RPC·응답 검증·완전/선택 부분/오류 응답으로 경계가 바뀐다.

Nest Gateway · route별 실패 계약

같은 상류 실패를 직접 route와 조합 route의 약속으로 번역합니다

직접 Users route는 HTTP 오류를 반환합니다. Order가 있는 details route만 허용한 세 실패를 user:null·dependencyStatus로 나타냅니다. 나머지 상류 실패는 502이며 telemetry는 추가 구성합니다.

같은 상류 결과를 route 약속에 따라 번역한다 입력정수검증실패는RPC전에400,details의로컬Order부재는RPC전에404이다. 직접Users와Order가있는details만유한deadline의RPC로간다. 직접route는OKGET200/POST201,정한상류실패는HTTP오류이다. details는OK200또는NOT_FOUND/DEADLINE_EXCEEDED/UNAVAILABLE만user:null과dependencyStatus를담은200으로반환한다. 둘의기타상류실패는502이며partial로숨기지않는다. 유효Order를읽기전원격조회가먼저시작되거나잘못된입력이상류에가는흐름이아니다. 통과 details Order 있음 직접 직접 details 기타 실패 HTTP 경로 검증 실패는 400 · RPC 없음 route 선택 직접 Users / details details Order 조회 없음 → 404 · RPC 없음 Users RPC 직접 또는 Order 있음 유한 deadline 상류 결과 분류 OK / 허용 실패 / 기타 직접 결과 OK → 200 / 201 실패 → HTTP 오류 details 결과 OK200 · 허용실패200 user:null + 상태 예상 밖 상류 실패 둘 다 502
먼저 종료할 조건과 route별 실패 계약 입력정수검증실패는RPC전에400,details의로컬Order부재는RPC전에404이다. 직접Users와Order가있는details만유한deadline의RPC로간다. 직접route는OKGET200/POST201,정한상류실패는HTTP오류이다. details는OK200또는NOT_FOUND/DEADLINE_EXCEEDED/UNAVAILABLE만user:null과dependencyStatus를담은200으로반환한다. 둘의기타상류실패는502이며partial로숨기지않는다. 유효Order를읽기전원격조회가먼저시작되거나잘못된입력이상류에가는흐름이아니다. 있음 HTTP 경로 검증 ParseIntPipe 실패 → 400 통과 route 선택 직접 Users / details 직접 route Users RPC 상류 결과 details Order 조회 없으면 404 Users RPC Order가 있을 때 조회 유한한 deadline 상류 결과 분류 OK / 허용 실패 / 기타 직접 결과 OK:200/201 실패 → HTTP 오류 details 결과 OK → 200 허용실패 → 200 + 상태 예상 밖 상류 실패 둘 다 502 partial로 숨기지 않음
request budget

요청 한 건의 routing·composition·failure 경계

  1. HTTP 입력과 공통 정책을 먼저 확정한다

    Route parameter는 ParseIntPipe 같은 HTTP 경계에서 검증합니다. 인증·권한·rate limit과 request ID도 내부 호출 전에 적용해 잘못된 요청이 상류 서비스로 번지지 않게 합니다.

  2. route가 실제 처리 경로를 선택한다

    Users route는 gRPC unary RPC로 위임하고 Orders 생성은 로컬 provider를 호출합니다. Details route는 먼저 Order를 찾고, 존재할 때만 Users 조회를 이어 갑니다.

  3. 상류 호출에 유한한 deadline을 건다

    예제는 Users RPC에 Date.now() + 1_500을 전달합니다. 조합 route는 남은 HTTP 시간 예산 안에서 필요한 호출을 끝내야 하며, 운영 값은 tail latency와 부하 측정으로 정합니다.

  4. gRPC status를 route 계약으로 분류한다

    직접 Users route는 실패를 HTTP 예외로 번역합니다. Details route는 계약이 허용한 NOT_FOUND, timeout, unavailable만 user: null과 상태 필드로 표현하고 예상 밖 오류는 502로 실패합니다.

  5. 외부 응답과 관측 신호를 함께 남긴다

    운영 telemetry에서는 최종 HTTP status, partial 여부, route template, upstream RPC, gRPC code와 duration을 같은 request/trace ID로 연결합니다. 최소 예제의 실패 로그만으로 이 완료 조건이 충족되지는 않습니다.

failure mapping

같은 상류 결과도 route 계약에 따라 다르게 보인다

OK

직접 Users route: GET 200 · POST 201 · User
Order details 조합 route: 200 · dependencyStatus = "ok"
판정: 요청한 전체 데이터가 준비됨

NOT_FOUND

직접 Users route: 404 · User not found
Order details 조합 route: 200 · user: null, not-found
판정: Order가 있을 때만 조합 route의 부분 응답 허용

deadline 초과

직접 Users route: 504 · upstream timeout
Order details 조합 route: 200 · user: null, timeout
판정: 시간 예산 초과를 명시

UNAVAILABLE

직접 Users route: 503 · 이 예제의 계약
Order details 조합 route: 200 · user: null, unavailable
판정: 다른 외부 계약은 502 또는 503을 선택할 수 있음

INVALID_ARGUMENT

직접 Users route: 400 · 잘못된 직접 Users 입력
Order details 조합 route: 502 · 저장된 Order에서 만든 상류 요청 실패
판정: 같은 gRPC code도 어느 경계의 입력인지에 따라 외부 의미가 다름

그 밖의 예상하지 못한 상류 실패

직접 Users route: 502 · upstream failure
Order details 조합 route: 502 · partial로 숨기지 않음
판정: 알 수 없는 실패를 성공처럼 반환하지 않음
Users routes

직접 위임을 검증한다

GET /api/users/:userId와 POST /api/users가 각각 올바른 gRPC 메서드를 호출하는지 확인합니다. 성공 200·201과 실패 status별 응답, deadline을 검증하고, 비멱등 생성 요청은 idempotency 계약 없이 명시적·정책 재시도를 추가하지 않습니다.

Orders route

로컬 처리는 불필요한 상류 호출이 없어야 한다

POST /api/orders가 Orders provider만 호출하고 Users gRPC를 호출하지 않는지 확인합니다. 이 route의 도메인 검증과 데이터 소유권은 Gateway 공통 정책이 아니라 Orders 책임입니다.

composition

조합 순서와 부분 실패를 함께 검증한다

Details route는 Order가 없으면 404로 끝나고 Users를 호출하지 않아야 합니다. Order가 있으면 Users 결과를 합치며, 허용한 실패만 안정된 dependencyStatus 값으로 반환합니다.

observability

응답과 내부 경로를 한 trace로 맞춘다

운영 관측 계층을 구성한 뒤 gRPC 호출 유무, code, duration, 부분 응답 flag와 최종 status를 비교합니다. Route template을 metric label로 쓰고 높은 cardinality의 ID와 payload는 trace·보호된 로그에서 다룹니다.

운영 완료 조건: route가 의도한 내부 경로만 타고, deadline과 실패 분류가 문서화한 외부 응답으로 나타나며, 추가한 telemetry의 같은 request/trace ID로 원인을 추적할 수 있어야 합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 입력 정수 검증 실패는 RPC 전에 400, details의 로컬 Order 부재는 RPC 전에 404로 끝난다. 직접 Users와 Order가 있는 details만 유한한 deadline의 RPC로 간다. 직접 route의 성공은 GET 200/POST 201, 정해진 상류 실패는 HTTP 오류다. details의 성공은 200이며 NOT_FOUND/DEADLINE_EXCEEDED/UNAVAILABLE만 user:null과 dependencyStatus를 담은 200으로 반환한다. 둘의 기타 상류 실패는 502이며 partial로 숨기지 않는다. 유효한 Order를 읽기 전 원격 조회가 먼저 시작되거나 잘못된 입력이 상류에 가는 흐름이 아니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Gateway 라우팅·부분 실패·외부 응답 검증
6입력 정수 검증 실패는 RPC 전에 400, details의 로컬 Order 부재는 RPC 전에 404로 끝난다. 직접 Users와 Order가 있는 details만 유한한 deadline의 RPC로 간다. 직접 route의 성공은 GET 200/POST 201, 정해진 상류 실패는 HTTP 오류다. details의 성공은 200이며 NOT_FOUND/DEADLINE_EXCEEDED/UNAVAILABLE만 user:null과 dependencyStatus를 담은 200으로 반환한다. 둘의 기타 상류 실패는 502이며 partial로 숨기지 않는다. 유효한 Order를 읽기 전 원격 조회가 먼저 시작되거나 잘못된 입력이 상류에 가는 흐름이 아니다.
27OK 200 · 허용 실패 200
45허용 실패 →
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
RPC 전 검사

정수 입력과 details의 로컬 Order를 확인합니다.

입력 실패: 400 · Order 없음: 404 · RPC: 미호출유효한 로컬 입력만 원격 경계를 넘습니다.
유한한 deadline RPC

직접 Users 요청 또는 유효한 Order의 details 요청이 RPC를 실행합니다.

제한: 유한한 deadline · 산출물: 상류 결과원격 결과를 정한 범위에서 기다립니다.
완전·부분·오류 응답

details의 정해진 세 status만 partial 200이고 기타 실패는 502입니다.

partial: NOT_FOUND·DEADLINE_EXCEEDED·UNAVAILABLE · 기타: 502허용한 부분 실패만 공개 결과로 좁힙니다.

Gateway 라우팅·부분 실패·외부 응답 검증

입력 검사·로컬 Order 확인·유한한 deadline RPC·응답 검증·완전/선택 부분/오류 응답으로 경계가 바뀐다.

Nest Gateway · route별 실패 계약

같은 상류 실패를 직접 route와 조합 route의 약속으로 번역합니다

직접 Users route는 HTTP 오류를 반환합니다. Order가 있는 details route만 허용한 세 실패를 user:null·dependencyStatus로 나타냅니다. 나머지 상류 실패는 502이며 telemetry는 추가 구성합니다.

같은 상류 결과를 route 약속에 따라 번역한다 입력정수검증실패는RPC전에400,details의로컬Order부재는RPC전에404이다. 직접Users와Order가있는details만유한deadline의RPC로간다. 직접route는OKGET200/POST201,정한상류실패는HTTP오류이다. details는OK200또는NOT_FOUND/DEADLINE_EXCEEDED/UNAVAILABLE만user:null과dependencyStatus를담은200으로반환한다. 둘의기타상류실패는502이며partial로숨기지않는다. 유효Order를읽기전원격조회가먼저시작되거나잘못된입력이상류에가는흐름이아니다. 통과 details Order 있음 직접 직접 details 기타 실패 HTTP 경로 검증 실패는 400 · RPC 없음 route 선택 직접 Users / details details Order 조회 없음 → 404 · RPC 없음 Users RPC 직접 또는 Order 있음 유한 deadline 상류 결과 분류 OK / 허용 실패 / 기타 직접 결과 OK → 200 / 201 실패 → HTTP 오류 details 결과 OK200 · 허용실패200 user:null + 상태 예상 밖 상류 실패 둘 다 502
먼저 종료할 조건과 route별 실패 계약 입력정수검증실패는RPC전에400,details의로컬Order부재는RPC전에404이다. 직접Users와Order가있는details만유한deadline의RPC로간다. 직접route는OKGET200/POST201,정한상류실패는HTTP오류이다. details는OK200또는NOT_FOUND/DEADLINE_EXCEEDED/UNAVAILABLE만user:null과dependencyStatus를담은200으로반환한다. 둘의기타상류실패는502이며partial로숨기지않는다. 유효Order를읽기전원격조회가먼저시작되거나잘못된입력이상류에가는흐름이아니다. 있음 HTTP 경로 검증 ParseIntPipe 실패 → 400 통과 route 선택 직접 Users / details 직접 route Users RPC 상류 결과 details Order 조회 없으면 404 Users RPC Order가 있을 때 조회 유한한 deadline 상류 결과 분류 OK / 허용 실패 / 기타 직접 결과 OK:200/201 실패 → HTTP 오류 details 결과 OK → 200 허용실패 → 200 + 상태 예상 밖 상류 실패 둘 다 502 partial로 숨기지 않음
request budget

요청 한 건의 routing·composition·failure 경계

  1. HTTP 입력과 공통 정책을 먼저 확정한다

    Route parameter는 ParseIntPipe 같은 HTTP 경계에서 검증합니다. 인증·권한·rate limit과 request ID도 내부 호출 전에 적용해 잘못된 요청이 상류 서비스로 번지지 않게 합니다.

  2. route가 실제 처리 경로를 선택한다

    Users route는 gRPC unary RPC로 위임하고 Orders 생성은 로컬 provider를 호출합니다. Details route는 먼저 Order를 찾고, 존재할 때만 Users 조회를 이어 갑니다.

  3. 상류 호출에 유한한 deadline을 건다

    예제는 Users RPC에 Date.now() + 1_500을 전달합니다. 조합 route는 남은 HTTP 시간 예산 안에서 필요한 호출을 끝내야 하며, 운영 값은 tail latency와 부하 측정으로 정합니다.

  4. gRPC status를 route 계약으로 분류한다

    직접 Users route는 실패를 HTTP 예외로 번역합니다. Details route는 계약이 허용한 NOT_FOUND, timeout, unavailable만 user: null과 상태 필드로 표현하고 예상 밖 오류는 502로 실패합니다.

  5. 외부 응답과 관측 신호를 함께 남긴다

    운영 telemetry에서는 최종 HTTP status, partial 여부, route template, upstream RPC, gRPC code와 duration을 같은 request/trace ID로 연결합니다. 최소 예제의 실패 로그만으로 이 완료 조건이 충족되지는 않습니다.

failure mapping

같은 상류 결과도 route 계약에 따라 다르게 보인다

OK

직접 Users route: GET 200 · POST 201 · User
Order details 조합 route: 200 · dependencyStatus = "ok"
판정: 요청한 전체 데이터가 준비됨

NOT_FOUND

직접 Users route: 404 · User not found
Order details 조합 route: 200 · user: null, not-found
판정: Order가 있을 때만 조합 route의 부분 응답 허용

deadline 초과

직접 Users route: 504 · upstream timeout
Order details 조합 route: 200 · user: null, timeout
판정: 시간 예산 초과를 명시

UNAVAILABLE

직접 Users route: 503 · 이 예제의 계약
Order details 조합 route: 200 · user: null, unavailable
판정: 다른 외부 계약은 502 또는 503을 선택할 수 있음

INVALID_ARGUMENT

직접 Users route: 400 · 잘못된 직접 Users 입력
Order details 조합 route: 502 · 저장된 Order에서 만든 상류 요청 실패
판정: 같은 gRPC code도 어느 경계의 입력인지에 따라 외부 의미가 다름

그 밖의 예상하지 못한 상류 실패

직접 Users route: 502 · upstream failure
Order details 조합 route: 502 · partial로 숨기지 않음
판정: 알 수 없는 실패를 성공처럼 반환하지 않음
Users routes

직접 위임을 검증한다

GET /api/users/:userId와 POST /api/users가 각각 올바른 gRPC 메서드를 호출하는지 확인합니다. 성공 200·201과 실패 status별 응답, deadline을 검증하고, 비멱등 생성 요청은 idempotency 계약 없이 명시적·정책 재시도를 추가하지 않습니다.

Orders route

로컬 처리는 불필요한 상류 호출이 없어야 한다

POST /api/orders가 Orders provider만 호출하고 Users gRPC를 호출하지 않는지 확인합니다. 이 route의 도메인 검증과 데이터 소유권은 Gateway 공통 정책이 아니라 Orders 책임입니다.

composition

조합 순서와 부분 실패를 함께 검증한다

Details route는 Order가 없으면 404로 끝나고 Users를 호출하지 않아야 합니다. Order가 있으면 Users 결과를 합치며, 허용한 실패만 안정된 dependencyStatus 값으로 반환합니다.

observability

응답과 내부 경로를 한 trace로 맞춘다

운영 관측 계층을 구성한 뒤 gRPC 호출 유무, code, duration, 부분 응답 flag와 최종 status를 비교합니다. Route template을 metric label로 쓰고 높은 cardinality의 ID와 payload는 trace·보호된 로그에서 다룹니다.

운영 완료 조건: route가 의도한 내부 경로만 타고, deadline과 실패 분류가 문서화한 외부 응답으로 나타나며, 추가한 telemetry의 같은 request/trace ID로 원인을 추적할 수 있어야 합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 입력 정수 검증 실패는 RPC 전에 400, details의 로컬 Order 부재는 RPC 전에 404로 끝난다. 직접 Users와 Order가 있는 details만 유한한 deadline의 RPC로 간다. 직접 route의 성공은 GET 200/POST 201, 정해진 상류 실패는 HTTP 오류다. details의 성공은 200이며 NOT_FOUND/DEADLINE_EXCEEDED/UNAVAILABLE만 user:null과 dependencyStatus를 담은 200으로 반환한다. 둘의 기타 상류 실패는 502이며 partial로 숨기지 않는다. 유효한 Order를 읽기 전 원격 조회가 먼저 시작되거나 잘못된 입력이 상류에 가는 흐름이 아니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Gateway 라우팅·부분 실패·외부 응답 검증
6입력 정수 검증 실패는 RPC 전에 400, details의 로컬 Order 부재는 RPC 전에 404로 끝난다. 직접 Users와 Order가 있는 details만 유한한 deadline의 RPC로 간다. 직접 route의 성공은 GET 200/POST 201, 정해진 상류 실패는 HTTP 오류다. details의 성공은 200이며 NOT_FOUND/DEADLINE_EXCEEDED/UNAVAILABLE만 user:null과 dependencyStatus를 담은 200으로 반환한다. 둘의 기타 상류 실패는 502이며 partial로 숨기지 않는다. 유효한 Order를 읽기 전 원격 조회가 먼저 시작되거나 잘못된 입력이 상류에 가는 흐름이 아니다.
27OK 200 · 허용 실패 200
45허용 실패 →
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
RPC 전 검사

정수 입력과 details의 로컬 Order를 확인합니다.

입력 실패: 400 · Order 없음: 404 · RPC: 미호출유효한 로컬 입력만 원격 경계를 넘습니다.
유한한 deadline RPC

직접 Users 요청 또는 유효한 Order의 details 요청이 RPC를 실행합니다.

제한: 유한한 deadline · 산출물: 상류 결과원격 결과를 정한 범위에서 기다립니다.
완전·부분·오류 응답

details의 정해진 세 status만 partial 200이고 기타 실패는 502입니다.

partial: NOT_FOUND·DEADLINE_EXCEEDED·UNAVAILABLE · 기타: 502허용한 부분 실패만 공개 결과로 좁힙니다.

위 그림은 요청 하나가 거치는 판단 순서입니다. 정수 검증에 실패하면 RPC 없이 400으로, details route에서 로컬 주문이 없으면 RPC 없이 404로 끝납니다. 직접 Users route와, 주문이 있는 details route만 유한한 deadline으로 RPC를 실행합니다. 직접 route는 성공하면 200이나 201, 실패하면 정해 둔 HTTP 오류를 돌려주고, details route는 성공과 허용한 세 가지 실패를 모두 200으로 돌려주되 실패일 때는 user: null과 상태 필드를 담습니다. 두 route 모두 예상하지 못한 상류 실패는 부분 성공으로 숨기지 않고 502로 끝냅니다.

먼저 7장 두 번째 글의 gRPC 구성으로 준비한 users-service에서 npm run start:dev를 실행하고, 콘솔에 Users gRPC Microservice is listening on localhost:50051이 찍히는지 확인합니다. 대신 Users Service (HTTP Server)가 보이면 앞 글의 HTTP 설정이 남아 있는 것입니다. 다음으로 orders-service에서 npm run start:dev를 실행하고 API Gateway (Orders Service) is listening on port 3000을 확인합니다.

이제 Postman이나 curl로 다음 요청을 보내 봅니다.

  • 사용자 생성(게이트웨이에서 Users gRPC로): POST http://localhost:3000/api/users에 {"name": "Evan", "email": "[email protected]"}을 보내면 {"id":3,"name":"Evan","email":"[email protected]"}이 돌아오고, 사용자 서비스 콘솔에 생성 요청 로그가 찍힙니다. ID 3은 사용자 서버를 새로 시작해 초기 배열에 Alice와 Bob만 있을 때의 값이며, 7장 두 번째 글의 Charlie처럼 같은 프로세스에서 사용자를 이미 하나 만들었다면 4가 됩니다.
  • 사용자 조회(게이트웨이에서 Users gRPC로): GET http://localhost:3000/api/users/1은 {"id":1,"name":"Alice","email":"[email protected]"}을 돌려주고, 사용자 서비스 콘솔에 Users gRPC Service: Received request for user ID: 1이 찍힙니다.
  • 주문 생성(게이트웨이 자체 처리): POST http://localhost:3000/api/orders에 {"userId": 1, "item": "Monitor"}를 보내면 {"id":1,"userId":1,"item":"Monitor","createdAt":"..."}가 돌아옵니다. 이때 사용자 서비스 콘솔에는 아무것도 찍히지 않아야 합니다.
  • 주문과 사용자 정보 합성: GET http://localhost:3000/api/orders/1/details는 다음 응답을 돌려주고, 사용자 서비스 콘솔에 ID 1 조회 로그가 찍힙니다.
{
  "order": {
    "id": 1,
    "userId": 1,
    "item": "Monitor",
    "createdAt": "..."
  },
  "user": {
    "id": 1,
    "name": "Alice",
    "email": "[email protected]"
  },
  "dependencyStatus": "ok"
}

마지막으로 실패와 부분 응답을 확인합니다. 사용자 서비스를 멈추거나 테스트 대역으로 gRPC status를 주입한 뒤, 다음을 차례로 봅니다.

  • GET /api/users/1의 성공은 200, POST /api/users의 성공은 Nest 기본값인 201입니다. 실패는 이 예제의 규칙대로 INVALID_ARGUMENT는 400, UNAVAILABLE은 503, DEADLINE_EXCEEDED는 504, NOT_FOUND는 404이며, 내부 gRPC 오류 문자열이 응답에 그대로 드러나지 않아야 합니다.
  • GET /api/orders/1/details는 주문이 있을 때 user: null과 dependencyStatus의 "unavailable", "timeout", "not-found" 가운데 하나를 돌려줘야 합니다. 예상하지 못한 상류 실패는 부분 성공으로 숨기지 않고 502로 실패해야 합니다.
  • GET /api/users/not-a-number는 400, 존재하지 않는 주문의 details route는 404이며, 두 경우 모두 Users gRPC 호출이 일어나지 않아야 합니다.
  • 운영용 관측 계층을 추가했다면 route, 상류 RPC, gRPC status, 최종 HTTP 상태, 처리 시간, 부분 응답 여부를 같은 요청·추적 ID로 연결합니다. 원본 payload와 개인 정보는 지표 label이나 로그에 넣지 않습니다.

API 게이트웨이 패턴은 마이크로서비스의 복잡성을 클라이언트에게서 감추고 일관된 인터페이스를 제공하기 위한 구조입니다. 게이트웨이는 내부 구조를 숨기되 도메인의 소유권까지 가져오지 않으며, 외부 실패 규칙과 관측 방법까지 분명히 정해 둘 때 안정적인 진입점이 됩니다.


정리

  • API 게이트웨이는 클라이언트 요청의 단일 진입점으로, 라우팅·응답 합성·공통 인증·속도 제한·프로토콜 변환을 맡지만 단일 실패 지점과 병목이 될 수 있습니다.
  • 신원 확인과 route 단위 권한은 게이트웨이에, 리소스 소유자 확인처럼 도메인 데이터가 필요한 권한과 업무 규칙은 각 서비스에 둡니다.
  • NestJS에서는 HTTP controller 안에서 ClientGrpc 프록시로 상류 서비스를 호출하고, 각 RPC에 유한한 deadline을 둡니다.
  • 같은 gRPC status라도 직접 route와 합성 route에서 외부 결과가 다를 수 있으므로, route마다 실패 대응을 문서화하고 테스트합니다.
  • 입력 검증과 로컬 데이터 확인은 RPC 전에 끝내, 잘못된 요청이 상류 서비스로 번지지 않게 합니다.

연습 문제

1. 같은 상류 실패가 다른 HTTP 결과가 되는 이유

① GET /api/users/not-a-number를 보냈습니다. ② 메모리에 주문 ID 1이 이미 있고, GET /api/orders/1/details의 Users 조회가 NOT_FOUND를 돌려주었습니다. ③ GET /api/users/999의 Users 조회도 NOT_FOUND입니다. 각 HTTP 결과와 실제 Users 호출 여부를 예측해 보세요. 인증 같은 추가 정책은 없는 이 코드만을 기준으로 합니다.

해설 보기

①은 ParseIntPipe에서 400으로 끝나며, controller가 호출되기 전이므로 Users RPC가 없습니다. ②는 주문을 먼저 찾은 뒤 Users를 호출했고, 합성 route의 규칙대로 200과 user: null, dependencyStatus: "not-found"를 돌려줍니다. ③은 Users를 직접 조회하는 route이므로 같은 gRPC NOT_FOUND를 HTTP 404로 바꿉니다.

②는 상태 코드만 보면 데이터가 모두 준비된 것처럼 오해할 수 있으므로, 소비자는 dependencyStatus를 함께 읽어야 합니다. 이 결과들은 게이트웨이라는 이름이 보장하는 공통 동작이 아니라, 각 메서드가 구현한 외부 규칙입니다.

2. Users 서버의 필터가 빠졌다면

7장 두 번째 글의 연습 문제처럼 users-service에서 GrpcExceptionFilter 등록이 빠졌습니다. 주문 1은 존재하지 않는 사용자 999의 주문입니다. GET /api/orders/1/details는 어떤 응답을 돌려줄까요?

해설 보기

필터가 없으므로 사용자 서비스가 던진 GrpcNotFoundException은 NOT_FOUND가 아니라 UNKNOWN status로 전달됩니다. 합성 route가 부분 응답으로 허용하는 실패는 NOT_FOUND, DEADLINE_EXCEEDED, UNAVAILABLE뿐이므로, UNKNOWN은 예상하지 못한 실패로 분류되어 502 Bad Gateway가 됩니다.

원래 기대한 응답은 200과 dependencyStatus: "not-found"였습니다. 게이트웨이의 실패 대응은 상류 서비스가 올바른 status를 보낸다는 전제 위에서 동작하므로, 게이트웨이를 테스트할 때 상류 서비스의 오류 직렬화도 함께 확인해야 합니다.

3. 주문 소유자 확인은 어디에

로그인한 사용자가 자기 주문의 details만 볼 수 있어야 한다는 요구사항이 생겼습니다. JWT에는 사용자 ID가 들어 있습니다. 이 확인을 게이트웨이의 공통 guard에 둘지, 주문 로직에 둘지 판단해 보세요.

해설 보기

JWT 서명을 검증하고 로그인 여부를 확인하는 일은 모든 route에 공통이므로 게이트웨이의 guard에 둡니다. 하지만 “이 주문의 소유자가 현재 사용자인가”는 주문 데이터를 읽어야 알 수 있는 도메인 권한입니다.

그래서 소유자 비교는 주문을 조회하는 Orders 로직에서 order.userId와 인증된 사용자 ID를 비교하는 방식으로 둡니다. 이 학습용 앱에서는 Orders provider가 같은 프로세스에 있지만, 나중에 Orders를 별도 서비스로 분리해도 이 판단은 Orders 서비스와 함께 옮겨 가야 합니다. 다른 사용자의 주문이라면 존재 여부를 숨기려고 404로 응답하는 정책을 고를 수 있습니다.

이것으로 7장을 마칩니다. 7장에서는 서비스를 나누는 기준과 TCP, gRPC, Kafka라는 세 가지 통신 방식, 그리고 그 앞에 둘 API 게이트웨이까지 이어서 살펴보았습니다. 다음 장의 단위 테스트 작성과 모킹부터는 지금까지 만든 provider와 controller를 테스트로 검증하는 방법을 다룹니다.