C++, CS

[CS] 프로세스 간 통신 방법과 Race Condition

dhlee-dev 2026. 5. 14. 21:38

이전에 프로세스와 스레드의 차이를 정리하면서,
프로세스는 서로 독립적인 메모리 공간을 가지고
스레드는 같은 프로세스 안에서 메모리를 공유한다는 것을 알게 되었다.

그렇다면 독립적인 메모리 공간을 가진 프로세스들은 서로 어떻게 데이터를 주고받을 수 있을까?
또 여러 스레드나 프로세스가 같은 자원에 접근하면 어떤 문제가 생길 수 있을까?

이번 글에서는 이러한 궁금증을 해결해보려고 한다.


프로세스의 메모리 구조 간단 정리

프로세스는 서로 독립된 가상 주소 공간을 가진다.

예를 들어 A 프로세스와 B 프로세스가 있다고 했을 때,
A 프로세스의 0x1000 주소와 B 프로세스의 0x1000 주소는
같은 주소처럼 보여도 실제로는 서로 다른 물리 메모리를 가리킬 수 있다.

프로세스가 사용하는 가상 주소는
MMU(Memory Management Unit)를 통해 실제 물리 메모리 주소로 변환된다.

이런 구조 덕분에 운영체제는 프로세스 간 메모리를 보호할 수 있다.

즉, 한 프로세스가 다른 프로세스의 메모리에 직접 접근하지 못하도록 막을 수 있다.

또한 실제로 필요한 메모리만 RAM에 올리고,
나머지는 디스크 공간에 페이징하는 방식으로
물리 메모리 부족 문제를 어느 정도 해결할 수 있다.


프로세스 간 통신이 필요한 이유

프로세스는 기본적으로 서로 독립된 메모리 공간을 가진다.
이 구조는 안정성 측면에서는 장점이 있다.

한 프로세스가 잘못된 메모리에 접근하거나 종료되더라도
다른 프로세스에 미치는 영향이 상대적으로 적기 때문이다.

하지만 반대로 생각하면, 프로세스끼리 데이터를 주고받으려면 별도의 통신 방법이 필요하다.
이때 사용하는 방식이 IPC(Inter-Process Communication), 프로세스 간 통신 이다.

대표적인 IPC 방법은 다음과 같다.

  • 공유 메모리
  • 파이프
  • 메시지 큐
  • 소켓
  • 시그널

공유 메모리

공유 메모리는 여러 프로세스가 같은 물리 메모리 영역을
자신의 가상 주소 공간에 매핑해서 함께 사용하는 방식이다.

즉, 서로 다른 프로세스가 같은 메모리 영역을 읽고 쓸 수 있게 만드는 것이다.

공유 메모리는 한 번 메모리 영역을 매핑한 뒤에는
커널을 거쳐 데이터를 매번 복사하지 않고 직접 읽고 쓸 수 있기 때문에 IPC 방식 중에서도 빠른 편이다.

그래서 큰 데이터를 여러 프로세스가 함께 사용해야 할 때 유리하지만,
같은 메모리 영역을 여러 프로세스가 함께 사용하기 때문에 동기화가 반드시 필요하다.

예를 들어 두 프로세스가 같은 메모리 값을 동시에 수정하면
실행 순서에 따라 결과가 달라질 수 있다.

이런 문제가 Race Condition으로 이어질 수 있다.

정리하면 공유 메모리는 빠르고 큰 데이터 공유에 유리하지만,
공유 자원을 직접 다루는 만큼 동기화 처리가 중요하다.


파이프

파이프는 한 프로세스의 출력이 다른 프로세스의 입력으로 연결되는 통신 방식이다.
데이터는 바이트 스트림 형태로 파이프를 통해 이동한다.
한쪽에서는 write로 데이터를 쓰고, 다른 쪽에서는 read로 데이터를 읽는 방식이다.

파이프는 익명 파이프와 명명된 파이프로 나눌 수 있다.

익명(Anonymous) 파이프는 이름이 없는 파이프이며,
주로 부모 프로세스와 자식 프로세스처럼 서로 관련이 있는 프로세스 사이에서 사용된다.
일반적으로 단방향 통신에 사용된다.

