무엇인가
하나(마스터)가 일을 쪼개 여럿(슬레이브)에게 나눠 주고, 돌아온 결과를 합쳐 최종 답을 만든다. 슬레이브끼리는 서로 모르고, 오직 마스터하고만 이야기한다.
이름에 대해. 요즘은 이 용어를 피하고
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에 커널을 뿌리는 방식도 이 모양이다.