🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

A

AI Generated

2025. 12. 28. · 35 Views

동기화 도구 완벽 가이드

멀티스레드 프로그래밍에서 핵심이 되는 동기화 도구들을 알아봅니다. 뮤텍스, 세마포어, 모니터 등 운영체제의 동기화 메커니즘을 실무 예제와 함께 쉽게 이해할 수 있습니다.


목차

  1. 뮤텍스 (Mutex)
  2. 세마포어 (Semaphore)
  3. 이진 세마포어 vs 카운팅 세마포어
  4. 생산자-소비자 문제
  5. Readers-Writers 문제
  6. 모니터 (Monitor)

1. 뮤텍스 (Mutex)

김개발 씨는 오늘 이상한 버그를 발견했습니다. 분명히 은행 잔고가 10만원이었는데, 두 개의 스레드가 동시에 출금을 시도하자 잔고가 마이너스가 되어버린 것입니다.

어떻게 이런 일이 가능한 걸까요?

**뮤텍스(Mutex)**는 Mutual Exclusion의 줄임말로, 한 번에 하나의 스레드만 공유 자원에 접근할 수 있도록 보장하는 잠금 장치입니다. 마치 화장실 문에 달린 잠금장치처럼, 누군가 안에 들어가면 다른 사람은 밖에서 기다려야 합니다.

이것을 제대로 이해하면 데이터 경쟁 조건을 방지하고 안전한 멀티스레드 프로그램을 작성할 수 있습니다.

다음 코드를 살펴봅시다.

import java.util.concurrent.locks.ReentrantLock;

public class BankAccount {
    private int balance = 100000;
    private final ReentrantLock mutex = new ReentrantLock();

    public void withdraw(int amount) {
        // 뮤텍스 획득 - 다른 스레드는 여기서 대기
        mutex.lock();
        try {
            if (balance >= amount) {
                balance -= amount;
                System.out.println("출금 완료: " + amount);
            }
        } finally {
            // 반드시 뮤텍스 해제 - finally로 보장
            mutex.unlock();
        }
    }
}

김개발 씨는 입사 3개월 차 주니어 개발자입니다. 오늘 배포한 은행 시스템에서 심각한 버그가 발견되었습니다.

고객의 잔고가 10만원인데, 두 곳에서 동시에 8만원씩 출금을 시도했더니 둘 다 성공해버린 것입니다. 선배 개발자 박시니어 씨가 다가와 코드를 살펴봅니다.

"아, 여기가 문제네요. 뮤텍스를 사용하지 않아서 생긴 전형적인 경쟁 조건(Race Condition) 버그예요." 그렇다면 뮤텍스란 정확히 무엇일까요?

쉽게 비유하자면, 뮤텍스는 마치 공중화장실의 잠금장치와 같습니다. 화장실에 누군가 들어가서 문을 잠그면, 다른 사람은 그 사람이 나올 때까지 밖에서 기다려야 합니다.

안에 있는 사람이 문을 열고 나와야만 다음 사람이 들어갈 수 있습니다. 이처럼 뮤텍스도 하나의 스레드가 임계 영역에 들어가면 다른 스레드의 진입을 막는 역할을 합니다.

뮤텍스가 없던 시절에는 어땠을까요? 두 스레드가 동시에 같은 변수를 읽고 수정하면 예측할 수 없는 결과가 발생했습니다.

스레드 A가 잔고를 읽고, 스레드 B도 같은 잔고를 읽은 뒤, 둘 다 각자 계산한 값을 저장하면 하나의 연산이 사라져버립니다. 이것이 바로 데이터 경쟁입니다.

바로 이런 문제를 해결하기 위해 뮤텍스가 등장했습니다. 뮤텍스를 사용하면 상호 배제가 보장됩니다.

한 스레드가 잠금을 획득하면 다른 스레드는 그 잠금이 해제될 때까지 대기합니다. 이렇게 하면 공유 자원에 대한 접근이 순차적으로 이루어져 데이터 일관성이 유지됩니다.

위의 코드를 한 줄씩 살펴보겠습니다. 먼저 ReentrantLock을 뮤텍스로 사용합니다.

withdraw 메서드가 호출되면 가장 먼저 **mutex.lock()**을 호출하여 잠금을 획득합니다. 만약 다른 스레드가 이미 잠금을 가지고 있다면, 현재 스레드는 여기서 멈추고 기다립니다.

