머지 마스터하기: 병렬 세계에서 상태 조율하기
SchemaBridge Team · 2025-12-29 · Concurrency, State Management, Synchronization
경쟁 조건 없이 병렬 브랜치 동기화하기: 내구성 있는 조인, 롱테일 문제, 그리고 어떤 조인이 적용될지 결정하는 기본값.
병렬성의 역설: 자유 vs. 동기화
성능을 추구하면서 우리는 병렬성을 받아들였습니다. 작업을 팬아웃하고, 비동기 워커를 띄우고, 데이터를 여러 노드에 흩어 놓습니다. 처리량은 늘지만, 그 대가로 복잡성이라는 높은 비용을 치릅니다. 분산 시스템에서 어려운 것은 여러 일을 동시에 시작하는 것이 아니라, 그것들을 다시 하나로 모으는 것입니다.
복잡한 주문 처리 과정을 떠올려 보세요. "카드 결제" 작업, "재고 확인" 작업, "배송비 계산" 작업을 동시에 시작합니다. 다음 단계인 송장 출력으로 넘어가려면 세 작업의 결과가 모두 필요합니다. 이것이 머지 vertex이며, 분산 조인 또는 배리어 동기화기라고도 합니다.
단일 스레드 환경에서는 쉽습니다. 세 개의 함수 호출이 반환되기를 기다리기만 하면 됩니다. 하지만 분산 엔진에서는 이 작업들이 서로 다른 머신에서 실행되며, 몇 초 또는 몇 시간 간격으로 끝날 수도 있습니다. 하나는 실패하고 나머지는 성공할 수도 있습니다. 이것이 병렬성의 역설입니다. 속도를 얻기 위해 작업을 병렬화할수록 최종 결과를 조율하기는 더 어려워집니다.
배리어 동기화의 역사적 발전
상태를 머지하는 일이 왜 이렇게 어려운지 이해하려면 고성능 컴퓨팅(HPC)의 역사를 살펴봐야 합니다. 1970~80년대에 컴퓨터 과학자들은 배리어라는 개념을 고안했습니다. 배리어는 동기화 지점으로, 병렬 프로세스의 모든 스레드가 그곳에서 멈추고 다른 모든 스레드가 도착할 때까지 기다려야 합니다. 개수가 채워져야만 프로세스가 앞으로 나아갈 수 있습니다.
모놀리식 시스템에서는 이를 공유 메모리 위의 스핀락이나 뮤텍스로 구현했습니다. 머신의 CPU가 거의 무한에 가까운 속도로 배리어의 상태를 관리했습니다. 그러나 분산 시스템으로 옮겨 가면서 우리는 "공유 메모리"를 잃었습니다. 더 이상 중재자 역할을 할 단일 CPU가 없었습니다.
2000년대에는 Map-Reduce(Google의 기념비적인 논문)가 부상했습니다. Map-Reduce는 대규모 병렬 처리를 가능하게 했지만, "배치 워크로드"를 위해 설계되었습니다. 데이터를 map한 뒤, 모든 결과를 집계하는 "Reduce" 단계가 이어졌습니다. 실패한 map 태스크는 그냥 다시 실행되었지만, 느린 태스크는 reduce 단계를 붙잡아 두었습니다. Google의 설계는 작업 막바지에 아직 실행 중인 태스크를 투기적으로 다시 실행하는 백업 태스크로 이런 낙오자에 대응했습니다. 이것이 상태 머지에서 가장 중요한 운영상의 과제인 롱테일 문제로 이어집니다.
롱테일 문제: 가장 느린 브랜치가 이긴다
1,000개 항목에 대한 분산 조인에서 전체 실행 시간은 워커의 평균 속도가 아니라 가장 느린 워커의 지연 시간으로 결정됩니다. 999개 항목이 10ms 만에 끝나더라도, 1개 항목이 데이터베이스 락이나 일시적인 네트워크 문제로 10초가 걸리면 워크플로 전체가 10초를 기다립니다.
이것이 롱테일 문제입니다. 순진한 구현에서는 리소스 사용량이 크게 쌓입니다. 999개의 스레드가 마지막 낙오자를 기다리는 동안 메모리를 소비하고, 커넥션을 점유하며, 다른 우선순위 높은 워크플로를 막을 수도 있습니다.
SchemaBridge에서는 대기가 스레드를 붙잡지 않습니다. 각 브랜치는 완료되면 머지의 내구성 있는 조인 상태(PostgreSQL의 조인 상태 행이며, 브랜치 완료는 Valkey Streams를 통해 전달됨)에 도착을 기록하고 즉시 종료합니다. Merge vertex는 CPU를 소비하지 않고 기다립니다. 도착한 브랜치 수가 예상 수에 도달하면 엔진이 머지를 디스패치하고 워크플로가 다음으로 진행됩니다. 이것이 비동기 배리어 동기화이며, 여러 브랜치를 가진 비즈니스 로직이 리소스를 점유하지 않고 몇 분 또는 몇 시간을 기다릴 수 있게 해 줍니다.
늦은 신호 처리: 길 잃은 신호 문제
상태 머지에서 특히 까다로운 장애 유형은 길 잃은 신호(stray signal)입니다. 시스템이 이미 다음으로 넘어간 뒤에 보고하는 브랜치, 또는 브랜치 완료의 중복 전달을 말합니다. 서비스가 죽은 것이 아니라 단지 느렸거나, 메시지가 재전달된 것입니다.
레거시 스크립트에서는 이것이 재앙입니다. 늦게 도착한 신호가 코드에 닿아 이미 진행된 상태를 업데이트하려고 합니다. 그 결과 이중 결제, 손상된 데이터베이스 레코드, 또는 단계의 중복 실행이 일어날 수 있습니다. "완료된 워크플로의 신호는 무시"하기 위한 복잡한 로직을 작성해야 합니다.
SchemaBridge는 특별한 장치가 아니라 일반적인 상태 가드로 이러한 신호를 흡수합니다. 조인은 단 한 번만 디스패치됩니다. 머지 디스패치는 조건부 상태 전이이므로, 두 번째 시작 시도는 거부됩니다. 이미 종료 상태에 도달한 단계는 다시 완료될 수 없으므로, 그에 대한 늦은 완료는 중복으로 기록되고 버려집니다. 또한 단계를 실행하려는 각 시도는 claim 에포크로 보호되므로, claim을 잃은 워커는 작업을 넘겨받은 워커의 결과를 덮어쓸 수 없습니다. 이 중 어느 것도 실행을 exactly-once로 만들지는 않습니다. 전달은 여전히 at-least-once입니다. 하지만 길 잃은 신호가 조인을 두 번 진행시킬 수는 없습니다.
부실한 동기화의 비용
제대로 관리되지 않는 조인은 개발자의 골칫거리 이상입니다. 상품 검색마다 50개 서드파티 공급업체를 대상으로 "가격 집계" 조인을 실행하는 가상의 이커머스 사이트를 생각해 봅시다.
- 순진한 방식: Java 서비스가 50개의 스레드를 띄우고
CountDownLatch를 사용합니다. 공급업체 하나가 p99에서 느리면, 사용자의 브라우저는 그 응답이 올 때까지 멈추고 페이지가 고장 난 것처럼 느껴집니다. - 대기의 비용: 인터랙티브 경로의 지연 시간은 전환율을 떨어뜨린다고 널리 알려져 있습니다. 숫자를 붙이기 전에 자신의 퍼널을 직접 측정하세요.
여기서 워크플로 엔진이 어디에 맞는지에 대해서는 솔직해야 합니다. SchemaBridge MERGE에는 시간 창이 없습니다. 예상되는 모든 브랜치가 도착했을 때 실행되며, "500ms 후 그때까지 돌아온 것만으로" 실행되지 않습니다. 기한이 중요하다면 각 브랜치 자체의 호출 안에 두세요(공급업체 요청에 짧은 타임아웃 설정). 그러면 느린 브랜치도 성공 여부와 관계없이 빠르게 끝납니다. 팬아웃에서 부분 성공은 기한이 아니라, 실패한 브랜치를 제한된 수까지 허용하는 방식으로 표현합니다. 지연 시간이 중요한 검색 페이지는 대개 애플리케이션 자체의 요청 경로에서 처리하고, 캐시 갱신 같은 느린 백그라운드 작업은 워크플로에 맡기는 편이 낫습니다.
머지 전략 비교: Map-Reduce vs. Flow-Sync
| 기능 | Map-Reduce (대규모 배치) | Apache Spark (스트리밍) | SchemaBridge Flow-Sync |
| :--- | :--- | :--- | :--- |
| 초점 | 오프라인 처리 | 준실시간 스트림 | 트랜잭션 비즈니스 로직 |
| 상태 영속성 | 중간 파일 | 체크포인트된 상태 | PostgreSQL의 내구성 있는 조인 상태 |
| 오류 처리 | 실패한 태스크 재실행 | 체크포인트/재시작 | 브랜치별 실패, 재시도 또는 제한된 허용 |
| 조인 로직 | 키 기반 셔플 | 시간 창 조인 | 그래프 기반 의존성 |
| 내구성 | 높음 | 높음(체크포인트 사용 시) | 높음(워커 재시작에도 유지) |
"상태 기반 조인" 마스터클래스: 실제로 어떤 조인이 적용되었나?
모든 머지가 "모두 기다리기"인 것은 아니며, SchemaBridge 조인에 대해 알아야 할 가장 중요한 점은 아무것도 선택하지 않았을 때 어떤 조인이 적용되는가입니다.
1. 기본값: 먼저 도착한 것이 이긴다 (WAIT_ONE)
여러 엣지가 일반 vertex로 모일 때, 그 활성화 전략의 기본값은 WAIT_ONE입니다. vertex는 먼저 도착한 브랜치에 의해 한 번 실행되고, 이후 도착한 것은 버려집니다. 취소되는 것은 없습니다. 느린 브랜치도 끝까지 실행되지만, 그 페이로드는 조인에 도달하지 못합니다. 나머지 두 호출도 여전히 일어난다는 점을 받아들인다면, "세 날씨 제공자 중 가장 빠른 것을 사용"하는 경우에는 정확히 맞는 동작입니다. 하지만 세 결과를 모두 기대하며 다이아몬드를 그렸다면 정확히 틀린 동작이며, 이를 알려 주는 오류도 발생하지 않습니다. 이것은 플랫폼에서 가장 파급력이 큰 기본값이므로, 합류 지점을 그릴 때마다 반드시 확인하세요.
2. 모두를 기다리는 머지(배리어)
모든 브랜치를 수집하려면 MERGE vertex를 사용하거나 활성화 전략을 WAIT_ALL로 설정하세요. 조인은 예상되는 모든 브랜치가 도착했을 때만 실행되며, 페이로드를 결합하는 방식을 선택할 수 있습니다. 하나의 평평한 객체로 덮어쓰기, 브랜치를 키로 하는 맵, 또는 배열입니다.
{
"type": "MERGE",
"config": {
"mergeStrategy": "NAMED_MAP"
}
}
어떤 브랜치든 실패하면 조인은 실행되지 않고 실행(run)은 눈에 보이게 실패합니다. 성공한 브랜치에 대한 자동 롤백은 없으므로, 보상 작업은 명시적으로 모델링해야 합니다. Spawner가 생성한 팬아웃의 경우, 대신 제한된 수의 실패한 자식을 허용하도록 선택할 수 있습니다.
3. 굶주린 조인: 설계 규칙
WAIT_ALL 또는 MERGE vertex를 조건부 브랜치의 하류에 절대 두지 마세요. 조건이 입력 중 하나를 우회하면 그 입력은 결코 도착하지 않고, 조인은 애초에 실행될 일이 없던 브랜치를 영원히 기다립니다. 모두 실행되지 않을 수도 있는 경로들에서 결과를 모아야 한다면, WAIT_ONE을 사용하고 조인 본문에서 누락된 데이터를 처리하거나, 조건을 완전히 한 브랜치 안으로 옮기세요.
분산 머지를 위한 전문가 체크리스트
탄력적인 머지 전략을 구축하려면 다음 원칙을 따르세요.
1. 조인을 명시적으로 선택하세요: 모든 합류는 먼저 도착(WAIT_ONE) 또는 모두 기다리기(MERGE / WAIT_ALL) 중 하나입니다. 기본값을 물려받는 대신 어느 쪽을 의도하는지 결정하세요.
2. 머지가 아니라 각 브랜치를 제한하세요: 머지에는 자체 타임아웃이 없으므로, 각 브랜치 내부의 호출에 기한을 두고 모든 브랜치가 종료 상태에 도달하도록 하세요.
3. 브랜치에 멱등성을 적용하세요: 전달은 at-least-once입니다. 브랜치가 두 번 실행되더라도 부수 효과가 안전하도록 하세요.
4. 조건 분기를 배리어 밖에 두세요: 모두 기다리는 조인에 조건부 경로의 입력을 연결하지 마세요.
5. 상태 크기를 최소화하세요: 불필요한 데이터를 머지에 통과시키지 마세요. 직렬화 비용을 줄이기 위해 다음 단계에 필요한 필드만 가져오세요.
6. 부분 성공을 계획하세요: 모든 비즈니스 로직이 입력의 100%를 필요로 하지는 않습니다. 프로덕트 매니저에게 "진행하는 데 필요한 최소한의 데이터는 무엇인가?"라고 물어보세요.
결론: 머지는 분산의 마지막 개척지
관리되는 동기화 없는 병렬성은 혼돈일 뿐입니다. 조인을 엔진의 내구성 있는 상태로 옮기면, 긴 대기가 스레드를 소모하지 않고, 늦거나 중복된 신호가 조인을 두 번 실행시킬 수 없으며, 실패한 브랜치가 조용히 사라지지 않고 드러나게 됩니다. 엔진이 할 수 없는 것은 여러분이 어떤 조인을 의도했는지 추측하는 일입니다. 그래서 위의 기본값과 설계 규칙은 그 메커니즘만큼이나 중요합니다.
뮤텍스나 래치를 걱정할 필요는 없습니다. 데이터가 성공적으로 다시 모인 후에 일어나는 로직에 집중해야 합니다.
함께 읽기: 멀티 브랜치 흐름과 그 흐름이 사용하는 자격 증명을 보호하는 방법을 다룬 파이프라인 보안: 이벤트 계층의 제로 트러스트, 그리고 애초에 브랜치를 만들어 내는 방법을 다룬 팬아웃 마스터하기.