Skip to content

NextMarket - StepPay 연동 설계서


1. 연동 개요

1.1 연동 범위

NextMarket 기능StepPay API연동 방식
회원 가입Customer API회원 생성 시 StepPay 고객 생성
상품 등록Product API상품 생성 시 StepPay 상품/가격 생성
주문/결제Order API주문 생성 → 결제 링크 반환
구독 관리Subscription API구독 생성/변경/취소
결제 결과WebhookStepPay → NextMarket 알림

1.2 환경 설정

.env 대신 conf/config.json 파일로 설정을 관리합니다.

json
// conf/config.json
{
  "steppay": {
    "apiUrl": "https://api.steppay.kr",
    "secretToken": "your-secret-token",
    "paymentKey": "your-payment-key",
    "webhookSecret": "your-webhook-secret",
    "paymentUrl": "https://payment.steppay.kr"
  }
}
typescript
// src/config/index.ts 에서 로드
import config from '@config';
const steppayConfig = config.steppay;

1.3 HTTP 클라이언트 설정

typescript
// modules/steppay/steppay.client.ts
import axios, { AxiosInstance } from 'axios';

export class SteppayClient {
  private client: AxiosInstance;

  constructor(config: SteppayConfig) {
    this.client = axios.create({
      baseURL: config.apiUrl,
      headers: {
        'Secret-Token': config.secretToken,
        'Content-Type': 'application/json',
      },
      timeout: 30000,
    });

    // 요청/응답 인터셉터
    this.client.interceptors.response.use(
      (response) => response,
      (error) => this.handleError(error),
    );
  }
}

2. 데이터 동기화 전략

2.1 고객 동기화

NextMarket User              StepPay Customer
─────────────────           ──────────────────
id                    ←→    (저장: steppay_customer_id)
email                 →     email
name                  →     name
phone                 →     phone (optional)
marketing_agreed      →     marketingAgreed

동기화 시점

  • 회원가입 시: StepPay 고객 생성
  • 회원정보 수정 시: StepPay 고객 업데이트 (선택적)

2.2 상품 동기화

NextMarket Product           StepPay Product
─────────────────           ──────────────────
id                    ←→    (저장: steppay_product_id)
name                  →     name
type (SUBSCRIPTION)   →     type: SOFTWARE
status (SALE)         →     status: SALE

NextMarket Plan              StepPay Price
─────────────────           ──────────────────
id                    ←→    (저장: steppay_price_id)
price                 →     price
interval_unit         →     recurring.intervalUnit
interval_count        →     recurring.interval

동기화 시점

  • 관리자가 상품 등록 시: StepPay 상품 + 가격 생성
  • 상품 수정 시: StepPay 업데이트

3. 주문/결제 플로우

3.1 단건 주문 시퀀스

핵심 원칙: StepPay API 먼저 호출 → 성공 시 로컬 DB에 저장 → STP 매핑 저장

┌──────────┐      ┌──────────────┐      ┌──────────┐      ┌──────────┐
│  Client  │      │  NextMarket  │      │  StepPay │      │   PG     │
└────┬─────┘      └──────┬───────┘      └────┬─────┘      └────┬─────┘
     │                   │                   │                 │
     │ 1. POST /orders   │                   │                 │
     │ (상품,배송지)      │                   │                 │
     │──────────────────>│                   │                 │
     │                   │                   │                 │
     │                   │ 2. POST /orders   │                 │
     │                   │ (StepPay 먼저)    │                 │
     │                   │──────────────────>│                 │
     │                   │                   │                 │
     │                   │ 3. 주문번호+결제링크│                 │
     │                   │<──────────────────│                 │
     │                   │                   │                 │
     │                   │ 4. 로컬 DB 저장    │                 │
     │                   │ (주문+항목 생성)   │                 │
     │                   │                   │                 │
     │                   │ 5. STP 매핑 저장   │                 │
     │                   │ (STP_ORDERS)      │                 │
     │                   │                   │                 │
     │ 6. 결제 링크 반환  │                   │                 │
     │<──────────────────│                   │                 │
     │                   │                   │                 │
     │ 7. 결제 페이지 이동│                   │                 │
     │──────────────────────────────────────>│                 │
     │                   │                   │                 │
     │                   │                   │ 8. 결제 요청    │
     │                   │                   │────────────────>│
     │                   │                   │                 │
     │                   │                   │ 9. 결제 결과    │
     │                   │                   │<────────────────│
     │                   │                   │                 │
     │                   │ 10. Webhook       │                 │
     │                   │ (order.paid)      │                 │
     │                   │<──────────────────│                 │
     │                   │                   │                 │
     │                   │ 11. 주문상태 업데이트                │
     │                   │ (PENDING → PAID)  │                 │
     │                   │                   │                 │
     │ 12. 결제 완료 페이지                  │                 │
     │<──────────────────────────────────────│                 │