반면 명명된(Named) 파이프는 이름을 가진 파이프이다.
익명 파이프와 달리 서로 관련이 없는 프로세스끼리도 이름을 통해 파이프에 접근할 수 있다.
환경에 따라 양방향 통신도 가능하다.

정리하면 파이프는 구조가 비교적 단순하고,
프로세스 간 데이터를 순차적인 흐름으로 전달할 때 사용하기 좋다.


메시지 큐

메시지 큐는 프로세스들이 메시지를 큐에 넣고 다른 프로세스가 그 메시지를 꺼내 처리하는 방식이다.
송신 프로세스는 메시지를 큐에 넣고 수신 프로세스는 큐에서 메시지를 꺼내 처리한다.

메시지 큐는 데이터를 메시지 단위로 구조화하기 좋고 생산자 / 소비자 구조에 잘 어울린다.
또한 송신과 수신의 실행 타이밍을 어느 정도 분리할 수 있다.

다만 큐 크기 제한이 있을 수 있고,
대용량 데이터를 계속 전달해야 하는 상황에서는 공유 메모리보다 비효율적일 수 있다.

정리하면 메시지 큐는 작업 요청, 이벤트 전달, 명령 처리처럼
데이터를 메시지 단위로 주고받는 구조에 적합하다.


소켓

소켓은 네트워크 통신에 사용하는 대표적인 IPC 방식이다.

같은 컴퓨터 안의 프로세스끼리 통신할 수도 있고,
서로 다른 컴퓨터에 있는 프로세스끼리도 통신할 수 있다.

TCP나 UDP 같은 프로토콜을 사용할 수 있으며 클라이언트-서버 구조에 적합하다.

소켓은 로컬 환경을 넘어 네트워크까지 확장할 수 있다는 장점이 있다.

다만 공유 메모리처럼 같은 메모리를 직접 사용하는 방식보다
상대적으로 오버헤드가 크다.

정리하면 소켓은 네트워크를 통한 통신이 필요하거나,
클라이언트-서버 구조로 데이터를 주고받아야 할 때 적합하다.


시그널

시그널은 특정 이벤트가 발생했을 때 프로세스에 알림을 보내는 방식이다.
데이터를 주고받는 용도보다는 알림이나 제어 목적으로 사용하는 경우가 많다.

예를 들어 프로세스 종료 요청, 예외 상황, 잘못된 메모리 접근 같은 상황을
프로세스에 알리는 데 사용될 수 있다.

시그널은 복잡한 데이터를 전달하기에는 적합하지 않다.

정리하면 시그널은 데이터를 주고받는 통신 방식이라기보다는
프로세스에게 어떤 일이 발생했음을 알려주는 제어 수단에 가깝게 볼 수 있다.


IPC 방식 정리

방식 특징 주의할 점
공유 메모리 같은 메모리 영역을 여러 프로세스가 공유 동기화 필요
파이프 한 프로세스의 출력과 다른 프로세스의 입력 연결 단순한 흐름에 적합
메시지 큐 메시지를 큐에 넣고 꺼내 처리 대용량 데이터에는 비효율적일 수 있음
소켓 네트워크까지 확장 가능한 통신 방식 상대적으로 오버헤드가 큼
시그널 이벤트 발생을 프로세스에 알림 데이터 전달에는 부적합

각 IPC 방식은 목적이 다르다.

큰 데이터를 빠르게 공유해야 한다면 공유 메모리가 유리할 수 있고,
작업이나 이벤트를 메시지 단위로 전달하고 싶다면 메시지 큐가 적합할 수 있다.

네트워크를 통한 통신이 필요하다면 소켓을 사용하고,
단순 알림이나 제어가 목적이라면 시그널을 사용할 수 있다.


Race Condition이란?

Race Condition은 여러 스레드나 프로세스가 공유 자원에 동시에 접근해서 조작할 때,
실행 순서에 따라 결과가 달라지는 문제이다.

예를 들어 전역 변수 sum이 있고,
두 개의 스레드가 각각 sum1을 더하는 작업을 10000번씩 수행한다고 해보자.

기대하는 결과는 20000이다.

하지만 sum을 증가시키는 작업은 실제로 하나의 동작처럼 보여도,
내부적으로는 값을 읽고, 증가시키고, 다시 저장하는 과정으로 나누어질 수 있다.

