프로세스 간 통신(IPC) — 파이프부터 소켓까지
프로세스는 독립된 메모리 공간을 가진다고 했는데, 그러면 쉘에서
ls | grep .md할 때 두 프로세스는 어떻게 데이터를 주고받는 걸까요?
프로세스끼리 직접 메모리를 읽을 수는 없으니, OS가 제공하는 별도의 통로가 필요합니다. 이게 IPC(Inter-Process Communication) 인데, 파이프, 소켓, 공유 메모리 등 종류가 꽤 많아서 헷갈리기 쉽습니다. 하나씩 정리해보겠습니다.
왜 IPC가 필요한가
프로세스마다 가상 메모리 공간이 완전히 분리되어 있습니다. 스레드는 같은 프로세스 안에서 힙을 공유하니까 데이터 교환이 쉽지만, 프로세스는 그렇지 않아요. 커널의 보호 장벽이 있기 때문에 다른 프로세스의 메모리를 직접 읽거나 쓸 수 없습니다.
그래서 OS가 제공하는 별도의 메커니즘 을 통해야만 프로세스 간에 데이터를 주고받을 수 있습니다. 이게 IPC이고, 크게 두 갈래로 나뉩니다.
| 방식 | 핵심 아이디어 |
|---|---|
| 공유 메모리 (Shared Memory) | 커널이 메모리 영역을 공유하도록 설정해주면, 이후엔 프로세스끼리 직접 읽고 씀 |
| ** 메시지 패싱 (Message Passing)** | 커널을 통해 메시지를 보내고 받음. 파이프, 소켓, 메시지 큐 등 |
공유 메모리는 빠르지만 동기화를 직접 해야 하고, 메시지 패싱은 커널이 중간에서 관리해주니까 안전하지만 오버헤드가 있습니다. 상황에 따라 골라 써야 합니다.
공유 메모리 (Shared Memory)
가장 빠른 IPC 방식입니다. 커널이 처음에 공유 메모리 영역을 만들어주면, 그다음부터는 ** 커널 개입 없이** 프로세스끼리 직접 데이터를 읽고 씁니다. 시스템 콜이 필요 없으니 속도가 압도적이에요.
// POSIX 공유 메모리 생성
int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
ftruncate(fd, 4096);
void *ptr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 이제 ptr을 통해 직접 읽고 쓸 수 있음
sprintf(ptr, "hello from process A");
문제는 동기화 입니다. 두 프로세스가 동시에 같은 영역에 쓰면 데이터가 깨집니다. 그래서 보통 세마포어나 뮤텍스를 같이 씁니다. 빠른 대신 개발자가 챙겨야 할 게 많은 거죠.
언제 쓰나
- 대량의 데이터를 빠르게 교환해야 할 때
- 같은 머신에서 프로세스 간 고성능 통신이 필요한 경우
- PostgreSQL의 shared buffer가 대표적인 예시입니다
파이프 (Pipe)
유닉스에서 가장 오래된 IPC 방식 중 하나. 쉘에서 | 기호를 쓸 때 바로 이 파이프가 동작합니다.
cat access.log | grep 404 | wc -l
이 명령에서 cat의 stdout이 grep의 stdin으로 연결되고, grep의 stdout이 wc의 stdin으로 연결됩니다. 각각 별도의 프로세스인데, 파이프를 통해 데이터가 흐르는 겁니다.
특징을 정리하면:
- **단방향 **: 한쪽에서 쓰고, 반대쪽에서 읽음. 양방향이 필요하면 파이프 2개를 만들어야 합니다
- ** 익명(anonymous)**: 이름이 없어서 파일 시스템에 나타나지 않음
- ** 부모-자식 관계 **:
fork()로 생성된 프로세스 간에만 사용 가능. 파일 디스크립터를 상속받아야 하니까요
int fd[2];
pipe(fd); // fd[0]: 읽기 끝, fd[1]: 쓰기 끝
if (fork() == 0) {
// 자식 프로세스
close(fd[1]); // 쓰기 끝 닫기
read(fd[0], buf, sizeof(buf));
} else {
// 부모 프로세스
close(fd[0]); // 읽기 끝 닫기
write(fd[1], "data", 4);
}
커널 내부에서 파이프는 고정 크기 버퍼(리눅스 기본 64KB)로 구현됩니다. 버퍼가 가득 차면 write가 블로킹되고, 비어있으면 read가 블로킹됩니다. 별도의 동기화 코드를 짤 필요가 없다는 점이 공유 메모리와의 차이입니다.
명명된 파이프 (Named Pipe / FIFO)
익명 파이프의 한계가 뭐였나요? 부모-자식 관계가 아니면 쓸 수 없다는 겁니다. 명명된 파이프는 이걸 해결합니다. ** 파일 시스템에 이름이 있는 특수 파일 **로 존재하기 때문에, 아무 관련 없는 프로세스도 그 경로만 알면 통신할 수 있습니다.
# 터미널 1: FIFO 생성 후 읽기
mkfifo /tmp/my_pipe
cat /tmp/my_pipe
# 터미널 2: FIFO에 쓰기
echo "hello" > /tmp/my_pipe
파일처럼 보이지만 실제로 디스크에 데이터가 저장되는 건 아닙니다. 커널 버퍼를 통해 전달되고, 읽으면 사라집니다. 역시 단방향이라 양방향 통신이 필요하면 두 개를 만들어야 합니다.
메시지 큐 (Message Queue)
파이프가 바이트 스트림이라면, 메시지 큐는 ** 구조화된 메시지 단위 **로 데이터를 주고받습니다. 각 메시지에 타입을 붙일 수 있어서, 수신자가 특정 타입의 메시지만 골라 받는 것도 가능합니다.
// 메시지 구조
struct msg_buffer {
long msg_type;
char msg_text[100];
};
// 메시지 보내기
struct msg_buffer message;
message.msg_type = 1;
strcpy(message.msg_text, "order:12345");
msgsnd(msgid, &message, sizeof(message.msg_text), 0);
// 타입 1인 메시지만 받기
msgrcv(msgid, &message, sizeof(message.msg_text), 1, 0);
파이프와 비교하면:
| 구분 | 파이프 | 메시지 큐 |
|---|---|---|
| 데이터 단위 | 바이트 스트림 | 구조화된 메시지 |
| 방향 | 단방향 | 양방향 가능 |
| 메시지 경계 | 없음 | 있음 (메시지 단위로 읽음) |
| 선택적 수신 | 불가능 | 가능 (타입별 필터링) |
| 지속성 | 프로세스 종료 시 소멸 | 명시적 삭제 전까지 유지 |
커널이 메시지를 관리해주니까 동기화를 신경 쓸 필요가 없고, 비동기적으로 동작합니다. 보내는 쪽은 큐에 넣고 바로 다른 일을 할 수 있어요.
소켓 (Socket)
네트워크 프로그래밍할 때 쓰는 그 소켓, 맞습니다. 소켓은 원래 네트워크 통신을 위해 만들어졌지만, ** 같은 머신 안에서** IPC 용도로도 사용할 수 있습니다. 가장 범용적인 IPC 방식이에요.
두 가지 종류가 있습니다.
인터넷 소켓 (TCP/UDP)
IP 주소와 포트 번호로 통신합니다. 네트워크를 넘어서 다른 머신과도 통신 가능하죠. 같은 머신이라면 127.0.0.1(loopback)을 쓰면 됩니다.
클라이언트 → TCP/IP 스택 → 서버
유닉스 도메인 소켓 (Unix Domain Socket)
같은 머신에서만 동작합니다. 네트워크 스택을 타지 않고 커널 내부에서 직접 데이터를 전달하기 때문에 TCP 소켓보다 빠릅니다. 파일 시스템의 소켓 파일을 통해 연결하죠.
# MySQL이 유닉스 도메인 소켓을 쓰는 예
mysql -S /var/run/mysqld/mysqld.sock
Docker 데몬(/var/run/docker.sock), Nginx와 PHP-FPM 간 통신 등 실무에서 자주 등장합니다.
소켓의 장점은 ** 양방향 통신 **이 가능하고, ** 클라이언트-서버 모델 **을 자연스럽게 구현할 수 있다는 점입니다. 단점은 설정해야 할 게 많고, 파이프보다 오버헤드가 큽니다.
시그널 (Signal)
다른 IPC 방식들과는 성격이 좀 다릅니다. 데이터를 전달하는 게 아니라, ** 비동기적으로 이벤트를 알리는** 메커니즘입니다. 프로세스한테 "이런 일이 일어났으니 처리해라"라고 통보하는 거죠.
흔히 쓰는 시그널들:
| 시그널 | 번호 | 설명 |
|---|---|---|
SIGKILL | 9 | 프로세스 강제 종료. 무시 불가능 |
SIGTERM | 15 | 정상 종료 요청. 핸들러로 graceful shutdown 가능 |
SIGINT | 2 | 인터럽트. 터미널에서 Ctrl+C 누르면 발생 |
SIGSTOP | 19 | 프로세스 일시 정지. 무시 불가능 |
SIGCONT | 18 | 정지된 프로세스 재개 |
SIGCHLD | 17 | 자식 프로세스가 종료되면 부모에게 전달 |
SIGUSR1/2 | 10/12 | 사용자 정의 시그널 |
# 프로세스에 SIGTERM 보내기
kill -15 1234
# 강제 종료
kill -9 1234
SIGKILL과 SIGSTOP은 절대 핸들링할 수 없습니다. 나머지는 시그널 핸들러를 등록해서 원하는 동작을 정의할 수 있어요. Nginx에서 kill -HUP으로 설정 파일을 재로드하는 것도 시그널 기반입니다.
한 가지 주의할 점은, 시그널은 ** 데이터를 담을 수 없다 **는 겁니다. 그냥 "이 시그널이 왔다"는 사실만 전달합니다. 그래서 본격적인 데이터 통신에는 쓰지 않고, 제어 목적으로 사용합니다.
메모리 맵 파일 (Memory-Mapped File)
파일을 프로세스의 가상 메모리 공간에 직접 매핑하는 방식입니다. mmap() 시스템 콜을 사용하죠.
int fd = open("shared_data.bin", O_RDWR);
void *addr = mmap(NULL, filesize, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 파일 내용을 메모리처럼 직접 접근
int *data = (int *)addr;
data[0] = 42; // 파일에 반영됨
공유 메모리와 비슷해 보이는데, 차이가 있습니다.
| 구분 | 공유 메모리 (shm) | 메모리 맵 파일 (mmap) |
|---|---|---|
| 백업 저장소 | 없음 (메모리만) | 파일 |
| 지속성 | 프로세스 종료 시 사라질 수 있음 | 파일로 남음 |
| 용도 | 순수 IPC | IPC + 파일 I/O 최적화 |
대용량 파일을 읽을 때 read() 시스템 콜 대신 mmap을 쓰면 커널-유저 공간 복사를 줄일 수 있어서 성능이 좋습니다. 여러 프로세스가 같은 파일을 MAP_SHARED로 매핑하면 IPC처럼 쓸 수도 있고요.
공유 메모리 vs 메시지 패싱 비교
| 기준 | 공유 메모리 | 메시지 패싱 |
|---|---|---|
| 속도 | 빠름 (커널 개입 최소) | 상대적으로 느림 (시스템 콜 필요) |
| ** 동기화** | 직접 해야 함 (세마포어 등) | 커널이 알아서 처리 |
| ** 구현 난이도** | 높음 | 낮음 |
| ** 데이터 크기** | 대용량에 유리 | 소규모 메시지에 적합 |
| ** 결합도** | 높음 (메모리 구조 공유) | 낮음 (메시지 포맷만 합의) |
| ** 네트워크 확장** | 불가능 (같은 머신만) | 가능 (소켓 기반이면) |
| ** 대표 예시** | POSIX shm, mmap | 파이프, 소켓, 메시지 큐 |
"둘 중 뭘 쓸 건가요?"라는 질문에는, 상황에 따라 다릅니다. 같은 머신에서 대량 데이터를 주고받아야 하면 공유 메모리, 네트워크를 넘어야 하거나 프로세스 간 결합도를 낮추고 싶으면 메시지 패싱입니다.
실무 사례: IPC의 확장
여기서 잠깐 시야를 넓혀보면, 실무에서 쓰는 많은 기술이 사실 IPC 개념의 확장입니다.
Redis Pub/Sub
Redis가 중간에서 메시지를 중계합니다. 퍼블리셔가 채널에 메시지를 보내면, 해당 채널을 구독 중인 모든 서브스크라이버가 받습니다. 프로세스 간 통신을 넘어 서버 간 통신으로 확장한 셈이에요.
Publisher → Redis Channel → Subscriber 1
→ Subscriber 2
단, Redis Pub/Sub은 메시지를 저장하지 않습니다. 구독자가 오프라인이면 그 메시지는 유실됩니다. 이걸 보완하려면 Redis Streams를 쓰거나 아예 메시지 브로커를 도입합니다.
RabbitMQ
AMQP 프로토콜 기반의 메시지 브로커. OS의 메시지 큐를 분산 시스템 수준으로 끌어올린 느낌입니다. Exchange, Queue, Binding이라는 개념으로 메시지 라우팅을 유연하게 제어할 수 있습니다.
메시지 영속성, ACK/NACK, Dead Letter Queue 등 신뢰성 보장 메커니즘이 잘 갖춰져 있어서, 주문 처리나 결제 같은 유실되면 안 되는 작업에 많이 씁니다.
Kafka
RabbitMQ가 메시지 큐 패턴에 가깝다면, Kafka는 ** 분산 로그 스트림** 플랫폼입니다. 메시지를 디스크에 순차적으로 기록하고, 컨슈머가 오프셋을 기준으로 읽습니다. 같은 메시지를 여러 컨슈머 그룹이 독립적으로 소비할 수 있죠.
초당 수십만 건의 이벤트를 처리해야 하는 로그 수집, 이벤트 소싱, 실시간 스트리밍에 적합합니다. 프로세스 간 통신이라는 근본 개념이 이렇게까지 확장된 겁니다.
IPC 방식 선택 기준
소켓 vs 파이프, 언제 쓰나
"같은 머신에서 프로세스 간 통신을 할 때, 파이프와 소켓 중 어떤 걸 선택하나요?"
답의 핵심은 ** 양방향이 필요한지, 관련 없는 프로세스인지 **입니다.
- 부모-자식 관계 + 단방향 데이터 흐름 → ** 파이프** (단순하고 가벼움)
- 비관련 프로세스 + 양방향 통신 → ** 유닉스 도메인 소켓** (파이프보다 유연하고 TCP보다 빠름)
- 네트워크 넘어야 함 → TCP 소켓 (유일한 선택)
Docker 컨테이너 간 통신
컨테이너는 본질적으로 격리된 프로세스입니다. 같은 호스트에 있어도 네트워크 네임스페이스가 분리되어 있기 때문에, 공유 메모리나 파이프는 기본적으로 사용할 수 없습니다.
대신 Docker 네트워크 를 통해 TCP/UDP 소켓으로 통신합니다. docker-compose에서 서비스 이름으로 서로를 찾는 것도 내부 DNS를 통한 소켓 통신이에요. 볼륨 마운트로 유닉스 도메인 소켓 파일을 공유하는 방법도 있긴 합니다.
services:
app:
networks:
- backend
db:
networks:
- backend
networks:
backend:
RPC (Remote Procedure Call)
IPC를 추상화해서 "원격 프로세스의 함수를 로컬처럼 호출"하는 패턴입니다. 네트워크 통신의 세부사항(소켓, 직렬화, 역직렬화)을 프레임워크가 감춰주죠.
gRPC가 대표적인 구현체인데, 내부적으로 HTTP/2 + Protocol Buffers를 사용합니다. MSA에서 서비스 간 동기 통신에 REST 대신 gRPC를 쓰는 경우가 늘고 있습니다. 바이너리 직렬화라 JSON보다 빠르고 타입 안전성도 확보할 수 있거든요.
service UserService {
rpc GetUser (UserRequest) returns (UserResponse);
}
이렇게 인터페이스를 정의하면 클라이언트/서버 코드가 자동 생성됩니다. IPC라는 개념이 네트워크를 넘어서 분산 시스템의 핵심 기반이 된 거예요.
파생 개념
IPC를 제대로 이해했다면, 자연스럽게 이어지는 주제들이 있습니다.
- **소켓 프로그래밍 **: TCP/UDP 소켓의 동작 원리, 블로킹/논블로킹 I/O, epoll/kqueue 같은 이벤트 기반 I/O 멀티플렉싱. 고성능 서버를 만들려면 반드시 알아야 하는 영역입니다
- ** 메시지 브로커 **: RabbitMQ, Kafka, Redis Streams 등. IPC의 메시지 큐 개념을 분산 환경으로 확장한 것. 비동기 아키텍처의 핵심이죠
- ** 마이크로서비스 통신 **: 동기(REST, gRPC) vs 비동기(메시지 큐, 이벤트 스트림) 통신 패턴. 서비스 메시(Istio, Linkerd)를 통한 통신 관리까지 확장됩니다
- ** 직렬화/역직렬화 **: 프로세스 간에 데이터를 보내려면 바이트로 변환해야 합니다. JSON, Protocol Buffers, Avro, MessagePack 등 다양한 포맷이 있고, 성능과 호환성 트레이드오프가 있습니다