영상 인코딩 분산과 워커 회수

서버 메모리에 살던 비동기 작업을 DB로 옮기고, 두 서버가 나눠 처리하며, 워커가 죽으면 다른 서버가 작업을 가져가게 한 기록. MSG-494, 2026-08-27 dev 실측.

요약

업로드 확정 트랜잭션에 인코딩 작업 행을 함께 저장하고, BE EC2와 AI EC2에서 도는 같은 JAR이 FOR UPDATE SKIP LOCKED로 작업을 한 건씩 가져간다. 선점할 때 받은 토큰과 임대 시각이 종결 UPDATE의 조건이라, 죽은 워커의 늦은 결과는 0행으로 끝나고 롤백된다. 별도 큐 서버도, 로드 밸런서도 없다.

9 / 93회 × 3편 모두 READY, 유실 0건
25.683초확정 뒤 READY까지 최댓값 (목표 30초)
0건중복 알림 · 미완료 작업
be 5 · ai 49건의 처리 노드 분포
17.764초SIGTERM 뒤 다른 노드가 이어받아 READY
1.836ms선점 쿼리 실행 시간 (작업 행 21건)

인코딩 입력은 10.08초, 15.08초, 19.10초 MP4 세 편이고 3회 모두 같은 SHA-256을 썼다.

고친 문제

이전 인코딩은 스프링의 @Async 실행기, 즉 프로세스 메모리 안의 큐였다. 여기엔 두 가지 약점이 있다.

수치로 보면, 2026-08-27 dev에서 10초에서 20초짜리 MP4 세 편을 연속 업로드했을 때 READY까지 9.4초, 15.8초, 30.4초가 걸렸다. 한 서버에서 FFmpeg 두 개를 병렬로 돌려 봐도 순차 20.38초 대 병렬 19.60초라 스레드를 늘려서는 처리량이 늘지 않았다. t3.small 한 대의 CPU가 병목이었고, 답은 "다른 서버도 쓰자"였다.

노드당시 역할가용 메모리참고
BE EC2API, PostgreSQL, Redis, Kafka, FFmpeg566MiBSpring Boot RSS 563MiB, swap 459MiB 사용
AI EC2FastAPI, Prometheus, Grafana, Alertmanager1,150MiBFastAPI RSS 238MiB, swap 135MiB 사용

설계

왜 DB 테이블인가

작업 대기열을 어디에 둘지가 첫 결정이었다. 기준은 하나다. 영상 저장과 작업 등록을 한 트랜잭션에 넣을 수 있는가. 그게 안 되면 둘 사이의 유실 창을 닫으려고 outbox와 릴레이가 또 필요해진다.

후보판정이유
PostgreSQL 작업 테이블채택이미 쓰는 저장소이고 영상과 작업을 한 트랜잭션으로 묶인다. SKIP LOCKED로 두 소비자가 서로 기다리지 않고 다른 행을 가져간다
Kafka기각DB 커밋과 발행 사이 유실 창을 닫으려면 outbox와 relay가 추가로 필요하다. dev 브로커의 외부 노드 접속 설정도 새로 열어야 한다
SQS기각관리형 큐 자체는 적합하지만 DB와 한 트랜잭션이 안 돼 outbox가 다시 필요하고, 새 비용과 인프라가 생긴다
Redis기각dev Redis는 BE 호스트의 단일 컨테이너라 인코딩 정본을 맡길 내구성 근거가 없다
AI FastAPI 내부 인코딩 API기각push 조정자와 별도 요청 계약이 필요하고, 두 노드가 같은 변환 코드를 재사용하지 못한다

PostgreSQL 문서는 SKIP LOCKED가 일반 조회에는 맞지 않지만 여러 소비자가 큐 형태의 테이블을 나눠 처리할 때 잠금 경합을 피하는 용도라고 명시한다. 이 작업의 대기 행이 바로 그 용도다.

같은 JAR, 다른 프로필

