무엇인가

하나(마스터)가 일을 쪼개 여럿(슬레이브)에게 나눠 주고, 돌아온 결과를 합쳐 최종 답을 만든다. 슬레이브끼리는 서로 모르고, 오직 마스터하고만 이야기한다.

이름에 대해. 요즘은 이 용어를 피하고 primary/replica, coordinator/worker, leader/follower로 부르는 쪽이 많다. 여러 프로젝트가 실제로 용어를 바꿨으므로 새로 쓰는 코드에는 그쪽을 쓰는 편이 낫다. 여기서는 패턴 이름으로만 남긴다.

구조

graph TD
    M[마스터<br/>일을 쪼개고 결과를 합친다]
    M -->|조각 1| W1[워커 1]
    M -->|조각 2| W2[워커 2]
    M -->|조각 3| W3[워커 3]
    W1 -->|부분 결과| M
    W2 -->|부분 결과| M
    W3 -->|부분 결과| M

C++로 보기

큰 배열의 합을 구하는 전형적인 분할 작업이다. 마스터는 나누고 합치는 일만 하고, 워커는 자기 조각만 본다.

#include <iostream>
#include <numeric>
#include <thread>
#include <vector>
 
// 워커: 자기 몫만 계산해 자기 칸에 써 넣는다.
void worker(const std::vector<long long>& data, std::size_t begin, std::size_t end,
            long long& slot) {
    slot = std::accumulate(data.begin() + begin, data.begin() + end, 0LL);
}
 
// 마스터: 쪼개고, 뿌리고, 기다리고, 합친다.
long long parallelSum(const std::vector<long long>& data, unsigned workers) {
    if (workers == 0) workers = 1;
    std::vector<long long> partial(workers, 0);
    std::vector<std::thread> threads;
 
    const std::size_t chunk = data.size() / workers;
    for (unsigned i = 0; i < workers; ++i) {
        std::size_t begin = i * chunk;
        std::size_t end = (i + 1 == workers) ? data.size() : begin + chunk;
        threads.emplace_back(worker, std::cref(data), begin, end, std::ref(partial[i]));
    }
    for (auto& t : threads) t.join();
 
    return std::accumulate(partial.begin(), partial.end(), 0LL);
}
 
int main() {
    std::vector<long long> data(1'000'000);
    std::iota(data.begin(), data.end(), 1);   // 1..1000000
 
    std::cout << "1 워커: " << parallelSum(data, 1) << '\n';
    std::cout << "4 워커: " << parallelSum(data, 4) << '\n';
    std::cout << "정답  : " << 1'000'000LL * 1'000'001LL / 2 << '\n';
}

워커마다 자기 칸(partial[i])에만 쓰기 때문에 락이 없다. 공유 누적 변수 하나에 모두가 더하면 그 순간 경쟁이 생기고, 락을 걸면 병렬화한 의미가 사라진다. 나눌 때 겹치지 않게 나누는 것이 이 패턴의 실제 기술이다.

언제 쓰나

  • 일이 독립적인 조각으로 갈리는 계산(합계, 정렬, 렌더링, 검색).
  • 같은 계산을 여러 곳에서 돌려 결과를 비교해야 할 때(내결함성).
  • 읽기 부하를 복제본으로 분산할 때(DB의 primary/replica).

대가

  • 조각이 고르지 않으면 가장 느린 워커를 기다린다. 워커를 늘려도 그만큼 안 빨라진다.
  • 마스터가 병목이자 단일 장애점이다. 쪼개고 합치는 비용이 계산 비용을 넘으면 손해다.
  • 조각 사이에 의존이 있으면 이 패턴이 안 맞는다. 그때는 파이프-필터 쪽을 본다.

분산 ID 발급기를 만들며 잰 것이 이 지점과 닿는다. 공유 생성기 하나에 여러 스레드가 붙으면 스레드를 늘려도 처리량이 안 늘었고, 스레드마다 독립 노드를 준 뒤에야 8배 가까이 늘었다. 위 예제가 워커마다 칸을 따로 준 것과 같은 이야기다.

실제로 만나는 곳

MapReduce, 데이터베이스 복제, 빌드 시스템의 병렬 컴파일(make -j), 그리고 GPU에 커널을 뿌리는 방식도 이 모양이다.