개발일지
Automation·3 min read

앱 출시 후 큰 실수로 글로벌 버전에서 한국어 알림이 나와버렸습니다

App Store에 낸 noon에서 한국어 알림이 영문 기기 사용자에게 그대로 나갔다. 세 번째였다. 기억에 의존한 체크가 계속 뚫리자 커밋 전 검증 게이트를 짰는데, 첫 버전이 59건 오탐으로 늑대소년이 됐다. SwiftUI가 문자열을 두 방식으로 번역한다는 걸 놓친 게 원인이었고, 검증을 두 곳으로 나누자 풀렸다.

알림이 한국어로 나갔다. 영문 기기를 쓰는 사람이 "무슨 말인지 모르겠다"고 제보했다. noon을 App Store에 낸 뒤였고, 같은 사고로는 세 번째였다.

매번 고쳤다. 매번 다시 샜다. 흐름은 늘 비슷했다. 새 기능을 짜다가 알림 body에 문자열 하나를 넣는다. 그게 번역 함수로 안 감싸져 있다. 내 테스트는 한국어 기기에서만 도니까 안 잡힌다. 커밋이 올라가고, 며칠 뒤 영문 기기 사용자가 한국어 알림을 받는다.

세 번째가 됐을 때 일단 손으로 고쳤다(커밋 2bedb81, 비 알림 body가 날것으로 박혀 있었다). 그리고 커밋 로그에 적었다. "이런 게 반복되면 스크립트로 잡겠다." 기억해서 체크하는 걸 세 번 뚫렸으면, 기억이 아니라 게이트가 필요한 거였다.

뭘 검증해야 하는지부터

게이트를 짜려면 "노출되면 안 되는 것"을 먼저 정의해야 했다. noon은 폰(EyeCompanionPhone)과 워치(EyeCompanionWatch) 두 타깃이고, 번역 방식이 서로 다르다.

폰은 L("한글", "English") 헬퍼로 감싼다. 워치는 String(localized: "키")에 xcstrings 카탈로그를 붙인다. 그러니까 "따옴표 안에 한글이 있는데 이 둘 중 어느 것으로도 안 감싸진 것" — 그게 사용자한테 번역 없이 나갈 수 있는 후보다. 검증의 대상은 그거였다.

1차 검증: 코드 한 곳만 훑었다

첫 버전은 단순했다. Swift 파일을 통째로 긁어서 한글이 든 문자열을 찾고, 명시적으로 감싼 것만 빼면 되겠거니 했다.

grep -rnE '"[^"]*[가-힣][^"]*"' "$WATCH" "$PHONE" --include='*.swift' \
  | grep -vE 'String\(localized|L\('

돌렸더니 59건이 나왔다(커밋 e15e5d2). 이게 다 진짜 버그라면 앱이 이미 절반은 한국어로 새고 있다는 뜻이었다. 그럴 리 없었다.

59건이 늑대소년을 만들었다

한 줄씩 다 봤다. 진짜 문제는 거의 없었다.

제일 많은 게 Text("눈 깜박이기") 계열이었다. 여기서 내가 틀렸다. SwiftUI의 Text()는 한글 문자열을 넣어도 알아서 xcstrings 카탈로그를 찾아 번역한다. 코드에 한글을 쌩으로 쓰는 게 오히려 정상이다. 나는 String(localized:)로 감싼 것만 정상으로 쳤으니, Text() 전부를 오탐으로 긁어온 거였다.

나머지도 다 사용자와 무관했다. 주석 꼬리에 붙은 한글, case 바쁨 = "busy" 같은 enum rawValue, ("눈깜박", "EyeBlink") 형태의 bilingual 튜플, 내부 디버그용 showToast(...), Logger에 찍는 로그 문자열. 화면에 안 나오거나 이미 다른 방식으로 처리되는 것들이었다.

거기에 grep [가-힣] 자체에도 버그가 있었다. 시스템 로케일(LC_ALL)에 따라 grep이 멀티바이트 문자를 바이트로 다뤄서, 특수 대시나 이모지가 한글 범위에 걸려 오탐이 났다. 맨 위에 export LC_ALL=en_US.UTF-8 한 줄을 박으니 사라졌다.

게이트는 만들었는데 열에 아홉이 오탐이면 아무도 안 돌린다. 두 번째 커밋부터는 그냥 꺼버리게 된다. 59건짜리 늑대소년이었다.

