
IPC(Inter-Process Communication): 프로세스 간 통신
프로세스는 서로 격리되어 있습니다. 근데 어떻게 크롬 브라우저 탭끼리 데이터를 주고받을까요? 파이프부터 소켓까지.

프로세스는 서로 격리되어 있습니다. 근데 어떻게 크롬 브라우저 탭끼리 데이터를 주고받을까요? 파이프부터 소켓까지.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

크롬 브라우저를 켜고 작업 관리자를 열면 신기한 광경이 펼쳐집니다. 탭 하나당 프로세스가 하나씩 떠 있습니다. "YouTube" 탭도 프로세스 하나, "Gmail" 탭도 프로세스 하나. 근데 제가 YouTube에서 복사한 텍스트를 Gmail에 붙여넣기할 수 있습니다. 분명 운영체제는 프로세스를 완전히 격리된 독립 공간으로 만든다고 배웠는데, 어떻게 이게 가능한 걸까요?
저는 처음에 "그냥 메모리 주소 공유하면 되는 거 아냐?"라고 생각했습니다. 프로세스 A가 메모리 0x1234에 데이터를 쓰고, 프로세스 B가 그 주소를 읽으면 될 것 같았죠. 근데 이게 완전히 착각이었습니다. 각 프로세스는 가상 메모리라는 자기만의 주소 공간을 가지고 있어서, 프로세스 A의 0x1234와 프로세스 B의 0x1234는 물리 메모리상 완전히 다른 위치입니다. 벽으로 막혀 있는 겁니다.
제가 처음 멀티프로세스 아키텍처를 설계하려고 했을 때의 일입니다. Node.js로 웹 서버를 만들고 있었는데, CPU 코어를 다 활용하려면 여러 개의 워커 프로세스를 띄워야 했습니다. 그래서 메인 프로세스가 요청을 받으면 워커 프로세스들에게 작업을 분배하는 구조를 만들려고 했죠.
문제는 "어떻게 데이터를 전달하지?"였습니다. 메인 프로세스가 받은 HTTP 요청 데이터를 워커에게 줘야 하는데, 함수 호출처럼 worker.process(data)를 하면 될 줄 알았습니다. 근데 막상 코드를 짜니 안 되더라고요. 메모리가 분리되어 있으니 당연합니다. 전역 변수를 공유할 수도 없고, 포인터를 넘겨줄 수도 없고. 완전히 막막했습니다.
그때 깨달았습니다. 프로세스 격리는 보안과 안정성을 위한 설계이지만, 동시에 협업을 막는 장벽이기도 하다는 것을요. 마치 각자 방에 갇혀 있는 사람들처럼, 소통하려면 특별한 도구가 필요합니다. 바로 IPC(Inter-Process Communication)입니다.
문득 이런 비유가 떠올랐습니다. 프로세스들은 방음이 완벽한 방에 각각 갇혀 있는 사람들 같습니다. 직접 소리를 질러도 들리지 않죠. 이때 필요한 게 전화기, 우편함, 공용 게시판 같은 매개체입니다. 그리고 이 모든 걸 관리하는 건 건물 관리자, 즉 운영체제입니다.
운영체제는 프로세스들이 안전하게 소통할 수 있는 여러 방법을 제공합니다. 파이프(Pipe)는 마치 두 방 사이에 뚫린 관처럼 데이터를 한쪽에서 다른 쪽으로 흘려보냅니다. 메시지 큐(Message Queue)는 우체통처럼 메시지를 넣어두면 나중에 꺼내 갈 수 있습니다. 공유 메모리(Shared Memory)는 공용 공간을 만들어서 모두가 접근할 수 있게 해줍니다. 소켓(Socket)은 전화선처럼 실시간으로 대화할 수 있게 해주죠.
각각의 방법은 장단점이 명확합니다. 공유 메모리는 가장 빠르지만 여러 프로세스가 동시에 접근하면 데이터가 꼬일 수 있어서 동기화가 필요합니다. 메시지 큐는 안전하지만 데이터를 복사해야 해서 느립니다. 소켓은 범용적이지만 설정이 복잡합니다. 상황에 맞는 도구를 선택하는 게 핵심이었습니다.
IPC는 단순히 "데이터 주고받기"가 아니라, 프로세스 간 협업을 가능하게 하는 운영체제의 핵심 메커니즘입니다. 현대 소프트웨어 아키텍처는 대부분 멀티프로세스 기반입니다. 크롬의 멀티프로세스 아키텍처, Docker 컨테이너 간 통신, 마이크로서비스 간 데이터 교환, 모두 IPC의 확장된 형태입니다.
파이프는 IPC의 가장 기본 형태입니다. 터미널에서 매일 쓰는 | 기호가 바로 파이프입니다.
# ls 프로세스의 출력을 grep 프로세스의 입력으로 연결
ls -la | grep ".txt"
# 여러 프로세스를 체인처럼 연결
cat access.log | grep "ERROR" | wc -l
# 파이프는 단방향 - 데이터가 한쪽에서 다른 쪽으로만 흐름
ps aux | sort -k 3 -r | head -10
파이프는 익명 파이프(Anonymous Pipe)와 명명된 파이프(Named Pipe, FIFO) 두 종류가 있습니다. 익명 파이프는 부모-자식 프로세스 간에만 사용 가능하고, 명명된 파이프는 파일 시스템에 이름을 가진 파일로 존재해서 관계없는 프로세스끼리도 통신할 수 있습니다.
// C에서 파이프 생성 예시 (개념)
int pipefd[2];
pipe(pipefd); // pipefd[0]: 읽기, pipefd[1]: 쓰기
if (fork() == 0) {
// 자식 프로세스: 파이프에 데이터 쓰기
close(pipefd[0]);
write(pipefd[1], "Hello from child", 16);
close(pipefd[1]);
} else {
// 부모 프로세스: 파이프에서 데이터 읽기
close(pipefd[1]);
char buf[100];
read(pipefd[0], buf, 100);
printf("Received: %s\n", buf);
close(pipefd[0]);
}
파이프의 가장 큰 제약은 단방향이라는 점입니다. A가 B에게 보내면 B는 받기만 할 수 있습니다. 양방향 통신이 필요하면 파이프를 두 개 만들거나 다른 IPC 방법을 써야 합니다.
메시지 큐는 우체통 비유가 딱 맞습니다. 보내는 쪽은 메시지를 큐에 넣고 자기 할 일을 계속합니다. 받는 쪽은 준비되면 큐에서 메시지를 꺼냅니다. 동기화가 필요 없고, 보내는 쪽과 받는 쪽의 속도가 달라도 문제없습니다.
POSIX 메시지 큐와 System V 메시지 큐가 있는데, POSIX가 더 현대적이고 사용하기 쉽습니다.
// POSIX 메시지 큐 개념 (의사코드)
// 프로세스 A: 메시지 보내기
mqd_t mq = mq_open("/my_queue", O_WRONLY | O_CREAT, 0644, NULL);
char msg[] = "Task data for worker";
mq_send(mq, msg, strlen(msg), 0);
mq_close(mq);
// 프로세스 B: 메시지 받기
mqd_t mq = mq_open("/my_queue", O_RDONLY);
char buffer[256];
mq_receive(mq, buffer, 256, NULL);
printf("Received: %s\n", buffer);
mq_close(mq);
메시지 큐의 장점은 디커플링입니다. 보내는 쪽은 받는 쪽이 지금 실행 중인지, 언제 메시지를 처리할지 신경 쓸 필요가 없습니다. 현대 아키텍처에서는 RabbitMQ, Kafka, AWS SQS 같은 메시지 브로커가 이 개념을 네트워크로 확장했습니다.
공유 메모리는 여러 프로세스가 같은 물리 메모리 영역을 공유하는 방식입니다. 데이터를 복사할 필요가 없어서 가장 빠릅니다. 마치 두 방 사이에 벽을 허물고 공용 공간을 만드는 것과 같습니다.
// POSIX 공유 메모리 개념
// 프로세스 A: 공유 메모리 생성 및 쓰기
int shm_fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
ftruncate(shm_fd, 4096); // 4KB 크기
void *ptr = mmap(0, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, shm_fd, 0);
sprintf(ptr, "Shared data from process A");
// 프로세스 B: 공유 메모리 읽기
int shm_fd = shm_open("/my_shm", O_RDONLY, 0666);
void *ptr = mmap(0, 4096, PROT_READ, MAP_SHARED, shm_fd, 0);
printf("Data: %s\n", (char*)ptr);
공유 메모리의 함정은 동기화 문제입니다. 프로세스 A가 데이터를 쓰는 도중에 프로세스 B가 읽으면 망가진 데이터를 읽을 수 있습니다. 이를 방지하려면 세마포어(Semaphore)나 뮤텍스(Mutex)로 접근을 제어해야 합니다.
세마포어는 공유 자원에 대한 접근을 제어하는 카운터입니다. "지금 이 메모리에 접근할 수 있는 프로세스가 몇 개까지냐"를 관리합니다. 뮤텍스는 세마포어의 특수한 형태로, 값이 0 또는 1만 가능합니다(락/언락).
시그널(Signal)은 가벼운 알림 메커니즘입니다. 프로세스에게 "이벤트가 발생했어"라고 알려주는 방식입니다.
# 터미널에서 시그널 사용 예시
# SIGTERM: 정상 종료 요청 (프로세스가 정리 작업 가능)
kill -TERM 1234
# SIGKILL: 강제 종료 (즉시 종료, 정리 불가)
kill -9 1234
# SIGHUP: 설정 재로딩 요청 (많은 데몬들이 이렇게 설정 리로드)
kill -HUP 1234
시그널은 데이터를 전달하기엔 부족하지만, 이벤트 알림용으로는 매우 효율적입니다. Nginx를 재시작하지 않고 설정을 리로드할 때 nginx -s reload를 하면 내부적으로 SIGHUP 시그널을 보냅니다.
소켓은 네트워크 통신의 표준 인터페이스입니다. TCP/IP 소켓은 다른 컴퓨터와 통신할 때 쓰고, Unix Domain Socket은 같은 컴퓨터 안에서 프로세스 간 통신에 씁니다.
# Unix Domain Socket 예시 (Python)
import socket
import os
# 서버 프로세스
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind('/tmp/my_socket')
sock.listen(1)
connection, client_address = sock.accept()
data = connection.recv(1024)
print(f"Received: {data.decode()}")
# 클라이언트 프로세스
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
client.connect('/tmp/my_socket')
client.sendall(b'Hello from client')
client.close()
Unix Domain Socket은 TCP/IP 소켓보다 빠릅니다. 네트워크 스택을 거치지 않고 커널 내부에서 직접 데이터를 전달하기 때문입니다. Docker 데몬이 /var/run/docker.sock을 통해 CLI와 통신하는 게 대표적인 예시입니다.
메모리 맵 파일은 파일을 메모리처럼 다루는 기법입니다. 파일의 일부를 프로세스의 주소 공간에 매핑하면, 파일 I/O 함수 대신 메모리 읽기/쓰기로 파일을 다룰 수 있습니다. 여러 프로세스가 같은 파일을 메모리에 매핑하면 IPC로 활용 가능합니다.
// mmap을 이용한 파일 매핑
int fd = open("data.bin", O_RDWR);
void *addr = mmap(NULL, 4096, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 이제 addr을 일반 포인터처럼 사용 가능
memcpy(addr, "New data", 8);
munmap(addr, 4096);
데이터베이스들이 성능 최적화를 위해 자주 쓰는 기법입니다. SQLite는 데이터베이스 파일을 메모리에 매핑해서 디스크 I/O를 최소화합니다.
크롬은 보안과 안정성을 위해 탭마다 별도의 렌더러 프로세스를 실행합니다. 한 탭이 크래시 나도 다른 탭은 멀쩡하죠. 그런데 이렇게 격리하면 문제가 생깁니다. 사용자가 한 탭에서 복사한 텍스트를 다른 탭에 붙여넣기 하려면 어떻게 해야 할까요?
크롬은 브라우저 프로세스(Browser Process)가 중재자 역할을 합니다. 모든 렌더러 프로세스는 브라우저 프로세스와 IPC로 통신합니다. 주로 사용하는 방법은:
이런 구조 덕분에 크롬은 보안(렌더러 프로세스는 샌드박스 안에 갇힘)과 안정성(크래시 격리)을 얻으면서도 협업(클립보드 공유, 히스토리 동기화)을 할 수 있습니다.
전통적인 IPC는 같은 컴퓨터 안의 프로세스들을 위한 것이었지만, 현대 아키텍처는 네트워크를 넘어선 IPC를 요구합니다.
gRPC는 Google이 만든 RPC(Remote Procedure Call) 프레임워크입니다. Protocol Buffers로 인터페이스를 정의하면, 마치 로컬 함수를 호출하듯이 다른 서버의 함수를 호출할 수 있습니다.
// user.proto
service UserService {
rpc GetUser(UserRequest) returns (UserResponse);
}
message UserRequest {
int32 user_id = 1;
}
message UserResponse {
string name = 1;
string email = 2;
}
이렇게 정의하면 클라이언트 코드에서는 userService.GetUser({user_id: 123})처럼 쓸 수 있습니다. HTTP/2 기반이라 빠르고, 양방향 스트리밍도 지원합니다. 마이크로서비스 간 통신의 표준으로 자리 잡았습니다.
D-Bus는 리눅스 데스크톱 환경에서 애플리케이션과 시스템 서비스가 통신하는 표준입니다. 예를 들어 네트워크 매니저가 "Wi-Fi 연결됨"이라는 시그널을 브로드캐스트하면, 모든 구독 중인 앱들이 알림을 받습니다.
# D-Bus를 이용해 알림 보내기
dbus-send --session --type=method_call \
--dest=org.freedesktop.Notifications \
/org/freedesktop/Notifications \
org.freedesktop.Notifications.Notify \
string:"MyApp" uint32:0 string:"icon" \
string:"Hello" string:"This is a test" \
array:string:"" dict:string:string:"" int32:5000
D-Bus는 메시지 버스 개념을 사용합니다. 모든 프로세스가 버스에 연결되어 있고, 메시지를 퍼블리시하거나 서브스크라이브합니다. Pub/Sub 패턴의 로컬 버전이라고 볼 수 있습니다.
Redis는 단순한 캐시가 아닙니다. Pub/Sub 기능을 이용하면 여러 프로세스나 서버 간 실시간 메시징이 가능합니다.
# Redis Pub/Sub 예시
import redis
# Publisher (프로세스 A)
r = redis.Redis()
r.publish('notifications', 'New order received')
# Subscriber (프로세스 B, C, D...)
pubsub = r.pubsub()
pubsub.subscribe('notifications')
for message in pubsub.listen():
if message['type'] == 'message':
print(f"Received: {message['data']}")
저는 실시간 채팅 시스템을 만들 때 Redis Pub/Sub을 썼습니다. 여러 웹 서버 인스턴스가 떠 있어도, 한 서버에 들어온 메시지를 Redis 채널에 퍼블리시하면 다른 서버들이 구독해서 받습니다. 덕분에 어느 서버에 연결된 사용자든 메시지를 받을 수 있었죠.
RabbitMQ는 AMQP 프로토콜 기반의 메시지 브로커입니다. 메시지 큐의 네트워크 버전이라고 보면 됩니다. Exchange, Queue, Binding 개념을 이용해 복잡한 라우팅도 가능합니다.
Kafka는 대용량 데이터 스트리밍을 위한 분산 메시지 시스템입니다. LinkedIn에서 만들었고, 로그 수집, 이벤트 소싱, 실시간 데이터 파이프라인에 씁니다. 메시지를 디스크에 저장해서 나중에 재처리할 수도 있습니다(replay).
| 방법 | 속도 | 사용 난이도 | 적합한 상황 | 제약 사항 |
|---|---|---|---|---|
| 파이프 | 빠름 | 쉬움 | 단순한 부모-자식 통신, 커맨드라인 | 단방향, 같은 머신 |
| Named Pipe (FIFO) | 빠름 | 쉬움 | 관계없는 프로세스 간 단방향 통신 | 단방향, 같은 머신 |
| 메시지 큐 | 중간 | 중간 | 비동기 작업 큐, 태스크 분배 | 메시지 크기 제한 |
| 공유 메모리 | 매우 빠름 | 어려움 | 대용량 데이터, 고성능 필요 | 동기화 필수, 복잡함 |
| 세마포어 | N/A | 중간 | 공유 자원 접근 제어, 동기화 | 데이터 전달 불가 |
| 시그널 | 빠름 | 쉬움 | 간단한 이벤트 알림 | 데이터 전달 제한적 |
| Unix Socket | 빠름 | 중간 | 양방향 통신, 복잡한 프로토콜 | 같은 머신 |
| TCP Socket | 느림 | 중간 | 네트워크 통신, 원격 서버 | 네트워크 지연 |
| gRPC | 빠름 | 중간 | 마이크로서비스, 타입 안전성 | HTTP/2 필요 |
| Redis Pub/Sub | 빠름 | 쉬움 | 실시간 이벤트 브로드캐스트 | 메시지 유실 가능 |
| RabbitMQ | 중간 | 어려움 | 복잡한 라우팅, 신뢰성 중요 | 인프라 필요 |
| Kafka | 매우 빠름 | 어려움 | 대용량 스트림, 로그 수집 | 복잡한 설정 |
같은 머신, 간단한 통신: 파이프나 Unix Domain Socket. Docker CLI가 Docker 데몬과 통신할 때 소켓을 씁니다.
같은 머신, 고성능: 공유 메모리. 데이터베이스나 비디오 처리 같은 대용량 데이터 전달에 적합합니다.
비동기 작업 처리: 메시지 큐. 백그라운드 작업(이메일 발송, 이미지 리사이징)을 워커 프로세스에 분배할 때 좋습니다.
마이크로서비스 간 통신: gRPC나 REST API. gRPC는 타입 안전성과 성능이 중요할 때, REST는 단순함과 호환성이 중요할 때 씁니다.
실시간 이벤트 브로드캐스트: Redis Pub/Sub. 채팅, 알림, 실시간 대시보드 업데이트에 적합합니다.
대용량 로그/이벤트 처리: Kafka. 초당 수백만 건의 이벤트를 처리해야 할 때 씁니다.
IPC를 공부하면서 깨달은 건, 이게 단순히 "데이터 전달 방법"이 아니라 시스템 설계 철학이라는 점입니다. 프로세스를 격리해서 안정성을 얻되, IPC로 협업을 가능하게 한다. 이 균형이 현대 운영체제의 핵심입니다.
예전엔 "그냥 함수 호출하면 되는데 왜 이렇게 복잡하게 해?"라고 생각했습니다. 하지만 멀티프로세스 아키텍처를 직접 설계하고, 프로세스가 크래시 나도 시스템이 멈추지 않는 걸 보면서 이해했습니다. 격리와 통신, 이 두 가지를 동시에 해결하는 게 IPC의 본질입니다.
크롬을 켜서 작업 관리자를 보세요. 탭마다 프로세스가 따로 떠 있지만, 부드럽게 협업합니다. 북마크 동기화, 클립보드 공유, 확장 프로그램 통신. 이 모든 게 IPC 덕분입니다. 프로세스들이 벽 너머로 손을 내미는 모습, 그게 IPC입니다.