#include <iostream>
#include <thread>

int sum = 0;

void Add() {
    for (int i = 0; i < 10000; ++i) {
        ++sum;
    }
}

int main() {
    std::thread t1(Add);
    std::thread t2(Add);

    t1.join();
    t2.join();

    std::cout << sum << '\n';
}

기대하는 결과는 20000 이지만 실제 실행 결과는 매번 달라질 수 있다.

겉으로 보면 ++sum은 단순히 값을 하나 증가시키는 코드처럼 보인다.

하지만 실제로는 보통 다음과 같은 과정으로 처리된다.

  1. CPU가 sum 값을 읽는다.
  2. 읽은 값을 1 증가시킨다.
  3. 증가시킨 값을 다시 sum에 저장한다.

문제는 이 과정이 하나의 원자적인 동작이 아니라는 점이다.

예를 들어 한 스레드가 sum을 읽고 1 증가시키는 중에,
다른 스레드가 아직 저장되기 전의 sum 값을 읽을 수 있다.

그러면 두 스레드가 같은 값을 기준으로 증가 연산을 수행하고,
결과적으로 한쪽 증가 결과가 덮어써질 수 있다.

이런 식으로 실행 순서에 따라 결과가 달라지는 문제가 Race Condition이다.


Race Condition과 Data Race의 차이

Race Condition과 Data Race는 비슷해 보이지만 같은 의미는 아니다.

Race Condition은 더 넓은 개념이다.

실행 순서에 따라 결과가 달라지는 문제를 말하며,
메모리뿐만 아니라 파일 접근, 네트워크 요청 순서, 이벤트 처리 순서 등에서도 발생할 수 있다.

반면 Data Race는 메모리에 대한 더 좁은 개념이다.

여러 스레드가 같은 메모리 위치에 동기화 없이 접근하고,
그중 하나 이상이 쓰기 작업을 할 때 발생한다.

정리하면 다음과 같다.

구분 의미
Race Condition 실행 순서에 따라 결과가 달라지는 넓은 개념
Data Race 같은 메모리 위치에 동기화 없이 접근하고, 하나 이상이 쓰기 작업을 하는 경우

즉, Data Race는 Race Condition의 한 종류로 볼 수 있다.

다만 모든 Race Condition이 Data Race인 것은 아니다.


Race Condition을 해결하는 방법

Race Condition을 해결하려면
공유 자원에 동시에 접근하지 못하도록 제어해야 한다.

대표적인 방법은 다음과 같다.

  • mutex / lock 사용
  • semaphore 사용
  • atomic 사용

mutex와 lock

mutex는 mutual exclusion의 약자로,
한 번에 하나의 스레드만 공유 자원에 접근할 수 있도록 막는 동기화 도구이다.

공유 자원을 사용하기 전에 lock을 걸고 작업이 끝나면 lock을 해제한다.

이렇게 하면 여러 스레드가 동시에 공유 자원을 수정하는 일을 막을 수 있다.

#include <iostream>
#include <thread>
#include <mutex>

int sum = 0;
std::mutex m;

void Add() {
    for (int i = 0; i < 10000; ++i) {
        std::lock_guard<std::mutex> lock(m);
        ++sum;
    }
}

int main() {
    std::thread t1(Add);
    std::thread t2(Add);

    t1.join();
    t2.join();

    std::cout << sum << '\n';
}

결과는 다음과 같다.

20000

std::lock_guard는 생성될 때 mutex를 lock하고 스코프를 벗어날 때 자동으로 unlock한다.

따라서 예외가 발생하거나 함수가 중간에 종료되더라도
mutex가 해제되지 않는 문제를 줄일 수 있다.


lock 사용 시 주의점

lock은 공유 자원을 보호하는 데 유용하지만 잘못 사용하면 성능 문제나 교착 상태가 발생할 수 있다.

주의할 점은 다음과 같다.

  • lock 범위가 너무 넓으면 대기 시간이 길어진다.
  • 여러 스레드가 lock을 기다리면서 lock 경합이 발생할 수 있다.
  • lock을 해제하지 않거나 서로의 lock을 기다리면 deadlock이 발생할 수 있다.

따라서 lock은 필요한 범위에만 최소한으로 사용하는 것이 좋다.


