"뮤텍스는 하나, 세마포어는 여러 개"로 끝내면, 바이너리 세마포어와 뮤텍스의 차이를 설명할 수 없습니다. 핵심은 소유권 입니다.

이 글에서는 임계 영역 문제부터 시작해서, 각 동기화 도구가 왜 필요하고 어떻게 다른지 정리합니다. Java의 synchronized가 내부적으로 모니터를 사용한다는 것까지 연결해봅니다.


임계 영역 (Critical Section) 문제

여러 스레드가 공유 자원에 동시에 접근 하면 예상치 못한 결과가 나옵니다. 이걸 Race Condition이라고 하죠.

JAVA
// 두 스레드가 동시에 실행
count++;  // read → modify → write (3단계)

이 한 줄이 사실은 원자적이지 않습니다. 스레드 A가 값을 읽고 수정하는 사이에 스레드 B도 같은 값을 읽어버리면, 둘 다 1을 더했는데 결과는 1만 증가하는 거죠. 이런 문제가 발생하는 코드 영역을 임계 영역 이라고 부릅니다.

임계 영역 문제를 해결하려면 세 가지 조건을 만족해야 합니다.

조건설명
상호 배제 (Mutual Exclusion)한 스레드가 임계 영역에 있으면 다른 스레드는 진입 불가
진행 (Progress)임계 영역에 아무도 없으면, 들어가려는 스레드가 무한히 기다리면 안 됨
한정 대기 (Bounded Waiting)특정 스레드가 영원히 밀려나지 않아야 함 (기아 방지)

이 조건들을 만족시키기 위해 나온 도구들이 뮤텍스, 세마포어, 모니터입니다.


뮤텍스 (Mutex)

Mutual Exclusion의 줄임말. 가장 직관적인 잠금 메커니즘입니다.

핵심은 소유권 개념입니다. 락을 잡은 스레드만 락을 해제할 수 있습니다. 다른 스레드가 대신 풀어줄 수 없어요.

PLAINTEXT
lock(mutex)
    // 임계 영역
unlock(mutex)   ← 반드시 lock을 건 스레드가 해제

동작 방식을 좀 더 풀어보면:

  1. 스레드 A가 lock() 호출 → 락 획득, 임계 영역 진입
  2. 스레드 B가 lock() 호출 → 락이 이미 잡혀있으니 대기 큐(sleep) 로 들어감
  3. 스레드 A가 unlock() 호출 → 대기 중인 스레드 B를 깨움
  4. 스레드 B가 락 획득, 임계 영역 진입

Java에서는 ReentrantLock이 뮤텍스에 해당합니다.

JAVA
ReentrantLock mutex = new ReentrantLock();

mutex.lock();
try {
    // 임계 영역
} finally {
    mutex.unlock();  // 반드시 같은 스레드가 해제
}

ReentrantLock이라는 이름에서 알 수 있듯이 재진입(reentrant) 을 지원합니다. 같은 스레드가 이미 잡고 있는 락을 다시 잡아도 데드락에 빠지지 않아요. 내부적으로 카운터를 두고, 잡은 횟수만큼 풀어야 완전히 해제됩니다.


세마포어 (Semaphore)

뮤텍스가 열쇠 하나짜리 방이라면, 세마포어는 N개의 자리가 있는 주차장 같은 겁니다.

세마포어는 내부에 정수 카운터 를 갖고 있고, 두 가지 원자적 연산으로 제어합니다.

연산별칭동작
P 연산wait, acquire, down카운터 -= 1. 만약 카운터 < 0이면 대기
V 연산signal, release, up카운터 += 1. 대기 중인 스레드가 있으면 깨움

P와 V는 다익스트라가 네덜란드어에서 따온 이름입니다. Proberen(시도하다)과 Verhogen(증가시키다).

카운팅 세마포어

초기값 N으로 설정하면, 최대 N개의 스레드가 동시에 접근할 수 있습니다. 커넥션 풀이나 스레드 풀의 동시 접근 수를 제한할 때 유용합니다.