잠금을 획득한 후에야 잔고 확인과 출금 로직이 실행됩니다. 마지막으로 finally 블록에서 반드시 잠금을 해제합니다.

실제 현업에서는 어떻게 활용할까요? 데이터베이스 커넥션 풀 관리, 파일 쓰기 작업, 공유 캐시 업데이트 등 여러 스레드가 동시에 접근할 수 있는 모든 곳에서 뮤텍스가 사용됩니다.

특히 금융 시스템에서는 잔고 업데이트 같은 중요한 연산에 필수적입니다. 하지만 주의할 점도 있습니다.

뮤텍스를 획득한 후 반드시 해제해야 합니다. 예외가 발생해서 unlock이 호출되지 않으면 다른 스레드가 영원히 대기하는 **교착 상태(Deadlock)**가 발생할 수 있습니다.

따라서 항상 try-finally 패턴을 사용하여 잠금 해제를 보장해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.

박시니어 씨의 설명을 들은 김개발 씨는 출금 메서드에 뮤텍스를 적용했고, 더 이상 잔고가 마이너스가 되는 일은 발생하지 않았습니다. 뮤텍스는 멀티스레드 프로그래밍의 가장 기본적이면서도 중요한 동기화 도구입니다.

실전 팁

💡 - 뮤텍스는 반드시 try-finally 패턴으로 해제를 보장하세요

  • 뮤텍스를 오래 잡고 있으면 성능이 저하되므로 임계 영역은 최소화하세요
  • Java에서는 synchronized 키워드나 ReentrantLock을 사용할 수 있습니다

2. 세마포어 (Semaphore)

김개발 씨가 뮤텍스를 마스터했다고 생각한 순간, 새로운 과제가 주어졌습니다. 데이터베이스 커넥션 풀을 구현해야 하는데, 최대 5개의 커넥션만 동시에 사용할 수 있어야 한다는 것입니다.

뮤텍스로는 한 명만 들어갈 수 있는데, 5명까지 허용하려면 어떻게 해야 할까요?

**세마포어(Semaphore)**는 지정된 개수만큼의 스레드가 동시에 자원에 접근할 수 있도록 허용하는 동기화 도구입니다. 마치 주차장에 5대만 주차할 수 있을 때, 입구에서 남은 자리 수를 표시하는 전광판과 같습니다.

세마포어를 이해하면 제한된 자원을 효율적으로 관리하는 시스템을 구축할 수 있습니다.

다음 코드를 살펴봅시다.

import java.util.concurrent.Semaphore;

public class ConnectionPool {
    // 최대 5개 커넥션 허용
    private final Semaphore semaphore = new Semaphore(5);

    public void useConnection() throws InterruptedException {
        // 허가 획득 - 카운트가 0이면 대기
        semaphore.acquire();
        try {
            System.out.println("커넥션 사용 중... 남은 허가: "
                + semaphore.availablePermits());
            Thread.sleep(1000);
        } finally {
            // 허가 반환 - 카운트 증가
            semaphore.release();
        }
    }
}

김개발 씨는 새로운 도전에 직면했습니다. 데이터베이스 커넥션은 귀중한 자원이라 무한정 만들 수 없습니다.

최대 5개의 커넥션만 동시에 사용할 수 있도록 제한해야 합니다. 박시니어 씨가 힌트를 줍니다.

"뮤텍스는 한 명만 들어갈 수 있잖아요. 그런데 여러 명이 동시에 들어갈 수 있게 하려면 세마포어를 써야 해요." 그렇다면 세마포어란 정확히 무엇일까요?

쉽게 비유하자면, 세마포어는 마치 주차장 입구의 전광판과 같습니다. 주차장에 5대만 주차할 수 있다면, 전광판에는 "남은 자리: 5"라고 표시됩니다.

차가 한 대 들어가면 4로 줄어들고, 나가면 다시 늘어납니다. 0이 되면 새로운 차는 빈자리가 생길 때까지 기다려야 합니다.

세마포어의 핵심은 카운터입니다. 세마포어는 내부에 정수 값을 가지고 있습니다.

acquire() 메서드를 호출하면 카운터가 1 감소하고, release() 메서드를 호출하면 1 증가합니다. 카운터가 0이면 acquire를 호출한 스레드는 다른 스레드가 release를 호출할 때까지 대기합니다.

