블로그

2026년 8월 29일 · 6분 읽기

상류를 고칠 자리인가, 이 기기에만 있는 축인가

Mac 앱은 iOS 정본을 고정 커밋에서 재생성한다. 핀을 올리자 유료 등급이 끊겼고, 고칠 자리를 한 번 잘못 지목했다. 그리고 그 수정이 새 결함을 만들었다.

  • macOS
  • 코드 공유
  • StoreKit
  • 패치

Mac 앱은 iOS 앱의 포크가 아니다. 고정 커밋에서 iOS 소스를 꺼내 vendor/를 다시 만들고, Mac 전용 패치를 그 위에 얹어 빌드한다. 이 방식은 상류 변경을 골라 받게 해 주지만, 상류가 판정 하나를 바꾼 날 유료 등급이 조용히 끊긴다.

Mac 앱은 iOS 정본을 다시 만든다

절차는 네 단계다.

핀 하나가 vendor 트리와 패치 스택을 결정한다

핀은 파일 한 줄이고(UPSTREAM_SHA:1-9), 그 파일이 유일한 정본이다. 워크트리에서 커밋을 읽지 않는 이유가 파일 주석에 적혀 있다.

// UPSTREAM_SHA:1-9
# 워크트리에서 복사하지 않는다: ios-prod는 여러 세션이 동시에 쓰는 저장소이고
# 인덱스·워킹트리가 초 단위로 흔들린다(실측 2026-08-14: 같은 파일이 ??↔M 왕복,
# index 905 vs HEAD 906). git archive는 오브젝트 DB에서 읽으므로 그 흔들림에
# 영향받지 않는다.

지금 핀과 상류의 거리는 명령 하나로 나온다. 이번 상향은 6c5219c9 → a0423b88이었고, 그 사이 빌드 183 → 191이 한 번에 들어왔다.

$ scripts/vendor-sync.sh --report
고정: a0423b88
상류 main: 15ddd43e17b2575db31dac42aa24ba6e71d00ebf
드리프트: 39 커밋 뒤처짐
 45 files changed, 3984 insertions(+), 291 deletions(-)

핀 파일이 정본인 이유

핀을 워크트리에서 읽으면 재생성이 비결정적이 된다. 여러 세션이 같은 저장소를 쓰는 동안 인덱스와 워킹트리는 초 단위로 흔들리고, 같은 파일이 미추적과 수정 사이를 왕복한다. 오브젝트 DB에서 꺼내면 그 흔들림이 사라진다 — 커밋 하나는 불변이기 때문이다.

이 선택의 부수 효과가 하나 있다. 상류에서 커밋되지 않은 변경은 절대 들어오지 않는다. 급할 때 손으로 복사해 넣고 싶은 유혹이 생기는데, 그렇게 하면 다음 재생성에서 조용히 사라진다.

드리프트가 0이어도 재생성은 막힐 수 있다

커밋 거리는 "얼마나 뒤처졌는가"만 답한다. 패치가 아직 붙는지는 별개 값이다.

# scripts/vendor-sync.sh:62-68
# 왜 필요한가: `--report` 는 커밋 드리프트만 센다. 그 값이 0이어도 패치는 이미
# 썩어 있을 수 있고, 그때 `vendor-sync` 는 첫 실패에서 죽는다 — 즉 **재생성이
# 불가능한 상태가 조용히 유지된다**(실측 2026-08-17: 네 패치가 상류에 흡수된 뒤에도
# 남아 있어 파이프라인이 막혀 있었고, 그 사실을 아무도 몰랐다.

패치는 스택이라 앞 패치가 뒤 패치의 문맥을 만든다. 그래서 하나씩 원본에 대고 시험하면 멀쩡한 패치가 깨진 것으로 보인다. 검사도 순서대로 적용해야 한다.

핀을 올리자 유료 등급이 끊겼다

핀 범위 안에 상류의 결정 하나가 있었다. 서버를 구독 판정에서 완전히 내리고 등급은 App Store만 정하도록 바꾼 커밋이다.

iOS에서 그 결정은 옳다. 앱이 App Store로만 팔리므로 현재 권한 시퀀스가 완전한 판정이다. 공식 문서도 그 시퀀스의 범위를 이렇게 정의한다.

The current entitlements sequence emits the latest transaction for each product the customer has an entitlement to. Products that the App Store has refunded or revoked don't appear in the current entitlements.

문제는 Mac이 App Store로만 팔리지 않는다는 것이다. 홈페이지에서 내려받은 판은 그 시퀀스에 아무것도 갖지 않는다. 상류 판정을 그대로 받으면 그 사용자의 등급은 언제나 무료다.

고칠 자리를 잘못 지목했다

처음에는 "고칠 자리는 상류"라고 적고 막힘으로 올렸다. 그 판단부터 틀렸다.

규칙은 "상류 로직이 틀렸다고 판단되면 상류를 고친다"였고, 상류 로직은 틀리지 않았다. 없는 것은 이 기기의 축이었다. 배포 채널이라는 개념 자체가 이 저장소의 것이고, 상류에는 그 개념이 없다.

// Sources/MacOverlay/MacDistribution.swift:10-20
/// 안전한 기본값인 `direct`로 보아 구매 표면을 숨기고 웹 Apple 로그인을 사용한다.
...
/// Debug와 Release는 `direct`를 주입하고, App Store 구성은 App Store 제출 시 별도로

