본문 바로가기
Java

Java 뮤텍스와 세마포어 실전 활용

by 정권이 내 2026. 3. 11.

🚀 동시성 제어, 왜 안전장치가 필요한가?

우리는 트래픽이 몰리는 상황을 필연적으로 마주하게 됩니다. 성능을 높이기 위해 여러 개의 스레드를 동시에 실행하는 멀티스레딩 환경을 구축하지만 여러 스레드가 동시에 하나의 공유 자원에 접근하려고 하면 어떻게 될까요?

이때 발생하는 문제가 바로 경쟁 상태, 즉 레이스 컨디션입니다. 두 개의 스레드가 동시에 100원인 계좌 잔고에 접근해서 각각 50원씩 입금한다고 가정해 봅시다. 정상적이라면 200원이 되어야 하지만, 동시성 제어가 없다면 두 스레드가 모두 100원이라는 초기 상태를 읽어가서 각자 150원으로 계산한 뒤 덮어써 버릴 수 있습니다. 결과적으로 50원이 증발하는 치명적인 버그가 발생합니다.

이러한 장애를 막고 스레드들이 질서있게 공유 자원에 접근하도록 해주는 핵심 기술이 바로 뮤텍스와 세마포어입니다. 이 둘은 멀티스레딩 환경의 안정성을 책임지는 가장 중요한 기둥입니다.

⚠️ 동시성 제어의 흔한 실수들

뮤텍스와 세마포어의 개념을 본격적으로 알아보기 전에, 현업에서 자주 마주치는 시행착오들을 먼저 살펴보겠습니다.

💀 락을 반환하지 않아 발생하는 데드락과 무한 대기

가장 흔한 실수는 자원을 다 사용한 뒤에 락을 해제하지 않는 것입니다. 특히 예외 처리를 꼼꼼하게 하지 않았을 때 이런 일이 빈번하게 발생합니다.

다음은 자바의 ReentrantLock 객체를 사용하면서 흔히 저지르는 실수 예제입니다.

public class LockMistakeExample {
    private final ReentrantLock lock = new ReentrantLock();
    private int sharedResource = 0;

    public void updateResource(int value) {
        lock.lock(); // 락 획득

        // 비즈니스 로직 수행
        sharedResource += value;

        // 만약 여기서 예외가 발생한다면?
        if (value < 0) {
            throw new IllegalArgumentException("음수는 처리할 수 없습니다.");
        }

        // 예외가 발생하면 아래 코드는 영원히 실행되지 않음
        lock.unlock(); // 락 해제
    }
}

위 코드에서 만약 인자로 음수가 들어와서 예외가 던져진다면, 메서드는 그 즉시 종료되어 버립니다. 가장 큰 문제는 락을 획득한 상태로 종료되기 때문에 unlock 메서드가 호출되지 않는다는 점입니다. 이로 인해 다른 스레드들은 영원히 락을 얻지 못하고 대기 상태에 빠지는 무한 대기 현상이 발생합니다.

서버의 스레드 풀이 고갈되고 결국 장애로 이어지기 때문에 반드시 try-finally 블록을 사용하여 어떤 상황에서도 락이 해제되도록 보장해야 합니다.

public class LockSolutionExample {
    private final ReentrantLock lock = new ReentrantLock();
    private int sharedResource = 0;

    public void updateResource(int value) {
        lock.lock(); // 락 획득
        try {
            // 비즈니스 로직 수행
            sharedResource += value;
            if (value < 0) {
                throw new IllegalArgumentException("음수는 처리할 수 없습니다.");
            }
        } finally {
            // 예외 발생 여부와 상관없이 무조건 락 해제 보장
            lock.unlock(); 
        }
    }
}

🤷‍♂️ 뮤텍스와 세마포어의 용도 혼동

또 다른 실수는 뮤텍스와 세마포어의 본질적인 차이를 이해하지 못하고 섞어 쓰는 것입니다. 뮤텍스는 단 하나의 스레드만 자원을 독점해야 할 때 사용하고, 세마포어는 여러 스레드가 제한된 수량의 자원을 나누어 쓸 때 사용합니다.

