포스트

프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지

프로세스 간 통신(IPC) 완전정리 — 파이프, 시그널, 메시지 큐, 공유 메모리, 유닉스 소켓의 동작 원리와 복사 비용·zero-copy 트레이드오프를 비교합니다.

프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지

“벽을 세워놨더니, 이제 대화를 못 한다”

운영체제는 프로세스마다 독립된 주소 공간을 줍니다. A 프로세스의 포인터 0x7fff...와 B 프로세스의 같은 주소는 전혀 다른 물리 메모리를 가리킵니다. 이 벽 덕분에 한 프로세스의 버그가 옆 프로세스를 못 건드리죠 — 보호의 핵심입니다.

그런데 현실의 프로그램은 협력해야 합니다. 셸의 ps aux | grep nginx는 ps의 출력이 grep의 입력으로 흘러가야 하고, 웹 서버는 워커 프로세스끼리 캐시를 공유해야 합니다. 벽을 세워 격리해 놓고, 그 벽에 정해진 창구를 뚫는 것 — 그게 프로세스 간 통신(IPC)입니다.

그래서 IPC의 본질은 언제나 같은 긴장입니다: 격리는 유지하면서, 어떻게 안전하고 빠르게 데이터를 건네줄 것인가. 이 글은 파이프·시그널·메시지 큐·공유 메모리·소켓을 하나씩 비교하되, “API 목록”이 아니라 각 방식이 복사 비용과 동기화 부담을 어디에 두는가라는 한 축으로 꿰뚫습니다.

두 개의 축: “어떻게 건네나”와 “언제 건네나”

모든 IPC 메커니즘은 두 질문에 대한 서로 다른 답입니다.

  • 데이터 전달 방식 — 복사 기반(메시지가 커널을 거쳐 양쪽에 따로 존재)이냐, 공유 기반(같은 물리 메모리를 양쪽이 직접 보느냐).
  • 동기화 방식 — 보내는 쪽과 받는 쪽의 박자를 어떻게 맞추나. 받을 데이터가 없으면 블로킹? 버퍼가 차면 블로킹? 신호 한 번 툭 던지고 끝?

이 두 축으로 보면 난해해 보이던 API들이 한 줄에 정렬됩니다.

방식전달속도동기화방향/범위대표 용도
파이프 / FIFO복사(커널 버퍼)보통블로킹 내장단방향 스트림셸 파이프라인, 부모-자식
시그널거의 없음(번호만)빠름비동기 인터럽트알림종료·타이머·자식 종료 통지
메시지 큐복사(커널)보통경계 보존·우선순위다대다 메시지작업 큐, 이벤트
공유 메모리복사 없음가장 빠름직접 해야 함영역 공유대용량·고빈도 데이터
소켓(UDS)복사(커널)보통블로킹/논블로킹양방향, 머신 간 확장서비스 간 통신
mmap 파일복사 없음(페이지캐시 공유)빠름직접 해야 함파일 매개 공유DB·로그·대용량 파일

핵심 직관 하나만 미리: 빠를수록 위험합니다. 공유 메모리는 복사가 없어 가장 빠르지만, 커널이 박자를 맞춰주지 않으니 동기화를 직접 책임져야 합니다(→ [8편 동기화]). 파이프는 느린 대신 커널이 블로킹·백프레셔를 알아서 해줍니다.

파이프: 커널 버퍼 위의 단방향 스트림

가장 친숙한 IPC. ps | grep의 |가 바로 익명 파이프입니다. 커널 안에 고정 크기 링 버퍼(리눅스 기본 64KB)를 만들고, 한쪽 끝(write end)에 쓰면 다른 쪽 끝(read end)에서 읽힙니다. 단방향이고, 바이트 스트림이라 메시지 경계가 없습니다.

여기서 IPC의 동기화가 처음 등장합니다. 아래에서 Writer가 바이트를 커널 버퍼에 채워 넣고 Reader가 빼 갑니다. 생산이 소비보다 빠르면 버퍼가 가득 차고, 그 순간 Writer는 블로킹됩니다 — 커널이 강제로 속도를 맞추는 백프레셔(backpressure)입니다. 반대로 버퍼가 비면 Reader가 블로킹됩니다.

