Software Architecture/Architectural Styles

Architectural Styles - 10. 파이프라인

파이프라인 아키텍처 — 단계별로 흐르는 구조

터미널에서 한 번쯤 이런 명령을 쳐봤을 것이다.

cat access.log | grep "404" | awk '{print $1}' | sort | uniq -c | sort -rn | head -20

이 한 줄은 다섯 개의 프로그램(cat, grep, awk, sort, uniq)이 '파이프'(|)로 연결돼, 앞의 출력이 뒤의 입력으로 흘러간다. 각 프로그램은 자기 일만 하고, 다른 단계가 뭘 하는지 모른다. 이 간결한 구조가 파이프라인(pipeline) 또는 파이프-필터(pipe-filter) 아키텍처 스타일이다. Doug McIlroy가 1964년에 제안하고 1973년 Unix에 구현된 이후로, 데이터 처리 시스템의 자연스러운 형태가 됐다.

이 글이 다루는 질문: 시스템을 '계층'이 아니라 '단계'로 자르면 어떤 이점과 비용이 생기나.

비유로 감 잡기 — 공장 컨베이어 벨트

자동차 공장을 상상하자. 프레임 조립 → 엔진 장착 → 도색 → 내장 → 품질 검사의 컨베이어 라인이다. 각 공정은 이전 공정의 결과를 받아 자기 일을 하고, 다음 공정으로 넘긴다. 엔진 장착 공정은 프레임이 어떻게 조립됐는지 모른다 — 그냥 "프레임이 왔으니 엔진을 얹는다". 도색 공정은 엔진이 어떻게 장착됐는지 모른다 — "프레임+엔진이 왔으니 칠한다".

파이프라인 아키텍처의 본질이 이 비유에 있다. 각 단계(filter)는 앞의 출력을 입력으로 받아 변환하고, 결과를 다음 단계로 넘긴다. 단계 사이는 '파이프'로 연결된다. 단계는 자기 앞뒤 단계가 뭘 하는지 모른다 — 그저 정해진 입력 형식을 받고 정해진 출력 형식을 낸다.

이 비유가 가르치는 핵심 — 각 단계가 독립적이라서, 한 단계를 바꾸거나 교체하거나 새 단계를 끼워 넣기 쉽다. 도색을 '분체 도색'에서 '전기 도장'으로 바꿔도, 앞뒤 공정은 그대로. 이게 파이프라인의 결합도 낮춤이다.

비유의 한계 — 컨베이어 비유는 '물리적 흐름'을 강조하지만, 소프트웨어 파이프라인은 데이터의 변환이다. 또한 공장은 동기(한 공정이 끝나야 다음)인 반면, 소프트웨어 파이프라인은 비동기(버퍼를 두고 병렬 처리)가 가능. 비유는 '물리적 제약'에서 무너진다.

파이프라인의 네 요소

Mary Shaw과 David Garlan이 (1996)에서 정리한 파이프-필터 스타일의 요소는 단순하다.

  • 필터(filter) — 입력을 받아 변환하고 출력을 내는 독립적 단계. 앞뒤 단계를 모른다.
  • 파이프(pipe) — 필터 사이의 데이터 통로. 한 필터의 출력을 다른 필터의 입력으로 전달.
  • 데이터 흐름(directional) — 보통 단방향. 드물게 양방향도 있지만 복잡해짐.
  • 버퍼(buffer) — 파이프에 있는 데이터 임시 저장. 비동기 처리를 가능하게 함.
flowchart LR
    SRC[데이터 소스] -->|pipe| F1[Filter 1<br/>정제]
    F1 -->|pipe| F2[Filter 2<br/>변환]
    F2 -->|pipe| F3[Filter 3<br/>집계]
    F3 -->|pipe| F4[Filter 4<br/>출력 포맷]
    F4 --> SINK[결과 저장]
    F1 x--x F2
    F2 x--x F3

각 필터가 다른 필터의 존재를 모른다는 점이 결합도를 낮춘다. Filter 2를 교체해도 Filter 1과 Filter 3은 입력/출력 형식만 맞으면 영향받지 않는다.

파이프라인이 자연스러운 영역 — 데이터 처리

