안동민 개발노트

본문 시작

모니터와 고전적 동기화 문제

모니터와 조건 변수가 상호 배제를 구조화하는 방식을 배우고 Java와 Python으로 고전적 동기화 문제를 구현합니다.

세마포어는 강력하지만 위험합니다.

wait과 signal의 순서를 프로그래머가 직접 관리해야 하며, 순서가 틀리면 데드락이 발생하고, signal을 빠뜨리면 영원히 대기하고, wait을 빠뜨리면 상호 배제가 깨집니다.

이 실수들은 컴파일 타임에 잡히지 않고 런타임에도 간헐적으로만 나타나서, 찾기가 극도로 어렵습니다.

모니터(Monitor)는 상호 배제와 조건 대기를 언어 또는 라이브러리 차원에서 구조화하는 동기화 도구입니다. 조건식과 락 경계를 잘못 설계하는 오류까지 자동으로 막지는 않습니다.

Hoare와 Brinch Hansen의 연구로 발전했으며, Java의 객체 모니터나 Python의 Lock·Condition 조합으로 이 방식을 구현할 수 있습니다.


모니터의 개념

모니터는 상호 배제가 내장된 추상 데이터 타입입니다.

핵심 특성:

  1. 모니터 안의 메서드(프로시저)는 한 번에 하나의 스레드만 실행할 수 있습니다.
  2. 개발자가 락을 직접 잡고 풀 필요가 없습니다 — 모니터가 자동으로 처리합니다.
  3. 조건 변수(Condition Variable)를 통해 특정 조건이 만족될 때까지 대기할 수 있습니다.

세마포어와의 핵심 차이: 세마포어는 프로그래머가 wait/signal을 올바른 위치에 올바른 순서로 배치해야 합니다.

모니터는 지정한 경계의 잠금과 해제를 구조화합니다. 개발자는 어떤 객체의 락으로 어떤 상태를 보호할지, 조건 검사와 변경이 모두 그 경계를 따르는지 결정해야 합니다.


Java의 synchronized — 모니터 구현

Java에서 모든 객체는 내장 모니터를 가지고 있습니다.

synchronized 키워드로 접근합니다.

BoundedBuffer.java
public class BoundedBuffer<T> {
    private final Object[] buffer;
    private int count = 0, in = 0, out = 0;

    public BoundedBuffer(int size) {
        buffer = new Object[size];
    }

    public synchronized void produce(T item) throws InterruptedException {
        while (count == buffer.length) {
            wait();  // 버퍼 가득 참 → 조건 변수에서 대기 (락 자동 반납)
        }
        buffer[in] = item;
        in = (in + 1) % buffer.length;
        count++;
        notifyAll();  // 대기 중인 소비자 깨우기
    }

    @SuppressWarnings("unchecked")
    public synchronized T consume() throws InterruptedException {
        while (count == 0) {
            wait();  // 버퍼 비어있음 → 조건 변수에서 대기
        }
        T item = (T) buffer[out];
        out = (out + 1) % buffer.length;
        count--;
        notifyAll();  // 대기 중인 생산자 깨우기
        return item;
    }
}

synchronized 메서드에 진입하면 자동으로 해당 객체의 모니터 락이 잡힙니다.

다른 스레드가 같은 객체의 synchronized 메서드를 호출하면 블로킹됩니다.

메서드가 반환되면(예외로든 정상으로든) 락이 자동으로 해제됩니다.

wait(): 현재 스레드를 조건 큐(Wait Set)에 넣고, 모니터 락을 원자적으로 반납합니다.

알림 외에도 인터럽트나 허위 각성으로 대기에서 벗어날 수 있습니다. 반환 또는 InterruptedException 전에 같은 모니터를 다시 획득하며, wait()는 다른 객체의 락까지 해제하지 않습니다.

notifyAll(): 조건 큐의 모든 스레드를 깨웁니다.

깨어난 스레드들은 모니터 락을 다시 경쟁합니다.

notify(): 조건 큐에서 하나의 스레드만 깨웁니다.

어떤 스레드가 깨어나는지는 비결정적입니다.

