Skip to content

Keychain access group이 바뀌자 UUID가 초기화됐다#

들어가며#

빌드 파이프라인을 교체한 뒤 UUID가 바뀐다는 제보가 들어왔다. 코드 자체는 그대로였고, 달라진 것은 keychain access group 형식뿐이었다.

기존에는 teamid.* 형태를 사용했지만, 이후 teamid.bundleid 형태로 바뀌었다. 그 순간부터 이전에 저장한 UUID를 조회하지 못했고, 앱은 새 UUID를 발급해 저장하고 있었다.

핵심 원인

UUID 생성 로직이 아니라 keychain의 저장 위치가 바뀐 것이 문제였다. CI가 암묵적으로 주입하던 와일드카드 그룹을 앱 전용 그룹으로 명시하면서, 기존 UUID는 삭제되지 않은 채 조회 대상에서만 빠졌다.

내 소스에는 그 설정이 없었다#

access group을 바꾼 기억이 없었다. 그럴 만도 했다. Unity 빌드의 PostBuildProcess에는 keychain-access-groups를 정의하는 코드가 전혀 없었다.

실제로 값을 넣고 있던 주체는 빌드 머신이었다. CI 단계가 entitlements를 임의로 생성·주입하고, 그 안에서 와일드카드 그룹으로 서명하고 있었다. 프로젝트 소스만 봐서는 알 수 없는 블랙박스였다.

그러다 iOS Universal Link를 붙이게 됐다. Associated Domains는 코드사인된 entitlements에 포함돼야 동작하므로, entitlements를 CI에만 맡길 수 없었다. 직접 구성하면서 앱 전용 설정을 추가했다.

결과적으로 없던 설정을 새로 정의한 것이 아니라, CI가 뒤에서 넣어주던 와일드카드 그룹을 앱 전용 그룹으로 교체한 셈이었다.

access group이 바뀌면 값을 잃는 이유#

Keychain 아이템은 access group 단위로 격리된다. 따라서 access group이 바뀌면 이전 그룹에 저장한 아이템은 조회되지 않는다. 코드 입장에서는 저장한 적이 없는 상태와 구분할 수 없으므로, 새 UUID를 조용히 만들어 저장하게 된다.

중요한 점은 access group을 명시하지 않아도 그룹이 없는 것은 아니라는 사실이다. entitlements에 keychain access group을 설정하지 않으면 기본 저장소는 teamid.bundleid가 된다. 즉 설정을 지우는 행위도 저장 그룹을 바꾸는 행위다.

<!-- 프로젝트 소스에는 없고, CI가 빌드 단계에서 생성·주입했다. -->
<key>keychain-access-groups</key>
<array>
  <string>$(AppIdentifierPrefix)*</string>
</array>
<!-- Universal Link를 추가하며 associated-domains와 앱 전용 그룹을 구성했다. -->
<key>com.apple.developer.associated-domains</key>
<array>
  <string>applinks:내-도메인</string>
</array>
<key>keychain-access-groups</key>
<array>
  <string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
</array>

teamid.* 와일드카드 그룹을 사용했다는 것은 같은 team prefix를 가진 여러 프로젝트가 같은 UUID를 공유했을 가능성도 뜻한다. 이 동작이 의도된 것인지부터 확인해야 한다.

대응 전에 확인한 것#

원인 가설은 빠르게 세웠지만, 마이그레이션 로직을 작성하기 전에 다음을 검증해야 했다.

확인 항목 이유
UUID가 실제 teamid.* keychain에 저장되는가 기존 저장 위치를 확인해야 한다.
다른 프로젝트도 같은 UUID를 사용하는가 와일드카드 그룹의 공유 범위를 파악해야 한다.
access group을 바꾸면 기본 저장소도 바뀌는가 설정 변경의 실제 영향을 확인해야 한다.
access group 미설정 시 기본값이 teamid.bundleid인가 제거가 안전한지 판단해야 한다.
teamid.bundleid와 teamid.*를 함께 등록해 둘 다 접근할 수 있는가 마이그레이션 가능 여부가 결정된다.
loadUuid가 두 번 호출되는 것이 의도된 것인가 상태가 달라질 수 있는 호출 경로를 제거해야 한다.

마지막 두 항목이 대응 설계를 갈랐다. 두 그룹에 동시에 접근할 수 있다면, 옛 그룹에서 값을 읽어 새 그룹으로 저장하는 마이그레이션이 가능하다.

대응은 두 갈래로 나눴다#

이미 UUID가 바뀐 프로젝트와, 아직 파이프라인이 바뀌지 않은 프로젝트는 같은 로직으로 처리할 수 없었다.

새 그룹에 UUID가 없으면 옛 그룹을 조회한다. 값이 있으면 새 그룹에 다시 저장하고 그대로 사용한다. 사용자는 UUID 변경을 경험하지 않는다.

옛 UUID가 새 값으로 덮였거나 새 그룹의 값이 이미 자리 잡았을 수 있어 복구를 보장할 수 없다. 대신 legacy UUID 변경과 저장 실패를 C# 이벤트로 전달해 발생 규모를 추적한다.

