본문 바로가기
TYLER SONGBlog
블로그 목록
CS학습

Monitor: 상호 배제와 조건 변수를 하나로 묶은 동기화 구조물

Monitor는 공유 자원에 대한 접근을 하나의 락과 조건 변수로 캡슐화하여, 세마포어를 직접 다룰 때 발생하는 실수를 줄이는 고수준 동기화 기법이다. 임계 구역 진입은 자동으로 직렬화되고, 특정 조건을 기다려야 하는 스레드는 조건 변수를 통해 대기하고 깨어난다. Java의 synchronized, Python의 threading.Condition이 대표적

송민성6분 읽기

1. 개념

Monitor(모니터)는 공유 자원과 그 자원에 접근하는 프로시저(메서드)들을 하나의 모듈로 캡슐화하고, 그 모듈에 대한 상호 배제(mutual exclusion)를 언어 또는 런타임 차원에서 보장하는 동기화 구조물이다.

핵심 아이디어는 두 가지다.

  • 상호 배제: 한 시점에 모니터 내부의 메서드는 오직 하나의 스레드만 실행할 수 있다.
  • 조건 변수(condition variable): 특정 조건이 만족될 때까지 스레드를 대기시키고, 조건이 바뀌면 대기 중인 스레드를 깨우는 메커니즘.

세마포어(semaphore)가 카운터 하나로 동기화를 표현하는 저수준 도구라면, 모니터는 "락 + 조건 변수 + 캡슐화"를 언어 차원에서 묶어 제공하는 고수준 도구다.

2. 왜 사용하는가

세마포어를 직접 사용하면 wait()signal() 호출 순서를 프로그래머가 수동으로 관리해야 한다. 호출 하나만 빠뜨리거나 순서가 어긋나도 데드락(deadlock)이나 경쟁 상태(race condition)가 발생한다.

모니터는 다음 문제를 구조적으로 해결한다.

  • 임계 구역 진입/퇴출을 언어 문법(synchronized, with lock)이 자동 처리하므로 락 해제 누락 실수가 줄어든다.
  • 조건 변수를 모니터 내부에 두어 "자원이 준비될 때까지 대기"라는 패턴을 표준화한다.
  • 공유 상태와 그 상태를 다루는 로직이 한 모듈에 모여 있어 코드 리뷰와 유지보수가 쉽다.

3. 동작 원리

모니터는 내부적으로 다음 요소로 구성된다.

  1. 진입 락(entry lock): 모니터 메서드 하나에 진입하면 다른 스레드는 대기 큐에서 블록된다.
  2. 조건 변수 큐: wait()를 호출한 스레드가 대기하는 큐. 조건 변수마다 별도 큐를 가진다.
  3. signal/notify: 조건이 충족되면 대기 큐의 스레드 하나(또는 전체)를 깨운다.

여기서 중요한 차이가 Hoare 방식Mesa 방식이다.

  • Hoare 모니터: signal을 호출하면 즉시 대기 중이던 스레드에게 제어권을 넘긴다. signal을 호출한 스레드는 즉시 블록된다. 조건이 signal 시점 그대로 유지됨을 보장하지만 구현이 복잡하다.
  • Mesa 모니터: signal은 단지 "깨어나도 좋다"는 힌트일 뿐, 즉시 실행을 넘기지 않는다. 깨어난 스레드는 락을 다시 획득한 뒤 조건을 다시 검사해야 한다. Java, POSIX pthread, Python이 모두 이 방식을 따른다.

Mesa 방식에서는 if (조건)이 아니라 반드시 while (조건)으로 조건을 재검사해야 한다. 스레드가 깨어난 시점과 실제 실행 시점 사이에 다른 스레드가 끼어들어 조건이 다시 깨질 수 있기 때문이다.

4. 코드 예제

Python의 threading.Condition은 락과 조건 변수를 결합한 모니터 스타일 API다. 생산자-소비자 문제로 구현한다.

