[1편] NestJS 마이크로서비스 통신(TCP) 개요 · 아키텍처 · 환경설정
시리즈 목표: User · Product · Payment 마이크로서비스와 Order API를 NestJS TCP Transport로 연결하여, 주문 생성부터 결제 승인까지의 동기 RPC 통신 흐름을 구현
0. 왜 TCP 마이크로서비스인가?
간결함: AMQP/Redis 없이 Nest 기본 제공 Transport.TCP로 요청-응답(RPC) 구현 가능
속도: 브로커 레이어가 없어 낮은 지연
한계: 서비스 간 결합↑, 다운스트림 장애 전파. 타임아웃/재시도/서킷브레이커 필요
실무에서는 메시지 브로커(예: RabbitMQ, Kafka) 또는 gRPC를 선택하는 경우가 많습니다. 본 시리즈는 TCP로 개념과 패턴을 먼저 익힌 뒤, 이후 다른 전송으로 확장 가능한 구조를 목표로 합니다.
1. 전체 아키텍처
[Client] --HTTP--> [Order API]
                     |\
                     | \--TCP--> [User MS]      (사용자 인증/조회)
                     |---TCP-->  [Product MS]   (상품 조회)
                     \---TCP-->  [Payment MS]   (결제 처리)
DBs:  Order(MongoDB/Mongoose),  Payment(PostgreSQL/TypeORM)
Order API: 외부 HTTP 엔드포인트(/order) 제공. 내부적으로 TCP 클라이언트 3개를 통해 각 MS RPC 호출
각 MS(User/Product/Payment): @MessagePattern() 으로 명령 패턴(cmd)을 수신
2. 서비스별 역할
User MS: Bearer 토큰 파싱 → 사용자 sub 확인 → get_user_info 응답
Product MS: get_products_info 로 다건 조회(가격, 이름 등) → Order가 총액 계산
Payment MS: make_payment 로 승인/거절 처리, DB에 영속화(상태: approved|rejected)
Order API: 흐름 오케스트레이션(검증 → 주문 생성 → 결제 요청 → 상태 반영)
3. 환경 변수(.env) 예시
# Order API HTTP_PORT=3004 TCP_PORT=4004 MONGO_URI=mongodb://mongo:27017/order # Downstream Microservices (TCP Server) USER_HOST=users USER_TCP_PORT=4010 PRODUCT_HOST=products PRODUCT_TCP_PORT=4020 PAYMENT_HOST=payments PAYMENT_TCP_PORT=4030 # Payment DB_URL=postgres://postgres:postgres@postgres_payment:5432/postgres # Auth JWT_SECRET=your-secret-key ACCESS_TOKEN_SECRET=your_access_token_secret REFRESH_TOKEN_SECRET=your_refresh_token_secret
주의: 포트명은 일관성 있게 HTTP_PORT/TCP_PORT로 구분해 사용하세요. 소문자 process.env.port 같은 혼용은 버그 원인입니다.
4. 공통 패키지(@app/common) 분리 제안
토큰 파서 메시지 패턴, 인터셉터(RPC 표준 응답), 상수(SERVICE TOKENS) 를 공통 패키지로 분리하면 앱 간 의존 관계가 단순해집니다.
예: export const USER_SERVICE = 'USER'; 와 같은 토큰 문자열, RpcInterceptor, RpcExceptionFilter 등
5. Order API에 TCP 클라이언트 등록
// app.module.ts (Order)
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import * as Joi from 'joi';
import { MongooseModule } from '@nestjs/mongoose';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { OrderModule } from './order/order.module';
import { PAYMENT_SERVICE, PRODUCT_SERVICE, USER_SERVICE } from '@app/common';
@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      validationSchema: Joi.object({
        HTTP_PORT: Joi.number().required(),
        TCP_PORT: Joi.number().required(),
        MONGO_URI: Joi.string().required(),
        USER_HOST: Joi.string().required(),
        USER_TCP_PORT: Joi.number().required(),
        PRODUCT_HOST: Joi.string().required(),
        PRODUCT_TCP_PORT: Joi.number().required(),
        PAYMENT_HOST: Joi.string().required(),
        PAYMENT_TCP_PORT: Joi.number().required(),
      }),
    }),
    MongooseModule.forRootAsync({
      useFactory: (cs: ConfigService) => ({ uri: cs.getOrThrow('MONGO_URI') }),
      inject: [ConfigService],
    }),
    ClientsModule.registerAsync({
      isGlobal: true,
      clients: [
        {
          name: USER_SERVICE,
          useFactory: (cs: ConfigService) => ({
            transport: Transport.TCP,
            options: { host: cs.getOrThrow('USER_HOST'), port: cs.getOrThrow('USER_TCP_PORT') },
          }),
          inject: [ConfigService],
        },
        {
          name: PRODUCT_SERVICE,
          useFactory: (cs: ConfigService) => ({
            transport: Transport.TCP,
            options: { host: cs.getOrThrow('PRODUCT_HOST'), port: cs.getOrThrow('PRODUCT_TCP_PORT') },
          }),
          inject: [ConfigService],
        },
        {
          name: PAYMENT_SERVICE,
          useFactory: (cs: ConfigService) => ({
            transport: Transport.TCP,
            options: { host: cs.getOrThrow('PAYMENT_HOST'), port: cs.getOrThrow('PAYMENT_TCP_PORT') },
          }),
          inject: [ConfigService],
        },
      ],
    }),
    OrderModule,
  ],
})
export class AppModule {}
6. Order 서버 부트스트랩(HTTP + 내부 TCP)
// main.ts (Order)
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 ?? '4004', 10) },
  });
  await app.startAllMicroservices();
  await app.listen(parseInt(process.env.HTTP_PORT ?? '3004', 10));
}
bootstrap();
7. 테스트용 Docker Compose (요지)
실제 포트/서비스명은 현 프로젝트에 맞게 치환
version: '3.8'
services:
  order:
    build: ./apps/order
    environment:
      - HTTP_PORT=3004
      - TCP_PORT=4004
      - MONGO_URI=mongodb://mongo:27017/order
      - USER_HOST=users
      - USER_TCP_PORT=4010
      - PRODUCT_HOST=products
      - PRODUCT_TCP_PORT=4020
      - PAYMENT_HOST=payments
      - PAYMENT_TCP_PORT=4030
    depends_on: [mongo, users, products, payments]
    ports: ['3004:3004']
  users:
    build: ./apps/user
    environment:
      - TCP_PORT=4010
    ports: ['4010:4010']
  products:
    build: ./apps/product
    environment:
      - TCP_PORT=4020
    ports: ['4020:4020']
  payments:
    build: ./apps/payment
    environment:
      - TCP_PORT=4030
      - DB_URL=postgres://postgres:postgres@postgres_payment:5432/postgres
    depends_on: [postgres_payment]
    ports: ['4030:4030']
  mongo:
    image: mongo:6
    ports: ['27017:27017']
  postgres_payment:
    image: postgres:15
    environment:
      - POSTGRES_PASSWORD=postgres
    ports: ['5432:5432']
