세마포어와 모니터, 동기화 추상화의 선택 기준
세마포어와 모니터의 추상화 수준, 조건 동기화, 오류 가능성, 성능 특성을 비교하고 시스템과 애플리케이션에서의 선택 기준을 정리한다.
2026-08-14 · 최초 발행 2026-01-04
동기화 책임을 어디에 둘 것인가
세마포어와 모니터는 프로세스 동기화에 쓰이지만, 개발자가 직접 관리해야 하는 범위가 다르다. 세마포어는 원자적 연산을 제공하는 저수준 수단이고, 모니터는 상호배제와 조건 대기를 언어 수준에서 구조화한다. 이 차이는 코드의 가독성뿐 아니라 교착 상태, 대기 누락, 유지보수 난이도에도 이어진다.
| 특성 | Semaphore | Monitor |
|---|---|---|
| 추상화 | 저수준 신호 메커니즘 | 고수준 ADT (추상 자료형) |
| 상호배제 | 수동 (P/V 호출) | 자동 (메서드 진입 시) |
| 조건 동기화 | 추가 세마포어 필요 | Condition Variable 내장 |
| 캡슐화 | 없음 (전역 변수) | 있음 (데이터 + 프로시저) |
| 언어 지원 | 라이브러리 | Java, C# 등 |
| 오류 가능성 | 높음 | 낮음 |
세마포어는 상태와 동기화가 분리된다
세마포어 방식에서는 공유 데이터와 동기화 제어가 별도로 존재한다. 생산자와 소비자는 빈 공간, 채워진 항목, 상호배제를 각각 관리해야 하며 P/V 호출 순서도 코드의 일부가 된다.
// 전역 변수와 세마포어 분리
int shared_buffer[10];
int count = 0;
Semaphore mutex;
Semaphore empty;
Semaphore full;
init_semaphore(&mutex, 1);
init_semaphore(&empty, 10);
init_semaphore(&full, 0);
// 생산자 - 명시적 동기화 필요
void producer(int item) {
P(&empty); // 빈 공간 확보
P(&mutex); // 상호배제
shared_buffer[count++] = item;
V(&mutex); // 상호배제 해제
V(&full); // 채워진 항목 증가
}
// 소비자
int consumer() {
P(&full); // 채워진 항목 확보
P(&mutex); // 상호배제
int item = shared_buffer[--count];
V(&mutex); // 상호배제 해제
V(&empty); // 빈 공간 증가
return item;
}
이 구조에서는 P/V 순서를 잘못 두면 Deadlock이 생길 수 있고, V 호출을 빠뜨리면 대기 상태가 영구히 유지된다. 데이터와 동기화 로직이 분리되어 있어 수정 시 함께 확인해야 할 지점도 늘어난다.
모니터는 데이터와 대기 규칙을 함께 감싼다
모니터는 공유 데이터와 이를 다루는 메서드를 한 단위로 묶는다. Java의 synchronized, wait, notifyAll은 이 방식을 보여주는 대표적인 형태다.
// 데이터와 동기화 로직 캡슐화
public class BoundedBuffer {
private int[] buffer = new int[10];
private int count = 0;
// 자동 상호배제 (synchronized)
public synchronized void produce(int item) throws InterruptedException {
// 조건 대기
while (count == buffer.length) {
wait(); // 자동으로 lock 해제하고 대기
}
buffer[count++] = item;
notifyAll(); // 대기자 깨움
}
public synchronized int consume() throws InterruptedException {
while (count == 0) {
wait();
}
int item = buffer[--count];
notifyAll();
return item;
}
}
메서드 진입 시 상호배제가 적용되고, 조건을 기다릴 때는 잠금을 해제한 뒤 대기한다. 데이터와 동기화 규칙이 한 클래스 안에 머물기 때문에 호출 누락이나 분산된 상태 변경을 줄이기 쉽다.
생산자와 소비자에서 드러나는 관리 방식의 차이
세마포어로 생산자-소비자 문제를 다루면 mutex, empty, full처럼 역할이 다른 세마포어를 함께 운영한다.
// 3개의 세마포어 필요
Semaphore mutex = 1;
Semaphore empty = N;
Semaphore full = 0;
void producer() {
while (1) {
produce_item();
P(&empty); // ← 순서 중요!
P(&mutex); // ← 순서 바뀌면 Deadlock
add_to_buffer();
V(&mutex);
V(&full);
}
}
void consumer() {
while (1) {
P(&full); // ← 순서 중요!
P(&mutex); // ← 순서 바뀌면 Deadlock
remove_from_buffer();
V(&mutex);
V(&empty);
consume_item();
}
}
모니터에서는 버퍼 상태를 확인하는 조건과 대기 처리가 해당 객체 안에 들어간다.
public class ProducerConsumer {
private Queue<Item> buffer = new LinkedList<>();
private final int MAX_SIZE = 10;
public synchronized void produce(Item item) throws InterruptedException {
while (buffer.size() == MAX_SIZE) {
wait(); // 순서 걱정 없음
}
buffer.add(item);
notifyAll();
}
public synchronized Item consume() throws InterruptedException {
while (buffer.isEmpty()) {
wait(); // 순서 걱정 없음
}
Item item = buffer.remove();
notifyAll();
return item;
}
}
세마포어 방식은 P/V 호출 순서가 맞아야 하므로 Deadlock 위험을 직접 관리해야 한다. 모니터는 메서드 경계에서 상호배제를 보장해 호출 순서에 대한 부담을 줄인다.
Readers-Writers의 상태 관리는 더 복잡해진다
Readers-Writers 문제에서는 세마포어가 읽기 횟수와 쓰기 잠금을 별도로 보호한다.
int read_count = 0;
Semaphore mutex = 1; // read_count 보호
Semaphore write_lock = 1; // 쓰기 상호배제
void reader() {
P(&mutex);
read_count++;
if (read_count == 1)
P(&write_lock); // 첫 reader가 writer 차단
V(&mutex);
// 읽기 수행
read_data();
P(&mutex);
read_count--;
if (read_count == 0)
V(&write_lock); // 마지막 reader가 writer 허용
V(&mutex);
}
void writer() {
P(&write_lock);
// 쓰기 수행
write_data();
V(&write_lock);
}
모니터를 사용하면 읽는 중인 수와 쓰기 상태를 클래스 내부의 상태로 두고, 접근 규칙을 메서드에 붙일 수 있다.
public class ReadersWriters {
private int readers = 0;
private boolean writing = false;
public synchronized void startRead() throws InterruptedException {
while (writing) {
wait();
}
readers++;
}
public synchronized void endRead() {
readers--;
if (readers == 0) {
notifyAll();
}
}
public synchronized void startWrite() throws InterruptedException {
while (writing || readers > 0) {
wait();
}
writing = true;
}
public synchronized void endWrite() {
writing = false;
notifyAll();
}
}
세마포어에서는 카운터와 세마포어가 중첩되어 관리된다. 모니터에서는 플래그와 카운터를 같은 동기화 경계 안에 둘 수 있다.
조건을 기다리는 방법
조건 대기가 필요한 세마포어 코드에서는 조건별 세마포어와 잠금 해제·재획득 순서를 직접 작성한다.
// 특정 조건 대기를 위해 추가 세마포어 필요
Semaphore condition1 = 0;
Semaphore condition2 = 0;
Semaphore mutex = 1;
void wait_for_condition1() {
P(&mutex);
// 조건 확인
if (!condition_met) {
V(&mutex); // Lock 해제
P(&condition1); // 조건 대기
P(&mutex); // Lock 재획득
}
V(&mutex);
}
void signal_condition1() {
P(&mutex);
// 조건 만족시킴
make_condition_true();
V(&condition1); // 대기자 깨움
V(&mutex);
}
조건이 늘어날수록 세마포어와 잠금 상태를 맞추는 부담도 커진다. 원자성 보장이 어려운 이유도 여기에 있다.
모니터의 조건 변수는 잠금 해제와 재획득을 대기 연산에 포함한다.
public class MultiCondition {
private boolean condition1 = false;
private boolean condition2 = false;
public synchronized void waitForCondition1() throws InterruptedException {
while (!condition1) {
wait(); // 자동 lock 해제 및 대기
}
// wait()에서 깨어나면 자동으로 lock 재획득
}
public synchronized void signalCondition1() {
condition1 = true;
notifyAll(); // 모든 대기자 깨움
}
// 여러 조건을 쉽게 추가 가능
}
C++에서는 조건마다 별도의 조건 변수를 둘 수 있다.
#include <mutex>
#include <condition_variable>
class MultiCondition {
std::mutex mtx;
std::condition_variable cv1;
std::condition_variable cv2;
bool condition1 = false;
bool condition2 = false;
public:
void waitForCondition1() {
std::unique_lock<std::mutex> lock(mtx);
cv1.wait(lock, [this] { return condition1; });
// Lambda로 조건 표현, Spurious wakeup 자동 처리
}
void signalCondition1() {
{
std::lock_guard<std::mutex> lock(mtx);
condition1 = true;
}
cv1.notify_all(); // 특정 조건 대기자만 깨움
}
};
여러 조건 변수를 독립적으로 관리할 수 있고, 대기 중 잠금 해제와 깨어난 뒤의 잠금 재획득도 구조 안에서 처리된다.
세마포어 코드에서 자주 생기는 균열
세마포어는 유연하지만 P/V 호출 자체가 정확해야 한다. 다음과 같은 실수는 상호배제와 대기 상태를 쉽게 깨뜨린다.
// 1. P/V 순서 실수
P(&sem2);
P(&sem1); // 다른 프로세스는 sem1 → sem2 순서
// → Deadlock!
// 2. V 누락
P(&sem);
critical_section();
// V(&sem) 누락!
// → 영구 대기
// 3. 중복 V 호출
P(&sem);
critical_section();
V(&sem);
V(&sem); // 실수로 중복
// → 상호배제 위반
// 4. 잘못된 세마포어 사용
P(&wrong_sem);
critical_section();
V(&correct_sem);
// → 예측 불가능한 동작
모니터는 동기화 경계를 언어 구성 요소로 제공해 이런 누락을 줄인다.
// 1. 자동 상호배제 - 빠뜨릴 수 없음
public synchronized void method() {
// 자동으로 lock/unlock
}
// 2. 예외 발생 시에도 안전
public synchronized void method() {
try {
critical_section();
} finally {
// synchronized는 자동으로 unlock
}
}
// 3. 조건 변수 잘못 사용해도
public synchronized void method() throws InterruptedException {
wait(); // Lock을 소유하지 않으면 예외 발생
}
// 4. 컴파일러 검증
public void method() {
wait(); // 컴파일 에러: synchronized 블록 내에서만 호출 가능
}
제어 범위와 오버헤드의 균형
세마포어는 필요한 구간만 분리해 보호하기 쉬워 Lock 세분화와 성능 최적화의 여지가 크다.
// 장점: 세밀한 제어
Semaphore lock1, lock2;
void fine_grained() {
P(&lock1);
// Section 1
V(&lock1);
// 중간 작업 (Lock 없음)
P(&lock2);
// Section 2
V(&lock2);
}
모니터는 메서드 전체를 동기화 경계로 둘 수 있다. 필요한 경우 synchronized 블록으로 임계 영역을 좁힐 수 있다.
// 단점: 메서드 전체가 임계 영역
public synchronized void method() {
// 전체가 상호배제됨
non_critical_part1();
critical_part();
non_critical_part2();
}
// 개선: synchronized 블록 사용
public void method() {
non_critical_part1();
synchronized(this) {
critical_part(); // 필요한 부분만
}
non_critical_part2();
}
| 시나리오 | Semaphore | Monitor | 차이 |
|---|---|---|---|
| 단순 상호배제 | 100ns | 105ns | Monitor 약간 느림 |
| 조건 대기 | 150ns | 120ns | Monitor 더 빠름 (내장) |
| 복잡한 동기화 | 200ns | 140ns | Monitor 효율적 |
| 오버헤드 | 낮음 | 중간 | Monitor는 메타데이터 필요 |
실행 환경에 따른 사용 모습
Linux Kernel은 주로 Semaphore와 Spinlock을 사용한다.
// Kernel은 주로 Semaphore/Spinlock 사용
struct semaphore sem;
sema_init(&sem, 1);
down(&sem); // P 연산
// Critical Section
up(&sem); // V 연산
// Monitor는 언어 지원 필요 → Kernel에 부적합
Java 애플리케이션에서는 세마포어를 라이브러리로 사용하거나, 언어에 내장된 모니터를 사용할 수 있다.
// Java는 Monitor 기본 제공
public class JavaSync {
// Semaphore는 라이브러리로 제공
import java.util.concurrent.Semaphore;
Semaphore sem = new Semaphore(1);
public void useSemaphore() throws InterruptedException {
sem.acquire();
// Critical Section
sem.release();
}
// Monitor는 언어 내장
public synchronized void useMonitor() {
// Critical Section
}
}
애플리케이션에서는 편의성을 이유로 Monitor를 우선 고려할 수 있다. 커널이나 시스템 프로그래밍처럼 세밀한 제어가 필요한 환경에서는 Semaphore가 적합하다.
추상화는 서로 구현할 수 있다
모니터의 대기와 알림을 이용해 세마포어의 P/V 연산을 구성할 수 있다.
public class SemaphoreImpl {
private int value;
public SemaphoreImpl(int initial) {
this.value = initial;
}
public synchronized void P() throws InterruptedException {
while (value <= 0) {
wait();
}
value--;
}
public synchronized void V() {
value++;
notifyAll();
}
}
반대로 세마포어와 조건용 큐를 조합해 모니터의 진입, 퇴장, 조건 대기를 구현할 수도 있다.
typedef struct {
Semaphore mutex; // 상호배제
Semaphore next; // signal 후 대기
int next_count;
} Monitor;
typedef struct {
Semaphore sem;
int count;
} Condition;
void enter_monitor(Monitor *m) {
P(&m->mutex);
}
void exit_monitor(Monitor *m) {
if (m->next_count > 0)
V(&m->next);
else
V(&m->mutex);
}
void wait_condition(Monitor *m, Condition *c) {
c->count++;
if (m->next_count > 0)
V(&m->next);
else
V(&m->mutex);
P(&c->sem);
c->count--;
}
void signal_condition(Monitor *m, Condition *c) {
if (c->count > 0) {
m->next_count++;
V(&c->sem);
P(&m->next);
m->next_count--;
}
}
이론적으로는 상호 변환이 가능하지만, 실제 구현에서는 각 방식이 제공하는 장점을 살려 선택하는 편이 낫다.
선택은 필요한 제어 수준에서 시작한다
시스템 프로그래밍, 자원 풀의 카운팅, 이벤트 신호 전달, 세밀한 최적화가 필요한 경우에는 세마포어가 맞을 수 있다.
// 1. 시스템 프로그래밍
// 커널, 디바이스 드라이버 등
// 2. 카운팅 필요
Semaphore resources = 10; // 10개 자원 풀
// 3. 신호 전달
Semaphore event = 0;
// Producer: V(&event)
// Consumer: P(&event)
// 4. 최대 성능 필요
// 세밀한 최적화 가능
비즈니스 로직이나 서버 개발처럼 복잡한 조건 동기화, 팀 협업, 유지보수가 중심인 경우에는 모니터의 캡슐화가 유리하다.
// 1. 애플리케이션 개발
// 비즈니스 로직, 서버 등
// 2. 복잡한 조건 동기화
public synchronized void complexLogic() {
while (!condition1 || !condition2) {
wait();
}
}
// 3. 팀 개발
// 명확한 캡슐화로 협업 용이
// 4. 유지보수 중요
// 구조화된 코드로 버그 감소
}