semaphore

semaphore는 동시에 접근할 수 있는 스레드나 프로세스의 개수를 제한하는 동기화 도구이다.

mutex가 보통 한 번에 하나의 실행 흐름만 접근하게 한다면,
semaphore는 내부 카운터를 이용해 동시에 접근 가능한 개수를 조절한다.

예를 들어 어떤 자원에 최대 3개의 스레드만 동시에 접근할 수 있게 하고 싶다면
카운터를 3으로 둔 semaphore를 사용할 수 있다.

즉, semaphore는 공유 자원에 대한 접근 수를 제한할 때 사용할 수 있다.


atomic

atomic은 특정 연산을 원자적으로 수행하도록 도와주는 도구이다.

예를 들어 단순한 정수 증가처럼 하나의 변수에 대한 연산은
std::atomic을 사용해 Data Race를 방지할 수 있다.

#include <iostream>
#include <thread>
#include <atomic>

std::atomic<int> sum{ 0 };

void Add() {
    for (int i = 0; i < 10000; ++i) {
        ++sum;
    }
}

int main() {
    std::thread t1(Add);
    std::thread t2(Add);

    t1.join();
    t2.join();

    std::cout << sum << '\n';
}

결과는 다음과 같다.

20000

std::atomic<int>를 사용하면
여러 스레드가 동시에 sum을 증가시켜도 연산이 원자적으로 처리된다.

그래서 단순한 공유 변수의 증가 같은 상황에서는 mutex 없이도 안전하게 처리할 수 있다.


atomic 사용 시 주의점

atomic은 변수 하나에 대한 단순한 연산에는 적합하다.

하지만 여러 데이터를 한꺼번에 검사하고 수정해야 하는 경우에는
mutex가 더 적합할 수 있다.

예를 들어 다음과 같은 흐름이 있다고 해보자.

if (count > 0) {
    --count;
    UseResource();
}

이 경우 count 자체가 atomic이라고 해도,
count > 0을 확인하는 시점과 --count를 수행하는 시점 사이에
다른 스레드가 끼어들 수 있다.

즉, atomic은 개별 연산의 원자성을 보장할 수 있지만,
여러 줄로 이루어진 논리 전체를 자동으로 안전하게 만들어주는 것은 아니다.

이런 경우에는 전체 논리를 하나의 임계 구역으로 묶어
mutex로 보호하는 편이 더 적절할 수 있다.


정리

프로세스는 서로 독립된 가상 주소 공간을 가진다.
그래서 기본적으로 다른 프로세스의 메모리에 직접 접근할 수 없다.

프로세스 간 데이터를 주고받기 위해서는 IPC가 필요하다.

대표적인 IPC 방법은 다음과 같다.

  • 공유 메모리
  • 파이프
  • 메시지 큐
  • 소켓
  • 시그널

공유 메모리는 빠르지만 동기화가 필요하고, 메시지 큐는 메시지 단위 처리에 적합하다.
소켓은 네트워크까지 확장할 수 있으며, 시그널은 데이터 전달보다는 알림과 제어에 가깝다.

Race Condition은 여러 스레드나 프로세스가 공유 자원에 동시에 접근할 때,
실행 순서에 따라 결과가 달라지는 문제이다.

이를 해결하기 위해 mutex, semaphore, atomic 같은 동기화 방법을 사용할 수 있다.

다만 동기화 도구를 사용한다고 해서 항상 문제가 해결되는 것은 아니다.

lock 경합, deadlock 같은 문제도 함께 고려해야 한다.


마무리

이번에는 프로세스 간 통신 방법과 Race Condition에 대해 정리해보았다.

프로세스는 서로 독립된 메모리 공간을 가지기 때문에
안정성은 높지만, 데이터를 주고받으려면 IPC 같은 별도의 통신 방법이 필요하다.

또한 공유 메모리나 멀티스레드 환경처럼 여러 실행 흐름이 같은 자원을 다루는 경우에는
Race Condition이 발생할 수 있다는 것도 알게 되었다.

앞으로 멀티스레드 코드나 프로세스 간 통신 구조를 볼 때는
데이터를 어떻게 주고받는지뿐만 아니라,
공유 자원을 어떻게 안전하게 보호할지도 함께 고려해야겠다.