본문으로 건너뛰기

안동민 개발노트

본문 시작

API 게이트웨이 패턴 구현

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

지난 절에서는 NestJS와 Kafka를 활용하여 마이크로서비스 간 비동기 통신 및 이벤트 기반 아키텍처를 구현하는 방법을 살펴보았습니다.

이제 7장의 마지막으로, 마이크로서비스 아키텍처에서 중요한 역할을 하는 API 게이트웨이(API Gateway) 패턴에 대해 알아보고, NestJS를 사용하여 이를 구현하는 방법에 대해 자세히 설명하겠습니다.

마이크로서비스 아키텍처는 백엔드를 여러 개의 작은 서비스로 분해하여 독립적인 개발, 배포, 확장을 가능하게 합니다.

그러나 클라이언트(웹 애플리케이션, 모바일 앱 등)의 관점에서는 수많은 마이크로서비스와 직접 통신하는 것이 복잡하고 비효율적일 수 있습니다.

이럴 때 필요한 것이 바로 API 게이트웨이입니다.

API Gateway는 클라이언트 앞의 단일 진입점이다

클라이언트가 여러 마이크로서비스 주소와 프로토콜을 알지 않도록, 게이트웨이가 외부 요청을 내부 서비스 호출로 바꿉니다.

  1. 웹·모바일 앱

    Client 하나의 HTTP API만 호출합니다.

  2. 인증·라우팅·조합

    Gateway 공통 정책을 적용하고 필요한 서비스로 요청을 나눕니다.

  3. Users / Orders

    Services 각 서비스는 자신의 도메인 규칙만 처리합니다.

  4. Routing

    URL과 method를 보고 내부 대상을 고릅니다.

  5. Auth

    인증과 권한 검사를 한 곳에서 통일합니다.

  6. Composition

    여러 응답을 클라이언트 형태로 합칩니다.

  7. Failure map

    내부 오류를 HTTP 상태로 바꿉니다.


게이트웨이 패턴이란?

API 게이트웨이는 클라이언트의 모든 API 요청을 단일 진입점으로 받아들이고, 이 요청을 적절한 마이크로서비스로 라우팅하는 서비스입니다.

즉, 클라이언트와 백엔드 마이크로서비스 간의 중개자 역할을 수행합니다.

API 게이트웨이의 주요 역할
  • 요청 라우팅(Request Routing): 클라이언트의 요청 URL이나 헤더 등을 분석하여 해당 요청을 처리할 적절한 마이크로서비스로 전달합니다.
  • 요청 합성(Request Composition): 여러 마이크로서비스의 응답을 받아 클라이언트에 필요한 형태로 조합하여 단일 응답으로 제공합니다. (Backend For Frontend, BFF 패턴과도 관련)
  • 인증 및 권한 부여(Authentication & Authorization): 모든 요청에 대해 일관된 인증 및 권한 부여 로직을 적용하여 개별 마이크로서비스에 중복 구현하는 것을 방지합니다.
  • 속도 제한(Rate Limiting): 특정 클라이언트나 사용자로부터의 요청 속도를 제한하여 서비스 과부하를 방지합니다.
  • 캐싱(Caching): 자주 요청되는 데이터를 캐싱하여 백엔드 서비스의 부하를 줄이고 응답 속도를 높입니다.
  • 로깅 및 모니터링(Logging & Monitoring): 모든 API 요청에 대한 중앙 집중식 로깅 및 모니터링 기능을 제공합니다.
  • 프로토콜 변환(Protocol Translation): 클라이언트는 HTTP/REST로 요청하고, 게이트웨이가 이를 gRPC나 Kafka 이벤트 등으로 변환하여 백엔드 서비스와 통신할 수 있습니다.
  • 장애 처리(Fault Tolerance): 백엔드 서비스의 장애를 감지하고, 서킷 브레이커(Circuit Breaker)나 재시도(Retry)와 같은 패턴을 적용하여 클라이언트에 안정적인 응답을 제공합니다.
API 게이트웨이의 장점
  • 클라이언트 복잡도 감소: 클라이언트가 여러 서비스 엔드포인트를 알 필요 없이 단일 엔트리포인트만 호출하면 됩니다.
  • 보안 강화: 인증/권한 부여를 중앙에서 처리하여 보안을 일관되게 적용할 수 있습니다.
  • 유연한 마이크로서비스 변경: 백엔드 서비스 변경이 클라이언트에 미치는 영향을 최소화합니다.
  • 성능 최적화: 캐싱, 압축 등으로 응답 속도를 개선할 수 있습니다.