JAVA
Semaphore pool = new Semaphore(3);  // 동시 접근 3개까지

pool.acquire();  // P 연산 — 카운터 3 → 2
try {
    // 공유 자원 사용
} finally {
    pool.release();  // V 연산 — 카운터 2 → 3
}

바이너리 세마포어 (초기값 1)

카운터를 1로 설정하면 뮤텍스처럼 동작합니다. 그런데 여기서 핵심 포인트가 나옵니다.


뮤텍스 vs 세마포어, 진짜 차이

"바이너리 세마포어 = 뮤텍스"라고 답하면 틀립니다. 핵심 차이는 소유권 입니다.

구분뮤텍스세마포어
소유권있음 — 잠근 스레드만 해제 가능없음 — 아무 스레드나 signal 가능
용도상호 배제자원 카운팅, 순서 제어
재진입보통 지원 (ReentrantLock)개념 자체가 없음
우선순위 역전 처리Priority Inheritance 프로토콜 적용 가능불가능 (소유자가 없으니까)

세마포어는 소유권이 없기 때문에, 스레드 A가 acquire 하고 스레드 B가 release 하는 것도 가능합니다. 이 특성 덕분에 스레드 간 순서 제어(signaling) 에 쓸 수 있죠.

PLAINTEXT
// 세마포어로 실행 순서 보장
Semaphore sem = new Semaphore(0);

// 스레드 A: "일 다 했어" 신호
doWork();
sem.release();  // 다른 스레드가 release

// 스레드 B: A가 끝날 때까지 기다림
sem.acquire();  // A의 release를 대기
processResult();

뮤텍스로는 이런 패턴이 불가능합니다. 잠그지 않은 스레드가 풀려고 하면 IllegalMonitorStateException 같은 에러가 터지니까요.


모니터 (Monitor)

뮤텍스와 세마포어는 개발자가 직접 lock/unlock, acquire/release를 호출해야 합니다. 실수로 unlock을 빠뜨리면? 데드락입니다.

모니터는 이 문제를 언어 차원에서 해결 한 고수준 동기화 구조입니다. 핵심 구성 요소는 두 가지:

  1. 상호 배제: 모니터 내부 메서드는 한 번에 하나의 스레드만 실행 가능
  2. 조건 변수 (Condition Variable): wait/notify로 스레드 간 협력
PLAINTEXT
monitor BoundedBuffer {
    condition notFull, notEmpty;

    procedure put(item) {
        while (buffer가 가득 참)
            wait(notFull);       // 자리 날 때까지 대기
        buffer에 item 추가;
        signal(notEmpty);        // "이제 비어있지 않아" 알림
    }

    procedure get() {
        while (buffer가 비어있음)
            wait(notEmpty);      // 데이터 올 때까지 대기
        item = buffer에서 꺼냄;
        signal(notFull);         // "이제 가득 차지 않아" 알림
        return item;
    }
}

모니터의 wait()를 호출하면 자동으로 락을 해제 하고 대기 상태로 들어갑니다. signal()로 깨어나면 다시 락을 획득 한 후 실행을 재개합니다. 이 자동 관리가 모니터의 핵심이에요.

Java의 synchronized가 바로 모니터

Java의 모든 객체는 내부에 모니터 락(intrinsic lock) 을 하나씩 가지고 있습니다. synchronized 키워드가 이걸 사용합니다.

JAVA
synchronized (obj) {         // 모니터 락 획득
    while (!조건충족)
        obj.wait();          // 락 해제 + 대기
    // 임계 영역
    obj.notify();            // 대기 중인 스레드 하나 깨움
}                            // 모니터 락 자동 해제

wait(), notify(), notifyAll()은 반드시 synchronized 블록 안에서 호출해야 합니다. 모니터 락을 갖고 있지 않은 상태에서 호출하면 IllegalMonitorStateException이 발생합니다.

한 가지 아쉬운 점이 있는데, Java의 built-in 모니터는 조건 변수가 하나뿐 입니다. 위 BoundedBuffer 예제처럼 notFull, notEmpty를 구분할 수 없어요. notifyAll()로 전부 깨운 다음 조건을 다시 체크하는 수밖에 없습니다.