[2편] Order API — DTO·검증·오케스트레이션·예외 처리
목표: HTTP 요청을 받아 User/Product/Payment 마이크로서비스로 TCP RPC를 순차 호출하여 주문 생성 → 결제 처리까지 오케스트레이션하는 전 과정을 코드로 해부합니다.
1. DTO · 유효성 검증
// dto/create-order.dto.ts
import { Type } from 'class-transformer';
import { IsArray, ArrayNotEmpty, IsString, ValidateNested, IsNotEmpty } from 'class-validator';
import { PaymentDto } from './payment.dto';
class AddressDto {
  @IsString() @IsNotEmpty() name: string;
  @IsString() @IsNotEmpty() street: string;
  @IsString() @IsNotEmpty() city: string;
  @IsString() @IsNotEmpty() postalCode: string;
  @IsString() @IsNotEmpty() country: string;
}
export class CreateOrderDto {
  @IsArray() @ArrayNotEmpty() productIds: string[];
  @ValidateNested() @Type(() => AddressDto) address: AddressDto;
  @ValidateNested() @Type(() => PaymentDto) payment: PaymentDto;
}
// dto/payment.dto.ts
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
export type PaymentMethod = 'CreditCard' | 'VirtualAccount';
export class PaymentDto {
  @IsString() @IsNotEmpty() paymentMethod: PaymentMethod;
  @IsString() @IsNotEmpty() paymentName: string;
  @IsString() @IsNotEmpty() cardNumber: string;
  @IsString() @IsNotEmpty() expiryYear: string;
  @IsString() @IsNotEmpty() expiryMonth: string;
  @IsString() @IsNotEmpty() birthOrRegistration: string;
  @IsString() @IsNotEmpty() passwordTwoDigits: string;
  @IsNumber() @IsNotEmpty() amount: number;
}
2. 컨트롤러 — Authorization 헤더 수신
// order.controller.ts
import { Body, Controller, Headers, Post, UsePipes, ValidationPipe } from '@nestjs/common';
import { OrderService } from './order.service';
import { CreateOrderDto } from './dto/create-order.dto';
@Controller('order')
export class OrderController {
  constructor(private readonly orderService: OrderService) {}
  @Post()
  @UsePipes(new ValidationPipe({ transform: true }))
  async create(
    @Headers('authorization') auth: string,
    @Body() dto: CreateOrderDto,
  ) {
    return this.orderService.createOrder(dto, auth ?? '');
  }
}
팁: 인증 데코레이터를 별도 공통 패키지로 제공해도 좋지만, 배포 분리를 위해 단순히 헤더를 읽고 User MS로 위임하는 방식이 안전합니다.
3. 서비스 — 오케스트레이션 핵심
// order.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { lastValueFrom, timeout } from 'rxjs';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { PAYMENT_SERVICE, PRODUCT_SERVICE, USER_SERVICE } from '@app/common';
import { CreateOrderDto } from './dto/create-order.dto';
import { Order, OrderDocument, OrderStatus } from './entities/order.entity';
@Injectable()
export class OrderService {
  constructor(
    @Inject(USER_SERVICE) private readonly userSvc: ClientProxy,
    @Inject(PRODUCT_SERVICE) private readonly productSvc: ClientProxy,
    @Inject(PAYMENT_SERVICE) private readonly paymentSvc: ClientProxy,
    @InjectModel(Order.name) private readonly orderModel: Model<OrderDocument>,
  ) {}
  async createOrder(dto: CreateOrderDto, authorization: string) {
    // 1) 사용자 확인 (토큰 파싱 → 사용자 조회)
    const user = await this.getUserFromAuthHeader(authorization);
    // 2) 상품 조회
    const products = await this.getProductsByIds(dto.productIds);
    // 3) 총액 계산 & 금액 일치 검증
    const total = products.reduce((sum, p) => sum + p.price, 0);
    if (total !== dto.payment.amount) {
      throw new Error('결제 금액이 상품 총액과 일치하지 않습니다. 장바구니를 새로고침 해주세요.');
    }
    // 4) 주문 생성 (초기 상태: created)
    const order = await this.orderModel.create({
      status: OrderStatus.created,
      customer: { userId: user.id, email: user.email, name: user.name },
      items: products.map((p) => ({ productId: p.id, name: p.name, price: p.price })),
      deliveryAddress: dto.address,
      payment: dto.payment,
    });
    // 5) 결제 처리 (orderId 포함 — 멱등/추적)
    await this.processPayment(order.id, dto.payment, user.email);
    // 6) 결과 조회 반환
    return this.orderModel.findById(order.id).lean();
  }
  private async getUserFromAuthHeader(authorization: string) {
    const parsed = await lastValueFrom(
      this.userSvc.send({ cmd: 'parse_bearer_token' }, { token: authorization }).pipe(timeout(5000)),
    );
    if (parsed?.status === 'error') throw new Error('토큰 파싱 실패');
    const user = await lastValueFrom(
      this.userSvc.send({ cmd: 'get_user_info' }, { userId: parsed.data.sub }).pipe(timeout(5000)),
    );
    if (user?.status === 'error') throw new Error('사용자 조회 실패');
    return user.data;
  }
  private async getProductsByIds(productIds: string[]) {
    const resp = await lastValueFrom(
      this.productSvc.send({ cmd: 'get_products_info' }, { productIds }).pipe(timeout(5000)),
    );
    if (resp?.status === 'error') throw new Error('상품 조회 실패');
    return resp.data;
  }
  private async processPayment(orderId: string, payment: any, userEmail: string) {
    const resp = await lastValueFrom(
      this.paymentSvc
        .send({ cmd: 'make_payment' }, { ...payment, userEmail, orderId })
        .pipe(timeout(8000)),
    );
    const approved = resp?.paymentStatus === 'Approved' || resp?.paymentStatus === 'approved';
    await this.orderModel.findByIdAndUpdate(orderId, {
      status: approved ? OrderStatus.paymentProcessed : OrderStatus.paymentFailed,
    });
    if (!approved) throw new Error('결제 실패');
    return resp;
  }
}
4. 주문 엔티티 (Mongoose)
// entities/order.entity.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export enum OrderStatus {
  created = 'created',
  paymentProcessed = 'paymentProcessed',
  paymentFailed = 'paymentFailed',
}
@Schema({ timestamps: true })
export class Order {
  @Prop({ type: String, enum: OrderStatus, default: OrderStatus.created })
  status: OrderStatus;
  @Prop({ type: Object })
  customer: { userId: string; email: string; name: string };
  @Prop({ type: Array })
  items: Array<{ productId: string; name: string; price: number }>;
  @Prop({ type: Object })
  deliveryAddress: any;
  @Prop({ type: Object })
  payment: any;
}
export type OrderDocument = HydratedDocument<Order>;
export const OrderSchema = SchemaFactory.createForClass(Order);
5. 타임아웃/재시도/로깅 팁
timeout(ms): 다운스트림 지연이 체인 전체를 블로킹하지 않도록 각 RPC에 타임아웃 설정
retryWhen: 네트워크 일시 장애에 재시도(단, 결제는 멱등키 필요)
Correlation ID: orderId를 로그/메타데이터로 전파하여 트레이싱
6. 요청 예시
curl -X POST http://localhost:3004/order \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <JWT>" \
  -d '{
    "productIds": ["8cc2...", "39a9..."],
    "address": {"name":"홍길동","street":"도산대로 14","city":"서울","postalCode":"123123","country":"KR"},
    "payment": {"paymentMethod":"CreditCard","paymentName":"법인카드","cardNumber":"123123",
                 "expiryYear":"26","expiryMonth":"12","birthOrRegistration":"9",
                 "passwordTwoDigits":"12","amount":3000}
  }'
