Nodejs

 

 

17. 구매상품 정보 가져오기 – Order ↔ Product 마이크로서비스 통신

 

핵심은 Order 서비스가 Product 서비스와 TCP 기반 NestJS 마이크로서비스 RPC로 통신해 상품 정보를 받아오는 과정을 실전 관점에서 끝까지 다루는 것입니다. 실습 코드 전체를 포함하고, 각 블록마다 자세한 해설을 덧붙였습니다.

1) 아키텍처 & 디렉토리 구조

apps/
 ├─ order/
 │   └─ order.service.ts
 ├─ product/
 │   ├─ product.controller.ts
 │   ├─ product.service.ts
 │   ├─ entity/
 │   │   └─ product.entity.ts
 │   └─ dto/
 │       └─ get-products-info.dto.ts
 └─ auth/
     └─ auth.controller.ts
libs/
 └─ common/
     └─ interceptor/
         ├─ rpc.interceptor.ts
         └─ index.ts
  • Order: 주문 생성 시 사용자/상품 데이터를 외부 MS에서 받아옴

  • Product: 상품 데이터 보유, RPC 메시지 { cmd: 'get_products_info' } 수신

  • Auth/User(이전 강의): 토큰 파싱 및 사용자 정보 반환

  • common/interceptor: 마이크로서비스 응답을 { status, data|error }로 표준화

 

 

2) 통신 개요 – 흐름 한눈에 보기

A. 통합 시퀀스 (Order → Auth → User → Product)

sequenceDiagram
    participant Client as Client(웹/앱)
    participant Order as Order(API)
    participant Auth as Auth(MS)
    participant User as User(MS)
    participant Product as Product(MS)

    Client->>Order: createOrder(productIds, address, payment, <Bearer token>)
    Order->>Auth: { cmd: 'parse_bearer_token' , token }
    Auth-->>Order: { status:'success', data:{ sub:userId, ... } }
    Order->>User: { cmd:'get_user_info', userId }
    User-->>Order: { status:'success', data:UserInfo }
    Order->>Product: { cmd:'get_products_info', productIds }
    Product-->>Order: { status:'success', data:Products[] }
    Note right of Order: 상품 합계 계산, 재고/금액 검증, 주문 DB 저장, 결제 시도

B. 핵심 시퀀스 (Order → Product)

sequenceDiagram
    participant Order
    participant Product

    Order->>Product: send({ cmd:'get_products_info' }, { productIds })
    Product-->>Order: { status:'success', data:[ {id,name,price,...}, ... ] }
    Note right of Order: 응답을 주문 아이템 모델로 변환 → 합계/검증 로직으로 연결

포인트

  • NestJS 마이크로서비스의 RPC 통신(기본 TCP Transport)을 사용합니다.

  • ClientProxy.send()는 RxJS Observable을 반환하므로, 서버 쪽에서 응답이 올 때까지 비동기 RPC를 수행하고, lastValueFrom()으로 결과를 받아옵니다.

  • 응답은 RpcInterceptor로 { status:'success'|'error', data|error } 포맷을 강제해 호출부 판단 로직이 단순해집니다.

 

 

3) 런타임 구성 – 서버/클라이언트 포트와 호스트

Product 서비스 부트스트랩 (서버)

// main.ts (발췌)
app.connectMicroservice<MicroserviceOptions>({
  transport: Transport.TCP,
  options: {
    host: '0.0.0.0',
    port: parseInt(process.env.TCP_PORT) || 4002,
  },
});
await app.startAllMicroservices();
  • **host: '0.0.0.0'**로 바인딩해야 Docker 컨테이너 외부(같은 네트워크의 다른 컨테이너)에서 접근이 가능합니다.

  • TCP_PORT 환경변수로 마이크로서비스 포트를 관리하세요. (예: 4002)

Order 서비스의 ClientProxy 등록 (예시)

실제 코드에 DI 토큰과 환경변수가 이미 세팅되어 있다고 가정하고, 필수 개념만 강조합니다.