이걸 해결하려면 ReentrantLockCondition을 사용합니다.

JAVA
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition();
Condition notEmpty = lock.newCondition();

lock.lock();
try {
    while (buffer.isFull())
        notFull.await();        // notFull 조건 대기
    buffer.add(item);
    notEmpty.signal();          // notEmpty 조건에 시그널
} finally {
    lock.unlock();
}

Java의 동기화 도구 정리

"Java에서 동기화는 어떻게 하나요?" 이 질문에 답하려면, 도구별 차이를 명확하게 구분할 수 있어야 합니다.

synchronized

가장 기본적인 모니터 기반 동기화. 블록이 끝나면 자동으로 락을 해제하니까 실수로 unlock을 빠뜨릴 일이 없습니다.

JAVA
// 메서드 레벨
public synchronized void increment() {
    count++;
}

// 블록 레벨 — 락 범위를 좁힐 수 있음
public void increment() {
    synchronized (this) {
        count++;
    }
}

ReentrantLock

synchronized보다 세밀한 제어가 필요할 때 씁니다.

  • tryLock(): 락을 잡지 못하면 바로 리턴 (블로킹 안 함)
  • tryLock(timeout): 일정 시간만 기다림
  • lockInterruptibly(): 대기 중 인터럽트 가능
  • 여러 개의 Condition 변수
  • 공정(fair) 락 지원 — 대기 순서대로 락 획득
JAVA
ReentrantLock lock = new ReentrantLock(true);  // fair = true

if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        // 작업
    } finally {
        lock.unlock();
    }
} else {
    // 타임아웃 — 다른 처리
}

volatile

락이 아닙니다. 가시성(visibility) 만 보장합니다.

CPU 캐시 때문에, 한 스레드가 변수를 수정해도 다른 스레드가 이전 값을 볼 수 있습니다. volatile을 붙이면 항상 메인 메모리에서 읽고 씁니다.

JAVA
volatile boolean running = true;

// 스레드 A
while (running) {
    // 작업
}

// 스레드 B
running = false;  // A가 즉시 인지 가능

단, count++ 같은 복합 연산은 volatile로 안전하지 않습니다. read-modify-write가 원자적이지 않기 때문이에요.

Atomic 클래스

AtomicInteger, AtomicLong, AtomicReference 등. 내부적으로 CAS(Compare-And-Swap) 연산을 사용해서 락 없이 원자적 연산을 수행합니다.

JAVA
AtomicInteger count = new AtomicInteger(0);

count.incrementAndGet();  // 원자적 증가
count.compareAndSet(5, 10);  // 값이 5면 10으로 변경

락을 안 쓰니까 컨텍스트 스위칭 오버헤드가 없습니다. 경합이 낮은 상황에서 synchronized보다 훨씬 빠릅니다.


스핀락 (Spinlock)

뮤텍스에서 락을 못 잡으면 스레드가 sleep 상태로 들어간다고 했는데, 스핀락은 다릅니다. 계속 루프를 돌면서 락을 확인 합니다. 이걸 busy waiting이라고 부릅니다.

C
while (test_and_set(&lock) == true)
    ;  // 그냥 계속 돈다
// 락 획득

언제 쓰나

sleep/wake-up에는 컨텍스트 스위칭 비용이 듭니다. 임계 영역이 매우 짧다면, 잠들었다 깨는 것보다 잠깐 돌면서 기다리는 게 더 빠를 수 있습니다.

상황적합한 방식
임계 영역이 매우 짧음 (수 나노초)스핀락
임계 영역이 긴 편뮤텍스 (sleep)
싱글 코어스핀락 쓰면 안 됨 (어차피 다른 스레드가 실행 못 하니까)
멀티 코어 + 짧은 임계 영역스핀락이 유리

리눅스 커널 내부에서 자주 사용됩니다. 인터럽트 핸들러처럼 sleep이 불가능한 컨텍스트에서도 쓸 수 있다는 장점이 있습니다.

