우리 백엔드
↓
외부 API 호출
↓
외부 서비스 응답
↓
결과 저장
외부 서비스 장애 가능
네트워크 timeout 가능
응답 형식 변경 가능
rate limit 가능
인증 토큰 만료 가능
중복 요청 가능
성공했는데 응답만 실패할 가능성
운영 로그와 추적이 필요함
외부 서비스에서 이벤트 발생
↓
우리 서버의 Webhook URL 호출
↓
우리 서버가 이벤트 저장/처리
결제 완료 알림
본인인증 완료 알림
배송 상태 변경 알림
알림톡 발송 결과 알림
통신사 개통 상태 변경 알림
광고 전환 이벤트 수신
외부 CRM 상담 상태 변경 수신
| 구분 | 일반 외부 API 호출 | Webhook |
|---|---|---|
| 요청 주체 | 우리 서버 | 외부 서비스 |
| 방향 | 우리 → 외부 | 외부 → 우리 |
| 예시 | 알림톡 발송 요청 | 알림톡 발송 결과 수신 |
| 핵심 | 요청/응답 처리 | 수신/검증/중복 처리 |
1. 외부 API 호출은 Adapter로 분리한다
2. transaction 안에서 외부 API를 직접 호출하지 않는다
3. timeout을 반드시 설정한다
4. 실패 결과를 DB에 기록한다
5. 재시도 가능/불가능 오류를 구분한다
6. 중복 요청과 중복 응답을 고려한다
7. Secret과 개인정보를 로그에 남기지 않는다
8. requestId/providerMessageId로 추적 가능하게 만든다
DB 정합성과 외부 API 호출을 분리해야 함
Worker / Use Case
↓
AlimtalkAdapter
↓
외부 알림톡 API
await axios.post('https://alimtalk.example.com/send', {
phone,
templateCode,
variables,
}, {
headers: {
Authorization: `Bearer ${process.env.ALIMTALK_API_KEY}`,
},
});
문제:
외부 API 주소가 업무 로직에 직접 노출
인증 방식이 여러 곳에 중복
timeout 설정 누락 가능
응답/에러 처리 중복
테스트 어려움
@Injectable()
export class AlimtalkAdapter {
constructor(private readonly httpService: HttpService) {}
async sendTemplate(params: {
recipient: string;
templateCode: string;
variables: Record<string, string>;
idempotencyKey?: string;
}): Promise<AlimtalkSendResult> {
const response = await firstValueFrom(
this.httpService.post(
'/messages/alimtalk',
{
phone: params.recipient,
templateCode: params.templateCode,
variables: params.variables,
},
{
timeout: 5000,
headers: {
'Idempotency-Key': params.idempotencyKey,
},
},
),
);
return {
providerMessageId: response.data.messageId,
status: response.data.status,
};
}
}
외부 URL 관리
요청 body 변환
인증 헤더 구성
timeout 설정
응답 body 변환
외부 에러를 내부 에러 형태로 변환
Transaction 시작
↓
consult 저장
↓
알림톡 API 호출
↓
외부 발송 성공
↓
audit log 저장 실패
↓
DB rollback
↓
하지만 알림톡은 이미 발송됨
외부 API는 rollback 불가
응답 지연 시 transaction 길어짐
DB lock 유지 시간이 늘어남
외부 장애가 DB 처리에 영향
성공/실패 상태가 꼬일 수 있음
Transaction 시작
↓
consult 저장
↓
notification_jobs 생성
↓
commit
↓
Worker가 알림톡 API 호출
↓
발송 결과 DB 저장
await axios.post(url, body);
문제:
응답이 없으면 오래 대기
Worker 처리 지연
Job이 PROCESSING에 오래 머무름
연쇄 지연 가능
await axios.post(url, body, {
timeout: 5000,
});
알림톡/SMS:
3~10초
결제/인증:
5~15초
파일 업로드:
용량에 따라 별도 설정
대량 API:
짧은 timeout + 재시도/분할 처리
네트워크 timeout
외부 서버 500 오류
일시적인 502/503/504
rate limit
일시적인 연결 실패
잘못된 전화번호
잘못된 템플릿 코드
필수 파라미터 누락
인증키 오류
권한 없음
수신 거부
존재하지 않는 대상
type ExternalApiErrorKind =
| 'RETRYABLE'
| 'NON_RETRYABLE'
| 'AUTH'
| 'RATE_LIMIT'
| 'UNKNOWN';
function classifyAlimtalkError(error: unknown): ExternalApiErrorKind {
if (isTimeoutError(error)) {
return 'RETRYABLE';
}
if (isRateLimitError(error)) {
return 'RATE_LIMIT';
}
if (isInvalidTemplateError(error)) {
return 'NON_RETRYABLE';
}
if (isAuthError(error)) {
return 'AUTH';
}
return 'UNKNOWN';
}
실패
↓
재시도 가능 여부 판단
↓
retryCount 증가
↓
nextRetryAt 설정
↓
일정 시간 후 재처리
최대 재시도:
3회
간격:
1분 → 5분 → 15분
최종 실패:
FAILED 상태 저장
관리자 화면에 실패 사유 표시
재시도 횟수가 늘수록 대기 시간을 늘리는 방식
function getNextRetryAt(retryCount: number) {
const delays = [60, 300, 900];
const delaySeconds = delays[retryCount] ?? 1800;
return addSeconds(new Date(), delaySeconds);
}
재시도 시간이 동시에 몰리지 않게 약간의 랜덤 시간을 추가
정상 상태:
외부 API 호출
실패 누적:
회로 열림
회로 열림 상태:
일정 시간 호출 차단
시간 경과:
일부 요청으로 회복 여부 확인
외부 알림톡 API 장애
결제 API 장애
인증 API 장애
외부 CRM API 장애
통신사 API 장애
최근 5분간 실패율 80% 이상
↓
10분간 호출 중단
↓
Job은 RETRYING 상태로 대기
↓
이후 다시 시도
초기:
retry/backoff + 실패 로그로 충분
장애가 반복됨:
circuit breaker 검토
외부 API 의존도가 높아짐:
도입 가치 증가
같은 알림 발송 요청이 2번 실행
↓
고객에게 알림이 2번 가면 안 됨
알림톡 발송
결제 승인/취소
Webhook 이벤트 처리
상담 신청 중복 클릭
외부 API timeout 후 재시도
Worker 재시작 후 Job 재처리
고유한 요청 key 생성
↓
이미 처리된 key인지 확인
↓
처리됨이면 기존 결과 반환
↓
처리 전이면 실행
알림톡:
notificationJobId를 idempotency key로 사용
Webhook:
providerEventId를 idempotency key로 사용
결제:
paymentId + eventType 사용
외부 Webhook 요청
↓
서명 검증
↓
eventId 중복 확인
↓
webhook_events 저장
↓
빠르게 200 응답
↓
Worker가 실제 처리
외부 서비스가 timeout으로 판단할 수 있음
같은 Webhook을 반복 재전송할 수 있음
긴 처리 중 실패하면 원인 추적 어려움
raw body 수신
signature header 추출
서명 검증
eventId 추출
WebhookEvent 저장 Use Case 호출
200 응답
model WebhookEvent {
id Int @id @default(autoincrement())
provider String
providerEventId String
eventType String
status WebhookEventStatus @default(RECEIVED)
payload Json
headers Json?
signatureValid Boolean @default(false)
processedAt DateTime?
errorCode String?
errorMessage String?
retryCount Int @default(0)
nextRetryAt DateTime?
receivedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, providerEventId])
@@index([status, nextRetryAt])
@@index([eventType, receivedAt])
}
enum WebhookEventStatus {
RECEIVED
PROCESSING
PROCESSED
FAILED
IGNORED
RETRYING
}
| 컬럼 | 의미 |
|---|---|
provider | Webhook 제공자 |
providerEventId | 외부 이벤트 고유 ID |
eventType | 이벤트 종류 |
status | 처리 상태 |
payload | 원본 또는 필요한 payload |
signatureValid | 서명 검증 여부 |
retryCount | 재시도 횟수 |
nextRetryAt | 다음 재시도 시각 |
provider + providerEventId unique
중복 Webhook 방지
처리 상태 추적
실패 사유 저장
비동기 재처리 가능
외부 서비스:
payload + secret으로 signature 생성
우리 서버:
같은 방식으로 signature 계산
↓
header signature와 비교
가짜 요청 방지
임의 상태 변경 방지
개통/결제/알림 결과 위조 방지
보안 사고 예방
function verifyWebhookSignature(params: {
rawBody: string;
signature: string;
secret: string;
}) {
const expected = crypto
.createHmac('sha256', params.secret)
.update(params.rawBody)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(params.signature),
);
}
raw body가 필요할 수 있음
JSON parse 후 body로 검증하면 실패할 수 있음
secret은 SSM/환경변수에서 관리
signature mismatch 로그에 secret 출력 금지
timingSafeEqual 사용 고려
Webhook eventId = abc123 수신
↓
처리 완료
↓
외부 서비스가 같은 eventId 다시 전송
↓
중복 처리되면 문제
try {
await this.webhookEventRepository.create({
provider: 'ALIMTALK',
providerEventId: event.id,
eventType: event.type,
payload: event.payload,
signatureValid: true,
});
} catch (error) {
if (isUniqueViolation(error)) {
return {
duplicated: true,
};
}
throw error;
}
providerEventId로 unique 처리
이미 처리된 이벤트는 200 반환
중복 수신 로그는 남기되 비즈니스 처리 중복 금지
webhook_events.RECEIVED
↓
Worker claim
↓
eventType별 Handler 실행
↓
상태 변경/이력/Audit Log 저장
↓
PROCESSED
WebhookProcessor
├─ AlimtalkDeliveryResultHandler
├─ PaymentCompletedHandler
├─ CarrierStatusChangedHandler
└─ CrmLeadUpdatedHandler
@Injectable()
export class WebhookProcessor {
async process(event: WebhookEvent) {
switch (event.eventType) {
case 'ALIMTALK_DELIVERED':
return this.alimtalkDeliveredHandler.handle(event);
case 'ALIMTALK_FAILED':
return this.alimtalkFailedHandler.handle(event);
default:
return this.webhookEventRepository.markIgnored({
eventId: event.id,
reason: '지원하지 않는 이벤트 타입입니다.',
});
}
}
}
Webhook 수신 응답이 빨라짐
처리 실패 시 재시도 가능
이벤트별 처리 로직 분리
외부 재전송과 내부 처리 분리
외부 알림톡 상태:
DELIVERED
FAILED
READ
EXPIRED
내부 NotificationJob 상태:
DONE
FAILED
function mapAlimtalkStatusToInternal(status: string): JobStatus {
switch (status) {
case 'DELIVERED':
return 'DONE';
case 'FAILED':
case 'EXPIRED':
return 'FAILED';
default:
return 'PROCESSING';
}
}
외부 상태를 내부 enum에 그대로 섞지 않기
매핑되지 않은 상태는 IGNORED 또는 UNKNOWN 처리
외부 상태 원본은 필요 시 rawStatus로 저장
상태 변경 이력과 Audit Log 기준 정리
providerMessageId
providerStatus
errorCode
safeErrorMessage
requestedAt
respondedAt
durationMs
전화번호 원본
고객 이름 원본
access token
refresh token
authorization header
provider secret
전체 response body
전체 request body
운영 추적에 필요한 최소값만 저장
원본 응답은 보관 필요성이 있을 때만 제한적으로 저장
민감정보 필터링 후 저장
로그와 DB 모두 같은 보안 기준 적용
requestId
jobId
provider
operation
targetType
targetId
durationMs
status
errorCode
retryCount
{
"requestId": "req_123",
"jobId": 10,
"provider": "ALIMTALK",
"operation": "SEND_TEMPLATE",
"targetType": "CONSULT",
"targetId": "532",
"durationMs": 842,
"status": "FAILED",
"errorCode": "TIMEOUT",
"retryCount": 1
}
phone 원본
recipientEncrypted
Authorization header
API key
template 변수 중 개인정보
전체 request/response body
외부 API:
1분에 100건까지만 허용
우리 Worker:
1분에 500건 호출
결과:
429 Too Many Requests
Worker concurrency 제한
초당/분당 처리량 제한
429 응답 시 재시도 간격 증가
provider별 rate limit 설정
대량 작업 분할 처리
const MAX_PER_MINUTE = 60;
// 간단한 구조에서는 interval 또는 batch size를 줄이는 방식으로 시작
외부 API 문서의 제한 확인
provider별 제한값 설정화
429는 재시도 가능 오류로 분류
backoff 적용
서명 검증
timestamp 검증
replay attack 방지
providerEventId unique
허용된 IP 대역 검토
secret 안전 보관
raw body 검증
정상 Webhook 캡처
↓
나중에 같은 요청 재전송
↓
상태가 다시 변경될 위험
timestamp header 검증
너무 오래된 요청 거부
providerEventId unique로 중복 처리 방지
signature에 timestamp 포함 여부 확인
const apiKey = 'sk_live_abcdef...';
코드에 Secret 하드코딩 금지
Git 커밋 금지
SSM/Secrets Manager 사용
권한 최소화
운영/개발 Secret 분리
로그 출력 금지
주기적 rotation 고려
로컬 .env 직접 보관 최소화
AWS SSM 경로로 관리
운영 Secret은 로컬/AI에 제공 금지
외부 API 키는 provider별로 분리
# Runbook: 외부 API 장애 대응
## 상황
- 알림톡 발송 실패 증가
- 외부 API timeout 증가
- Webhook 수신 실패
- provider 인증 오류
- rate limit 발생
## 즉시 확인
- provider:
- operation:
- 장애 시작 시각:
- 실패 job 수:
- 주요 errorCode:
- retryCount 분포:
- 최근 배포:
- Secret 변경 여부:
- provider 공지 여부:
## 확인 절차
1. Worker 로그 확인
2. 실패 Job 목록 확인
3. errorCode별 분포 확인
4. 외부 API 상태 페이지 또는 공지 확인
5. 인증키/Secret 만료 여부 확인
6. rate limit 여부 확인
7. 재시도 가능한 오류인지 분류
8. 필요 시 Worker 일시 중지
9. circuit breaker 또는 retry 간격 증가
10. 관리자 화면/운영자에게 안내
## 복구 후
- FAILED Job 재처리 여부 결정
- 중복 발송 여부 확인
- 고객 영향 범위 확인
- 장애 기록 작성
- retry/backoff 기준 수정
이유:
상담 신청/상태 변경 알림과 연결
외부 API 장애 가능
개인정보 포함 가능
재시도/로그 기준 필요
적용 범위:
AlimtalkAdapter
NotificationWorker
NotificationJob
providerMessageId 저장
errorCode/errorMessage 저장
timeout 설정
이유:
발송 결과/외부 상태 변경 수신 가능
중복 수신 방지 필요
처리 상태 추적 필요
적용 범위:
WebhookEvent table
Webhook Controller
signature 검증
providerEventId unique
WebhookProcessor
이유:
외부 장애가 있을 때 운영 대응 필요
무한 재시도 방지
실패 사유 관리 필요
적용 범위:
retryable/non-retryable 분류
retryCount
nextRetryAt
backoff
FAILED 상태 관리
이유:
외부 요청으로 내부 상태가 바뀔 수 있음
위조 요청 방지 필요
적용 범위:
signature 검증
timestamp 검증
raw body 처리
secret 관리
replay 방지
알림톡 외부 API 호출을 AlimtalkAdapter로 분리해줘.
조건:
1. NotificationWorker에서 직접 axios/http 호출하지 말고 AlimtalkAdapter를 사용하게 해줘
2. Adapter에서 timeout을 설정해줘
3. 외부 API 요청/응답을 내부 타입으로 변환해줘
4. providerMessageId, providerStatus, errorCode를 반환하게 해줘
5. 외부 API 실패는 retryable/non-retryable/auth/rate_limit으로 분류해줘
6. 전화번호 원본, API Key, Authorization header는 로그에 남기지 마
7. API Key는 환경변수/SSM에서 주입받는 ConfigService로 처리해줘
8. Worker는 Adapter 결과에 따라 NotificationJob을 DONE/FAILED/RETRYING으로 업데이트하게 해줘
9. 테스트 가능한 구조로 만들어줘
10. 변경 후 위험 요소와 QA 체크리스트를 정리해줘
알림톡 발송 결과 Webhook 수신 구조를 만들어줘.
조건:
1. Webhook Controller는 raw body와 signature header를 받아 검증하게 해줘
2. signature 검증 실패 시 401 또는 400으로 거절해줘
3. provider + providerEventId unique로 중복 수신을 막아줘
4. WebhookEvent 테이블에 payload, eventType, status, receivedAt을 저장해줘
5. 이미 수신한 eventId면 비즈니스 처리를 다시 하지 말고 200을 반환해줘
6. Controller에서 실제 비즈니스 처리까지 하지 말고 WebhookEvent 저장 후 빠르게 응답해줘
7. WebhookProcessor 또는 Worker가 RECEIVED 이벤트를 처리하게 해줘
8. eventType별 handler 구조로 분리해줘
9. payload와 로그에 개인정보/Secret이 남지 않게 해줘
10. 테스트 케이스와 보안 체크리스트를 정리해줘
외부 API 호출이 Adapter로 분리되었는가?
timeout이 설정되어 있는가?
transaction 안에서 외부 API를 호출하지 않는가?
retryable/non-retryable 오류가 구분되는가?
Webhook 서명 검증이 있는가?
Webhook 중복 처리가 unique로 방어되는가?
Secret과 개인정보가 로그에 남지 않는가?
Webhook Controller가 빠르게 응답하는가?
NestJS + Prisma + PostgreSQL 기반 온라인 휴대폰 판매몰에서 알림톡 외부 API와 Webhook 수신 구조를 설계하려고 해.
서비스 상황:
1. 고객 상담 신청 후 알림톡을 발송해야 함
2. 알림톡 발송은 NotificationJob + Worker에서 처리함
3. 외부 API timeout, rate limit, 인증 오류, 템플릿 오류를 구분하고 싶음
4. providerMessageId, errorCode, retryCount, nextRetryAt을 저장하고 싶음
5. 외부 API 호출은 Adapter로 분리하고 싶음
6. 외부 API Secret은 AWS SSM 또는 환경변수로 관리해야 함
7. 알림톡 발송 결과 Webhook을 받을 수 있음
8. Webhook은 signature 검증, providerEventId unique, 중복 처리 방지가 필요함
9. Controller에서는 WebhookEvent만 저장하고 빠르게 200 응답하고 싶음
10. 실제 이벤트 처리는 Worker/Processor가 담당하게 하고 싶음
11. 전화번호 원본, API Key, Authorization header, Secret은 로그에 남기면 안 됨
요청:
- AlimtalkAdapter 구조
- 외부 API timeout 설정 기준
- retryable/non-retryable 에러 분류
- retry/backoff 설계
- NotificationJob 상태 업데이트 기준
- WebhookEvent 테이블 설계
- Webhook signature 검증 구조
- 중복 Webhook 처리 방식
- Webhook Processor/Handler 구조
- 외부 상태와 내부 상태 매핑
- 로그/보안 체크리스트
- 장애 대응 Runbook
을 실무 기준으로 정리해줘.
외부 API 호출을 Adapter로 분리하는가?
timeout과 retry/backoff를 언급하는가?
재시도 가능/불가능 오류를 구분하는가?
transaction 안에서 외부 API를 호출하지 않게 하는가?
Webhook 서명 검증과 raw body 필요성을 설명하는가?
providerEventId unique로 중복 처리를 제안하는가?
Webhook Controller가 실제 처리를 길게 하지 않게 하는가?
Secret과 개인정보 로그 노출을 강하게 경고하는가?
현재 규모에서 과한 이벤트 시스템을 강요하지 않는가?
AlimtalkAdapter, SmsAdapter, PaymentAdapter 같은 Adapter로 분리하는 것이 좋습니다.NotificationJob row만 생성한 뒤 Worker가 실제 외부 API를 호출하는 구조가 안전합니다.AlimtalkAdapter, NotificationJob, WebhookEvent, retry/backoff, 중복 발송 방지 기준부터 잡는 것이 현실적인 우선순위입니다.