본문으로 건너뛰기

안동민 개발노트

본문 시작

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

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

POSIX 메시지 큐 사용 예
#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, 동기화 규약의 비용을 함께 부담합니다.

Python 공유 메모리 예제
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 방식별 데이터 이동 비용과 동기화 부담을 함께 비교한 것입니다.

파이프와 메시지 큐의 커널 버퍼 전달 경로를, 두 프로세스가 같은 페이지를 매핑하고 동기화하는 공유 메모리 방식과 비교
버퍼 기반 IPC와 공유 메모리의 데이터 경로 파이프와 메시지 큐에서는 송신 데이터가 write와 read 경계를 거쳐 전달되고, 공유 메모리에서는 두 프로세스가 같은 페이지를 매핑하되 동시 수정을 위한 동기화 규약이 필요하다. BUFFERED TRANSFER SHARED MAPPING WRITE READ MAPPED MAPPED 송신 프로세스 사용자 공간 커널 버퍼 파이프 · 메시지 큐 수신 프로세스 사용자 공간 프로세스 A 가상 주소 공간 공유 페이지 같은 메모리 객체 프로세스 B 가상 주소 공간 동기화 규약 mutex · semaphore
파이프 · 메시지 큐
경로 송신 프로세스 → 커널 버퍼 → 수신 프로세스
비용 보통 write()read() 경계에서 사용자·커널 공간 사이의 payload 복사가 일어난다.
공유 메모리
경로 두 프로세스가 같은 메모리 객체의 페이지를 각 주소 공간에 매핑한다.
비용 반복 전달의 copy overhead는 줄지만 mutex·semaphore와 데이터 layout 규약이 필요하다.
고정된 “가장 빠른 IPC”는 없다. payload 크기, 호출 빈도, 복사 비용과 동기화 비용을 함께 비교한다.

시그널 (Signal)

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

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

대량의 데이터를 전달하기에는 적합하지 않고, 주로 프로세스에 특정 행동을 지시하는 데 사용합니다. 일반적인 표준 시그널은 시그널 번호로 이벤트를 알리며, queued real-time signal은 작은 정수나 포인터 값을 덧붙일 수 있지만 메시지 전송 수단을 대신하지는 않습니다.

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

시그널Linux x86/ARM 번호(예)기본 동작설명
SIGTERM15종료정상 종료 요청. kill 명령의 기본 시그널
SIGKILL9종료강제 종료. 프로세스가 잡거나 무시할 수 없음
SIGINT2종료Ctrl+C. 인터럽트
SIGSTOP19정지프로세스 일시 정지. 잡거나 무시할 수 없음
SIGCONT18계속정지된 프로세스 재개
SIGCHLD17무시자식 프로세스 상태 변경
SIGSEGV11코어 덤프잘못된 메모리 접근
SIGPIPE13종료파이프의 모든 읽기 끝이 닫힌 상태에서 쓰기

시그널 번호는 아키텍처와 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)

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

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

방식데이터 단위연결 범위핵심 부담대표 용도
익명 파이프경계 없는 바이트 스트림같은 호스트, 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 방식을 고를 때 먼저 확인할 질문과 대표 선택지를 연결한 것입니다.

이벤트 알림, 큰 공유 payload, 바이트 스트림, 로컬 client-server, 비동기 메시지 큐잉 여부를 차례로 확인해 대표 IPC 후보를 좁히고, 맞는 조건이 없으면 요구사항을 다시 검토하는 흐름
로컬 IPC 선택 질문 데이터 없는 이벤트 알림인지, 큰 payload를 공유할지, 연속 바이트 스트림이면 되는지, 로컬 client-server 양방향 통신인지, 경계 있는 메시지를 비동기로 큐잉할지 차례로 확인한다. 어떤 조건에도 맞지 않으면 요구사항과 운영 환경을 다시 검토한다. YES NO YES NO YES NO YES NO YES NO 이벤트 알림만 필요한가? 시그널 제어 이벤트 통지 큰 payload를 같이 매핑할까? 공유 메모리 동기화 규약 필수 연속 byte stream이면 충분한가? 파이프 · FIFO 메시지 경계 없음 로컬 client-server 양방향인가? Unix 도메인 소켓 로컬 socket endpoint 경계 있는 메시지를 비동기로 큐잉할까? POSIX 메시지 큐 메시지 경계 · priority 요구사항 다시 확인 신뢰 경계 · 복구 · 운영 환경
  1. 이벤트 알림만 필요한가?

    그렇다면 시그널을 사용한다. bulk data 전달 수단으로 사용하지 않는다.

  2. 큰 payload를 같은 영역에서 반복해서 공유할까?

    그렇다면 공유 메모리를 고려하고 mutex·semaphore와 데이터 layout 규약을 함께 설계한다.

  3. 경계 없는 연속 바이트 스트림이면 충분한가?

    그렇다면 파일 디스크립터를 공유하는 파이프나 pathname으로 접속하는 FIFO를 사용한다.

  4. 로컬 client-server 양방향 통신인가?

    그렇다면 Unix 도메인 소켓을 사용한다.

  5. 경계 있는 메시지를 비동기로 큐잉할까?

    그렇다면 POSIX 메시지 큐를 사용하고 priority와 크기·개수 제한을 확인한다. 아니라면 신뢰 경계, 복구와 운영 환경을 포함해 요구사항을 다시 좁힌다.

이 흐름은 로컬 IPC의 대표 선택을 좁히는 출발점이다. 신뢰 경계, backpressure, 장애 복구와 운영 환경도 최종 결정에 포함한다.

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

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