Post

장시간 비동기 작업 RDB 기반 Task Queue 파헤치기

장시간 비동기 작업 RDB 기반 Task Queue 파헤치기

우아한형제들 Task Queue 적용기를 읽고 — 장시간 비동기 작업에 Kafka가 꼭 필요할까?

참고한 아티클: 장시간 비동기 작업, Kafka 대신 RDB 기반 Task Queue로 해결하기

1. 문제 정의

기존 Kafka 기반 처리 흐름

기존 시스템은 Kafka를 이용해 엑셀 생성 작업을 비동기로 처리하고 있었다. 사용자가 엑셀 생성을 신청하면 Kafka에 이벤트를 발행하고, Worker가 이를 소비해 엑셀을 생성한 뒤 메일로 발송하는 구조였다.

그런데 새로운 엑셀 타입을 추가하면서, 각 행에 필요한 데이터를 채우기 위해 타 팀의 API를 여러 번 호출해야 했다. 이 과정에서 데이터 조회에만 30분 이상 걸리는 등 전체 작업 시간이 늘어났다.

장시간 작업과 Consumer 리밸런싱

작업이 길어지면서 Worker가 다음 poll()을 호출하기까지의 간격이 Kafka의 max.poll.interval.ms 설정값을 초과했다. 그 결과 리밸런싱이 발생했고, 동일한 메시지가 다른 Worker에게 재할당되어 같은 엑셀이 중복 생성·발송되는 문제가 생겼다.

즉, 사용자가 엑셀 생성을 다시 신청한 것이 아니라, 기존 요청이 중복 처리된 것으로 이해했다.

결국 해결하려는 문제는 무엇일까?

처음에는 타임아웃을 늘리면 해결할 수 있는 문제처럼 보였다. 하지만 작업 시간이 더 길어지면 같은 문제가 반복될 수 있다.

그래서 내가 이해한 이 아티클의 목표는 단순히 타임아웃을 늘리는 데서 끝내지 않고, 오래 걸리는 작업을 안정적으로 처리할 수 있는 구조를 만드는 것이다.

  • 서버 장애나 배포로 작업이 중단되어도 요청이 유실되지 않아야 한다.
  • 일시적인 오류가 발생하면 자동으로 재시도할 수 있어야 한다.
  • 여러 Worker가 작업을 나누어 병렬로 처리할 수 있어야 한다.
  • 이 과정에서 동일한 작업의 중복 처리를 방지해야 한다.

2. 내가 만약 이 문제를 맞닥뜨렸다면?

문제를 다시 보니, 기존 구조는 엑셀 생성과 메일 발송이 모두 끝난 뒤에 ACK하는 방식이었다. 여기서 중요한 것은 ACK가 늦다는 사실만이 아니라, 긴 작업을 처리하는 동안 다음 poll() 호출도 늦어진다는 점이었다. 그렇다고 ACK부터 보내면 작업이 끝나기 전에 완료 처리하는 셈이므로, 중간에 장애가 발생했을 때의 복구를 생각해야 한다.

내가 이 문제를 만났다면, 먼저 무엇을 막으려는지 두 가지로 나눠 봤을 것 같다.

  1. 사용자에게 같은 메일이 두 번 발송되는 것이 가장 큰 문제인가?
  2. 메일 중복 발송보다 오래 걸리는 엑셀 생성 작업 자체를 두 번 실행하는 것이 더 큰 문제인가?

두 문제는 연결되어 있지만, 어디에 초점을 맞추느냐에 따라 다른 방법을 시도해 볼 수 있다고 생각했다.

첫 번째 관점: 메일 발송 직전에 중복을 막으면 어떨까?

우선 작업을 고유하게 식별하는 키를 이용해, 메일을 보내기 전에 해당 작업의 DB 행에 잠금을 거는 방법을 생각했다. 락을 획득한 뒤 아직 발송 전인지 확인하고, 발송 전인 경우에만 메일을 보내는 것이다.

이렇게 하면 여러 Worker가 같은 작업을 처리하더라도, 메일을 보내는 단계에서 중복 발송을 막을 수 있지 않을까?

