1. 뮤텍스(Mutex): 무엇이며 어떻게 동작하는가
뮤텍스(영어 “mutual exclusion” — “상호 배제”)는 한 번에 오직 하나의 스레드만 임계 구역 코드를 실행하도록 허용하는 메커니즘입니다. 뮤텍스가 점유(다른 스레드가 획득)된 상태라면, 나머지 스레드는 해제될 때까지 대기합니다.
Java에서는 코드가 동기화되는 대상 객체 자체가 자주 뮤텍스 역할을 합니다: synchronized. Java 5 버전부터는 ReentrantLock 클래스가 도입되어, 더 명시적이고 유연한 뮤텍스 구현을 제공합니다.
도식적으로
열쇠가 하나뿐인 방을 상상해 보세요(그 열쇠가 뮤텍스). 들어가려면 열쇠를 가져야 합니다. 열쇠가 없으면(누군가가 이미 가져갔다면) 문 앞에서 기다립니다. 열쇠가 제자리에 돌아오면(뮤텍스가 해제되면) 다음 사람이 들어갈 수 있습니다.
Java에서 뮤텍스 문법
synchronized 사용(고전적인 방식):
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
여기서는 increment 메서드 전체가 뮤텍스로 보호됩니다 — 현재 시점에 오직 하나의 스레드만 실행할 수 있습니다.
ReentrantLock 사용(더 유연):
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // 뮤텍스 획득
try {
count++;
} finally {
lock.unlock(); // 반드시 해제!
}
}
}
중요! 항상 finally 블록에서 뮤텍스를 해제하세요. 그렇지 않으면 “영원한 블로킹”(deadlock)을 일으켜 프로그램이 멈출 수 있습니다.
언제 뮤텍스가 필요한가?
한 번에 하나의 스레드만 자원에 접근해야 할 때 뮤텍스가 필요합니다. 자원은 변수, 파일, 데이터베이스가 될 수 있습니다. 작업이 원자적이지 않다면 특히 중요합니다. 예를 들어 단순한 count++ 역시 실제로는 값 읽기, 증가, 다시 쓰기의 세 단계로 구성됩니다. 뮤텍스 없이 여러 스레드가 이 단계 사이에 끼어들면 경합(race) 상태가 발생할 수 있습니다.
2. 세마포어(Semaphore): 왜 필요하며 어떻게 동작하는가
세마포어는 동시에 자원에 접근할 수 있는 스레드 수를 지정된 한도까지만 허용하는 “조절기”입니다. 한도가 다 찼다면, 나머지 스레드는 자신의 차례를 기다립니다.
비유: 3대 주차 공간이 있는 주차장. 모든 자리가 찼다면, 다른 차들은 누군가가 빠져나갈 때까지 대기합니다.
Java에서 세마포어 문법
이를 위해 java.util.concurrent 패키지의 Semaphore 클래스를 사용합니다:
import java.util.concurrent.Semaphore;
public class ParkingLot {
private final Semaphore spots;
public ParkingLot(int places) {
this.spots = new Semaphore(places);
}
public void parkCar(String car) throws InterruptedException {
spots.acquire(); // 자리를 차지 시도(없으면 대기)
try {
System.out.println(car + " 가 주차했습니다.");
Thread.sleep(1000); // 자동차가 주차장에 머무는 중
} finally {
spots.release(); // 자리 해제
System.out.println(car + " 가 떠났습니다.");
}
}
}
사용:
ParkingLot parking = new ParkingLot(3);
for (int i = 1; i <= 5; i++) {
final String car = "자동차 " + i;
new Thread(() -> {
try {
parking.parkCar(car);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
결과: 동시에 주차장에 3대 이상 주차되지 않으며 — 나머지는 대기합니다.
세마포어는 어떻게 동작하나?
- 세마포어를 생성할 때 “허용량”(permits) 개수를 지정합니다.
- acquire()는 허용량을 가져오려 시도합니다. 여유가 있으면 통과하고, 없으면 기다립니다.
- release()는 허용량을 반환합니다.
- 허용량이 1인 세마포어는 거의 뮤텍스처럼 동작하지만 ‘소유자’ 개념이 없습니다.
3. 뮤텍스와 세마포어: 차이점은?
| 특성 | 뮤텍스 (Mutex) | 세마포어 (Semaphore) |
|---|---|---|
| 스레드 수 | 오직 1개 | 여러 개(제한된 수) |
| 용도 | 자원 보호 | 접근 제한(예: 풀) |
| Java의 API | |
|
| 관리 | 일반적으로 ‘소유자’가 있음 | 어떤 스레드나 해제 가능 |
| 대표 시나리오 | 공유 카운터, 객체 | 연결 풀, 주차장, 한도 |
- 뮤텍스 — 배타적 접근이 필요할 때.
- 세마포어 — 여러 개는 허용하지만 모두는 아닐 때.
비유: 뮤텍스 — 칸 하나짜리 화장실; 세마포어 — 칸이 세 개인 화장실.
4. 실전 예제
예제 1: 임계 구역 보호를 위한 뮤텍스
공유 은행 계좌가 있고, 여러 스레드가 계좌 간 송금을 한다고 가정해 봅시다. 연산은 원자적으로 이뤄져야 합니다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class BankAccount {
private int balance;
private final Lock lock = new ReentrantLock();
public BankAccount(int initial) {
this.balance = initial;
}
public void deposit(int amount) {
lock.lock();
try {
balance += amount;
} finally {
lock.unlock();
}
}
public void withdraw(int amount) {
lock.lock();
try {
if (balance >= amount) {
balance -= amount;
}
} finally {
lock.unlock();
}
}
public int getBalance() {
return balance;
}
}
여기서는 잔액에 대한 모든 연산이 뮤텍스로 보호되어 race condition이 발생하지 않습니다.
예제 2: 접근 제한을 위한 세마포어
서버는 동시에 2명의 클라이언트만 처리할 수 있습니다(예: 라이선스 제한).
import java.util.concurrent.Semaphore;
public class Server {
private final Semaphore connections = new Semaphore(2);
public void handleRequest(String client) throws InterruptedException {
connections.acquire();
try {
System.out.println(client + " 가 서버에 연결되었습니다.");
Thread.sleep(2000); // 요청 처리 시뮬레이션
} finally {
connections.release();
System.out.println(client + " 의 연결이 종료되었습니다.");
}
}
}
사용:
Server server = new Server();
for (int i = 1; i <= 5; i++) {
final String client = "클라이언트 " + i;
new Thread(() -> {
try {
server.handleRequest(client);
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
결과: 동시에 서버는 두 명을 초과하여 클라이언트를 처리하지 않습니다.
5. 사용 시 주의사항과 팁
뮤텍스: 반드시 해제하라!
예외가 발생하더라도 unlock()(또는 동기화 블록을 벗어남)을 호출하는 것을 잊지 않는 것이 매우 중요합니다. try-finally를 사용하세요:
lock.lock();
try {
// 임계 구역
} finally {
lock.unlock();
}
잊어버리면 “영원한 블로킹”이 발생할 수 있으며, 다른 스레드는 무한히 대기하게 됩니다.
세마포어: 다른 스레드가 해제해도 될까?
뮤텍스와 달리 release()는 acquire()를 호출하지 않은 스레드라도 호출할 수 있습니다. 때로는 유용하지만, 실수하기 쉽습니다 — 규칙을 지키세요.
허용량 1인 Semaphore = 뮤텍스?
거의 그렇습니다. 하지만 세마포어에는 ‘소유자’ 개념이 없습니다. 어떤 해제든 허용량 카운터를 증가시킵니다. 반면 뮤텍스는 획득한 스레드가 해제해야 합니다.
세마포어와 풀을 혼동하지 말 것
세마포어는 객체 풀 자체가 아니라 단지 “허용량 카운터”일 뿐입니다. 세마포어로 풀(예: DB 연결 풀)을 구현하는 경우가 많지만, 세마포어 자체는 아무 것도 저장하지 않습니다.
6. 뮤텍스/세마포어 사용 시 흔한 오류
오류 №1: unlock/release 호출 누락. 뮤텍스나 세마포어를 획득하고 unlock() 또는 release()를 호출하지 않으면, 다른 스레드가 영원히 대기할 수 있습니다. 항상 try-finally를 사용해 예외가 있어도 잠금이 해제되도록 하세요.
오류 №2: 잘못된 객체에 동기화. 모든 스레드에 공통이 아닌 변수(예: 지역 변수나 문자열 리터럴)에 대해 동기화하면, 동기화가 제대로 동작하지 않습니다.
오류 №3: 이중 해제. 세마포어의 경우 acquire()보다 release()를 더 많이 호출하면 허용량이 한도를 넘겨 증가합니다. 균형을 맞추세요!
오류 №4: 뮤텍스 대신 세마포어 사용(또는 그 반대). 배타적 접근이 필요하면 뮤텍스(synchronized 또는 Lock)를 사용하세요. 동시에 동작할 수 있는 스레드 수를 제한하려면 Semaphore를 사용하세요.
오류 №5: 잠금을 오래 잡고 있음. 스레드가 뮤텍스나 세마포어를 오래 보유할수록 다른 스레드는 더 오래 기다립니다. 임계 구역 내 실행 시간을 최소화하세요.
GO TO FULL VERSION