API 게이트웨이의 단점
  • 단일 실패 지점(Single Point of Failure): 게이트웨이가 다운되면 전체 시스템이 마비될 수 있습니다 (고가용성 확보 필요).
  • 성능 병목(Performance Bottleneck): 모든 요청이 게이트웨이를 통과하므로, 게이트웨이가 성능 병목이 될 수 있습니다 (확장성 고려).
  • 복잡성 증가: 게이트웨이 자체의 개발, 배포, 유지보수 복잡성이 추가됩니다.

게이트웨이에 모을 공통 정책과 각 마이크로서비스에 남겨야 할 도메인 책임을 분리하면, 인증·속도 제한·응답 합성의 경계가 더 선명해집니다.

공통 정책은 Gateway에, 도메인 판단은 Service에 둔다

게이트웨이가 모든 일을 하면 병목이 되고, 서비스가 공통 정책을 반복하면 일관성이 깨집니다. 책임 배치 기준이 먼저 필요합니다.

  1. 외부 계약과 공통 정책

    Gateway 인증, rate limit, CORS, 응답 모양을 통일합니다.

  2. 업무 규칙과 데이터 소유

    Service 주문 생성, 사용자 조회 같은 도메인 판단을 맡습니다.

  3. DTO와 오류 규칙

    Shared contract 프로토콜이 달라도 같은 의미로 해석될 계약을 공유합니다.

질문GatewayService
모든 API에 똑같이 적용되는가인증, 요청 제한, 공통 헤더개별 업무 검증은 제외
도메인 데이터를 판단하는가결과를 조합하되 규칙은 갖지 않음소유 데이터와 업무 규칙 판단
실패를 외부 계약으로 바꾸는가gRPC/Kafka 오류를 HTTP 응답으로 변환실제 실패 원인과 복구 기준 제공

위 다이어그램은 클라이언트 직접 호출 구조와 API 게이트웨이 단일 진입점 구조를 비교해, 라우팅·인증·요청 합성 책임이 어디에 배치되는지 보여줍니다.


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

API 게이트웨이는 인증, 라우팅, 프로토콜 변환, 장애 응답 경계를 나눠 읽습니다.

Orders Service는 HTTP Gateway와 gRPC Client 역할을 함께 맡는다

클라이언트는 `/api`만 호출하고, 게이트웨이는 자체 주문 로직과 Users gRPC 호출을 조합해 응답합니다.

  1. @Controller('api')

    HTTP surface GET /api/orders/1/details 외부 요청 표면은 HTTP로 고정합니다.

  2. OrdersController

    Gateway logic lastValueFrom(userService.GetUserById()) 주문 조회와 사용자 조회를 한 응답으로 합칩니다.

  3. Users gRPC

    Internal call localhost:50051 내부 서비스는 gRPC 계약으로 호출합니다.

  4. Users Service로 라우팅

    /api/users 사용자 생성과 조회는 gRPC로 위임합니다.

  5. Orders Service 자체 처리

    /api/orders 주문 생성은 게이트웨이 앱의 도메인 로직을 씁니다.

  6. 응답 합성

    /api/orders/:id/details 주문과 사용자 정보를 묶어 클라이언트에 돌려줍니다.

NestJS에서는 HTTP 라우팅과 마이크로서비스 클라이언트를 한 애플리케이션 안에서 함께 구성할 수 있습니다.

여기서는 orders-service를 API 게이트웨이 역할을 겸하도록 확장하여 이전의 users-service와 통신하는 예시를 봅니다.

시나리오: 웹 클라이언트가 orders-service (API 게이트웨이)에 HTTP 요청을 보내면, orders-service는 내부적으로 users-service (gRPC)를 호출하여 데이터를 조합하고 클라이언트에 응답합니다.

위 다이어그램은 클라이언트 요청이 게이트웨이에서 인증·속도 제한·라우팅 단계를 거친 뒤 gRPC 서비스 호출과 응답 조합으로 이어지는 흐름을 보여줍니다.

사전 준비
  • 7장 2절에서 구축한 users-service (gRPC 서버)가 localhost:50051에서 실행 중이어야 합니다.
  • 새로운 orders-service (API 게이트웨이 겸) 프로젝트를 시작합니다.

Orders Service (API Gateway) 설정

단계 1: 새 NestJS 프로젝트 생성 (기존 orders-service를 재사용하거나 새로 생성)
# orders-service 프로젝트가 없다면 새로 생성 (있다면 스킵)
nest new orders-service --skip-install
cd orders-service
npm install @nestjs/microservices @grpc/grpc-js @grpc/proto-loader rxjs
npm install --save-dev @types/grpc__proto-loader
단계 2: main.ts 파일 수정 (HTTP 서버 역할)