위의 코드를 살펴보겠습니다. **Semaphore(5)**로 초기 허가 수를 5로 설정합니다.

useConnection이 호출되면 먼저 **acquire()**로 허가를 얻습니다. 5개의 스레드까지는 바로 허가를 얻을 수 있지만, 6번째 스레드부터는 대기해야 합니다.

작업이 끝나면 **release()**로 허가를 반환합니다. 실제 현업에서 세마포어는 다양하게 활용됩니다.

데이터베이스 커넥션 풀, 스레드 풀, API 요청 속도 제한(Rate Limiting) 등에서 널리 사용됩니다. 예를 들어 외부 API가 초당 10개의 요청만 허용한다면, 세마포어로 동시 요청 수를 제한할 수 있습니다.

세마포어와 뮤텍스의 차이점은 무엇일까요? 뮤텍스는 카운터가 1인 특수한 경우입니다.

오직 한 스레드만 진입 가능합니다. 반면 세마포어는 N개의 스레드가 동시에 진입할 수 있습니다.

또한 뮤텍스는 잠금을 획득한 스레드만 해제할 수 있지만, 세마포어는 다른 스레드가 release를 호출할 수도 있습니다. 주의할 점도 있습니다.

acquire와 release의 횟수가 맞지 않으면 문제가 발생합니다. release를 너무 많이 호출하면 허가 수가 초기값을 초과할 수 있고, acquire 후 release를 잊으면 자원이 고갈됩니다.

따라서 세마포어도 try-finally 패턴을 사용해야 합니다. 김개발 씨는 세마포어를 사용해 커넥션 풀을 성공적으로 구현했습니다.

이제 동시에 5개의 커넥션만 사용되고, 6번째 요청부터는 자연스럽게 대기열에 들어갑니다.

실전 팁

💡 - 세마포어의 초기값은 동시 접근 가능한 최대 스레드 수입니다

  • acquire()는 블로킹되므로, 타임아웃이 필요하면 tryAcquire()를 사용하세요
  • 세마포어는 자원 풀링 패턴 구현에 매우 유용합니다

3. 이진 세마포어 vs 카운팅 세마포어

박시니어 씨가 김개발 씨에게 퀴즈를 냈습니다. "카운터가 1인 세마포어와 뮤텍스는 같은 거 아니에요?" 김개발 씨는 잠시 생각에 빠졌습니다.

둘 다 한 번에 하나만 통과시키는 건 같은데, 뭔가 다른 점이 있을 것 같습니다.

세마포어는 허가 수에 따라 이진 세마포어카운팅 세마포어로 나뉩니다. 이진 세마포어는 0과 1 두 가지 상태만 가지며 뮤텍스와 비슷하게 동작합니다.

카운팅 세마포어는 N개의 동시 접근을 허용합니다. 각각의 특성을 이해하면 상황에 맞는 도구를 선택할 수 있습니다.

다음 코드를 살펴봅시다.

import java.util.concurrent.Semaphore;

public class SemaphoreTypes {
    // 이진 세마포어 - 0 또는 1만 가능
    private final Semaphore binarySem = new Semaphore(1);

    // 카운팅 세마포어 - N개까지 허용
    private final Semaphore countingSem = new Semaphore(5);

    public void useBinary() throws InterruptedException {
        binarySem.acquire();  // 한 스레드만 진입
        try {
            System.out.println("단독 작업 수행");
        } finally {
            binarySem.release();
        }
    }

    public void useCounting() throws InterruptedException {
        countingSem.acquire();  // 최대 5개 스레드 동시 진입
        try {
            System.out.println("공유 자원 사용");
        } finally {
            countingSem.release();
        }
    }
}

김개발 씨의 질문에 박시니어 씨가 차근차근 설명을 시작합니다. "이진 세마포어와 뮤텍스가 비슷해 보이지만, 중요한 차이가 있어요." **이진 세마포어(Binary Semaphore)**는 값이 0 또는 1만 가질 수 있습니다.

마치 방에 열쇠가 하나뿐인 것과 같습니다. 열쇠를 가진 사람만 방에 들어갈 수 있고, 나갈 때 열쇠를 반납합니다.

**카운팅 세마포어(Counting Semaphore)**는 N개의 값을 가질 수 있습니다. 마치 호텔의 여러 방처럼, 방이 5개면 5명까지 동시에 투숙할 수 있습니다.