데이터베이스 커넥션 풀처럼 10개의 커넥션을 여러 스레드가 나누어 써야 하는 상황에 뮤텍스를 걸어버리면, 한 번에 하나의 스레드만 DB에 접근하게 되어 심각한 성능 저하가 발생합니다. 반대로 잔고 업데이트 로직에 세마포어를 잘못 적용하면 데이터 정합성이 깨지게 됩니다. 이제 이 두 가지 기술의 핵심을 명확하게 파헤쳐 보겠습니다.

💡 뮤텍스와 세마포어, 완벽하게 이해하기

동시성 프로그래밍에서 가장 헷갈리지만 반드시 정복해야 하는 산이 바로 뮤텍스와 세마포어입니다. 둘 다 동시성 문제를 해결하기 위한 동기화 도구이지만, 동작하는 방식과 목적이 완전히 다릅니다.

🔑 뮤텍스: 오직 한 명만 가질 수 있는 단 하나의 열쇠

뮤텍스는 Mutual Exclusion, 즉 상호 배제의 줄임말입니다. 이름에서 알 수 있듯이, 특정 코드 구간(임계 구역)에 오직 단 하나의 스레드만 진입할 수 있도록 철저하게 통제하는 기법입니다.

가장 이해하기 쉬운 비유는 식당에 있는 단 한 칸짜리 화장실입니다. 화장실 문에는 자물쇠가 있고, 열쇠는 카운터에 딱 하나만 있습니다.

  1. 손님(스레드)이 화장실(임계 구역)에 가려면 카운터에서 열쇠(뮤텍스 락)를 받아야 합니다.
  2. 열쇠가 있다면 화장실에 들어가서 문을 잠그고 볼일을 봅니다.
  3. 이때 다른 손님들이 화장실에 오면, 문이 잠겨있고 열쇠가 없으므로 문 밖에서 순서를 기다려야 합니다.
  4. 볼일을 다 본 손님이 나와서 열쇠를 카운터에 반납(락 해제)하면, 기다리던 다음 손님이 열쇠를 받아 들어갑니다.

뮤텍스의 가장 큰 특징은 락을 획득한 스레드만이 락을 해제할 권한을 가진다는 것입니다. 열쇠를 가지고 들어간 사람이 안에서 문을 열고 나와야만 다른 사람이 들어갈 수 있는 것과 같은 이치입니다. 이를 소유권 개념이라고 부릅니다.

자바에서는 ReentrantLock 클래스를 통해 뮤텍스와 매우 유사한 기능을 완벽하게 제어할 수 있습니다. 자바의 synchronized 키워드 역시 내부적으로는 뮤텍스와 유사한 모니터 락을 사용합니다. 아래 코드를 통해 구조를 살펴보겠습니다.

import java.util.concurrent.locks.ReentrantLock;

public class MutexExample {
    // 단 하나의 열쇠 생성
    private final ReentrantLock lock = new ReentrantLock();
    private int counter = 0;

    public void increment() {
        // 임계 구역 진입 전 열쇠(락)를 획득합니다.
        // 열쇠가 없다면 다른 스레드가 반납할 때까지 여기서 대기합니다.
        lock.lock(); 

        try {
            // 이 구간은 오직 하나의 스레드만 안전하게 실행합니다. (임계 구역)
            // 데이터의 정합성이 완벽하게 보장됩니다.
            System.out.println(Thread.currentThread().getName() + " 가 락을 획득하고 데이터를 수정합니다.");
            counter++;

            // 데이터 수정을 시뮬레이션 하기 위한 지연
            Thread.sleep(100); 

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            // 작업을 마치면 반드시 본인이 락을 해제해야 합니다.
            System.out.println(Thread.currentThread().getName() + " 가 락을 반납합니다.");
            lock.unlock(); 
        }
    }

    public int getCounter() {
        return counter;
    }
}