orders-service는 클라이언트로부터 HTTP 요청을 받아야 하므로, 일반적인 NestJS HTTP 서버로 구성됩니다.

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

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // CORS 활성화 (옵션, 클라이언트 웹 개발 시 필요)
  app.enableCors();
  await app.listen(3000); // 클라이언트가 접근할 API 게이트웨이 포트
  console.log('API Gateway (Orders Service) is listening on port 3000');
}
bootstrap();
단계 3: .proto 파일 복사 또는 공유 설정

users-service와 동일한 users.proto 파일을 orders-service 프로젝트의 proto 폴더에 복사하거나, 별도의 공유 모듈/패키지를 통해 관리하는 것이 좋습니다.

여기서는 orders-service/proto/users.proto로 복사했다고 가정합니다.

// orders-service/proto/users.proto (users-service의 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;
}
단계 4: OrdersModule에 gRPC 클라이언트 등록

orders-serviceusers-service (gRPC)와 통신해야 하므로, gRPC 클라이언트를 등록합니다.

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'), // .proto 파일 경로
          url: 'localhost:50051', // Users gRPC 서비스 주소
          loader: {
            keepCase: true,
            longs: String,
            enums: String,
            defaults: true,
            oneofs: true,
          },
        },
      },
    ]),
  ],
  controllers: [OrdersController],
  providers: [OrdersService],
})
export class OrdersModule {}
단계 5: 주문 서비스 컨트롤러 (API 게이트웨이 로직 구현)

이 컨트롤러는 클라이언트의 HTTP 요청을 받아, 내부적으로 gRPC USERS_SERVICE를 호출하고, 그 결과를 HTTP 응답으로 변환합니다.

여기서는 7장 2절의 orders.controller.ts와 유사하지만, API Gateway의 라우팅과 조합 기능을 명확히 보여줍니다.

orders-service/src/orders/orders.controller.ts (수정)
import { Controller, Get, Post, Body, Param, Inject, OnModuleInit, HttpException, HttpStatus } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { OrdersService } from './orders.service';
import { Observable, lastValueFrom } from 'rxjs';

// .proto 파일에 정의된 서비스 인터페이스 정의
interface UserService {
  GetUserById(data: { id: number }): Observable<User>;
  CreateUser(data: { name: string; email: string }): Observable<User>;
}

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

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

@Controller('api') // 모든 API 요청의 접두사 (API Gateway 역할 명시)
export class OrdersController implements OnModuleInit {
  private userService: UserService;

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

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

  // --- API 게이트웨이 라우팅 및 요청 조합 예시 ---

  // GET /api/users/:userId : 사용자 정보 조회 (Users Service로 라우팅)
  @Get('users/:userId')
  async getUser(@Param('userId') userId: string): Promise<User> {
    const id = parseInt(userId, 10);
    if (isNaN(id)) {
      throw new HttpException('Invalid user ID', HttpStatus.BAD_REQUEST);
    }

    try {
      const user = await lastValueFrom(this.userService.GetUserById({ id }));
      if (!user || user.id === 0) { // gRPC에서 사용자 없는 경우 id=0으로 가정
        throw new HttpException('User not found', HttpStatus.NOT_FOUND);
      }
      return user;
    } catch (error) {
      console.error('gRPC Error for getUser:', error.details || error.message);
      // gRPC 에러를 HTTP 에러로 변환하여 클라이언트에 전달
      throw new HttpException(
        `Failed to fetch user: ${error.details || error.message}`,
        error.code === 5 ? HttpStatus.NOT_FOUND : HttpStatus.INTERNAL_SERVER_ERROR // gRPC NOT_FOUND(5)를 HTTP 404로 매핑
      );
    }
  }

  // POST /api/users : 사용자 생성 (Users Service로 라우팅)
  @Post('users')
  async createUser(@Body() userDto: { name: string; email: string }): Promise<User> {
    try {
      const newUser = await lastValueFrom(this.userService.CreateUser(userDto));
      return newUser;
    } catch (error) {
      console.error('gRPC Error for createUser:', error.details || error.message);
      throw new HttpException(
        `Failed to create user: ${error.details || error.message}`,
        HttpStatus.INTERNAL_SERVER_ERROR
      );
    }
  }

  // POST /api/orders : 주문 생성 (Orders Service 자체 처리)
  @Post('orders')
  createOrder(@Body() orderDto: { userId: number; item: string }): Order {
    return this.ordersService.createOrder(orderDto.userId, orderDto.item);
  }

