Appearance
NextMarket - StepPay 연동 설계서
1. 연동 개요
1.1 연동 범위
| NextMarket 기능 | StepPay API | 연동 방식 |
|---|---|---|
| 회원 가입 | Customer API | 회원 생성 시 StepPay 고객 생성 |
| 상품 등록 | Product API | 상품 생성 시 StepPay 상품/가격 생성 |
| 주문/결제 | Order API | 주문 생성 → 결제 링크 반환 |
| 구독 관리 | Subscription API | 구독 생성/변경/취소 |
| 결제 결과 | Webhook | StepPay → 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 이벤트 추가*