Appearance
구독 서비스 연동 방안
NextMarket에서 구독 상품 결제 시 각 SaaS 서비스(aiagent 등)에 구독 활성화를 전달하는 아키텍처
1. 개요
1.1 현재 상황
┌─────────────────────────────────────────────────────────────┐
│ NextMarket │
│ (결제 플랫폼 - StepPay 연동) │
├─────────────────────────────────────────────────────────────┤
│ 상품 A: aiagent 월간 구독 │
│ 상품 B: 다른 SaaS 서비스 구독 │
│ 상품 C: 또 다른 서비스 구독 │
└─────────────────────────────────────────────────────────────┘
│
│ 결제 완료 후 각 서비스에 구독 활성화 필요
▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ aiagent │ │ Service B │ │ Service C │
│ API │ │ API │ │ API │
└─────────────┘ └─────────────┘ └─────────────┘1.2 핵심 요구사항
- 상품별 서비스 구분: 어떤 상품이 어떤 서비스와 연동되는지 관리
- 구독 상태 동기화: 활성화/일시정지/해지 등 상태 변경 시 서비스에 전파
- 확장성: 새로운 서비스 추가 시 코드 변경 최소화
2. 아키텍처 설계
2.1 전체 흐름
┌──────────────────────────────────────────────────────────────────────┐
│ NextMarket API │
├──────────────────────────────────────────────────────────────────────┤
│ │
│ [1] StepPay Webhook 수신 │
│ │ │
│ ▼ │
│ [2] Webhook Handler (subscription.paid, subscription.cancelled 등) │
│ │ │
│ ▼ │
│ [3] Service Registry에서 상품에 연결된 서비스 조회 │
│ │ │
│ ▼ │
│ [4] 각 서비스 Adapter를 통해 구독 상태 전파 │
│ │ │
│ ├──────────────┬──────────────┬──────────────┐ │
│ ▼ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ aiagent │ │ Service │ │ Service │ │ Generic │ │
│ │ Adapter │ │ B Adapter│ │ C Adapter│ │ Webhook │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
└────────┼──────────────┼──────────────┼──────────────┼────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
aiagent-api service-b-api service-c-api 외부 서비스2.2 서비스 레지스트리 패턴
상품과 서비스의 매핑을 DB에서 관리하여 유연성 확보:
┌─────────────────────┐ ┌─────────────────────────────┐
│ PRODUCTS │ │ SERVICE_REGISTRY │
├─────────────────────┤ ├─────────────────────────────┤
│ ID: 1001 │ │ SERVICE_CODE: aiagent │
│ NAME: AI Agent 월간 │──┐ │ SERVICE_NAME: AI Agent │
│ CODE: aiagent_m01 │ │ │ BASE_URL: https://aiagent..│
└─────────────────────┘ │ │ WEBHOOK_PATH: /webhooks/sub│
│ └─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ PRODUCT_SERVICE_MAPPING │
├─────────────────────────────┤
│ PRODUCT_ID: 1001 │
│ SERVICE_CODE: aiagent │
│ SYNC_CONFIG: {...} │
└─────────────────────────────┘3. 데이터베이스 스키마
3.1 SERVICE_REGISTRY (서비스 레지스트리)
sql
CREATE TABLE SERVICE_REGISTRY (
SERVICE_CODE VARCHAR(50) PRIMARY KEY COMMENT '서비스 코드',
SERVICE_NAME VARCHAR(100) NOT NULL COMMENT '서비스명',
BASE_URL VARCHAR(500) NOT NULL COMMENT '서비스 API URL',
WEBHOOK_PATH VARCHAR(200) DEFAULT '/webhooks/subscription' COMMENT '웹훅 경로',
API_KEY VARCHAR(255) NULL COMMENT 'API 인증키',
AUTH_TYPE ENUM('BEARER', 'API_KEY', 'BASIC', 'NONE') DEFAULT 'BEARER',
IS_ACTIVE BOOLEAN DEFAULT TRUE COMMENT '활성화 여부',
RETRY_COUNT INT DEFAULT 3 COMMENT '재시도 횟수',
TIMEOUT_MS INT DEFAULT 5000 COMMENT '타임아웃 (ms)',
CREATE_TS DATETIME DEFAULT CURRENT_TIMESTAMP,
MODIFY_TS DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX IDX_ACTIVE (IS_ACTIVE)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;예시 데이터:
| SERVICE_CODE | SERVICE_NAME | BASE_URL | API_KEY |
|---|---|---|---|
| aiagent | AI Agent 서비스 | https://api.aiagent.com | sk_xxx |
| analytics | 분석 서비스 | https://api.analytics.com | ak_yyy |
3.2 PRODUCT_SERVICE_MAPPING (상품-서비스 매핑)
sql
CREATE TABLE PRODUCT_SERVICE_MAPPING (
PRODUCT_ID BIGINT NOT NULL COMMENT 'StepPay 상품 ID',
SERVICE_CODE VARCHAR(50) NOT NULL COMMENT '서비스 코드',
SYNC_CONFIG JSON NULL COMMENT '동기화 설정 (서비스별 추가 파라미터)',
IS_ACTIVE BOOLEAN DEFAULT TRUE,
CREATE_TS DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (PRODUCT_ID, SERVICE_CODE),
FOREIGN KEY (PRODUCT_ID) REFERENCES PRODUCTS(ID) ON DELETE CASCADE,
FOREIGN KEY (SERVICE_CODE) REFERENCES SERVICE_REGISTRY(SERVICE_CODE) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;SYNC_CONFIG 예시:
json
{
"planType": "SUBSCRIBE:M01",
"features": ["chat", "analytics"],
"maxUsers": 5
}3.3 SERVICE_SYNC_LOG (동기화 로그)
sql
CREATE TABLE SERVICE_SYNC_LOG (
ID BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
SUBSCRIPTION_ID BIGINT NOT NULL COMMENT '구독 ID',
SERVICE_CODE VARCHAR(50) NOT NULL COMMENT '서비스 코드',
EVENT_TYPE VARCHAR(50) NOT NULL COMMENT '이벤트 타입',
REQUEST_PAYLOAD JSON NULL COMMENT '요청 데이터',
RESPONSE_PAYLOAD JSON NULL COMMENT '응답 데이터',
STATUS ENUM('PENDING', 'SUCCESS', 'FAILED', 'RETRY') DEFAULT 'PENDING',
ERROR_MESSAGE TEXT NULL,
RETRY_COUNT INT DEFAULT 0,
CREATE_TS DATETIME DEFAULT CURRENT_TIMESTAMP,
PROCESSED_TS DATETIME NULL,
INDEX IDX_SUBSCRIPTION (SUBSCRIPTION_ID),
INDEX IDX_SERVICE (SERVICE_CODE),
INDEX IDX_STATUS (STATUS),
INDEX IDX_CREATE_TS (CREATE_TS)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;4. 이벤트 타입
4.1 구독 관련 이벤트
| 이벤트 | 설명 | 서비스 액션 |
|---|---|---|
subscription.activated | 구독 활성화 (첫 결제 완료) | 서비스 접근 권한 부여 |
subscription.renewed | 구독 갱신 (정기 결제 완료) | 만료일 연장 |
subscription.paused | 구독 일시정지 | 서비스 일시 중단 |
subscription.resumed | 구독 재개 | 서비스 접근 복구 |
subscription.cancelled | 구독 해지 | 서비스 접근 권한 제거 |
subscription.expired | 구독 만료 | 서비스 접근 권한 제거 |
4.2 웹훅 페이로드 구조
typescript
interface SubscriptionWebhookPayload {
eventType: string; // 이벤트 타입
timestamp: string; // ISO 8601 형식
subscription: {
id: number; // NextMarket 구독 ID
code: string; // 구독 코드
status: string; // 구독 상태
startDate: string; // 시작일
endDate: string; // 종료일 (해지 시)
nextPaymentDate?: string; // 다음 결제일
};
customer: {
id: number; // NextMarket 고객 ID
code: string; // 고객 코드
email: string;
name?: string;
phone?: string;
};
product: {
id: number; // 상품 ID
code: string; // 상품 코드
name: string; // 상품명
};
syncConfig?: object; // PRODUCT_SERVICE_MAPPING.SYNC_CONFIG
}5. 구현 방안
5.1 서비스 연동 모듈 구조
src/modules/service-sync/
├── service-sync.module.ts
├── service-sync.service.ts # 메인 동기화 로직
├── service-registry.service.ts # 서비스 레지스트리 관리
├── adapters/
│ ├── base.adapter.ts # 기본 어댑터 인터페이스
│ ├── aiagent.adapter.ts # aiagent 전용 어댑터
│ ├── generic-webhook.adapter.ts # 범용 웹훅 어댑터
│ └── index.ts
├── dto/
│ └── webhook-payload.dto.ts
└── interfaces/
└── service-adapter.interface.ts5.2 어댑터 인터페이스
typescript
// interfaces/service-adapter.interface.ts
export interface ServiceAdapter {
serviceCode: string;
// 구독 활성화
activateSubscription(payload: SubscriptionWebhookPayload): Promise<AdapterResult>;
// 구독 갱신
renewSubscription(payload: SubscriptionWebhookPayload): Promise<AdapterResult>;
// 구독 일시정지
pauseSubscription(payload: SubscriptionWebhookPayload): Promise<AdapterResult>;
// 구독 재개
resumeSubscription(payload: SubscriptionWebhookPayload): Promise<AdapterResult>;
// 구독 해지
cancelSubscription(payload: SubscriptionWebhookPayload): Promise<AdapterResult>;
}
export interface AdapterResult {
success: boolean;
serviceResponse?: any;
error?: string;
}5.3 aiagent 어댑터 구현 예시
typescript
// adapters/aiagent.adapter.ts
@Injectable()
export class AiagentAdapter implements ServiceAdapter {
serviceCode = 'aiagent';
constructor(private readonly httpService: HttpService) {}
async activateSubscription(payload: SubscriptionWebhookPayload): Promise<AdapterResult> {
// aiagent API 호출: 구독 승인
const response = await this.httpService.post(
`${payload.serviceConfig.baseUrl}/v1/subscribe/approval`,
{
customerId: payload.customer.id,
customerEmail: payload.customer.email,
subType: payload.syncConfig?.planType || 'SUBSCRIBE:M01',
subStart: payload.subscription.startDate,
subFinish: payload.subscription.endDate,
}
).toPromise();
return {
success: response.data.success,
serviceResponse: response.data,
};
}
async cancelSubscription(payload: SubscriptionWebhookPayload): Promise<AdapterResult> {
// aiagent API 호출: 구독 만료 처리
const response = await this.httpService.post(
`${payload.serviceConfig.baseUrl}/v1/subscribe/expire`,
{
customerId: payload.customer.id,
}
).toPromise();
return {
success: response.data.success,
serviceResponse: response.data,
};
}
// ... 나머지 메서드 구현
}5.4 범용 웹훅 어댑터
새로운 서비스 추가 시 코드 변경 없이 DB 설정만으로 연동:
typescript
// adapters/generic-webhook.adapter.ts
@Injectable()
export class GenericWebhookAdapter implements ServiceAdapter {
serviceCode = 'generic';
async activateSubscription(
payload: SubscriptionWebhookPayload,
serviceConfig: ServiceRegistry
): Promise<AdapterResult> {
const webhookUrl = `${serviceConfig.baseUrl}${serviceConfig.webhookPath}`;
const response = await this.httpService.post(webhookUrl, {
eventType: 'subscription.activated',
...payload,
}, {
headers: this.buildAuthHeaders(serviceConfig),
timeout: serviceConfig.timeoutMs,
}).toPromise();
return {
success: response.status === 200,
serviceResponse: response.data,
};
}
private buildAuthHeaders(config: ServiceRegistry): Record<string, string> {
switch (config.authType) {
case 'BEARER':
return { 'Authorization': `Bearer ${config.apiKey}` };
case 'API_KEY':
return { 'X-API-Key': config.apiKey };
default:
return {};
}
}
}6. 처리 흐름
6.1 구독 활성화 흐름
StepPay Webhook (subscription.paid)
│
▼
┌─────────────────────────────────────────────┐
│ WebhooksController.handleSteppayWebhook() │
│ - 웹훅 검증 및 로깅 │
└─────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ ServiceSyncService.handleSubscriptionEvent()│
│ 1. 구독 정보 조회 │
│ 2. 상품에 연결된 서비스 목록 조회 │
│ 3. 각 서비스별 어댑터 호출 │
└─────────────────────────────────────────────┘
│
├─── aiagent 연결됨 ──────────────────┐
│ ▼
│ ┌────────────────────────────┐
│ │ AiagentAdapter.activate() │
│ │ - /v1/subscribe/approval │
│ └────────────────────────────┘
│
├─── service-b 연결됨 ────────────────┐
│ ▼
│ ┌────────────────────────────┐
│ │ GenericWebhook.activate() │
│ │ - POST /webhooks/sub │
│ └────────────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ SERVICE_SYNC_LOG에 결과 저장 │
│ - 성공/실패 여부 │
│ - 재시도 필요 시 큐에 등록 │
└─────────────────────────────────────────────┘6.2 실패 재처리 전략
typescript
// 재시도 로직
async function retryFailedSync() {
const failedLogs = await this.db.call('SP_GET_FAILED_SYNC_LOGS');
for (const log of failedLogs) {
if (log.retryCount >= log.maxRetry) {
// 최대 재시도 초과 - 알림 발송
await this.notifyAdmin(log);
continue;
}
// 지수 백오프로 재시도
const delay = Math.pow(2, log.retryCount) * 1000; // 1s, 2s, 4s, 8s...
await this.sleep(delay);
await this.processSync(log);
}
}7. aiagent 연동 상세
7.1 aiagent 현재 구독 API
aiagent-api의 기존 구독 관련 API:
| 엔드포인트 | 메서드 | 설명 |
|---|---|---|
/v1/subscribe/regist | POST | 구독 등록 (신규) |
/v1/subscribe/approval | POST | 구독 승인 (활성화) |
/v1/subscribe/update | POST | 구독 수정 |
/v1/subscribe/list | GET | 구독 목록 조회 |
/v1/subscribe/info | GET | 구독 상세 정보 |
7.2 aiagent 구독 상태값
SUB_STAT:WAIT - 대기 (결제 전)
SUB_STAT:APROVAL - 승인 (활성)
SUB_STAT:EXPIRE - 만료7.3 NextMarket → aiagent 매핑
| NextMarket 이벤트 | aiagent API | aiagent 상태 |
|---|---|---|
| subscription.activated | /v1/subscribe/approval | SUB_STAT:APROVAL |
| subscription.renewed | /v1/subscribe/update | SUB_STAT:APROVAL (기간 연장) |
| subscription.cancelled | (상태 업데이트) | SUB_STAT:EXPIRE |
| subscription.expired | (상태 업데이트) | SUB_STAT:EXPIRE |
7.4 aiagent용 SYNC_CONFIG 예시
json
{
"planType": "SUBSCRIBE:M01",
"createRagTenant": true,
"agentId": "smartstore-message-agent",
"features": {
"chat": true,
"analytics": true,
"export": false
}
}8. 구현 우선순위
Phase 1: 기본 인프라 (1주차)
- [ ] SERVICE_REGISTRY, PRODUCT_SERVICE_MAPPING 테이블 생성
- [ ] service-sync 모듈 기본 구조 구현
- [ ] 범용 웹훅 어댑터 구현
Phase 2: aiagent 연동 (2주차)
- [ ] aiagent 전용 어댑터 구현
- [ ] 구독 활성화/해지 테스트
- [ ] 에러 핸들링 및 로깅
Phase 3: 안정화 (3주차)
- [ ] 재시도 로직 구현
- [ ] 모니터링 대시보드
- [ ] 관리자 알림 시스템
9. 설정 예시
9.1 config.json 추가 항목
json
{
"serviceSync": {
"enabled": true,
"retryMaxCount": 3,
"retryDelayMs": 1000,
"timeoutMs": 5000
},
"services": {
"aiagent": {
"baseUrl": "https://api.aiagent.example.com",
"apiKey": "${AIAGENT_API_KEY}"
}
}
}9.2 초기 데이터 INSERT
sql
-- 서비스 레지스트리 등록
INSERT INTO SERVICE_REGISTRY (SERVICE_CODE, SERVICE_NAME, BASE_URL, API_KEY, AUTH_TYPE) VALUES
('aiagent', 'AI Agent 서비스', 'https://api.aiagent.example.com', 'sk_xxx', 'BEARER');
-- 상품-서비스 매핑 (상품 ID는 실제 StepPay 상품 ID로 변경)
INSERT INTO PRODUCT_SERVICE_MAPPING (PRODUCT_ID, SERVICE_CODE, SYNC_CONFIG) VALUES
(1001, 'aiagent', '{"planType": "SUBSCRIBE:M01", "createRagTenant": true}');10. 참고 자료
작성일: 2026년 1월