파이프라인은 특정 영역에서 자연스러운 형태다. 특히 '데이터 처리' 시스템에서.

  • ETL(Extract-Transform-Load) — 데이터 웨어하우스 적재. 추출 → 정제 → 변환 → 적재.
  • 로그 처리 — 로그 수집 → 정규화 → 필터링 → 집계 → 시각화.
  • ML 파이프라인 — 데이터 수집 → 전처리 → 특징 추출 → 학습 → 평가 → 배포.
  • CI/CD — 빌드 → 테스트 → 정적 분석 → 패키지 → 배포.
  • Unix 파이프 — 앞서 본 cat | grep | sort의 전통.

이런 시스템에선 데이터가 단계별로 흘러가는 게 자연스럽다. 역할별 계층(UI/비즈니스/데이터)으로 자르는 것보다, 처리 단계별로 자르는 게 직관적이다. Richards-Ford가 (2020)에서 파이프라인을 "데이터 처리의 자연스러운 스타일"로 분류하는 이유다.

동기식 vs 비동기식 파이프라인

파이프라인은 두 형태로 나타난다.

동기식 (batch)

한 단계가 끝나야 다음 단계로. 전통적인 배치 처리.

// 동기식 — 각 단계가 끝나야 다음 단계
class LogPipeline {
    void run() {
        List<Log> raw = extract();           // 1. 추출 (완료 대기)
        List<Log> cleaned = clean(raw);      // 2. 정제 (완료 대기)
        List<Stat> stats = aggregate(cleaned); // 3. 집계 (완료 대기)
        save(stats);                          // 4. 저장
    }
}

단순하지만, 한 단계가 느리면 전체가 느려진다. extract가 10분 걸리면 그 10분 동안 clean 단계는 쉰다.

비동기식 (streaming)

단계들이 버퍼를 두고 병렬로 동작. 한 단계가 출력을 버퍼에 밀어 넣으면, 다음 단계가 버퍼에서 가져와 처리. Kafka 같은 메시지 브로커가 흔히 버퍼 역할을 한다.

// 비동기식 — 각 단계가 병렬로 동작
class LogPipeline {
    // 각 단계가 별개 컨슈머, Kafka 토픽이 버퍼
    void start() {
        // Extract → "raw-logs" 토픽
        kafkaProducer.send("raw-logs", fetchLogs());

        // Clean 단계가 "raw-logs" 구독, "cleaned-logs"에 출력
        @KafkaListener(topics = "raw-logs")
        void clean(Log raw) {
            Log cleaned = cleanLog(raw);
            kafkaProducer.send("cleaned-logs", cleaned);
        }

        // Aggregate 단계가 "cleaned-logs" 구독, "stats"에 출력
        @KafkaListener(topics = "cleaned-logs")
        void aggregate(Log cleaned) {
            Stat stat = computeStat(cleaned);
            kafkaProducer.send("stats", stat);
        }
    }
}
flowchart LR
    E[Extract] -->|raw-logs 토픽| C[Clean]
    C -->|cleaned-logs 토픽| A[Aggregate]
    A -->|stats 토픽| S[Save]
    E -.병렬 동작.- C
    C -.병렬 동작.- A

비동기식의 장점 — 느린 단계가 있으면 그 단계만 확장(인스턴스 추가)하면 된다. extract가 느리면 extract 인스턴스만 늘리고, aggregate가 빠르면 한 대로 충분. 전체 처리량이 가장 느린 단계에 맞춰지긴 하지만 (병목 이론), 각 단계를 독립 확장할 수 있다.

단점 — 복잡성 증가, 메시지 순서·중복·유실 처리. 동기식에선 자연스럽던 것(한 단계 실패 시 전체 실패)이 비동기식에선 개별 처리 돼야 한다.

설계 사례 — 주문 분석 파이프라인

전자상거래의 주문 데이터를 분석하는 파이프라인을 설계해 본다. 원시 주문 이벤트를 받아 일간 리포트를 만드는 흐름.

1단계 — 동기식 단일 프로세스 (단순하지만 비효율)

class OrderAnalyticsJob {
    void runDaily() {
        // 5단계가 한 프로세스에서 직렬 실행
        List<OrderEvent> events = eventStore.findAll(yesterday());
        List<OrderEvent> validated = events.stream()
            .filter(this::isValid)
            .toList();
        Map<LocalDate, DailyStat> grouped = validated.stream()
            .collect(groupingBy(OrderEvent::date, summarizing()));
        List<DailyReport> reports = grouped.entrySet().stream()
            .map(this::toReport)
            .toList();
        reportRepo.saveAll(reports);
        notify(reports);
    }
}

