모바일 개발에서 Deadlock: 정의, 발생 원인 및 상호 차단을 피하는 방법

저자: IT Sectr 게시일: 2026-03-18 읽는 시간: 10 분

Deadlock(상호 차단)은 두 개 이상의 스레드가 다른 참가자가 보유한 리소스가 해제되기를 무기한 기다리는 상태입니다. Oracle Java Tutorials (2024)에 따르면 Deadlock은 각 스레드가 다른 스레드에 필요한 잠금을 보유하는 순환 대기에서 발생합니다. 특별한 탐지 도구가 없으면 Deadlock은 눈에 띄는 오류 없이 애플리케이션 실행을 완전히 중지시킵니다.

핵심 포인트

  • Deadlock — 각 스레드가 다른 스레드가 보유한 리소스를 기다리는 스레드의 상호 차단
  • Coffman의 네 가지 조건(Mutual Exclusion, Hold and Wait, No Preemption, Circular Wait)은 Deadlock 발생에 필요
  • Deadlock은 Starvation과 달리 스레드가 차단되지 않고 순환 종속성에서 적극적으로 대기함
  • Thread Dump — JVM 및 Android Runtime에서 Deadlock 탐지의 주요 도구
  • 잠금 계층 구조와 리소스 획득의 통일된 순서 — 상호 차단을 방지하는 주요 방법

Deadlock이란?

Deadlock은 멀티스레드 프로그래밍에서 두 개 이상의 스레드가 서로를 영구적으로 차단하는 상황입니다. 각 스레드는 다른 스레드에 필요한 리소스를 보유하고 있으며, 부족한 리소스를 획득하기를 기다리는 동안 이를 해제하지 않습니다. 결과적으로 어떤 스레드도 실행을 계속할 수 없습니다.

모바일 개발에서 Deadlock은 예외나 충돌을 발생시키지 않기 때문에 특히 중요합니다. 애플리케이션이 사용자 작업에 응답하지 않게 되고(ANR — Application Not Responding), 유일한 해결책은 프로세스를 강제 종료하는 것입니다. Google(Android Performance Patterns, 2023)에 따르면 Google Play Console의 ANR 보고서 중 약 15%가 백그라운드 스레드의 상호 차단과 관련됩니다.

Deadlock과 다른 동시성 문제의 주요 차이점은 외부 개입 없이는 되돌릴 수 없다는 것입니다. 운영 체제 스케줄러가 강제로 잠금을 해제할 수 없기 때문에 스레드가 스스로 리소스를 해제하지 않습니다. 이는 스레드가 활성 상태이지만 유용한 작업을 수행하지 않는 Livelock과 Deadlock을 구별합니다.

Deadlock의 조건

1971년 Edward G. Coffman은 Deadlock 발생에 필요한 네 가지 필수 조건을 공식화했습니다. 이 중 하나라도 없으면 상호 차단은 불가능합니다. 이러한 조건은 Coffman의 조건으로 알려져 있으며 모든 Deadlock 방지 알고리즘의 기초를 형성합니다.

상호 배제(Mutual Exclusion)

리소스는 한 번에 오직 하나의 스레드만이 획득할 수 있습니다. 리소스가 여러 스레드에 의한 동시 읽기를 허용하는 경우(예: 읽기 모드의 ReadWriteLock), Deadlock이 발생하지 않습니다. 이 조건은 Mutex와 잠금의 본질에서 비롯됩니다.

보유 및 대기(Hold and Wait)

스레드는 이미 획득한 리소스를 보유하고 동시에 다른 리소스 획득을 기다립니다. 스레드가 다음 리소스를 요청하기 전에 현재 리소스를 해제할 수 있는 경우(2단계 잠금을 통해), Hold and Wait 조건이 깨집니다. Android에서는 스레드가 데이터베이스 잠금을 보유하고 SharedPreferences 잠금을 획득하려고 할 때 자주 나타납니다.

