Redis나 별도 워커 없이, BEAM 런타임과 SQLite 지속성을 활용해 Curling IO v3에서 백그라운드 잡 시스템을 구성한 방식과 그 이유를 설명한다.
대부분의 웹 스택에서 백그라운드 잡을 추가한다는 것은 인프라를 추가한다는 뜻이다. Redis, Sidekiq, 별도의 워커 프로세스, 모니터링 대시보드, 그리고 배포하고 계속 실행 상태를 유지해야 하는 또 다른 무언가. Curling IO Version 2는 PostgreSQL 기반의 Delayed Job을 사용했는데, 잘 동작하긴 하지만 웹 프로세스와 함께 별도의 워커 데몬이 필요했다.
Curling IO Version 3는 BEAM(Erlang의 가상 머신) 위에서 동작하며, 백그라운드 잡은 같은 런타임 안에서 돌아가는 또 하나의 프로세스일 뿐이다. Redis도 없다. 별도의 워커도 없다. 추가 인프라도 없다. 이 글에서는 우리가 이를 어떻게 만들었는지, 왜 인메모리 큐 대신 SQLite 지속성을 선택했는지, 그리고 이 모든 것이 Gleam 수백 줄 안에 어떻게 들어가는지 다룬다.
BEAM VM은 다운타임 없이 수백만 개의 동시 작업을 처리해야 하는 통신 시스템을 위해 설계되었다. 모든 BEAM 애플리케이션에는 경량 프로세스, 슈퍼바이저, 메시지 패싱이 기본으로 내장되어 있다. 이것들은 OS 스레드가 아니다. VM 자체 스케줄러가 관리하며, 하나의 OS 프로세스 안에서 수십만 개를 실행할 수 있다.
즉 "백그라운드 워커"는 별도의 서비스가 아니다. HTTP 핸들러와 나란히 같은 런타임에서 돌고, 같은 로그 출력도 공유하는 또 하나의 프로세스일 뿐이다. 워커를 시작하는 것은 배포가 아니라 함수 호출이다.
BEAM에서 가장 단순한 접근은 잡마다 프로세스를 spawn해서 그대로 실행하게 두는 것이다. 또는 인메모리 메시지를 쓰는 OTP 액터를 사용하는 방법도 있다. 우리도 이를 고려했지만, 한 가지 빈틈이 있다. 서버가 재시작되면 대기 중인 잡이 전부 사라진다. 로그인 이메일 하나가 유실되는 건 큰 문제가 아닐 수 있다(사용자는 그냥 다시 요청하면 된다). 하지만 우리는 앞으로 대진표(드로우) 일정 생성, Stripe를 통한 결제 처리, 회계 동기화도 실행할 예정이다. 그런 것들이 처리 도중에 유실되는 건 실제로 큰 문제다.
우리는 복잡성 없이 내구성을 원했다. 그리고 SQLite는 이미 거기에 있었다.
Curling IO는 이미 종목별 데이터베이스(종목당 하나)에 SQLite를 사용한다. 거기에 jobs 테이블을 추가할 수도 있었지만, 우리는 의도적으로 별도의 shared.db에 두었다. 백그라운드 잡 처리는 잦은 쓰기(잡 삽입, 실행 중 표시, 완료 표시)를 의미하며, 종목 데이터베이스에 대한 쓰기 락을 두고 경쟁할 이유가 없다. SQLite는 단일 라이터를 갖는 write-ahead log를 사용하므로, 워크로드를 분리하면 잡 처리가 등록(조회) 쿼리를 막지 않고 그 반대도 성립한다.
jobs 테이블:
CREATE TABLE IF NOT EXISTS jobs ( id INTEGER PRIMARY KEY AUTOINCREMENT, kind TEXT NOT NULL, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, max_attempts INTEGER NOT NULL DEFAULT 3, max_running_seconds INTEGER NOT NULL DEFAULT 120, error TEXT, created_at INTEGER NOT NULL, run_at INTEGER NOT NULL, started_at INTEGER, completed_at INTEGER) STRICT;
요청 핸들러에서 백그라운드 이메일을 보내는 코드는 이렇게 생겼다:
jobs.enqueue_email(request_context.shared_db, request_context.postmark_api_token, email)
내부적으로는 payload 컬럼에 이메일 세부 정보를 JSON으로 직렬화해 넣는 단 한 번의 INSERT INTO jobs이다. 마이크로초 단위로 끝난다. 사용자는 Postmark API 호출이 끝나기를 100-500ms 기다리는 대신 즉시 응답을 받는다.
워커는 단일 OTP 액터이며, 이는 메시지를 순차적으로 처리하는 상태ful 프로세스에 대한 BEAM의 추상화다. 우리 액터에는 메시지 타입이 정확히 하나뿐이다:
pub type Message { CheckForWork}
시작할 때 액터는 1초 지연 후 스스로에게 CheckForWork 메시지를 보낸다. 그 메시지를 처리할 때 대기 중인 잡을 조회해 처리하고, 다음 체크를 예약한다. 이는 스스로 유지되는 폴링 루프다:
fn handle_message(state, msg) { case msg { CheckForWork -> { process_pending_jobs(state) process.send_after(state.self, 1000, CheckForWork) actor.continue(state) } }}
각 폴링은 run_at이 현재 시각보다 과거인 pending 잡들을 배치로 가져와, 한 번에 하나씩 처리하고 각 잡을 completed 또는 failed로 표시한다. 이 액터는 잘못된 잡 하나 때문에 절대 크래시하지 않는다. 에러는 캐치해 로그로 남기고, 배치의 나머지를 계속 처리한다.
모든 실패가 영구적인 것은 아니다. Postmark가 잠깐 접근 불가일 수도 있고, 500을 반환할 수도 있다. 재시도 로직은 단순하다. 잡이 실패했고 남은 시도가 있다면, 미래의 run_at으로 pending 상태로 되돌린다. 백오프는 지수적이다(5초, 25초, 125초):
Attempt 1 fails → retry in 5sAttempt 2 fails → retry in 25sAttempt 3 fails → permanently failed
기본값은 3회이며, max_attempts 컬럼으로 잡별 설정이 가능하다. 영구 실패한 잡은 에러 메시지와 함께 테이블에 남기 때문에 무엇이 잘못됐는지 정확히 볼 수 있다:
SELECT id, kind, error, attempts FROM jobs WHERE status = 'failed';
별도의 모니터링 대시보드는 필요 없다. 그냥 SQL이다.
액터는 OTP 슈퍼바이저 아래에서 실행된다. 액터 프로세스가 죽으면 슈퍼바이저가 같은 등록 이름으로 자동 재시작한다. 폴링 루프는 다시 이어지고, 크래시가 발생했을 때 running으로 표시되어 있던 잡들은 클린업 스윕으로 복구된다.
각 잡에는 max_running_seconds 컬럼(기본: 120초)이 있다. 매 폴링 사이클 시작 시 액터는 running 상태로 타임아웃보다 오래 머문 잡을 찾아 pending으로 되돌린다. attempts 카운터는 잡이 running에 들어갈 때 이미 증가했으므로, 기존의 재시도/백오프 로직이 나머지를 처리하며 max_attempts로 재시도 횟수도 제한된다. completed 및 failed 잡은 7일 후 삭제되는데, 문제를 디버그하기엔 충분한 시간이다.
이건 BEAM의 주특기다. Erlang의 "let it crash" 철학은 가능한 모든 실패를 막기 위해 방어적으로 코드를 쓰는 대신, 실패에서 복구하는 슈퍼바이저와, 빠져나간 것들을 잡아내는 클린업 스윕을 작성하라고 말한다. 결과적으로 코드는 더 적고, 복원력은 더 좋아진다.
사용자가 로그인 이메일을 요청할 때 일어나는 일은 다음과 같다:
jobs.enqueue_email()을 호출한다. 이는 shared.db에 대한 INSERT일 뿐이다(마이크로초)running으로 표시하고 Postmark API를 호출한 뒤 completed로 표시한다pending으로 되돌아간다이 구성에서 빠져 있는 것들은 다음과 같다:
이 구성은 수만 개의 클럽까지도 무리 없이 확장할 수 있다. SQLite가 처리량을 감당하고, BEAM이 동시성을 감당하며, 전체를 한 번에 읽어볼 수 있을 정도로 단순하다.
이 잡 시스템은 성장하도록 설계되었다. 새로운 잡 타입을 추가하는 것은 새로운 kind 문자열과 핸들러 함수를 추가하는 것뿐이다. 장시간 실행되는 리포트 생성, 데이터 임포트, 이메일 캠페인: 모두 같은 패턴을 따른다. 행을 INSERT하고, 액터가 가져가게 두면 된다.
이 글은 Curling IO Foundation 시리즈의 Part 4이다. 다음 글: Why We Chose SQLite.