본문 바로가기
TYLER SONGBlog
← Blog index
CS학습5 min read

Pipe: 프로세스의 출력을 다른 프로세스의 입력으로 잇는 단방향 바이트 스트림

파이프(pipe)는 한 프로세스의 표준 출력(stdout)을 다른 프로세스의 표준 입력(stdin)으로 연결하는 커널 내부의 단방향 바이트 버퍼다. 셸의 `|` 연산자로 작은 명령을 조합해 복잡한 처리를 만들 수 있다. 이 글에서는 파일 디스크립터, 버퍼, 블로킹 동작을 중심으로 원리를 설명하고, 셸과 Python 예제로 확인한다.

By 송민성

1. 개념

파이프(pipe)는 커널이 관리하는 메모리 버퍼를 통해 한 프로세스가 쓴 바이트를 다른 프로세스가 읽게 해 주는 단방향 통신 통로다. 프로세스 간 통신(IPC, Inter-Process Communication) 방식 중 가장 단순한 형태다.

  • 한쪽 끝은 쓰기 전용, 반대쪽 끝은 읽기 전용이다.
  • 데이터는 선입선출(FIFO) 순서로 전달된다.
  • 데이터에는 메시지 경계가 없고, 바이트 스트림(byte stream)으로만 취급된다.
  • 파이프는 크게 두 종류다.

- 익명 파이프(anonymous pipe): 셸의 |, pipe() 시스템 콜로 만들며 파일 시스템에 이름이 없다. - 이름 있는 파이프(named pipe, FIFO): mkfifo로 만들며 파일 시스템에 경로가 존재한다.

2. 왜 사용하는가

  • 조합성: grep, sort, wc처럼 한 가지 일만 하는 도구를 이어 붙여 새로운 기능을 만든다. 유닉스 철학의 핵심이다.
  • 임시 파일 제거: 중간 결과를 디스크에 저장하지 않아도 된다.
  • 동시 실행: 파이프라인의 각 프로세스가 동시에 실행되므로 앞 단계가 끝나기 전에도 뒤 단계가 데이터를 처리하기 시작한다.
  • 메모리 절약: 전체 데이터를 한꺼번에 올리지 않고 흘려보내며 처리할 수 있다.

3. 동작 원리

파일 디스크립터

리눅스 프로세스는 기본적으로 세 개의 파일 디스크립터(file descriptor)를 가진다.

번호이름용도
0stdin표준 입력
1stdout표준 출력
2stderr표준 에러

셸이 a | b를 실행하는 과정

  1. 셸이 pipe() 시스템 콜로 읽기 끝(fd)과 쓰기 끝(fd) 한 쌍을 만든다.
  2. 각 명령을 위해 fork()로 자식 프로세스를 만든다.
  3. a의 자식은 dup2()로 자신의 stdout(1번)을 파이프의 쓰기 끝으로 교체한다.
  4. b의 자식은 dup2()로 자신의 stdin(0번)을 파이프의 읽기 끝으로 교체한다.
  5. 각자 exec()로 해당 프로그램을 실행한다. 프로그램은 파이프인지 모른 채 stdin/stdout만 사용한다.

버퍼와 블로킹

  • 파이프 버퍼가 가득 차면 쓰는 쪽은 블로킹(blocking)되어 읽는 쪽이 비울 때까지 기다린다.
  • 버퍼가 비어 있으면 읽는 쪽은 데이터가 올 때까지 기다린다.
  • 쓰기 끝이 모두 닫히면 읽는 쪽은 EOF(read가 0 반환)를 받는다.
  • 읽기 끝이 모두 닫힌 파이프에 쓰면 쓰는 프로세스는 SIGPIPE 시그널을 받으며, 기본 동작은 프로세스 종료다.
  • 리눅스의 기본 파이프 용량은 64KiB(65536바이트)다. fcntl의 F_SETPIPE_SZ로 조정할 수 있다.

4. 코드 예제

셸 파이프라인

shell
# 가장 많이 등장한 단어 상위 3개 printf 'apple\nbanana\napple\ncherry\nbanana\napple\n' \ | sort \ | uniq -c \ | sort -rn \ | head -n 3

이름 있는 파이프 (FIFO)

shell
mkfifo /tmp/demo_fifo # 백그라운드에서 읽기 대기 cat /tmp/demo_fifo & # 쓰기 (읽는 쪽이 있어야 open이 진행됨) echo "hello fifo" > /tmp/demo_fifo wait rm /tmp/demo_fifo

Python으로 pipe() 직접 사용