  // GET /api/orders/:orderId/details : 주문 및 관련 사용자 정보 조합 (Orders Service + Users Service)
  // 이 엔드포인트는 API 게이트웨이의 "요청 합성" 기능을 보여줍니다.
  @Get('orders/:orderId/details')
  async getOrderDetails(@Param('orderId') orderId: string): Promise<any> {
    const id = parseInt(orderId, 10);
    if (isNaN(id)) {
      throw new HttpException('Invalid order ID', HttpStatus.BAD_REQUEST);
    }

    // 1. 주문 정보 조회 (Orders Service 자체 로직)
    const order = this.ordersService.findOne(id);
    if (!order) {
      throw new HttpException('Order not found', HttpStatus.NOT_FOUND);
    }

    // 2. 사용자 정보 조회 (Users Service 호출 - gRPC)
    try {
      const user = await lastValueFrom(this.userService.GetUserById({ id: order.userId }));
      if (!user || user.id === 0) {
        // 사용자 정보가 없더라도 주문 정보는 반환
        return { order, user: null, message: 'User not found for this order.' };
      }
      return { order, user }; // 두 서비스의 응답을 조합하여 반환
    } catch (error) {
      console.error('gRPC Error for getOrderDetails (user):', error.details || error.message);
      // 사용자 서비스에 문제가 있더라도 주문 정보는 반환
      return { order, user: null, message: `Failed to fetch user details: ${error.details || error.message}` };
    }
  }
}
  • @Controller('api'): 모든 엔드포인트에 /api 접두사를 붙여 API 게이트웨이 역할을 명시합니다.
  • 라우팅: /api/users 경로는 users-service로 라우팅되고, /api/ordersorders-service 자체에서 처리됩니다.
  • 요청 합성: /api/orders/:orderId/details 엔드포인트는 orders-service 내부에서 주문 정보를 조회한 후, 해당 주문의 userId를 가지고 다시 users-service를 호출하여 사용자 정보를 가져옵니다. 최종적으로 두 정보를 조합하여 클라이언트에 단일 응답을 제공합니다.
  • 에러 처리: gRPC에서 발생한 에러를 캐치하여 HTTP 상태 코드와 메시지로 변환하여 클라이언트에 전달합니다. (예: status.NOT_FOUND (gRPC 코드 5)를 HttpStatus.NOT_FOUND (HTTP 404)로 매핑).

게이트웨이의 품질은 성공 응답보다 실패 응답에서 더 잘 드러납니다.

아래 다이어그램은 gRPC 오류, 부분 실패, 재시도 여부를 HTTP 응답으로 바꾸는 판단 흐름을 정리합니다.

Gateway는 내부 실패를 클라이언트가 이해할 HTTP 응답으로 바꾼다

gRPC 오류, 부분 실패, timeout은 그대로 노출하지 않고 외부 API 계약에 맞는 상태 코드와 메시지로 정리합니다.

  1. gRPC / timeout

    Internal error gRPC / timeout Users Service 호출이 실패하거나 늦어집니다.

  2. 매핑 또는 부분 응답

    Gateway decision 매핑 또는 부분 응답 재시도할지, 404/502로 바꿀지, 주문만 반환할지 정합니다.

  3. 일관된 응답

    HTTP contract 일관된 응답 클라이언트는 내부 프로토콜을 몰라도 같은 규칙으로 처리합니다.

내부 상태Gateway 판단HTTP 응답
NOT_FOUND없는 사용자로 확정404 User not found
UNAVAILABLE상류 서비스 장애502 또는 fallback 응답
Order exists, User fail부분 실패 허용200 + user:null + message
단계 6: OrdersService 구현 (주문 데이터 관리)
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);
  }
}
단계 7: AppModuleOrdersModule 임포트 (동일)
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 {}

게이트웨이 동작 검증

API 게이트웨이는 인증, 라우팅, 프로토콜 변환, 장애 응답 경계를 나눠 읽습니다.

게이트웨이 검증은 진입 정책부터 응답 조합까지 나눠 본다

엔드포인트가 200을 반환하는지만 보지 말고, 요청이 게이트웨이의 각 책임을 통과했는지 확인합니다.

  1. HTTP 진입

    Entry GET /api/users/1 정상 이면 게이트웨이 포트와 라우터가 열려 있습니다.

  2. 공통 정책

    Policy auth / cors / limit 모든 요청에 같은 필터가 적용되는지 봅니다.

  3. gRPC 호출

    Upstream UserService.GetUserById Users Service 로그에 요청이 도착해야 합니다.

  4. 응답 조합

    Compose { order, user } 여러 내부 결과가 하나의 HTTP 응답으로 합쳐집니다.

게이트웨이 패턴 개념과 NestJS에서 API 게이트웨이 구현하기 흐름을 함께 정리한 보조 다이어그램입니다.