3.2 주문 생성 코드

typescript
// modules/orders/orders.service.ts
@Injectable()
export class OrdersService {
  constructor(
    private readonly db: DatabaseService,
    private readonly steppay: SteppayService,
  ) {}

  async createOrder(userId: number, dto: CreateOrderDto) {
    // 1. 사용자/배송지/상품 정보 조회
    const user = await this.getUser(userId);
    const address = await this.getAddress(dto.shippingAddressId, userId);
    const itemsInfo = await this.getOrderItemsInfo(dto.items);

    // 2. StepPay 주문 생성 (API 먼저)
    const steppayOrder = await this.steppay.orders.create({
      customerId: user.steppay_customer_id,
      items: itemsInfo.map(item => ({
        priceId: item.steppayPriceId,
        quantity: item.quantity,
      })),
    });

    // 3. NextMarket 주문 생성 (로컬 DB)
    const orderResult = await this.db.call('NMP_ORDER_CREATE',
      userId, OrderType.ONETIME, productName, subtotal, totalAmount, ...shippingParams
    );
    const orderId = orderResult[0]?.[0]?.ORD_IDX;

    // 4. 주문 항목 생성
    for (const item of itemsInfo) {
      await this.db.call('NMP_ORDER_ITEM_CREATE',
        orderId, item.productId, item.steppayPriceId, ...itemParams
      );
    }

    // 5. StepPay 주문 매핑 저장 (STP_ORDERS 테이블)
    await this.db.call('NMP_ORDER_CREATE_STEPPAY',
      orderId, steppayOrder.id, steppayOrder.code
    );

    // 6. 결제 링크 반환
    return {
      orderId,
      steppayOrderCode: steppayOrder.code,
      paymentUrl: this.steppay.orders.getPaymentUrl(steppayOrder.code),
    };
  }
}

3.3 StepPay 주문 생성 요청

typescript
// modules/steppay/services/steppay-order.service.ts
async create(params: CreateSteppayOrderParams) {
  const response = await this.client.post('/api/v1/orders', {
    customerId: params.customerId,
    items: params.items.map(item => ({
      priceId: item.priceId,
      quantity: item.quantity,
      currency: 'KRW',
    })),
  });

  return {
    id: response.data.id,
    code: response.data.code,
    status: response.data.status,
  };
}

3.4 주문 취소 플로우

typescript
async cancelOrder(orderId: number, reason: string) {
  const order = await this.getOrder(orderId);

  // 1. StepPay 주문 취소
  await this.steppay.orders.cancel(order.steppayOrderCode, { reason });

  // 2. NextMarket 주문 상태 업데이트
  await this.db.call('NMP_ORDER_UPDATE_STATUS',
    orderId, OrderType.CANCELLED
  );

  return { success: true };
}

4. 구독 플로우

4.1 구독 신청 시퀀스

핵심 원칙: StepPay API 먼저 호출 → 성공 시 로컬 DB에 저장 → STP 매핑 저장