python
import os read_fd, write_fd = os.pipe() pid = os.fork() if pid == 0: # 자식: 읽기만 하므로 쓰기 끝을 닫는다 os.close(write_fd) with os.fdopen(read_fd) as r: print("자식이 받음:", r.read()) os._exit(0) else: # 부모: 쓰기만 하므로 읽기 끝을 닫는다 os.close(read_fd) with os.fdopen(write_fd, "w") as w: w.write("부모가 보낸 메시지") os.waitpid(pid, 0)

Python subprocess로 파이프라인 구성

python
import subprocess p1 = subprocess.Popen(["printf", "b\\na\\nc\\na\\n"], stdout=subprocess.PIPE) p2 = subprocess.Popen(["sort"], stdin=p1.stdout, stdout=subprocess.PIPE) p1.stdout.close() # p2만 읽도록 부모의 복사본을 닫는다 out, _ = p2.communicate() print(out.decode())

5. 시간 복잡도 또는 성능 특성

  • 파이프를 통한 전송은 데이터 크기 n에 대해 복사 비용이 O(n)이다. 쓰는 쪽에서 커널 버퍼로, 커널 버퍼에서 읽는 쪽으로 복사가 일어난다.
  • 디스크를 거치지 않으므로 임시 파일 방식보다 일반적으로 I/O 부담이 적다.
  • 버퍼 크기(기본 64KiB)보다 많은 데이터를 보내면 읽는 쪽 속도에 맞춰 쓰는 쪽이 대기한다. 즉 파이프라인 전체 처리량은 가장 느린 단계에 의해 제한된다.
  • 시스템 콜(read, write) 호출 횟수가 많아지면 오버헤드가 생기므로, 작은 조각을 자주 보내기보다 적당한 크기로 묶어 보내는 편이 유리하다.

6. 실무 사용 사례

shell
# 로그에서 5xx 에러 수 세기 grep ' 5[0-9][0-9] ' access.log | wc -l # 실행 중인 프로세스에서 특정 이름 찾기 ps aux | grep nginx # 큰 파일을 압축하며 원격으로 전송 tar -cf - ./project | gzip | ssh user@host 'cat > project.tar.gz' # JSON 응답에서 필드 추출 curl -s https://api.example.com/users | jq '.[].name'
  • 로그 분석, 배포 스크립트, CI 파이프라인에서 명령 조합에 널리 쓰인다.
  • 도커 컨테이너 로그를 docker logs app 2>&1 | grep ERROR처럼 필터링할 때도 쓴다.

7. 주의할 점

  • stderr는 기본적으로 파이프를 타지 않는다. |는 stdout만 연결한다. 에러까지 넘기려면 2>&1 | 또는 bash의 |&를 쓴다.
  • 종료 코드는 마지막 명령 기준이다. 중간 명령이 실패해도 $?는 마지막 명령의 값이다. bash에서는 set -o pipefail을 설정하면 파이프라인 중 실패한 명령의 코드가 반영된다.
shell
set -o pipefail false | true echo $? # pipefail 적용 시 1
  • 파이프 안의 명령은 서브셸에서 실행된다. bash에서 echo hi | read x처럼 쓰면 x가 현재 셸에 남지 않는다.
  • 교착 상태(deadlock) 위험: 부모가 쓰기 끝이나 읽기 끝을 닫지 않으면 EOF가 도착하지 않아 상대가 영원히 대기할 수 있다. 사용하지 않는 fd는 반드시 닫는다.
  • SIGPIPE: yes | head -n 1처럼 읽는 쪽이 먼저 종료하면 쓰는 쪽은 SIGPIPE로 종료된다. 이는 정상 동작이다.
  • 불필요한 `cat`: cat file | grep x는 grep x file로 충분하다.
  • 단방향이므로 양방향 통신이 필요하면 파이프를 두 개 만들거나 소켓을 사용한다.

8. 핵심 정리

  • 파이프는 커널 버퍼로 연결된 단방향 FIFO 바이트 스트림이다.
  • 셸의 a | b는 pipe(), fork(), dup2(), exec()의 조합으로 동작한다.
  • 버퍼가 가득 차면 쓰는 쪽이, 비면 읽는 쪽이 블로킹된다. 리눅스 기본 용량은 64KiB다.
  • 쓰기 끝이 닫히면 EOF, 읽기 끝이 닫히면 SIGPIPE가 발생한다.
  • |는 stdout만 연결하며, 종료 코드 확인에는 set -o pipefail을 함께 사용한다.
  • 사용하지 않는 파이프 끝은 반드시 닫아야 교착을 피할 수 있다.