워커 잡 하나가 35시간을 돌자, 아침 매출 데이터가 사라졌습니다
아침 9시, 고객사에서 연락이 왔습니다. 전날 매출 데이터가 비어 있다는 내용이었습니다. 운영하며 가장 피하고 싶은 종류의 연락입니다. 본사는 아침에 전일 매출 숫자로 하루를 시작하는데, 그 숫자가 없으면 시스템 전체가 멈춘 것처럼 보입니다.
서버를 확인했지만 워커는 살아 있었습니다. 에러 로그도 없었습니다. 워커는 성실하게 일하고 있었습니다. 다만 하나의 잡을, 아주 오래 하고 있었을 뿐입니다.
범인은 느린 워커가 아니라 줄이었습니다
이 시스템의 워커는 큐(SQS)에서 잡을 한 번에 1건씩 꺼내 직렬로 처리하는 구조였습니다. POS 매출, 물류, 날씨 — 성격이 다른 잡이 전부 같은 줄에 섭니다. 처음엔 문제가 없었습니다. 잡이 대부분 분·초 단위로 끝났기 때문입니다.
실행 로그를 7일치 실측해 보니 분포가 극단적이었습니다. 한 무거운 프로세스가 1회에 10~35시간을 돌았고, 7회 중 5회는 실패했으며, 35시간짜리는 다음날 실행과 겹치기까지 했습니다. 나머지 잡은 여전히 분·초 단위였습니다.
긴 잡이 줄 앞에 서면 뒤의 POS·물류 수집 워커가 최대 14시간을 대기했습니다. 전형적인 head-of-line blocking입니다. 워커가 느린 게 아니라, 속도가 극단적으로 다른 잡들을 한 줄로 세우는 구조가 문제였습니다.

전면 병렬화는 답이 아니었습니다
직렬 큐가 막히면 반사적으로 나오는 답이 '병렬로 돌리자'입니다. 처음엔 저도 그쪽으로 기울었지만, 잡 목록을 하나씩 뜯어보니 병렬로 돌리면 안 되는 이유가 잡마다 달랐습니다. 이번 무거운 작업은 단일 세션 제약이 있어 동시에 두 개를 돌리면 세션이 깨지고, 레이트리밋에 걸리는 소스도 있었습니다. DB 커넥션 풀도 무한하지 않습니다.
그래서 방향을 바꿨습니다. 전면 병렬화가 아니라 레인 분리입니다. 병렬로 돌려도 안전하다고 확인된 잡만 병렬 레인으로 격리하고, 나머지는 기존 직렬 큐에 그대로 남깁니다. 병렬은 기본값이 아니라, 증명된 잡만 얻는 특권으로 정의했습니다.
핵심은 '병렬 가능 여부'를 코드가 추측하게 두지 않고 데이터로 선언한 것입니다. 잡 메타데이터에 병렬 안전 플래그를 신설했고 기본값은 직렬(안전)입니다. 원래는 기존 제약 컬럼을 판정 근거로 쓰려 했지만, 실측해 보니 대부분 NULL이라 쓸 수 없었습니다. 그래서 새 축을 명시적으로 만들었고, 병렬 안전이 검증된 POS·물류·날씨 잡 7건만 병렬로 승격했습니다.
기본값이 직렬이라는 점이 보험입니다. 새 잡이 추가되면 자동으로 안전한 쪽에서 시작합니다. 병렬 안전 판정이 틀리면 — 숨은 공유 자원이 있다면 — 데이터 손상으로 이어지는데, 기본 직렬은 그 리스크를 신규 잡에서 원천 차단합니다. 승격은 실증한 뒤에만 합니다.

백프레셔는 큐 길이가 아니라 in-flight 수로
병렬 레인 초안에서 저는 큐 길이를 보고 잡을 더 던질지 말지를 정했습니다. 리뷰에서 이 방식이 뒤집혔습니다. 큐 길이는 '지금 처리 중인 메시지'를 세지 못합니다. 꺼내서 처리 중인 메시지는 카운트 밖에 있으니, 큐가 비어 보인다고 계속 밀어 넣으면 처리 중 메시지가 visibility timeout으로 재노출되는 사고로 이어집니다.
그래서 BoundedSemaphore로 in-flight 수 자체를 제한하는 방식으로 교체했습니다. 동시에 살아 있는 잡 수를 세마포어가 직접 세고, 슬롯이 빌 때만 다음 잡이 들어갑니다. 큐 길이라는 간접 신호를 훔쳐보는 대신, 제한하고 싶은 값을 직접 제한하는 구조입니다.
병렬 워커 수는 감이 아니라 산수로 정했습니다. 잡 내부 스레드 수 × 동시 잡 수가 DB 커넥션 풀(15)을 넘지 않도록 계산하니 워커 2가 나왔습니다. 이 곱셈을 건너뛰면 나중에 풀 고갈로 되갚게 됩니다. 배포 순서도 챙겼습니다. 병렬 큐가 아직 프로비저닝되지 않았으면 직렬로 폴백하도록 라우팅을 먼저 배포해, 어느 시점에 배포돼도 안전하게 만들었습니다.
굶김 해소와 느린 잡 개선은 다른 문제입니다
배치를 수동 실행해 확인하니 병렬 레인 2건과 직렬 레인 1건이 동시에 시작됐고, 한 잡이 끝나자 다음 잡이 그 슬롯에 자동으로 들어갔습니다. 커넥션 에러는 0건이었습니다. 이제 긴 잡이 돌아도 POS·물류는 자기 레인에서 제때 돕니다.
그리고 문제의 근원처럼 보이는 무거운 작업은 자체 — 10~35시간 소요, 잦은 실패 — 는 이번 작업 범위에서 명시적으로 뺐습니다. 단일 세션 제약 때문에 직렬 유지가 맞고, 굶김 해소와 느린 잡 개선은 다른 문제이기 때문입니다. 레인을 나눈 지금, 그 잡은 자기만 느립니다. 다음에 따로 고치면 됩니다.

직렬 큐가 밀릴 때 확인할 것들
잡별 소요 시간을 실측했는가 — 로그 7일이면 충분하고, 분포가 극단 이봉이면 레인 분리가 답이다
병렬 안전 여부가 코드 추측이 아니라 잡의 속성(데이터)으로 선언돼 있는가 — 기본값은 직렬(안전)로
백프레셔가 큐 길이가 아니라 in-flight 수를 제한하고 있는가
병렬 워커 수를 자원 산수(잡 내부 스레드 × 동시 잡 수 vs 커넥션 풀)로 정했는가
굶김 해소와 느린 잡 개선을 한 작업에 섞고 있지 않은가
레인이 늘어난 만큼 레인별 모니터링(어느 레인이 밀리는지)이 따라오는가
잡이 전부 짧은 시스템이라면 레인 분리는 과설계입니다. 이 처방은 실측 분포가 극단으로 갈라졌을 때의 것입니다. 다만 그 판단조차 로그를 세어 보기 전에는 할 수 없습니다. 저 역시 '아침에 매출 데이터가 없다'는 연락을 받고 나서야 7일치 로그를 세었습니다.