그렇다면 이진 세마포어와 뮤텍스는 정말 같은 것일까요? 겉보기에는 비슷하지만 중요한 차이가 있습니다.

뮤텍스소유권 개념이 있습니다. 잠금을 획득한 스레드만이 해제할 수 있습니다.

반면 이진 세마포어는 소유권이 없습니다. 스레드 A가 acquire하고 스레드 B가 release하는 것도 가능합니다.

이 차이가 왜 중요할까요? 스레드 간 신호 전달(Signaling)에서 차이가 드러납니다.

예를 들어 스레드 A가 작업을 완료하면 스레드 B에게 신호를 보내야 하는 상황을 생각해봅시다. 이진 세마포어는 A가 release하고 B가 acquire하는 방식으로 신호를 전달할 수 있습니다.

하지만 뮤텍스는 이런 용도로 사용하기 어렵습니다. 카운팅 세마포어는 언제 사용할까요?

제한된 자원 풀을 관리할 때 사용합니다. 데이터베이스 커넥션 10개, 프린터 3대, API 동시 요청 수 100개 같은 상황에서 카운팅 세마포어가 적합합니다.

실무에서의 선택 기준을 정리해봅시다. 상호 배제가 목적이라면 뮤텍스를 사용하세요.

공유 변수 보호, 임계 영역 구현에 적합합니다. 스레드 간 신호 전달이 목적이라면 이진 세마포어를 사용하세요.

제한된 자원 관리가 목적이라면 카운팅 세마포어를 사용하세요. 박시니어 씨가 덧붙입니다.

"도구를 이해하고 목적에 맞게 선택하는 게 중요해요. 망치도 쓸 수 있지만, 나사를 조이려면 드라이버가 맞잖아요." 김개발 씨는 고개를 끄덕였습니다.

비슷해 보이는 도구들도 각각의 용도가 있다는 것을 깨달았습니다.

실전 팁

💡 - 단순 상호 배제에는 뮤텍스, 신호 전달에는 이진 세마포어를 사용하세요

  • 카운팅 세마포어의 초기값은 가용 자원 수와 일치시키세요
  • Java의 Semaphore 클래스는 카운팅 세마포어이며, 초기값 1로 이진 세마포어처럼 사용 가능합니다

4. 생산자-소비자 문제

김개발 씨에게 새로운 미션이 주어졌습니다. 로그 수집 시스템을 구현해야 하는데, 여러 서버에서 로그를 생성하고 별도의 처리기가 이를 소비하는 구조입니다.

그런데 생성 속도와 소비 속도가 다르면 어떻게 될까요?

생산자-소비자 문제는 동시성 프로그래밍의 고전적인 문제입니다. 생산자는 데이터를 만들어 버퍼에 넣고, 소비자는 버퍼에서 데이터를 꺼내 처리합니다.

버퍼가 가득 차면 생산자가 대기하고, 버퍼가 비면 소비자가 대기해야 합니다. 세마포어를 활용하면 이 문제를 우아하게 해결할 수 있습니다.

다음 코드를 살펴봅시다.

import java.util.concurrent.Semaphore;
import java.util.LinkedList;
import java.util.Queue;

public class ProducerConsumer {
    private final Queue<String> buffer = new LinkedList<>();
    private final int BUFFER_SIZE = 5;
    private final Semaphore empty = new Semaphore(BUFFER_SIZE);
    private final Semaphore full = new Semaphore(0);
    private final Semaphore mutex = new Semaphore(1);

    public void produce(String item) throws InterruptedException {
        empty.acquire();  // 빈 공간 대기
        mutex.acquire();  // 버퍼 접근 잠금
        buffer.offer(item);
        mutex.release();
        full.release();   // 데이터 있음 신호
    }

    public String consume() throws InterruptedException {
        full.acquire();   // 데이터 대기
        mutex.acquire();  // 버퍼 접근 잠금
        String item = buffer.poll();
        mutex.release();
        empty.release();  // 빈 공간 생김 신호
        return item;
    }
}

김개발 씨는 로그 수집 시스템을 설계하면서 고민에 빠졌습니다. 10대의 서버에서 초당 1000개의 로그가 생성되는데, 처리기는 초당 500개밖에 처리하지 못합니다.

어떻게 해야 할까요? 박시니어 씨가 화이트보드에 그림을 그리기 시작합니다.