┌──────────┐      ┌──────────────┐      ┌──────────┐
│  Client  │      │  NextMarket  │      │  StepPay │
└────┬─────┘      └──────┬───────┘      └────┬─────┘
     │                   │                   │
     │ 1. POST           │                   │
     │ /subscriptions    │                   │
     │──────────────────>│                   │
     │                   │                   │
     │                   │ 2. 상품/가격/사용자│
     │                   │ 정보 조회          │
     │                   │                   │
     │                   │ 3. StepPay 구독    │
     │                   │ 생성 (API 먼저)    │
     │                   │──────────────────>│
     │                   │                   │
     │                   │ 4. 구독 결과 반환   │
     │                   │<──────────────────│
     │                   │                   │
     │                   │ 5. 로컬 DB 저장    │
     │                   │ (구독 생성)        │
     │                   │                   │
     │                   │ 6. STP 매핑 저장   │
     │                   │ (STP_SUBSCRIPTIONS)│
     │                   │                   │
     │ 7. paymentUrl     │                   │
     │<──────────────────│                   │
     │                   │                   │
     │ ... 결제 진행 ...  │                   │
     │                   │                   │
     │                   │ 8. Webhook        │
     │                   │ (subscription     │
     │                   │  .created)        │
     │                   │<──────────────────│
     │                   │                   │
     │                   │ 9. 구독 활성화     │

4.2 구독 신청 코드

typescript
// modules/subscriptions/subscriptions.service.ts
async createSubscription(userId: number, dto: CreateSubscriptionDto) {
  // 1. 사용자/상품/가격플랜/배송지 정보 조회
  const user = await this.getUser(userId);
  const product = await this.getProduct(dto.productId);
  const price = await this.getPrice(dto.priceId);

  // 2. 기존 활성 구독 확인 (동일 상품)
  await this.checkExistingSubscription(userId, product.name);

  // 3. StepPay 구독 생성 (API 먼저)
  const steppaySubscription = await this.steppay.subscriptions.create({
    customerId: user.steppay_customer_id,
    priceId: price.steppay_price_id,
  });

  // 4. NextMarket 구독 생성 (로컬 DB)
  const subscriptionResult = await this.db.call('NMP_SUBSCRIPTION_CREATE',
    userId, product.name, price.recurring_interval, ...params
  );
  const subscriptionId = subscriptionResult[0]?.[0]?.SUB_IDX;

  // 5. StepPay 구독 매핑 저장 (STP_SUBSCRIPTIONS 테이블)
  await this.db.call('NMP_SUBSCRIPTION_CREATE_STEPPAY',
    subscriptionId, steppaySubscription.id, steppaySubscription.code, user.steppay_customer_id
  );

  return {
    subscriptionId,
    steppaySubscriptionCode: steppaySubscription.code,
    paymentUrl: this.steppay.subscriptions.getPaymentUrl(steppaySubscription.code),
  };
}

4.3 구독 관리 API

typescript
// 구독 일시정지
async pauseSubscription(subscriptionId: number, resumeDate?: Date) {
  const subscription = await this.getSubscription(subscriptionId);

  await this.steppay.subscriptions.pause(
    subscription.steppaySubscriptionId,
    { resumeDate }
  );

  await this.db.call('NMP_SUBSCRIPTION_UPDATE_STATUS',
    subscriptionId, SubStatus.PAUSED
  );
}

// 구독 취소
async cancelSubscription(subscriptionId: number, dto: CancelDto) {
  const subscription = await this.getSubscription(subscriptionId);

  await this.steppay.subscriptions.cancel(
    subscription.steppaySubscriptionId,
    { cancelImmediately: dto.cancelImmediately }
  );

  const newStatus = dto.cancelImmediately ? SubStatus.CANCELLED : SubStatus.CANCEL_PENDING;

  await this.db.call('NMP_SUBSCRIPTION_UPDATE_STATUS',
    subscriptionId, newStatus
  );
}

// 결제수단 변경
async changePaymentMethod(subscriptionId: number) {
  const subscription = await this.getSubscription(subscriptionId);

  const result = await this.steppay.subscriptions.createPaymentMethodChange(
    subscription.steppaySubscriptionId
  );

  return {
    paymentMethodUpdateUrl: result.paymentUrl,
  };
}

5. 웹훅 처리

5.1 웹훅 컨트롤러