강제 선점 없음(No Preemption)

운영 체제는 스레드로부터 잠금을 강제로 빼앗을 수 없습니다. 리소스는 스레드가 직접 해제할 때만 해제됩니다. 일부 시스템(예: SQLite WAL 모드)에서는 개별 작업 수준에서 강제 선점이 구현되어 Deadlock 위험을 줄입니다.

순환 대기(Circular Wait)

스레드의 닫힌 체인이 존재하며, 각 스레드는 체인 내 다음 스레드가 보유한 리소스를 기다립니다. 예를 들어, 스레드 A는 리소스 1을 보유하고 리소스 2를 기다리며, 스레드 B는 리소스 2를 보유하고 리소스 1을 기다립니다. 이것은 개발자가 아키텍처적으로 제거할 수 있는 유일한 조건입니다 — 잠금 계층 구조를 통해. 모든 스레드가 엄격하게 정의된 전역 순서로 리소스를 획득하면 순환은 물리적으로 불가능합니다.

실제로 Android 애플리케이션에서 Deadlock은 서로 다른 수준의 잠금이 암시적으로 교차하기 때문에 가장 자주 발생합니다: 데이터베이스 잠금(Room), SharedPreferences 잠금, 인메모리 컬렉션 잠금. 이러한 각 잠금은 서로 다른 구성 요소에 의해 관리되며, 획득 순서에 대한 중앙 집중식 프로토콜이 없으면 개발자는 의도치 않게 순환을 만듭니다.

Kotlin의 Deadlock 코드 예제

상호 차단의 고전적인 예를 살펴보겠습니다 — 두 스레드가 다른 순서로 잠금을 획득하는 경우입니다. 첫 번째 스레드가 리소스 A를 잠그고 B를 획득하려 하고, 두 번째 스레드가 B를 잠그고 A를 획득하려 하면 Deadlock이 발생합니다.

kotlin
class DeadlockExample {
    private val lockA = Any()
    private val lockB = Any()

    fun operationA() {
        synchronized(lockA) {
            Thread.sleep(50)  // 작업 시뮬레이션
            synchronized(lockB) {
                println("operationA 완료")
            }
        }
    }

    fun operationB() {
        synchronized(lockB) {  // 역순서
            Thread.sleep(50)
            synchronized(lockA) {
                println("operationB 완료")
            }
        }
    }
}

fun main() {
    val ex = DeadlockExample()
    Thread { ex.operationA() }.start()
    Thread { ex.operationB() }.start()
    // 애플리케이션이 영원히 중단됩니다 — Deadlock!
}

이 예제에서 operationA가 lockA를 획득하고 operationB가 lockB를 획득합니다. 그런 다음 각각 두 번째 잠금을 획득하려고 시도하고 — 둘 다 무기한 대기합니다. 프로그램이 오류 메시지 없이 중단됩니다. 수정하는 유일한 방법은 모든 메서드에서 동일한 잠금 획득 순서를 보장하는 것입니다.

Deadlock vs Starvation vs Livelock

이 세 가지 동시성 문제는 종종 혼동되지만 그 메커니즘과 결과는 근본적으로 다릅니다. Deadlock — 완전 정지, Starvation — 리소스에 대한 무한 대기, Livelock — 활성 유휴 상태. 차이점을 이해하는 것이 올바른 해결 전략을 선택하는 데 매우 중요합니다.

특성DeadlockStarvationLivelock
스레드 상태차단됨(BLOCKED)준비됨(RUNNABLE)활성(RUNNABLE)
작업 수행아니요아니요예, 하지만 무의미함
원인순환 대기불공정 스케줄링잘못된 충돌 처리
탐지Thread Dump, 타임아웃진행 모니터링재시도 카운터