"이게 바로 유명한 생산자-소비자 문제예요." 쉽게 비유하자면, 이것은 마치 빵집과 같습니다. 제빵사(생산자)가 빵을 구워서 진열대(버퍼)에 놓으면, 손님(소비자)이 진열대에서 빵을 가져갑니다.

진열대에 빈 공간이 없으면 제빵사는 기다려야 하고, 빵이 없으면 손님이 기다려야 합니다. 이 문제를 해결하려면 세 가지를 관리해야 합니다.

첫째, 버퍼가 가득 찼는지 확인해야 합니다. 생산자는 빈 공간이 있을 때만 데이터를 넣을 수 있습니다.

둘째, 버퍼가 비었는지 확인해야 합니다. 소비자는 데이터가 있을 때만 꺼낼 수 있습니다.

셋째, 버퍼 접근 자체를 동기화해야 합니다. 두 스레드가 동시에 버퍼를 수정하면 안 됩니다.

코드에서 세 개의 세마포어가 이 역할을 합니다. empty 세마포어는 빈 공간의 수를 나타냅니다.

초기값은 버퍼 크기(5)입니다. 생산자가 데이터를 넣으면 감소하고, 소비자가 꺼내면 증가합니다.

full 세마포어는 채워진 공간의 수입니다. 초기값은 0입니다.

생산자가 넣으면 증가하고, 소비자가 꺼내면 감소합니다. mutex는 버퍼 자체에 대한 접근을 보호합니다.

produce 메서드의 흐름을 따라가 봅시다. 먼저 **empty.acquire()**로 빈 공간을 확보합니다.

버퍼가 가득 차면 여기서 대기합니다. 그 다음 **mutex.acquire()**로 버퍼에 대한 배타적 접근을 얻습니다.

데이터를 버퍼에 넣고, **mutex.release()**로 잠금을 해제합니다. 마지막으로 **full.release()**로 "데이터가 생겼다"는 신호를 보냅니다.

consume 메서드는 정확히 반대입니다. **full.acquire()**로 데이터가 있는지 확인하고, 없으면 대기합니다.

mutex로 버퍼를 잠그고 데이터를 꺼낸 후, **empty.release()**로 "빈 공간이 생겼다"는 신호를 보냅니다. 이 패턴은 실무에서 매우 자주 사용됩니다.

메시지 큐, 작업 큐, 이벤트 버퍼, 로그 수집기 등 많은 시스템이 이 패턴을 기반으로 합니다. Java에서는 BlockingQueue가 이 패턴을 이미 구현해 두었으니, 실무에서는 직접 구현하기보다 표준 라이브러리를 활용하는 것이 좋습니다.

김개발 씨는 이 패턴으로 로그 수집 시스템을 구현했습니다. 버퍼 덕분에 생산 속도와 소비 속도의 차이가 흡수되어 시스템이 안정적으로 동작합니다.

실전 팁

💡 - 세마포어 acquire 순서가 중요합니다 - 잘못된 순서는 교착 상태를 유발할 수 있습니다

  • 실무에서는 BlockingQueue 같은 표준 라이브러리를 활용하세요
  • 버퍼 크기는 생산-소비 속도 차이와 메모리를 고려해 결정하세요

5. Readers-Writers 문제

김개발 씨가 만든 캐시 시스템에 문제가 생겼습니다. 읽기 요청이 대부분인데, 쓰기가 일어날 때마다 모든 읽기가 멈춰서 성능이 크게 떨어집니다.

읽기끼리는 동시에 해도 괜찮은데, 무조건 한 번에 하나씩만 처리하고 있었던 것입니다.

Readers-Writers 문제는 공유 데이터에 대해 읽기와 쓰기 연산을 효율적으로 동기화하는 문제입니다. 여러 Reader는 동시에 읽을 수 있지만, Writer는 혼자서만 쓸 수 있어야 합니다.

읽기가 많은 시스템에서 이 패턴을 적용하면 성능을 크게 향상시킬 수 있습니다.

다음 코드를 살펴봅시다.

import java.util.concurrent.Semaphore;

public class ReadersWriters {
    private int readerCount = 0;
    private final Semaphore mutex = new Semaphore(1);
    private final Semaphore writeLock = new Semaphore(1);

