프로세스 간 통신 (IPC)
파이프·메시지 큐·공유 메모리·시그널·소켓의 통신 방식과 격리 수준, 성능, 적용 상황을 비교합니다.
프로세스는 독립적인 메모리 공간을 가집니다.
A 프로세스의 변수를 B 프로세스가 직접 읽을 수 없습니다.
이 격리가 안정성의 핵심이지만, 현실에서는 프로세스들이 협력해야 하는 경우가 많습니다.
웹 서버가 데이터베이스 프로세스에 쿼리를 보내거나, 부모 프로세스가 워커 프로세스에게 작업을 분배하거나, 로그 수집 데몬이 여러 서비스의 로그를 모아야 합니다.
이렇게 프로세스 간에 데이터를 주고받는 메커니즘을 IPC(Inter-Process Communication)라 합니다.
IPC 방식은 여러 가지가 있고, 각각 다른 상황에 최적화되어 있습니다.
파이프 (Pipe)
파이프는 가장 간단하고 오래된 IPC 방식입니다.
한쪽 끝에서 쓰고, 다른 쪽 끝에서 읽는 단방향 바이트 스트림 채널입니다.
이름 없는 관을 통해 물이 한 방향으로 흐르는 것에 비유할 수 있습니다.
셸에서 |(파이프) 기호가 바로 이것입니다.
ls -la | grep ".txt" | wc -l을 실행하면, 세 개의 프로세스가 파이프로 연결되어 ls의 출력이 grep의 입력으로, grep의 출력이 wc의 입력으로 흘러갑니다.
이것이 Unix 철학 — 한 가지 일을 잘하는 작은 도구를 조합한다 — 의 기술적 기반입니다.
익명 파이프 (Anonymous Pipe)
pipe() 시스템 콜로 생성하며, 파일 디스크립터 한 쌍(읽기 끝, 쓰기 끝)을 반환합니다.
fork() 전에 파이프를 만들면, 부모와 자식이 파이프의 양쪽 끝을 상속받아 공유합니다.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>
int main() {
int fd[2]; /* fd[0]: 읽기 끝, fd[1]: 쓰기 끝 */
if (pipe(fd) == -1) {
perror("pipe");
exit(EXIT_FAILURE);
}
pid_t pid = fork();
if (pid == 0) {
/* 자식: 파이프에서 읽기 */
close(fd[1]); /* 쓰기 끝 닫기 (사용하지 않는 끝은 반드시 닫아야 함) */
char buf[256];
ssize_t n = read(fd[0], buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Child received: %s\n", buf);
}
close(fd[0]);
exit(0);
} else {
/* 부모: 파이프에 쓰기 */
close(fd[0]); /* 읽기 끝 닫기 */
const char *msg = "Hello from parent process!";
write(fd[1], msg, strlen(msg));
close(fd[1]); /* 버퍼가 비면 이후 read()가 EOF를 받도록 쓰기 끝을 닫음 */
wait(NULL);
}
return 0;
}사용하지 않는 파이프 끝을 close()하는 것이 중요합니다.
자식이 스트림을 EOF까지 반복해서 읽는다면, 부모나 다른 프로세스에 쓰기 끝이 하나라도 열려 있는 동안 버퍼가 빈 뒤의 read()는 EOF를 반환하지 않고 기다립니다. 위 예제는 메시지를 한 번만 읽지만, 스트림 끝까지 처리하는 구현에서는 모든 프로세스가 사용하지 않는 쓰기 끝을 닫아야 합니다.
반대로 파이프의 모든 읽기 끝이 닫힌 뒤 write()하면 SIGPIPE가 발생합니다. 기본 동작은 프로세스 종료이며, SIGPIPE를 무시하거나 처리한 경우 write()는 EPIPE 오류를 반환합니다.
익명 파이프는 다시 열 수 있는 이름이 없으므로 보통 fork() 전에 만들고 부모와 자식이 파일 디스크립터를 상속받아 사용합니다. 핵심 조건은 양쪽 프로세스가 해당 디스크립터를 가지고 있어야 한다는 점이며, 서로 독립적으로 접속해야 한다면 FIFO나 Unix 도메인 소켓이 더 자연스럽습니다.
네임드 파이프 (Named Pipe, FIFO)
네임드 파이프는 파일 시스템에 이름을 가지는 특수 파일입니다.
부모-자식 관계가 아닌 무관한 프로세스 간에도 사용할 수 있습니다.
# 터미널 1: FIFO 생성 및 읽기
mkfifo /tmp/myfifo
cat /tmp/myfifo # 누군가 쓸 때까지 블로킹
# 터미널 2: FIFO에 쓰기
echo "Hello via named pipe" > /tmp/myfifo
# 사용 후 삭제
rm /tmp/myfifo메시지 큐 (Message Queue)
메시지 큐는 커널이 관리하는 메시지 버퍼입니다.
프로세스가 구조화된 메시지를 큐에 넣으면(send), 다른 프로세스가 꺼냅니다(receive).
파이프와 달리 두 가지 중요한 차이가 있습니다.
첫째, 큐 API가 제공하는 순서 규칙을 사용할 수 있습니다.
이 절의 예제인 POSIX 메시지 큐는 각 메시지에 unsigned priority를 붙이고 높은 priority부터 전달합니다. 반면 System V 메시지 큐는 mtype을 사용해 원하는 타입의 메시지를 선택적으로 받을 수 있습니다. 두 API의 priority와 type을 같은 기능으로 혼동하면 안 됩니다.
둘째, 메시지 경계(Message Boundaries)가 보존됩니다.
파이프에서 100바이트를 두 번 쓰면, 읽는 측은 200바이트의 연속된 스트림으로 받을 수 있습니다.
메시지 큐에서는 두 개의 별도 메시지로 받습니다.
#include <fcntl.h>
#include <mqueue.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#define QUEUE_NAME "/myqueue"
#define MAX_MSG_SIZE 256
/* 송신 측 */
void sender(void) {
struct mq_attr attr = {
.mq_flags = 0,
.mq_maxmsg = 10,
.mq_msgsize = MAX_MSG_SIZE,
.mq_curmsgs = 0
};
mqd_t mq = mq_open(QUEUE_NAME, O_CREAT | O_WRONLY, 0644, &attr);
if (mq == (mqd_t)-1) {
perror("mq_open");
exit(EXIT_FAILURE);
}
const char *msg = "Hello, Message Queue!";
if (mq_send(mq, msg, strlen(msg), 3) == -1) {
perror("mq_send");
}
mq_close(mq);
}
/* 수신 측 */
void receiver(void) {
mqd_t mq = mq_open(QUEUE_NAME, O_RDONLY);
if (mq == (mqd_t)-1) {
perror("mq_open");
exit(EXIT_FAILURE);
}
struct mq_attr attr;
if (mq_getattr(mq, &attr) == -1) {
perror("mq_getattr");
mq_close(mq);
exit(EXIT_FAILURE);
}
char *buf = malloc((size_t)attr.mq_msgsize + 1);
if (buf == NULL) {
perror("malloc");
mq_close(mq);
exit(EXIT_FAILURE);
}
unsigned int prio;
ssize_t n = mq_receive(mq, buf, (size_t)attr.mq_msgsize, &prio);
if (n == -1) {
perror("mq_receive");
free(buf);
mq_close(mq);
exit(EXIT_FAILURE);
}
buf[n] = '\0';
printf("Received: %s (priority: %u)\n", buf, prio);
free(buf);
mq_close(mq);
mq_unlink(QUEUE_NAME);
}mq_receive()에 전달하는 버퍼 크기는 큐의 mq_msgsize 이상이어야 하므로, 수신 측은 mq_getattr()로 그 값을 확인합니다.
분산 환경에서는 OS 수준의 메시지 큐 대신 Redis, RabbitMQ, Apache Kafka 같은 외부 메시지 브로커를 사용하는 경우가 많습니다.
이들은 분산 환경에서의 안정적 메시지 전달, 영속성, 구독 모델 등 고급 기능을 제공합니다.
이들도 생산자가 메시지를 넣고 소비자가 꺼내며 메시지 경계를 유지한다는 큐 모델을 공유합니다.
공유 메모리 (Shared Memory)
공유 메모리는 여러 프로세스가 같은 물리적 메모리 영역을 자신의 가상 주소 공간에 매핑하여 공유하는 방식입니다.
공유 메모리 객체를 각 프로세스에 매핑한 뒤에는 메시지마다 별도의 read()·write() 호출로 payload를 옮기지 않고 같은 영역을 직접 읽고 쓸 수 있습니다. 따라서 큰 데이터를 반복해서 교환할 때 copy overhead를 줄일 수 있습니다.
다만 모든 상황에서 무조건 가장 빠른 것은 아닙니다. 파이프와 메시지 큐는 보통 사용자 공간과 커널 버퍼 사이의 복사를 수반하지만 구현과 데이터 크기에 따라 실제 비용이 달라집니다. 공유 메모리도 생성·매핑 비용, cache coherence, 동기화 규약의 비용을 함께 부담합니다.
from multiprocessing import Process, Value, Array
import ctypes
def worker(counter, arr):
"""공유 메모리에 직접 접근"""
with counter.get_lock():
counter.value += 1 # 원자적 증가 (락 필요)
with arr.get_lock():
for i in range(len(arr)):
arr[i] *= 2 # 읽기-수정-쓰기를 하나의 임계 구역으로 보호
if __name__ == "__main__":
counter = Value(ctypes.c_int, 0) # 공유 정수
arr = Array(ctypes.c_double, [1.0, 2.0, 3.0]) # 공유 배열
processes = []
for _ in range(4):
p = Process(target=worker, args=(counter, arr))
processes.append(p)
p.start()
for p in processes:
p.join()
print(f"Counter: {counter.value}") # 4
print(f"Array: {list(arr)}") # 각 원소가 2^4 = 16배하지만 공유 메모리는 동기화 문제를 필연적으로 동반합니다.
두 프로세스가 동시에 같은 메모리 위치를 수정하면 데이터가 손상됩니다.
위 예제에서 counter.get_lock()과 arr.get_lock()을 사용한 이유입니다. +=나 *=처럼 읽기와 쓰기가 결합된 연산은 synchronized wrapper를 사용하더라도 연산 전체가 자동으로 원자적이 되지 않습니다.
뮤텍스나 세마포어 같은 동기화 프리미티브를 사용하여 동시 접근을 제어해야 합니다.
이 문제는 6장에서 깊이 다룹니다.
아래 다이어그램은 IPC 방식별 데이터 이동 비용과 동기화 부담을 함께 비교한 것입니다.
- 파이프 · 메시지 큐
- 경로 송신 프로세스 → 커널 버퍼 → 수신 프로세스
- 비용 보통
write()와read()경계에서 사용자·커널 공간 사이의 payload 복사가 일어난다. - 공유 메모리
- 경로 두 프로세스가 같은 메모리 객체의 페이지를 각 주소 공간에 매핑한다.
- 비용 반복 전달의 copy overhead는 줄지만 mutex·semaphore와 데이터 layout 규약이 필요하다.
시그널 (Signal)
시그널은 프로세스에 비동기적 이벤트를 알리는 메커니즘입니다.
일종의 "소프트웨어 인터럽트"입니다.
대량의 데이터를 전달하기에는 적합하지 않고, 주로 프로세스에 특정 행동을 지시하는 데 사용합니다. 일반적인 표준 시그널은 시그널 번호로 이벤트를 알리며, queued real-time signal은 작은 정수나 포인터 값을 덧붙일 수 있지만 메시지 전송 수단을 대신하지는 않습니다.
자주 사용하는 시그널은 다음과 같습니다.
| 시그널 | Linux x86/ARM 번호(예) | 기본 동작 | 설명 |
|---|---|---|---|
| SIGTERM | 15 | 종료 | 정상 종료 요청. kill 명령의 기본 시그널 |
| SIGKILL | 9 | 종료 | 강제 종료. 프로세스가 잡거나 무시할 수 없음 |
| SIGINT | 2 | 종료 | Ctrl+C. 인터럽트 |
| SIGSTOP | 19 | 정지 | 프로세스 일시 정지. 잡거나 무시할 수 없음 |
| SIGCONT | 18 | 계속 | 정지된 프로세스 재개 |
| SIGCHLD | 17 | 무시 | 자식 프로세스 상태 변경 |
| SIGSEGV | 11 | 코어 덤프 | 잘못된 메모리 접근 |
| SIGPIPE | 13 | 종료 | 파이프의 모든 읽기 끝이 닫힌 상태에서 쓰기 |
시그널 번호는 아키텍처와 Unix 계열 운영체제에 따라 달라질 수 있으므로 코드에서는
SIGTERM같은 기호 이름을 사용합니다.
Linux에서 SIGCHLD의 기본 동작이 표에 “무시”로 표시되어도 종료한 자식은 부모가
wait()계열 함수로 회수할 때까지 zombie로 남을 수 있습니다. SIGCHLD를 명시적으로SIG_IGN으로 설정하거나SA_NOCLDWAIT를 사용하는 경우와 기본 disposition은 구분해야 합니다.
import signal
import os
import time
def graceful_shutdown(signum, frame):
print(f"\nReceived signal {signum}, shutting down gracefully...")
# 연결 정리, 파일 저장 등
exit(0)
signal.signal(signal.SIGTERM, graceful_shutdown)
signal.signal(signal.SIGINT, graceful_shutdown)
print(f"PID: {os.getpid()}, waiting for signals...")
while True:
time.sleep(1)SIGTERM과 SIGKILL의 차이는 실무에서 매우 중요합니다.
SIGTERM은 프로세스에게 정상적으로 종료하라는 요청입니다.
프로세스는 이 시그널을 잡아서 열린 파일을 닫고, 처리 중 작업을 안전하게 마무리하거나 롤백하고, 네트워크 연결을 정리한 후 종료할 수 있습니다.
SIGKILL은 커널이 프로세스를 즉시 강제 종료합니다.
프로세스가 어떤 코드도 실행할 기회 없이 바로 종료되므로, 데이터 손실이 발생할 수 있습니다.
Docker에서 docker stop은 이미지나 컨테이너에 설정된 stop signal을 먼저 보내며, 별도 설정이 없으면 SIGTERM을 사용합니다. 제한 시간이 지나도 종료되지 않으면 SIGKILL을 보내며, daemon 기본 제한 시간은 Linux 컨테이너 10초, Windows 컨테이너 30초입니다. signal과 제한 시간은 설정으로 바꿀 수 있습니다.
따라서 종료 전에 정리할 상태가 있는 컨테이너 애플리케이션은 stop signal을 처리하여 우아한 종료(Graceful Shutdown)를 구현해야 합니다.
소켓 기반 IPC
소켓(Socket)은 네트워크 통신뿐 아니라 같은 호스트의 프로세스 간 통신에도 사용됩니다.
같은 시스템의 프로세스 간에는 Unix 도메인 소켓(Unix Domain Socket, UDS)을 사용합니다.
Unix 도메인 소켓은 익숙한 socket API를 사용하지만 AF_UNIX 주소 패밀리로 같은 호스트 안에서 통신합니다. IP 라우팅과 TCP/UDP 전송 처리를 사용하지 않아 로컬 IPC에 적합하지만, 실제 성능 차이는 메시지 크기와 workload에 따라 달라집니다.
일반적으로 파일 시스템 pathname의 소켓 파일(.sock)에 bind하지만, unnamed socket도 있고 Linux에는 abstract namespace도 있습니다.
대표적인 사용 예:
- Docker:
/var/run/docker.sock— Docker CLI가 Docker 데몬과 통신합니다. - MySQL/PostgreSQL: 로컬 연결에서 Unix 도메인 소켓을 선택할 수 있습니다.
- Nginx + PHP-FPM: FastCGI 통신에 Unix 도메인 소켓을 사용합니다.
- systemd: 서비스와의 통신에 Unix 도메인 소켓을 사용합니다.
ls -la /var/run/docker.sock
# srw-rw---- 1 root docker 0 Mar 15 10:00 /var/run/docker.sock
# ↑ 's'는 소켓 파일을 의미
# 시스템의 모든 Unix 소켓 목록
ss -x | head
# Netid State Recv-Q Send-Q Local Address:Port
# u_str ESTAB 0 0 /run/dbus/system_bus_socket 1234IPC 방식 비교
| 방식 | 데이터 단위 | 연결 범위 | 핵심 부담 | 대표 용도 |
|---|---|---|---|---|
| 익명 파이프 | 경계 없는 바이트 스트림 | 같은 호스트, FD 공유 필요 | 끝점 종료와 EOF 관리 | 셸 파이프라인 |
| 네임드 파이프 | 경계 없는 바이트 스트림 | 같은 호스트, pathname으로 접속 | open blocking과 framing | 단순 생산자→소비자 흐름 |
| POSIX 메시지 큐 | 경계 있는 메시지와 priority | 같은 호스트, 이름으로 접속 | 메시지 크기·개수 제한과 수명 관리 | 구조화된 비동기 메시지 |
| 공유 메모리 | 함께 매핑한 메모리 영역 | 같은 호스트, 매핑 권한 필요 | 동기화와 데이터 layout 규약 | 큰 payload 반복 교환 |
| 시그널 | 이벤트 번호와 선택적 작은 값 | 프로세스 또는 스레드 대상 | handler 안전성과 중복 신호 처리 | 종료·재개 같은 제어 알림 |
| Unix 소켓 | stream·datagram·seqpacket | 같은 호스트의 socket endpoint | 타입별 framing과 프로토콜 설계 | 로컬 client-server 통신 |
실무에서의 선택 기준을 정리하면: 셸 스크립트에서 프로그램을 조합할 때는 파이프, 이벤트 알림에는 시그널, 로컬 client-server 통신에는 Unix 도메인 소켓, 큰 payload를 반복해서 공유할 때는 동기화 규약과 함께 공유 메모리를 고려합니다.
분산 시스템에서의 IPC는 TCP 소켓이나 외부 메시지 브로커(Kafka, RabbitMQ)로 확장됩니다.
아래 다이어그램은 IPC 방식을 고를 때 먼저 확인할 질문과 대표 선택지를 연결한 것입니다.
-
이벤트 알림만 필요한가?
그렇다면 시그널을 사용한다. bulk data 전달 수단으로 사용하지 않는다.
-
큰 payload를 같은 영역에서 반복해서 공유할까?
그렇다면 공유 메모리를 고려하고 mutex·semaphore와 데이터 layout 규약을 함께 설계한다.
-
경계 없는 연속 바이트 스트림이면 충분한가?
그렇다면 파일 디스크립터를 공유하는 파이프나 pathname으로 접속하는 FIFO를 사용한다.
-
로컬 client-server 양방향 통신인가?
그렇다면 Unix 도메인 소켓을 사용한다.
-
경계 있는 메시지를 비동기로 큐잉할까?
그렇다면 POSIX 메시지 큐를 사용하고 priority와 크기·개수 제한을 확인한다. 아니라면 신뢰 경계, 복구와 운영 환경을 포함해 요구사항을 다시 좁힌다.
IPC 방식은 단순히 빠른 순서로 고르는 것이 아니라, 데이터 크기, 프로세스 관계, 메시지 경계, 동기화 부담을 함께 보고 선택해야 합니다.
다음 장에서는 프로세스보다 가벼운 실행 단위인 스레드를 다루겠습니다.