개요
동기화를 위한 여러 전략과 각각의 특징과 차이에 대해 알아보려고 한다.
본격적으로 살펴보기 전에 알아두어야 할 개념이 있다.
racecondition(경쟁조건)
여러 프로세스/스레드가 동시에 같은 데이터를 조작할 때 타이밍이나 접근 순서에 따라 결과가 달라질 수 있는 상황.
synchronization(동기화)
여러 프로세스/스레드를 동시에 실행해도 공유 데이터의 일관성을 유지하는 것
critical section(임계 영역)
공유 데이터의 일관성을 보장하기 위해 하나의 프로세스/스레드만 진입해서 실행 가능한 영역 이라 한다.
이와 같이 하나만 접근 가능하도록 하는 것을 mutual exclusion이라고 한다.
이제 이러한 문제를 해결하기 위해 어떤 동기화 전략들이 있는지 살펴보자.
하나의 프로세스/스레드만 진입해서 실행 가능한 영역 (mutual exclusion)을 보장할 수 있을까?
바로 락(Lock)을 사용하면 된다.
SpinLock
SpinLock은 락을 얻을 때까지 스레드가 계속 반복문을 돌면서(lock이 해제되기를 기다리면서) CPU를 소비하는 방식이다.
간단하지만, 기다리는 동안 CPU를 낭비한다는 단점이 있다.
아래는 자바에서 'AtomicBoolean'을 활용해 SpinLock을 직접 구현한 예시이다.
import java.util.concurrent.atomic.AtomicBoolean;
class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
// 다른 스레드가 lock을 이미 true로 바꿨으면 계속 루프 돌며 대기 (spin)
while (lock.getAndSet(true)) {
// busy waiting
}
}
public void unlock() {
lock.set(false); // 락 반납
}
}
public class SpinLockExample {
private static int count = 0;
private static final SpinLock spinLock = new SpinLock();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
spinLock.lock(); // 락 획득
count++; // 임계 영역
spinLock.unlock(); // 락 해제
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
spinLock.lock();
count++;
spinLock.unlock();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 count: " + count);
}
}
실행하면 두 스레드가 1000번씩 증가시켜 최종 count: 2000이 출력된다.
반복문 부분에 spinLock 부분을 지우고 결과를 보면 이렇게 결과가 일정하지 않다.

SpinLock을 사용했을 때는 항상 최종 count: 2000이 출력된다.
하지만 SpinLock을 제거하고 실행하면 아래 이미지처럼 '997', '1532', '1847' 등 매번 다른 값이 나오게 된다.
이는 여러 스레드가 동시에 'count++' 연산에 접근하면서 값이 덮어씌워지는 race condition이 발생했기 때문이다.
따라서 임계 영역을 보호하기 위한 동기화 전략(락)이 반드시 필요하다.
하지만 이방식은 위에서도 말한 단점이 있는데, Lock을 기다리는 동안 CPU를 계속 낭비한다는 점이 있다.
이를 개선하기 위해 나온 방식이 바로 mutex 이다.
Mutex
Mutex(Mutual Exclusion Object)는 는 상호 배제를 보장하는 도구다.
SpinLock처럼 “임계 영역에 한 번에 하나만 들어갈 수 있다”는 성질은 같지만, 대기 방식이 다르다.
SpinLock은 락을 얻을 때까지 계속 돌며(CPU를 소비) 기다리지만,
Mutex는 락이 점유 중이면 스레드를 잠재우고(sleep/park) 락이 풀리면 OS/AQS가 깨워준다.
따라서 짧은 임계 영역은 SpinLock이 빠를 수 있으나, 긴 임계 영역에서는 Mutex가 효율적이다.
자바에는 Mutex라는 이름의 클래스가 없고, 보통 ReentrantLock(혹은 synchronized)으로 Mutex를 구현한다.
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // 뮤텍스 잠금
try {
count++; // 임계 영역
} finally {
lock.unlock(); // 반드시 해제
}
}
public int getCount() {
return count;
}
}
public class MutexExample {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) counter.increment();
System.out.println("[t1] done");
}, "t1");
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) counter.increment();
System.out.println("[t2] done");
}, "t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("최종 count: " + counter.getCount());
}
}
왜 t1이 잡으면 t2는 못 잡나?

Lock은 인터페이스이고, 실제 동작은 ReentrantLock이 구현한다.

ReentrantLock.lock() 호출해 내부의 sync.lock()으로 진입한다. 진입 후 아래의 메서드를 탄다.