Writer write(fd, ...) ⛔ BLOCKED (버퍼 가득) 커널 파이프 버퍼 (고정 크기·64KB) Reader read(fd, ...) 채움 → 가득 차면 Writer 블로킹 → Reader가 빼 가면 다시 진행 (백프레셔)

파이프에서 꼭 기억할 두 가지. (1) 양쪽 다 블로킹될 수 있다 — 이게 동기화를 공짜로 얻는 이유입니다. 생산-소비 속도를 커널이 맞춰줍니다. (2) 읽는 쪽이 모두 닫힌 파이프에 쓰면 SIGPIPE 가 날아옵니다(기본 동작은 프로세스 종료). yes | head 가 깔끔히 끝나는 것도 이 메커니즘 덕입니다. 익명 파이프는 부모-자식처럼 fd를 물려받는 관계에서만 쓰고, 무관한 프로세스끼리는 이름 있는 named pipe(FIFO, mkfifo)를 씁니다.

1
2
3
4
5
6
7
8
9
10
int fd[2];
pipe(fd);                 // fd[0]=읽기, fd[1]=쓰기
if (fork() == 0) {        // 자식: 표준입력을 파이프 읽기 끝으로
    dup2(fd[0], STDIN_FILENO);
    close(fd[1]);
    execlp("grep", "grep", "nginx", NULL);
}
// 부모: 표준출력을 파이프 쓰기 끝으로 → 이게 셸의 `… | grep nginx`
dup2(fd[1], STDOUT_FILENO);
close(fd[0]);

시그널: 데이터가 아니라 “알림”

시그널은 IPC 중에서도 특이합니다. 데이터를 거의 안 나릅니다 — 보내는 건 번호 하나(SIGTERM=15, SIGKILL=9 …). 비동기적으로 프로세스의 정상 흐름을 인터럽트해 등록된 핸들러로 점프시키죠. “끝낼 시간이야”(SIGTERM), “자식이 죽었어”(SIGCHLD), “타이머 만료”(SIGALRM) 같은 알림에 적합합니다.

현실 체크 — “시그널 핸들러 안에서는 거의 아무것도 하면 안 된다.” 핸들러는 프로그램이 임의의 지점에서 멈춘 채 끼어듭니다. 그 순간 malloc이 내부 락을 쥔 상태였다면, 핸들러에서 또 malloc을 부르는 순간 데드락입니다(printf도 내부적으로 위험). 그래서 핸들러에서 부를 수 있는 함수는 async-signal-safe 목록(write, _exit 등)으로 한정됩니다. 실무 정석은 self-pipe trick 또는 signalfd/eventfd: 핸들러에선 파이프에 1바이트만 write 하고, 진짜 처리는 메인 이벤트 루프에서 합니다.

1
2
3
kill -l           # 시그널 번호↔이름 전체 목록
kill -TERM 1234   # 정중히 종료 요청(핸들러가 가로채 정리 가능)
kill -9 1234      # SIGKILL — 가로챌 수 없음, 커널이 즉시 회수

메시지 큐: 경계가 보존되는 복사

파이프가 바이트 스트림(경계 없음)이라면, 메시지 큐는 메시지 단위로 넣고 뺍니다. “100바이트 메시지 3개”를 넣으면 정확히 그 3개로 나옵니다 — 수신 측에서 직접 파싱해 경계를 복원할 필요가 없죠. POSIX mq_*는 우선순위도 지원해, 급한 메시지를 먼저 빼낼 수 있습니다. 다만 전달 시 커널 버퍼로 한 번 복사됩니다.

공유 메모리: 복사를 없애면 속도가 폭발한다 (그리고 위험도)

지금까지 본 파이프·메시지 큐는 모두 데이터가 커널을 거칩니다: 보내는 프로세스의 버퍼 → 커널 → 받는 프로세스의 버퍼. 즉 복사가 2번입니다. 작은 데이터면 무시할 만하지만, 초당 수 GB를 주고받는다면 이 복사가 곧 병목입니다.