이 코드는 작동하지만 — 데이터가 늘어나면 전부 메모리에 들어야 하고, 한 단계가 느리면 전체가 밀린다. 하루 1억 건이면 동작이 안 된다.

2단계 — 분산 파이프라인 (Kafka + 마이크로서비스)

// Filter 1: 정제 (Validate)
@Service
class OrderValidationFilter {
    @KafkaListener(topics = "raw-order-events", groupId = "validator")
    void handle(OrderEvent event) {
        if (validator.isValid(event)) {
            kafka.send("validated-orders", event);
        } else {
            kafka.send("invalid-orders", event);  // 별도 흐름
        }
    }
}

// Filter 2: 보강 (Enrich — 고객 정보 등 추가)
@Service
class OrderEnrichmentFilter {
    @KafkaListener(topics = "validated-orders", groupId = "enricher")
    void handle(OrderEvent event) {
        CustomerInfo info = customerClient.findById(event.customerId());
        EnrichedOrder enriched = event.with(info);
        kafka.send("enriched-orders", enriched);
    }
}

// Filter 3: 집계 (Aggregate — 시간 창별 그룹화)
@Service
class OrderAggregationFilter {
    private final WindowedAggregator<EnrichedOrder, DailyStat> aggregator;

    @KafkaListener(topics = "enriched-orders", groupId = "aggregator")
    void handle(EnrichedOrder order) {
        // 일 단위 윈도우로 집계
        Optional<DailyStat> emitted = aggregator.add(order);
        if (emitted.isPresent()) {
            kafka.send("daily-stats", emitted.get());
        }
    }
}

// Sink: 저장 + 알림
@Service
class DailyStatSink {
    @KafkaListener(topics = "daily-stats", groupId = "sink")
    void handle(DailyStat stat) {
        statRepo.save(stat);
        if (stat.totalOrders() > THRESHOLD) {
            alertClient.send("주문 폭증 알림: " + stat.totalOrders());
        }
    }
}
flowchart LR
    R[raw-order-events 토픽] --> V[Validate Filter]
    V -->|validated-orders 토픽| E[Enrich Filter]
    V -->|invalid-orders 토픽| X[에러 처리]
    E -->|enriched-orders 토픽| A[Aggregate Filter]
    A -->|daily-stats 토픽| S[Stat Sink]
    S --> DB[(Report DB)]
    S --> ALERT[Alert]

각 단계가 별도 서비스로, Kafka 토픽이 파이프(버퍼) 역할을 한다. 정제 단계가 느려지면 정제 서비스만 여러 인스턴스로 확장. 보강 단계가 외부 API 호출 때문에 병목이면, 거기만 회복탄력성 패턴(retry, circuit breaker) 적용. 각 단계가 독립적이라 이런 조정이 가능하다.

단계 독립성 검증 — 결합도 감지

신호 의미
한 필터가 다른 필터의 내부 구현을 앎 결합 — 필터가 독립적이지 않음
한 필터 변경이 다른 필터의 재배포로 이어짐 강결합 — 파이프(데이터 형식)로만 연결 안 됨
필터가 입력 형식 외의 것에 의존 컨텍스트 누출
테스트하려면 다른 필터가 살아있어야 함 필터 간 강결합
데이터 형식이 단계별로 안정적이지 않음 파이프 계약 불안정

필터는 '입력 형식과 출력 형식'만 알아야 한다. 그 이상을 알면 결합된 것이다.

파이프라인의 비용과 한계

파이프라인이 '항상 좋은' 스타일은 아니다. 비용이 뚜렷하다.

  • 오류 처리 복잡 — 한 단계가 실패하면, 그 단계의 입력은 어디로 가나. 재시도, dead-letter queue(DLQ), 역방향 처리 등을 설계해야.
  • 순서 보장 어려움 — 비동기 파이프라인에선 이벤트 순서가 보장되지 않을 수 있다. 순서가 중요한 집계엔 추가 설계 필요.
  • end-to-end 지연 — 여러 단계를 거치므로 입력부터 출력까지의 지연이 커진다. 실시간(밀리초)엔 부적합.
  • 디버깅 어려움 — 한 이벤트가 여러 단계를 거치면서 어디서 잘못됐는지 추적이 어렵다. 분산 추적 필수.
  • 과잉 설계 위험 — 단순한 처리를 파이프라인으로 만들면 복잡성만 증가. "한 번에 끝나는 일을 굳이 여러 단계로"는 안티패턴.