Java에서는 직접 스핀락을 구현할 일이 거의 없지만, JVM 내부적으로 synchronized의 경량 락(lightweight lock) 단계에서 짧은 스핀을 합니다. 경합이 심해지면 그제서야 OS 레벨 뮤텍스로 전환(inflation)되는 구조입니다.


심화 개념

CAS (Compare-And-Swap)

Atomic 클래스의 핵심입니다. 하드웨어 수준에서 지원하는 원자적 연산이에요.

PLAINTEXT
CAS(메모리 주소, 기대값, 새 값)
→ 현재 값이 기대값과 같으면 새 값으로 교체하고 true 반환
→ 다르면 아무것도 안 하고 false 반환
JAVA
// AtomicInteger.incrementAndGet() 내부 동작 (간소화)
do {
    current = get();           // 현재 값 읽기
    next = current + 1;        // 새 값 계산
} while (!compareAndSet(current, next));  // CAS 실패하면 재시도

실패하면 루프를 돌면서 재시도하니까 스핀과 비슷한데, 락을 잡지 않는다는 점이 다릅니다.

ABA 문제: CAS의 유명한 함정입니다. 값이 A → B → A로 바뀌면, CAS는 "아 그대로네?" 하고 성공해버립니다. 이걸 방지하려면 AtomicStampedReference처럼 버전 번호를 같이 관리합니다.

Lock-free 자료구조

CAS를 기반으로 락 없이 동시성을 보장하는 자료구조입니다.

  • ConcurrentLinkedQueue — lock-free 큐
  • ConcurrentSkipListMap — lock-free 정렬 맵

전통적인 락 방식은 하나의 스레드가 락을 잡으면 나머지가 전부 멈춥니다. Lock-free는 최소한 하나의 스레드는 항상 진행 한다는 걸 보장합니다. 처리량이 중요한 고성능 시스템에서 의미가 있습니다.

java.util.concurrent 패키지

"concurrent 패키지에서 뭘 써봤나요?"는 자주 헷갈리는 부분입니다. 주요 클래스를 정리하면:

클래스설명
ConcurrentHashMap세그먼트 단위 락. synchronized HashMap보다 훨씬 빠름
CopyOnWriteArrayList쓰기 시 배열 복사. 읽기가 압도적으로 많은 경우 유리
CountDownLatchN개의 작업이 끝날 때까지 기다림 (일회용)
CyclicBarrierN개의 스레드가 모두 도착하면 동시에 진행 (재사용 가능)
ExecutorService스레드 풀 관리
BlockingQueue생산자-소비자 패턴의 핵심. put/take가 블로킹
ReadWriteLock읽기는 여러 스레드 동시, 쓰기는 배타적
StampedLockReadWriteLock보다 낙관적 읽기를 지원해서 더 빠름

ConcurrentHashMap에 대해 조금 더 말하면, Java 7까지는 Segment 배열 기반이었고, Java 8부터 Node 배열 + CAS + synchronized로 바뀌었습니다. 개별 버킷 단위로 락을 거니까 경합이 크게 줄었습니다.


한눈에 비교

도구상호 배제소유권조건 변수레벨
뮤텍스OOX저수준
세마포어O (바이너리)XX저수준
모니터OOO고수준
스핀락OOX저수준

파생 개념

동기화 도구를 이해했다면, 다음 주제들로 자연스럽게 확장됩니다.

  • 데드락: 동기화 도구를 잘못 사용하면 발생하는 대표적인 문제. 발생 조건 4가지와 해결법은 별도 글에서 다룹니다
  • Java 멀티스레딩: Thread, Runnable, Callable, Future, CompletableFuture까지 이어지는 비동기 처리 흐름
  • 동시성 패턴: 생산자-소비자, 읽기-쓰기 락, 더블 체크 락킹 등 실제로 자주 쓰이는 패턴들
  • 분산 락: 단일 JVM을 넘어서면 Redis(Redisson), ZooKeeper 같은 외부 시스템 기반 락이 필요합니다. MSA 환경에서 자주 등장하는 주제
댓글 로딩 중...