산책 경로를 기록해 영역을 점령하는 안드로이드 앱입니다. 인계문서에 항목이 25개 있었고, 실제로 1순위였던 것을 고쳐 출하했습니다. 문서는 그 항목을 과소평가해 뒀습니다.
세 문자열이 이렇게 붙어 있었습니다.
| 문자열 키 | 실제 동작 | 영어 | 한국어 |
|---|---|---|---|
| 알림 액션 | cancel() — 기록을 버린다 |
Stop walk | 종료 |
| 앱 안 버튼 | close() — 영역을 점령한다 |
End walk | 종료 |
| 앱 안 버튼 | cancel() — 버린다 |
Cancel | 취소 |
버리는 버튼과 점령하는 버튼의 한국어 라벨이 글자까지 같았습니다.
질문 하나 드리겠습니다. 당신 앱에서 서로 반대되는 두 동작의 라벨을, 모든 언어에 대해 한 표로 늘어놓아 본 적이 있습니까?
1. 앱이 그 버튼을 직접 유도했습니다
같은 시각 다른 알림이 이렇게 안내하고 있었습니다. "출발지 근처입니다. 〈그 라벨〉을 누르면 영역 획득."
즉 앱이 스스로 점령을 약속하며 유도한 버튼이 사용자의 산책을 파괴했습니다. 걸은 경로가 이 앱의 유일한 산출물이고, 플릿에서 활성 기반이 가장 큰 앱입니다.
2. 코드 리뷰로도 유닛 테스트로도 안 잡힙니다
각 함수는 정상이었습니다. 테스트도 통과했습니다. cancel()은 버리는 게 맞고 close()는
점령하는 게 맞습니다.
결함은 어느 함수에 어느 라벨이 붙었는지였습니다. 그건 함수 안에도, 호출부 한 줄에도 없습니다. 같은 동작을 가리키는 문자열을 전부 한 표로 늘어놓아야 보입니다.
⚠️ 영어만 보면 Stop / End로 겨우 갈려서 통과합니다. 충돌은 번역에서 태어납니다. 자간이 낱말을 먹어 버린 이야기와 같은 계열입니다. 원문에서는 존재하지 않던 결함이 번역에서 생겨납니다.
테스트는 통과했는데 버튼이 아무 일도 안 하던 이야기와는 원인이 다릅니다. 저건 함수가 결함이고, 이건 라벨이 결함입니다.
3. 당신이라면 어떻게 고치시겠습니까
- (a) 알림 액션의 라벨을 바꾼다
- (b) 알림 액션을 없앤다
- (c) 알림에서 하는 일 자체를 바꾼다
(a)만 하면 알림에서 산책을 버릴 수 있다는 사실은 그대로입니다. 앱을 안 열고 누르는 자리에서 되돌릴 수 없는 파괴가 일어나는 건 라벨 문제가 아닙니다. (b)는 진행 중인 산책을 멈출 방법을 없앱니다.
답은 (c)였습니다. 제3의 동작을 만드는 것입니다.
4. 알림에서는 버리지도 점령하지도 않습니다
stopTrackingKeepingWalk()
phase = PAUSED // ← 먼저
flush() // 기록 저장
tearDown() // 수집 job 만 취소, scope 는 살려 둔다⚠️ 순서가 반대면 저장이 건너뛰어집니다. 저장 함수가 phase로 게이트돼 있어서
IDLE/CLAIMING 상태에서는 아무것도 쓰지 않습니다. 그리고 tearDown()이 자기 scope를
취소하면 flush가 같이 죽습니다. 정리를 조기 종료 앞에 두는 것과 같은 부류의 함정입니다.
라벨은 Pause tracking / 추적 일시중지로 갈랐고, 힌트 문구에 "앱을 열고"를 넣었습니다.
버리기는 앱 안의 '취소'로만 일어납니다.
5. 테스트를 결정적으로 만들려면 디스패처를 주입해야 했습니다
"저장된 뒤 취소하면 지워진다"를 검증할 수 없었습니다. 행 식별자 대입이
withContext(Dispatchers.IO) 경계 뒤에서 일어나기 때문입니다.
⚠️ yield()도, 모킹 라이브러리의 coVerify(timeout = …)도 실제 IO 스레드의 재개를 기다려
주지 않습니다. timeout은 "호출됐다"만 기다리고 그 뒤의 대입은 못 봅니다. 그래서 테스트가
간헐적으로 통과합니다. 간헐적으로 통과하는 테스트는 통과한 게 아닙니다.
internal var ioDispatcher: CoroutineContext = Dispatchers.IO // 프로덕션 기본값 동일
// 테스트가 UnconfinedTestDispatcher 를 넣는다 (5곳 교체)메인 스코프는 이미 Dispatchers.Main.immediate라 테스트 규칙으로 잡혀 있었습니다.
검증: 테스트 409건 실패 0, 릴리스 lint 통과. 프로덕션 롤아웃 100%.
6. 이 결함을 어떻게 찾았는지
코드를 읽어서 찾은 게 아닙니다. 문자열 표를 만들어서 찾았습니다.
인계문서를 1원칙으로 다시 읽는 과정에서 순위가 뒤집혔습니다. 문서의 서술 품질은 좋았고 순위만 틀렸습니다. 25개 항목 중 이게 몇 번째였는지는 중요하지 않습니다. 중요한 건 "문서가 매긴 순위"와 "실제로 사용자에게 무슨 일이 일어나는가"가 다른 축이라는 점입니다.
아직 모르는 것
- 사용자가 실제로 몇 명 이 버튼을 눌러 산책을 잃었는지는 모릅니다. 계측에 그 구분이 없었습니다. 그래서 이 글에는 피해 규모가 없습니다.
- 같은 앱의 iOS에는 이 결함이 없습니다(확인했습니다). 알림 액션 버튼이 아예 없고, 앱 밖 경로인 워치는 버리기를 빨간 X, 점령을 체크 아이콘으로 도형부터 갈라 뒀습니다. 라벨이 아니라 모양으로 구분한 설계가 옳았습니다.
자가진단 3가지
- 되돌릴 수 없는 동작 두 개의 라벨을 모든 로케일에 대해 한 표로 뽑아 보십시오. 같은 값이 두 줄 있으면 그게 이 결함입니다.
- 알림 · 위젯 · 워치처럼 앱 밖에서 눌리는 자리에서 파괴적 동작이 가능합니까? 그 자리에는 확인 화면이 없습니다.
- 정리 함수가 자기 scope를 취소합니까? 그 뒤에 오는 저장은 실행되지 않습니다.
솔직한 부분
이 앱의 인계문서는 잘 쓰여 있었습니다. 25개 항목의 서술은 정확했고, 제가 새로 발견한 사실도 거의 없습니다. 문서가 틀린 건 순위 하나였습니다.
그런데 그 순위 하나 때문에, 활성 기반이 가장 큰 앱에서 사용자가 산책을 잃는 결함이 "나중에" 칸에 앉아 있었습니다. 문서를 믿는 것과 문서의 우선순위를 믿는 것은 다른 일입니다.
지금 딱 하나만 해 보세요. 당신 앱의 문자열 리소스에서 값이 중복되는 줄을 뽑아 보십시오.
sort | uniq -d 한 줄이면 됩니다. 그중에 반대되는 두 동작이 섞여 있는지만 보시면 됩니다.