파이프라인 적합/부적합 판단표

조건 파이프라인 추천 이유
데이터가 여러 변환 단계를 거침 추천 자연스러운 형태
단계별로 처리량이 다름 추천 각 단계 독립 확장 가능
단계별로 독립적인 장애 허용 추천 한 단계 장애가 다른 단계에 전파 제한
데이터가 단순 CRUD 부적합 단계가 의미 없음
강한 트랜잭션 일관성 필요 부적합 파이프라인은 eventual consistency
사용자 요청-응답 (동기식 UI) 부적합 파이프라인은 비동기 배치/스트림에 적합
단계가 1~2개뿐 부적합 파이프라인 오버헤드만 남음

다른 스타일과의 비교

파이프라인은 앞의 스타일들과 축이 다르다.

  • 계층형·헥사고날·클린 — 시스템을 '역할별(UI/비즈니스/데이터)'로 자름. 한 요청이 모든 계층을 통과.
  • 마이크로서비스 — 시스템을 '비즈니스 능력별'로 자름. 한 요청이 보통 하나의 서비스 안에서 끝.
  • 이벤트 기반·CQRS·이벤트 소싱 — 시스템을 '상태와 시간'으로 모델링.
  • 파이프라인 — 시스템을 '데이터 처리 단계별'로 자름. 데이터가 단계를 따라 흐름.

한 시스템이 여러 스타일을 섞을 수 있다. 예를 들어 마이크로서비스 아키텍처 내부의 한 서비스가 파이프라인 형태(데이터 처리 서비스)일 수 있다. 계층형 안의 비즈니스 로직이 이벤트 소싱으로 구현될 수 있다. 스타일은 '전체 시스템'에만 적용되는 게 아니라, '서브 시스템'에도 적용된다.

Unix 파이프의 교훈 — 작고 조합 가능한 도구

파이프라인 스타일의 원형인 Unix 파이프가 가르치는 교훈은 "작고 조합 가능한 도구"다. grep은 텍스트에서 패턴을 찾는 일만, sort는 정렬만, uniq는 중복 제거만. 각 도구는 한 일만 잘하지만, 파이프로 연결하면 복잡한 작업이 된다. 이 철학은 Eric Raymond가 (2003)에서 정리한 'Unix 철학'의 한 축이다.

현대의 데이터 처리(Apache Beam, Spark, Flink), CI/CD(GitHub Actions, GitLab CI), ML 파이프라인(Kubeflow, Airflow)이 모두 이 철학을 잇는다. 각 단계는 작고 독립적, 결합은 파이프(데이터 또는 메시지)로만. 파이프라인이 60년이 지난 지금도 데이터 처리의 자연스러운 형태인 이유다.

다음으로 — 마이크로커널과 서버리스

파이프라인이 '데이터 흐름'을 자르는 스타일이었다면, 다음 편의 마이크로커널(microkernel)과 서버리스(serverless)는 '확장 구조'를 다루는 스타일이다. 코어는 최소로 두고, 확장 기능을 플러그인 형태로 끼워 넣는 구조. IDE(Eclipse), 웹 브라우저(Firefox), 커널(Linux)이 마이크로커널 사상을, AWS Lambda 같은 FaaS가 서버리스 형태를 보여준다.

파이프라인의 핵심 통찰은 "데이터 처리는 단계별로 자르는 게 자연스럽다"는 것이다. 역할별로 자르는 것(계층형)과 다른 축의 자르기이며, 데이터 중심 시스템에선 이 쪽이 훨씬 직관적이다.


참고

  • Shaw, Garlan — (Prentice Hall, 1996), 파이프-필터 스타일 장
  • Richards, Ford — (O'Reilly, 2020), Ch.6 (파이프라인)
  • Raymond — (Addison-Wesley, 2003), Unix 파이프 철학
  • Dean, Ghemawat — "MapReduce: Simplified Data Processing on Large Clusters" (OSDI 2004) — 접근 2026-07-20
  • Fowler — (Addison-Wesley, 2002), 데이터 변환 패턴