출시까지 3주가 남은 시점에 결제 SDK 교체 일정과 크래시 급증이 같은 주에 겹쳤습니다. QA는 “재현 조건이 매번 다르다”고 적었고, 기획은 베타 재배포 일정을 물었습니다. 워룸이라고 부르긴 했지만 실제로는 회의실 하나와 대시보드 한 장이 전부였습니다.
결론부터 적으면, 우리는 화면을 뜯어고치지 않았습니다. 크래시가 난 자리는 화면이었지만 원인은 화면이 아니었기 때문입니다. 우리가 바꾼 것은 결제 흐름의 상태 모델이었고, 그 뒤로 버그 리포트가 눈에 띄게 짧아졌습니다. 아래는 D-21부터 D-3까지의 기록입니다.
읽기 전에이 글은 여러 프로젝트에서 반복해서 겪은 상황을 하나의 가상 팀 이야기로 합쳐 재구성한 예시입니다. 등장하는 수치와 로그는 설명을 위해 만든 것이고, 특정 회사나 특정 제품의 실제 기록이 아닙니다.
D-21 · 대시보드가 먼저 말을 걸었습니다
월요일 아침에 크래시 대시보드를 열었을 때, 전날 배포한 베타 빌드의 그래프가 평소와 다른 모양이었습니다. 세션당 크래시율이 0.31%에서 1.24%로 올라 있었고, 그래프의 상승 구간이 결제 SDK 교체 빌드를 올린 시각과 정확히 맞물려 있었습니다.
같은 시간에 고객센터 쪽에서도 신호가 왔습니다. 평소 하루 서너 건이던 “결제가 두 번 된 것 같다”는 문의가 하루 만에 서른 건을 넘었습니다. 실제 승인 내역을 조회해 보면 결제는 한 번만 되어 있었습니다. 즉 돈이 두 번 빠진 게 아니라, 화면이 두 번 빠진 것처럼 보여 주고 있었습니다. 이 차이를 처음 30분 안에 확인한 것이 이후 판단을 크게 바꿨습니다. 정산 문제였다면 우리는 다른 방을 열었어야 했습니다.
QA 시트에는 이런 문장이 올라와 있었습니다. “결제 완료 화면에서 뒤로 갔다가 다시 들어오면 가끔 앱이 죽습니다. 가끔입니다.” 재현율이 낮은 버그가 출시 3주 전에 들어오면, 팀은 보통 두 갈래로 갈립니다. 하나는 눈에 보이는 화면을 방어 코드로 덮는 쪽이고, 다른 하나는 왜 그 화면이 그런 상태가 되는지 되묻는 쪽입니다. 우리는 첫 이틀 동안 첫 번째 길로 갔다가 되돌아왔습니다.
D-18 · 숫자를 먼저 맞췄습니다
“가끔”이라는 단어를 그대로 두면 아무 것도 결정할 수 없습니다. 그래서 이틀 동안 로그와 크래시 리포트만 정리했습니다. 버전별 세션 수, 세션당 크래시율, 그리고 전체 크래시 가운데 결제 화면에서 발생한 비중을 한 표에 놓았습니다.
크래시 집계 (내부 대시보드 내보내기, 예시 수치)
build sessions crash/session checkout 비중 ANR
4.12.0 182,400 0.31% 11% 0.04%
4.13.0 176,900 1.24% 78% 0.05%
4.13.0-r 64,300 1.21% 77% 0.04%
# 4.13.0-r = 결제 SDK만 직전 버전으로 되돌린 검증 빌드
표를 보고 두 가지를 알았습니다. 첫째, 크래시는 앱 전체가 아니라 결제 화면 한 곳에 몰려 있었습니다. 전체 크래시의 78%가 체크아웃 흐름에서 나왔습니다. 둘째, ANR 지표는 거의 움직이지 않았습니다. 응답이 느려서 죽는 게 아니라, 특정 순간에 확실하게 죽는다는 뜻이었습니다. 느린 문제와 어긋난 문제는 다루는 방법이 다릅니다.
스택 트레이스를 열 개쯤 모아 놓고 보니 모양이 거의 같았습니다.
대표 스택 트레이스 (요약)
Fatal Exception: java.lang.IllegalStateException
Fragment CheckoutFragment{9f21ac} not attached to a context.
at androidx.fragment.app.Fragment.requireContext(Fragment.java:900)
at com.example.checkout.CheckoutFragment.renderResult(CheckoutFragment.kt:214)
at com.example.checkout.CheckoutFragment$observePayment$1.invoke(CheckoutFragment.kt:132)
at com.example.payment.CallbackDispatcher.dispatch(CallbackDispatcher.kt:48)
여기서 눈에 걸린 줄은 맨 아래가 아니라 위에서 두 번째였습니다. 예외가 터진 지점은 우리 프래그먼트의 renderResult였고, 프래그먼트가 이미 분리된 뒤에 화면을 그리려 한 상황이었습니다. 외부 코드가 우리를 부른 것은 맞지만, 죽은 건 우리 코드였습니다.
D-14 · 틀린 가설을 하나 버렸습니다
이 시점에 팀 안에서 가장 힘이 셌던 가설은 “새 결제 SDK 버전의 버그”였습니다. 근거도 그럴듯했습니다. 크래시가 오른 시점이 SDK 교체 시점과 일치했고, 콜백이 예상보다 늦게 두 번 들어오는 로그도 몇 건 있었습니다. 이 가설이 맞다면 대응은 간단합니다. 이전 버전으로 되돌리고, 출시 후에 다시 올리면 됩니다.
그래서 검증 빌드를 만들었습니다. 애플리케이션 코드는 그대로 두고 결제 SDK 의존성만 직전 버전으로 내린 4.13.0-r 빌드를 내부 테스터 6만 세션 규모로 사흘 돌렸습니다. 기대한 결과는 크래시율이 0.3%대로 내려오는 것이었습니다.
결과는 1.21%였습니다. 1.24%에서 사실상 움직이지 않았습니다. 체크아웃 비중도 78%에서 77%로 그대로였고, 스택 트레이스의 모양도 같았습니다. 가설이 맞았다면 최소한 스택의 시작점이라도 바뀌었어야 하는데, 여전히 CheckoutFragment.renderResult에서 시작했습니다.
가설은 여기서 기각됐습니다. 동시에 하나가 분명해졌습니다. SDK 교체는 원인이 아니라 방아쇠였습니다. 새 SDK는 콜백을 예전보다 조금 더 늦게, 그리고 때로는 다른 스레드에서 돌려줬을 뿐입니다. 그 타이밍 변화가 우리 코드에 원래 있던 결함을 드러낸 것입니다. 우리는 이 문장을 화이트보드에 적어 두었습니다. “바뀐 쪽을 의심하되, 깨진 쪽을 확인한다.”
“외부 의존성을 되돌려도 증상이 남는다면, 그 의존성은 원인이 아니라 조건입니다.”
D-9 · 상태를 갱신하는 손이 네 개였습니다
깨진 쪽을 들여다보기로 하고, 체크아웃 화면에서 화면 상태를 바꾸는 코드를 전부 찾았습니다. 네 군데였습니다. 결제 SDK 콜백 핸들러, 주문 조회 API 응답 핸들러, 재시도 버튼 클릭 리스너, 그리고 화면이 다시 보일 때 실행되는 복구 로직. 이 넷은 서로를 모른 채 같은 필드를 썼습니다. isLoading, orderId, errorMessage가 프래그먼트 안에 흩어져 있었습니다.
정상 흐름에서는 문제가 없습니다. 콜백이 먼저 오고 화면이 그 뒤에 갱신되기 때문입니다. 그런데 SDK가 결제 창을 닫는 시점과 콜백을 돌려주는 시점 사이에 간격이 생기면, 사용자가 그 사이에 뒤로 가기를 누를 수 있습니다. 그러면 프래그먼트는 분리되는데 콜백은 그대로 도착합니다. 도착한 콜백은 이미 사라진 화면을 그리려 하고, 복구 로직은 자기가 아는 마지막 상태를 다시 그립니다. 그래서 완료 화면이 두 번 스쳐 지나가고, 사용자는 두 번 결제된 것처럼 느낍니다. 크래시와 이중 결제 표시는 같은 뿌리에서 나온 두 개의 증상이었습니다.
D-9 오전
상태 이름을 코드와 QA 시트에서 통일했습니다
“실패”, “재시도 가능”, “재인증 필요”를 서로 다른 말로 부르던 것을 하나의 목록으로 정리했습니다. 이 작업만으로 회의 시간이 절반이 됐습니다.
D-8 오후
상태 변경 지점을 한 곳으로 모았습니다
네 개의 손을 하나의 리듀서로 합치고, 화면은 상태를 읽기만 하도록 바꿨습니다.
D-6 저녁
전이 표를 그대로 테스트로 옮겼습니다
허용되지 않는 전이가 무시되는지 확인하는 단위 테스트를 먼저 쓰고, 그 다음에 화면을 붙였습니다.
실제로 바꾼 코드는 다음과 같은 모양이었습니다. 화면이 가진 여러 개의 플래그를 없애고, 가능한 상태를 sealed 계층으로 못박은 뒤, 바깥에서 들어오는 모든 입력을 이벤트 하나로 받았습니다.
CheckoutViewModel.kt — 단일 상태와 단방향 이벤트
sealed interface CheckoutState {
data object Idle : CheckoutState
data object Submitting : CheckoutState
data class Approved(val orderId: String) : CheckoutState
data class Declined(val reason: DeclineReason) : CheckoutState
data class Retryable(val attempt: Int) : CheckoutState
data object ReauthRequired : CheckoutState
}
sealed interface CheckoutEvent {
data object SubmitTapped : CheckoutEvent
data class SdkResult(val code: Int, val orderId: String?) : CheckoutEvent
data object RetryTapped : CheckoutEvent
}
class CheckoutViewModel(
private val confirmOrder: ConfirmOrderUseCase
) : ViewModel() {
private val _state = MutableStateFlow<CheckoutState>(CheckoutState.Idle)
val state: StateFlow<CheckoutState> = _state.asStateFlow()
fun onEvent(event: CheckoutEvent) {
val next = reduce(_state.value, event) ?: return // 허용되지 않는 전이는 흡수
_state.value = next
if (next is CheckoutState.Submitting) confirm()
}
private fun reduce(state: CheckoutState, event: CheckoutEvent): CheckoutState? =
when (state) {
CheckoutState.Idle -> when (event) {
CheckoutEvent.SubmitTapped -> CheckoutState.Submitting
else -> null
}
CheckoutState.Submitting -> when (event) {
is CheckoutEvent.SdkResult -> event.toState()
else -> null // 제출 중 재시도 탭은 무시합니다
}
is CheckoutState.Approved -> null // 승인 이후에는 어떤 콜백도 화면을 되돌리지 못합니다
is CheckoutState.Retryable -> if (event is CheckoutEvent.RetryTapped)
CheckoutState.Submitting else null
else -> null
}
private fun confirm() = viewModelScope.launch {
val result = runCatching { confirmOrder() }
onEvent(CheckoutEvent.SdkResult(result.toCode(), result.getOrNull()?.orderId))
}
}
화면 쪽은 오히려 짧아졌습니다. 프래그먼트는 repeatOnLifecycle 안에서 상태를 구독하고, 상태에 따라 무엇을 보여 줄지만 결정합니다. 콜백은 더 이상 화면을 직접 만지지 않고 onEvent로 들어옵니다. 화면이 사라진 뒤에 콜백이 도착하면 상태만 조용히 갱신되고, 화면이 다시 붙을 때 최신 상태 하나를 그립니다. Approved에서 어떤 이벤트도 상태를 되돌리지 못하게 막은 덕분에 완료 화면이 두 번 지나가는 증상도 사라졌습니다.
콜백과 코루틴이 섞인 흐름을 다루는 기본기는 Kotlin 비동기 가이드에 따로 정리해 두었습니다.
D-3 · 회귀는 시나리오가 아니라 전이로 돌렸습니다
남은 사흘 동안 QA와 함께 회귀 범위를 다시 잡았습니다. 화면 단위 체크리스트 대신 전이 표를 기준으로 삼았습니다. 상태가 여섯 개, 이벤트가 세 개니 조합은 열여덟 개고, 그중 허용되는 전이는 일곱 개였습니다. 나머지 열한 개는 “아무 일도 일어나지 않아야 한다”가 기대 결과였습니다. 이 열한 개를 단위 테스트로 먼저 덮고, 사람 손으로는 결제 창에서 뒤로 가기, 화면 회전, 백그라운드 전환 세 가지 방해 동작만 반복했습니다. 이런 식으로 전이 테이블을 테스트로 옮기는 요령은 Kotlin 테스트 가이드에 더 자세히 적어 두었습니다.
배포 판단은 마지막 날 오후에 내렸습니다. 크래시율은 교체 이전 수준보다도 낮았고, 남은 두 건의 문의는 결제 자체가 아니라 영수증 메일 지연에 대한 내용이었습니다. 굳이 덧붙이면, 우리가 이번 주에 새로 도입한 라이브러리는 없습니다.
이 주간이 남긴 원칙
가장 크게 남은 것은 기술 선택이 아니라 판단 순서였습니다. 비동기 콜백이 끼어드는 화면에서 버그가 났을 때, 우리는 습관적으로 “어디서 죽었는가”를 먼저 봅니다. 하지만 실제로 물어야 할 것은 “이 순간 이 화면의 상태를 누가 정하는가”입니다. 상태를 정하는 주체가 둘 이상이면, 그 코드는 언젠가 반드시 어긋납니다. 지금 어긋나지 않는 이유는 대개 타이밍이 우연히 맞아서일 뿐이고, 의존성 업데이트 한 번이면 그 우연은 사라집니다.
그래서 저는 이 경험을 이렇게 요약합니다. 동시성 버그는 대부분 상태 소유권 문제입니다. 락이나 스레드 전환으로 덮으려 하기 전에, 상태를 바꿀 수 있는 입구를 하나로 줄이는 편이 훨씬 싸게 끝납니다. sealed class와 StateFlow는 그 소유권을 컴파일러가 대신 검사하게 만드는 도구일 뿐, 그 자체가 답은 아닙니다. 같은 원리는 다른 언어에서도 똑같이 적용됩니다. 상태 소유권을 타입으로 묶는 패턴은 Kotlin 패턴 가이드에 예제로 정리해 두었습니다.
- 외부 의존성을 되돌려도 증상이 남으면, 그 의존성은 원인이 아니라 조건입니다.
- 화면 상태를 갱신하는 코드 지점을 세어 보고, 둘 이상이면 그 자체를 결함으로 취급합니다.
- 허용되지 않는 상태 전이는 예외를 던지지 말고 조용히 흡수하되, 테스트로는 반드시 확인합니다.
- 상태 이름은 코드, QA 시트, 고객센터 응대 문구에서 같은 단어를 씁니다.
- 출시 직전 주간에는 새 추상화를 들이는 대신, 이미 있는 분기를 하나로 모읍니다.