Gateway 테스트는 라우트별 기대 결과와 상류 호출을 함께 확인한다

게이트웨이가 직접 처리한 응답인지, gRPC를 호출한 응답인지, 두 결과를 조합한 응답인지 분리해 검증합니다.

  1. 요청이 어디로 가는가

    Route `/api/users`, `/api/orders`, `/api/orders/:id/details`를 따로 봅니다.

  2. 내부 호출이 발생했는가

    Upstream Users Service 로그와 Orders Service 로그를 같이 확인합니다.

  3. 외부 계약이 유지되는가

    Response 성공·부분 실패·not found가 HTTP 규칙으로 보이는지 봅니다.

테스트기대 결과실패 신호
POST /api/usersUsers gRPC CreateUser 호출 후 User 반환게이트웨이만 성공하고 Users 로그 없음
POST /api/ordersOrders 자체 로직으로 Order 생성불필요한 Users 호출 발생
GET /api/orders/1/detailsOrder와 User를 하나의 응답으로 조합부분 실패 메시지 없이 전체 500 처리
Users Service (gRPC 서버) 시작
  • 7장 2절에서 만든 users-service 프로젝트로 이동합니다.
  • cd users-service
  • npm run start:dev
  • 콘솔에 Users gRPC Microservice is listening on localhost:50051 메시지 확인.
Orders Service (API Gateway) 시작
  • cd orders-service
  • npm run start:dev
  • 콘솔에 API Gateway (Orders Service) is listening on port 3000 메시지 확인.
API 테스트 (Postman 또는 cURL)
  • 새로운 사용자 생성 (API Gateway -> Users Service gRPC)
  • POST http://localhost:3000/api/users
  • Headers: Content-Type: application/json
  • Body: {"name": "Evan", "email": "evan@example.com"}
  • 응답: {"id":4,"name":"Evan","email":"evan@example.com"}
  • Users Service 콘솔: Users gRPC Service: Received request to create user: {"name":"Evan","email":"evan@example.com"} 확인.
  • 사용자 정보 조회 (API Gateway -> Users Service gRPC)
  • GET http://localhost:3000/api/users/1
  • 응답: {"id":1,"name":"Alice","email":"alice@example.com"}
  • Users Service 콘솔: Users gRPC Service: Received request for user ID: 1 확인.
  • 새로운 주문 생성 (API Gateway 자체 처리)
  • POST http://localhost:3000/api/orders
  • Headers: Content-Type: application/json
  • Body: {"userId": 1, "item": "Monitor"}
  • 응답: {"id":1,"userId":1,"item":"Monitor","createdAt":"2023-06-23T..."}
  • 주문 및 사용자 정보 조회 (API Gateway 조합)
  • GET http://localhost:3000/api/orders/1/details
  • 응답:
    {
      "order": {
        "id": 1,
        "userId": 1,
        "item": "Monitor",
        "createdAt": "2023-06-23T..."
      },
      "user": {
        "id": 1,
        "name": "Alice",
        "email": "alice@example.com"
      }
    }
  • Users Service 콘솔: Users gRPC Service: Received request for user ID: 1 확인.

API 게이트웨이 패턴은 마이크로서비스 아키텍처의 복잡성을 줄이고, 클라이언트에 일관된 인터페이스를 제공하기 위한 구조입니다.

NestJS에서는 HTTP 서버 기능과 마이크로서비스 클라이언트 기능을 한 애플리케이션 안에서 함께 구성할 수 있습니다.

이것으로 7장 마이크로서비스 아키텍처를 마칩니다.

NestJS 마이크로서비스 구성, 통신 방식, API 게이트웨이 패턴을 한 흐름으로 정리했습니다.

API Gateway는 외부 계약을 중앙화하고 내부 도메인은 분리한다

좋은 게이트웨이는 클라이언트에는 단순한 API를 제공하고, 내부 서비스에는 자신의 책임만 남깁니다.

  1. Gateway에 중앙화

    인증, 권한, rate limit HTTP route와 응답 포맷 gRPC/Kafka 오류의 외부 응답 변환

  2. Service에 남김

    도메인 규칙과 데이터 소유권 내부 서비스 계약의 실제 처리 장애 원인과 복구 가능한 상태 제공

  3. 단일 진입점

    클라이언트는 `/api` 계약만 압니다.

  4. 내부 호출

    Gateway는 필요한 서비스만 호출합니다.

  5. 응답 합성

    여러 결과를 한 응답으로 정리합니다.

  6. 실패 번역

    내부 오류를 HTTP 계약으로 바꿉니다.