우리 서버
↓ 외부 API 요청
외부 서비스
↓ 비동기 처리
우리 서버의 Webhook URL 호출
↓
결과 저장 및 후속 처리
| 구분 | 일반 API 호출 | Webhook |
|---|---|---|
| 요청 주체 | 우리 서버 | 외부 서비스 |
| 처리 방식 | 우리가 호출하고 응답 받음 | 외부 서비스가 나중에 알려줌 |
| 사용 예시 | 결제 승인 요청, SMS 발송 요청 | 결제 완료 알림, 발송 결과 알림 |
| 중요 포인트 | timeout, 재시도 | 검증, 중복 처리, 이력 저장 |
본인인증 요청
↓
사용자가 인증 완료
↓
인증 업체가 callbackUrl로 결과 전달
↓
우리 서버가 인증 결과 저장
주문 생성
↓
결제 요청
↓
외부 결제사 처리
↓
결제 완료 Webhook 수신
↓
주문 상태 PAID 변경
↓
결제 이력 저장
본인인증 시작
↓
사용자 인증 완료
↓
인증 업체 callback 호출
↓
인증 성공/실패 저장
↓
다음 가입 단계 진행 가능
알림톡 발송 요청 성공
↓
외부 업체가 실제 발송 처리
↓
성공/실패 결과 Webhook 전달
↓
발송 로그 상태 업데이트
상담 신청 정보 CRM 전송
↓
CRM 시스템에서 접수 처리
↓
처리 결과 Callback 수신
↓
우리 DB에 외부 접수번호 저장
POST /api/webhooks/payment
POST /api/webhooks/identity
POST /api/webhooks/kakao-alimtalk
POST /api/webhooks/sms
POST /api/webhooks/crm
POST를 사용한다.개발:
https://dev-api.example.com/api/webhooks/payment
운영:
https://api.example.com/api/webhooks/payment
공격자가 Webhook URL을 알아냄
↓
임의로 결제 완료 요청 전송
↓
서명 검증이 없으면 주문이 PAID로 변경될 수 있음
Header:
x-webhook-secret: shared-secret-value
const secret = request.headers['x-webhook-secret'];
if (secret !== this.configService.get('WEBHOOK_SECRET')) {
throw new UnauthorizedException('Invalid webhook secret');
}
외부 서비스:
request body + secret으로 signature 생성
↓
Header에 signature 포함
우리 서버:
받은 body + secret으로 signature 재계산
↓
Header signature와 비교
import * as crypto from 'crypto';
function verifySignature(rawBody: string, signature: string, secret: string) {
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature),
);
}
timingSafeEqual 같은 안전한 비교 방식을 사용하는 것이 좋습니다.외부 서비스가 서명한 대상:
원본 요청 Body 문자열
우리 서버가 검증할 대상:
JSON 파싱 후 객체
결과:
서명 불일치 가능
200 OK 또는 204 No Content를 반환합니다.정상 처리:
200 OK
잘못된 서명:
401 Unauthorized 또는 403 Forbidden
잘못된 요청 형식:
400 Bad Request
서버 오류:
500 Internal Server Error
Webhook 수신
↓
검증
↓
기본 이력 저장
↓
Queue에 후속 작업 등록
↓
빠르게 200 응답
외부 서비스가 Webhook 전송
↓
우리 서버가 처리 성공
↓
응답 중 네트워크 오류
↓
외부 서비스는 실패로 판단
↓
같은 Webhook 재전송
externalEventId = evt_12345
이미 처리한 evt_12345가 있으면:
중복 처리하지 않음
model WebhookEvent {
id Int @id @default(autoincrement())
provider String
eventId String
eventType String
status String
relatedType String?
relatedId Int?
payload Json?
errorMessage String?
receivedAt DateTime @default(now())
processedAt DateTime?
@@unique([provider, eventId])
@@index([provider, eventType])
@@index([status, receivedAt])
}
@@unique([provider, eventId])로 같은 이벤트가 중복 저장되지 않게 막을 수 있습니다.async handleWebhook(payload: PaymentWebhookPayload) {
const event = await this.prisma.webhookEvent.create({
data: {
provider: 'PAYMENT_PROVIDER',
eventId: payload.eventId,
eventType: payload.type,
status: 'RECEIVED',
payload,
},
}).catch((error) => {
if (error.code === 'P2002') {
return null;
}
throw error;
});
if (!event) {
return {
duplicated: true,
};
}
// 실제 처리 로직 진행
}
PENDING → PAID
PENDING → FAILED
PAID → REFUNDED
PAID → PARTIAL_REFUNDED
불가능한 흐름:
FAILED → PAID
REFUNDED → PAID
주문이 이미 CANCELLED 상태
↓
뒤늦게 결제 완료 Webhook 수신
↓
검증 없이 PAID 변경
↓
취소된 주문이 결제 완료로 바뀜
정상 순서:
PAYMENT_APPROVED → PAYMENT_CANCELLED
실제 수신:
PAYMENT_CANCELLED 먼저 도착
PAYMENT_APPROVED 나중에 도착
1. WebhookEvent 저장
2. 주문 조회
3. 상태 전이 검증
4. 주문 상태 PAID 변경
5. 결제 이력 저장
6. WebhookEvent 처리 완료 변경
await this.prisma.$transaction(async (tx) => {
const order = await tx.order.findUnique({
where: {
id: orderId,
},
});
if (!order) {
throw new NotFoundException('주문을 찾을 수 없습니다.');
}
if (!canChangePaymentStatus(order.paymentStatus, 'PAID')) {
throw new BadRequestException('변경할 수 없는 결제 상태입니다.');
}
await tx.order.update({
where: {
id: order.id,
},
data: {
paymentStatus: 'PAID',
paidAt: new Date(),
},
});
await tx.paymentHistory.create({
data: {
orderId: order.id,
fromStatus: order.paymentStatus,
toStatus: 'PAID',
provider: 'PAYMENT_PROVIDER',
externalPaymentId,
},
});
await tx.webhookEvent.update({
where: {
id: webhookEventId,
},
data: {
status: 'PROCESSED',
processedAt: new Date(),
},
});
});
Webhook 수신
↓
서명 검증
↓
이벤트 저장
↓
핵심 상태 변경
↓
Queue에 후속 작업 등록
↓
200 응답
고객에게 결제 완료 알림톡 발송
관리자에게 신규 결제 알림
CRM 상태 동기화
광고 전환 API 전송
통계 집계 갱신
정산 데이터 생성
Webhook 자체는 빠르게 끝내고, 오래 걸리는 작업은 Worker가 처리하게 만드는 것이 안정적입니다.
| 실패 | 대응 |
|---|---|
| 서명 검증 실패 | 401/403 반환, 이력 저장 검토 |
| 요청 형식 오류 | 400 반환 |
| 연관 데이터 없음 | 실패 이력 저장, 관리자 확인 |
| 상태 전이 불가 | 무시 또는 보류 처리 |
| DB 오류 | 500 반환, 재전송 유도 |
| 중복 이벤트 | 200 반환 가능, 중복 처리 안 함 |
이미 처리한 Webhook 재수신
↓
중복으로 판단
↓
추가 처리 없이 200 OK
↓
외부 서비스는 전달 성공으로 판단
원본 payload 저장:
디버깅에 유리하지만 개인정보 위험 있음
마스킹 payload 저장:
보안에 유리하지만 디버깅 정보가 줄어듦
WebhookEvent 상태 FAILED
↓
관리자 페이지에서 실패 원인 확인
↓
재처리 버튼 클릭
↓
Queue에 재처리 Job 등록
↓
Worker가 다시 처리
↓
성공 시 PROCESSED 변경
@Controller('webhooks/payment')
export class PaymentWebhookController {
constructor(
private readonly paymentWebhookService: PaymentWebhookService,
) {}
@Post()
async handlePaymentWebhook(
@Headers('x-signature') signature: string,
@Body() body: PaymentWebhookDto,
@Req() req: Request,
) {
await this.paymentWebhookService.handle({
signature,
body,
rawBody: req['rawBody'],
ipAddress: req.ip,
userAgent: req.headers['user-agent'],
});
return {
received: true,
};
}
}
async handle(params: HandlePaymentWebhookParams) {
this.verifySignature(params.rawBody, params.signature);
const normalizedEvent = this.normalizeEvent(params.body);
const webhookEvent = await this.createWebhookEventIfNotExists(normalizedEvent);
if (!webhookEvent) {
return {
duplicated: true,
};
}
await this.processPaymentEvent(webhookEvent.id, normalizedEvent);
return {
success: true,
};
}
NestJS + Prisma 서비스에서 결제사 Webhook을 수신하는 구조를 만들고 싶어.
상황:
1. 결제사는 결제 승인, 결제 실패, 결제 취소 이벤트를 Webhook으로 보냄
2. 각 이벤트에는 eventId, paymentId, orderId, eventType, occurredAt이 있음
3. 같은 Webhook이 여러 번 올 수 있음
4. 이벤트 순서가 바뀌어 도착할 수도 있음
5. 주문 상태는 PENDING, PAID, FAILED, CANCELLED, REFUNDED가 있음
6. 서명 검증에는 Raw Body와 Secret이 필요함
7. 결제 상태 변경과 결제 이력을 트랜잭션으로 저장하고 싶음
8. 실패한 Webhook은 관리자 페이지에서 확인하고 재처리하고 싶음
요청:
- Webhook URL 설계
- Signature 검증 방식
- WebhookEvent 테이블 설계
- 중복 이벤트 처리
- 상태 전이 검증
- Prisma 트랜잭션 처리 흐름
- 실패 이벤트 재처리 구조
- 로그 보안 기준
을 실무 기준으로 설명해줘.