
Race Condition (경쟁 상태)
Multi-thread 환경에서 여러 thread가 동시에 같은 자원(변수, 리스트, 파일 등)을 수정하면 문제가 발생할 수 있다.
var counter = 0
fun main() {
val threads = List(100) {
Thread {
repeat(1_000_000) {
counter++ // 공유 자원에 동시 접근
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
println("Counter = $counter")
}
예를 들어, 두 개의 thread가 같은 변수를 동시에 증가시키려 한다면, 기대값은 100 * 1,000,000 = 100,000,000이지만 실제 출력은 이보다 훨씬 작을 수 있다.
이게 Race Condition(경쟁 상태) 이다.
Critical Section (임계 구역)
- 여러 thread가 동시에 같은 공유 자원에 접근하는 공유 영역.
- 예를 들어, 전역 변수, 파일, DB 연결 객체 등이 공유 자원이 될 수 있고, 이런 자원은 동시에 수정되면 데이터 무결성이 깨질 수 있다.
- 따라서 임계 구역은 "한 번에 하나의 thread만 실행할 수 있도록 보장해야 하는 코드 블록"이라 할 수 있다.

Lock
Lock은 여러 thread가 동시에 자원에 접근하는 것을 방지하기 위해, 특정 thread가 lock을 획득하면 그 외 다른 thread는 해당 자원을 얻기까지 대기하도록 만드는 방법이다.
synchronized (기본 동기화 블록)
synchronized는 동기화 락(lock)을 사용해서 한 번에 단 하나의 thread만 해당 블록을 실행할 수 있도록 보장한다.
기본적인 동기화 도구이자 가장 오래된 방식으로, 모니터 락(Monitor Lock)을 사용한다. 모든 Java/Kotlin 객체(인스턴스)는 내장된 모니터(Intrinsic Lock)가 존재하며, synchronized 블록/메서드를 사용하면 해당 락을 획득 후 실행할 수 있다.
가장 간단하고 직관적이며, 자동으로 락을 획득/해제 처리한다.
class Counter {
private var count = 0
fun increment() {
synchronized(this) {
count++
}
}
fun getCount(): Int {
return count
}
}
fun main() {
val counter = Counter()
val threads = List(100) {
Thread {
repeat(1_000_000) {
counter.increment()
}
}
}
threads.forEach{ it.start() }
threads.forEach{ it.join() }
println("Counter : ${counter.getCount()}")
}
// Counter = 100000000
동작 원리
- 락 객체 지정 : synchronized 블록 안의 this를 잠금 대상으로 지정한다.
- 락 획득 시도 : 현재 thread가 지정된 락 객체의 내장된 모니터(락) 획득을 시도한다.
- 배타적 실행 : 락을 획득한 thread 만이 synchronized 블록 내부의 코드를 실행할 수 있다.
- 다른 thread 대기 : 다른 thread는 해당 락이 해제될 때까지 대기(blocking) 상태에 들어간다.
- 락 해제 : 실행 중이던 thread가 블록을 벗어나면 락이 자동으로 해제된다.
Synchronized Lock은 OS에 존재하는 락을 불러온다.
Reentrant Lock (재진입 가능 락)
명시적으로 획득하고 해제해야 하는 락으로, 세밀한 락 제어가 가능하지만 이를 누락할 시 교착 상태가 발생할 수 있다.
Condition, time, interrupt 등 순서 제어를 위한 고급 기능을 제공한다.
import java.util.concurrent.locks.ReentrantLock
private var counter = 0
val reentrantLock = ReentrantLock()
fun main() {
val threads = List(100) {
Thread {
repeat(1_000_000) {
reentrantLock.lock()
try {
counter++
} finally {
reentrantLock.unlock()
}
}
}
}
threads.forEach { it.start() }
threads.forEach { it.join() }
println("Counter = $counter")
}
// Counter = 100000000
동작 원리
- 락 객체 지정 및 초기 획득
- 모든 thread가 전역으로 선언된 동일한 객체(ReentrantLock)를 동기화 대상으로 지정한다.
- Thread A가 락을 획득하면 락 객체는 락 소유 스레드 정보와 재진입 카운터를 기록한다.
- 락 재진입 (Reentrancy)
- 이미 락을 소유한 thread가 같은 락 객체에 대해 lock() 메서드를 재호출할 경우 발생한다.
- 이 때, 락은 호출 thread가 이미 소유자인지 확인한다.
- 확인에 성공하면 재진입 카운터만 1 증가시키며, 해당 thread는 차단되지 않고 임계 영역을 재실행한다.
- 다른 thread의 대기
- 특정 thread가 락을 소유하고 있는 동안 다른 thread가 락 획득을 시도할 때 발생한다.
- 이 때, 락은 호출 thread가 소유자가 아님을 확인한다.
- 락을 소유하지 않은 thread는 차단 상태(blocking) 상태로 전환되어, 락이 완전히 해제될 때까지 대기한다.
- 락 해제 및 최종 해방
- 락 소유 thread는 finally 블록 내의 unlock() 메서드를 호출할 때마다 카운터를 감소시킨다. 카운터가 0이 되어야만 락이 완전히 해제된다.
※ 카운터 : ReentrantLock 내부에서 락 재진입 횟수를 세는 내부 변수
Reentrant Lock은 JVM 구현 레벨에서 락을 불러온다.
> 적어도 절대 동기화 문제가 발생하지 않기 때문에 Java에서 락을 구현한다면 대표적으로 사용되는 락 방식이다.
StampedLock (낙관적 읽기/쓰기 락)
Java 8 이상에서 도입된 고성능 락으로, 읽기 락과 쓰기 락이 분리되어 있어 동시성이 향상된다.
쓰기가 거의 없으면 락이 없어도 읽기 가능한 낙관적 읽기(Optimistic Read)를 통해 성능을 최적화할 수 있다.
쓰기 락은 여전히 배타적(Busy)이어서 쓰기 시 다른 읽기/쓰기는 대기한다.
import java.util.concurrent.locks.StampedLock
private var counter = 0
val stLock = StampedLock()
fun main() {
val threads = List(10) { id ->
Thread {
repeat(500) {
if (id % 2 == 0) {
// Write Thread : counter increment
val stamp = stLock.writeLock()
try {
counter++
println("Write Thread-$id incremented counter to $counter")
} finally {
stLock.unlockWrite(stamp)
}
} else {
// Read Thread : Optimistic Read
var value : Int
var stamp : Long
do {
stamp = stLock.tryOptimisticRead()
value = counter
} while (!stLock.validate(stamp)) // Retry when conflicted with the other Write threads
println("Read Thread-$id read counter = $value")
}
Thread.sleep(10)
}
}
}
threads.forEach{ it.start() }
threads.forEach{ it.join() }
println("Counter = $counter")
}
동작 원리
- 10개의 thread 생성 > 짝수 thread는 쓰기, 홀수 thread는 읽기
- 쓰기 thread
- writeLock()으로 락 획득 > counter++ > unlockWirte()
- 해당 블록은 배타적(Exclusive)으로 동작한다.
- 읽기 thread
- tryOptimisticRead() > 락 없이 읽기 시도
- validate(stamp)로 충돌 여부를 확인한다.
- 쓰기와 충돌하면 다시 읽기
- 읽기는 여러 thread가 동시에 수행할 수 있다.
- Thread.sleep(10)으로 읽기/쓰기가 섞이도록 조정한다.
유사한 락으로 ReadWriteLock이 존재한다. 재진입이 가능한 대신, 좀 느리다.
Stamped Lock은 재진입을 불가능하게 한 대신 빠른 편이다.
Semaphore (동시 접근 제한)
Thread 동시 접근 수를 제한하는 동기화 도구로, 락이 아니라 카운터 기반으로 접근을 제어한다.
- 초기화 시 지정한 허용 가능한 최대 동시 접근 수 만큼 thread가 접근 가능하다.
- 접근하면 acquire()를 통해 락이 감소하고, 작업이 끝나면 release()를 통해 락을 증가시킨다.
자원 풀 관리, DB 연결 풀, thread 풀 등에 유용하다.
import java.util.concurrent.Semaphore
import java.util.concurrent.locks.ReentrantLock
var resource = 0
val semp = Semaphore(3)
val lock = ReentrantLock()
fun main() {
val threads = List(100) { id ->
Thread {
repeat(1_000_000) {
semp.acquire() // Access permission requests
try {
lock.lock()
try {
resource++
} finally {
lock.unlock()
}
} finally {
semp.release() // Resource released
}
}
}
}
threads.forEach{ it.start() }
threads.forEach{ it.join() }
println("SharedResource = $resource")
}
// SharedResource = 100000000
동작 원리
- 내부 카운터 변수로 남은 허용 접근 수를 관리한다.
- acquire 호출 시 여분의 카운터가 0을 초과한다면 이를 감소시키고 진입하며, 0이면 대기한다.
- release 호출 시 카운터를 증가시키고, 대기 중인 thread 중 하나가 접근할 수 있다.
※ Semaphore는 동시 접근 수만 제한할 뿐, 내부 연산까지는 보호하지 않는다.
> 안전하게 공유자원을 수정하려면 Atomic 변수 또는 락(synchronized/ReentrantLock)이 필요하다.
종합 비교
| 락 유형 | 형태 | 재진입 | 읽기/쓰기 분리 | 동시 접근 제한 | 특징 |
| synchronized | 모니터 락 | O | X | 1개 thread만 | 간단함, 자동 락/해제 |
| ReentrantLock | 락 객체 | O | X | 1개 thread만 | 세밀한 제어, Condition 지원 |
| StampedLock | 락 객체 | X | O | 쓰기 = 1개만 읽기 = 다수 |
고성능, 낙관적 읽기 |
| Semaphore | 카운터 기반 | X | 읽기/쓰기 개념 X | 지정한 수 만큼 | 자원 풀 관리, 동시 접근 제한 |
- Semaphore의 동시 진입 가능 개수를 하나로 제한한다면 락 시스템과 다른 건 뭘까?
- Semaphore(1)은 상호배제(Mutual Exclusion)을 흉내낼 수 있지만 Lock이 아니다.
- 락은 락을 획득한 thread만 해제 가능해야 하며(Ownership), 특정 코드 블록(Critical Section)의 논리적 일관성을 보장해야 한다.
- Semaphore(1)은 단순히 "동시에 몇 명까지 들어와도 되는가"를 제어하는 동기화 도구로, 임계 구역의 논리적 소유자 개념이 없다.
- Semaphore는 다른 thread가 해제할 수 있기 때문에 락으로 사용하면 안된다.
- 모니터 락과 단순 락 객체 간 차이점?
- 핵심은 "구조적으로 안전한가와 표현력이 높은가"의 차이다.
- 모니터 락(synchronized)은 구조적 락(Structured Locking)으로, 블록을 벗어나면 무조건 해제한다.
- 락 객체(ReentrantLock)는 비구조적 락(Unstructured Locking)으로, 락 생명주기를 코드로 표현하여 표현력을 높이지만 안정성은 낮다.
- ReentrantLock의 카운터 vs. Semaphore의 카운터
- ReentratntLock의 카운터는 (owner thread, hold count) 쌍으로, owner thread만 count를 감소시킬 수 있으며 count가 0이 되면 해당 thread의 ownershipt은 해제된다.
- Semaphore의 카운터는 owner 개념이 없으며, 단순히 한 번에 접근할 수 있는 thread의 수를 표시한다.
- ReentrantLock의 Condtion 활용 예제
> Condition은 thread 간 신호 전달(일종의 wait/notify) 기능을 제공한다.
import java.util.concurrent.locks.ReentrantLock
import java.util.concurrent.locks.Condition
val reentLock = ReentrantLock()
val condition: Condition = reentLock.newCondition()
volatile var ready = false
fun main() {
// 스레드 1: 기다리는 스레드
val waiter = Thread {
reentLock.lock()
try {
while (!ready) {
println("[Thread-1] Waiter: ready is false, waiting..")
condition.await() // wait()와 유사
}
println("[Thread-1] Waiter: ready is true, task execution")
} finally {
reentLock.unlock()
}
}
// 스레드 2: 상태 변경 후 신호 보내는 스레드
val signaler = Thread {
Thread.sleep(500) // 준비 시간
reentLock.lock()
try {
ready = true
condition.signal() // notify()와 유사
println("[Thread-2] Signaler: ready=true, signal sent")
} finally {
reentLock.unlock()
}
}
waiter.start()
signaler.start()
waiter.join()
signaler.join()
}
/*
[Thread-1] Waiter: ready is false, waiting..
[Thread-2] Signaler: ready=true, signal sent
[Thread-1] Waiter: ready is true, task execution
*/
참고 자료
- Kotlin Synchonized와 MultiThread :: 매일매일 꾸준히
Powered By. ChatGPT & Gemini
'Backend' 카테고리의 다른 글
| [Backend] Pub/Sub Pattern (1) | 2025.09.24 |
|---|---|
| [Backend] ThreadLocal과 Atomic (1) | 2025.09.22 |
| [Backend] Process & Thread (2) | 2025.09.17 |
| [Backend] 데이터 구조 (Queue) (0) | 2025.09.11 |
| [Backend] 데이터 구조 (Stack) (0) | 2025.09.11 |