이처럼 생산자와 소비자가 같은 wait set을 쓰는 예제에서는 notifyAll()로 각 대기자가 자기 조건을 다시 검사하게 합니다. 알림은 모니터 소유권이나 다음 실행 차례를 바로 넘기지 않으며 공정성도 보장하지 않습니다.


Python의 Condition — 모니터 구현

Python에서는 threading.Condition이 모니터 역할을 합니다.

monitor.py
import threading

class BoundedBuffer:
    def __init__(self, size):
        self.buffer = []
        self.size = size
        self.condition = threading.Condition()

    def produce(self, item):
        with self.condition:  # 자동으로 락 획득
            while len(self.buffer) >= self.size:
                self.condition.wait()  # 락 반납 + 대기
            self.buffer.append(item)
            self.condition.notify_all()  # 대기자 깨우기

    def consume(self):
        with self.condition:
            while len(self.buffer) == 0:
                self.condition.wait()
            item = self.buffer.pop(0)
            self.condition.notify_all()
            return item

with self.condition 블록에 진입하면 자동으로 내부 락이 잡히고, 블록을 벗어나면 자동으로 풀립니다.

wait() 호출 시 락이 반납되고 스레드가 대기합니다.

notify_all()이 호출되면 대기 중인 스레드들이 깨어나고, 모니터 락을 재획득한 후 while 조건을 다시 확인합니다.

이 구현의 condition.wait()는 연결된 락을 놓고 기다리므로, 그 락을 쥔 채 empty 허가를 기다리던 앞 절의 순서 문제를 피합니다. 다른 락을 보유한 대기나 잘못된 조건식까지 막아주는 것은 아닙니다. Java·Python 두 버퍼 예제는 양수 용량을 전제로 하며 생성자의 입력 검증은 생략했습니다.


조건 변수와 허위 각성

while을 써야 하는 이유

조건 변수에서 wait() 후 깨어났을 때, 조건이 여전히 만족되지 않을 수 있습니다.

이유는 크게 두 가지로 구분합니다.

  1. 허위 각성(Spurious Wakeup): 명시적 알림 없이도 대기가 반환할 수 있습니다. POSIX와 Java API는 이를 허용합니다.

  2. 정상 알림 뒤의 경쟁: notifyAll()로 여러 소비자가 동시에 깨어났지만, 첫 번째 소비자가 버퍼의 마지막 아이템을 가져가면 두 번째 소비자는 빈 버퍼를 만납니다.

그래서 조건 변수는 반드시 while 루프와 함께 사용합니다.

올바른 패턴
# 올바른 코드
while not condition_is_true():
    condition.wait()

# 위험한 코드 (절대 사용하지 말 것)
if not condition_is_true():
    condition.wait()
    # 깨어났을 때 조건이 여전히 거짓일 수 있음!

C의 pthread 조건 변수

condition_variable.c
#include <stdio.h>
#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t data_ready = PTHREAD_COND_INITIALIZER;
int data_available = 0;

void *producer(void *arg) {
    pthread_mutex_lock(&mutex);
    data_available = 1;
    printf("데이터 준비 완료\n");
    pthread_cond_signal(&data_ready);  /* 대기 중인 소비자 하나 깨우기 */
    pthread_mutex_unlock(&mutex);
    return NULL;
}

void *consumer(void *arg) {
    pthread_mutex_lock(&mutex);
    while (!data_available) {  /* while, not if */
        pthread_cond_wait(&data_ready, &mutex);
        /* wait 중에는 mutex가 반납됨 */
        /* 깨어나면 mutex를 다시 잡은 상태로 리턴 */
    }
    printf("데이터 소비\n");
    data_available = 0;
    pthread_mutex_unlock(&mutex);
    return NULL;
}

pthread_cond_wait(&cond, &mutex)는 mutex 반납과 조건 대기를 연결합니다. 다른 스레드가 그 mutex를 획득한 뒤 보내는 알림은 대기 등록 이후에 보낸 것처럼 작동합니다.

깨어날 때 mutex를 재획득한 후 리턴합니다.

이 원자성이 보장되지 않으면, mutex를 반납한 후 조건 큐에 추가되기 전에 producer가 signal을 보내면 잃어버린 깨우기(Lost Wakeup)가 발생합니다.

