콘텐츠로 이동

작업 큐와 백그라운드 워커

11/10(화)

한 줄 요약: 오래 걸리는 일은 요청 안에서 끝까지 하지 않는다. "할 일 목록(큐)"에 적어 두고 바로 응답한다. 뒤에서 항상 떠 있는 일꾼(워커)이 그 일을 집어 처리한다. 정해진 시각에만 도는 CronJob과는 다른 도구다.

📍 만난 상황

  • 2026-11-10 13:26 — 아키텍처 설명을 준비하면서 "Next.js를 계속 쓸 수 있나" 표를 보고 있었다. "5만 행 넘는 CSV는 작업 큐 + 워커로" 행에 대해 Node 단일 스레드 때문인지 물었다.
  • 직전에 들은 답은 "절반만 맞다"였다. CSV 받기는 대부분 기다리는 시간이라 단일 스레드가 주된 이유가 아니라는 것이다. 진짜 이유는 요청 하나를 몇 분씩 붙잡는 것 자체가 위험해서였다.
  • 그래서 "그럼 그 작업은 어떻게 해야 해? CronJob 같은 걸 하나 더 띄워야 하는 거야?"라고 물었다.
  • 정해진 시간에 도는 CronJob과, 요청이 오면 바로 처리하는 워커를 같은 것으로 본 것이다. → 그날 일지

🧩 쉽게 말하면

사진관에 인화를 맡기는 장면을 떠올리면 된다.

사진 100장을 맡기면 직원이 그 자리에서 1시간 동안 인화하지 않는다. 번호표를 주고 "다 되면 찾으러 오세요"라고 한다. 뒤쪽 작업실의 기사가 순서대로 인화한다. 손님은 가끔 "됐나요?"를 묻고, 다 되면 받아 간다.

번호표가 없으면 손님은 1시간 동안 카운터 앞에 서 있어야 한다. 그사이 전화가 끊기거나 가게가 문을 닫으면 처음부터 다시 해야 한다.

반대로 매일 아침 9시에 오는 청소 업체가 CronJob이다. 손님이 언제 오든 상관없이 시간이 되면 온다.

📖 제대로 알기

구성 요소

  • 작업(job): 해야 할 일 하나. 예: "강남구 2020~2025 침수흔적 CSV 만들기".
  • 큐(queue): 작업을 줄 세워 두는 곳. Redis 같은 전용 서버를 쓰기도 하고, DB 표 하나로 대신하기도 한다.
  • 생산자(producer): 큐에 작업을 넣는 쪽. 여기서는 BFF(Next.js 서버)다.
  • 워커(worker): 큐에서 작업을 꺼내 실제로 처리하는 프로그램. 항상 떠 있다.
  • 상태(status): 작업이 어디까지 왔는지. 보통 queued(대기) → running(처리 중) → done(완료) 또는 failed(실패).

쿠버네티스에서 무엇으로 띄우나

쿠버네티스(k8s: 컨테이너를 띄우고 관리하는 플랫폼)에는 실행 방식이 여러 개 있다.

종류 언제 도나 맞는 일
Deployment 항상 떠 있음, 죽으면 다시 띄움 웹 서버, 워커
CronJob 정해진 시각에 떴다가 끝나면 사라짐 정기 동기화, 오래된 파일 정리
Job 한 번 떠서 끝까지 하고 사라짐 일회성 이전 작업

워커는 보통 Deployment로 띄운다. 할 일이 없으면 기다리고, 생기면 바로 집는다.

왜 요청 안에서 끝까지 하면 안 되나

단일 스레드 때문이 아니다. 요청이 오래 열려 있는 것 자체가 문제다. - 중간 장치의 시간 제한: Ingress·로드밸런서·프록시는 보통 60초 안팎에서 연결을 끊는다. - 끊기면 처음부터: 와이파이가 끊기거나 탭을 닫으면 이어 받기가 안 된다. - 배포·재시작: 새 버전을 배포하면 파드(k8s에서 컨테이너가 도는 단위)가 바뀌어 진행 중인 다운로드가 모두 끊긴다. - 자원을 오래 붙잡음: 플랫폼 SQL 창구가 한 번에 1,000행까지라, 50만 행이면 호출이 500번이다. - 진행률·재시도 불가: "40% 진행"을 보여 줄 수 없고, 실패하면 전부 다시 해야 한다.