typescript
// modules/webhooks/webhooks.controller.ts
@Controller('webhooks')
export class WebhooksController {
  constructor(private readonly webhooksService: WebhooksService) {}

  @Post('steppay')
  @HttpCode(200)
  async handleSteppayWebhook(
    @Headers('x-steppay-signature') signature: string,
    @Body() payload: any,
    @Req() req: Request,
  ) {
    // 1. 서명 검증
    if (!this.webhooksService.verifySignature(payload, signature)) {
      throw new UnauthorizedException('Invalid signature');
    }

    // 2. 웹훅 로그 저장
    const logId = await this.webhooksService.logWebhook({
      source: 'STEPPAY',
      eventType: payload.eventType,
      payload,
      signature,
      ipAddress: req.ip,
    });

    // 3. 이벤트 처리
    try {
      await this.webhooksService.processEvent(payload);
      await this.webhooksService.updateWebhookStatus(logId, 'PROCESSED');
    } catch (error) {
      await this.webhooksService.updateWebhookStatus(logId, 'FAILED', error.message);
      throw error;
    }

    return { received: true };
  }
}

5.2 웹훅 이벤트 처리

typescript
// modules/webhooks/webhooks.service.ts
@Injectable()
export class WebhooksService {
  async processEvent(payload: SteppayWebhookPayload) {
    const { eventType, data } = payload;

    switch (eventType) {
      // 주문 관련
      case 'order.created':
        await this.handleOrderCreated(data);
        break;
      case 'order.paid':
        await this.handleOrderPaid(data);
        break;
      case 'order.cancelled':
        await this.handleOrderCancelled(data);
        break;

      // 구독 관련
      case 'subscription.created':
        await this.handleSubscriptionCreated(data);
        break;
      case 'subscription.renewed':
        await this.handleSubscriptionRenewed(data);
        break;
      case 'subscription.cancelled':
        await this.handleSubscriptionCancelled(data);
        break;
      case 'subscription.paused':
        await this.handleSubscriptionPaused(data);
        break;

      // 결제 관련
      case 'payment.completed':
        await this.handlePaymentCompleted(data);
        break;
      case 'payment.failed':
        await this.handlePaymentFailed(data);
        break;

      default:
        console.log(`Unhandled event type: ${eventType}`);
    }
  }

  // 주문 생성
  private async handleOrderCreated(data: OrderWebhookData) {
    // StepPay 코드로 로컬 주문 IDX 조회
    const order = await this.db.call('NMP_ORDER_GET_BY_STP_CODE', data.code);
    const ordIdx = order[0]?.[0]?.ORD_IDX;

    // StepPay 주문 동기화
    await this.db.call('NMP_SYNC_ORDER', ...orderParams);
  }

  // 주문 결제 완료
  private async handleOrderPaid(data: OrderWebhookData) {
    // StepPay 코드로 로컬 주문 IDX 조회
    const order = await this.db.call('NMP_ORDER_GET_BY_STP_CODE', data.code);
    const ordIdx = order[0]?.[0]?.ORD_IDX;

    // StepPay 주문 동기화 (주문 데이터 upsert + 상태 업데이트)
    await this.db.call('NMP_SYNC_ORDER', ...orderParams);

    // 주문 상태 업데이트
    await this.db.call('NMP_ORDER_UPDATE_STATUS',
      ordIdx, OrderType.PAID
    );
  }

  // 구독 생성 완료
  private async handleSubscriptionCreated(data: SubscriptionWebhookData) {
    // 구독 데이터 동기화 + 활성화
    await this.db.call('NMP_SYNC_SUBSCRIPTION', ...subscriptionParams);
    await this.db.call('NMP_SUBSCRIPTION_ACTIVATE',
      subscriptionId
    );
  }

  // 구독 갱신
  private async handleSubscriptionRenewed(data: SubscriptionWebhookData) {
    await this.db.call('NMP_SUBSCRIPTION_RENEW',
      subscriptionId
    );

    // 갱신 주문 생성
    await this.ordersService.createRenewalOrder(data);
  }

