인코딩 워커
S3 원본 → 720p H.264 변환 → 썸네일 → READY
MSG-65 구현 완료 로컬 실측 완료 dev 실측 완료 · 대상: 백엔드 팀원 · 2026-07-16
MSG-64까지 끝나 업로드는 관통했지만, 모든 영상이
UPLOADED에 멈춰 있었다. 이 문서는 그 뒤를 잇는 인코딩 워커를 다룬다.
업로드 쪽 이야기(presigned·격자·점령)는 MSG-64 문서에 있다.
1. 왜 변환이 필요한가
업로드되는 원본은 기기마다 제각각이다 — 아이폰 4K HEVC, 안드로이드 1080p H.264, 갤러리에서 고른 오래된 영상... 이걸 그대로 재생시키면 어떤 기기에선 안 열리고, 열려도 데이터를 낭비한다. 그래서 서버가 한 번 720p H.264 + AAC로 표준화한다. 도감 목록에 쓸 썸네일도 이때 뽑는다.
변환은 오래 걸리므로 업로드 응답을 붙잡지 않는다. 메타저장 API는 즉시
UPLOADED를 반환하고, 변환은 뒤에서 돈다.
2. 파이프라인
VideoServiceImpl.saveVideo가 커밋된 뒤 VideoEncodingService.encode(videoId)가
다른 스레드에서 시작된다.
1. videos row 조회 (없으면 로그만 남기고 종료)
2. ENCODING 으로 전이 ← 별도 트랜잭션으로 즉시 커밋
3. S3 에서 원본 다운로드 ← original_s3_key
4. ffprobe 로 실제 길이 확인 ← 30초 초과면 FAILED 후 종료 (D7)
5. ffmpeg 720p H.264+AAC 인코딩
6. ffmpeg 썸네일 1장 추출
7. S3 업로드
videos/encoded/{userId}/{videoId}.mp4
videos/thumb/{userId}/{videoId}.jpg
8. READY 로 전이 + key 2개 저장
finally: 임시 디렉터리 재귀 삭제
예외: FAILED 기록 + log.error (재시도 없음)
담당 클래스는 이렇게 나뉜다:
| 클래스 | 역할 |
|---|---|
VideoEncodingServiceImpl | @Async 파이프라인 전체 |
FfmpegRunner | ProcessBuilder로 ffmpeg/ffprobe 호출 |
VideoStatusWriter | 상태 전이만 담당 (4장에 이유) |
AsyncConfig | 인코딩 전용 스레드풀 (크기 1) |
3. 상태 전이
└ 어느 단계에서든 실패 → FAILED (30초 초과 · 손상 파일 · ffmpeg 오류 · S3 오류)
BLURRING은 AI Highlight-Blur 단계로 이 티켓 범위 밖이다(architecture.md).
ProcessingStatus enum에는 이미 있지만 지금은 아무도 세팅하지 않는다.
전이는 Video 엔티티의 도메인 메서드로만 일어난다 — setter를 열지 않는다.
public void markEncoding() { this.processingStatus = ProcessingStatus.ENCODING; }
public void markReady(String encodedKey, String thumbnailKey) {
this.encodedUrl = encodedKey;
this.thumbnailUrl = thumbnailKey;
this.processingStatus = ProcessingStatus.READY;
}
public void markFailed() { this.processingStatus = ProcessingStatus.FAILED; }
재시도는 없다
실패하면 FAILED로 기록하고 끝이다(D8). 자동 재시도도, 수동 재트리거 API도 없다.
MVP에서 실패율을 보고 나서 붙일 일이다. 비동기라 예외를 던져봐야 받을 곳이 없으므로
encode()는 어떤 예외도 밖으로 내보내지 않는다.
4. 함정 다섯 읽을 가치 있음
구현하면서 실제로 밟은 함정들이다. 다섯 다 조용히 잘못 동작하는 종류 — 예외도 안 나고 테스트도 통과하는데 운영에서 터진다. ④⑤는 코드 리뷰에서 지적받아 잡았다.
① @Transactional self-invocation
처음엔 markEncoding() 같은 전이 메서드를 VideoEncodingServiceImpl 안에
@Transactional(REQUIRES_NEW)로 뒀다. 이건 동작하지 않는다.
@Transactional은 프록시로 구현되는데, 같은 클래스 안에서 호출하면 프록시를 거치지 않아
애노테이션이 통째로 무시된다.
결과가 고약하다:
ENCODING이 DB에 보이지 않는다 (같은 트랜잭션에 묶여 끝날 때 한 번에 커밋)- 실패하면
markFailed까지 함께 롤백돼서UPLOADED로 남는다 — 실패했는데 실패 기록조차 없다
그래서 전이만 담당하는 VideoStatusWriter를 별도 빈으로 분리했다. 다른 빈을 통해 호출하니
프록시를 타고 REQUIRES_NEW가 실제로 걸린다.
② 트리거 타이밍 — 커밋 전에 쏘면 진다
saveVideo는 @Transactional이다. 그 안에서 encode(videoId)를
그냥 호출하면 아직 커밋되지 않은 row를 다른 스레드가 조회하게 된다. 워커는 "영상 없음"으로
끝나고, 업로드는 성공했는데 인코딩만 조용히 사라진다.
// 커밋 이후에 띄운다
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
videoEncodingService.encode(videoId);
}
});
트랜잭션 밖에서 호출되는 경우(테스트 등)를 위해 isSynchronizationActive()가 false면
바로 호출하는 분기를 뒀다.
③ IAM 정책이 결과물 경로를 몰랐다
MSG-64에서 만든 FillMapVideoUploadDev 정책은 videos/original/*만 허용했다.
업로드만 생각한 범위였는데, 인코딩 결과는 videos/encoded/·videos/thumb/로 간다.
로컬 실측에서 403 AccessDenied로 드러났다.
Resource를 arn:aws:s3:::fillmap-video-dev/videos/*로 넓혔다 — MSG-71·72에서 경로가 늘어도
정책을 다시 안 고쳐도 된다. 버킷 전체가 아니라 videos/ 하위로만 열린 건 그대로다.
FAILED로 기록됐다 — 실패 처리가 설계대로
작동한다는 걸 의도치 않게 검증한 셈이다.
④ 스트림을 먼저 읽으면 타임아웃이 죽는다
FfmpegRunner에 10분 타임아웃을 걸어뒀는데 동작하지 않았다. 원인은 순서다.
String stdout = new String(process.getInputStream().readAllBytes()); // ← 여기서 막힘
if (!process.waitFor(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) { // ← 도달 못 함
process.destroyForcibly();
throw new IllegalStateException("ffmpeg 타임아웃...");
}
readAllBytes()는 프로세스가 stdout을 닫을 때까지 블로킹한다. 정상 종료 시엔
문제없지만, ffmpeg가 행에 걸리면 스트림이 안 닫히고 waitFor는 영영 실행되지 않는다.
풀이 1개짜리라 그 순간 인코딩 전체가 영구 정지한다.
sleep 5로 재현한 결과 — 타임아웃 1초인데 5011ms 블로킹:
readAllBytes() 반환까지: 5011ms
waitFor(1초) 결과: true ← 프로세스가 이미 끝난 뒤라 항상 true
stdout도 파일로 리다이렉트하고 waitFor를 먼저 호출하도록 고쳤다.
덤으로 임시 로그 파일이 예외 경로에서 새던 것도 finally로 옮겼다.
프로세스가_행이면_타임아웃으로_끊는다 테스트가 sleep 30을 300ms 타임아웃으로
잡는다. 옛 구현으로 되돌려 확인해보면 30.02초를 꽉 채우고 실패한다 — 테스트가 실제로
이 버그를 잡는다는 뜻이다. ffmpeg와 무관해서 CI에서도 돈다.
⑤ @Async 거부 예외는 호출자에게 날아온다
큐가 가득 차면 AbortPolicy가 TaskRejectedException을 던진다. 문제는
어디서 던지느냐다.
호출 스레드가 잡은 예외: org.springframework.core.task.TaskRejectedException
발생 위치: submit() — @Async 메서드 본문 진입 전
encode() 안의 catch (Exception e)는 실행조차 되지 않는다.
메서드가 시작을 못 했으니까. 그리고 이 예외가 터지는 곳이 afterCommit() 안이라 더 나쁘다 —
Spring은 afterCommit 예외를 커밋 밖으로 전파하므로 saveVideo가 500을 던진다.
즉 고치기 전엔 이랬다:
- 영상은 이미 커밋됨 (DB에 저장 완료)
- 클라이언트는 500 수신 → 실패로 알고 재시도 → 중복 업로드
- 영상은
UPLOADED로 영구 방치 (FAILED 기록조차 없음)
submitEncoding()에서 TaskRejectedException을 삼키고 markFailed로
기록하도록 고쳤다. 업로드 자체는 성공했으므로 예외를 밖으로 내보내지 않는 게 핵심이다.
5. 인프라 제약
Dockerfile이 없다
스펙 D3는 "런타임 이미지에 ffmpeg 포함(Dockerfile apt-get install ffmpeg)"을 전제했지만,
이 프로젝트엔 Dockerfile이 없다. CD가 jar를 scp하고 systemd가
java -jar로 직접 띄운다. 그래서 ffmpeg는 호스트에 직접 설치한다.
| 환경 | 설치 |
|---|---|
| dev/prod EC2 | sudo apt-get install -y ffmpeg (1회) |
| 로컬 | brew install ffmpeg |
| CI | 설치하지 않는다 — 아래 참조 |
앱은 PATH의 ffmpeg/ffprobe를 호출한다. 경로를 설정으로 빼지 않았다 —
PATH에 있으면 그만이고, 없으면 어차피 못 돈다.
FfmpegRunnerTest)는 바이너리가 없으면 assumeTrue로 전부
skip된다. PATH를 비우고 돌려 5건 skip · BUILD SUCCESSFUL을 확인했다.
목만으로는 "정말 720p로 나오는지"를 검증할 수 없어서 이 테스트가 따로 존재한다.
encoded_url에는 URL이 아니라 key가 들어간다
버킷이 Block Public Access라 영구 URL이 존재하지 않는다. presigned GET은 TTL이 있어
DB에 박아둘 수 없고, CloudFront(MSG-67)는 아직 없다. 그래서 videos/encoded/1/22.mp4 같은
key를 저장하고, 재생 조회 시점에 presigned GET을 발급한다.
encoded_url인데 key가 들어간다. 컬럼명을 고치려면 V 파일을 손봐야 하는데,
그게 정확히 dev를 32시간 죽인 원인이다(체크섬 불일치 · MSG-130). 이름 하나 때문에
같은 사고를 반복할 이유가 없어 엔티티 필드 주석으로 명시하는 선에서 멈췄다.
MSG-67(CDN)이 들어오면 key 앞에 도메인만 붙이면 되므로 이식성은 오히려 이쪽이 낫다.
스레드풀 크기 1
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(50);
ffmpeg는 JVM 힙(-Xmx512m) 밖에서 도는 별도 프로세스라 힙 설정이 그 메모리를
막아주지 않는다. dev가 t3.small(2GB)이라 동시 인코딩을 허용하면 OOM 위험이 있고, 그때
OOM Killer는 ffmpeg가 아니라 메모리를 제일 많이 쓰는 JVM을 고를 수 있다.
그래서 동시 실행을 1로 묶었다.
dev 실측 결과 1회 인코딩은 여유가 넉넉하다(6장). 풀 1은 지금
과보호에 가깝지만, 실패 시 비용(JVM이 죽어 API 전체 중단)이 커서 그대로 둔다. 처리량이 부족해지면
그때 큐 + 별도 워커로 분리한다 — 코드에 ponytail: 주석으로 표시해뒀다.
6. 실측 결과 로컬 · dev
로컬
1920x1080 5초 영상을 실제로 업로드해 전 구간을 통과시켰다.
| 검증 | 결과 |
|---|---|
| 원본 | 1920x1080 h264 |
| 인코딩본 (S3에서 재다운로드) | 1280x720 h264 + aac |
| 재생 가능성 (전 프레임 디코드) | 에러 없음 |
| 썸네일 | 1280x720 mjpeg, 35K |
| DB | READY · videos/encoded/121/22.mp4 · videos/thumb/121/22.jpg |
| 임시파일 | 누수 없음 |
| 테스트 | 108건 통과 (90 → 108) |
35초 영상을 만들어 durationSec: 30으로 거짓 신고해서 업로드했다.
API 검증(@Max(30))은 통과했지만, 워커가 ffprobe로 실제 길이를 읽고 거부했다.
WARN 영상 길이 초과로 인코딩 중단: videoId=23 duration=35.0s
DB → status=FAILED encoded_url=null
dev (t3.small) — OOM 우려는 실측으로 해소됐다
1920x1080 25초 영상을 api.fillmap.kr로 올려 인코딩하는 동안 메모리를 관찰했다.
결과는 READY · 1280x720 h264+aac · 25.0초 · 썸네일 1280x720.
인코딩 전 avail=1009MB JVM=352MB ffmpeg=0MB
인코딩 중 avail=775MB JVM=426MB ffmpeg=197MB ← 최저점
인코딩 후 avail=900MB JVM=427MB ffmpeg=0MB
OOM Killer 흔적: 0건 · JVM 재시작: 0회 · 임시파일 누수: 없음
ffmpeg는 197MB만 쓴다. 720p 인코딩은 프레임 몇 장 분량의 버퍼만 있으면 되므로 영상 길이와 무관하게 대체로 일정하다. 가용 메모리가 775MB까지만 내려갔으니 여유가 크다.
free는 페이지 캐시를 제외한 수치라 실제 여유를 나타내지 않는다.
봐야 할 값은 available이고, 그건 처음부터 1GB였다.
free -m으로 용량을 판단할 때 주의할 것.
7. 알려진 갭
FAILED가 되면 끝이다. 재시도도, 사용자 알림도, 원본 S3 정리도 없다.
사용자는 도감에서 영원히 로딩 중인 격자를 보게 된다. MVP 이후 정책이 필요하다.
FAILED로 기록된다. 재시도가 없으니 그 영상은 끝내 재생 불가다.
MVP 트래픽엔 오지 않을 상황이지만, 온다면 큐로 분리하라는 신호다.
근거 파일: VideoEncodingServiceImpl · VideoStatusWriter ·
FfmpegRunner · AsyncConfig · Video(상태 전이) ·
VideoServiceImpl(커밋 후 트리거·큐 포화 처리) · S3Config(S3Client)
테스트: FfmpegRunnerTest(실 ffmpeg + 행 타임아웃) · VideoEncodingServiceTest(분기) ·
VideoEncodingTriggerTest(큐 포화) · VideoStatusTransitionTest
문서: docs/spec/MSG-65.md(스펙·정정) · MSG-64-flow.html(업로드 플로우)