이 개념을 상류에 심으면 iOS가 시험할 수 없는 분기를 갖게 된다. 그래서 축은 이 저장소의 패치로 남았다. 판단 기준은 "어느 쪽이 그 개념을 실제로 갖는가"다.

그 패치가 새 결함을 만들었다

축을 세운 패치를 다시 읽다가 내가 만든 결함이 나왔다. 서버 왕복이 실패했을 때 등급을 무료로 내리고 있었다.

서버가 한 번 유료라고 답한 사용자가, 다음 일시적 실패마다 강등된다. 그 왕복은 포그라운드 복귀와 구독 변경마다 돌기 때문에 드문 일도 아니다. 막으려던 해악을 다른 자리에 다시 만든 것이다.

고친 방향은 하나다. 실패 경로에서 판정을 건드리지 않는다. 첫 왕복부터 실패했으면 등급은 미확정에 머문다. 알 수 없을 때 이전 값을 유지하는 것과 알 수 없을 때 최솟값으로 내리는 것은 다른 선택이고, 후자는 사용자에게서 이미 산 것을 빼앗는다.

두 저장소가 각자 옳을 수 있다

이 사건의 교훈은 "상류를 고치지 말라"가 아니다. 상류 로직이 틀렸으면 상류를 고치는 것이 맞다. 가릴 질문은 하나다 — 그 개념을 실제로 갖는 저장소가 어디인가.

배포 채널은 Mac에만 있다. 그러므로 그 판정도 Mac에 있어야 한다. 반대로 구독 상품의 기간이나 크로스그레이드 규칙은 두 앱이 공유하므로 상류에 있어야 한다. 축을 어디에 두는지가 정해지면, 패치로 남길 것과 상류로 올릴 것이 자동으로 갈린다. 등급 판정을 감싸는 패치가 그 결과다(scripts/vendor-sync.sh:62-72와 같은 스택에 놓인다).

지금 상태와 남은 대가

현재 패치는 59개다. 이번 작업에서 58 → 59로 하나 늘었고, 재생성 뒤 드리프트는 0으로 떨어졌다. 이 방식의 비용은 그 숫자에 그대로 비례한다. 상류가 같은 파일을 만지면 패치가 썩고, 썩은 패치는 재생성을 막는다. 그래서 핀을 올리는 작업은 "새 기능 받기"가 아니라 "패치 스택 정비"에 더 가깝다.

그 대가를 받아들이는 것은 반대쪽 비용이 더 크기 때문이다. 포크하면 상류의 수정이 오지 않고, 완전히 합치면 Mac에만 있는 개념이 iOS 코드에 섞인다. 핀과 패치는 두 저장소가 서로의 개념을 모른 채 각자 옳게 남는 유일한 방법이었다.

마지막으로 남는 질문은 언제 핀을 올리는가다. 드리프트가 커질수록 한 번에 들어오는 변경이 늘고, 그만큼 이런 사건이 한 번에 여러 개 터진다. 이번에는 39 커밋 뒤처진 상태에서 올렸고, 등급 축 하나가 끊겼다.

반대로 자주 올리면 패치 정비가 상시 업무가 된다. 지금 규칙은 "상류가 공유 계층을 만졌을 때"다. 두 앱이 함께 쓰는 코어가 바뀌면 올리고, iOS 전용 화면만 바뀌었으면 미룬다. 그 판단을 하려면 드리프트 숫자만으로는 부족하고, 무엇이 바뀌었는지 파일 목록을 봐야 한다.

그래서 보고 명령이 커밋 수와 함께 파일 통계를 같이 낸다. 45개 파일이 바뀐 것과 3984줄이 추가된 것 중 후자가 더 정확한 신호다 — 줄 수가 큰 변경은 대개 코어에 닿는다.

이 방식을 고르는 기준

두 앱이 코드를 공유하는 길은 셋이다. 하나의 저장소로 합치기, 포크해서 갈라지기, 핀과 패치로 재생성하기다. 우리가 셋째를 고른 이유는 두 앱의 공통 코어가 크고(화면·모델·동기화) 서로 다른 부분이 작기 때문이다. 작은 차이를 패치로 들고 있는 비용이, 큰 공통부를 두 벌로 유지하는 비용보다 낮다.

바뀌는 순간은 정해져 있다. 패치가 계속 늘어 재생성이 상시 작업이 되면 그 계산이 뒤집힌다. 지금 59개는 정비 가능한 규모이고, 그 판단의 근거는 재생성이 실패하는 빈도다. 이번 상향에서는 붙지 않은 패치가 0개였고 빌드 로그의 오류가 0줄이었다. 보고 명령 자체는 0.06 s에 끝난다 — 드리프트를 확인하는 비용은 사실상 없다.

숫자를 하나 더 남긴다. 패치 스택을 다시 적용하는 데 드는 시간은 커밋 수가 아니라 상류가 만진 파일과 우리 패치가 만지는 파일의 교집합으로 결정된다. 이번에는 45개 파일이 바뀌었고 그중 우리 패치가 만지는 파일이 겹쳐 두 패치를 다시 썼다. 상류 거리는 39 → 0으로 떨어졌고, 패치 수는 58 → 59가 됐다.

정리하면 이 작업에서 판단이 두 번 바뀌었다. 처음에는 상류를 고칠 일로 보았고, 다음에는 이 저장소의 축으로 보았다. 그리고 그 축을 세운 패치가 실패 경로에서 사용자 등급을 깎는 것을 뒤늦게 찾았다. 두 번의 정정 모두 코드를 다시 읽어서 나왔고, 테스트가 먼저 알려 준 것은 아니다.