Loading the catalog…
Loading the catalog…
영상을 여러 개 올리면 뒤 영상이 줄 서서 기다린다. "큐 넣으면 되겠지" 하고 Kafka를 꺼내려다, 먼저 재 봤더니 병목은 큐가 아니라 워커였다. 결국 이미 있던 PostgreSQL 테이블 하나와 SKIP LOCKED 로 서버 2대에 인코딩을 나눴고, 동시 12건 기준 198초 → 107초가 됐다. 그리고 워커를 진짜로 죽여 봤다. 지난 글 이 알림 쪽 이야기였다면 이번엔 영상 쪽이다. FillMap은 방문한 장소를 30초 영상으로 남기는 서비스라, 영상 처리가 느리면 서비스 자체가 느린 거다. 영상은 어떻게 올라가나 브라우저가 사전서명 URL로 S3에 직접 올리기 때문에 서버는 영상 본문을 중계하지 않는다. 대신 "업로드 확정" API를 받은 뒤에 할 일이 많다. FFmpeg로 인코딩하고, 썸네일을 뽑고, AI 서버에 하이라이트를 요청하고, 산출물을 S3에 올려야 그제서야 READY 가 된다. 사용자가 기다리는 건 확정 → READY 구간이다. 요구사항은 단독 업로드 기준 30초 이내(NFR-003), 실패해도 유실 없음, 재처리 가능. 셋 중에 첫 번째가 제일 쉬워 보였는데 제일 먼저 깨졌다. 문제상황 처음 구현은 단순했다. 확정 API가 @Async 로 인코딩을 던지고, 스레드 1개짜리 executor가 순서대로 처리한다. dev에서 10~20초짜리 MP4 세 개를 연속으로 올려 봤다. READY까지 9.4초, 15.8초, 30.4초. 세 번째 영상은 앞 두 개가 끝나기를 기다린 거다. 혼자 올리면 30초 안에 들어오는데, 동시에 올리면 2건째부터 넘는다. 더 신경 쓰이는 건 따로 있었다. 메모리 큐라서 프로세스가 내려가면 대기 중인 작업이 같이 사라진다. 배포할 때마다 그 순간 올린 영상이 영영 PENDING으로 남을 수 있다. 서버가 두 대인데 한 대만 일한다. AI 서버(t3.small)는 FastAPI 하이라이트만 가끔 돌리고 가용 메모리가 1,150MiB나 남아 있었다. 근데 API 서버 메모리 큐에 있는 작업을 넘겨줄 방법이 없다. 같은 t3.small에서 FFmpeg 2개를 병렬로 돌려 보기도 했는데 순차 20.4초, 병렬 19.6초. 한 호스트에서 스레드만 늘려서는 답이 안 나왔다. CPU가 2 vCPU인데 FFmpeg 하나가 이미 다 쓴다. Kafka 넣으면 되지 않나? 솔직히 제일 먼저 든 생각이다. 알림 쪽엔 이미 Kafka가 있었고, "작업 큐 = 메시지 브로커"는 거의 반사적으로 나오는 조합이니까. 근데 멘토링에서 계속 들었던 말이 있다. "그게 병목인지 재 봤냐." 그래서 큐를 넣기 전에 먼저 쟀다. 동시 3건, 6건을 올리고 유실 여부, 최종 상태, 어디서 기다리는지를 봤다. 6건에서 첫 영상 5.3분, 마지막 영상 21분. 한 건당 일정하게 늘어나는 선형 이었다. 적체가 폭주하지도 않고, 유실 0, 타임아웃 오탐 0. 그 와중에 업로드 확정 API는 부하 중에도 1.6초로 멀쩡했다. 이때는 AI 블러까지 켜져 있어서 AI 단일 워커(시간당 20~23건)가 병목이었다. 지금 dev는 블러를 꺼 두고 FFmpeg가 병목이다. 병목 위치는 달라도 결론은 같다. 이 숫자가 말해 주는 건 명확했다. 작업을 전달하는 게 느린 게 아니라, 작업을 처리하는 손이 하나뿐인 거다. Kafka를 넣어도 큐 뒤에서 기다리는 건 똑같다. 손을 늘려야 한다. 그래서 Kafka는 보류했다. 대신 "시간당 업로드 20건을 넘으면 다시 본다"는 재평가 조건만 적어 뒀다. 새 기술을 안 넣은 판단도 측정으로 했다는 게 이 글에서 제일 하고 싶은 말이다. 그럼 손을 어떻게 늘리지? AI 서버에 워커를 하나 더 띄우면 된다. 문제는 두 워커가 같은 작업을 둘 다 잡지 않게 , 그리고 한쪽이 죽어도 작업이 안 사라지게 하는 것. 이게 결국 큐가 하는 일이다. 후보를 늘어놓고 지웠다. 후보 판정 이유 Kafka 기각 DB 커밋과 Kafka 발행 사이 유실 창을 닫으려면 outbox + relay가 또 필요하다. 알림 쪽은 그게 있지만 인코딩에까지 붙이긴 과하다 SQS 기각 관리형 큐 자체는 좋은데 DB와 한 트랜잭션으로 못 묶는다. 결국 outbox가 다시 필요하고 비용도 생긴다 Redis 기각 dev Redis는 BE 호스트의 단일 컨테이너. 인코딩 정본을 맡길 내구성 근거가 없다 PostgreSQL 작업 테이블 채택 이미 있는 저장소. 영상 행과 작업 행을 한 트랜잭션에 넣을 수 있고, SKIP LOCKED 로 두 소비자가 서로 기다리지 않고 다른 행을 가져간다 작업 수가 시간당 수십 건이고, 영상 상태가 이미 DB에 있고, "어느 워커가 몇 번째 시도 중인가"를 한 곳에서 보고 싶었다. 브로커 하나를 더 운영하는 비용보다 테이블 하나가 쌌다. 구조는 이렇다. 업로드 확정 트랜잭션 안에서 videos 행과 video_encoding_jobs 행을 같이 INSERT한다. 영상만 남고 작업이 빠지는 커밋 창이 없다. API 서버와 AI 서버가 같은 JAR 를 돌린다. AI 서버 쪽은 systemd 서비스로 띄운 인코딩 워커 모드다. 각 워커는 1초마다 poll 하고, 빈 슬롯이 있으면 작업을 한 건만 선점해서 FFmpeg에 넘긴다. CPU 보고 작업을 밀어 주는 로드 밸런서 같은 건 없다. 빈 쪽이 다음 행을 먼저 가져가는 경쟁 자체가 분배다. SKIP LOCKED가 하는 일 핵심은 선점 쿼리 한 문장이다. WITH candidate AS ( SELECT j.id FROM video_encoding_jobs j WHERE (j.status = 'PENDING' AND j.available_at <= now_utc AND j.attempt_count < 3) OR (j.status = 'PROCESSING' AND j.lease_until <= 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 = now_utc + make_interval(secs => :leaseSeconds) FROM candidate WHERE j.id = candidate.id RETURNING j.id, j.video_id, j.original_s3_key, j.claim_token, j.attempt_count; 두 워커가 같은 순간에 poll 하면 어떻게 될까. FOR UPDATE 만 있으면 워커 2는 1이 잠근 첫 행 앞에서 커밋을 기다렸다가, 풀리는 순간 같은 행을 또 잡는다. SKIP LOCKED 를 붙이면 잠긴 행은 없는 셈 치고 다음 행으로 넘어간다. PostgreSQL 문서도 이 옵션이 "여러 소비자가 큐 형태의 테이블을 처리할 때" 쓰라고 적어 놨다. 선점할 때 남기는 세 가지가 나중에 전부 쓰인다. claim_token — 새 UUID. "이 작업은 지금 내 거"라는 증표 lease_until — 임대 만료 시각 (35분). 워커가 소리 없이 죽었을 때를 위한 보험 attempt_count — 최대 3회. 넘으면 DEAD로 보내고 FFmpeg를 다시 돌리지 않는다 WHERE 절에 PENDING 과 만료된 PROCESSING 을 OR로 같이 둔 건, 새 작업 선점과 죽은 워커 작업 회수를 한 문장으로 하기 위해서다. 실행 계획을 보니 작업 행 21건 기준 1.8ms라 쿼리를 둘로 나눌 이유가 없었다. 선점은 짧게, 인코딩은 길게 여기서 하나 조심한 게 있다. 선점 트랜잭션과 FFmpeg 실행을 분리 했다. FFmpeg는 수십 초에서 길면 분 단위로 도는데, 그 동안 DB 트랜잭션을 열어 두면 연결 하나가 계속 묶인다. 선점은 UPDATE 한 문장으로 커밋하고, FFmpeg는 트랜잭션 밖에서 돌리고, 끝나면 다시 짧은 트랜잭션으로 종결한다. 종결 UPDATE의 WHERE에는 claim_token 과 lease_until 을 다시 확인한다. WHERE job.id = :jobId AND job.status = 'PROCESSING' AND job.claim_token = :myToken AND job.lease_until > now_utc 이게 0행이면 "내 작업이 아니게 됐다"는 뜻이라 ClaimLostException 을 던져 앞선 영상 상태 전이와 알림 INSERT까지 전부 롤백한다. 왜 이게 필요한지는 바로 아래에서. 결과 같은 영상(10·19·29초 구간)을 동시 3·6·12건씩, 1노드와 2노드로 번갈아(ABBA) 올렸다. 측정은 확정 API 응답부터 DB READY 까지, 즉 사용자가 기다리는 시간이다. 동시 건수 1노드 2노드 단축 가장 늦은 영상 3 55.1초 34.8초 37% 53초 → 33초 6 101.7초 55.7초 45% 98초 → 56초 12 197.9초 107.3초 46% 193초 → 102초 84편 전부 READY, 시도 1회, 오류 0. 작업은 be/ai에 12건 기준 5:7, 6:6으로 나뉘었다. 선점이 편중 없이 분배한다. 1노드가 55 → 102 → 198초로 정확히 2배씩 느는 게 보인다. 워커 하나가 순차 처리하니 당연한데, 확인하고 싶었던 건 적체가 초선형으로 터지지 않는다 는 거였다. 2노드는 기울기가 딱 절반이다. 워커를 하나 더 붙이면 그만큼 는다는 뜻이고, 세 번째를 붙이면 또 그만큼 늘 거다. 점선이 NFR-003의 30초다. 단독은 지키고, 동시 3건부터는 2노드로도 넘는다. 이건 숨기지 않았다. "단독 충족·동시 초과"가 지금 상태고, 다음 단계는 워커 추가다. 워커를 죽여 봤다 "재시도가 있다"는 말과 "실제로 회수되는 걸 봤다"는 말은 다르다. 그래서 dev에서 처리 중인 워커를 진짜로 죽였다. 정상 종료 (SIGTERM) — systemctl stop . 처음엔 @PreDestroy 에서 작업을 반납하게 했는데, executor가 먼저 멈춘 뒤에 호출돼서 늦었다. systemd가 FFmpeg에 먼저 SIGTERM을 보내니 exit 255가 "파일 오류"로 분류되는 것도 봤다. 반납 시점을 ContextClosedEvent 로 앞당긴 뒤에는 AI 서버가 잡고 있던 작업이 PENDING ·시도 0으로 돌아가고, API 서버가 바로 집어서 시도 1로 끝냈다. 강제 종료 (SIGKILL) — kill -9 . 반납할 기회 자체가 없다. 작업은 PROCESSING 으로 남고 아무도 모른다. 이게 lease_until 이 있는 이유다. 임대가 만료되면 선점 쿼리의 OR 두 번째 조건에 걸려 다른 워커가 회수한다. 시도 2로 완료됐다. (35분을 기다리진 않고 시험용으로 임대를 1분으로 줄였다. 그러니 "회수 경로"를 검증한 거지 운영 복구 시간을 증명한 건 아니다.) 늦게 온 결과 — 종료된 워커의 FFmpeg가 뒤늦게 실패 응답을 보내는 경우가 있었다. 이미 다른 워커가 같은 작업을 잡고 있는데, 이 늦은 실패가 영상을 FAILED 로 덮으면 안 된다. 여기서 claim_token 가드가 일했다. 토큰이 안 맞으니 종결 UPDATE가 0행, 전부 롤백, 버림. 세 시나리오 모두 작업 유실 0, 영상당 알림 1건, 중복 0이었다. 마무리 이번 작업에서 제일 오래 걸린 건 코드가 아니라 "큐가 병목인가"를 확인하는 측정 이었다. 그 측정이 없었으면 Kafka 컨슈머를 짜고 outbox를 붙이고 브로커 접속을 열고, 그러고 나서 "어? 여전히 느린데?"를 했을 거다. 손이 하나인 건 큐가 아무리 좋아도 못 고친다. 그리고 SKIP LOCKED 는 생각보다 훨씬 쓸 만하다. 작업 수가 시간당 수십 건이고 워커가 몇 대 안 되면, 브로커 없이 DB 테이블 하나로 "같은 작업 두 번 안 잡기"와 "죽어도 회수하기"가 다 된다. 물론 한계도 안다. 워커가 수십 대로 늘면 1초 폴링이 DB를 괴롭힐 거고, 그때는 LISTEN/NOTIFY 나 진짜 브로커를 봐야 한다. 그 시점이 언제인지를 적어 둔 것까지가 이번 작업이다.
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
영상 인코딩을 빠르게 하기 위한 노력들. 영상을 여러 개 올리면 뒤 영상이 줄 서서 기다린다. "큐 넣으면 되겠지" 하고 Kafka를 꺼내려다, 먼저 재 봤더니 병목은 큐가 아니라 워커였다. 결국 이미 있던 PostgreSQL 테이블 하나와 SKIP LOCKED 로 서버 2대에 인코딩을 나눴고, 동시 12건 기준 198초 → 107초가 됐다. 그리고 워커를 진짜로 죽여 봤다. 지난 글 이 알림 쪽 이야기였다면 이번엔 영상 쪽이다. FillMap은 방문한 장소를 30초 영상으로 남기는 서비스라, 영상 처리가 느리면 서비스 자체가 느린 거다. 영상은 어떻게 올라가나 브라우저가 사전서명 URL로 S3에 직접 올리기 때문에 서버는 영상 본문을 중계하지 않는다. 대신 "업로드 확정" API를 받은 뒤에 할 일이…
Open source