세마포어와 모니터, 동기화 추상화의 선택 기준

세마포어와 모니터의 추상화 수준, 조건 동기화, 오류 가능성, 성능 특성을 비교하고 시스템과 애플리케이션에서의 선택 기준을 정리한다.

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. 유지보수 중요
// 구조화된 코드로 버그 감소
}
세마포어모니터프로세스 동기화운영체제조건 변수