⚙️ 어떻게 동작하나

  1. 사용자가 "CSV 만들어 줘"를 누른다. 화면이 POST /api/exports를 보낸다.
  2. BFF가 작업 표에 한 줄(조건, status=queued)을 넣고 바로 응답한다. 몇 ms 걸린다.
  3. 워커가 대기 중인 작업 하나를 잠그고 집는다. 다른 워커가 같은 작업을 집지 못한다.
  4. 워커가 플랫폼에서 1,000행씩 받아 CSV로 이어 쓴다. 중간중간 진행률을 표에 적는다.
  5. 다 되면 파일을 저장소(S3·MinIO 같은 파일 보관소)에 올리고 status=done으로 바꾼다.
  6. 화면은 몇 초마다 상태를 묻는다. 완료되면 임시 링크(일정 시간만 열리는 주소)로 받는다.
sequenceDiagram
  participant U as 화면
  participant S as BFF
  participant Q as 작업 표 (PG)
  participant W as 워커
  participant F as 파일 저장소
  U->>S: CSV 만들어 줘
  S->>Q: 작업 1줄 넣기 (queued)
  S-->>U: 접수됨, 작업 17번
  W->>Q: 대기 작업 하나 잠그고 가져감
  W->>F: 1,000행씩 이어 쓴 CSV 올림
  W->>Q: done 으로 바꿈
  U->>S: 17번 됐나요
  S-->>U: 완료, 받기 링크

화면과 BFF 사이 요청은 모두 짧게 끝난다. 오래 걸리는 부분(워커 ↔ 저장소)은 사용자 연결과 상관없이 뒤에서 돈다.

🧪 예시

  • 예시 1 · 일상에 빗대어: 50만 행 CSV를 한 요청으로 받으면 약 5분이 걸린다고 하자. 중간 프록시가 60초에 끊으면 매번 20%에서 실패한다. 작업 큐로 바꾸면 요청은 0.01초 만에 "접수됨"을 받는다. 5분 뒤 링크를 받는다. 그사이 탭을 닫아도 작업은 계속된다.

  • 예시 2 · 실제로는: Redis 없이 PG를 큐로 쓰는 워커의 "작업 집기" 쿼리다.

UPDATE app.export_job
   SET status = 'running', started_at = now()
 WHERE id = (
   SELECT id FROM app.export_job
    WHERE status = 'queued'              -- ①
    ORDER BY created_at                  -- ②
    LIMIT 1
    FOR UPDATE SKIP LOCKED               -- ③
 )
RETURNING id, params;                    -- ④
① 아직 아무도 안 집은 작업만 본다. ② 먼저 들어온 것부터 처리한다. ③ 집을 행을 잠근다. 이미 다른 워커가 잠근 행은 기다리지 않고 건너뛴다. 워커가 3개여도 같은 작업을 두 번 집지 않는다. ④ 집은 작업의 번호와 조건을 돌려받아 처리를 시작한다.

워커 프로그램은 새로 만들 필요가 적다. 같은 Docker 이미지를 시작 명령만 바꿔(node worker.js) 파드로 하나 더 띄운다. 1,000행씩 받는 기존 함수(iterateRows)를 그대로 쓴다. 새로 필요한 인프라는 파일 저장소 하나다.

⚖️ 비교 · 흔한 오해