initialTryLock()이 성공하면(락이 비어 있으면) 즉시 획득하고 반환시키고 끝낸다.
락이 이미 획득되어 있다면 false를 반환하고 현재 스레드를 acquire(1)로 넘긴다.
여기서 AQS(AbstractQueuedSynchronizer)가 현재 스레드를 대기 큐(wait queue)에 등록시키고,
LockSupport.park()를 통해 락이 해제될 때까지 블로킹 상태로 유지한다.
락 소유자(t1)가 unlock()을 호출하면 AQS가 큐의 다음 스레드(t2)를 깨워(unpark) 락을 넘긴다.
즉, t2는 lock() 호출 “즉시” 임계 영역에 들어가는 게 아니라,
AQS 큐에 들어가 잠들어 있다가 t1이 락을 반납할 때 깨어나 락을 획득하게 된다.
여기까지 살펴본 SpinLock과 Mutex는 공통적으로 "임계 영역에는 한 번에 하나의 스레드만 들어간다"는 점을 보장한다.
하지만 실제 상황에서는 자원을 동시에 여러 개까지만 허용해야 하는 경우가 있다.
예를 들어, 네트워크 소켓 연결을 동시에 N개까지만 제한하거나, 데이터베이스 커넥션 풀을 5개까지만 허용하는 경우가 그렇다.
이런 상황에서는 단순히 1개만 허용하는 Mutex만으로는 부족하다. 이때 사용하는 전략이 바로 세마포어(Semaphore)이다.
Semaphore
세마포어(Semaphore)는 공유 자원에 동시에 접근할 수 있는 스레드의 개수를 제어하는 동기화 도구다.
앞서 살펴본 SpinLock과 Mutex는 “임계 영역에는 한 번에 하나만 들어갈 수 있다”는 점을 보장했지만,
세마포어는 좀 더 일반화된 방식으로, 동시에 여러 개의 스레드까지 접근할 수 있도록 허용한다.
즉,
- Mutex = 동시 접근 허용 개수가 1인 세마포어
- Semaphore(N) = 동시에 최대 N개의 스레드가 접근 가능
예를 들어,
- 데이터베이스 커넥션 풀을 동시에 최대 5개까지만 사용 가능하게 하거나
- 네트워크 소켓 연결을 동시에 10개까지만 허용하는 경우
- 세마포어를 사용하면 된다.
자바에서의 세마포어 사용
자바에서는 java.util.concurrent.Semaphore 클래스로 세마포어를 제공한다.
다음은 동시에 최대 3개의 스레드만 임계 영역에 들어갈 수 있도록 제한한 예시다.
import java.util.concurrent.Semaphore;
public class SemaphoreExample {
private static final Semaphore semaphore = new Semaphore(3); // 동시에 3개 허용
public static void main(String[] args) {
for (int i = 1; i <= 10; i++) {
Thread t = new Thread(new Worker(i));
t.start();
}
}
static class Worker implements Runnable {
private final int id;
Worker(int id) {
this.id = id;
}
@Override
public void run() {
try {
semaphore.acquire(); // 세마포어 획득 (없으면 대기)
System.out.println("작업자 " + id + " 시작");
Thread.sleep(1000); // 임계 영역 (자원 사용)
System.out.println("작업자 " + id + " 종료");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
semaphore.release(); // 세마포어 반환
}
}
}
}
실행결과

출력은 항상 다를 수 있지만, 동시에 최대 3개의 작업자만 실행되는걸 볼수 있다.
정리
SpinLock
특징: 락을 얻을 때까지 계속 반복문을 돌면서 기다림 (busy waiting, CPU 점유)
장점: 임계 영역이 아주 짧을 때는 빠름, 커널 전환(컨텍스트 스위칭) 비용 없음
단점: CPU를 계속 사용해 낭비 발생, 싱글 코어에서는 비효율적
사용 예시: 짧고 자주 접근하는 공유 변수 보호
Mutex
특징: 한 번에 하나만 접근 가능. 이미 점유 중이면 스레드를 잠재우고(block) 락이 풀릴 때 깨움
장점: CPU 낭비 없음, 멀티/싱글 코어 모두 안정적
단점: 커널 호출, 스케줄링 비용이 발생 (SpinLock보다 무거움)
사용 예시: 임계 영역이 길거나 언제 끝날지 모르는 경우
Semaphore
특징: 공유 자원에 동시에 접근 가능한 개수를 N개로 제한 (Mutex = Semaphore(1))
장점: 여러 개의 스레드를 동시에 제어 가능, 자원 개수 제한 관리에 유용
단점: 잘못 사용하면 데드락, 자원 누수 위험
사용 예시: DB 커넥션 풀(예: 5개), 네트워크 소켓 동시 연결 제한
결국 SpinLock, Mutex, Semaphore는 전부 race condition(경쟁 조건)을 막고 공유 자원의 일관성을 지키기 위한 도구다.
- SpinLock은 짧은 구간에서 빠르지만 CPU를 낭비한다.
- Mutex는 안정적이고 가장 널리 쓰인다.
- Semaphore는 동시에 여러 개 접근을 제어할 수 있는 확장된 개념이다.
상황에 따라 어떤 걸 쓰느냐가 성능과 안정성을 결정한다.
참고자료
'CS > 운영체제' 카테고리의 다른 글
| Monitor(모니터) 개념과 자바 예제 (0) | 2025.09.03 |
|---|---|
| 스레드 풀(Thread Pool)의 개념과 사용방법 (1) | 2025.08.31 |
| 스레드 (Thread)의 종류 (1) | 2025.08.30 |
| 프로세스와 쓰레드 개념 정리 (1) | 2025.07.17 |