본문으로 건너뛰기

안동민 개발노트

본문 시작

프로세스 간 통신 (IPC)

파이프·메시지 큐·공유 메모리·시그널·소켓의 통신 방식과 격리 수준, 성능, 적용 상황을 비교합니다.

프로세스는 독립적인 메모리 공간을 가집니다.

A 프로세스의 변수를 B 프로세스가 직접 읽을 수 없습니다.

이 격리가 안정성의 핵심이지만, 현실에서는 프로세스들이 협력해야 하는 경우가 많습니다.

웹 서버가 데이터베이스 프로세스에 쿼리를 보내거나, 부모 프로세스가 워커 프로세스에게 작업을 분배하거나, 로그 수집 데몬이 여러 서비스의 로그를 모아야 합니다.

이렇게 프로세스 간에 데이터를 주고받는 메커니즘을 IPC(Inter-Process Communication)라 합니다.

IPC 방식은 여러 가지가 있고, 각각 다른 상황에 최적화되어 있습니다.

프로세스 간 통신 (IPC)

프로세스는 독립적인 메모리 공간을 가집니다. A 프로세스의 변수를 B 프로세스가 직접 읽을 수 없습니다.

  1. 파이프 (Pipe)

    한쪽이 쓴 바이트를 커널 버퍼 순서대로 다른 쪽이 읽습니다. Process A → Pipe buffer → Process B

  2. 메시지 큐 (Message Queue)

    커널 큐가 메시지 경계와 우선순위를 보존해 송수신 시점을 분리합니다. Sender → Message queue → Receiver

  3. 공유 메모리 (Shared Memory)

    같은 물리 페이지를 두 주소 공간에 매핑해 복사 비용을 줄입니다. Process A ↔ Shared pages ↔ Process B

  4. 프로세스 간 통신 기준

    메시지 경계 Pipe는 바이트 흐름, Queue와 datagram은 메시지 단위를 보존합니다. 복사 비용 공유 메모리는 payload 복사를 줄이지만 설정과 동기화가 필요합니다. 통신 범위 Pipe와 shared memory는 같은 호스트, socket은 원격 호스트까지 잇습니다. 동기화 공유 상태에는 mutex·semaphore가 필요하고, 메시지는 소유권 이동이 쉽습니다.

  5. 메시지 경계 Pipe

    바이트 흐름, Queue와 datagram은 메시지 단위를 보존합니다.

  6. 복사 비용 공유 메모리

    payload 복사를 줄이지만 설정과 동기화가 필요합니다.

  7. 통신 범위 Pipe

    shared memory는 같은 호스트, socket은 원격 호스트까지 잇습니다.

  8. 동기화 공유 상태에

    mutex·semaphore가 필요하고, 메시지는 소유권 이동이 쉽습니다.


