Durable Delays: 시간을 분산 상태로 관리하기

SchemaBridge Team · 2026-01-10 · Time, Scheduling, Orchestration

메모리 누수 없이 30일 대기를 처리하는 법. `sleep()`이 분산 시스템의 안티패턴인 이유.

sleep()의 거짓말: 왜 시간은 가장 어려운 분산 프리미티브인가

모든 프로그래밍 언어에는 대기 명령이 있습니다. Python에서는 time.sleep(), Node.js에서는 setTimeout(), Java에서는 Thread.sleep()입니다. 이 명령들은 단순하고 직관적이지만, 몇 초를 넘어서는 용도라면 프로덕션 분산 시스템에서는 전혀 쓸모가 없습니다.

소박한 sleep()이 거짓말인 이유는 환경이 안정적이라고 가정하기 때문입니다. 코드를 실행하는 머신이 계속 살아 있고, 로드 밸런서가 프로세스를 죽이지 않으며, OS가 메모리를 회수하지 않는다고 가정합니다. 현대 클라우드 환경에서 이 가정들은 틀렸습니다. 일반적인 Kubernetes 파드에서 sleep(24 60 60)(하루)를 실행하면, 그 파드는 하루가 끝나기 전에 교체되거나 재스케줄되거나 재배포될 가능성이 매우 높습니다.

프로세스가 죽으면 여러분의 "sleep"도 함께 죽습니다. 비즈니스 로직은 허공으로 사라집니다. 이렇게 해서 "잘 잊어버리는 소프트웨어"가 탄생합니다. 고객의 체험 기간을 놓치고, 구독 갱신을 빠뜨리고, 중요한 후속 이메일을 보내지 못하는 시스템 말입니다.

시간의 계층: 초에서 개월까지

시간을 올바르게 관리하려면 먼저 분류해야 합니다. 모든 지연이 같지는 않습니다.

1. 일시적 지연(밀리초~초): 대개 네트워크 백오프나 빠른 데이터베이스 락 대기입니다. 50ms 사이에 장애가 날 위험은 낮으므로 여기서는 sleep()이 허용되기도 합니다.

2. 짧은 지연(분): 여기서부터 sleep()이 실패하기 시작합니다. 아무것도 하지 않으면서 워커 스레드나 컨테이너 리소스를 몇 분 동안 붙잡고 있게 됩니다. 돈 낭비이자 커넥션 풀에 대한 위험입니다.

3. Durable Delay(시간~개월): 이것은 비즈니스 라이프사이클의 영역입니다. 14일 무료 체험, 30일 결제 기한, 6개월 주기의 유지보수 일정 같은 것들입니다. 이런 것은 코드 안에 살 수 없고, 인프라에 살아야 합니다.

잠자는 워크플로의 아키텍처

SchemaBridge에서는 시간을 영속적인 상태로 다룹니다. 워크플로가 DELAY vertex에 도달해도 스레드를 블로킹하지 않습니다. 단계는 SUSPENDED로 보류되고, 깨어날 의도가 데이터베이스에 기록되며, 워커는 즉시 다른 작업을 할 수 있게 됩니다.

DB 폴링 모델(낮은 정밀도, 높은 내구성)

고전적인 자체 구현 방식은 "기상 시각"을 데이터베이스 테이블에 저장하고, 백그라운드 워커("폴러")가 몇 초마다 테이블을 조회합니다: SELECT * FROM timers WHERE wake_up < NOW(). DB 레코드는 재시작을 견디므로 내구성은 있지만, 그 대가로 빈 쿼리가 끊임없이 발생하고 정밀도의 하한은 폴링 간격과 같아집니다.

SchemaBridge의 접근 방식

