본문으로 건너뛰기

Webhook 처리

R2 업로드 이벤트와 Stream 트랜스코딩 완료 이벤트를 서버가 처리하는 과정

문서 정보​

  • 작성일: 2026-03-02
  • 최종 업데이트: 2026-03-02
  • 버전: v1.0.0
  • 관련 코드: src/modules/cloudflare/raw-video-webhook/

개요​

체육관 R2에 영상이 업로드되면 두 가지 webhook이 Spoclip 서버로 전달됩니다.

Webhook엔드포인트트리거
R2 WebhookPOST /cloudflare/raw-video-webhooks/:gymCode/r2R2 객체 생성/삭제
Stream WebhookPOST /cloudflare/raw-video-webhooks/:gymCode/streamStream 트랜스코딩 완료/실패

두 엔드포인트 모두 @Public() (JWT 불필요)이며, HMAC-SHA256 서명으로 요청의 진위를 검증합니다.


R2 Webhook​

요청 페이로드​

{
"idempotencyKey": "uuid-v4",
"eventType": "object-create",
"object": {
"key": "COURT-A/2026-03-01/14/3/CAM-FRONT.mp4",
"size": 524288000,
"passthrough": {
"source": "R2",
"type": "RAW_VIDEO",
"gymCode": "GYM-001",
"courtCode": "COURT-A",
"cameraCode": "CAM-FRONT",
"date": "2026-03-01",
"hour": 14,
"timeSlot": 3
}
}
}

처리 흐름 (R2 Orchestrator)​

관련 코드: src/modules/cloudflare/raw-video-webhook/orchestrators/r2/r2.orchestrator.ts

ObjectCreatedCommand 상세​

  1. 해당 시간대(courtId + startTime + endTime)의 Video가 없으면 새로 생성
  2. Recording을 upsert (courtId + startTime + endTime 기준)
  3. 체육관 R2 크레덴셜로 presigned URL 생성 (72시간 TTL)
  4. Video.metadata에 카메라별 R2 정보 추가:
videoResources[cameraCode] = {
r2: {
bucketName: 'raw-videos',
objectKey: 'COURT-A/2026-03-01/14/3/CAM-FRONT.mp4',
uploadStatus: 'UPLOADED',
size: 524288000,
uploadedAt: '2026-03-01T14:40:00Z',
playbackUrl: 'https://...' // presigned URL
}
}

관련 코드: src/modules/cloudflare/raw-video-webhook/orchestrators/r2/commands/object-created.command.ts


Stream Webhook​

요청 페이로드 (Cloudflare 원본)​

{
"uid": "stream-video-uid",
"readyToStream": true,
"status": { "state": "ready" },
"duration": 600,
"thumbnail": "https://...",
"playback": {
"hls": "https://customer-xxx.cloudflarestream.com/.../manifest/video.m3u8"
},
"meta": {
"source": "R2",
"type": "RAW_VIDEO",
"gymCode": "GYM-001",
"courtCode": "COURT-A",
"cameraCode": "CAM-FRONT",
"date": "2026-03-01",
"hour": "14",
"timeSlot": "3"
}
}

Interceptor 변환​

Cloudflare Stream은 passthrough 필드를 meta 최상위에 flat하게 전송합니다. CloudflareStreamTransformInterceptor가 이를 서버 내부 형식으로 변환합니다.

변환 전변환 후
meta.sourcemeta.passthrough.source
meta.gymCodemeta.passthrough.gymCode
meta['downloaded-from']meta.downloadedFrom

관련 코드: src/modules/cloudflare/raw-video-webhook/shared/interceptors/cloudflare-stream-transform.interceptor.ts

처리 흐름 (Stream Orchestrator)​

R2 Orchestrator와 동일한 전처리(파싱, 분산 락, Context, 서명 검증) 후:

Stream 상태처리
readyToStream === trueReadyCommand - Stream 메타데이터 설정
status.state === 'error'ErroredCommand - 실패 기록
그 외SKIPPED

ReadyCommand 상세​

R2 webhook이 먼저 처리되어야 합니다 (카메라 엔트리가 Video.metadata에 존재해야 함).

videoResources[cameraCode].stream = {
videoId: 'stream-video-uid',
uploadStatus: 'READY',
readyToStream: true,
encodedAt: '2026-03-01T14:45:00Z',
playbackUrl: 'https://customer-xxx.cloudflarestream.com/.../manifest/video.m3u8',
duration: 600,
thumbnail: 'https://...'
}

Stream의 HLS URL이 설정되면 사용자가 앱에서 영상을 시청할 수 있습니다.

관련 코드: src/modules/cloudflare/raw-video-webhook/orchestrators/stream/commands/ready.command.ts


Context 조회​

Webhook 처리 시 체육관의 크레덴셜과 설정 정보를 조회합니다.

조회 경로​

gyms → courts → court_media_configs → gym_media_configs
WHERE provider = 'CLOUDFLARE' AND status = 'ACTIVE'

반환 정보​

필드용도
gymId체육관 식별
courtId코트 식별
webhookSecretHMAC 서명 검증 (복호화)
accountIdCF Account ID (복호화)
r2ApiKey, r2ApiSecretR2 presigned URL 생성 (복호화)

캐싱​

동일 시간대에 여러 카메라 webhook이 연속으로 도착하므로, Context는 10분간 Redis에 캐싱됩니다.

관련 코드: src/modules/cloudflare/raw-video-webhook/shared/context-resolver.util.ts


분산 락​

락 키 형식​

video:{gymCode}:{courtCode}:{date}:{hour}:{timeSlot}

락 파라미터​

파라미터값
TTL10초
최대 재시도50회
재시도 간격100ms

동일 시간대의 멀티카메라 webhook이 동시에 도착해도, 분산 락으로 하나의 Video 엔티티에 대한 동시 쓰기를 방지합니다.


HMAC 서명 검증​

모든 webhook 요청은 Webhook-Signature 헤더에 HMAC-SHA256 서명을 포함합니다. 서버는 gym_media_configs의 webhookSecret(복호화)으로 서명을 검증합니다.

관련 코드: src/modules/cloudflare/raw-video-webhook/core/signature.util.ts


멱등성​

모든 webhook 요청에는 idempotencyKey가 포함됩니다. 처리 완료 시 webhook 로그를 생성하여 동일한 요청이 재전송되어도 중복 처리를 방지합니다.


변경 이력​

버전날짜변경 내용
v1.0.02026-03-02초기 문서 작성
- R2/Stream Webhook 처리 흐름
- Context 조회 및 캐싱
- 분산 락, HMAC 서명, 멱등성 설명
- Interceptor 변환 로직