Starvation(기아)은 스케줄러가 다른 스레드를 우선하여 낮은 우선순위 스레드의 실행을 지속적으로 연기할 때 발생합니다. Deadlock과 달리 스레드는 차단되지 않습니다 — 실행 준비는 되었지만 CPU 시간을 얻지 못합니다. Android에서 UI 스레드와 Service 스레드가 지속적으로 활성 상태인 경우 낮은 우선순위의 백그라운드 스레드가 실행되지 않는 것이 일반적인 시나리오입니다.

Livelock(활성 잠금)은 스레드가 차단되지 않았지만 서로의 작업에 무한히 반응하여 유용한 작업을 수행하지 않는 상황입니다. 전형적인 비유는 복도에서 두 사람이 마주쳐서 둘 다 같은 방향으로 비켜주려고 하는 것입니다. Deadlock과 달리 Livelock의 스레드는 CPU를 소비하여 기기 배터리를 소모합니다.

Deadlock 탐지 방법

Thread Dump는 JVM 및 Android Runtime에서 상호 차단을 탐지하는 주요 도구입니다. 덤프 중에 JVM은 모니터 간의 종속성 그래프를 자동으로 분석하고 Deadlock 주기를 표시합니다. Android Studio에서는 Android Profiler 또는 ADB Shell의 kill -3 PID 명령을 통해 스레드 덤프를 얻을 수 있습니다.

런타임 시 자동 Deadlock 탐지는 Watchdog 타이머를 통해 구현됩니다. 스레드가 지정된 타임아웃 내에 작업을 완료하지 않으면 watchdog이 덤프를 시작하고 크래시 리포팅 시스템(Firebase Crashlytics, Sentry)에 보고서를 보냅니다. Sentry(Issue Resolution Report, 2024)에 따르면 watchdog을 구성하면 Deadlock 진단 시간이 몇 주에서 몇 시간으로 단축됩니다.

개발 단계에서는 JetBrains의 ThreadSafe 정적 분석기와 Lock Checker 모듈이 포함된 Checker Framework가 효과적입니다. 이러한 도구는 소스 코드 수준에서 잠금 획득 순서를 분석하고 잠재적인 주기에 대해 경고합니다. 또한 Test-Driven Deadlock Detection — 수백 개의 스레드에서 다양한 잠금 순서로 작업을 실행하는 스트레스 테스트가 권장됩니다.

특별한 주목을 받는 것은 Cooperative Deadlock Detection — 스레드가 전역 레지스트리를 통해 획득한 잠금에 대한 정보를 교환하는 방법입니다. 스레드가 잠재적인 주기를 감지하면 모든 리소스를 해제하고 작업을 다시 시도합니다. 이 접근 방식은 분산 시스템(Apache ZooKeeper, Google Chubby)에서 사용되며 Jetpack Sync와 같은 라이브러리를 통해 점차 모바일 개발에 채택되고 있습니다.

상호 차단 방지 방법

잠금 계층 구조(Lock Ordering)

가장 신뢰할 수 있는 방법은 애플리케이션 전체에 잠금 획득의 전역 순서를 설정하는 것입니다. 모든 스레드가 항상 더 작은 번호의 잠금을 먼저 획득하고 그 다음에 더 큰 번호의 잠금을 획득하면 순환 대기(Circular Wait 조건)가 불가능합니다. 대규모 프로젝트에서는 순서가 문서화되고 코드 리뷰를 통해 검증됩니다.

타임아웃이 있는 TryLock

TryLock은 스레드를 무기한 차단하지 않고 지정된 시간 내에 잠금을 획득하지 못하면 false를 반환하는 잠금 방법입니다. Java에서는 ReentrantLock.tryLock(timeout, TimeUnit)을 통해, Kotlin Coroutines에서는 타임아웃이 있는 Mutex.withLock을 통해 구현됩니다. 실패 시 스레드는 획득한 모든 리소스를 해제하고 나중에 다시 시도합니다.