python
import threading import time import random from collections import deque class BoundedBuffer: def __init__(self, capacity: int): self.capacity = capacity self.queue = deque() # Condition은 내부적으로 Lock을 하나 갖는 모니터 구조체다. self.condition = threading.Condition() def put(self, item): with self.condition: # 진입 락 획득 while len(self.queue) >= self.capacity: # Mesa 방식: 깨어난 뒤에도 while로 조건을 재검사한다. self.condition.wait() self.queue.append(item) print(f"[생산] {item}, 현재 크기={len(self.queue)}") self.condition.notify_all() # 대기 중인 소비자를 깨운다. def get(self): with self.condition: while len(self.queue) == 0: self.condition.wait() item = self.queue.popleft() print(f"[소비] {item}, 현재 크기={len(self.queue)}") self.condition.notify_all() # 대기 중인 생산자를 깨운다. return item def producer(buffer: BoundedBuffer, count: int): for i in range(count): buffer.put(i) time.sleep(random.uniform(0.01, 0.05)) def consumer(buffer: BoundedBuffer, count: int): for _ in range(count): buffer.get() time.sleep(random.uniform(0.02, 0.06)) if __name__ == "__main__": buf = BoundedBuffer(capacity=3) t1 = threading.Thread(target=producer, args=(buf, 10)) t2 = threading.Thread(target=consumer, args=(buf, 10)) t1.start(); t2.start() t1.join(); t2.join()

with self.condition이 진입 락 역할을, wait()/notify_all()이 조건 변수 역할을 한다. wait()는 호출 시 락을 자동으로 해제하고 대기하며, 깨어날 때 다시 락을 획득한 뒤 리턴한다.

5. 시간 복잡도 또는 성능 특성

모니터 자체는 자료구조가 아니라 동기화 프리미티브이므로 Big-O 표기보다는 비용 요인으로 설명하는 것이 정확하다.

  • 락 획득/해제: 경합이 없을 때는 사용자 공간에서 CAS(compare-and-swap) 연산 한 번으로 처리되는 경우가 많아 수십 나노초 수준이다.
  • 경합 발생 시: 커널 개입이 필요해 컨텍스트 스위칭 비용(마이크로초 단위)이 추가된다.
  • wait/notify: 스레드를 대기 큐에 넣고 깨우는 과정에서 커널 스케줄러가 개입하므로 순수 연산보다 비용이 훨씬 크다.
  • notify_all vs notify: notify_all은 대기 중인 모든 스레드를 깨우므로 "너 나 놀람(thundering herd)" 현상이 발생할 수 있다. 조건이 여러 종류라면 조건 변수를 분리해 notify 하나만 쓰는 것이 효율적이다.

6. 실무 사용 사례

  • Java `synchronized` + `wait()`/`notify()`: 모든 객체가 모니터 역할을 할 수 있는 Java의 기본 동기화 모델.
  • Python `threading.Condition`: 위 예제처럼 생산자-소비자, 작업 큐 구현에 사용.
  • 데이터베이스 커넥션 풀: 사용 가능한 커넥션이 없을 때 요청 스레드를 대기시키고, 커넥션이 반환되면 대기 스레드를 깨우는 구조가 모니터 패턴을 그대로 따른다.
  • 스레드 풀의 작업 큐: java.util.concurrent.LinkedBlockingQueue 내부 구현이 락과 조건 변수를 조합한 모니터 기반이다.

7. 주의할 점

  • 스푸리어스 웨이크업(spurious wakeup): 일부 플랫폼에서는 notify 없이도 대기 스레드가 깨어날 수 있다. 그래서 조건 검사는 반드시 if가 아니라 while 루프로 해야 한다.
  • notify 대상 누락: notify()는 대기 큐의 스레드 하나만 깨우는데, 깨어난 스레드가 조건에 맞지 않는 스레드라면 다른 스레드는 영원히 깨어나지 못할 수 있다. 확신이 없으면 notify_all()을 쓰는 것이 안전하다.
  • 중첩 락으로 인한 데드락: 모니터 두 개를 서로 다른 순서로 획득하면 순환 대기가 발생한다. 락 획득 순서를 일관되게 정의해야 한다.
  • wait 중 락 해제 착각: wait()는 대기 동안 락을 해제하지만, 다시 깨어난 뒤에는 반드시 락을 재획득한 상태로 리턴한다. 이 사이에 공유 상태가 바뀔 수 있다는 점을 항상 염두에 두어야 한다.
  • 긴 임계 구역: 모니터 메서드 안에서 I/O처럼 오래 걸리는 작업을 수행하면 다른 스레드가 오래 블록된다. 임계 구역은 최소한으로 유지해야 한다.

8. 핵심 정리

Monitor는 상호 배제와 조건 변수를 하나의 모듈로 캡슐화하여 세마포어의 저수준 실수 가능성을 줄인 동기화 구조물이다. Mesa 방식이 현대 언어의 표준이며, 이 방식에서는 깨어난 뒤 조건을 while로 반드시 재검사해야 한다. Java의 synchronized, Python의 threading.Condition처럼 언어 런타임이 기본 제공하는 경우가 많으며, 커넥션 풀이나 블로킹 큐 같은 실무 동시성 구조의 근간을 이룬다.

© 2026 Tyler Song