안녕하세요, 로건입니다 👋
여러분, CodePush 시절 가장 조용히 불편했던 일이 있었어요. staging에서 한 번 빌드하고, production에서 또 한 번 빌드하는 일. 두 빌드의 입력(소스 코드)은 같지만, 출력(binary)이 정말 같다고 100% 단언하기는 어려웠어요. 빌드 환경의 시점 차이, 의존성 lock의 미세한 변화, 캐시 적중 여부 — 작은 변수가 산처럼 쌓이는 게 빌드라서요.
QA가 staging의 v123을 검증하고 OK 했는데, production에 올라간 게 v124라면, 우리가 사용자에게 보내는 건 검증되지 않은 빌드예요. 옛 시절엔 그 미세한 어긋남이 종종 사고로 이어졌습니다.
3편은 그 어긋남을 원리적으로 없애는 방법에 대한 이야기예요. EAS Update의 update:republish — rebuild 없이, 같은 binary를 다른 채널에 묶는 단 한 줄의 명령.
groupId가 진실이라고 했죠
1편에서 살짝 흘리고 지나간 이야기를 다시 꺼낼게요. EAS Update의 각 업데이트는 groupId라는 UUID로 식별돼요. 그리고 그 groupId 하나가 곧 그 시점의 정직한 번들입니다. 다른 채널에 옮겨도, 다른 메시지로 발표해도, binary는 변하지 않아요.
이 트릭 위에 우리는 한 가지 운영 룰을 박았어요. production은 staging의 가장 최신 group-A에서만 옵니다. 절대 main 브랜치에서 다시 빌드하지 않아요. 검증된 적 없는 번들을 production에 보낼 가능성을 0으로 만드는 룰이에요.
eas-republish.js의 다섯 단계
eas-republish.js는 짧아요. 76줄이고, 다섯 단계로 갈라집니다.
각 단계가 왜 필요한지를 한 줄씩 풀어볼게요.
① gcloud access token
gcloud auth application-default print-access-token. Firebase Remote Config는 사용자 자격증명이나 서비스 계정 토큰이 필요한데, GitLab Runner에는 서비스 계정 키가 GOOGLE_APPLICATION_CREDENTIALS 파일 변수로 박혀 있어요. gcloud CLI가 그걸 읽어 짧은 수명의 access token을 떨궈줍니다. 우리가 토큰 값을 코드에 적을 일이 없어요.
② RC fetch — 현재 상태와 etag
// ota-common.js — getRemoteConfigData() 요지
curl -D headers.txt \
-H "Authorization: Bearer <TOKEN>" \
https://firebaseremoteconfig.googleapis.com/v1/projects/<PROJECT_ID>/remoteConfig \
-o template.json
// 응답 헤더에서 etag를 뽑아둠
const etag = parseHeaderValue(headers, "etag");여기서 etag를 반드시 들고 와야 해요. 나중 ⑤에서 RC를 갱신할 때 If-Match: <etag>로 던지지 않으면, 그 사이에 누가 RC를 바꿨어도 알지 못하고 덮어쓰게 됩니다. 낙관적 동시성 제어예요. 이 etag 트릭은 별도 note 글로 따로 풀 예정입니다.
③ staging의 가장 최신 group을 찾는다
// ota-common.js — getLatestUpdateGroupForChannel() 요지
const updateList = runJson("eas", [
"update:list",
"--branch",
"staging",
"--limit",
"1",
"--json",
"--non-interactive",
]);
const latest = extractLatestUpdateGroup(updateList);
// → { groupId, message, runtimeVersion, branchName, createdAt }여기가 가장 위험한 한 줄이에요. --limit 1은 "가장 최신"을 가져옵니다. 그게 우리가 QA 검증한 그것이라는 보장은 시스템이 해주지 않아요. 사람의 운영 룰로 채워야 해요. 그래서 우리는 두 가지를 같이 묶었습니다.
- staging에 새 OTA를 올린 직후엔 즉시 QA가 검증하지 못하면 production republish를 누르지 않는다. 검증 안 된 staging이 가장 최신이 되어 있을 위험을 사람 단에서 차단.
- **GitLab CI 트리거를
when: manual**로 둠. 자동 머지가 아니라 사람이 버튼. QA OK 직후에만 누를 수 있는 게이트를 사람으로 만들어요. (4편의 핵심 주제)
④ republish — 명령은 한 줄, 의미는 시스템 보장
// ota-common.js — runEasRepublish() 요지
spawn("eas", [
"update:republish",
"--group",
latestUpdate.groupId,
"--destination-channel",
"production",
"-m",
`${updateType} - ${latestUpdate.message}`,
"--json",
"--non-interactive",
]);이 한 줄에 세 가지 결정이 들어 있어요.
--group <id>— 새로 빌드하지 않습니다. 그 식별자가 가리키는 바로 그 번들을 그대로 가져옵니다.--destination-channel production— 그 binary를 production 채널에 묶습니다. staging은 그대로 남아 있어요.-m "<Type> - <원본 메시지>"— staging의 원본 메시지에 production용 type prefix만 새로 입혀요. 이게 2편의 4모드와 만나는 지점이에요.
2편을 본 분은 눈치채셨을 거예요. 같은 binary, 다른 type. 같은 group-A를 staging에서는 Silent로, production에서는 Force로 묶을 수 있어요. 바이너리는 동일하지만, 사용자 경험은 다르게 줄 수 있는 거죠. EAS Update의 binary 동일성과 우리 4모드의 분리 디자인이 이 자리에서 만납니다.
⑤ Remote Config 갱신 — binary는 묶였지만 아직 끝나지 않음
여기가 운영의 가장 미묘한 지점이에요. eas update:republish가 끝났다고 사용자 경험이 바뀌는 게 아닙니다. 클라이언트는 RC의 otaUpdate 값을 읽어서 4모드 중 하나를 적용해요(2편). 그 값을 안 바꾸면, 번들은 production에 묶였지만 사용자에게는 아무 일도 일어나지 않는 상태가 됩니다.
// ota-common.js — updateRemoteConfig() 요지
updatedTemplate.parameters["appConfig"].defaultValue.value = JSON.stringify({
...currentValue,
otaUpdate: "Force", // ← 2편의 4모드 중 하나
});
// 1단계: validateOnly=true 로 먼저 검증
curl -X PUT ... ?validateOnly=true ...
// 2단계: 통과하면 실제 publish (If-Match: etag)
curl -X PUT ... -H "If-Match: ${etag}" ...여기에도 두 가지 안전망이 박혀 있어요.
validateOnly=true로 먼저 dry-run. 잘못된 JSON 구조면 실제 publish 전에 막혀요. RC가 반쪽짜리 상태가 되는 걸 막습니다.If-Match: <etag>. ②에서 들고 온 etag. 그 사이에 다른 누군가가 RC를 바꿨으면, 우리의 publish가 거절돼요. 사일런트 덮어쓰기가 없는 운영. (별도 note 예정)
두 단계 사이의 race를 어떻게 다루는가
④와 ⑤ 사이에 짧은 race window가 있어요. binary는 이미 production에 묶였는데 RC가 아직 안 바뀐 몇 초~몇십 초.
이 window의 의미를 정직하게 적어두자면 이래요.
- 사용자가 그 사이에 앱을 켰다면, 새 번들이 다운로드는 시작될 수 있어요. (EAS Update는 RC와 무관)
- 하지만 RC의
otaUpdate가 아직 이전 값이면, 클라이언트는 이전 노출 규칙으로 동작해요. - ⑤가 끝난 직후 켜는 사용자부터 새 노출 규칙으로 분기됨.
이 race를 완전히 막는 방법은 ④와 ⑤를 트랜잭션으로 묶는 거예요. 그런데 EAS와 Firebase는 다른 시스템이라 그게 불가능합니다. 그래서 우리는 순서만 보장해요. ④ 먼저 → ⑤ 나중. 사용자에게 가는 영향이 덜 깜짝 놀랄 순서로요.
- 만약 RC 먼저 갱신했다면 → 사용자는 "새 노출 규칙"인데 "이전 번들"을 받음. 모드와 binary가 어긋남.
- binary 먼저 묶고 RC를 나중에 → 사용자는 "이전 노출 규칙"이지만 어차피 이전이라 어긋남이 없음.
작은 디자인이지만, 어긋남의 방향을 정직한 쪽으로 두는 게 운영에서 큰 차이를 만들어요.
정리 — republish가 만든 것
eas update:republish --group <staging의 그 id> --destination-channel production
이 한 줄이 우리에게 만들어준 게 셋이에요.
- 재빌드 0. production은 staging의 정확한 복제예요. 환경 차이로 인한 어긋남이 원리적으로 없습니다.
- 검증 100%. production에 가는 모든 번들은 staging에서 QA가 OK했던 그것이에요. 언제나.
- 같은 binary, 다른 type. 4모드와 결합해 바이너리는 같되 사용자 경험은 다르게 줄 수 있어요. 새 기능을 staging에선 Silent로 띄우고 production에선 Soft로 권유하는 식.
옛 CodePush 시절엔 staging→production이 재패키징에 가까웠어요. 두 번 빌드, 두 번 압축, 두 번 업로드. EAS는 그게 같은 식별자라는 걸 사람의 약속이 아니라 시스템 보장으로 들고 있어요.
같은 binary를 두 채널에 정직하게 묶을 수 있다는 건, 운영을 추측에서 증명으로 옮기는 일이다.
다음 편에서는 이 모든 흐름을 묶어주는 마지막 한 겹 — GitLab CI의 when: manual 트리거, ota_preflight, redactArg 보안 가드, Dooray 알림 포맷까지 한 번에 다뤄볼게요. 파이프라인은 끝의 일이라는 이야기입니다.