본문으로 건너뛰기

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_configswebhookSecret(복호화)으로 서명을 검증합니다.

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


멱등성

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


변경 이력

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