방식 장점 단점
요청 안에서 끝까지 가장 단순 시간 제한·배포에 끊김
CronJob 1분마다 대기 작업 처리 워커를 상시 안 띄움 시작까지 최대 1분, 매번 파드 비용
작업 큐 + 워커 바로 시작, 진행률·재시도 작업 표·워커·저장소가 필요
Redis + BullMQ 같은 큐 도구 재시도·지연 기능이 다 있음 Redis 서버를 새로 운영
  • 헷갈렸던 점: "CronJob을 하나 더 띄우면 되나?" — 둘 다 "화면 요청과 따로 뒤에서 도는 프로그램"이다. 같은 이미지로 띄우기도 한다. 그래서 같은 것으로 보기 쉽다. 하지만 기준이 다르다. CronJob은 시계가 깨우고, 워커는 할 일이 깨운다. 큰 내보내기는 사용자가 누른 순간 시작해야 하므로 워커가 맞다.
  • 헷갈렸던 점: "단일 스레드라서 큐가 필요하다" — Node가 단일 스레드라는 말을 먼저 들으면, 느린 건 다 그 탓으로 보인다. 하지만 CSV 받기는 대부분 플랫폼 응답을 기다리는 시간이다. FastAPI로 바꿔도 시간 제한과 배포 끊김은 그대로라, 작업 큐는 여전히 필요하다.
  • 오해: "큐에는 꼭 Redis가 필요하다" — 작업이 하루 수십~수백 건이면 PG 표 하나로 충분하다.

🛠️ 실무에서는

  • 언제 쓰나: 처리에 수십 초 이상 걸린다. 사용자가 기다리지 않아도 된다. 실패하면 다시 시도할 가치가 있다.
  • 언제 피하나: 1~2초면 끝나는 일. 큐를 거치면 오히려 복잡해진다.
  • 체크리스트
  • 상태값(queued·running·done·failed)과 진행률 열이 있나.
  • 워커가 처리 중에 죽으면? running인 채 오래된 작업을 다시 queued로 돌리는 규칙이 있나.
  • 재시도 횟수에 상한이 있나. 계속 실패하는 작업이 큐를 막지 않나.
  • 같은 작업을 두 번 처리해도 결과가 같나(멱등성: 여러 번 해도 결과가 한 번 한 것과 같은 성질).
  • 한 사람이 동시에 요청할 수 있는 개수를 제한하나.
  • 만든 파일은 며칠 뒤 지우나. 이 정리는 CronJob이 맡기 좋다.
  • 문제가 생기면 먼저 볼 것: 작업 표에서 상태별 개수. queued만 쌓이면 워커가 죽었거나 부족한 것이다. running이 오래 머물면 워커가 멈췄거나 플랫폼이 느린 것이다.

❓ 자주 묻는 질문

  • Q. 워커는 몇 개를 띄우나? A. 처음엔 1개로 시작한다. 대기가 길어지면 replicas(같은 파드 개수)를 늘린다. SKIP LOCKED 덕분에 늘려도 안전하다.
  • Q. 화면은 완료를 어떻게 아나? A. 가장 쉬운 방법은 몇 초마다 상태를 묻는 폴링(polling)이다. 더 나가면 서버가 먼저 알려 주는 방식(SSE, WebSocket)도 있다.
  • Q. 플랫폼에서 바로 파일로 내보내 주면 워커가 필요 없지 않나? A. 맞다. 데이터가 플랫폼에 있으니 가장 효율적이다. 다만 dhub2에 "조건으로 걸러 파일로 내보내기" 기능이 있는지 확인이 필요하다.

🔭 더 깊이 보기

📚 참고 자료

  • 크론잡(CronJob) (Kubernetes 한국어 문서) — 정해진 시각에 Job을 띄우는 방법 · 링크
  • 디플로이먼트(Deployment) (Kubernetes 한국어 문서) — 항상 떠 있는 워커를 띄우는 방법 · 링크
  • PostgreSQL Documentation: SELECT — The Locking Clause (영문) — FOR UPDATE, SKIP LOCKED의 정확한 뜻 · 링크

🗓️ 이력

  • 2026-11-10: 큰 CSV 내보내기에 CronJob을 더 띄워야 하는지 물음 → 일지

🕸️ 연결된 주제