코딩

[코루틴 (8)] 동시성 문제와 스레드 안전성(Mutex, Semaphore)

Eastpark 2025. 1. 20. 21:21
728x90
반응형

지난 7편에서는 안드로이드 실무에서 Retrofit, Room, ViewModel을 코루틴과 결합하는 방법을 살펴보았습니다. 이번 글에서는 동시성(Concurrency) 문제스레드 안전성에 초점을 맞추어, 코루틴 환경에서 발생할 수 있는 Race Condition과 이를 방지하기 위한 Mutex, Semaphore 등의 기법을 소개하려고 합니다.

 

핵심 키워드

동시성 문제(Concurrency Issues)

Race Condition

스레드 안전성(Thread-safety)

Mutex, Semaphore

Shared Mutable State


1. 왜 동시성 문제가 발생하는가?

 

1.1 Shared Mutable State

 

코루틴은 “가벼운 스레드”처럼 동작하지만, 실제로는 여러 스레드 위에서 동작할 수 있습니다. 그러므로 코루틴끼리 공유 자원(예: 전역 변수, 컬렉션, DB 객체 등)에 동시에 접근하면 스레드 경쟁(Race Condition)이 일어날 수 있습니다.

Race Condition

두(혹은 그 이상의) 코루틴이 동시에 같은 자원을 읽고/쓰는 과정에서, 수행 순서에 따라 예기치 않은 결과가 발생하는 문제

예: 한 코루틴이 변수 값을 읽기 직전에 다른 코루틴이 그 변수를 변경하면, 의도하지 않은 결과가 나올 수 있음

 

1.2 Atomic Operation과 Lock 개념

 

전통적인 스레드 프로그래밍에서 원자적(Atomic) 연산이나 Lock/Mutex를 사용해 문제를 해결해왔습니다. 코틀린 코루틴 환경에서도 유사한 접근이 필요합니다.


2. 기본 예시: Race Condition 시뮬레이션

var counter = 0

fun main() = runBlocking {
    val jobList = List(100) {
        launch(Dispatchers.Default) {
            repeat(1000) {
                counter++
            }
        }
    }
    jobList.joinAll()
    println("Final counter = $counter")
}

이상적으로는 counter 값이 100 * 1000 = 100000이 되어야 하지만, 실제로는 Race Condition 때문에 더 낮은 값이 나올 수 있습니다.

코루틴끼리 동시에 counter를 읽고 쓰는 과정에서 충돌이 일어나기 때문입니다.


3. Mutex로 동시성 제어

 

3.1 Mutex란?

 

Mutex는 상호 배제(Mutual Exclusion)를 보장하는 객체입니다. 한 번에 단 하나의 코루틴만 특정 구역(임계 구역)에서 코드를 실행할 수 있게 합니다.

기존 스레드 프로그래밍의 ReentrantLock과 유사하며, 코루틴 환경에서도 일시 중단 가능한 withLock 함수를 제공합니다.

val mutex = Mutex()
var safeCounter = 0

suspend fun incrementSafe() {
    mutex.withLock {
        safeCounter++
    }
}

withLock { ... } 블록 안에서는 단 하나의 코루틴만 진입 가능하므로, Race Condition이 발생하지 않습니다.

 

3.2 단순 예시

fun main() = runBlocking {
    val mutex = Mutex()
    var counter = 0

    val jobs = List(100) {
        launch(Dispatchers.Default) {
            repeat(1000) {
                mutex.withLock {
                    counter++
                }
            }
        }
    }
    jobs.joinAll()
    println("Final counter = $counter") // 항상 100000 보장
}

이 코드는 Race Condition 없이 100,000이 정확히 출력됩니다.

그러나 동시성 성능(병렬성)은 떨어질 수 있으므로, 필요한 곳에서만 락을 사용해야 합니다.

반응형

4. Semaphore로 동시 처리량 조절

 

4.1 Semaphore란?

 

Semaphore는 특정 자원에 대해 동시 접근 가능한 개수를 제한하는 동시성 도구입니다. 예를 들어, 네트워크 연결 풀에서 동시에 처리 가능한 스레드 수를 제한하거나, 여러 코루틴이 같은 파일에 접근하는 횟수를 제어할 때 사용합니다.

val semaphore = Semaphore(3)

suspend fun limitedAccessTask() {
    semaphore.acquire()
    try {
        // 동시에 최대 3개 코루틴만 이 로직을 실행
        println("Working with limited access")
        delay(500)
    } finally {
        semaphore.release()
    }
}

acquire()가 허용 가능한 범위를 초과하면, 해당 코루틴은 일시 중단 상태가 되어 자원(permit)이 생길 때까지 기다립니다.