위의 예제에서 lock 메서드를 호출하는 순간, 스레드는 뮤텍스의 소유권을 주장합니다. 만약 다른 스레드가 이미 소유권을 가지고 있다면, 현재 스레드는 운영체제 레벨에서 대기 상태로 빠지게 되며 CPU 자원을 소모하지 않고 조용히 기다립니다.

이후 락이 해제되면 운영체제의 시그널을 받아 다시 깨어나 작업을 수행합니다. 데이터의 무결성이 중요한 결제 시스템이나 재고 차감 로직에서 뮤텍스는 필수입니다.

🎟️ 세마포어: 정해진 인원만 입장 가능한 놀이공원 매표소

세마포어는 뮤텍스보다 조금 더 유연하고 확장된 동기화 도구입니다. 뮤텍스가 단 하나의 자원을 보호한다면, 세마포어는 여러 개의 동일한 자원을 여러 스레드가 안전하게 공유할 수 있도록 접근을 제어합니다.

세마포어는 놀이공원의 범퍼카 놀이기구에 비유할 수 있습니다. 범퍼카 탑승장에는 총 5대의 범퍼카가 있습니다.

  1. 직원은 5장의 탑승권(권한)을 가지고 있습니다.
  2. 손님(스레드)이 오면 탑승권을 한 장 주고 범퍼카를 타게 합니다. (권한 획득, 남은 권한 감소)
  3. 5명의 손님이 모두 탑승하면 탑승권이 0장이 됩니다.
  4. 6번째 손님이 오면 탑승권이 없으므로 누군가 타고 나올 때까지 대기줄에서 기다립니다.
  5. 먼저 탄 손님이 범퍼카 이용을 마치고 나오면 직원에게 탑승권을 반납합니다. (권한 반환, 남은 권한 증가)
  6. 직원은 대기줄의 맨 앞 손님에게 반납받은 탑승권을 주고 입장시킵니다.

세마포어의 핵심은 내부에 정수형 변수(카운터)를 가지고 있다는 것입니다. 스레드가 자원을 요청하면 카운터를 감소시키고, 자원을 다 쓰고 반납하면 카운터를 증가시킵니다. 카운터가 0이 되면 남은 자원이 없다는 뜻이므로 요청한 스레드는 대기하게 됩니다.

특히 세마포어는 소유권의 개념이 없습니다. A 스레드가 자원을 사용하고 있더라도, 설계상 필요하다면 B 스레드가 대신 반납하도록 만들 수 있습니다.

이는 스레드 간의 통신이나 실행 순서를 제어하는 신호 체계로 활용될 때 매우 강력한 무기가 됩니다.

자바에서는 Semaphore 클래스를 제공합니다. 외부 API를 호출하는 횟수를 제한하거나, DB 커넥션 풀을 직접 구현할 때 세마포어가 해결책이 됩니다.

import java.util.concurrent.Semaphore;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SemaphoreExample {

    // 동시에 3개의 스레드만 접근을 허용하는 세마포어 생성 (퍼밋 3개)
    private static final Semaphore semaphore = new Semaphore(3);

    public static void main(String[] args) {
        // 10개의 스레드를 생성하여 동시에 실행을 시도합니다.
        ExecutorService executor = Executors.newFixedThreadPool(10);

        for (int i = 1; i <= 10; i++) {
            int threadNum = i;
            executor.execute(() -> {
                try {
                    System.out.println("스레드 " + threadNum + " 자원 접근 대기 중...");

                    // 세마포어로부터 퍼밋을 하나 획득합니다.
                    // 만약 남은 퍼밋이 없다면 여기서 블로킹(대기) 상태가 됩니다.
                    semaphore.acquire();

                    System.out.println("🚀 스레드 " + threadNum + " 퍼밋 획득 및 자원 사용 시작!");

                    // 제한된 자원을 사용하는 비즈니스 로직 (예: 무거운 쿼리 실행, 외부 API 호출)
                    Thread.sleep(2000); // 자원을 2초간 사용한다고 가정

                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    // 작업이 끝나면 반드시 퍼밋을 세마포어에 반납합니다.
                    // 반납된 퍼밋은 대기 중인 다른 스레드에게 할당됩니다.
                    System.out.println("✅ 스레드 " + threadNum + " 퍼밋 반납 완료.");
                    semaphore.release();
                }
            });
        }
        executor.shutdown();
    }
}