조건 변수는 wait() 한 줄만 따로 보면 위험하게 단순해 보입니다.

알림을 받았다는 사실 대신, 같은 락 아래에서 읽는 공유 조건이 진행 여부의 기준입니다. 아래 그림은 정상 반환 경로이며 인터럽트·오류·취소 처리는 사용하는 API에 따라 따로 수행합니다.

조건 대기에서 정상 반환하면 같은 락 아래에서 재검사

알림은 조건이 참이라는 보장이 아니다. wait의 정상 반환 전 락을 다시 획득하며, 공유 조건을 while로 재검사한다.

락과 조건 재검사같은 락 획득 후 조건이 참이면 락 안에서 진행한다. 거짓이면 락을 놓고 기다리고, 정상 반환 전 재획득한 뒤 조건을 다시 검사한다.같은 락 획득진행 가능?락 안에서 진행락 반납·대기정상 반환 전 재획득참거짓반환 후 재검사락과 조건 재검사같은 락으로 조건을 검사한다. 거짓이면 대기하고 락 재획득 후 다시 검사한다. 참일 때만 락 안에서 진행한다.같은 락 획득진행 가능?락 반납·대기정상 반환 전 재획득락 안에서 진행거짓참반환 후 재검사

식사하는 철학자 문제

식사하는 철학자(Dining Philosophers)는 Dijkstra가 1965년에 제안한 동기화의 가장 유명한 문제입니다.

5명의 철학자가 원탁에 앉아 있고, 각 철학자 사이에 젓가락(포크) 하나씩 총 5개가 놓여 있습니다.

식사하려면 양쪽의 젓가락 두 개가 모두 필요합니다.

문제 상황: 5명 모두 동시에 왼쪽 젓가락을 집으면, 오른쪽 젓가락은 이미 옆 사람이 잡고 있습니다.

5명 모두 오른쪽 젓가락을 기다리며 영원히 멈춥니다.

교착 상태(Deadlock)입니다.

해결 전략들

전략 1 — 비대칭 순서: 짝수 번 철학자는 왼쪽부터, 홀수 번 철학자는 오른쪽부터 집습니다.

7장에서 다룰 "순환 대기 조건"을 깨뜨립니다.

전략 2 — 최대 인원 제한: 동시에 식사를 시도하는 철학자를 최대 4명으로 제한합니다.

세마포어(초기값 4)를 사용합니다.

집기를 시도하는 동안만 최대 4명을 들이고 두 젓가락을 내려놓은 뒤 허가를 반납하면, 모두 한 개씩 보유하는 5명 순환 대기를 막을 수 있습니다. 개별 철학자의 기아 방지는 별도의 정책입니다.

전략 3 — 모니터 기반: 양쪽 젓가락이 모두 사용 가능할 때만 두 개를 동시에 집도록 합니다.

조건 변수로 "양쪽이 모두 비어있을 때까지" 대기합니다.

dining_philosophers_monitor.py
import threading
import time
import random

class DiningTable:
    def __init__(self, n):
        self.n = n
        self.state = ["thinking"] * n  # thinking, hungry, eating
        self.lock = threading.Lock()
        self.condition = [threading.Condition(self.lock) for _ in range(n)]

    def pickup(self, i):
        with self.lock:
            self.state[i] = "hungry"
            self._test(i)
            while self.state[i] != "eating":
                self.condition[i].wait()

    def putdown(self, i):
        with self.lock:
            self.state[i] = "thinking"
            # 양옆 철학자가 먹을 수 있는지 확인
            self._test((i - 1) % self.n)
            self._test((i + 1) % self.n)

    def _test(self, i):
        left = (i - 1) % self.n
        right = (i + 1) % self.n
        if (self.state[i] == "hungry" and
            self.state[left] != "eating" and
            self.state[right] != "eating"):
            self.state[i] = "eating"
            self.condition[i].notify()  # 호출자는 공통 self.lock을 보유