다만 이것은 아직 설계 가설이다. 메일 발송은 성공했는데 DB에 발송 완료를 기록하지 못한 경우까지 이 방식으로 해결할 수 있는지는 추가로 확인해야 한다. 또한 발송 단계에서만 제어한다면, 그 앞의 엑셀 생성 작업이 중복 실행되는 문제는 남는다.

두 번째 관점: 긴 작업 자체의 중복 실행을 막으려면?

Kafka의 타임아웃을 늘리는 방법부터 생각할 수 있지만, 실제 장애가 발생했을 때 작업을 어떻게 감지하고 복구할지도 함께 고려해야 한다.

리밸런싱을 끄면 되지 않을까?

처음에는 오래 걸리는 작업을 다른 Worker에게 넘기지 않으면 되는 것 아닌가 생각했다. 알아보니 Kafka에서는 assign()으로 파티션을 직접 할당해 자동 리밸런싱을 사용하지 않는 방식도 가능했다. 다만 이 경우 Worker가 실제로 종료되었을 때 파티션을 다른 Worker에 넘기는 일도 직접 책임져야 한다. 중복 실행을 피하려다가 장애 복구라는 과제가 남는 것이다.

별도 Worker가 작업을 맡고, Consumer는 계속 메시지를 확인하면 어떨까?

그렇다면 Kafka를 유지하면서 메시지를 가져오는 역할과 엑셀을 생성하는 역할을 분리할 수 있지 않을까? Consumer가 작업을 받아 별도 실행 스레드에 넘기고, 자신은 계속 poll()을 호출하는 방식이다.

  1. Consumer가 메시지를 받아 실행 Worker에게 맡긴다.
  2. 처리 중인 파티션은 pause()해서 추가 메시지 수신을 멈춘다.
  3. Worker가 엑셀을 생성하는 동안에도 Consumer는 계속 poll()한다.
  4. 작업 완료를 전달받으면 Consumer가 처리 완료된 오프셋을 커밋하고, 파티션을 resume()한다.

Kafka 공식 문서에서도 처리 시간을 예측하기 어려운 경우 이런 분리를 설명한다. Consumer API 호출은 Consumer 스레드에서 관리하고, 작업이 완료되기 전에 오프셋을 커밋하지 않는 것이 중요하다. Kafka Consumer 공식 문서

이 구조라면 엑셀 생성이 오래 걸린다는 이유로 poll()이 지연되는 문제를 피할 수 있다. 즉, Kafka를 제거하기 전에 검토할 수 있는 방법이라고 생각했다.

여기서 다시 궁금해졌다. 별도 Worker가 해당 작업을 소유하게 하면 충분하지 않을까?

정상적으로 실행되는 동안에는 가능한 접근이다. 다만 Kafka가 관리하는 것은 Consumer의 파티션 할당이며, 별도 Worker의 작업 소유권까지 자동으로 관리해 주지는 않는다. 예를 들어 Consumer와 별도 프로세스로 동작하는 Worker를 두었다면, Consumer만 종료되고 Worker는 계속 엑셀을 생성하는 상황도 생각해야 한다. 이때 아직 커밋하지 않은 메시지를 새 Consumer가 받으면, 기존 Worker와 새 Worker가 같은 작업을 실행할 수 있다.

따라서 단순히 실행을 맡기는 것에서 더 나아가, 작업 ID별로 누가 실행 중인지, 기존 소유자가 살아 있는지, 언제 다른 Worker가 넘겨받아도 되는지를 관리해야 한다. 같은 프로세스의 실행 스레드를 사용하는 경우에도 장애 후 재처리나 다른 원인의 리밸런싱에 대한 대응은 필요하다.

작업을 DB에 저장한 뒤 실행하는 방법은 어떨까?

다른 방법으로는 Consumer가 작업 ID를 기준으로 DB에 작업을 저장한 뒤 ACK하고, 별도 Worker가 DB에서 작업을 선점해 실행하는 구조를 검토했다. 같은 작업 ID의 중복 등록을 막고, 상태와 Heartbeat로 실행 및 복구를 관리하는 방식이다.

1
요청 → Kafka → Consumer → DB 작업 큐 → Worker