워커 애플리케이션을 따로 만들지 않았다. AI EC2에 같은 app.jardev,encoding-worker 프로필로 8081 포트에 띄운다. 이 프로필은 인코딩에 필요 없는 것을 전부 끈다. 행사 스케줄러, 블러 폴러, 알림 릴레이와 스케줄러, 시더, Flyway. Redis도 쓰지 않고 DB 커넥션 풀은 최대 2개다. JVM은 -Xmx384m으로 시작한다. 보안그룹에 8081 인바운드를 열지 않아 EC2 밖에서는 접근할 수 없다.

노드마다 인코딩 작업은 한 번에 한 건만 소유한다. 폴러가 1초마다 빈 슬롯을 확인하고, 비어 있으면 한 건을 선점해 스레드 1개짜리 실행기에 넘긴다. CPU를 읽어 작업을 반대 노드로 밀어 주는 분기는 없다. 빈 노드가 다음 행을 먼저 가져가는 경쟁 자체가 분배다.

선점 토큰과 임대

선점하면 claim_token을 새 UUID로 바꾸고 claimed_by, lease_until, attempt_count를 기록한다. 임대는 35분이다. FFmpeg 호출별 상한 10분 × 3(ffprobe, encode, thumbnail)에 S3 입출력 여유 5분을 더한 값이고, 정상 처리 속도를 정하는 값이 아니라 프로세스가 강제 종료됐을 때만 쓰는 최후 회수선이다.

정상 종료는 임대를 기다리지 않는다. ContextClosedEvent를 받으면 새 선점을 막고 현재 claim을 즉시 PENDING으로 되돌린다. 통제된 종료는 처리 실패가 아니므로 attempt_count를 하나 줄여 다음 노드가 같은 시도 번호로 시작한다. SIGKILL이나 EC2 장애처럼 종료 훅을 못 탄 경우에만 35분 임대가 동작한다.

늦은 결과 차단

영상 상태를 쓰는 모든 인코딩 경로는 종결 UPDATE의 WHERE 절에서 네 가지를 확인한다.

job.id          = claim.jobId
job.status      = PROCESSING
job.claim_token = claim.claimToken
job.lease_until > 현재 UTC 시각

READY와 FAILED 종결은 업로더 행과 영상 행을 잠그고 알림 outbox를 INSERT한 뒤, 작업 COMPLETED UPDATE를 트랜잭션의 마지막 문장으로 실행한다. 0행이면 ClaimLostException을 던져 앞선 영상 전이와 알림 INSERT까지 전부 롤백한다. 임대가 만료된 뒤 다른 노드가 재선점해 토큰이 바뀌었다면, 옛 워커가 아무리 늦게 결과를 들고 와도 부분 반영이 없다.

메시지를 정확히 한 번 전달한다고 가정하지 않는다 재전달은 허용하되 상태 변경이 한 번의 결과로 수렴하게 한다. 토큰 가드는 "이 노드가 아직 권한이 있나"를, 원본 키 가드는 "영상 교체나 삭제로 현재 시도가 바뀌었나"를 확인한다. 알림은 event key로 중복을 막는다. 셋이 함께 멱등을 만든다.

흐름 도해

BE API 업로드 확정 · 교체 PostgreSQL video_encoding_jobs PENDING → PROCESSING → COMPLETED claim_token · lease_until · attempt_count BE 워커 fillmap-dev · FFmpeg 1개 AI EC2 워커 encoding-worker :8081 · FFmpeg 1개 S3 원본 · 산출물 영상 + 작업 한 트랜잭션 SKIP LOCKED LIMIT 1 · 1초 폴 원본 ↓ 산출물 ↑ 종결: token + 임대 확인 조건부 UPDATE, 0행이면 전체 롤백 SIGTERM: 즉시 PENDING 반납 · SIGKILL: 35분 임대 만료 뒤 다른 노드가 재선점 세 번째 시도 중 중단: DEAD → 다음 폴러가 영상 FAILED를 기록

재시도는 다음 상태 전이를 따른다. 재처리 상한은 코드와 DB CHECK 제약 양쪽에서 3회다.

