Heisenbug — 디버그하려고 하면 사라지는 버그입니다. 이 용어는 하이젠베르크의 불확정성 원리에서 유래했습니다: 관찰이 시스템의 행동에 영향을 미칩니다. 모바일 개발에서 Heisenbug는 가장 어려운 문제 중 하나입니다. 표준 디버깅 방법(로그, 중단점, 추가 코드)이 프로그램 상태를 변경하고 버그를 숨기기 때문입니다. 원인과 잡기 어려운 오류를 처리하는 방법을 살펴보겠습니다.
핵심 요점
Heisenbug — 프로덕션이나 정상 작동 중에는 발생하지만, 디버깅 환경에서 재현하려고 하면 사라지는 오류의 한 종류입니다. 이 용어는 1980년대 프로그래머 Jim Gray가 분산 시스템의 맥락에서 만들었지만, 오늘날에는 비동기적 특성 때문에 모바일 애플리케이션에 가장 관련이 높습니다.
주요 이유: 표준 디버깅 도구가 실행 환경을 변경합니다. 중단점은 스레드를 수 밀리초 동안 일시 중지하고, 로깅은 동기 I/O를 추가하며, 추가 검사는 작업 순서를 변경합니다. 멀티스레드 환경에서는 마이크로초의 지연도 스레드 실행 순서를 변경하고 데이터 경합을 숨길 수 있습니다.
Microsoft Research(2022)에 따르면, 멀티스레드 모바일 애플리케이션의 모든 버그 중 약 15-25%가 Heisenbug로 분류됩니다. 동시에 하나의 Heisenbug를 찾고 수정하는 데 걸리는 시간은 직접 재현할 수 없기 때문에 일반 버그보다 평균 5-10배 더 깁니다.
목록을 빠르게 스와이프하면 프로덕션에서 앱이 충돌하지만, 디버거에 연결하거나 로그를 추가하면 — 완벽하게 작동합니다. 원인: UI 스레드(RecyclerView 업데이트)와 백그라운드 스레드(어댑터 데이터 업데이트) 간의 데이터 경합. 로그가 지연을 추가하여 스레드를 무작위로 동기화합니다.
Bohrbug — 예측 가능하고 안정적으로 재현되는 버그. 보어의 원자 모델에 비유하여 명명: 원자처럼 버그는 관찰할 때마다 동일하게 작동합니다. 예: 데이터 로드 전 버튼 클릭 시 NullPointerException. 표준 단위 테스트로 처리합니다.
Mandelbug — 복잡하고 혼란스러운 인과 관계를 가진 버그(망델브로 집합에 비유하여 명명). 특정 조건의 조합에서만 발생: OS 버전, 기기 모델, 네트워크 상태. 디버깅 중에 사라지지 않는다는 점에서 Heisenbug와 다릅니다 — 문제는 재현의 어려움이지 도구로 인한 동작 변경이 아닙니다.
Heisenbug — 바로 디버깅 도구 때문에 사라지는 버그입니다. 로그를 추가하면 — 버그가 사라집니다. 중단점을 설정하면 — 버그가 나타나지 않습니다. 모든 것을 제거하면 — 버그가 돌아옵니다. 주요 원인: 디버깅 중 변경된 타이밍.
| 유형 | 재현성 | 디버깅 반응 | 예시 |
|---|---|---|---|
| Bohrbug | 100% | 변화 없음 | 빈 목록에서 NPE |
| Mandelbug | 혼란스러움 | 변화 없음 | Android 12, Samsung, 배터리 부족 시 충돌 |
| Heisenbug | 디버깅 없을 때만 | 사라짐 | 로그와 함께 사라지는 경합 조건 |
| Schrödinbug | 코드에서 나타나지 않음 | 볼 때 나타남 | 코드에 보이지만 절대 발생하지 않는 버그 |
Race condition — Heisenbug의 첫 번째 원인. 두 스레드가 동기화 없이 공유 데이터에 접근합니다. 디버거가 지연을 도입하여 스레드가 자연스럽게 동기화됩니다. 디버거가 없으면 실행 순서를 예측할 수 없습니다.
타이밍 의존 오류 — 특정 실행 속도에서만 발생하는 버그. 예: 다음 작업이 시작되기 전에 완료되어야 하는 애니메이션. 디버거에서는 애니메이션이 느리게 실행되어 작업이 애니메이션 종료 후 시작될 시간을 갖습니다. 프로덕션에서는 — 반대입니다.
// Example race condition — typical Heisenbug
class ListViewModel : ViewModel() {
private var items = mutableListOf<String>()
fun loadFromNetwork() {
viewModelScope.launch(Dispatchers.IO) {
val result = api.fetchItems()
items.addAll(result) // ❌ Not thread-safe
}
}
fun getItems(): List<String> = items.toList()
// Race condition: getItems read may overlap with loadFromNetwork write
}
컴파일러 최적화 — 컴파일러(JIT, ART, Kotlin/Native)가 최적화를 위해 명령어를 재정렬할 수 있습니다. 디버그 빌드에서는 최적화가 비활성화되어 코드가 «작성된 대로» 실행됩니다. 릴리스 빌드에서는 컴파일러가 작업 순서를 변경하여 코드의 숨겨진 가정을 드러낼 수 있습니다.
ThreadSanitizer(TSan) — C/C++ 및 Kotlin/Native에서 데이터 경합을 탐지하는 Google 도구입니다. 빌드에 내장되어 동기화 없이 공유 메모리 접근을 탐지합니다. 로그와 달리 TSan은 I/O가 아닌 계측된 코드를 통해 작동하므로 타이밍에 영향을 주지 않습니다.
결정론적 테스트 — 실제 비동기성을 제어된 비동기성으로 대체하세요. 실행 순서를 완전히 제어하려면 TestDispatcher(Kotlin), RxJava Plugins 또는 GCD 테스트 큐(iOS)를 사용하세요. 특정 시나리오를 지정: 스레드 A 실행, 다음 B, 다음 다시 A.
순환 로깅 — 메모리의 링 버퍼에 로깅(디스크가 아님). 버그가 발생하면 버퍼가 파일에 저장됩니다. 메모리 쓰기는 나노초(디스크 I/O의 밀리초 대신)밖에 걸리지 않으므로 이러한 로깅은 타이밍에 영향을 주지 않고 Heisenbug를 숨기지 않습니다.
class CyclicBuffer(val capacity: Int = 1000) {
private val buffer = ArrayDeque<String>(capacity)
private val lock = Any()
fun log(message: String) {
synchronized(lock) {
if (buffer.size >= capacity) buffer.removeFirst()
buffer.addLast(message)
}
}
fun flush() {
synchronized(lock) { buffer.forEach { fileWriter.write(it) } }
}
}
프로덕션 로깅 — 버그를 로컬에서 재현할 수 없는 경우 프로덕션에서 데이터를 수집하세요. Firebase Crashlytics 로그, Sentry Breadcrumbs 또는 사용자 정의 순환 로거를 사용하세요. 중요: 로깅은 비동기식이어야 하며 성능에 미치는 영향을 최소화해야 합니다.
상태 격리 — 공유 가변 상태를 최소화하세요. 각 구성 요소는 다른 구성 요소의 직접 쓰기가 불가능한 자체 격리된 상태를 가져야 합니다. Unidirectional Data Flow(UDF)를 사용하세요 — 상태는 한 방향으로 흐릅니다: 이벤트 → 리듀서 → 상태 → UI.
함수형 접근 방식 — 부작용이 없는 순수 함수는 테스트와 디버그가 더 쉽습니다. 부작용(네트워크, DB, 파일)을 엄격하게 정의된 계층(리포지토리, 데이터 소스)으로 격리하세요. 함수형 코드에서 스레딩 오류는 사실상 불가능합니다.
Strict mode — 디버그 빌드에서 Android StrictMode를 활성화하세요. 스레딩 정책 위반(메인 스레드의 네트워크, 메인 스레드의 디스크 I/O)을 감지하고 예외를 throw합니다. 이렇게 하면 잠재적인 Heisenbug가 즉시 표시되는 결정론적 Bohrbug로 변환됩니다.
class DebugApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog()
.build()
)
}
}
}
비동기성에 초점을 맞춘 코드 리뷰 — 프로세스의 필수 부분입니다. 각 풀 리퀘스트는 공유 가변 상태, 스레드 안전하지 않은 컬렉션 및 동기화 부족을 확인해야 합니다. 특정 패턴(예: synchronized 없이 MutableList에 접근)을 자동으로 금지하려면 lint 규칙을 사용하세요.
자주 묻는 질문
표준 방법 — 중단점, 로그, print — 이 실행 환경을 너무 많이 변경하여 버그가 더 이상 나타나지 않기 때문입니다. 디버거는 모든 스레드를 수십 밀리초 동안 일시 중지합니다. 이 시간 동안 버그를 일으킨 경합 조건이 자연스럽게 해결됩니다. 실행 타이밍에 영향을 주지 않는 도구가 필요합니다.
Mandelbug는 조건의 복잡성으로 인해 재현하기 어렵지만 디버깅 도구는 그 발현에 영향을 미치지 않습니다. Heisenbug는 바로 디버깅 도구 때문에 사라집니다. Mandelbug 예: Android 11, 3GB RAM, 배터리 15% 미만 기기에서만 충돌. Heisenbug 예: Log.d()를 추가하면 사라지는 경합 조건.
flaky test detection을 사용하세요 — 때로는 실패하고 때로는 성공하는 테스트입니다. Android에서는 Android Test Orchestrator를 사용하여 테스트를 격리하세요. 디버그 테스트에 StrictMode를 추가하세요. ThreadSanitizer로 빌드를 계측하세요. 테스트 실행의 >5%에서 flaky한 경우 — 잠재적인 Heisenbug로 간주하고 병합 전에 조사하세요.
부분적으로 그렇습니다. Kotlin의 Flow와 구조적 동시성은 공유 가변 상태의 양을 줄이고 스레드 관리를 단순화합니다. 그러나 코루틴은 스레드 안전성을 보장하지 않습니다: 두 코루틴이 상태를 공유하는 경우 경합 조건이 여전히 가능합니다. 공유 상태를 보호하려면 Mutex를, 코루틴 간 데이터 전달에는 Channel을 사용하세요.
오류 시 자동으로 플러시되는 메모리 내 순환 로그 버퍼를 사용하세요. 사용자 정의 breadcrumbs로 Crashlytics 또는 Sentry를 통해 상세 모니터링을 추가하세요. Android의 경우 ANR 감지를 활성화하고 추적을 확인하세요. 버그가 경합 조건인 경우 프로덕션과 유사한 부하의 디버그 빌드에서 ThreadSanitizer가 문제를 드러낼 수 있습니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.