동기화란?
: 두 가지 이상의 작업이 동시에 실행될 때 꼬이지 않도록 맞추는 것
여러 개의 작업(스레드/프로세스)이 동시에 실행될 때,
데이터의 일관성을 유지하고 충돌을 막기 위해 실행 순서나 접근을 제어하는 것을 말한다.
즉, 동기화에서 실행 순서와 공유자원 접근은 중요한 문제이다.
1. 실행 순서 문제 (순서 동기화)
“A가 끝난 뒤에 B를 실행해야 한다”
예:
- Producer → Consumer
- 초기화 후 사용
2. 공유 자원 접근 문제 (임계구역(critical section) 문제)
“여러 명이 동시에 만지면 안 된다”
예:
- sharedData++
- 파일 쓰기
- 캐시 수정
아래와 같이 2개의 스레드를 동시에 작동시켜보자.
public class Race {
static int sharedData = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(new Increment());
Thread thread2 = new Thread(new Decrement());
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// ⭐ 핵심: 결과 확인
System.out.println("Final sharedData = " + sharedData);
}
static class Increment implements Runnable {
public void run() {
for (int i = 0; i < 100000; i++) {
Race.sharedData++;
}
System.out.println("Increment finished");
}
}
static class Decrement implements Runnable {
public void run() {
for (int i = 0; i < 100000; i++) {
Race.sharedData--;
}
System.out.println("Decrement finished");
}
}
}
결과를 실행 시켜보면 Increment()와 Decrement()가 각각 10만번 실행되므로, 최종적으로 공유 자원이 0이나와야한다. 하지만 이를 실행 시켜보면 아래와 같이 0이 나오지 않는다.

왜 그런걸까?
공유 자원에 대한 동기화가 제대로 이루어지지 않아, Race condintion이 발생했기 때문이다.
++ / -- 연산은 원자적(atomic)이지 않기 때문에, 읽기 → 연산 → 쓰기 과정에서 충돌이 발생한다.
오늘은 동기화를 위한 도구들을 소개해보고자한다.
뮤텍스 락이란?
"임계구역에 접근하려면 허가증 들고 오세요!"
"임계구역에서 볼일 끝났으면 허가증 반납하세요!"
임계구역에 접근하기 위한 허가증은 하나이기 때문에, 동시에 여러 스레드가 임계구역에 접근할 수 없다. 이것이 뮤텍스 락을 이용해 동기화의 문제를 해결한 동기화 방법이다.
코드에서 좀 더 자세히 보자면,
임계구역에 접근하려고하면 반드시 락(Lock)을 획득(acquire)해야하고,
임계구역에서 작업이 끝나면 락(Lock)을 해제(release) 해야한다.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Race {
static int sharedData = 0;
static Lock lock=new ReentrantLock();
public static void main(String[] args) {
// (생략)
}
static class Increment implements Runnable {
public void run() {
for (int i = 0; i < 100000; i++) {
lock.lock();
try{
Race.sharedData++;
} finally{
lock.unlock();
}
}
System.out.println("Increment finished");
}
}
//
}
lock을 걸어주면 sharedData가 0으로 제대로 결과가 나오는 것을 확인할 수 있다.

에러 :
참고로 Lock을 획득하지않고, unlock() 하면 아래의 IllegalMonitorStateException 에러가 터진다.

세마포란?
뮤텍스가 "이 방에 들어가려면 허가증 가져오세요! 동반입장 안됩니다.(허가증 1개)"라면,
세마포란 "이 방은 허가증이 남아 있으면 들어갈 수 있습니다." 이다.
그래서 공유자원이 아래와 같이 1로 지정하면 허가증이 1개이므로, 위에서 사용한 뮤텍스 락과 동일한 효과를 가진다.
static Semaphore semaphore = new Semaphore(1);
코드로 살펴보자면, semaphore도 acquire()과 release()를 사용한다.
import java.util.concurrent.Semaphore;
public class Race {
static int sharedData = 0;
// 공유 자원 1개
static Semaphore semaphore = new Semaphore(1);
public static void main(String[] args) {
// (생략)
}
static class Increment implements Runnable {
public void run() {
for (int i = 0; i < 100000; i++) {
try{
semaphore.acquire();
Race.sharedData++;
} catch (InterruptedException e){
e.printStackTrace();
}
finally{
semaphore.release();
}
}
System.out.println("Increment finished");
}
}
}
에러:
semphore의 경우 InterruptedException이 발생할 수 있어서 이를 catch 해줘야한다.

하지만, 세마포어가 2개 이상으로 설정하면, 다시 sharedData가 틀리게 나오는걸 확인할 수 있다.

세마포어의 개수가 2 이상이면,
동시에 여러 스레드가 임계 구역에 들어갈 수 있기 때문에, 공유 자원 보호에 세마포어를 사용하는 것은 적합하지 않다.
세마포어는 락이 아니다!
세마포어는 락과 같은 효과로 사용할 수 있다 라는 관점에서 소개한 것이지, 사실 세마포어는 락 그 자체는 아니다. 락과 다른 개념의 동기화 도구라고 생각하면 된다.
그러면 세마포어는 언제 사용할까?
1) DB 커넥션 풀 (10개)
10개까지의 DB 커넥션을 사용하고, DB 작업이 끝나면 다시 반납한다.
Semaphore dbSemaphore = new Semaphore(10);
void useDb() throws InterruptedException {
dbSemaphore.acquire(); // 커넥션 하나 확보
try {
// DB 작업
} finally {
dbSemaphore.release(); // 커넥션 반납
}
}
2) API / 서버 동시 요청 제한 (Rate Limiting)
API에서도 아래와 같이 최대 100개가 동시에 요청을 받도록 세마포어를 사용할 수 있다.
200개를 동시에 요청한다면 100개는 바로 요청을 받고, 나머지 100개의 API 요청들은 세마포어를 획득할 때까지 대기하거나, 바로 클라이언트에게 실패를 반환한다. 세마포어를 획득할 때까지 대기하는 경우 서버에 무리가 갈 수 있기 때문에, 보통 실무에서는 tryAcquire() 방식으로 즉시 실패시키는 경우가 많다. (ex. "잠시 후 다시 시도해주세요")
Semaphore apiLimit = new Semaphore(100);
void handleRequest() throws InterruptedException {
apiLimit.acquire();
try {
// 요청 처리
} finally {
apiLimit.release();
}
}
3) Producer–Consumer (버퍼 크기 제한)
이 예시는 학교에서 동기화 개념을 배울 때 나오는 대표적인 예시이다.
하지만 실제로 Producer–Consumer 문제에 세마포어를 직접 구현하기 보다는BlockingQueue와 같은 다른 방법을 이용한다고 한다.
Semaphore empty = new Semaphore(BUFFER_SIZE);
Semaphore full = new Semaphore(0);
void produce() throws InterruptedException {
empty.acquire(); // 빈 공간 확보
// 생산
full.release(); // 채워졌다고 알림
}
void consume() throws InterruptedException {
full.acquire(); // 데이터 있음 확인
// 소비
empty.release(); // 공간 반납
}
'CS' 카테고리의 다른 글
| [CS] JAVA 싱글톤 패턴과 Spring 싱글톤의 차이, 그리고 thread-safe 오해 (0) | 2025.12.17 |
|---|---|
| [CS] 왜 HTTP 통신에는 직렬화가 필요할까? JSON과 Spring MVC로 이해하는 직렬화와 역직렬화 (0) | 2025.12.12 |