PENDING -> PROCESSING -> COMPLETED
                    \-> PENDING       처리 오류, 남은 시도 있음 (5초 뒤)
                    \-> DEAD          마지막 시도 중 프로세스 중단
DEAD -> DEAD(completed_at 기록)        영상 FAILED와 같은 트랜잭션

핵심 SQL

선점은 정렬이 고정된 한 행만 UPDATE하고 RETURNING으로 claim을 만든다. PENDING과 임대가 끝난 PROCESSING을 한 문장의 OR로 잡는다. 두 부분 인덱스를 BitmapOr로 합칠 수 있어서다.

WITH clock AS (
    SELECT statement_timestamp() AT TIME ZONE 'utc' AS now_utc
), candidate AS (
    SELECT j.id
    FROM video_encoding_jobs j
    CROSS JOIN clock c
    WHERE (j.status = 'PENDING'    AND j.available_at <= c.now_utc AND j.attempt_count < 3)
       OR (j.status = 'PROCESSING' AND j.lease_until  <= c.now_utc AND j.attempt_count < 3)
    ORDER BY j.available_at, j.id
    FOR UPDATE OF j SKIP LOCKED
    LIMIT 1
)
UPDATE video_encoding_jobs j
SET status = 'PROCESSING',
    attempt_count = j.attempt_count + 1,
    claim_token = :claimToken,
    claimed_by = :nodeId,
    lease_until = c.now_utc + make_interval(secs => :leaseSeconds)
FROM candidate, clock c
WHERE j.id = candidate.id
RETURNING j.id, j.video_id, j.original_s3_key, j.claim_token, j.attempt_count, j.enqueued_at;

현재 시각은 전부 statement_timestamp() AT TIME ZONE 'utc'다. CURRENT_TIMESTAMP는 timestamptz라 KST 세션에서 naive 컬럼과 비교하면 9시간 어긋난다.

정상 종료 때의 반납은 토큰과 살아 있는 임대를 확인하고, 시도 횟수를 하나 돌려준다.

UPDATE video_encoding_jobs
SET status = 'PENDING',
    attempt_count = attempt_count - 1,
    claim_token = NULL, claimed_by = NULL, lease_until = NULL,
    available_at = statement_timestamp() AT TIME ZONE 'utc'
WHERE id = :jobId
  AND status = 'PROCESSING'
  AND claim_token = :claimToken
  AND attempt_count > 0
  AND lease_until > (statement_timestamp() AT TIME ZONE 'utc');

잠금 순서는 users → videos → video_encoding_jobs로 고정했다. 회원 탈퇴 CASCADE와 같은 순서라 교차 대기가 생기지 않는다. 선점은 작업 행만 잠그고 바로 커밋하므로 FFmpeg가 도는 동안 DB 락이나 트랜잭션을 들고 있지 않는다.

장애별 처리

상황처리
길이 31초 초과, 깨진 미디어재시도하지 않고 영상 FAILED, 작업 COMPLETED
S3, 임시 파일, FFmpeg 실행 환경, DB 오류1회와 2회는 5초 뒤 PENDING, 3회 실패는 영상 FAILED, 작업 COMPLETED
SIGTERM, systemd restart종료 훅이 즉시 PENDING으로 반납하고 다른 노드가 선점
SIGKILL, OOM, EC2 강제 중단임대가 끝난 PROCESSING을 다른 노드가 다시 선점
세 번째 시도 중 프로세스 중단임대 만료 뒤 DEAD로 바꾸고, 다음 폴러가 영상 FAILED와 completed_at을 한 트랜잭션으로 기록
영상 교체, 삭제, 이미 종결작업 COMPLETED, 영상과 S3 현재 키는 건드리지 않음. FFmpeg는 실행하지 않음

재시도할 때 산출물 키는 시도별 결정적 키를 그대로 쓴다. 앞선 시도가 S3 업로드 뒤 DB 기록 전에 죽어도 다음 시도가 같은 키를 덮어쓰므로 고아 파일이 늘지 않는다.