ClientsModule.register([
  {
    name: PRODUCT_SERVICE, // @app/common 에 정의된 토큰
    transport: Transport.TCP,
    options: { host: process.env.PRODUCT_HOST, port: +process.env.PRODUCT_TCP_PORT },
  },
]);
  • Docker Compose 사용 시 host는 product 서비스명을 쓰는 게 안전합니다. localhost 를 쓰면 컨테이너 내부 루프백을 바라봐 연결이 끊깁니다.

  • 포트 미스매치(서버 4002, 클라이언트 3001 등)나 서비스명 오류가 가장 흔한 Connection closed 원인입니다.

실무 팁: 헬스체크 엔드포인트(/product/test)를 두고 기동 후 정상 여부를 빠르게 확인하세요.

 

4) 데이터 계약(Contract) – 요청/응답 페이로드

요청 (Order → Product)

{
  "productIds": ["uuid-1", "uuid-2", "uuid-3"]
}

응답 (Product → Order, RpcInterceptor 적용)

{
  "status": "success",
  "data": [
    { "id": "uuid-1", "name": "사과!", "price": 1000, "stock": 2, "description": "..." },
    { "id": "uuid-2", "name": "메론",  "price": 2000, "stock": 1, "description": "..." }
  ]
}
  • 실패 시: { "status":"error", "error": <RpcException payload> }

Order 쪽에서는 주문 항목 모델로 매핑해 후속 합계/검증 로직에서 사용합니다.

 

 

5) 코드 – 전체 + 해설

(1) OrderService – 상품 조회 통합

import { Inject, Injectable } from '@nestjs/common';
import { CreateOrderDto } from './dto/create-order.dto';
import { ClientProxy } from '@nestjs/microservices';
import { lastValueFrom } from 'rxjs';
import { PRODUCT_SERVICE, USER_SERVICE } from '@app/common';
import { PaymentCancelledException } from './exception/payment-cancelled.exception';

@Injectable()
export class OrderService {
  constructor(
    @Inject(USER_SERVICE) private readonly userServiceClient: ClientProxy,
    @Inject(PRODUCT_SERVICE) private readonly productServiceClient: ClientProxy,
  ) {}

  async createOrder(createOrderDto: CreateOrderDto, token: string) {
    const { productIds, address, payment } = createOrderDto;

    // 1) 사용자 정보
    const user = await this.getUserFromToken(token);

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

    // 3) 총 금액 계산
    // 4) 금액/재고 검증
    // 5) 주문 DB 저장
    // 6) 결제 시도
    // 7) 주문 상태 업데이트
    // 8) 결과 반환
  }

  async getUserFromToken(token: string) {
    const resp = await lastValueFrom(
      this.userServiceClient.send({ cmd: 'parse_bearer_token' }, { token }),
    );
    if (resp.status === 'error') {
      throw new PaymentCancelledException(resp);
    }
    const userId = resp.data.sub;
    const uResp = await lastValueFrom(
      this.userServiceClient.send({ cmd: 'get_user_info' }, { userId }),
    );
    if (uResp.status === 'error') {
      throw new PaymentCancelledException(uResp);
    }
    return uResp.data;
  }

  async getProductsByIds(productIds: string[]) {
    const resp = await lastValueFrom(
      this.productServiceClient.send(
        { cmd: 'get_products_info' },
        { productIds },
      ),
    );

    if (resp.status === 'error') {
      // 사용자에게 노출할 메시지는 비즈니스 용어로 정리해 주는 것이 좋습니다.
      throw new PaymentCancelledException('상품 정보가 잘못되었습니다.');
    }

    // 응답 엔티티 → 주문 항목 모델로 사상
    return resp.data.map((product) => ({
      productId: product.id,
      name: product.name,
      price: product.price,
      // 필요 시 stock/description 등도 보존
    }));
  }
}