이 코드를 실행해보면, 10개의 스레드가 동시에 실행을 시도하지만 초기에 단 3개의 스레드만이 퍼밋을 얻어 "자원 사용 시작!" 메시지를 출력합니다.

나머지 7개의 스레드는 대기 중 상태에 머물러 있다가 2초 뒤 먼저 진입했던 스레드들이 작업을 마치고 퍼밋을 반납하면, 대기하던 스레드들이 3개씩 짝을 지어 순차적으로 작업을 수행하는 것을 확인할 수 있습니다. 이것이 바로 세마포어를 이용한 유량 제어의 핵심입니다.

⚔️ 뮤텍스와 세마포어: 결정적인 차이점 파헤치기

그렇다면 실제 아키텍처를 설계할 때 반드시 알아야 하는 이 둘의 결정적인 차이는 무엇일까요? 단지 하나냐, 여러 개냐의 차이를 넘어선 구조적인 차이점이 존재합니다.

첫째, 소유권과 해제 권한의 주체가 다릅니다.

앞서 화장실 열쇠 비유에서 설명했듯, 뮤텍스는 락을 획득한 스레드 본인만이 그 락을 해제할 수 있습니다. 반면 세마포어는 소유권 개념이 없기 때문에, 퍼밋을 획득한 스레드와 퍼밋을 해제하는 스레드가 달라도 무방합니다.

이 특징 때문에 세마포어는 작업의 처리 순서를 동기화하는 이벤트 시그널링 용도로 널리 사용됩니다. 예를 들어 스레드 A가 데이터 로딩을 완료하면 세마포어를 해제하여 시그널을 보내고, 이를 기다리던 스레드 B가 깨어나서 데이터를 처리하도록 설계할 수 있습니다.

둘째, 관리하는 대상의 범위가 다릅니다.

뮤텍스는 오직 단일 자원에 대한 배타적 독점을 위해 설계되어서 철저하게 동시 접근을 차단하고 무결성을 지키는 역할을 합니다. 반면 세마포어는 동시 접근을 허용하되, 그 최대 허용치를 시스템이 감당할 수 있는 수준으로 제어하는 조절기 역할을 합니다.

셋째, 이진 세마포어(Binary Semaphore)는 뮤텍스와 완전히 똑같을까요?

세마포어의 퍼밋 개수를 1로 설정한 것을 이진 세마포어라고 부릅니다. 동작하는 형태만 보면 단 하나의 스레드만 접근을 허용하므로 뮤텍스와 똑같아 보입니다.

하지만 소유권의 유무라는 근본적인 차이는 여전히 존재합니다. 이진 세마포어는 다른 스레드가 락을 강제로 풀어버릴 수 있는 여지가 열려있기 때문에, 엄격한 상호 배제가 필요한 상황에서는 이진 세마포어보다 뮤텍스를 사용하는 것이 안전합니다.

📝 오늘의 핵심 3줄 요약

  • 뮤텍스는 단일 공유 자원에 하나의 스레드만 접근하도록 통제하며, 락을 획득한 본인만이 해제할 수 있는 강한 소유권을 가집니다.
  • 세마포어는 설정된 개수만큼 여러 스레드의 동시 접근을 허용하는 카운터 기반의 통제 장치이며, 다른 스레드가 퍼밋을 해제하는 시그널링 용도로도 활용 가능합니다.
  • 자원 접근 중 예외가 발생하여 시스템이 마비되는 데드락을 방지하기 위해, 어떤 락이든 항상 finally 블록을 통해 해제를 보장하는 습관을 들여야 합니다.
반응형

댓글