실측

2026-08-27 dev. 세 편을 병렬로 확정하고 확정 응답 시각부터 READY까지를 500ms 간격으로 조회했다. 한 회차의 세 편이 끝난 뒤 다음 회차를 시작해 3회 반복했다.

BE 노드가 처리AI 노드가 처리

막대 순서는 작업 로그의 기록 순서이고 파일 길이 순이 아니다. 노드마다 한 번에 한 건이라 같은 노드에 두 편이 몰리면 뒤 편은 앞 편이 끝나기를 기다린 시간이 포함된다. 1회차 BE의 25.683초가 그 경우다.

회차영상 ①영상 ②영상 ③노드
1회차25,683ms13,573ms19,851msbe, be, ai
2회차11,386ms13,125ms25,467msai, be, ai
3회차22,480ms11,838ms12,783msbe, be, ai

아홉 편이 모두 READY와 COMPLETED로 끝났고 작업 유실, 중복 알림, 종료 뒤 미완료 작업은 0건이다. 자원은 처리 전후 순간값이다.

노드가용 메모리RSSswap
BE544 → 452MiB543,068 → 630,848KiB422.2 → 421.3MiB
AI740 → 671MiB468,264 → 544,408KiB125.3 → 138.2MiB

CPU는 처리 전후 유휴 순간값만 저장돼 처리 중 최고값으로 읽지 않는다.

장애 실험

실험결과
정상 종료 (SIGTERM)19초 영상이 BE에서 AI로 같은 회차 안에 넘어가 17,764ms에 READY. 종료된 BE의 늦은 실패 결과는 claim token 불일치로 버려졌다
강제 종료 (SIGKILL, 시험 임대 1분)AI 1회차 강제 종료 뒤 BE 2회차가 회수해 76,337ms에 READY, 알림 1건
실패 상한 (시험 임대 10초)19초 입력에 임대가 정상 인코딩보다 짧아 3회 모두 만료, DEAD와 FAILED로 종결. 세 번째 시도 종결과 실패 알림 1건을 확인했고, 오판을 막으려고 시험 임대를 1분으로 올렸다
블러 경합FastAPI 블러가 PROCESSING인 동안 새 인코딩 두 건이 BE와 AI에서 한 건씩 PROCESSING. 기존 블러 영상 58,281ms, 새 영상 114,774ms와 189,634ms에 READY, 알림은 영상마다 1건
선점 쿼리 EXPLAIN작업 행 21건에서 PENDING 후보 1.836ms, 만료 PROCESSING 후보 0.454ms. 작은 테이블이라 Seq Scan을 골랐고 디스크 읽기는 없었다

시험이 끝난 뒤 두 노드의 임대는 기본값 35분으로 복구했다.

고쳐 온 것

구현 한 번에 끝나지 않았다. dev에서 실제로 프로세스를 죽여 보고 나서야 드러난 것들이다.

반납이 실행기가 멈춘 뒤에 호출됐다 dev SIGTERM 실험

증상
처음엔 @PreDestroy에서 claim을 반납했는데, 스프링이 실행기를 먼저 내리고 나서 훅이 돌아 반납이 늦었다. 그사이 systemd가 FFmpeg에 SIGTERM을 보내면 exit 255가 파일 오류로 분류돼 실패 경로를 탔다.
수정
반납 시점을 ContextClosedEvent로 앞당겼다. 그 뒤 19초 영상이 BE에서 AI로 넘어가 17,764ms에 READY가 됐고, 종료된 BE의 늦은 실패 결과는 토큰 불일치로 버려졌다.

종료 직전에 진행 중이던 선점과 경합했다 후속 리뷰

증상
종료 이벤트가 새 선점을 막아도, 이미 DB에 나가 있던 선점이 이벤트 뒤에 돌아오면 그 claim은 반납 대상에 없었다.
수정
activeClaim 등록 직후 종료 상태를 다시 확인해 실행 전에 반납한다. latch 테스트로 고정했다.

첫 반납이 실패하면 fallback이 무력화됐다 후속 리뷰