해설

  • ClientProxy.send()는 메시지 패턴과 페이로드를 받고, Observable을 반환합니다.

  • lastValueFrom()은 RPC 응답 스트림이 완료될 때 마지막 값을 Promise로 변환해 줍니다.

  • 실패 응답은 공통 예외(PaymentCancelledException)로 변환해 주문 플로우 초기에 중단시켜 비용을 줄입니다.

 

(2) ProductController – RPC 엔드포인트

import { Controller, Get, Post, UseInterceptors, UsePipes, ValidationPipe } from '@nestjs/common';
import { ProductService } from './product.service';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { GetProductsInfo } from './dto/get-products-info.dto';
import { RpcInterceptor } from '@app/common/interceptor';

@Controller('product')
export class ProductController {
  constructor(private readonly productService: ProductService) {}

  @Get('test')
  test() {
    console.log('????test');
    return { message: '✅ API 정상 작동 중입니다!' };
  }

  @Post('sample')
  createSamples() {
    console.log('????createSamples');
    return this.productService.createSamples();
  }

  @MessagePattern({ cmd: 'get_products_info' })
  @UsePipes(ValidationPipe)
  @UseInterceptors(RpcInterceptor)
  getProductsInfo(@Payload() data: GetProductsInfo) {
    console.log('????getProductsInfo');
    return this.productService.getProductsInfo(data.productIds);
  }
}

해설

  • @MessagePattern은 마이크로서비스 RPC 수신 포인트입니다. HTTP 라우팅과 별개로 동작합니다.

  • ValidationPipe + GetProductsInfo DTO로 요청 데이터의 형태를 보장합니다.

  • RpcInterceptor는 성공/실패 응답을 표준화합니다.

 

 

(3) ProductService – TypeORM 조회 & 샘플 데이터

import { Inject, Injectable } from '@nestjs/common';
import { In, Repository } from 'typeorm';
import { Product } from './entity/product.entity';
import { InjectRepository } from '@nestjs/typeorm';

@Injectable()
export class ProductService {
  constructor(
    @InjectRepository(Product)
    private readonly productRepository: Repository<Product>,
  ) {}

  async getProductsInfo(productIds: string[]) {
    const products = await this.productRepository.find({
      where: { id: In(productIds) },
    });
    return products;
  }

  async createSamples() {
    const data = [
      { name: '사과!', price: 1000, description: '맛있는 청주사과', stock: 2 },
      { name: '메론',  price: 2000, description: '머스크 메론',   stock: 1 },
      { name: '수박',  price: 3000, description: '씨없는 수박',   stock: 10 },
      { name: '브로콜리', price: 2000, description: '맛없는 브로콜리', stock: 0 },
      { name: '바나나', price: 1500, description: '노란 바나나', stock: 3 },
    ];
    await this.productRepository.save(data);
    return true;
  }
}

해설

  • In(productIds)로 배치 조회하여 N+1 문제를 피합니다.

  • 테스트 용 createSamples()는 개발 환경 DB를 쉽고 빠르게 채우는 데 유용합니다.

(4) DTO – GetProductsInfo

import { IsArray, IsNotEmpty, IsString } from 'class-validator';

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

 

해설

  • productIds는 문자열 배열이어야 하며, 비어 있을 수 없습니다.

  • DTO 레벨 검증으로 잘못된 입력을 초기에 차단합니다.

 

 

(5) Product MS 부트스트랩 – TCP 서버 열기

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.TCP,
    options: { host: '0.0.0.0', port: parseInt(process.env.TCP_PORT) || 4002 },
  });

  await app.startAllMicroservices();
  await app.listen(process.env.HTTP_PORT ?? 3002);
}
bootstrap();

해설

  • startAllMicroservices() 호출 순서를 지켜 RPC 서버가 먼저 준비되도록 합니다.

  • HTTP 포트와 별도로 TCP 포트가 열려 있어야 RPC가 동작합니다.

 

(6) AuthController (참고)