[3편] User·Product·Payment MS — TCP 핸들러, DTO/엔티티, DB 연동
목표: 각 마이크로서비스가 TCP 서버로서 @MessagePattern 요청을 수신/응답하는 방식을 코드로 정리하고, Payment는 TypeORM + PostgreSQL, User/Product는 임시/예시 저장소를 통해 흐름을 완성합니다.
1. 공통 — 마이크로서비스 부트스트랩
// main.ts (각 MS 공통)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
    transport: Transport.TCP,
    options: { host: '0.0.0.0', port: parseInt(process.env.TCP_PORT ?? '4010', 10) },
  });
  await app.listen();
}
bootstrap();
2. User MS — 토큰 파싱 & 사용자 조회
// user.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
@Controller()
export class UserController {
  @MessagePattern({ cmd: 'parse_bearer_token' })
  parseBearerToken(@Payload() { token }: { token: string }) {
    // 실제로는 JWT 검증 로직 수행
    const raw = (token ?? '').replace(/^Bearer\s+/i, '');
    // 데모: sub=mock-user
    return { status: 'ok', data: { sub: 'mock-user', raw } };
  }
  @MessagePattern({ cmd: 'get_user_info' })
  getUserInfo(@Payload() { userId }: { userId: string }) {
    // 데모: 임시 사용자
    return { status: 'ok', data: { id: userId, email: 'user@example.com', name: '홍길동' } };
  }
}
실제 구현에서는 jsonwebtoken 또는 @nestjs/jwt로 서명 검증, 블랙리스트 확인, 권한 조회 등을 수행합니다.
3. Product MS — 다건 상품 조회
// product.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
const mockProducts = new Map<string, { id: string; name: string; price: number }>([
  ['8cc28226-076e-4472-8b08-4e5c2b51ebc5', { id: '8cc2...', name: 'USB-C 케이블', price: 1000 }],
  ['39a9fe98-091a-4dba-a314-195fea19f48c', { id: '39a9...', name: '마우스 패드', price: 2000 }],
]);
@Controller()
export class ProductController {
  @MessagePattern({ cmd: 'get_products_info' })
  getProducts(@Payload() { productIds }: { productIds: string[] }) {
    const result = (productIds ?? []).map((id) => mockProducts.get(id)).filter(Boolean);
    if (!result.length) return { status: 'error', message: '상품 없음' };
    return { status: 'ok', data: result };
  }
}
실제 환경에서는 캐시(Redis)와 읽기 모델을 두어 Order의 리스트 조회 부하를 줄입니다.
4. Payment MS — TypeORM + PostgreSQL
4.1 엔티티
// entity/payment.entity.ts
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
export enum PaymentStatus {
  approved = 'Approved',
  rejected = 'Rejected',
}
@Entity('payments')
export class Payment {
  @PrimaryGeneratedColumn('uuid') id: string;
  @Column() paymentMethod: string;
  @Column() paymentName: string;
  @Column() cardNumber: string;
  @Column() expiryYear: string;
  @Column() expiryMonth: string;
  @Column() birthOrRegistration: string;
  @Column() passwordTwoDigits: string;
  @Column('int') amount: number;
  @Column() userEmail: string;
  @Column() orderId: string; // 멱등/추적
  @Column({ type: 'varchar', default: PaymentStatus.approved })
  paymentStatus: PaymentStatus;
  @CreateDateColumn() createdAt: Date;
  @UpdateDateColumn() updatedAt: Date;
}
4.2 DTO
// dto/make-payment.dto.ts
import { IsNotEmpty, IsNumber, IsString } from 'class-validator';
export class MakePaymentDto {
  @IsString() @IsNotEmpty() paymentMethod: string;
  @IsString() @IsNotEmpty() paymentName: string;
  @IsString() @IsNotEmpty() cardNumber: string;
  @IsString() @IsNotEmpty() expiryYear: string;
  @IsString() @IsNotEmpty() expiryMonth: string;
  @IsString() @IsNotEmpty() birthOrRegistration: string;
  @IsString() @IsNotEmpty() passwordTwoDigits: string;
  @IsNumber() @IsNotEmpty() amount: number;
  @IsString() @IsNotEmpty() userEmail: string;
  @IsString() @IsNotEmpty() orderId: string;
}
4.3 모듈 & 부트스트랩
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Payment } from './entity/payment.entity';
import { PaymentModule } from './payment.module';
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (cs: ConfigService) => ({
        type: 'postgres',
        url: cs.getOrThrow('DB_URL'),
        autoLoadEntities: true,
        synchronize: true, // 운영에서는 마이그레이션 권장
      }),
    }),
    PaymentModule,
  ],
})
export class AppModule {}
4.4 컨트롤러 & 서비스
// payment.controller.ts
import { Controller, UseInterceptors, UsePipes, ValidationPipe } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { RpcInterceptor } from '@app/common/interceptor';
import { PaymentService } from './payment.service';
import { MakePaymentDto } from './dto/make-payment.dto';
@Controller()
export class PaymentController {
  constructor(private readonly paymentService: PaymentService) {}
  @MessagePattern({ cmd: 'make_payment' })
  @UsePipes(new ValidationPipe())
  @UseInterceptors(RpcInterceptor)
  makePayment(@Payload() dto: MakePaymentDto) {
    return this.paymentService.makePayment(dto);
  }
}
// payment.service.ts
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Payment, PaymentStatus } from './entity/payment.entity';
import { MakePaymentDto } from './dto/make-payment.dto';
@Injectable()
export class PaymentService {
  constructor(@InjectRepository(Payment) private readonly repo: Repository<Payment>) {}
  async makePayment(dto: MakePaymentDto) {
    // 1) 영속화 (초기 상태: Rejected → 승인 성공 시 Approved로 갱신하는 전략도 가능)
    const saved = await this.repo.save({ ...dto, paymentStatus: PaymentStatus.approved });
    // 2) 외부 PG 호출 모킹
    await new Promise((r) => setTimeout(r, 1000));
    // 3) 승인으로 상태 확정
    await this.repo.update({ id: saved.id }, { paymentStatus: PaymentStatus.approved });
    // 4) 결과 반환
    return this.repo.findOneBy({ id: saved.id });
  }
}
5. 운영 팁
멱등성: 동일 orderId 중복 결제 방지 → unique index(orderId) + 상태 체크
보상 트랜잭션: 결제 실패 시 Order 상태 paymentFailed로 업데이트, 재시도 경로 제공
관측성: 각 요청에 orderId를 로깅하여 분산 트레이싱 기본 확보
[4편] 운영 품질·테스트·트러블슈팅 — 타임아웃·재시도·멱등성·로그·서킷브레이커
목표: 개발한 TCP 통신 기반 마이크로서비스를 운영 품질까지 끌어올립니다. 타임아웃/재시도, 멱등성/보상 트랜잭션, 로깅/트레이싱, 테스트/디버깅, 배포 체크리스트를 제공합니다.
1. 타임아웃 & 재시도 & 서킷브레이커
1.1 타임아웃
// 예: Order → Product 호출에 3초 타임아웃
const products = await lastValueFrom(
  this.productSvc
    .send({ cmd: 'get_products_info' }, { productIds })
    .pipe(timeout(3000)),
);
다운스트림 지연이 체인 전체 블로킹을 유발하지 않도록 모든 RPC에 적용
UX 관점에서도 1~3초 선에서 피드백 → 프론트는 재시도/로딩 상태로 전환
1.2 재시도
import { retryWhen, delay, scan } from 'rxjs/operators';
const resp$ = this.userSvc.send(pattern, data).pipe(
  retryWhen((errors) => errors.pipe(
    scan((acc, err) => { if (acc >= 3) throw err; return acc + 1; }, 0),
    delay(300),
  )),
);
비결제성 트랜잭션에만 보수적으로 적용 (결제는 멱등키/중복검사 필수)
1.3 서킷브레이커(간단 패턴)
실패율/타임아웃이 일정 임계치를 넘으면 OPEN → 일정 시간 후 HALF-OPEN → 성공 시 CLOSE
라이브러리 없이도 전역 Interceptor/서비스 레벨 상태값으로 단순 구현 가능
2. 멱등성 & 보상 트랜잭션
2.1 결제 멱등성
orderId에 unique 인덱스(+ paymentStatus)를 두고, 동일 요청은 읽기 반환
Payment MS에서 orderId 기준 조회 후 이미 Approved면 즉시 승인 응답, Rejected면 사유 전달
2.2 보상 트랜잭션(Saga 사고방식)
결제 실패 → Order: paymentFailed
재시도 허용 정책: 사용자 확인 후 재결제 라우트 제공
3. 로깅 & 분산 트레이싱
3.1 Correlation ID 설계
orderId를 전 구간 로그에 포함
Interceptor로 요청마다 context.set('cid', orderId) 저장 후 로거 포맷에 포함
3.2 표준 응답 포맷(RPC)
// 예시 형태
{
  status: 'ok' | 'error',
  data?: any,
  message?: string,
  code?: string,
}
핸들러/서비스 전반에 동일 포맷을 유지하면, Order에서 에러 분기가 간단해집니다.
4. 테스트 시나리오
4.1 단위 테스트
DTO 유효성 → invalid payload에 대한 ValidationPipe 동작
서비스 메서드에서 총액 계산/검증 로직
4.2 통합 테스트
TestModule에서 TCP 서버/클라이언트 실제 바인딩(테스트 포트)
Order → User/Product/Payment 순차 호출 성공/실패 케이스
4.3 E2E 테스트
docker-compose로 모든 서비스 기동
curl 또는 supertest 로 /order 호출 → 승인/거절 플로우
5. 트러블슈팅 가이드
증상원인해결
ECONNREFUSED대상 MS 미기동/포트 불일치TCP_PORT·HOST·네트워크 확인, Docker 서비스 종속성 설정
응답 지연 → 전체 타임아웃다운스트림 지연각 RPC에 timeout() 적용, 프론트 피드백 강화
결제 중복 승인멱등 처리 누락Payment에 orderId unique index, 승인 시 빠른 반환
DTO 불일치Order↔Payment 스키마 상이공통 패키지로 DTO 공유 또는 계약 테스트 추가
예외 메시지 오타/클래스명 오류타이핑 실수CI에서 빌드/TS 검사, ESLint/TS-ESLint 규칙 강화
6. 배포 체크리스트
환경변수 키 일관성 (HTTP_PORT/TCP_PORT 등)
로그 수준/포맷 통일, Correlation ID 포함
RPC 타임아웃/재시도 파라미터 환경변수화
Payment 멱등성 + 고유 인덱스 적용
DTO 계약 테스트(Contract Test) 마련
7. 마무리
핵심 요지: TCP 통신은 빠르고 간단하지만 결합이 커집니다
다음 단계: 동일 구조를 gRPC/RabbitMQ로 바꾸어도 패턴은 유지됩니다(요청-응답/이벤트 발행, DTO 계약, 멱등/보상).














댓글 ( 0)  
댓글 남기기