| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- k8s
- 티스토리챌린지
- 스프링
- Effective Java 3
- 알고리즘정렬
- springboot
- 스프링부트
- Effective Java
- 스프링 핵심원리
- 카카오
- 함수형프로그래밍
- java
- Kotlin
- effectivejava
- 김영한
- 자바스크립트
- 스프링핵심원리
- ElasticSearch
- 알고리즘
- Spring
- 예제로 배우는 스프링 입문
- Sort
- 이펙티브자바
- kubernetes
- 엘라스틱서치
- 클린아키텍처
- 자바
- JavaScript
- 오블완
- 이펙티브 자바
- Today
- Total
Kim-Baek 개발자 이야기
동기화와 통신 - 멀티스레드의 가장 어려운 문제 본문
예약이 두 번 되는 버그
Court Alarm을 출시하고 일주일 후, 황당한 버그 리포트가 들어왔다.
"같은 시간대에 예약이 2개 잡혔어요. 코트는 하나인데..."
처음엔 믿기지 않았다. 예약 전에 분명히 중복 체크를 하는 코드가 있었다. 하지만 로그를 확인해보니 정말로 같은 시간대에 2명의 예약이 동시에 들어가 있었다.
[2024-01-05 10:23:45.123] 사용자A: 14시 코트 예약 시도
[2024-01-05 10:23:45.125] 사용자B: 14시 코트 예약 시도
[2024-01-05 10:23:45.234] 사용자A: 예약 성공
[2024-01-05 10:23:45.236] 사용자B: 예약 성공
두 요청이 거의 동시에 들어왔다. 둘 다 "예약 없음"을 확인하고, 둘 다 예약을 진행했다. 전형적인 Race Condition이었다.
이 문제를 해결하면서 5년 차에게도 동기화가 얼마나 어려운지 뼈저리게 느꼈다. 단순히 synchronized 키워드 하나면 해결될 줄 알았는데, 실제로는 훨씬 복잡했다.
Race Condition이란 무엇인가
기본 개념
Race Condition(경쟁 조건)은 여러 스레드가 공유 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 상황이다.
간단한 예시: 카운터
class Counter {
private var count = 0
fun increment() {
count++ // 이 한 줄이 사실은...
}
fun getCount(): Int = count
}
fun main() {
val counter = Counter()
// 1000개 스레드가 각각 1000번 증가
val threads = List(1000) {
thread {
repeat(1000) {
counter.increment()
}
}
}
threads.forEach { it.join() }
println("Expected: 1000000")
println("Actual: ${counter.getCount()}")
// 예상: 1,000,000
// 실제: 987,432 (매번 다름!)
}
왜 이런 일이 발생할까?
count++는 한 줄이지만, CPU 입장에서는 3단계다:
1. LOAD: 메모리에서 count 값을 레지스터로 읽기
2. ADD: 레지스터 값에 1 더하기
3. STORE: 레지스터 값을 메모리에 쓰기
문제 상황:
시간 Thread A Thread B
---- -------- --------
t1 LOAD count (0)
t2 LOAD count (0)
t3 ADD 1 (결과: 1)
t4 ADD 1 (결과: 1)
t5 STORE 1
t6 STORE 1
결과: count = 1 (본래 2가 되어야 함)
두 스레드가 동시에 0을 읽어서, 각각 1을 저장했다. 1 증가가 손실됐다.
실무 예시: 좌석 예약
// ❌ Race Condition이 있는 코드
@Service
class ReservationService(
private val reservationRepository: ReservationRepository
) {
fun reserve(courtId: Long, userId: String, time: LocalDateTime): Reservation {
// 1. 해당 시간 예약 확인
val existing = reservationRepository
.findByCourtIdAndTime(courtId, time)
if (existing != null) {
throw IllegalStateException("이미 예약된 시간입니다")
}
// 2. 예약 생성
val reservation = Reservation(
courtId = courtId,
userId = userId,
time = time
)
return reservationRepository.save(reservation)
}
}
문제 시나리오:
시간 Thread A (사용자A) Thread B (사용자B)
---- ------------------ ------------------
t1 기존 예약 확인 → 없음
t2 기존 예약 확인 → 없음
t3 새 예약 생성
t4 새 예약 생성
t5 DB 저장 (예약 1)
t6 DB 저장 (예약 2)
결과: 같은 시간에 예약 2개!
Critical Section (임계 영역)
개념
Critical Section은 여러 스레드가 동시에 실행하면 안 되는 코드 영역이다.
fun reserve(...) {
// 일반 코드 (여러 스레드 OK)
val userId = getCurrentUserId()
// ===== Critical Section 시작 =====
val existing = findExistingReservation()
if (existing == null) {
createNewReservation()
}
// ===== Critical Section 끝 =====
// 일반 코드 (여러 스레드 OK)
sendNotification()
}
Critical Section의 조건:
- Mutual Exclusion (상호 배제)
- 한 번에 하나의 스레드만 실행
- Progress (진행)
- Critical Section이 비어있으면 대기 중인 스레드가 들어갈 수 있어야 함
- Bounded Waiting (한정 대기)
- 스레드가 무한정 기다리면 안 됨
synchronized: 가장 기본적인 동기화
사용 방법
class Counter {
private var count = 0
// 방법 1: 메서드 전체를 동기화
@Synchronized
fun increment() {
count++
}
// 방법 2: 특정 블록만 동기화
fun incrementBlock() {
synchronized(this) {
count++
}
}
@Synchronized
fun getCount(): Int = count
}
동작 원리
Java/Kotlin의 synchronized는 Monitor 메커니즘을 사용한다.
객체마다 하나의 Lock (Monitor)이 있음
Thread A가 synchronized 블록 진입
→ Lock 획득
→ Critical Section 실행
→ Lock 해제
Thread B가 synchronized 블록 진입 시도
→ Lock이 이미 사용 중
→ BLOCKED 상태로 대기
→ Thread A가 Lock 해제하면 획득
→ Critical Section 실행
내부 구조:
객체
┌─────────────────────────┐
│ 데이터 필드 │
├─────────────────────────┤
│ Monitor (Lock) │
│ - Owner: Thread A │ ← 현재 Lock을 가진 스레드
│ - Entry Set: │ ← Lock을 기다리는 스레드들
│ [Thread B, Thread C] │
│ - Wait Set: [] │ ← wait() 호출한 스레드들
└─────────────────────────┘
실무 적용: 예약 동기화
// ✅ synchronized로 해결
@Service
class ReservationService(
private val reservationRepository: ReservationRepository
) {
// 코트별로 별도의 Lock 객체
private val courtLocks = ConcurrentHashMap<Long, Any>()
fun reserve(courtId: Long, userId: String, time: LocalDateTime): Reservation {
// 해당 코트의 Lock 객체 가져오기
val lock = courtLocks.computeIfAbsent(courtId) { Any() }
synchronized(lock) {
// === Critical Section ===
// 1. 기존 예약 확인
val existing = reservationRepository
.findByCourtIdAndTime(courtId, time)
if (existing != null) {
throw IllegalStateException("이미 예약된 시간입니다")
}
// 2. 새 예약 생성
val reservation = Reservation(
courtId = courtId,
userId = userId,
time = time
)
return reservationRepository.save(reservation)
// === Critical Section 끝 ===
}
}
}
핵심 포인트:
- 코트별로 별도 Lock: 코트 1번 예약과 코트 2번 예약은 서로 막지 않음
- 범위 최소화: synchronized 블록을 최소한으로 유지
- 교착 상태 주의: 여러 Lock을 동시에 획득할 때 조심
성능 측정
// 벤치마크 코드
fun benchmarkSynchronized() {
val counter = Counter()
val threadCount = 100
val iterationPerThread = 10000
val start = System.currentTimeMillis()
val threads = List(threadCount) {
thread {
repeat(iterationPerThread) {
counter.increment()
}
}
}
threads.forEach { it.join() }
val duration = System.currentTimeMillis() - start
println("synchronized: ${duration}ms, count: ${counter.getCount()}")
}
// 결과:
// synchronized: 347ms, count: 1000000 (정확함)
// 동기화 없음: 23ms, count: 987432 (부정확함)
synchronized는 정확하지만 느리다 (15배). Critical Section에 한 번에 하나의 스레드만 들어갈 수 있기 때문이다.
ReentrantLock: 더 유연한 동기화
synchronized의 한계
// ❌ synchronized는 타임아웃이 없음
@Synchronized
fun reserve(...) {
// 다른 스레드가 Lock을 잡고 있으면
// 무한정 대기...
}
// ❌ synchronized는 공정성을 보장 안 함
// 먼저 기다린 스레드가 먼저 실행된다는 보장 없음
// ❌ Lock 상태 확인 불가
// Lock이 사용 중인지 확인할 방법 없음
ReentrantLock의 장점
import java.util.concurrent.locks.ReentrantLock
import java.util.concurrent.TimeUnit
class BetterReservationService {
// 공정성 보장 (fair = true)
private val lock = ReentrantLock(true)
fun reserve(courtId: Long, userId: String, time: LocalDateTime): Reservation {
// 1. 타임아웃 있는 Lock 획득 시도 (3초)
val acquired = lock.tryLock(3, TimeUnit.SECONDS)
if (!acquired) {
throw TimeoutException("예약 처리 중 타임아웃 발생")
}
try {
// === Critical Section ===
val existing = reservationRepository
.findByCourtIdAndTime(courtId, time)
if (existing != null) {
throw IllegalStateException("이미 예약된 시간입니다")
}
return reservationRepository.save(
Reservation(courtId, userId, time)
)
} finally {
// 2. 반드시 Lock 해제
lock.unlock()
}
}
// Lock 상태 확인
fun isReserving(): Boolean {
return lock.isLocked
}
// 대기 중인 스레드 수 확인
fun getWaitingThreadCount(): Int {
return lock.queueLength
}
}
주요 기능:
- tryLock(): 타임아웃 설정 가능
- fair 모드: 먼저 기다린 스레드가 먼저 실행
- isLocked(): Lock 상태 확인
- getQueueLength(): 대기 중인 스레드 수 확인
실전 활용: 모니터링
@Service
class ReservationMonitoringService(
private val reservationService: BetterReservationService
) {
@Scheduled(fixedRate = 1000) // 1초마다
fun monitorLockStatus() {
val isLocked = reservationService.isReserving()
val waitingCount = reservationService.getWaitingThreadCount()
if (waitingCount > 10) {
logger.warn("예약 처리 지연: 대기 중인 요청 ${waitingCount}개")
// Slack 알림
slackNotifier.send(
"⚠️ 예약 시스템 병목 발생\n" +
"대기 중: ${waitingCount}개\n" +
"조치 필요"
)
}
// Prometheus 메트릭 기록
meterRegistry.gauge(
"reservation.lock.waiting",
waitingCount.toDouble()
)
}
}
효과:
- Lock 경합 상황을 실시간 모니터링
- 병목 지점 조기 발견
- 성능 저하 전에 대응 가능
데드락 (Deadlock)
개념
데드락은 두 개 이상의 스레드가 서로가 가진 Lock을 기다리며 영원히 멈추는 상황이다.
발생 예시
// ❌ 데드락 발생 코드
class BankAccount(val id: String, var balance: Int) {
private val lock = ReentrantLock()
fun transfer(to: BankAccount, amount: Int) {
// 1. 내 계좌 Lock
lock.lock()
try {
// 2. 상대 계좌 Lock
to.lock.lock()
try {
if (balance >= amount) {
balance -= amount
to.balance += amount
println("$id → ${to.id}: $amount 원 이체")
}
} finally {
to.lock.unlock()
}
} finally {
lock.unlock()
}
}
}
fun main() {
val accountA = BankAccount("A", 1000)
val accountB = BankAccount("B", 1000)
// Thread 1: A → B 이체
thread {
accountA.transfer(accountB, 100)
}
// Thread 2: B → A 이체
thread {
accountB.transfer(accountA, 200)
}
// 데드락 발생!
}
데드락 시나리오:
시간 Thread 1 (A→B) Thread 2 (B→A)
---- -------------- --------------
t1 A.lock.lock() ✓
t2 B.lock.lock() ✓
t3 B.lock.lock() 대기...
t4 A.lock.lock() 대기...
Thread 1: B의 Lock을 기다림
Thread 2: A의 Lock을 기다림
→ 영원히 대기 (Deadlock)
데드락의 4가지 조건
데드락은 다음 4가지 조건이 모두 만족될 때 발생한다:
1. Mutual Exclusion (상호 배제)
- 자원을 동시에 사용할 수 없음
- Lock은 한 번에 하나의 스레드만
2. Hold and Wait (점유 대기)
- 자원을 가진 채로 다른 자원 대기
- A Lock을 잡고 B Lock 대기
3. No Preemption (비선점)
- 자원을 강제로 빼앗을 수 없음
- Lock을 가진 스레드만 해제 가능
4. Circular Wait (순환 대기)
- 자원 대기가 순환 구조
- Thread 1 → B → Thread 2 → A → Thread 1
이 중 하나라도 제거하면 데드락 방지 가능!
해결 방법 1: Lock 순서 정하기 (Circular Wait 제거)
// ✅ Lock 순서를 정해서 데드락 방지
class BankAccount(val id: String, var balance: Int) {
private val lock = ReentrantLock()
fun transfer(to: BankAccount, amount: Int) {
// Lock을 항상 ID 순서대로 획득
val (first, second) = if (id < to.id) {
this to to
} else {
to to this
}
first.lock.lock()
try {
second.lock.lock()
try {
// 이체 로직
if (this.balance >= amount) {
this.balance -= amount
to.balance += amount
}
} finally {
second.lock.unlock()
}
} finally {
first.lock.unlock()
}
}
}
핵심:
- 모든 스레드가 같은 순서로 Lock 획득
- Circular Wait 불가능
- 데드락 완전 방지
해결 방법 2: tryLock with Timeout (Hold and Wait 제거)
// ✅ 타임아웃으로 데드락 회피
class BankAccount(val id: String, var balance: Int) {
private val lock = ReentrantLock()
fun transfer(to: BankAccount, amount: Int): Boolean {
// 1. 내 Lock 획득 (3초 타임아웃)
if (!lock.tryLock(3, TimeUnit.SECONDS)) {
logger.warn("$id Lock 획득 실패")
return false
}
try {
// 2. 상대 Lock 획득 (3초 타임아웃)
if (!to.lock.tryLock(3, TimeUnit.SECONDS)) {
logger.warn("${to.id} Lock 획득 실패")
return false
}
try {
// 이체 로직
if (balance >= amount) {
balance -= amount
to.balance += amount
return true
}
return false
} finally {
to.lock.unlock()
}
} finally {
lock.unlock()
}
}
}
// 재시도 로직
fun transferWithRetry(from: BankAccount, to: BankAccount, amount: Int) {
var attempts = 0
val maxAttempts = 3
while (attempts < maxAttempts) {
if (from.transfer(to, amount)) {
logger.info("이체 성공")
return
}
attempts++
logger.warn("이체 실패, 재시도 $attempts/$maxAttempts")
Thread.sleep(100 * attempts) // Exponential backoff
}
throw TransferFailedException("이체 실패: 최대 재시도 횟수 초과")
}
효과:
- 타임아웃으로 무한 대기 방지
- 실패 시 재시도
- Exponential backoff로 경합 감소
해결 방법 3: Lock-Free 알고리즘 (Mutual Exclusion 제거)
import java.util.concurrent.atomic.AtomicInteger
// ✅ Atomic 연산으로 Lock 없이 동기화
class AtomicBankAccount(val id: String, initialBalance: Int) {
private val balance = AtomicInteger(initialBalance)
fun transfer(to: AtomicBankAccount, amount: Int): Boolean {
// CAS (Compare-And-Swap) 사용
while (true) {
val currentBalance = balance.get()
if (currentBalance < amount) {
return false // 잔액 부족
}
// 현재 값이 currentBalance면 amount 차감
if (balance.compareAndSet(currentBalance, currentBalance - amount)) {
// 성공하면 상대 계좌 증가
to.balance.addAndGet(amount)
return true
}
// 실패하면 재시도 (다른 스레드가 먼저 수정함)
}
}
fun getBalance(): Int = balance.get()
}
장점:
- Lock 없음 → 데드락 불가능
- Context Switching 없음 → 빠름
- 공정성 보장 안 됨
단점:
- 구현 복잡
- 복잡한 로직에는 부적합
- ABA 문제 가능
실무에서 데드락 디버깅
Court Alarm에서 실제로 데드락이 발생했다.
증상:
- 특정 API가 응답 없음
- CPU 사용률 낮음
- 스레드 대부분 BLOCKED 상태
원인 찾기:
# 1. Thread Dump 생성
jstack <pid> > thread_dump.txt
# 2. 데드락 확인
grep -A 20 "Found one Java-level deadlock" thread_dump.txt
Thread Dump 내용:
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f8a2c004e00 (object 0x00000000d5f45678, a BankAccount),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x00007f8a2c004d00 (object 0x00000000d5f45680, a BankAccount),
which is held by "Thread-1"
Java stack information for the threads listed above:
===================================================
"Thread-1":
at BankAccount.transfer(BankAccount.kt:15)
- waiting to lock <0x00000000d5f45678> (a BankAccount)
- locked <0x00000000d5f45680> (a BankAccount)
"Thread-2":
at BankAccount.transfer(BankAccount.kt:15)
- waiting to lock <0x00000000d5f45680> (a BankAccount)
- locked <0x00000000d5f45678> (a BankAccount)
해결:
- Lock 획득 순서 통일
- 타임아웃 추가
- 모니터링 강화
분산 환경에서의 동기화
문제: synchronized는 단일 서버에서만 동작
Court Alarm은 서버 3대로 운영 중이었다. synchronized는 같은 JVM 내에서만 동작한다.
Server 1 Server 2 Server 3
┌─────────┐ ┌─────────┐ ┌─────────┐
│Thread A │ │Thread B │ │Thread C │
│synchronized │synchronized │synchronized
│ (Lock 1)│ │ (Lock 2)│ │ (Lock 3)│
└─────────┘ └─────────┘ └─────────┘
→ 각 서버마다 별도 Lock
→ 동시에 실행 가능
→ Race Condition 발생!
해결: Redis 분산 Lock
import org.redisson.api.RedissonClient
import org.redisson.api.RLock
import java.util.concurrent.TimeUnit
@Service
class DistributedReservationService(
private val redissonClient: RedissonClient,
private val reservationRepository: ReservationRepository
) {
fun reserve(
courtId: Long,
userId: String,
time: LocalDateTime
): Reservation {
// Redis에 저장된 분산 Lock 획득
val lockKey = "reservation:lock:court:$courtId:${time.toLocalDate()}"
val lock: RLock = redissonClient.getLock(lockKey)
try {
// Lock 획득 시도 (최대 3초 대기, 획득 후 10초 자동 해제)
val acquired = lock.tryLock(3, 10, TimeUnit.SECONDS)
if (!acquired) {
throw LockAcquisitionException("Lock 획득 실패")
}
// === Critical Section ===
// 모든 서버에서 이 코드는 한 번에 하나만 실행
val existing = reservationRepository
.findByCourtIdAndTime(courtId, time)
if (existing != null) {
throw IllegalStateException("이미 예약된 시간입니다")
}
val reservation = Reservation(
courtId = courtId,
userId = userId,
time = time
)
return reservationRepository.save(reservation)
} finally {
// Lock 해제 (반드시 실행)
if (lock.isHeldByCurrentThread) {
lock.unlock()
}
}
}
}
Redis Lock 동작 원리:
Redis (중앙 저장소)
┌─────────────────────────────────┐
│ Key: "reservation:lock:court:1" │
│ Value: "server-1-thread-42" │ ← Lock 소유자
│ TTL: 10초 │ ← 자동 해제 시간
└─────────────────────────────────┘
Server 1 Server 2 Server 3
Thread A Thread B Thread C
↓ ↓ ↓
Redis에 Lock 설정 Redis에 Lock 설정 시도 Redis에 Lock 설정 시도
성공 ✓ 실패 (이미 존재) 실패 (이미 존재)
Critical Section 실행 대기... 대기...
Lock 해제 Lock 획득 성공 ✓ 대기...
Critical Section 실행 ...
핵심 기능:
- tryLock(waitTime, leaseTime)
- waitTime: Lock 획득 최대 대기 시간
- leaseTime: Lock 자동 해제 시간
- 자동 해제 (TTL)
- 서버가 죽어도 Lock이 영원히 안 풀리는 문제 방지
- 10초 후 자동 해제
- 재진입 가능 (Reentrant)
- 같은 스레드는 여러 번 Lock 획득 가능
Redis Lock vs DB Lock
// DB Lock 방식 (SELECT FOR UPDATE)
@Transactional
fun reserveWithDbLock(courtId: Long, userId: String, time: LocalDateTime): Reservation {
// 1. 해당 행에 배타적 Lock
val court = courtRepository.findByIdForUpdate(courtId)
// 2. 예약 확인 및 생성
val existing = reservationRepository
.findByCourtIdAndTime(courtId, time)
if (existing != null) {
throw IllegalStateException("이미 예약된 시간입니다")
}
return reservationRepository.save(
Reservation(courtId, userId, time)
)
}
@Repository
interface CourtRepository : JpaRepository<Court, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Court c WHERE c.id = :id")
fun findByIdForUpdate(@Param("id") id: Long): Court?
}
비교:
항목 Redis Lock DB Lock
| 성능 | 빠름 (메모리) | 느림 (디스크 I/O) |
| Lock 범위 | 유연함 (임의 키) | 제한적 (테이블/행) |
| 자동 해제 | TTL 지원 | 트랜잭션 종료 시 |
| 장애 대응 | Redis 장애 시 문제 | DB 장애 시 문제 |
| 구현 복잡도 | 중간 | 낮음 |
| 적합한 경우 | 서버 여러 대, 빠른 응답 필요 | 단일 서버, DB 중심 |
Court Alarm 선택: Redis Lock
이유:
- 서버 3대 운영 (분산 환경)
- 평균 응답시간 200ms 목표 (DB Lock은 느림)
- Lock 범위 유연성 필요 (코트별, 날짜별)
성능 측정:
DB Lock:
- 평균 응답시간: 850ms
- TPS: 200
Redis Lock:
- 평균 응답시간: 230ms
- TPS: 800
Redis Lock이 3.7배 빠름!
wait()와 notify(): 스레드 간 통신
Producer-Consumer 문제
// ❌ Busy Waiting (비효율적)
class MessageQueue {
private val queue = LinkedList<String>()
private val capacity = 10
fun produce(message: String) {
// 큐가 가득 찰 때까지 대기
while (queue.size >= capacity) {
// CPU를 계속 사용하며 대기 (비효율적!)
Thread.sleep(10)
}
synchronized(queue) {
queue.add(message)
}
}
fun consume(): String? {
// 큐가 비어있으면 대기
while (queue.isEmpty()) {
// CPU를 계속 사용하며 대기 (비효율적!)
Thread.sleep(10)
}
return synchronized(queue) {
queue.removeFirst()
}
}
}
문제: CPU를 낭비하며 대기
wait()와 notify() 사용
// ✅ wait/notify로 효율적 대기
class MessageQueue {
private val queue = LinkedList<String>()
private val capacity = 10
private val lock = Object()
fun produce(message: String) {
synchronized(lock) {
// 큐가 가득 차면 대기
while (queue.size >= capacity) {
lock.wait() // Lock 해제하고 대기 (CPU 안 씀)
}
// 메시지 추가
queue.add(message)
println("[Producer] 메시지 추가: $message, 큐 크기: ${queue.size}")
// 대기 중인 Consumer 깨우기
lock.notifyAll()
}
}
fun consume(): String {
synchronized(lock) {
// 큐가 비어있으면 대기
while (queue.isEmpty()) {
lock.wait() // Lock 해제하고 대기
}
// 메시지 꺼내기
val message = queue.removeFirst()
println("[Consumer] 메시지 처리: $message, 큐 크기: ${queue.size}")
// 대기 중인 Producer 깨우기
lock.notifyAll()
return message
}
}
}
// 사용 예시
fun main() {
val queue = MessageQueue()
// Producer 스레드
repeat(5) { i ->
thread {
repeat(20) { j ->
queue.produce("Message-$i-$j")
Thread.sleep(100)
}
}
}
// Consumer 스레드
repeat(3) { i ->
thread {
repeat(33) {
val message = queue.consume()
Thread.sleep(150)
}
}
}
}
wait() 동작:
- 현재 Lock 해제
- Wait Set에 들어가서 대기 (WAITING 상태)
- CPU 사용 안 함
- notify() 호출되면 깨어남
- Lock 재획득 시도
- Lock 획득 후 실행 계속
notify() vs notifyAll():
- notify(): Wait Set에서 하나의 스레드만 깨움
- notifyAll(): Wait Set의 모든 스레드 깨움
- 일반적으로 notifyAll()이 안전 (모든 스레드가 조건 재확인)
실무 적용: 작업 큐
@Component
class TaskQueue {
private val queue = LinkedList<Task>()
private val lock = Object()
private val maxSize = 1000
// Producer: API 요청이 작업 추가
fun submit(task: Task) {
synchronized(lock) {
while (queue.size >= maxSize) {
logger.warn("작업 큐 가득 참, 대기 중...")
lock.wait(5000) // 최대 5초 대기
if (queue.size >= maxSize) {
throw QueueFullException("작업 큐 가득 참")
}
}
queue.add(task)
logger.info("작업 추가: ${task.id}, 큐 크기: ${queue.size}")
lock.notifyAll() // Worker 깨우기
}
}
// Consumer: Worker 스레드가 작업 가져감
fun take(): Task {
synchronized(lock) {
while (queue.isEmpty()) {
logger.debug("작업 없음, 대기 중...")
lock.wait() // 새 작업 올 때까지 대기
}
val task = queue.removeFirst()
logger.info("작업 처리: ${task.id}, 남은 작업: ${queue.size}")
lock.notifyAll() // Producer 깨우기 (큐에 공간 생김)
return task
}
}
fun size(): Int = synchronized(lock) { queue.size }
}
// Worker 스레드
@Service
class TaskWorker(private val taskQueue: TaskQueue) {
@PostConstruct
fun start() {
repeat(10) { workerId ->
thread(name = "Worker-$workerId") {
while (true) {
try {
val task = taskQueue.take()
task.execute()
} catch (e: InterruptedException) {
logger.info("Worker $workerId 종료")
break
} catch (e: Exception) {
logger.error("작업 실행 실패", e)
}
}
}
}
}
}
효과:
- CPU 낭비 없음 (Busy Waiting 제거)
- 자동 부하 조절 (큐 가득 차면 Producer 대기)
- Worker 개수로 처리 속도 조절
BlockingQueue: Java의 스레드 안전한 큐
wait/notify 직접 구현은 어렵다
실제로는 BlockingQueue를 사용하는 게 낫다.
import java.util.concurrent.ArrayBlockingQueue
import java.util.concurrent.TimeUnit
@Service
class ModernTaskQueue {
// 용량 1000, 공정성 보장
private val queue = ArrayBlockingQueue<Task>(1000, true)
// Producer
fun submit(task: Task) {
// 3초 안에 추가 못 하면 예외
val success = queue.offer(task, 3, TimeUnit.SECONDS)
if (!success) {
throw QueueFullException("작업 큐 가득 참")
}
logger.info("작업 추가: ${task.id}")
}
// Consumer
fun take(): Task {
// 블로킹, 작업이 올 때까지 대기
return queue.take()
}
// 타임아웃 있는 take
fun poll(timeout: Long, unit: TimeUnit): Task? {
return queue.poll(timeout, unit)
}
fun size(): Int = queue.size
fun remainingCapacity(): Int = queue.remainingCapacity()
}
BlockingQueue 종류:
- ArrayBlockingQueue
- 고정 크기 배열 기반
- FIFO 순서
- 공정성 모드 지원
- LinkedBlockingQueue
- 링크드 리스트 기반
- 무제한 크기 (또는 제한 가능)
- 높은 처리량
- PriorityBlockingQueue
- 우선순위 기반
- 무제한 크기
- Comparator로 순서 결정
- DelayQueue
- 지연 실행
- 시간 지나면 사용 가능
실무 적용: 알림 발송 시스템
@Service
class NotificationService {
// 우선순위별 큐
private val queues = mapOf(
Priority.CRITICAL to ArrayBlockingQueue<Notification>(100),
Priority.HIGH to ArrayBlockingQueue<Notification>(500),
Priority.NORMAL to ArrayBlockingQueue<Notification>(1000)
)
fun send(notification: Notification) {
val queue = queues[notification.priority]!!
// 논블로킹 추가
val success = queue.offer(notification)
if (!success) {
logger.warn("큐 가득 참: ${notification.priority}, 알림 드롭")
// 메트릭 기록
meterRegistry.counter("notification.dropped").increment()
}
}
@PostConstruct
fun startWorkers() {
// Priority별 Worker 수 다르게
startWorker(Priority.CRITICAL, workerCount = 10)
startWorker(Priority.HIGH, workerCount = 5)
startWorker(Priority.NORMAL, workerCount = 2)
}
private fun startWorker(priority: Priority, workerCount: Int) {
val queue = queues[priority]!!
repeat(workerCount) { workerId ->
thread(name = "Notification-${priority}-$workerId") {
while (true) {
try {
// 1초 타임아웃으로 폴링
val notification = queue.poll(1, TimeUnit.SECONDS)
if (notification != null) {
sendActual(notification)
}
} catch (e: InterruptedException) {
break
}
}
}
}
}
private fun sendActual(notification: Notification) {
// FCM, APNs 등으로 실제 발송
fcmClient.send(notification)
}
}
성능:
- CRITICAL 알림: 평균 지연 50ms
- HIGH 알림: 평균 지연 200ms
- NORMAL 알림: 평균 지연 1초
- 큐 Drop율: 0.01% (잘 설계됨)
면접에서 이렇게 답하자
1단계: Race Condition 설명 (30초)
"Race Condition은 여러 스레드가 공유 자원에 동시 접근할 때 실행 순서에 따라 결과가 달라지는 상황입니다. 예를 들어 count++는 LOAD-ADD-STORE 3단계인데, 두 스레드가 동시에 LOAD하면 1 증가가 손실됩니다."
2단계: 동기화 방법 (1분)
"해결 방법은 크게 세 가지입니다. 첫째, synchronized로 Critical Section을 상호 배제합니다. 둘째, ReentrantLock으로 타임아웃과 공정성을 제어합니다. 셋째, Atomic 변수로 Lock-free하게 구현합니다."
3단계: 실무 경험 (2분)
"Court Alarm에서 예약이 중복되는 버그가 있었습니다. 두 요청이 동시에 '예약 없음'을 확인하고 둘 다 예약을 진행했습니다. 처음엔 synchronized를 썼는데, 서버 3대 환경에서는 동작하지 않았습니다."
"Redis 분산 락으로 해결했습니다. Redisson 라이브러리의 tryLock을 사용해 3초 타임아웃, 10초 자동 해제로 설정했습니다. 결과적으로 중복 예약이 완전히 사라졌고, 평균 응답시간도 230ms로 DB Lock보다 3.7배 빠릅니다."
4단계: 데드락 경험 (1분 30초)
"이체 기능 구현 중 데드락이 발생했습니다. Thread 1이 A Lock을 잡고 B를 기다리고, Thread 2가 B Lock을 잡고 A를 기다리는 상황이었습니다. jstack으로 Thread Dump를 떠서 확인했습니다."
"Lock 획득 순서를 계좌 ID 순으로 통일해서 Circular Wait를 제거했습니다. 추가로 tryLock에 3초 타임아웃을 설정하고, 실패 시 Exponential Backoff로 재시도하도록 했습니다."
핵심 팁
구체적인 코드와 숫자:
- ❌ "동기화를 잘했어요"
- ✅ "Redis 분산 락으로 평균 응답시간 230ms 달성, DB Lock 대비 3.7배 향상"
문제 → 분석 → 해결 → 결과:
- 문제: 예약 중복
- 분석: Race Condition, 서버 3대 분산 환경
- 해결: Redis 분산 락, tryLock(3초, 10초)
- 결과: 중복 예약 0건, 응답시간 230ms
도구 언급:
- jstack (Thread Dump)
- Redisson (Redis 클라이언트)
- 모니터링 (Prometheus, Grafana)
정리
동기화는 멀티스레드 프로그래밍의 핵심이자 가장 어려운 부분이다.
주요 개념:
Race Condition:
- 여러 스레드가 공유 자원에 동시 접근
- 실행 순서에 따라 결과 달라짐
- Critical Section 보호 필요
동기화 방법:
- synchronized
- 장점: 간단, 안전
- 단점: 느림, 타임아웃 없음
- 사용: 단일 서버, 단순한 경우
- ReentrantLock
- 장점: 타임아웃, 공정성, 상태 확인
- 단점: 복잡함, 수동 unlock 필요
- 사용: 세밀한 제어 필요한 경우
- Atomic 변수
- 장점: Lock-free, 빠름
- 단점: 단순한 연산만 가능
- 사용: 카운터, 플래그 등
- Redis 분산 락
- 장점: 분산 환경 지원, 빠름
- 단점: Redis 의존성, 네트워크 지연
- 사용: 서버 여러 대
데드락:
- 4가지 조건: 상호 배제, 점유 대기, 비선점, 순환 대기
- 해결: Lock 순서 통일, 타임아웃, Lock-free
- 디버깅: jstack, Thread Dump
스레드 통신:
- wait/notify: 효율적 대기
- BlockingQueue: 안전한 큐, Producer-Consumer
- 실무: 작업 큐, 알림 시스템
핵심 원칙:
- Critical Section 최소화
- Lock 순서 통일
- 항상 타임아웃 설정
- 모니터링과 로깅
- 단순하게 유지
다음 글에서는 "네트워크 - OSI 7 Layer"를 다루면서, 데이터가 어떻게 네트워크를 통해 전달되는지, 그리고 각 계층에서 어떤 문제가 발생하는지 이야기해보겠다.
'컴퓨터 공학 > 운영체제' 카테고리의 다른 글
| 네트워킹 - 운영체제가 네트워크를 다루는 방법 (0) | 2026.01.08 |
|---|---|
| 메모리 관리 - 5년차 개발자가 겪은 메모리 누수와의 전쟁 (0) | 2026.01.06 |
| 프로세스와 스레드 - 경력 5년차 개발자의 실전 경험 (1) | 2026.01.05 |
| OS 메모리 구조, 스케쥴러 (0) | 2020.10.09 |