공유 메모리는 발상을 뒤집습니다. 같은 물리 페이지를 두 프로세스의 주소 공간에 동시에 매핑해, 한쪽이 쓴 걸 다른 쪽이 그 자리에서 즉시 봅니다. 커널을 거치지 않으니 복사가 0번 — IPC 중 가장 빠릅니다.

아래에서 위쪽(메시지 전달)은 데이터가 커널을 경유하며 복사가 두 번 일어나고, 아래쪽(공유 메모리)은 A가 쓴 값을 B가 같은 페이지에서 곧장 읽습니다.

① 메시지 전달 — 커널 경유, 복사 2회 Process A 커널 버퍼 Process B 복사 1 복사 2 ② 공유 메모리 — 같은 물리 페이지, 복사 0회 Process A write Process B read 공유 물리 페이지 data A가 쓴 값을 B가 같은 자리에서 즉시 읽음 — 커널 경유 없음

공짜 점심은 없습니다. 커널이 박자를 안 맞춰주니, “A가 다 썼는지” “B가 읽는 중에 A가 덮어쓰진 않는지”를 직접 보장해야 합니다. 두 프로세스가 같은 메모리를 동시에 만지는 순간 [8편의 경쟁 상태(race condition)]가 그대로 발생합니다. 그래서 공유 메모리는 항상 세마포어·뮤텍스 같은 동기화 도구와 짝으로 씁니다. “가장 빠르지만 가장 손이 많이 가는” IPC인 이유입니다.

1
2
3
4
5
6
// POSIX 공유 메모리 (shm_open + mmap)
int fd = shm_open("/myseg", O_CREAT | O_RDWR, 0600);
ftruncate(fd, 4096);
void *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 이제 p는 두 프로세스가 공유하는 메모리. 읽고 쓰면 즉시 반영.
// ※ 동기화는 별도! (예: 같은 영역에 pthread_mutex(PTHREAD_PROCESS_SHARED) 배치)

소켓과 mmap 파일: 확장성과 파일 매개 공유

Unix 도메인 소켓(UDS)은 소켓 API(socket/bind/connect)를 그대로 쓰되 한 머신 안에서만 도는 IPC입니다. TCP 루프백(127.0.0.1)보다 빠르고(체크섬·혼잡제어 같은 TCP/IP 스택 오버헤드가 없음), 양방향이며, fd를 메시지로 건네는(SCM_RIGHTS) 특수 능력까지 있습니다. 가장 큰 장점은 코드를 거의 안 바꾸고 나중에 네트워크 소켓으로 확장된다는 것 — Docker·systemd·DB 클라이언트가 UDS를 즐겨 쓰는 이유입니다. 무관한 두 프로세스를 빠르게 양방향 연결하려면 socketpair()가 편합니다.

mmap 파일은 공유 메모리의 사촌입니다. 같은 파일을 두 프로세스가 MAP_SHARED로 매핑하면, 둘 다 같은 페이지 캐시 페이지를 보게 되어 사실상 공유 메모리가 됩니다(게다가 디스크에 영속). 대용량 데이터·DB·로그 공유의 토대입니다([18편 페이지 캐시]에서 다시 만납니다).

무엇을 언제 쓰나 — 결정 가이드

flowchart TD
  A["무엇이 필요한가?"] --> B{"데이터? 아니면 알림?"}
  B -->|"단순 알림·종료·타이머"| S["시그널"]
  B -->|"데이터 전달"| C{"한 머신 안? 미래에 분산?"}
  C -->|"미래에 네트워크로 확장"| SK["소켓 (UDS→TCP)"]
  C -->|"한 머신 고정"| D{"처리량이 핵심인가?"}
  D -->|"초당 GB·고빈도"| SHM["공유 메모리 / mmap<br/>(+동기화 필수)"]
  D -->|"보통·스트림"| E{"경계 보존 필요?"}
  E -->|"메시지 단위·우선순위"| MQ["메시지 큐"]
  E -->|"바이트 스트림이면 충분"| P["파이프 / FIFO"]
1
2
3
ipcs -a            # 현재 시스템의 공유메모리·세마포어·메시지큐 현황
lsof -U            # 열려 있는 Unix 도메인 소켓
cat /proc/sys/fs/pipe-max-size   # 파이프 버퍼 최대 크기

