배포와 인프라10 분 읽기

버리는 버튼과 점령하는 버튼의 한국어가 글자까지 같았습니다

각 함수는 정상이었고 테스트도 통과했습니다. 결함은 어느 함수에 어느 라벨이 붙었는지였습니다. 영어로는 Stop과 End로 겨우 갈렸고, 충돌은 번역에서 태어났습니다.

#android#localization#reality-check#methodology#gotchas
개념 도식: 같은 한국어 라벨을 단 두 버튼이 각각 기록 저장과 기록 폐기로 이어지는 구조
글 내용을 요약한 개념 도식.

산책 경로를 기록해 영역을 점령하는 안드로이드 앱입니다. 인계문서에 항목이 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가지

  1. 되돌릴 수 없는 동작 두 개의 라벨을 모든 로케일에 대해 한 표로 뽑아 보십시오. 같은 값이 두 줄 있으면 그게 이 결함입니다.
  2. 알림 · 위젯 · 워치처럼 앱 밖에서 눌리는 자리에서 파괴적 동작이 가능합니까? 그 자리에는 확인 화면이 없습니다.
  3. 정리 함수가 자기 scope를 취소합니까? 그 뒤에 오는 저장은 실행되지 않습니다.

솔직한 부분

이 앱의 인계문서는 잘 쓰여 있었습니다. 25개 항목의 서술은 정확했고, 제가 새로 발견한 사실도 거의 없습니다. 문서가 틀린 건 순위 하나였습니다.

그런데 그 순위 하나 때문에, 활성 기반이 가장 큰 앱에서 사용자가 산책을 잃는 결함이 "나중에" 칸에 앉아 있었습니다. 문서를 믿는 것과 문서의 우선순위를 믿는 것은 다른 일입니다.

지금 딱 하나만 해 보세요. 당신 앱의 문자열 리소스에서 값이 중복되는 줄을 뽑아 보십시오. sort | uniq -d 한 줄이면 됩니다. 그중에 반대되는 두 동작이 섞여 있는지만 보시면 됩니다.

관련 글