팬아웃 마스터하기: 내구성 있고 멱등적인 워크플로 구축
SchemaBridge Team · 2025-12-15 · Scalability, Idempotency, Orchestration
작업의 행방을 잃지 않고 대규모 팬아웃을 처리하기. Spawner vertex, 항목별 멱등성 키, at-least-once 전달을 깊이 살펴봅니다.
대규모 팬아웃의 과제: 루프가 무너지는 곳
모든 개발자는 루프를 작성해 본 적이 있습니다. Java의 for 루프든, JavaScript의 .map()이든, Python의 list comprehension이든 로직은 같습니다. 항목 목록을 받아 각각에 대해 무언가를 수행하는 것입니다. 이것이 가장 단순한 형태의 데이터 처리입니다. 몇 개 수준이라면 사소한 일입니다. 수백 개 수준이라면 관리할 만합니다. 하지만 수천, 수만 개의 문턱을 넘어서면 이 소박한 루프는 애플리케이션의 안정성과 확장성에 심각한 약점이 됩니다.
분산 시스템의 세계에서 로컬 루프는 장애 지점입니다. 몇 개의 항목에서 수천 개로 넘어가면 복잡도는 단순히 선형으로 증가하지 않고 복잡도의 벽에 부딪힙니다. 이 벽은 메모리 관리, 네트워크 지연, 그리고 코드를 실행하는 머신이 결국 고장 난다는 냉혹한 현실로 이루어져 있습니다. 더 이상 "로직"을 고민하는 것이 아니라 "물리"와 싸우게 됩니다.
로컬 루프의 한계: Promise.all이 스케일 이전의 패턴인 이유
순진한 구현에서는 거대한 JSON 페이로드(예: 대용량 일일 주문 CSV나 CRM의 배치 내보내기)를 받아 다운스트림 서비스에 대한 API 호출을 루프로 감쌀 수 있습니다. 현대적인 JavaScript 개발자라면 Promise.all()을 사용해 모든 호출을 병렬로 발사할지도 모릅니다. 이것이 스케일을 고려하지 않는 개발자의 첫 번째 실수입니다.
규모가 커지면 이는 세 가지 중대한 이유로 재앙이 됩니다.
1. 메모리 고갈: 조용한 크래시
매우 많은 수의 복잡한 객체를 메모리에 올리면 워커가 쉽게 크래시할 수 있습니다. 객체당 크기가 크지 않더라도 금세 쌓이고, 메모리를 많이 쓰는 런타임에서는 원시 데이터가 메모리에서 더 부풀어 오를 수 있습니다. 그 결과 첫 번째 항목이 처리되기도 전에 프로세스를 죽이는 "Out of Memory"(OOM) 오류가 즉시 발생합니다.
2. 실행 타임아웃: 시간은 흐른다
대부분의 플랫폼에는 엄격한 제한이 있습니다. 루프가 수천 건의 API 호출을 수행하고 각 호출이 단 100ms만 걸린다 해도 스크립트는 수 분 동안 실행될 수 있습니다. 병렬화하더라도 여전히 그 단일 컨테이너의 리소스 제한과 오버헤드에 묶여 있습니다. 마지막 항목이 처리되기 전에 플랫폼이 프로세스를 종료시키고, 시스템은 불확정 상태로 남게 됩니다.
3. 진행 상황 기록 없음
프로세스가 죽으면 무엇이 처리되었는지에 대한 유일한 기록은 대상 시스템에만 남습니다. 포렌식 수준의 대조 작업 없이는 어떤 항목이 전송되었고, 어떤 항목이 성공했으며, 어떤 항목이 한 번도 시도되지 않았는지 아무도 말할 수 없습니다.
Spawner의 등장: 내구성 있는 자식 실행으로서의 팬아웃
SchemaBridge에서 팬아웃은 전용 Spawner Vertex입니다. Spawner는 단순한 루프가 아닙니다. 각 항목을 엔진이 개별적으로 추적하고 보고하며, 개별적으로 재시도할 수도 있는 일급 작업 단위로 바꿉니다.
Spawner의 실제 동작 방식: 병렬 분할
Spawner는 목록과 항목의 실행을 분리합니다. 각 항목은 하나의 프로세스 안의 반복이 아니라 그 자체로 독립된 실행이 됩니다.
1. 항목당 하나의 자식 실행: Spawner는 각 항목마다 대상 워크플로의 자식 실행을 시작합니다. 각 자식은 고유한 ID, 고유한 상태, 고유한 로그와 함께 PostgreSQL에 영속화되며, 부모 실행까지의 계보를 유지합니다.
2. 항목별 결정적 키: 모든 자식 요청은 부모 실행, Spawner vertex, 항목의 위치로부터 도출된 멱등성 키를 가집니다. Spawner 단계 자체가 재전달되면(예: 팬아웃 도중 워커 Pod가 재시작되어 다른 워커가 해당 단계를 다시 가져가는 경우) 목록을 처음부터 다시 제출하지만, 이미 시작된 자식은 키로 식별되어 두 번 생성되지 않습니다.
3. 수명 주기의 독립성: 501번째 항목이 실패해도 502번째 항목이 디스패치되거나 성공하는 것을 막지 않습니다. 하나의 거대하고 취약한 배치 대신 수많은 개별 실행이라는 세분성을 얻게 되며, 실패한 자식은 단독으로 재시도할 수 있습니다.
보장 수준은 냉정하게 이해해야 합니다. 이 키를 통한 중복 제거는 자식을 생성하기 전에 수행하는 조회이지 데이터베이스의 고유성 제약 조건이 아니므로, 중복이 생길 수 있는 시간 창을 닫는 것이 아니라 좁힐 뿐입니다. 엔진은 at-least-once이며, 자식이 외부 세계에 대해 수행하는 모든 작업은 대상 시스템에서 멱등적이어야 합니다.
병렬성의 경제학
무거운 단일 스레드 워커를 15분 동안 실행하는 것은 비쌉니다. 대용량 메모리 인스턴스 비용뿐 아니라, 코드가 API 응답을 기다리는 "유휴 시간"에 대해서도 비용을 지불하게 됩니다. 과금되는 CPU 사이클을 네트워크 I/O 대기에 낭비하는 셈입니다.
항목별 자식 실행을 사용하면 작업의 형태가 바뀝니다.
- 병렬 실행: 자식들은 하나의 장기 실행 프로세스 안에서 모두 실행되는 대신, 여유 용량이 있는 워커가 가져가 처리합니다.
- 영향 범위 축소: 한 자식의 실패는 다른 자식에 영향을 주지 않습니다. 전통적인 루프에서는 한 항목에서 발생한 메모리 누수나 처리되지 않은 예외 하나가 전체 실행을 중단시킬 수 있습니다.
이 방식은 대부분의 시간을 I/O 대기로 보내는 장기 실행 배치 스크립트보다 컴퓨팅 자원을 더 효율적으로 사용할 것으로 기대되지만, 더 큰 이점은 청구 금액이 아니라 안정성과 관찰 가능성입니다.
이 모델에는 한계가 있으며, 이를 알아 두어야 합니다. 모든 항목이 하나의 실행이므로, 팬아웃은 자식을 하나라도 시작하기 전에 플랜의 실행 할당량과 대조하여 검사됩니다. Spawner는 항목 목록을 메모리에 보관하고 단일 단계에서 자식을 하나씩 제출하므로, 무한히 넓은 파이프가 아닙니다. 그리고 플랫폼의 페이로드 저장소에 부하가 걸리면 Spawner는 이미 어려움을 겪는 저장소에 데이터를 더 밀어 넣는 대신 팬아웃 전체를 연기합니다. 즉 단계를 보류해 두었다가 나중에 내구성 있는 타이머에서 다시 구동합니다.
분산 팬아웃의 멱등성: 보호 장치가 있는 at-least-once
팬아웃 패턴에서 가장 큰 장애물은 멱등성입니다. Spawner 단계가 중단되었다가 다시 실행되면, 같은 자식을 두 번 시작하지 않으려면 어떻게 해야 할까요?
SchemaBridge는 여러 겹의 보호 장치를 둡니다.
- 항목별 키: 위에서 설명한 결정적 키 덕분에 다시 실행된 Spawner는 새 자식을 생성하는 대신 이미 존재하는 자식을 찾을 수 있습니다. 이 조회는 상태와 관계없이 기존 자식을 반환하므로, 이미 실패한 자식은 다시 실행되지 않고 다시 연결될 뿐입니다.
- 단계 수준 보호 장치: 엔진 내부에서는 이미 완료된 단계는 다시 실행되지 않으며, 실행을 위해 단계를 점유하는 것은 원자적 상태 전이이므로, 같은 단계에 대한 중복 메시지는 두 번 실행되지 않고 폐기됩니다.
이것이 제공하지 않는 것은 exactly-once 실행입니다. 부수 효과가 발생한 뒤 그 완료가 기록되기 전에 워커가 크래시할 수 있으며, 그러면 해당 단계는 다시 실행됩니다. 그래서 우리는 이 플랫폼을 강력한 보호 장치를 갖춘 at-least-once라고 설명하며, 멱등적이지 않은 호출의 대상 시스템에는 자체적인 보호가 필요한 것입니다.
예시 시나리오: 대규모 마이그레이션의 실패
한 팀이 매우 큰 원장 항목 배치를 옮기는 중요한 마이그레이션을 수행한다고 상상해 보십시오. 이 팀은 전통적인 Python 스크립트를 사용하기로 합니다. 장시간 실행되는 작업 도중 VPN 연결이 끊깁니다. 스크립트는 크래시합니다.
복구 (값비싼 방식)
복구는 고통스럽습니다. 엔지니어들은 며칠 동안 대상 데이터베이스를 뒤지고 레코드를 수작업으로 대조하며, 무엇이 기록되었고 무엇이 기록되지 않았는지 정확히 파악해야 합니다.
복구 (SchemaBridge 방식)
이번에는 같은 마이그레이션을 원장 항목마다 자식 실행 하나씩을 팬아웃하는 Spawner로 모델링하고, 같은 연결 끊김이 발생한다고 상상해 보십시오.
1. 실패는 항목 단위에 머문다: 이미 실행 중이던 자식은 계속 진행되고, 대상에 도달하지 못한 자식은 각자의 오류와 함께 개별적으로 실패하며, 팬아웃의 나머지는 영향을 받지 않습니다. Spawner 자체가 자식을 제출하지 못하면, Spawner 단계는 기다리는 척하는 대신 눈에 보이게 실패합니다.
2. 가시적인 진행 상황: 실행 뷰에 성공한 자식과 실패한 자식의 수가 표시되므로, 팀은 대상 데이터베이스에서 진행 상황을 역추적할 필요가 없을 것입니다.
3. 표적 복구: 연결이 복구되면 팀은 마이그레이션 전체를 다시 실행하는 대신 실패한 자식을 개별적으로 재시도합니다. 실패한 Spawner 단계를 재시도해도 그 자식들은 다시 실행되지 않습니다. 항목별 키가 실패한 것을 포함해 이미 존재하는 모든 자식을 다시 연결하므로, Spawner 재시도는 한 번도 시작되지 않은 자식만 생성합니다. 복구는 포렌식 프로젝트가 아니라 클릭 몇 번으로 끝나는 운영자 작업이 될 것입니다. 다만 원장 쓰기 자체는 여전히 멱등적이어야 합니다. 재시도된 자식이 이미 반영된 쓰기를 반복할 수 있기 때문입니다.
상세 비교: 실제 환경의 스케일링 모델
| 기능 | 순진한 루프 (forEach) | SQS/Lambda (직접 구축) | SchemaBridge Spawner |
| :--- | :--- | :--- | :--- |
| 상태 관리 | 로컬 (휘발성) | 수동 (DB/큐) | 네이티브 (내구성, 자식별) |
| 오류 처리 | 단일 try/catch | 수동 재시도/DLQ | 항목별 장애 격리 + 운영자 재시도 |
| 가시성 | 로그 파일 조각 | 불투명한 큐 깊이 | 실행 뷰의 자식별 상태 |
| 조인 로직 | 어려움 (단일 스레드) | 매우 어려움 (카운터) | 네이티브 Merge Vertex |
| 멱등 ID | 없음 | 수동 생성 | 항목별 결정적 키 |
대용량 오케스트레이션을 위한 전문가 체크리스트
대용량 팬아웃을 설계한다면 다음 경험 법칙을 따르십시오.
1. 대상 시스템에서 동시성을 제한하라: 동시성 제한으로 다운스트림 시스템을 보호하십시오. SchemaBridge에서는 이것이 Spawner 자체의 설정 항목이 아니라 대상별 동시성 제한(Rate Limits 페이지에서 설정)이며, 해당 대상을 호출하는 모든 단계에 적용됩니다. 보수적으로 시작하고 다운스트림 상태를 지켜보며 넓혀 가십시오.
2. 항목 실패를 정상으로 가정하라: 각 항목을 독립적으로 재시도할 수 있게 하고, 실패한 자식 하나가 팬아웃 전체를 실패시킬지 아니면 허용될지를 미리 결정하십시오.
3. "롱테일" 지연 시간을 모니터링하라: 나머지보다 훨씬 오래 걸리는 소수의 항목을 찾아보십시오. 이는 대개 가장 복잡한 엣지 케이스이거나 데이터베이스 잠금 대상입니다.
4. 결정적 ID를 활용하라: 재시작으로부터 보호하기 위해 엔진의 항목별 키를 사용하고, 대상 시스템에도 안정적인 비즈니스 키를 부여하십시오. 동시성이 높은 팬아웃에서 고유성을 타임스탬프에 의존해서는 절대 안 됩니다.
{
"type": "MERGE",
"config": {
"tolerateChildFailures": ["FAILED"],
"failureTolerance": { "maxFailureRatio": 0.05 },
"failFast": true
}
}
부분 실패 허용은 옵트인 방식이며 팬아웃의 콜백 merge에서 설정합니다. 기본적으로는 실패한 자식이 하나라도 있으면 조인이 실패하며, 이는 모든 항목이 중요할 때 안전한 선택입니다. 위 예시에서는 실패한 자식을 팬아웃의 5%까지 허용하며, failFast는 그 허용 한도를 초과하는 것이 확정되는 즉시 뒤처진 자식을 기다리지 않고 조인을 확정합니다.
결론: 스케일링은 인프라 문제다
팬아웃을 마스터하는 것은 더 나은 루프를 작성하는 것이 아니라 분산을 위한 아키텍처를 설계하는 것입니다. 반복, 항목별 상태, 중복 억제를 엔진으로 옮기면 부분 실패는 알 수 없는 상태가 아니라 구체적인 실패 항목의 목록이 됩니다. 그리고 그것이 재시도와 대조 프로젝트의 차이입니다.
스케일링에는 여전히 한계가 있습니다. 할당량, 백프레셔, 그리고 분산 전달의 at-least-once 특성입니다. 하지만 이러한 한계는 새벽 3시에 프로덕션에서 발견되는 것이 아니라 눈에 보이고 범위가 정해져 있습니다.
함께 보기: 키와 대상 측 멱등성이 어떻게 맞물리는지는 멱등성 엔진: 트랜잭션 무결성 보장을, 팬아웃의 결과를 수집하는 방법은 Merge 마스터하기를 참고하십시오.