은행원 알고리즘(Banker's Algorithm)

은행원 알고리즘은 Edsger Dijkstra가 제안한 Deadlock 방지의 이론적 방법입니다. 리소스 할당을 은행 거래로 모델링합니다: 시스템이 안전하지 않은 상태(deadlock)로 이어질 수 있는 경우 리소스를 할당하지 않습니다. 실제로 스레드의 최대 필요량을 미리 알기 어렵기 때문에 모바일 개발에서는 거의 사용되지 않지만, 그 원리는 SQLite 데이터베이스와 파일 시스템에서 사용됩니다.

자주 묻는 질문

단일 스레드 애플리케이션에서 Deadlock이 발생할 수 있나요?

아니요, 상호 차단에는 최소 두 개의 스레드가 필요합니다. 단일 스레드 코드에서는 모든 작업이 순차적으로 실행되므로 순환 대기가 불가능합니다. 그러나 파일 잠금 또는 프로세스 간 세마포어를 사용할 때 프로세스 간에 Deadlock이 발생할 수 있습니다.

Kotlin Coroutines의 Deadlock은 스레드의 Deadlock과 어떻게 다른가요?

코루틴에서 Deadlock은 일시 중단 함수(suspend) 수준에서 발생하며 OS 스레드를 차단하지 않아 덜 눈에 띕니다. kotlinx.coroutines의 Mutex는 일시 중단 잠금(suspending)으로, 스레드를 차단하지 않지만 코루틴은 실행되지 않습니다. 탐지에는 kotlinx-coroutines-debug 모듈의 DebugProbes를 사용하세요.

Android의 SQLite에서 Deadlock이란 무엇인가요?

SQLite Deadlock은 두 데이터베이스 연결이 다른 순서로 트랜잭션을 실행하려고 할 때 발생합니다. SQLite는 이러한 상황을 감지하고 SQLITE_BUSY 또는 SQLITE_LOCKED 오류 코드를 반환합니다. Android에서는 단일 데이터베이스 인스턴스와 @Transaction을 통한 트랜잭션으로 Room을 사용하는 것이 권장되며, 이는 연결 간 Deadlock을 제거합니다.

Android는 어떻게 Deadlock을 탐지하나요?

Android Runtime에는 ANR(Application Not Responding)이 생성될 때 실행되는 내장 Deadlock 감지기가 있습니다. 시스템은 애플리케이션의 모든 스레드에 대한 Thread Dump를 분석하고 상호 차단을 표시합니다. 결과는 /data/anr/traces.txt 및 Google Play Console의 ANR Reports 섹션에서 확인할 수 있습니다.

프로덕션에서 Deadlock이 발견되면 어떻게 해야 하나요?

먼저 애플리케이션의 모든 스레드에 대한 Thread Dump를 가져옵니다. 각 스레드가 어떤 잠금을 보유하고 어떤 잠금을 획득하려고 하는지 분석합니다. 시간 제한을 초과할 때 자동 덤프를 수행하는 Watchdog 타이머를 구현합니다. 수정 후 재발을 방지하기 위해 CI 파이프라인에 ThreadSafety lint 규칙을 추가합니다.

요약

  • Deadlock — 스레드가 서로가 보유한 리소스를 무한히 기다리는 상호 차단
  • Coffman의 네 가지 조건(Mutual Exclusion, Hold and Wait, No Preemption, Circular Wait)이 Deadlock에 필요
  • Thread Dump — JVM 및 Android Runtime에서 상호 차단을 탐지하는 표준 방법
  • 통일된 전역 순서의 잠금 계층 구조가 순환 대기 조건을 완전히 제거
  • 타임아웃이 있는 TryLock이 무한 대기를 방지하고 리소스 불가용성을 적절히 처리할 수 있게 함
  • Deadlock vs Starvation — Deadlock에서는 스레드가 차단되고, Starvation에서는 실행 준비가 되었지만 CPU를 얻지 못함
  • Watchdog 타이머와 정적 분석기(ThreadSafe, Checker Framework) — CI/CD에서 Deadlock 기본 보호

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기