파이프 (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바이트의 연속된 스트림으로 받을 수 있습니다.

메시지 큐에서는 두 개의 별도 메시지로 받습니다.

POSIX 메시지 큐 사용 예
#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입니다.

데이터가 커널을 거치지 않고, 프로세스가 메모리에서 직접 읽고 쓰기 때문입니다.

파이프나 메시지 큐는 데이터를 커널 버퍼로 복사하고 다시 수신 프로세스로 복사하는 두 번의 복사가 필요하지만, 공유 메모리는 복사가 없습니다.

Python 공유 메모리 예제
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장에서 깊이 다룹니다.

아래 다이어그램은 IPC 방식별 데이터 이동 비용과 동기화 부담을 함께 비교한 것입니다.

IPC 성능과 동기화

파이프와 큐는 커널 버퍼를 거치며 안전한 경계를 주고, 공유 메모리는 복사를 줄이는 대신 충돌 방지 규칙을 애플리케이션이 직접 설계해야 합니다.

  1. 파이프와 메시지 큐

    Kernel Buffer 송신 프로세스의 데이터가 커널 버퍼를 지나 수신 프로세스로 복사됩니다. Process A → Kernel buffer → Process B 장점 경계가 명확하고 수신자가 순서대로 처리하기 쉽습니다. 비용 copy in + copy out

  2. 공유 메모리

    Mapped Page 같은 물리 페이지를 서로 다른 가상 주소 공간에 매핑해 직접 읽고 씁니다. Process A ↔ Shared page ↔ Process B 장점 대용량 데이터 교환에서 복사 비용을 크게 줄입니다. 비용 lock + protocol

  3. 시그널과 소켓

    Control Event 이벤트 알림은 데이터보다 상태 변화를, Unix 소켓은 요청과 응답 경계를 전달합니다. Sender → Signal socket → Receiver 장점 종료 요청, 로컬 서비스 호출처럼 의미 단위가 분명합니다. 비용 event or syscall

  4. 데이터 크기

    작은 명령은 큐나 소켓, 큰 버퍼는 공유 메모리가 유리합니다.

  5. 메시지 경계

    파이프는 바이트 흐름이고, 큐와 소켓은 요청 단위를 보존하기 쉽습니다.

  6. 동기화

    공유 메모리는 가장 빠르지만 락 순서와 소유권 규칙이 필요합니다.

  7. 장애 격리

    커널 중재가 많을수록 느려질 수 있지만 잘못된 쓰기를 막기 쉽습니다.


시그널 (Signal)

시그널은 프로세스에 비동기적 이벤트를 알리는 메커니즘입니다.

일종의 "소프트웨어 인터럽트"입니다.

대량의 데이터를 전달하기에는 적합하지 않고(시그널 번호만 전달됨), 주로 프로세스에 특정 행동을 지시하는 데 사용합니다.

자주 사용하는 시그널은 다음과 같습니다.

시그널번호기본 동작설명
SIGTERM15종료정상 종료 요청. kill 명령의 기본 시그널
SIGKILL9종료강제 종료. 프로세스가 무시할 수 없음
SIGINT2종료Ctrl+C. 인터럽트
SIGSTOP19정지프로세스 일시 정지. 무시 불가
SIGCONT18계속정지된 프로세스 재개
SIGCHLD17무시자식 프로세스 상태 변경
SIGSEGV11코어 덤프잘못된 메모리 접근
SIGPIPE13종료파이프 읽기 끝이 닫힌 상태에서 쓰기
시그널 핸들러 등록
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)

SIGTERMSIGKILL의 차이는 실무에서 매우 중요합니다.

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 도메인 소켓을 사용합니다.
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 1234

IPC 방식 비교

방식속도방향프로세스 관계용도
익명 파이프단방향부모-자식 필수셸 파이프라인
네임드 파이프단방향무관단순 데이터 흐름
메시지 큐양방향무관구조화된 메시지
공유 메모리최고양방향무관대량 데이터 (동기화 필요)
시그널빠름단방향무관이벤트 알림 (데이터 없음)
Unix 소켓높음양방향무관범용 (서버-클라이언트)

실무에서의 선택 기준을 정리하면: 셸 스크립트에서 프로그램을 조합할 때는 파이프, 이벤트 알림에는 시그널, 고성능 로컬 서버 통신에는 Unix 도메인 소켓, 대용량 데이터 공유에는 공유 메모리가 적합합니다.

분산 시스템에서의 IPC는 TCP 소켓이나 외부 메시지 브로커(Kafka, RabbitMQ)로 확장됩니다.

아래 다이어그램은 IPC 방식을 고를 때 먼저 확인할 질문과 대표 선택지를 연결한 것입니다.

데이터 모양과 관계가 통신 방식을 정합니다

프로세스가 독립 주소 공간을 유지한 채 협력하려면, 전달량과 동기화 비용을 먼저 봅니다.

  1. 파이프

    Stream 파이프 셸 도구 조합처럼 한 방향 바이트 흐름이 필요할 때 단순합니다.

  2. 큐와 소켓

    Message 큐와 소켓 요청 단위와 경계를 보존해야 하면 메시지 기반 모델을 씁니다.

  3. 공유 메모리

    Memory 공유 메모리 대량 데이터를 빠르게 나누지만 락과 세마포어가 함께 필요합니다.

IPC 방식은 단순히 빠른 순서로 고르는 것이 아니라, 데이터 크기, 프로세스 관계, 메시지 경계, 동기화 부담을 함께 보고 선택해야 합니다.

다음 장에서는 프로세스보다 가벼운 실행 단위인 스레드를 다루겠습니다.