SchemaBridge는 데이터베이스 레코드의 내구성은 유지하면서 고정 폴링은 없앴습니다. 각 기상 시점은 PostgreSQL scheduled_tasks 테이블(ScheduledTaskRepository 뒤에 있음)에 절대 시각 execute_at을 가진 행으로 영속화됩니다. 클록 워커는 대기 중인 가장 이른 execute_at까지 잠들어 있다가, 작업이 실행 가능해질 때마다 PostgreSQL LISTEN/NOTIFY 푸시로 일찍 깨어납니다. 기한이 된 작업이 있으면 FOR UPDATE SKIP LOCKED를 사용한 단일 구문으로 이를 가져가므로, 여러 클록 워커 레플리카가 충돌 없이 백로그를 나눠 처리할 수 있습니다. 넉넉한 유휴 상한은 알림을 놓쳤을 경우를 대비한 안전장치로만 존재합니다.

-- 클레임의 단순화된 형태: 기한이 된 행을 오래된 것부터, 레플리카 간에 분할
UPDATE scheduled_tasks SET status = 'PROCESSING'
WHERE task_id IN (
  SELECT task_id FROM scheduled_tasks
  WHERE status = 'PENDING' AND execute_at <= :now
  ORDER BY execute_at
  FOR UPDATE SKIP LOCKED
  LIMIT 100
)
RETURNING task_id;

취소와 조기 종료: 미래를 바꾸기

Durable Delay는 이야기의 절반일 뿐입니다. 사용자가 "결제를 3일간 대기" 단계에 있다가 2시간 만에 결제했다면, 어떤 일이 일어나야 할까요?

첫째, 취소입니다. SchemaBridge에서 실행을 중지하면 실행이 취소되고, 이어서 해당 실행의 대기 중인 지연 타이머와 승인 타이머가 해제됩니다. 그래서 중지된 여정이 며칠 뒤 깨어나 계속 진행되는 일은 없습니다. 이 순서(먼저 취소, 그다음 해제)는 의도된 것으로, 최악의 경우라도 이미 취소된 실행에 타이머가 발화할 뿐이며, 종료 상태 가드가 이를 무해한 no-op으로 만듭니다.

둘째, 조기 기상입니다. SchemaBridge에는 "외부 이벤트가 도착하면 이 지연을 깨운다"는 내장 프리미티브가 없습니다. DELAY vertex는 설정된 기간만큼 기다립니다. 조기 종료를 정직하게 모델링하는 방법은 두 가지입니다.

어느 패턴도 엔진이 타이머와 신호를 경쟁시킬 필요가 없습니다. 이런 경쟁 상태는 잘 되리라 믿고 맡길 것이 아니라 설계로 제거해야 하는 것입니다.

클록 드리프트와 정밀도

어떤 분산 시스템에서도 시계는 결코 완벽하게 동기화되지 않습니다. 이것이 클록 드리프트 현상입니다. 한 머신의 시계가 다른 머신보다 50ms 빠르다면, "1초 대기"는 작업이 어디에서 처리되느냐에 따라 다르게 동작할 수 있습니다.

SchemaBridge는 기상 시점을 한 곳에 있는 절대 타임스탬프, 즉 PostgreSQL의 execute_at 컬럼으로 만들어 이 문제를 작게 유지합니다. 지연을 설정할 때 now + delaySeconds를 한 번 계산해 저장하고, 그 이후로는 어떤 워커도 메모리에 카운트다운을 유지하지 않습니다. 따라서 드리프트하거나 재시작 시 사라질 워커별 타이머가 없습니다. 머신 간의 일반적인 시계 오차는 여전히 존재하지만, 시간이나 일 단위로 측정되는 비즈니스 지연에서는 결과에 영향을 주지 않습니다.

예시 시나리오: 30일 드립 캠페인 자동화

"웰컴 여정" 로직으로 고전하는 마케팅 자동화 플랫폼을 생각해 봅시다.

과제

여정은 다음과 같이 구성됩니다:

Cron 작업과 Postgres 테이블로 만든 자체 솔루션 위에서는 이런 여정이 취약합니다. 데이터베이스 유지보수 시간이나 배포 중에 일부 사용자가 대기 상태에 "갇혀" 다음 이메일을 영영 받지 못할 수 있습니다.

SchemaBridge 방식

같은 여정을 SchemaBridge 워크플로로 다시 만든다고 상상해 보세요.