면접/리뷰 단골 질문

  • Q. 파이프와 공유 메모리의 근본 차이는? → 파이프는 커널 버퍼를 거치는 복사 기반이라 동기화(블로킹·백프레셔)를 커널이 공짜로 제공. 공유 메모리는 복사 없는 직접 공유라 가장 빠르지만 동기화를 직접 책임져야 한다.
  • Q. 공유 메모리가 가장 빠른데 왜 항상 안 쓰나? → 동기화를 직접 해야 하고(경쟁 상태 위험), 정리·생명주기 관리가 번거롭다. 작은/스트림 데이터엔 파이프·소켓이 훨씬 안전하고 충분하다.
  • Q. 시그널 핸들러에서 하면 안 되는 것은? → async-signal-safe 하지 않은 함수 호출(malloc/printf 등) — 재진입 데드락. 핸들러에선 플래그 세팅이나 self-pipe write만, 실제 처리는 메인 루프에서.
  • Q. Unix 도메인 소켓 vs TCP 루프백? → UDS가 더 빠르다(TCP/IP 스택·체크섬·혼잡제어 생략). fd 전달 가능. 단, 같은 머신 한정 — 분산 확장이 필요하면 TCP.
  • Q. SIGKILL과 SIGTERM의 차이? → TERM은 프로세스가 가로채 정리 후 종료 가능(graceful). KILL(9)은 가로챌 수 없고 커널이 즉시 회수 — 정리 코드도 안 돈다.

정리

  • IPC의 본질은 격리(보호)를 지키면서 협력시키기. 모든 메커니즘은 전달 방식(복사 vs 공유) 과 동기화 방식 두 축의 조합이다.
  • 파이프/FIFO: 커널 버퍼 위 단방향 스트림. 블로킹·백프레셔를 공짜로 제공. SIGPIPE 주의.
  • 시그널: 데이터가 아닌 비동기 알림. 핸들러는 async-signal-safe만 — self-pipe로 메인 루프에 위임.
  • 공유 메모리/mmap: 복사 0회로 가장 빠름. 대신 동기화는 내 책임 → [8편]과 한 묶음.
  • 소켓(UDS): 양방향·fd 전달, 코드 변경 없이 네트워크로 확장. 메시지 큐: 경계 보존·우선순위.
  • 고르는 기준: 알림이면 시그널, 분산 확장이면 소켓, 처리량이면 공유 메모리, 그 외엔 파이프.

다음 글: 공유 메모리를 안전하게 쓰려면 반드시 필요한 것 — 여러 실행 흐름이 같은 데이터를 만질 때 터지는 경쟁 상태와 그것을 막는 락을 동기화 1: race condition과 락에서 끝까지 팝니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 운영체제 A-Z (20편)

  1. 운영체제란 무엇인가 — 커널·유저모드·시스템콜, 왜 프로그램은 하드웨어를 직접 못 만지나
  2. 부팅 — 전원 버튼에서 커널까지, 그 1초 사이에 일어나는 모든 일
  3. 프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법
  4. 스레드와 동시성 — 한 주소 공간 속 여러 실행 흐름, 그리고 비결정성의 시작
  5. CPU 스케줄링 — 누구에게 CPU를 줄까, FCFS부터 리눅스 CFS까지
  6. 컨텍스트 스위치 — 전환은 공짜가 아니다, 직접비용과 캐시·TLB 오염
  7. 프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지 지금 보는 글
  8. 동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락
  9. 동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건
  10. 가상 메모리 — 모든 프로세스가 같은 주소에서 시작하는데 왜 안 겹치나
  11. 페이징·페이지 테이블·TLB — 4단계 주소 변환과 그 캐시
  12. 페이지 폴트·디맨드 페이징·스왑 — malloc 1GB가 즉시 성공하는 이유
  13. 페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까
  14. 메모리 할당자 — malloc은 어떻게 힙을 쪼개나, buddy와 slab까지
  15. 파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계
  16. 파일 시스템 (2) — 쓰는 중 전원이 꺼지면? 저널링과 크래시 일관성
  17. I/O·인터럽트·DMA — CPU보다 백만 배 느린 장치와 효율적으로 대화하기
  18. 블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가
  19. 가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝
  20. 성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리)