  // 결제 완료
  private async handlePaymentCompleted(data: PaymentWebhookData) {
    // 결제 완료 처리 (주문 상태 갱신 등)
    await this.db.call('NMP_ORDER_UPDATE_STATUS',
      ordIdx, OrderType.PAID
    );
  }

  // 결제 실패
  private async handlePaymentFailed(data: PaymentWebhookData) {
    // 구독 상태를 UNPAID로 업데이트
    await this.db.call('NMP_SUBSCRIPTION_UPDATE_STATUS',
      subscriptionId, SubStatus.UNPAID
    );
  }
}

5.3 서명 검증

typescript
// modules/webhooks/webhooks.service.ts
verifySignature(payload: any, signature: string): boolean {
  const expectedSignature = crypto
    .createHmac('sha256', this.config.webhookSecret)
    .update(JSON.stringify(payload))
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expectedSignature)
  );
}

6. 서비스 구현

6.1 StepPay 모듈 구조

modules/steppay/
├── steppay.module.ts
├── steppay.service.ts          # 통합 서비스 (Facade)
├── steppay.client.ts           # HTTP 클라이언트
├── steppay.controller.ts       # 테스트/관리 API 엔드포인트
├── services/
│   ├── steppay-customer.service.ts
│   ├── steppay-product.service.ts
│   ├── steppay-order.service.ts
│   └── steppay-subscription.service.ts
├── dto/
│   ├── customer.dto.ts
│   ├── product.dto.ts
│   ├── price.dto.ts
│   ├── order.dto.ts
│   ├── subscription.dto.ts
│   └── index.ts
├── interfaces/
│   └── steppay.types.ts        # 공통 타입 정의
└── exceptions/
    └── steppay.exception.ts    # 커스텀 예외 클래스

6.2 통합 서비스

typescript
// modules/steppay/steppay.service.ts
@Injectable()
export class SteppayService {
  constructor(
    readonly customers: SteppayCustomerService,
    readonly products: SteppayProductService,
    readonly orders: SteppayOrderService,
    readonly subscriptions: SteppaySubscriptionService,
  ) {}
}

6.3 타입 정의

typescript
// modules/steppay/interfaces/steppay.types.ts

// 고객
export interface SteppayCustomer {
  id: number;
  code: string;
  email: string;
  name: string;
  phone?: string;
  marketingAgreed: boolean;
  createdAt: string;
}

export interface CreateCustomerDto {
  email: string;
  name: string;
  phone?: string;
  marketingAgreed?: boolean;
  attributes?: Record<string, any>;
}

// 상품
export interface SteppayProduct {
  id: number;
  code: string;
  type: 'BOX' | 'SOFTWARE' | 'INVOICE' | 'BUNDLE';
  status: 'SALE' | 'OUT_OF_STOCK' | 'UNSOLD';
  name: string;
  prices: SteppayPrice[];
}

export interface SteppayPrice {
  id: number;
  code: string;
  type: 'ONE_TIME' | 'FLAT' | 'USAGE_BASED' | 'UNIT_BASED';
  price: number;
  recurring?: {
    interval: number;
    intervalUnit: 'DAY' | 'WEEK' | 'MONTH' | 'YEAR';
  };
}

// 주문
export interface SteppayOrder {
  id: number;
  code: string;
  type: 'ONE_TIME' | 'RECURRING_INITIAL' | 'RECURRING';
  status: string;
  paidAmount: number;
  items: SteppayOrderItem[];
}

export interface CreateOrderDto {
  customerId: number;
  items: {
    priceId: number;
    quantity: number;
    currency?: string;
  }[];
}

// 구독
export interface SteppaySubscription {
  id: number;
  code: string;
  status: 'ACTIVE' | 'PAUSED' | 'PENDING_CANCEL' | 'CANCELLED' | 'EXPIRED' | 'UNPAID';
  currentPeriod: {
    start: string;
    end: string;
  };
  nextBillingDate: string;
  items: SteppaySubscriptionItem[];
}

7. 에러 처리

7.1 StepPay API 에러