복구보다 관측

복구할 수 없는 상태를 억지로 되돌리기보다, UUID 변경과 저장 실패를 관측 가능하게 만드는 방향을 선택했다.

함께 정리한 구현#

entitlements 생성 주체 일원화#

XcodeOption.cs를 제거하고 iOS PostBuildProcess 구조를 개편했다. CI와 프로젝트가 같은 entitlements 파일을 만들면 어느 쪽의 값이 최종 산출물에 반영됐는지 IPA를 열어 보기 전까지 알 수 없다.

access group처럼 데이터 유실로 직결되는 설정에서는 생성 주체를 한 곳으로 모으는 일이 기능 추가보다 먼저다.

식별자 조합 보정#

Team ID, AppIdentifierPrefix, bundle identifier를 조합해 access group 문자열을 만드는 로직을 보정했다. 세 값의 처리 방식이 어긋나면 존재하지 않는 그룹을 조회하게 된다.

네이티브 인터페이스와 호출 경로 단순화#

UUID 로드·저장 인터페이스를 단순화하고, 저장 실패 상태가 C#까지 전달되도록 바꿨다. 이전에는 저장 실패가 호출부에 전달되지 않아 원인을 추적할 방법이 없었다.

iOS 네이티브 문자열 전달도 Unity 마샬링 방식으로 정리해 메모리 관리를 개선했다. 또한 loadUuid가 두 번 호출되던 구조를 한 번으로 줄였다. 두 호출 사이에 상태가 바뀔 수 있는 구조는 자체로 불안정하다.

identifierForVendor는 생각보다 잘 안 바뀐다#

패키지는 UUID를 SystemInfo.deviceUniqueIdentifier로 만들고 있었고, iOS에서는 내부적으로 identifierForVendor를 사용한다.

앱을 재설치하면 값이 새로 나올 것으로 예상했지만, 실제로는 같은 값이 나왔다.

구분 조건
반드시 변경되는 경우 같은 벤더 앱을 모두 삭제한 뒤 재설치, Xcode 테스트 빌드 설치, Ad-hoc 배포 설치, App Store 앱의 다른 개발자 계정 이전
변경될 수 있는 경우 TestFlight 빌드에서 App Store 빌드로 업데이트, iOS 베타 버전으로 OS 업그레이드
변경되지 않는 경우 같은 벤더의 앱이 하나라도 기기에 남아 있는 동안의 앱 업데이트 또는 정식 OS 업그레이드

UUID 유지의 기준은 같은 벤더의 앱이 기기에 하나라도 설치되어 있는지다. 모든 앱을 삭제하면 다음 설치에서 새 값이 발급된다.

이번에는 Xcode Development로 재설치했음에도 값이 유지됐다. 문서상 Xcode 설치는 값이 바뀌는 조건에 해당하지만, 삭제 없이 덮어쓴 설치였거나 같은 벤더의 다른 앱이 남아 있었는지 추가 확인이 필요하다.

어느 쪽이든 결론은 같다. 이 값은 저장하더라도 언제든 바뀔 수 있다고 가정해야 한다. 값이 바뀌었을 때 앱이 조용히 다른 사용자로 취급하기보다, 변경 사실을 감지하고 처리할 수 있어야 한다. legacy UUID 변경을 이벤트로 올린 이유도 여기에 있다.

정리#

이번 사고의 원인은 UUID 생성 로직이 아니라 저장 위치를 결정하는 설정이었다. 코드 한 줄을 바꾸지 않았는데 데이터가 사라진 이유도 여기에 있었다.

그리고 그 설정은 프로젝트 소스에 없었다. 설정이 없다는 말은 사용하지 않는다는 뜻이 아니라, 다른 누군가가 대신 넣고 있을 수 있다는 뜻이다. 그 값을 처음 명시하는 순간은 새 설정을 추가하는 시점이 아니라 기존 동작을 덮어쓰는 변경 시점일 수 있다.

다시 같은 문제를 겪는다면 다음 순서로 확인한다.

확인 이유
최종 빌드 산출물의 entitlements access group 값 소스와 최종 산출물이 다를 수 있다.
소스에 없는 설정이 산출물에 포함되는지 설정이 없다는 것이 미사용을 뜻하지는 않는다.
access group 생성·수정 주체가 한 곳으로 모였는지 여러 곳에서 수정하면 추적할 수 없다.
옛 그룹과 새 그룹을 동시에 등록해 읽을 수 있는지 마이그레이션 가능 여부가 결정된다.
저장 실패가 호출부에 전달되는지 조용한 실패는 원인 추적을 막는다.
값 변경을 감지할 수 있는지 복구할 수 없는 상황도 관측할 수 있어야 한다.

기기 식별자를 영속 값처럼 다루는 코드는 언젠가 한 번은 이 문제를 만날 수 있다.

참고 자료#

이번 사고의 계기가 된 Universal Link 적용 문제는 Unity 딥링크 트러블슈팅에서 다룬다.

Comments