프로세스 간 통신 (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()하는 것이 중요합니다. 부모가 쓰기 끝을 닫지 않으면, 자식의 read()는 EOF를 받지 못하고 영원히 블로킹됩니다. 반대로 자식이 읽기 끝을 닫으면, 부모의 write()는 SIGPIPE 시그널을 받아 프로세스가 종료됩니다.
익명 파이프는 fork()로 파일 디스크립터를 상속받아야 하므로, 부모-자식 관계가 아닌 프로세스 간에는 사용할 수 없습니다.
네임드 파이프 (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). 파이프와 달리 두 가지 중요한 차이가 있습니다.
첫째, 메시지에 타입(Type)을 지정할 수 있습니다. 수신 측이 타입 3의 메시지만 가져와라처럼 선택적으로 수신할 수 있습니다. 이것은 파이프의 순차적 바이트 스트림과 근본적으로 다릅니다.
둘째, 메시지 경계(Message Boundaries)가 보존됩니다. 파이프에서 100바이트를 두 번 쓰면, 읽는 측은 200바이트의 연속된 스트림으로 받을 수 있습니다. 메시지 큐에서는 두 개의 별도 메시지로 받습니다.
#include <mqueue.h>
#include <stdio.h>
#include <string.h>
/* 송신 측 */
void sender() {
mqd_t mq = mq_open("/myqueue", O_CREAT | O_WRONLY, 0644, NULL);
const char *msg = "Hello, Message Queue!";
mq_send(mq, msg, strlen(msg), 0);
mq_close(mq);
}
/* 수신 측 */
void receiver() {
mqd_t mq = mq_open("/myqueue", O_RDONLY);
char buf[256];
unsigned int prio;
ssize_t n = mq_receive(mq, buf, sizeof(buf), &prio);
buf[n] = '\0';
printf("Received: %s (priority: %u)\n", buf, prio);
mq_close(mq);
mq_unlink("/myqueue");
}현대 시스템에서는 OS 수준의 메시지 큐보다, Redis, RabbitMQ, Apache Kafka 같은 외부 메시지 브로커를 더 많이 사용합니다. 이들은 분산 환경에서의 안정적 메시지 전달, 영속성, 구독 모델 등 고급 기능을 제공합니다. 하지만 그 근본 개념 — 생산자가 메시지를 넣고 소비자가 꺼내간다 — 은 OS의 메시지 큐에서 출발했습니다.
공유 메모리 (Shared Memory)
공유 메모리는 여러 프로세스가 같은 물리적 메모리 영역을 자신의 가상 주소 공간에 매핑하여 공유하는 방식입니다. 가장 빠른 IPC입니다. 데이터가 커널을 거치지 않고, 프로세스가 메모리에서 직접 읽고 쓰기 때문입니다. 파이프나 메시지 큐는 데이터를 커널 버퍼로 복사하고 다시 수신 프로세스로 복사하는 두 번의 복사가 필요하지만, 공유 메모리는 복사가 없습니다.
from multiprocessing import Process, Value, Array
import ctypes
def worker(counter, arr):
"""공유 메모리에 직접 접근"""
with counter.get_lock():
counter.value += 1 # 원자적 증가 (락 필요)
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()을 사용한 이유입니다. 뮤텍스나 세마포어 같은 동기화 프리미티브를 사용하여 동시 접근을 제어해야 합니다. 이 문제는 6장에서 깊이 다룹니다.
시그널 (Signal)
시그널은 프로세스에 비동기적 이벤트를 알리는 메커니즘입니다. 일종의 "소프트웨어 인터럽트"입니다. 대량의 데이터를 전달하기에는 적합하지 않고(시그널 번호만 전달됨), 주로 프로세스에 특정 행동을 지시하는 데 사용합니다.
자주 사용하는 시그널은 다음과 같습니다.
| 시그널 | 번호 | 기본 동작 | 설명 |
|---|---|---|---|
| SIGTERM | 15 | 종료 | 정상 종료 요청. kill 명령의 기본 시그널 |
| SIGKILL | 9 | 종료 | 강제 종료. 프로세스가 무시할 수 없음 |
| SIGINT | 2 | 종료 | Ctrl+C. 인터럽트 |
| SIGSTOP | 19 | 정지 | 프로세스 일시 정지. 무시 불가 |
| SIGCONT | 18 | 계속 | 정지된 프로세스 재개 |
| SIGCHLD | 17 | 무시 | 자식 프로세스 상태 변경 |
| SIGSEGV | 11 | 코어 덤프 | 잘못된 메모리 접근 |
| SIGPIPE | 13 | 종료 | 파이프 읽기 끝이 닫힌 상태에서 쓰기 |
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은 먼저 SIGTERM을 보내고, 일정 시간(기본 10초) 후에도 종료되지 않으면 SIGKILL을 보냅니다. 따라서 컨테이너 안의 애플리케이션은 반드시 SIGTERM 핸들러를 등록하여 우아한 종료(Graceful Shutdown)를 구현해야 합니다.
소켓 기반 IPC
소켓(Socket)은 네트워크 통신뿐 아니라 같은 호스트의 프로세스 간 통신에도 사용됩니다. 같은 시스템의 프로세스 간에는 Unix 도메인 소켓(Unix Domain Socket, UDS)을 사용합니다.
Unix 도메인 소켓은 TCP/IP 소켓과 프로그래밍 인터페이스가 같지만, 네트워크 프로토콜 스택(TCP, IP 헤더 처리, 체크섬 등)을 거치지 않으므로 훨씬 빠릅니다. 파일 시스템의 소켓 파일(.sock)을 통해 통신합니다.
대표적인 사용 예:
- Docker:
/var/run/docker.sock— Docker CLI가 Docker 데몬과 통신합니다. - MySQL/PostgreSQL: 로컬 연결 시 TCP 대신 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 방식 비교
| 방식 | 속도 | 방향 | 프로세스 관계 | 용도 |
|---|---|---|---|---|
| 익명 파이프 | 중 | 단방향 | 부모-자식 필수 | 셸 파이프라인 |
| 네임드 파이프 | 중 | 단방향 | 무관 | 단순 데이터 흐름 |
| 메시지 큐 | 중 | 양방향 | 무관 | 구조화된 메시지 |
| 공유 메모리 | 최고 | 양방향 | 무관 | 대량 데이터 (동기화 필요) |
| 시그널 | 빠름 | 단방향 | 무관 | 이벤트 알림 (데이터 없음) |
| Unix 소켓 | 높음 | 양방향 | 무관 | 범용 (서버-클라이언트) |
실무에서의 선택 기준을 정리하면: 셸 스크립트에서 프로그램을 조합할 때는 파이프, 이벤트 알림에는 시그널, 고성능 로컬 서버 통신에는 Unix 도메인 소켓, 대용량 데이터 공유에는 공유 메모리가 적합합니다. 분산 시스템에서의 IPC는 TCP 소켓이나 외부 메시지 브로커(Kafka, RabbitMQ)로 확장됩니다.
다음 장에서는 프로세스보다 가벼운 실행 단위인 스레드를 다루겠습니다.