2차 검증: 검사를 두 곳으로 나눴다

핵심은 오탐 목록에서 나왔다. Text()가 왜 그렇게 많이 걸렸나를 보다가 알았다 — SwiftUI는 문자열을 두 방식으로 번역한다. 하나는 내가 L()·String(localized:)로 명시적으로 감싸는 것, 다른 하나는 Text()가 런타임에 카탈로그를 자동으로 찾는 것.

검증이 한 곳만 봐서 틀렸던 거였다. 번역 경로가 둘이면 검사도 둘이어야 했다.

번역 경로가 둘 → 검증도 둘

① 코드에서 검사
L()·String(localized:)로 감싸야 하는데 안 감싼 raw 한글. 감싸는 게 코드의 책임이라 코드에서 본다.
② 카탈로그에서 검사
Text()는 코드에 한글이 정상. 대신 xcstrings에서 그 한글 키의 en 값이 비었는지 본다.

①은 grep을 정교하게 다듬었다. Text(를 제외 목록에 넣고, 주석·enum rawValue·bilingual 튜플·디버그 토스트·로그·딕셔너리 키·프리뷰·브랜드명 "noon"을 전부 걸러냈다. 남는 건 "감싸야 하는데 안 감싼 것"뿐이다.

②는 카탈로그를 직접 판다. Text()가 자동으로 찾는 그 카탈로그(Localizable.xcstrings)를 파이썬으로 파싱해서, 한글 키인데 en 번역 값이 비어 있는 항목을 뽑는다. 코드에 한글이 있는 게 정상이라면, 검증은 "그 한글에 대응하는 영어가 실제로 채워졌나"로 옮겨가야 맞다.

# ① 코드: 안 감싼 raw 한글 (Text/String(localized)/L 밖)
raw=$(grep -rnE '"[^"]*[가-힣][^"]*"' "$WATCH" "$PHONE" --include='*.swift' \
        | grep -vE "$IGNORE" | grep -vE '//.*[가-힣]' \
        | grep -vE '"[^"]*", ?"[A-Za-z]' | grep -vE 'case [a-z]+ *= *"')

# ② 카탈로그: 한글 키인데 en 값이 빈 항목
python3 - "$CAT" <<'PY'
import json,re,sys
S=json.load(open(sys.argv[1],encoding="utf-8"))["strings"]
ko=re.compile(r"[가-힣]")
gaps=[k for k,v in S.items() if ko.search(k)
      and not v.get("localizations",{}).get("en",{}).get("stringUnit",{}).get("value")]
print("COUNT",len(gaps))
PY

둘 중 하나라도 걸리면 exit 1. 텍스트가 들어간 noon 패치를 커밋하기 전에 이 스크립트를 돌린다.

진짜 미스는 하나였다

두 번째 버전을 돌리니 코드 쪽 0건, 카탈로그 en 갭 0건이 나왔다. noon이 이미 완전히 로컬라이즈돼 있다는 뜻이었다. 지금 다시 돌려도 ✓ l10n 미스 없음으로 통과한다.

결국 이 소동에서 실재한 버그는 딱 하나, 비 알림 body였다. 그리고 그건 스크립트를 만들기로 결심한 계기였던 바로 그 버그라, 이미 손으로 고쳐진 뒤였다. 스크립트가 잡아야 할 걸 스크립트가 잡은 게 아니라, 앞으로 같은 게 다시 들어오면 잡는 파수꾼이 된 셈이다.

1차 · 한 곳만 검사

  • 코드 grep 하나로 끝
  • Text() 전부 오탐
  • 59건 → 아무도 안 씀

2차 · 번역 경로별로 검사

  • ① 코드: 안 감싼 raw 한글
  • ② 카탈로그: en 값 빈 키
  • 실제 미스만 → 게이트로 씀

59건을 잡겠다고 했다가 진짜는 하나였다는 게 좀 민망하긴 했다. 그래도 그 하나가 실제로 영문 기기에서 한국어 알림으로 나갔던 걸 생각하면, 게이트를 세울 값은 했다. 배운 건 두 개다. 검증은 대상이 만들어지는 방식만큼 쪼개야 한다는 것. 그리고 첫 버전이 늑대소년이면 두 번째 커밋부터 꺼버린다는 것 — 이건 해봐야 알았다.