typescript
// modules/steppay/exceptions/steppay.exception.ts
export class SteppayException extends Error {
  constructor(
    public readonly code: string,
    public readonly message: string,
    public readonly statusCode: number,
    public readonly originalError?: any,
  ) {
    super(message);
    this.name = 'SteppayException';
  }
}

// 에러 코드 매핑
export const STEPPAY_ERROR_CODES = {
  UNAUTHORIZED: 'STEPPAY_001',
  NOT_FOUND: 'STEPPAY_002',
  VALIDATION_ERROR: 'STEPPAY_003',
  ORDER_CREATION_FAILED: 'STEPPAY_004',
  SUBSCRIPTION_ERROR: 'STEPPAY_005',
  WEBHOOK_VERIFICATION_FAILED: 'STEPPAY_006',
  API_TIMEOUT: 'STEPPAY_007',
  UNKNOWN_ERROR: 'STEPPAY_999',
};

7.2 에러 핸들링

typescript
// modules/steppay/steppay.client.ts
private handleError(error: AxiosError): never {
  if (error.response) {
    const { status, data } = error.response;

    switch (status) {
      case 401:
        throw new SteppayException(
          STEPPAY_ERROR_CODES.UNAUTHORIZED,
          'StepPay 인증 실패',
          401,
          data
        );
      case 404:
        throw new SteppayException(
          STEPPAY_ERROR_CODES.NOT_FOUND,
          'StepPay 리소스를 찾을 수 없습니다',
          404,
          data
        );
      case 400:
        throw new SteppayException(
          STEPPAY_ERROR_CODES.VALIDATION_ERROR,
          data.message || 'StepPay 요청 유효성 검증 실패',
          400,
          data
        );
      default:
        throw new SteppayException(
          STEPPAY_ERROR_CODES.UNKNOWN_ERROR,
          'StepPay API 오류',
          status,
          data
        );
    }
  }

  if (error.code === 'ECONNABORTED') {
    throw new SteppayException(
      STEPPAY_ERROR_CODES.API_TIMEOUT,
      'StepPay API 타임아웃',
      504
    );
  }

  throw new SteppayException(
    STEPPAY_ERROR_CODES.UNKNOWN_ERROR,
    'StepPay 연결 실패',
    500,
    error
  );
}

7.3 재시도 전략

typescript
// modules/steppay/steppay.client.ts
import axiosRetry from 'axios-retry';

// 재시도 설정
axiosRetry(this.client, {
  retries: 3,
  retryDelay: axiosRetry.exponentialDelay,
  retryCondition: (error) => {
    return (
      axiosRetry.isNetworkOrIdempotentRequestError(error) ||
      error.response?.status === 429 // Rate limit
    );
  },
});

8. 체크리스트

8.1 연동 전 확인사항

  • [ ] StepPay 포털 계정 생성
  • [ ] Secret-Token 발급
  • [ ] PG 설정 및 Payment-Key 발급
  • [ ] 웹훅 URL 등록
  • [ ] 테스트 환경 설정

8.2 구현 체크리스트

  • [ ] StepPay HTTP 클라이언트 구현
  • [ ] 고객 동기화 로직
  • [ ] 상품/가격 동기화 로직
  • [ ] 주문 생성 및 결제 연동
  • [ ] 구독 관리 기능
  • [ ] 웹훅 수신 및 처리
  • [ ] 에러 핸들링 및 로깅
  • [ ] 재시도 로직

8.3 테스트 체크리스트

  • [ ] 고객 생성/조회 API 테스트
  • [ ] 상품/가격 생성 API 테스트
  • [ ] 단건 주문 결제 테스트
  • [ ] 구독 결제 테스트
  • [ ] 구독 일시정지/취소 테스트
  • [ ] 웹훅 수신 테스트
  • [ ] 결제 실패 시나리오 테스트

최종 업데이트: 2026년 2월 6일 - SP명 NMP__CREATE_STEPPAY 형식으로 수정, STP 코드→로컬 IDX 변환 패턴, order.created/payment.completed 이벤트 추가*