
Kotlin으로 안드로이드 개발을 하며 네트워크 로직 및 비동기 처리에는 주로 Coroutine + suspend 함수를 사용해왔었는데요, 최근 동시성 이슈를 막기 위해 Coroutine + Mutex 조합을 사용하게 되었습니다.
네트워크 로직 처리를 위해 Coroutine만을 생각했었는데 Mutex와 함께 사용해야 하는 필요성을 느낀 과정과 이유를 작성해 보려 합니다.
🤔 Coroutine은 '비동기'를 해결하지만, '동시성'을 해결하지는 않는다
"코루틴을 쓰고 있으니까 동시에 여러 요청 문제는 없지 않을까?"
저 역시 그랬듯, 많은 분들이 헷갈릴 수 있는 부분일 것이라고 생각이 드는데요
Coroutine의 역할은 비동기 작업을 쉽게 도와주는 것이지 '이 함수는 동시에 한 번만 실행된다'는 것을 보장하지는 않습니다
suspend 함수는 실행을 '멈출' 수 있을 뿐,
다른 Coroutine의 실행을 '기다리게' 하지는 않습니다.
즉, 여러 Coroutine이 같은 suspend 함수를 호출하면 각각 독립적으로 동시에 실행될 수 있습니다.
Coroutine은 아래와 같은 것을 해결해줍니다. 🙆🏻♀️
- 스레드 블로킹 없이 비동기 처리
- 네트워크 / IO 작업을 쉽게 다룸
- 코드 가독성 개선 (`suspend`, `await`)
반면 아래와 같은 것들은 해결해주지 않습니다. 🙅🏻♀️
- 동시에 실행되는 로직 간의 상태 충돌
- 공유 자원에 대한 동시 접근 제어
즉,
Coroutine = 동시에 실행할 수 있게 해주는 도구
Mutex = 동시에 실행되면 안되는 구간을 지켜주는 도구
라고 볼 수 있습니다!
🫢 네트워크 요청에서 필요한 경우
저의 경우, 네트워크 처리 중 토큰 갱신 문제를 처리할 때 동시성 이슈를 고려하게 되었는데요
예를 들어, 액세스 토큰이 만료된 상황에서 동시에 여러 API 요청이 발생합니다.
그럼 동시에 여러 API가 토큰을 갱신하기 위한 요청을 보내게 되겠죠?
그렇다면 서로의 상태를 고려하지 않은 채 토큰 갱신을 여러 차례 보내게 되고 공유 자원(accessToken)을
여러 코루틴이 동시에 건드리게 되는 구조가 됩니다.
그럼 Race Condition이 발생하게 되겠죠!?
그래서 이 문제를 해결하기 위해 Mutex가 필요합니다.
Mutex는 "임계 구역" 역할을 해줍니다.
즉, 이 코드 블록에는 한 번에 하나만 들어올 수 있도록 막아줍니다.
class TokenExer {
private var accessToken: String? = null
private val mutex = Mutex()
suspend fun getAccessToken(): String {
return mutex.withLock {
if (accessToken == null) {
refreshToken()
}
accessToken!! // *설명 단순화를 위해 accessToken!!을 사용했습니다
}
}
private suspend fun refreshToken() {
delay(1000) // 네트워크 요청
accessToken = "NEW_ACCESS_TOKEN"
}
}
위 코드에서 withLock은 누군가 이 블록에 들어오면 다른 코루틴은 문 앞에서 대기, 작업이 끝나야 다음 코루틴이 입장 가능하도록 제어해줍니다.
Mutex가 있는 경우와 없는 경우의 타임라인을 표로 살펴보면 아래와 같습니다.
🤔 Mutex가 없는 경우
| 시간 | Coroutine A | Coroutine B | Coroutine C |
| t1 | refresh 시작 | refresh 시작 | refresh 시작 |
| t2 | 토큰 발급 | 토큰 발급 | 토큰 발급 |
😮 Mutex가 있는 경우
| 시간 | Coroutine A | Coroutine B | Coroutine C |
| t1 | [Lock] refresh 시작 | 대기 | 대기 |
| t2 | 토큰 발급 | [Lock 풀림] 진입 -> 토큰 재사용 | 재사용 |
이렇게 표로 보면 어떤 변화와 차이가 있는지 이해가 되시나요?
🤨 왜 Synchronized가 아니라 Mutex 일까?
그렇다면 왜 `synchronized`가 아니라 `Mutex`를 사용할까요?
Coroutine 환경에서 synchronized를 사용할 수도 있지만,
이는 스레드를 블로킹하는 방식이기 때문에 Coroutine의 장점을 충분히 살리지 못합니다.
우선, `synchronized`는 "스레드 기반" 동기화입니다.
임계 구역에 진입하지 못한 스레드는 블로킹 됩니다.
즉, lock을 얻지 못하면 그 스레드 자체가 멈추고 아무 일도 못하고 대기를 하게 됩니다.
만약 멀티 스레드 환경이라면 문제가 될 것이 없지만, Coroutine 환경에서는 문제가 됩니다.
Coroutine의 가장 큰 특징 중 하나는 '하나의 스레드 위에서 여러 Coroutine이 실행될 수 있다'는 것인데요
그런데 여기에서 synchronized를 사용하면?!
예를 들어,
1. Coroutine A가 synchronized 블록 진입
2. Coroutine B가 같은 synchronized 진입 시도
3. ⚠️ 이때, 스레드 자체가 블로킹 됨
4. 같은 스레드에서 실행되던 다른 Coroutine들도 멈추게 됨
즉, 동기화하려다 전체 Coroutine 흐름을 멈춰버리는 상황이 생기게 됩니다.
반면, `Mutex`는 Coroutine 환경에 맞게 설계된 동기화 도구로,
스레드를 블로킹하지 않고 suspend 상태로 대기할 수 있습니다.
즉, 락을 못 얻으면, 해당 Coroutine만 잠시 멈추고 (suspend) 스레드는 다른 Coroutine을 실행할 수 있습니다
- `synchronized` → 스레드 블로킹
- `Mutex` → Coroutine 친화적, non-blocking
정리해보면, Coroutine 환경에서의 동기화는 단순히 "락을 걸 수 있느냐"의 문제가 아니라
"어떻게 기다리게 할 것인가"의 문제라고 생각합니다.
Mutex와의 조합로 인해 Coroutine을 조금 더 "Coroutine스럽게" 동기화 하고 있다고 말할 수 있지 않을까 싶네요 🤓
📚 참고
Mutex | kotlinx.coroutines – Kotlin Programming Language
Mutex Mutual exclusion for coroutines. Mutex has two states: locked and unlocked. It is non-reentrant, that is invoking lock even from the same thread/coroutine that currently holds the lock still suspends the invoker. JVM API note: Memory semantic of the
kotlinlang.org
https://kotlinlang.org/docs/coroutines-guide.html#table-of-contents
Coroutines guide | Kotlin
kotlinlang.org
'Android' 카테고리의 다른 글
| [Android] Build Variant는 왜 필요할까: 안드로이드 빌드 관리하기 (0) | 2026.01.11 |
|---|---|
| [Android] 결제에서 Consume은 왜 필요할까? (0) | 2025.12.28 |
| [Android] Kotlin 버전을 올렸는데 왜 Gson에서 이슈가 발생할까? (0) | 2025.10.26 |
| [Android] api와 implementation, 무엇이 다를까? (0) | 2025.10.12 |
| [Android] Portrait? Landscape? screenOrientation 이해하기 (0) | 2025.09.26 |