// (발췌) parse_bearer_token 메시지 처리 – Order가 사용자 검증에 사용
@MessagePattern({ cmd: 'parse_bearer_token' })
@UsePipes(ValidationPipe)
@UseInterceptors(RpcInterceptor)
parseBearerToken(@Payload() payload: ParseBearerTokenDto) {
  return this.authService.parseBearerToken(payload.token, false);
}

 

 

해설

  • 주문 흐름에서 사용자 인증 → 사용자 정보 → 상품 정보 순으로 신뢰 체인을 형성합니다.

 

 

6) 장애 포인트 & 해결 체크리스트

  1. Connection closed 반복

    • Product 서버의 TCP 포트와 Order 클라이언트의 포트 설정이 일치하는지 확인

    • Docker 환경이면 host는 서비스명(예: product) 사용, localhost 금지

    • Product MS가 정말 기동되었는지 /product/test 헬스 체크

  2. Validation 실패

    • productIds가 빈 배열/잘못된 타입인지 확인

    • DTO와 ValidationPipe가 Controller에 적용되어 있는지 확인

  3. 빈 결과/잘못된 매핑

    • 존재하지 않는 ID 요청 시 빈 배열 반환 → 주문 단계에서 예외 처리/에러 메시지 통일

    • 응답을 주문 도메인 모델로 명시적 사상(mapping) 할 것

  4. 시간 초과/일시 장애

    • timeout, retryWhen 등 RxJS 연산자로 재시도/타임아웃 정책 부여

    • 예:

from(this.productServiceClient.send({ cmd:'get_products_info' }, { productIds }))
  .pipe(timeout(3000), retry(2))

 

 

 

7) 성능·운영 팁

  • 배치 조회: 이미 In(productIds)로 일괄 조회. ID 개수 상한을 두어 과도한 요청 방지

  • 인덱스: id는 PK 인덱스, 추가로 자주 필터링하는 컬럼에 보조 인덱스 고려

  • 캐시: 뜨거운 카탈로그는 Product MS 측 캐시(예: Redis)로 응답 지연 감소

  • 관측성: Order↔Product RPC에 코릴레이션ID(요청 ID)를 부여해 분산 트레이싱(예: OpenTelemetry) 가능하게

  • 에러 표준화: RpcInterceptor로 표준화했더라도 사용자 노출 메시지는 비즈니스 용어로 재작성

 

 

8) 로컬 테스트 시나리오

  1. Product 샘플 데이터 주입

POST /product/sample  # 200/true 기대
  1. 헬스 체크

GET /product/test     # { message: '✅ API 정상 작동 중입니다!' }
  1. 주문 생성(가정) – Order의 HTTP API로 다음과 유사한 본문 전송

{
  "productIds": ["uuid-사과", "uuid-메론"],
  "address": { "receiver":"홍길동", "zip":"06236", "addr1":"..." },
  "payment": { "method":"CARD", "amount": 3000 }
}
  • Order 로그에서 get_products_info RPC 호출/응답 확인

  • 합계 금액/재고 검증이 이어지는지 점검

 

 

 

9) 결론 

Order 서비스는 TCP RPC(ClientProxy.send)로 Product 서비스의 { cmd:'get_products_info' }를 호출하여 필요한 상품 데이터를 배치로 가져오고, 이를 주문 도메인 모델로 사상해 합계/검증/저장/결제 플로우를 이어간다.

핵심은 정확한 포트/호스트 구성, DTO 검증, 응답 표준화, 에러 처리, 그리고 실무 운영(타임아웃/재시도/관측성) 입니다. 이 원칙을 잡으면 다른 도메인(Retail, Ticket, Booking 등)으로도 구조를 그대로 확장할 수 있습니다.

 

 

 

 

 

 

 

 

about author

PHRASE

Level 60  라이트

술은 백약(百藥)의 장(長)이다. 술은 적당히 즐겁게 마시면, 어떤 약보다 좋다.

댓글 ( 0)

댓글 남기기

작성