증상
반납 UPDATE 전에 참조를 비워, DB 반납이 실패하면 @PreDestroy의 재시도가 잡을 claim이 없었다.
수정
반납 성공 뒤에만 참조를 비운다. 예외 주입 테스트로 고정했다.

시험 임대가 정상 처리보다 짧아 회수를 오판할 뻔했다 측정 설계

증상
SIGKILL 회수를 빨리 보려고 임대를 10초로 두자 19초 영상이 임대 안에 못 끝나 세 번 다 만료돼 DEAD가 됐다. 회수가 아니라 실패 상한 실험이 돼 버렸다.
수정
시험 임대를 1분으로 올려 회수 실험과 실패 상한 실험을 갈랐다. 이 결과가 아래 "말할 때 주의"의 76초다.

말할 때 주의

"모든 영상 30초 이내"라고 쓰면 안 된다 9편 모두 30초 안은 블러 없는 기본 부하의 결과다. 블러 한 건과 인코딩 두 건을 겹치면 최대 189.634초가 걸렸다. 정확한 문장은 "블러 없는 기본 부하에서 9편 모두 30초 이내, 최댓값 25.683초"다.
76.337초는 시험 임대 1분의 결과다 기본 임대는 35분이다. 실제 SIGKILL이나 EC2 중단이면 회수까지 최대 35분이 걸릴 수 있다. heartbeat로 임대를 갱신하는 구조는 넣지 않았다. 정상 처리가 수십 초라 35분의 절반을 넘는 표본이 아직 없어서다. 그런 표본이 생기면 임대 갱신을 추가한다는 조건을 스펙에 적어 뒀다.
알림 outbox와 무엇이 다른가 "DB 트랜잭션에 함께 기록하고 멱등 키로 중복을 막는다"는 원리는 알림 outbox와 같다. 다른 것은 작업을 여러 노드가 나눠 갖는다는 점, 그리고 실행 중에 워커가 죽는 장애재처리의 소유권을 토큰과 임대로 푼다는 점이다. 이 셋을 중심에 두고 말하면 outbox와 별개의 역량이 된다. 한 줄로는 "유실 없는 구조"다.

용어

용어이 문서에서의 뜻
FOR UPDATE SKIP LOCKED다른 트랜잭션이 잠근 행은 기다리지 않고 건너뛰는 PostgreSQL 행 잠금 옵션. 여러 소비자가 큐 테이블을 나눠 처리하는 용도로 공식 문서가 명시한다
선점 (claim)작업 한 건을 "내가 처리한다"고 DB에 표시하는 일. 토큰, 처리 노드, 임대 만료 시각, 시도 횟수를 함께 기록한다
임대 (lease)선점이 유효한 기한. 이 시각이 지나면 다른 노드가 같은 작업을 다시 선점할 수 있다. 정상 처리의 속도가 아니라 강제 종료 때의 최후 회수선이다
claim token선점마다 새로 발급되는 UUID. 종결 UPDATE의 조건이라 재선점으로 토큰이 바뀌면 옛 워커의 결과는 0행으로 끝난다
멱등같은 작업이 여러 번 전달돼도 최종 상태와 외부 효과가 한 번 처리한 것과 같은 성질. 토큰, 원본 키, 영상 처리 상태, 알림 event key가 함께 만든다
SIGTERM / SIGKILL프로세스 종료 신호. SIGTERM은 프로세스가 받아서 정리할 기회가 있고(종료 훅이 돈다), SIGKILL은 즉시 죽어 정리할 기회가 없다
ContextClosedEvent스프링 컨테이너가 닫히기 시작할 때 발행하는 이벤트. 빈이 파괴되기 전이라 실행기가 살아 있을 때 반납할 수 있다

출처: docs/spec/MSG-494.md 결정 D1~D7, 12.6 dev 실측, 2026-08-27 작업 로그. 원본 SHA-256과 스크립트는 load-test/measure-msg494.sh. 이 문서는 2026-09-05에 정리했다.