-
Actor 모델을 통한 동시성 문제 해결kotlin/coroutine 2025. 11. 9. 15:35
1️⃣ Actor 모델의 본질
개념 요약
- Actor = 독립된 코루틴 + 메시지 큐(Channel)
- 외부에서는 actor.send(Message) 로만 상호작용 가능
- 내부 상태는 오직 해당 Actor 코루틴 안에서만 수정 가능
즉, 여러 스레드가 동시에 Actor의 내부 상태를 건드릴 수 없습니다.
→ 이게 Actor가 race condition을 구조적으로 줄이는 핵심 원리입니다.
2️⃣ 일반적인 race condition 예시
일반 코드 (shared mutable state)
var counter = 0 fun main() = runBlocking { val jobs = List(1000) { launch { repeat(1000) { counter++ } // race 발생 가능 } } jobs.forEach { it.join() } println(counter) // 예상: 1,000,000 → 실제: 예측 불가 }이 경우 counter++ 연산이 원자적이지 않기 때문에 여러 코루틴이 동시에 접근하면 race condition 발생.
3️⃣ Actor 모델로 개선한 예제
import kotlinx.coroutines.* import kotlinx.coroutines.channels.actor sealed class CounterMsg object IncCounter : CounterMsg() class GetCounter(val response: CompletableDeferred<Int>) : CounterMsg() fun CoroutineScope.counterActor() = actor<CounterMsg> { var counter = 0 // 이 상태는 Actor 내부에서만 접근 가능 for (msg in channel) { when (msg) { is IncCounter -> counter++ is GetCounter -> msg.response.complete(counter) } } } fun main() = runBlocking { val counter = counterActor() // 1000개의 코루틴이 동시에 counter 증가 요청 val jobs = List(1000) { launch { repeat(1000) { counter.send(IncCounter) } } } jobs.forEach { it.join() } val response = CompletableDeferred<Int>() counter.send(GetCounter(response)) println("Final count: ${response.await()}") // 정확히 1,000,000 출력 counter.close() }결과
Final count: 1000000
왜 안전할까?
- counter는 actor 내부 코루틴의 단일 스레드 문맥에서만 접근됨.
- 외부 스레드는 오직 채널을 통해 메시지를 보낼 뿐, 내부 상태에 직접 접근 불가.
- 따라서 counter++ 는 항상 순차적으로 실행되므로 race condition 자체가 구조적으로 제거됨.
4️⃣ 그러나 완전한 면역은 아님
Actor는 "내부 상태의 일관성" 은 보장하지만, 다음과 같은 경우에는 여전히 동시성 문제가 생길 수 있습니다.
(1) 여러 Actor가 동일 자원에 접근할 때
val dbActor1 = dbActor() val dbActor2 = dbActor()→ 서로 다른 Actor가 같은 DB 연결을 공유한다면 race condition 재발.
→ 해결: 공유 자원은 단일 Actor가 관리하도록 책임을 집중.
(2) Actor 외부에서 상태를 수정하는 경우
var sharedList = mutableListOf<Int>() val actor = actor<Int> { for (msg in channel) { sharedList.add(msg) // ❌ 외부 공유 리스트 접근 } }→ 이건 Actor 내부에서 실행돼도 안전하지 않음.
→ 해결: sharedList 자체를 Actor 내부에서 선언해야 함.
(3) 메시지 순서(Ordering) 문제
Actor는 한 코루틴 내에서는 순서를 보장하지만, 여러 Actor 간 메시지 전달 순서는 보장되지 않습니다.
→ 병렬 Actor 설계 시 “순서 기반 의존성”은 피해야 함.
5️⃣ Actor를 안전하게 설계하는 3대 원칙
원칙 설명 실천 방법 1. 상태는 Actor 내부에서만 유지 외부 공유 mutable 객체 금지 var는 Actor 내부에서만 선언 2. 메시지는 불변(Immutable) 구조로 전달 메시지 자체의 상태 변화 방지 data class는 불변 필드로 설계 3. 공유 자원은 Actor 단위로 격리 자원 관리 책임을 단일 Actor에 집중 DB, File, Cache 등 전용 Actor 생성
안전한 구조 예시
data class UpdateUser(val id: Int, val name: String) data class GetUser(val id: Int, val response: CompletableDeferred<String?>) fun CoroutineScope.userActor() = actor<Any> { val userMap = mutableMapOf<Int, String>() for (msg in channel) { when (msg) { is UpdateUser -> userMap[msg.id] = msg.name is GetUser -> msg.response.complete(userMap[msg.id]) } } }→ 외부는 항상 “명령 메시지”로 요청하고,
→ 내부 상태는 Actor 안에서만 수정되므로 동시성 안전.
6️⃣ 정리 — Actor 모델과 Race Condition 관계
구분 설명 RaceCondition 가능성 공유 변수 접근 (전통적 구조) 여러 스레드가 동일 변수 수정 ✅ 매우 높음 Lock / Mutex 사용 코드 블록 단위 동기화 ⚠️ 관리 복잡 Coroutine + Actor 모델 메시지 기반 상태 격리 🔒 구조적으로 차단 (단, 공유 자원 제외) 여러 Actor 간 협력 메시지 순서 보장 X ⚠️ 순서 경쟁 가능
7️⃣ 결론
Actor 모델은 “하나의 코루틴이 하나의 상태를 독점 관리” 한다는 원리로
race condition을 구조적으로 줄인다.
하지만 완전한 해결책은 아니며, 공유 자원 접근·메시지 순서·외부 상태 조작이 개입하면 여전히 동시성 문제가 발생할 수 있습니다.
OKR 형식 정리
구분 목표 설명 Objective Actor 기반 설계를 통해 비동기 안전성을 확보한다. 코루틴 메시지 모델로 상태 격리 KR1 Actor 내부 상태는 단일 코루틴에서만 접근 race condition 근본 제거 KR2 모든 메시지를 불변 객체로 정의 안전한 병렬 메시지 처리 KR3 공유 자원 접근은 전용 Actor로 제한 자원 단위 동시성 제어 KR4 Actor 간 협력은 메시지 순서 의존 없이 설계 논리적 순서 경쟁 제거
핵심 요약
- Actor는 race condition을 막는 가장 단순하고 안전한 방법이지만, 공유 mutable 자원을 Actor 외부에 두면 안전성이 깨진다.
- 즉, “Actor는 동시성 제어 도구이자 설계 원칙”으로써, 올바르게 사용해야 진정한 thread-safety를 확보할 수 있다.
'kotlin > coroutine' 카테고리의 다른 글
Actor의 생명주기 (공유 자원은 언제까지 살아있는가?) (0) 2025.11.09 비동기 의존 완화 (Asynchronous Dependency Mitigation) 가이드 (0) 2025.11.09 코루틴 테스트 환경(runTest vs UnconfinedTestDispatcher) 완벽 이해하기 (0) 2025.10.31