def philosopher(table, i):
    for _ in range(3):
        print(f"철학자 {i} 생각 중...")
        time.sleep(random.uniform(0.1, 0.3))
        table.pickup(i)
        print(f"철학자 {i} 식사 중!")
        time.sleep(random.uniform(0.1, 0.2))
        table.putdown(i)

table = DiningTable(5)
threads = [threading.Thread(target=philosopher, args=(table, i)) for i in range(5)]
for t in threads: t.start()
for t in threads: t.join()

모든 조건 변수는 self.lock을 공유합니다. 상태의 검사·변경과 알림은 이 락 아래에서 이루어지고, wait는 같은 락을 놓고 다시 얻습니다. 예제의 5명·각 3회 루프가 정상 동작하는 경로를 설명하며, 출력 순서·최대 완료 시간이나 예외·취소 시 복구, 개별 기아 방지는 보장하지 않습니다.

왜 이 문제가 중요한가

식사하는 철학자는 교과서 문제처럼 보이지만, 실무에서 정확히 같은 구조가 반복됩니다.

  • 데이터베이스 락: 트랜잭션 A가 테이블 1의 락을 잡고 테이블 2를 기다리고, 트랜잭션 B가 테이블 2의 락을 잡고 테이블 1을 기다리면 데드락
  • 네트워크 라우팅: 라우터 A가 링크 1을 점유하고 링크 2를 기다리고, 라우터 B가 링크 2를 점유하고 링크 1을 기다리면 데드락
  • 분산 시스템: 서비스 A가 서비스 B에 API 호출을 하며 대기하고, 서비스 B가 서비스 A에 콜백을 하며 대기하면 분산 데드락

철학자 문제의 핵심은 젓가락 자체가 아니라 여러 주체가 같은 자원 집합을 다른 타이밍에 붙잡는 구조입니다.

해결 전략들은 결국 순환 대기를 깨거나, 동시에 진입하는 수를 제한하거나, 두 자원을 원자적으로 확인하게 만드는 방식으로 수렴합니다.


실무에서의 동기화 패턴

현대 프로그래밍에서는 저수준 동기화 프리미티브보다 더 안전한 상위 추상화를 선호합니다.

동기화 도구를 고를 때는 공유 상태가 있는지, 블로킹을 허용할 수 있는지, 실패 시 복구 정책이 있는지를 먼저 나누면 선택이 쉬워집니다.

Lock-Free 자료구조: CAS 기반으로 락 없이 동시 접근을 지원합니다.

Java의 ConcurrentLinkedQueue는 비블로킹 알고리즘의 예입니다. ConcurrentHashMap은 일부 갱신에 잠금을 사용할 수 있으므로 전체를 lock-free 자료구조로 분류하지 않습니다. CAS 사용만으로 성능 우위가 보장되지는 않습니다.

Actor 모델: 상태를 각 Actor의 소유 경계 안에 두어 직접 공유를 줄입니다.

각 Actor가 자신만의 상태를 가지고, 다른 Actor와는 메시지 전달로만 통신합니다.

Erlang이 선구자이며, Akka(JVM), Orleans(.NET)이 대표적입니다.

채널 기반 통신: Go의 유명한 격언 "Do not communicate by sharing memory; instead, share memory by communicating."

고루틴 간에 채널을 통해 데이터를 전달합니다.

채널은 송수신을 동기화하지만, 포인터 등으로 같은 객체를 계속 공유하면 그 객체의 접근 규칙이 별도로 필요합니다. 채널을 사용했다는 사실만으로 모든 공유 상태가 사라지지는 않습니다.

비동기 프로그래밍: Python의 asyncio, JavaScript의 async/await처럼 단일 스레드에서 동시성을 구현합니다.

단일 이벤트 루프에서도 await 사이에 다른 코루틴이 공유 상태를 바꾸면 논리적 경쟁 조건이 생길 수 있습니다. 상태 소유권이나 asyncio.Lock 같은 해당 실행 모델의 동기화가 필요할 수 있습니다.

다음 장에서는 동기화 실패의 극단적 결과인 교착 상태(Deadlock)를 본격적으로 다루겠습니다.

교착 상태의 필요조건, 예방, 회피, 탐지와 복구 전략을 살펴봅니다.