하지만 이 구조는 조금 어색하게 느껴졌다. Kafka로 작업을 전달했는데 다시 DB에 대기열을 만들고 Worker가 가져간다면, 작업 전달 단계가 중복되는 것 아닌가?

Kafka가 다른 서비스에도 이벤트를 전달하는 역할을 한다면 유지할 이유가 있겠지만, 내부 엑셀 생성 작업을 전달하는 용도라면 DB 작업 큐를 추가하면서도 Kafka가 필요한 이유를 다시 생각해 봐야 한다.

현재로서는 Kafka를 유지한 채 Consumer와 실행 Worker의 역할을 분리하는 방법을 먼저 검토하고 싶다. 다만 이것이 해결하는 것은 장시간 작업 때문에 발생하는 불필요한 재할당이다. 장애 복구까지 포함한 중복 실행 방지는 작업 소유권과 재처리 정책을 더 설계해야 한다. 이 부분을 DB로 관리하기 시작하면, 원문처럼 Kafka 없이 DB에서 바로 작업을 가져오는 선택과도 연결된다고 이해했다.

3. 이 작업에 Kafka가 꼭 필요했을까?

처음에는 Kafka를 유지하면서 이 문제를 해결할 방법을 생각했다. 리밸런싱을 끄거나, Consumer와 실행 Worker를 분리하고, Worker가 작업을 소유하게 하면 되지 않을까 싶었다.

그런데 하나씩 고민할수록 작업 상태와 소유권을 관리하고, 장애를 감지해 다른 Worker가 이어받는 방법까지 필요해졌다. 결국 중요한 것은 오래 걸리는 작업을 안전하게 보관하고, 실행하고, 실패하면 복구하는 것이었다.

여기까지 생각하니 자연스럽게 의문이 들었다.

이 요구사항을 위해 작업 상태를 별도로 관리해야 한다면, 이 구조에서 Kafka가 꼭 필요한 걸까?

여기서 중요한 조건은 엑셀 발송 요청의 트래픽이 폭발적으로 증가할 일이 없었다는 점이다. 원문에서도 실제 운영에서는 그런 상황이 없었기 때문에, Kafka 도입 시 기대했던 급격한 트래픽 변화에 대한 대응 이점을 거의 활용하지 못했다고 설명한다.

또한 요청을 발행하는 쪽과 처리하는 쪽이 모두 같은 서비스 안에 있어, 여러 Consumer Group으로 이벤트를 전달할 필요도 크지 않았다. 이 두 조건을 함께 보면 Kafka를 유지해야 하는 이유가 더 약해진다.

이런 조건이라면 DB에 작업을 저장하고 Worker가 직접 가져가 처리하면서, 상태와 Heartbeat를 통해 재시도와 장애 복구를 관리하는 구조가 납득됐다. Kafka로도 해결할 수 있겠지만, 이 서비스에서 Kafka를 유지해야 하는 이유가 무엇인지 다시 따져 볼 필요가 있었던 것이다.

여러 대안을 고민하고 나니, 아티클에서 RDB 기반 Task Queue를 도입한 선택을 이해할 수 있었다. 처음부터 DB 작업 큐가 정답이라고 받아들인 것보다, 내가 생각한 방법들의 한계를 따라가면서 그 선택에 도달했다는 점이 기억에 남았다.

4. 마무리

이후의 구현은 작업 상태 관리, Heartbeat, 재시도와 장애 복구를 앞서 정리한 요구사항에 맞게 잘 풀어낸 사례라고 느꼈다. 세부 구현은 원문에 설명되어 있어 여기서 다시 정리하지는 않으려 한다.

이번 글에서 가장 기억에 남는 것은 구현 자체보다 “내가 이 문제를 만났다면 어떻게 했을까?”를 고민하다가 “Kafka가 꼭 필요한가?”라는 질문까지 가게 된 과정이었다. 기존 기술을 유지하면서 해결할 방법을 찾는 것도 필요하지만, 실제 서비스의 요구사항을 기준으로 그 기술이 여전히 필요한지 돌아보는 것도 중요하다고 느꼈다.

This post is licensed under CC BY 4.0 by the author.