release() 호출로 permit을 다시 반환하면, 대기 중이던 다른 코루틴이 접근할 수 있습니다.

 

4.2 예시: 동시 다운로드 제한

fun main() = runBlocking {
    val semaphore = Semaphore(3)
    val urls = listOf("url1", "url2", "url3", "url4", "url5")

    val jobs = urls.map { url ->
        launch {
            semaphore.acquire()
            try {
                println("Downloading $url")
                delay(1000) // 다운로드 시뮬레이션
                println("Finished $url")
            } finally {
                semaphore.release()
            }
        }
    }
    jobs.joinAll()
    println("All downloads complete")
}

동시에 최대 3개의 다운로드만 진행되고, 초과되는 코루틴은 semaphore.acquire()에서 대기하게 됩니다.


5. 기타 동시성 관리 기법

 

5.1 AtomicFu, Atomic variables

AtomicFu는 JetBrains에서 제공하는 Atomic 연산 라이브러리로, 코틀린 멀티플랫폼에서 간단한 원자적 연산을 제공해 줍니다.

atomic {} 블록, atomic<Int> 등 원자 변수로 CAS(Compare-And-Set)를 구현할 수 있습니다.

 

5.2 Thread confinement

코루틴 디스패처를 특정 단일 스레드(Dispatchers.IO)새로운 스레드로 고정해두고, 그 스레드에서만 공유 자원에 접근하도록 설계할 수도 있습니다.

대신 성능이 떨어질 수 있으니, 필요에 따라 선택하는 방식입니다.

 

5.3 Structured Concurrency와 분리

가능하다면, “함수형 설계”나 “불변(Immutable) 데이터”를 적극 활용해 공유 mutable state 자체를 줄이는 것이 근본적 해결책이 될 수 있습니다.

728x90

6. 예시: Safe Counter with Flow

 

아래는 Mutex를 사용해 카운터를 안전하게 증가시키면서, 변경 사항을 Flow로 노출하는 예시입니다.

class SafeCounter {
    private val mutex = Mutex()
    private val _counterState = MutableStateFlow(0)
    val counterState: StateFlow<Int> = _counterState

    suspend fun increment() {
        mutex.withLock {
            val newValue = _counterState.value + 1
            _counterState.value = newValue
        }
    }
}

fun main() = runBlocking {
    val counter = SafeCounter()
    
    // 구독
    val job1 = launch {
        counter.counterState.collect { value ->
            println("Collector: $value")
        }
    }
    // 변경
    val job2 = launch(Dispatchers.Default) {
        repeat(5) {
            counter.increment()
            delay(100)
        }
    }
    
    job2.join()
    delay(300)
    job1.cancelAndJoin()
    println("Done")
}

1. increment()Mutex를 이용해 동시 접근을 막고, 카운터 값을 하나씩 올립니다.

2. counterStateStateFlow로 현재 상태를 모든 구독자에게 실시간 반영합니다.


7. 마무리 및 다음 예고

 

이번 [8편]에서는 동시성 문제와 스레드 안전성에 대해 간단히 정리하고, 코루틴 환경에서 사용할 수 있는 Mutex, Semaphore 같은 도구들을 살펴보았습니다.

Race ConditionShared Mutable State 문제

Mutex로 임계 구역을 보호하고, Semaphore로 동시 접근 개수를 제한

AtomicFu나 Thread confinement, 함수형 설계 등 다양한 관점에서 동시성 문제를 회피하거나 완화할 수 있음

 

다음 9편에서는 코루틴 테스트 전략 & 디버깅을 다룰 예정입니다. runTestDispatchers.Unconfined, Turbine 라이브러리 같은 툴을 활용해 코루틴 코드를 안정적으로 테스트하고, 실시간 디버깅 방법까지 자세히 안내해 드리겠습니다.


시리즈 전체 목차

0. 코루틴 탄생 배경과 기본 개념

1. 코루틴 기초(suspend, CoroutineScope, Dispatcher)

2. Structured Concurrency와 코루틴 스코프 관리

3. 예외 처리와 취소(Cancellation) 기법

4. 코루틴 채널(Channel)과 select

5. Flow 심화(Flow, StateFlow, SharedFlow)

6. 안드로이드 실무 적용(Retrofit, Room, ViewModel)

7. 동시성 문제 해결(Mutex, Semaphore)

8. 코루틴 테스트 & 디버깅 전략

9. 고급 퍼포먼스 최적화

10. WorkManager + 코루틴 연동

11. LifecycleScope & 안드로이드 생명주기 대응

12. Compose + 코루틴 + Flow 시너지

13. Compose Navigation + 코루틴 취소/재시작 사례

728x90
반응형