1. 시각적 지연: DELAY vertex를 그래프로 끌어다 놓고 delaySeconds를 259200(3일)과 604800(7일)으로 설정합니다. 빌더는 이를 "3d"처럼 간결한 기간으로 표시합니다.

2. 영속적 재개: 배포 중에는 대기 중인 실행이 그냥 데이터베이스에 보류된 채로 남아 있습니다. 클록 워커가 돌아오면, 다운타임 동안 기한이 된 타이머를 오래된 것부터 가져가 재개합니다.

3. 기상 시 재확인: 각 지연 후 Gateway 단계가 이메일 또는 결제 제공자에게 사용자가 클릭했는지, 업그레이드했는지 묻고, 조건부 엣지가 다음 단계를 선택합니다.

기대 효과

비교: 기존 스케줄링 전략

| 기능 | setTimeout() | Cron 작업 / Quartz | SchemaBridge Durable Delays |

| :--- | :--- | :--- | :--- |

| 영속성 | 없음(휘발성) | 수동(DB 기반) | 네이티브(PostgreSQL scheduled_tasks) |

| 확장성 | 낮음(RAM에 종속) | 중간(DB 병목) | 높음(레플리카 간 SKIP LOCKED) |

| 취소 | 복잡함(수동 핸들) | 수동 DB 정리 | 실행을 중지하면 타이머가 해제됨 |

| 가시성 | 없음 | SQL 쿼리 | 대기 중(일시 중단) 단계를 실행 보기에서 확인 가능 |

| 해상도 | 밀리초 | 초/분 | 푸시로 기상(LISTEN/NOTIFY), 고정 폴링 없음 |

장기 실행 비즈니스 로직을 위한 전문가 체크리스트

5분 이상 기다리는 시스템을 설계한다면 다음 휴리스틱을 따르세요:

1. 스레드를 멈추세요: 기다리는 동안 워커를 절대 블로킹하지 마세요. 워커는 무상태여야 하며 언제든 종료될 준비가 되어 있어야 합니다.

2. 시계를 외부화하세요: 기상 시각은 로컬 머신의 인메모리 타이머가 아니라 중앙의 영속 저장소에 보관하세요.

3. 조기 결과를 고려해 설계하세요: 기다리는 행동을 사용자가 지연이 만료되기 전에 할 수 있다고 항상 가정하세요. 아무것도 바뀌지 않았다고 가정하지 말고, 대기가 끝날 때 상태를 다시 확인하세요.

4. 대기실을 감사하세요: 타이머에 걸려 대기 중인 단계를 가진 실행이 몇 개인지 지켜보세요. 이는 비즈니스 건강 상태를 보여주는 중요한 지표입니다.

5. 상대적 지연을 선호하세요: 대기를 고정된 달력 날짜가 아니라 이전 단계로부터의 기간(delaySeconds)으로 표현하세요. 그래야 이전 단계가 늦게 실행되더라도 단계 간 상대적 간격이 일정하게 유지됩니다.

결론: 시간은 인프라 문제다

잊어버리는 소프트웨어는 실패하는 소프트웨어입니다. 시간을 일급의 영속적인 상태로 다룸으로써, 우리는 실시간 이벤트와 지연 시간이 긴 인간의 행동 사이에 다리를 놓습니다. SchemaBridge를 사용하면 며칠 또는 몇 주에 걸친 여정도 잠든 스레드라면 죽어 버렸을 재시작과 배포를 견뎌냅니다. 단, 조기 종료를 설계하고 이후 단계가 필요로 하는 데이터를 넘겨준다는 전제하에서입니다.

상호작용의 타이밍을 안정적으로 관리하는 능력은 실질적인 경쟁 우위입니다. 우리는 영속적인 시계를 제공하고, 여러분은 여정을 제공합니다.

Building the Bridge 시리즈의 일부입니다. 함께 보기: 관측성 격차 — 로그 속에서 길을 잃지 않고 장기 실행·다단계 실행을 디버깅하는 방법.

살펴보기