
세마포어 vs 뮤텍스 (Semaphore vs Mutex): 동기화의 모든 것
화장실 키(Mutex)와 레스토랑 대기표(Semaphore)로 이해하는 동기화. 이진 세마포어와 뮤텍스의 결정적 차이(소유권), 스핀락, 모니터, 그리고 우선순위 역전 문제까지.

화장실 키(Mutex)와 레스토랑 대기표(Semaphore)로 이해하는 동기화. 이진 세마포어와 뮤텍스의 결정적 차이(소유권), 스핀락, 모니터, 그리고 우선순위 역전 문제까지.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

동시성 문제를 처음 제대로 마주한 건 멀티스레드 코드를 공부하면서였다. 같은 자원에 여러 스레드가 동시에 접근하면 어떤 일이 생길까? 이 단순한 질문에서 시작해서 뮤텍스(Mutex)와 세마포어(Semaphore)를 만났다.
자주 헷갈리는 질문이 있다. "세마포어와 뮤텍스의 차이가 뭔가요?" 처음에는 이렇게 이해했다. "세마포어는 카운트가 여러 개고, 뮤텍스는 1개 아닌가요?" 틀렸다. 결정적 차이는 소유권(Ownership)이었다.
실제로 데드락 예시 코드를 실행해보고, 왜 스레드들이 서로를 무한히 기다리는지 직접 확인하고 나서야 이 개념이 와닿았다. 동기화 도구를 선택할 때 반드시 던져야 할 질문이 있다. "이 락은 주인이 있어야 하나, 아니면 아무나 신호를 보낼 수 있어야 하나?" 이 글은 그 과정에서 정리해본 내용이다.
동기화 이론의 역사를 알면 왜 이렇게 복잡한지 이해가 됩니다.
에츠허르 데이크스트라(Edsger W. Dijkstra)가 세마포어(Semaphore) 개념을 처음 도입했습니다. 그는 네덜란드 사람이었기 때문에 P연산(Proberen, 시도하다)과 V연산(Verhogen, 증가시키다)이라는 용어를 사용했습니다. 영어로는 Wait/Signal이라고 부르지만, 논문에서는 아직도 P/V를 씁니다.
세마포어로 복잡한 프로그램을 짜다 보니 버그가 너무 많이 생겼습니다. P와 V 연산을 정확히 짝 맞춰서 호출해야 하는데, 실수로 P만 두 번 호출하거나 V를 빼먹으면 시스템 전체가 멈춰버렸습니다. 이 문제를 해결하기 위해 퍼 브린치 한센(Per Brinch Hansen) 등이 모니터(Monitor) 개념을 제안했습니다. 이게 나중에 Java의 synchronized 키워드가 되었습니다.
화성 탐사선 패스파인더(Pathfinder)가 화성에 도착하자마자 계속 리부팅되는 사고가 발생했습니다. NASA 엔지니어들은 지구에서 로그를 분석했고, 원인은 뮤텍스를 사용한 우선순위 역전(Priority Inversion) 문제였다는 것을 받아들였습니다. 이 사건은 동기화 이론의 중요성을 전 세계에 알린 계기가 되었습니다. 제가 이 이야기를 처음 들었을 때, 지구에서 5000만 킬로미터 떨어진 화성에서 소프트웨어 버그를 디버깅한다는 게 너무 와닿았습니다.
Mutual Exclusion (상호 배제)의 약자입니다.
저는 이 비유로 뮤텍스를 완전히 이해했습니다.
이 소유권 개념이 제대로 와닿았던 건, 제가 Python으로 멀티스레드 서버를 만들 때였습니다.
import threading
class BankAccount:
def __init__(self):
self.balance = 0
self.lock = threading.Lock() # Mutex
def deposit(self, amount):
# 락을 건 스레드만 해제할 수 있음
self.lock.acquire()
try:
current = self.balance
# 시뮬레이션: 다른 스레드가 끼어들 틈
import time
time.sleep(0.001)
self.balance = current + amount
print(f"입금 완료: {amount}, 잔액: {self.balance}")
finally:
self.lock.release() # 반드시 같은 스레드가 해제
# 테스트
account = BankAccount()
def worker():
for _ in range(100):
account.deposit(10)
threads = [threading.Thread(target=worker) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"최종 잔액: {account.balance}") # 5000이 나와야 정상
이 코드에서 lock.acquire()를 한 스레드만이 lock.release()를 할 수 있습니다. 다른 스레드가 강제로 락을 해제하려고 하면 예외가 발생합니다. 이게 소유권입니다.
수기 신호기(Semaphore)에서 유래했습니다. 자원의 개수를 관리합니다.
count = 5)4. (P operation, Wait)3.0이 되면 차단기가 내려가고 입장이 막힙니다.1. (V operation, Signal). 차단기가 올라갑니다.제가 이 차이를 받아들였던 순간은 API Rate Limiter를 만들 때였습니다.
import java.util.concurrent.Semaphore;
public class ApiRateLimiter {
// 동시에 최대 3개의 요청만 허용
private static Semaphore semaphore = new Semaphore(3);
public static void callApi(String userId) {
try {
semaphore.acquire(); // 빈 슬롯이 있나? (P연산)
System.out.println(userId + " API 호출 시작");
// API 호출 시뮬레이션 (1초 소요)
Thread.sleep(1000);
System.out.println(userId + " API 호출 완료");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 슬롯 반환 (V연산)
// 주목: 다른 스레드가 release()를 호출해도 문제없음
}
}
}
이 코드에서 acquire()를 호출한 스레드와 release()를 호출한 스레드가 달라도 상관없습니다. 세마포어는 단순히 "숫자"만 관리할 뿐, 누가 어떤 슬롯을 쥐고 있는지는 추적하지 않습니다.
자주 헷갈리는 질문입니다.
"이진 세마포어(Binary Semaphore, 카운트가 0과 1만 오가는 것)는 뮤텍스와 같지 않나요?"
정답: 다릅니다. 결국 이거였습니다.| 구분 | 뮤텍스 (Mutex) | 이진 세마포어 (Binary Semaphore) |
|---|---|---|
| 소유권 | 있음 (Has Ownership) | 없음 (No Ownership) |
| 해제 주체 | 락을 건 스레드만 해제 가능 | 아무 스레드나 해제 가능 (Signal) |
| 목적 | 자원 보호 (상호 배제) | 신호 전달 (동기화/순서 제어) |
| 성능 | 무겁다 (소유권 관리 오버헤드) | 가볍다 |
| 에러 복구 | 소유자가 죽으면 OS가 감지 가능 | 누가 시그널을 보낼지 모르므로 복구 어려움 |
// 스핀락 의사코드
while (lock == 1) {
// 계속 체크 (CPU 소모)
}
lock = 1; // 획득
// Critical Section
lock = 0; // 해제
제가 이 차이를 정리해본 계기는 커널 코드를 읽을 때였습니다. 리눅스 커널 내부에서는 스핀락을 많이 씁니다. 왜냐하면 커널 컨텍스트 스위칭은 비용이 엄청나게 크고, 대부분의 임계 구역은 몇 나노초 안에 끝나기 때문입니다.
lock(), unlock()을 해야 해서 실수가 많습니다.synchronized 키워드가 바로 모니터입니다.
public class SafeCounter {
private int count = 0;
// 이 메서드는 한 번에 한 스레드만 실행 가능
public synchronized void increment() {
count++; // 자동으로 락 획득/해제
}
// 내부적으로는 이렇게 변환됨 (의사코드)
public void increment() {
__monitor_enter(this);
try {
count++;
} finally {
__monitor_exit(this);
}
}
}
저는 처음에 Java를 배울 때 synchronized가 뭔지 몰랐습니다. 그런데 이게 Reentrant Mutex(재진입 가능한 뮤텍스) 기반의 모니터라는 걸 이해했을 때, 왜 Java가 멀티스레드 프로그래밍을 쉽게 만들어주는지 완전히 와닿았습니다.
이 이야기는 동기화 교육에서 빠지지 않는 전설입니다.
상황:
High 우선순위 태스크 (기상 관측, 중요함)Low 우선순위 태스크 (데이터 저장, 자원 A 점유 중)Medium 우선순위 태스크 (통신, 덜 중요함)문제:
Low가 자원 A를 락(뮤텍스) 걸고 작업 중.High가 자원 A가 필요해서 대기(Wait).Medium이 등장해서 CPU를 뺏어감 (Low보다 우선순위 높으니까).Low는 Medium 때문에 실행 못 함 -> 락 반납 못 함.High는 Medium 따위 때문에 무한 대기. (우선순위가 역전됨!)해결: 우선순위 상속(Priority Inheritance).
Low가 High가 기다리는 락을 잡고 있으면, Low의 우선순위를 일시적으로 High만큼 격상시켜서 빨리 끝내게 만듦.이 사건을 처음 읽었을 때, 저는 이런 생각을 했습니다. "아, 동시성 버그는 단순히 코드 문제가 아니라, 시스템 설계 문제구나."
데드락이 어떻게 발생하는지, 전형적인 예시를 분석해보겠습니다.
문제 코드 (의사코드):
class PaymentService {
private final Object lockA = new Object(); // 계좌 락
private final Object lockB = new Object(); // 결제 로그 락
// 스레드 1: 결제 처리
public void processPayment() {
synchronized(lockA) {
// 계좌 차감
Thread.sleep(10); // DB 쿼리 시뮬레이션
synchronized(lockB) {
// 로그 기록
}
}
}
// 스레드 2: 환불 처리
public void processRefund() {
synchronized(lockB) {
// 로그 기록
Thread.sleep(10); // DB 쿼리 시뮬레이션
synchronized(lockA) {
// 계좌 복구
}
}
}
}
데드락 시나리오:
lockA 획득lockB 획득lockB 대기 (스레드 2가 쥐고 있음)lockA 대기 (스레드 1이 쥐고 있음)해결책:
lockA -> lockB 순서로.java.util.concurrent.locks.ReentrantLock의 tryLock(timeout) 사용.ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();
public void safeProcess() {
if (lockA.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (lockB.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
// 안전하게 처리
} finally {
lockB.unlock();
}
} else {
// 타임아웃, 재시도 또는 에러 처리
}
} finally {
lockA.unlock();
}
}
}
락을 2개 이상 쓸 때는 반드시 순서를 문서화해두는 게 이런 버그를 막는 핵심이다.
public class SyncLab {
private int sharedCounter = 0;
// 1. Monitor (Mutex 방식)
// 한 번에 한 스레드만 진입 (소유권 있음)
public synchronized void mutexMethod() {
sharedCounter++;
System.out.println("안전 구역: " + sharedCounter);
}
}
import java.util.concurrent.Semaphore;
public class ApiThrottler {
// permits=3 : 동시에 3명까지 입장 가능 (주차장)
static Semaphore semaphore = new Semaphore(3);
public void accessResource() {
try {
semaphore.acquire(); // 빈 자리 있나? (P연산)
System.out.println("진입: " + Thread.currentThread().getName());
Thread.sleep(1000);
} catch (InterruptedException e) {
} finally {
semaphore.release(); // 나갑니다 (V연산)
System.out.println("퇴장");
}
}
}
synchronized는 무엇인가요?