    public void read() throws InterruptedException {
        mutex.acquire();
        readerCount++;
        if (readerCount == 1) writeLock.acquire();
        mutex.release();

        // 읽기 수행 (여러 Reader 동시 가능)
        System.out.println("읽기 중... 현재 Reader: " + readerCount);

        mutex.acquire();
        readerCount--;
        if (readerCount == 0) writeLock.release();
        mutex.release();
    }

    public void write() throws InterruptedException {
        writeLock.acquire();
        System.out.println("쓰기 중... (단독)");
        writeLock.release();
    }
}

김개발 씨의 캐시 시스템을 분석해봅시다. 요청의 95%가 읽기이고 5%만 쓰기입니다.

그런데 모든 요청을 뮤텍스로 순차 처리하니 병목이 심합니다. 박시니어 씨가 설명합니다.

"읽기끼리는 충돌이 없잖아요. 동시에 해도 데이터가 깨지지 않아요.

이럴 때는 Readers-Writers 패턴을 써야 해요." 도서관을 떠올려 봅시다. 여러 명이 같은 책을 동시에 읽어도 책이 손상되지 않습니다.

하지만 누군가 책에 메모를 적으려면(쓰기), 다른 사람이 읽고 있으면 안 됩니다. 메모하는 동안은 아무도 책을 볼 수 없어야 합니다.

이것이 Readers-Writers 문제의 핵심입니다. Reader-Reader: 동시 접근 허용 (충돌 없음) Reader-Writer: 동시 접근 불가 (Writer는 대기) Writer-Writer: 동시 접근 불가 (하나만 쓰기) 코드의 핵심은 readerCountwriteLock입니다.

readerCount는 현재 읽고 있는 Reader 수를 추적합니다. 첫 번째 Reader가 들어오면 writeLock을 획득하여 Writer의 진입을 막습니다.

마지막 Reader가 나가면 writeLock을 해제하여 Writer가 진입할 수 있게 합니다. read 메서드를 자세히 살펴봅시다.

먼저 mutex로 readerCount 접근을 보호합니다. readerCount를 증가시키고, 만약 첫 번째 Reader라면 writeLock을 획득합니다.

mutex를 해제하고 실제 읽기를 수행합니다. 읽기가 끝나면 다시 mutex를 잡고 readerCount를 감소시킵니다.

마지막 Reader라면 writeLock을 해제합니다. write 메서드는 단순합니다.

그냥 writeLock을 획득하고 쓰기를 수행합니다. Reader가 있으면 writeLock을 얻지 못해 대기하게 됩니다.

하지만 이 구현에는 문제가 있습니다. Writer 기아(Starvation) 문제입니다.

Reader가 끊임없이 들어오면 readerCount가 절대 0이 되지 않아서 Writer가 영원히 대기할 수 있습니다. 이를 해결하려면 Writer 우선 정책이나 공정성(Fairness) 메커니즘이 필요합니다.

실무에서는 ReadWriteLock 인터페이스를 사용합니다. Java의 ReentrantReadWriteLock은 이 패턴을 이미 구현해 두었고, 공정성 옵션도 제공합니다.

직접 구현하기보다 표준 라이브러리를 활용하는 것이 안전합니다. 김개발 씨는 캐시 시스템에 ReadWriteLock을 적용했습니다.

읽기 성능이 5배 이상 향상되었고, 쓰기도 정상적으로 처리됩니다.

실전 팁

💡 - 읽기가 대부분인 시스템에서는 Readers-Writers 패턴이 큰 성능 향상을 줍니다

  • Writer 기아 문제를 항상 고려하세요
  • Java에서는 ReentrantReadWriteLock 사용을 권장합니다

6. 모니터 (Monitor)

김개발 씨는 세마포어로 동기화 코드를 작성하다 보니 점점 복잡해지는 것을 느꼈습니다. acquire와 release 순서를 잘못 쓰면 교착 상태가 발생하고, 까먹고 해제를 안 하면 프로그램이 멈춥니다.

더 안전하고 쉬운 방법은 없을까요?

**모니터(Monitor)**는 상호 배제와 조건 동기화를 하나의 구조로 묶은 고수준 동기화 도구입니다. 모니터 내부에서는 자동으로 상호 배제가 보장되며, 조건 변수를 통해 스레드 간 신호를 주고받습니다.

Java의 synchronized 키워드와 wait/notify가 바로 모니터의 구현입니다.

다음 코드를 살펴봅시다.

