본문 바로가기

Android

[Android] Mutex와 Coroutine으로 동시성 문제 해결하기

 

 

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스럽게" 동기화 하고 있다고 말할 수 있지 않을까 싶네요 🤓

 

 

 

📚 참고

https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.sync/-mutex/

 

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