무엇인가

처리 단계(필터)를 줄로 이어 놓고 데이터를 한 방향으로 흘려보낸다. 각 필터는 앞이 누구고 뒤가 누구인지 모른 채 입력을 받아 출력을 낼 뿐이다.

마스터-슬레이브가 “같은 일을 여럿이 나눠서”라면, 이쪽은 “다른 일을 차례로” 다.

구조

graph LR
    S[원본] --> F1[필터: 소문자화] --> F2[필터: 구두점 제거] --> F3[필터: 공백 정리] --> D[결과]

C++로 보기

필터를 같은 시그니처의 함수로 통일해 두면 줄로 잇는 일이 그냥 합성이 된다.

#include <algorithm>
#include <cctype>
#include <functional>
#include <iostream>
#include <string>
#include <vector>
 
using Filter = std::function<std::string(std::string)>;
 
// 각 필터는 앞뒤를 모른다. 입력 하나 받아 출력 하나 낸다.
std::string toLower(std::string s) {
    std::transform(s.begin(), s.end(), s.begin(),
                   [](unsigned char c) { return static_cast<char>(std::tolower(c)); });
    return s;
}
 
std::string stripPunct(std::string s) {
    s.erase(std::remove_if(s.begin(), s.end(),
                           [](unsigned char c) { return std::ispunct(c) != 0; }),
            s.end());
    return s;
}
 
std::string squeezeSpaces(std::string s) {
    auto isDoubleSpace = [](char a, char b) { return a == ' ' && b == ' '; };
    s.erase(std::unique(s.begin(), s.end(), isDoubleSpace), s.end());
    return s;
}
 
// 파이프: 필터들을 순서대로 통과시킨다.
class Pipeline {
    std::vector<Filter> filters_;
 
  public:
    Pipeline& add(Filter f) {
        filters_.push_back(std::move(f));
        return *this;
    }
    std::string run(std::string input) const {
        for (const auto& f : filters_) input = f(std::move(input));
        return input;
    }
};
 
int main() {
    Pipeline p;
    p.add(toLower).add(stripPunct).add(squeezeSpaces);
 
    std::cout << '[' << p.run("Hello,,  WORLD!!  Nice   To Meet You.") << "]\n";
 
    // 순서를 바꾸거나 단계를 빼는 것이 한 줄이다
    Pipeline q;
    q.add(stripPunct).add(squeezeSpaces);
    std::cout << '[' << q.run("Hello,,  WORLD!!  Nice   To Meet You.") << "]\n";
}

필터가 서로를 모르기 때문에 순서를 바꾸거나 중간에 하나를 끼워 넣는 일이 싸다. 다만 그 대가로 필터 사이에 넘길 수 있는 것이 하나의 타입으로 고정된다. 중간 단계에서 부가 정보를 실어 보내고 싶어지는 순간 이 패턴이 삐걱대기 시작한다.

언제 쓰나

  • 입력을 여러 단계로 변환하고, 그 단계 구성이 바뀔 수 있을 때.
  • 각 단계가 독립적이라 따로 테스트하고 싶을 때.
  • 스트림처럼 데이터가 계속 들어올 때(단계별로 동시에 돌릴 수 있다).

대가

  • 중간에 오류가 나면 어디서 났는지 흐릿하다. 단계마다 맥락을 실어 나르지 않기 때문이다.
  • 단계마다 복사가 생기기 쉽다. 위 예제도 std::string을 값으로 주고받는다 (이동 덕에 최악은 아니지만, 큰 데이터라면 뷰나 참조 설계를 따로 해야 한다).
  • 단계 사이에 상태를 공유해야 하는 요구가 생기면 구조가 무너진다.

실제로 만나는 곳

유닉스 셸 파이프(cat x | grep y | sort), 컴파일러의 전처리 → 파싱 → 최적화 → 코드생성, 이미지·오디오 처리 체인, 그리고 C++20의 ranges 어댑터 체인도 같은 발상이다.