public class BoundedBuffer {
    private final String[] buffer = new String[5];
    private int count = 0, in = 0, out = 0;

    // synchronized로 모니터 진입 - 자동 상호 배제
    public synchronized void put(String item)
            throws InterruptedException {
        while (count == buffer.length) {
            wait();  // 버퍼 가득 참 - 조건 대기
        }
        buffer[in] = item;
        in = (in + 1) % buffer.length;
        count++;
        notifyAll();  // 대기 중인 스레드 깨우기
    }

    public synchronized String get() throws InterruptedException {
        while (count == 0) {
            wait();  // 버퍼 비어 있음 - 조건 대기
        }
        String item = buffer[out];
        out = (out + 1) % buffer.length;
        count--;
        notifyAll();
        return item;
    }
}

박시니어 씨가 김개발 씨의 고민을 듣고 말합니다. "세마포어는 강력하지만 실수하기 쉬워요.

그래서 더 안전한 추상화가 필요했고, 그게 바로 모니터예요." 모니터를 이해하기 위해 회의실을 떠올려 봅시다. 회의실에는 한 팀만 들어갈 수 있고, 회의실 안에서 특정 조건이 맞지 않으면 밖에서 대기해야 합니다.

예를 들어 "회의 자료가 준비되면 들어오세요"라는 조건이 있다면, 자료가 준비될 때까지 대기실에서 기다립니다. 누군가 "자료 준비됐어요!"라고 알려주면 다시 회의실에 들어갈 수 있습니다.

모니터는 두 가지 핵심 기능을 제공합니다. 첫째, 상호 배제입니다.

한 번에 하나의 스레드만 모니터에 진입할 수 있습니다. Java에서 synchronized 메서드나 블록이 이 역할을 합니다.

둘째, 조건 동기화입니다. **wait()**으로 조건이 만족될 때까지 대기하고, notify() 또는 **notifyAll()**로 대기 중인 스레드를 깨웁니다.

세마포어와 비교해 봅시다. 세마포어에서는 acquire와 release를 개발자가 직접 호출해야 합니다.

실수로 release를 빼먹으면 교착 상태가 됩니다. 반면 모니터에서는 synchronized 블록을 벗어나면 자동으로 잠금이 해제됩니다.

예외가 발생해도 마찬가지입니다. 코드를 살펴봅시다.

synchronized 키워드가 메서드에 붙어 있습니다. 이 메서드에 진입하면 자동으로 해당 객체에 대한 잠금을 획득합니다.

while 루프로 조건을 검사하는 것이 중요합니다. if가 아닌 while을 쓰는 이유는 가짜 깨어남(Spurious Wakeup) 때문입니다.

**wait()**은 두 가지 일을 합니다. 현재 스레드를 대기 상태로 만들고, 잠금을 일시적으로 해제합니다.

이렇게 해야 다른 스레드가 모니터에 진입해서 조건을 변경할 수 있습니다. notify나 notifyAll이 호출되면 대기 중이던 스레드가 깨어나서 잠금을 다시 획득하고 실행을 재개합니다.

왜 while 루프가 필수인가요? **notifyAll()**은 대기 중인 모든 스레드를 깨웁니다.

하지만 실제로 조건을 만족하는 것은 하나의 스레드뿐일 수 있습니다. 따라서 깨어난 후에 조건을 다시 검사해야 합니다.

조건이 여전히 만족되지 않으면 다시 wait에 들어갑니다. 모니터의 장점은 캡슐화에 있습니다.

동기화 로직이 객체 내부에 숨겨져 있어서, 외부에서는 그냥 메서드를 호출하면 됩니다. 동기화를 신경 쓰지 않아도 됩니다.

이것이 객체지향 프로그래밍과 잘 어울리는 이유입니다. 김개발 씨는 복잡한 세마포어 코드를 synchronized와 wait/notify로 리팩토링했습니다.

코드가 훨씬 간결해지고, 실수할 여지도 줄었습니다.

실전 팁

💡 - wait()은 반드시 while 루프 안에서 호출하세요 (if가 아닌 while)

  • notify()보다 notifyAll()이 더 안전하지만 성능은 떨어질 수 있습니다
  • Java 5 이상에서는 java.util.concurrent의 Condition 객체를 사용할 수도 있습니다

이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!

#OS#Mutex#Semaphore#Monitor#Concurrency#Operating System

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.