Logan.

2026-05-18

파이프라인은 끝의 GitLab CI · preflight · 비밀 마스킹 · Dooray로 묶다

코드는 이미 만들어져 있을 때 우리가 누르는 단 하나의 버튼. when:manual 트리거, ota_preflight 환경 검증, redactArg 비밀 마스킹, Dooray 한 줄 알림까지 — 4편짜리 시리즈를 묶어주는 마지막 한 겹.

by Logan·13분 읽기·part 4 of EAS Update Playbook·#expo #eas-update #gitlab-ci #ota #release-strategy

안녕하세요, 로건입니다 👋

여러분, 4편의 제목을 한 줄로 다시 적어볼게요. "파이프라인은 끝의 일." CI/CD 글을 쓸 때 자주 빠지는 함정이 있어요. 파이프라인을 시작에 두는 것 — 마치 파이프라인 설계가 운영 디자인의 출발점인 것처럼 쓰는 거죠. 저희 팀에서는 반대로 봅니다.

코드(1~3편)가 이미 정직한 동작을 보장하고, 그 다음에야 파이프라인이 그 위에 얇은 한 겹을 덮어요. 그 한 겹의 일은 단순합니다 — 사람이 한 번 누르는 버튼, 환경이 갖춰졌는지 확인, 비밀이 새지 않게, 결과를 한 줄로 알려주기. 그게 전부예요.

이번 편에서는 그 얇지만 신중한 한 겹을 풀어볼게요.

무엇을 파이프라인에 맡기고, 무엇을 사람에게 남기는가

가장 먼저 정해야 했던 건 자동화의 경계였어요. 모든 것을 자동으로 굴릴 수도 있었어요. main 머지 → 자동으로 staging OTA → 30분 뒤 자동으로 production republish. 빠르고 깔끔하죠. 그런데 우리는 그 길로 가지 않았어요.

이유는 3편에서 한 번 짚었습니다.

"staging의 가장 최신 group이 우리가 QA 검증한 그것이라는 보장은 시스템이 해주지 않아요. 사람의 운영 룰로 채워야 해요."

OTA는 운영 단면이 너무 직접적이에요. 사용자 화면에 몇 초 안에 도달합니다. 그래서 마지막 한 걸음은 사람이 누르기로 했어요. GitLab CI에서 이 결정을 표현하는 방법이 when: manual입니다.

.ota_publish_job:
  stage: ota
  when: manual          # ← 사람이 누른다
  allow_failure: false
  tags:
    - app-deploy-4.7.x

자동 트리거 한 줄을 빼고 when: manual 한 줄을 박은 것 — 이게 우리 파이프라인의 가장 중요한 디자인 결정이에요. 자동이 줄 수 있는 속도를 의도적으로 포기하고, 사람의 확인을 그 자리에 둔 거예요.

트리거 브랜치 분리 — 두 동작, 두 입구

같은 ota stage 안에 두 가지 동작이 있어요 (1~3편 회수).

  • Deploy: 새 번들을 staging 채널에 올림
  • Republish: staging의 검증된 번들을 production 채널에 묶음

이 둘을 같은 버튼으로 두지 않았어요. 입구를 물리적으로 갈랐습니다.

deploy_ota_staging:
  extends: .ota_publish_job
  variables:
    OTA_CHANNEL: "staging"
  only:
    refs:
      - deploy-4.7.x/ota-staging       # ← staging 입구
 
republish_ota_production:
  extends: .ota_republish_job
  variables:
    OTA_CHANNEL: "production"
  only:
    refs:
      - deploy-4.7.x/ota-production    # ← production 입구

서로 다른 git ref에 push될 때만 각 job이 보여요. production 버튼을 staging 브랜치에서 실수로 누르는 일이 일어날 수 없어요. 입구가 다르면 손도 다르거든요.

ota_preflight — 부정직한 환경에서 시작하지 않기

OTA가 반쪽짜리 상태로 빠지는 사고를 본 적이 있어요. EAS는 올라갔는데 Remote Config 갱신이 실패한 — 그런 상태. 사용자 입장에서는 번들은 받았는데 노출 규칙이 옛것인 묘한 화면이 됩니다.

원인은 보통 환경 변수 한 줄이에요. GitLab Runner에 GOOGLE_APPLICATION_CREDENTIALS이 박혀 있지 않다거나, gcloud CLI 자체가 안 깔려 있다거나. 그래서 우리는 모든 OTA job이 시작될 때 환경을 먼저 검증하기로 했어요.

.ota_preflight: &ota_preflight
  - command -v eas >/dev/null     || { echo "eas CLI is required";    exit 1; }
  - command -v gcloud >/dev/null  || { echo "gcloud CLI is required"; exit 1; }
  - test -n "$EXPO_TOKEN"         || { echo "EXPO_TOKEN is required"; exit 1; }
  - test -n "$GOOGLE_APPLICATION_CREDENTIALS" || \
      { echo "GOOGLE_APPLICATION_CREDENTIALS GitLab file variable is required"; exit 1; }
  - test -f "$GOOGLE_APPLICATION_CREDENTIALS" || \
      { echo "GOOGLE_APPLICATION_CREDENTIALS must point to a readable file"; exit 1; }

여기서 신경 쓴 두 가지:

  • CLI 존재변수 존재를 분리해서 확인. 어느 쪽이 깨졌는지 에러 메시지로 즉시 보입니다.
  • 변수 존재와 실제 파일을 또 분리. GOOGLE_APPLICATION_CREDENTIALS는 파일 경로 변수예요. 변수에 값이 있는데 파일이 없는 상황 (예: GitLab의 file variable 마운트 실패) — 이걸 따로 잡아냅니다.

다섯 줄짜리 preflight지만, 운영을 시작하고 보니 생각보다 자주 잡혀요. 환경은 사람의 의도와 다른 방식으로 깨지더라고요.

OTA_MESSAGE의 fallback — 사람이 잊어도 시스템이 채운다

OTA 메시지는 2편의 4모드 정규식 때문에 반드시 <Stale|Silent|Soft|Force>: 본문 형태여야 해요. 그런데 GitLab Variable로 매번 입력하는 건 마찰이 큽니다. 그래서 fallback 사슬을 만들었어요.

- 'export OTA_MESSAGE="${OTA_MESSAGE:-${CI_COMMIT_TITLE:-Silent: OTA update}}"'

세 단계 사슬이에요.

  1. **$OTA_MESSAGE**를 GitLab Variable로 명시했으면 그걸 사용
  2. 없으면 **마지막 커밋 제목($CI_COMMIT_TITLE)**을 사용 — 보통 Silent: 로그인 버그 수정 같은 형태로 커밋하면 자연스럽게 매칭됨
  3. 둘 다 비어 있으면 안전 디폴트 Silent: OTA update

가장 안전한 모드를 디폴트로 두는 게 디자인 포인트예요. 자동 fallback이 Force로 빠지면 사용자에게 재앙이 될 수 있어요. Silent는 아무에게도 화면이 안 보이는 가장 조용한 모드라 디폴트로 안전합니다.

redactArg — 로그가 보안 사고가 되지 않게

CI 로그는 오래 남고, 여러 사람이 봅니다. 거기에 토큰이나 webhook URL이 원본 그대로 찍히면, 그 자체로 보안 사고예요. 그래서 ota-common.js의 모든 명령 실행은 로그 출력 직전에 한 번 redact를 거칩니다.

function redactArg(arg) {
  if (typeof arg !== "string") return arg;
 
  if (arg.startsWith("Authorization: Bearer ")) {
    return "Authorization: Bearer [REDACTED]";
  }
 
  if (arg === DOORAY_WEBHOOK_URL) {
    return "[DOORAY_WEBHOOK_URL]";
  }
 
  return arg;
}
 
function formatCommandForLog(command, args) {
  return [command, ...args.map(redactArg)].join(" ");
}

이 함수의 좋은 점은 덕트테이프 같다는 거예요. 정교한 SDK도 아니고 정책 시스템도 아니에요. 그냥 우리가 알고 있는 비밀의 형태를 명시적으로 식별해서 가립니다.

  • Authorization: Bearer ... 헤더의 토큰 본문
  • Dooray webhook URL 그 자체

새로운 비밀이 추가되면 if 분기가 하나 늘어요. 단순하고 명백한 설계. 정교함 대신 명백함을 택한 가드예요.

덕트테이프는 우아하지 않지만, 사고가 나기 전에 항상 거기 있어요. CI 로그에 있는 redactArg가 그래요.

Dooray 알림 — 결과를 한 줄로 인지하기

마지막 한 겹은 결과 알림이에요. 파이프라인이 끝났다고 그 자리에 있던 사람만 알면 안 됩니다. 다른 동료도, QA도, PM도 어떤 OTA가 어떤 모드로 어디에 올라갔는지를 한 줄로 알아야 해요.

🚀 EAS Update 배포 완료
- Message: 로그인 화면 회귀 hotfix
- Channel: staging
- Runtime: 4.9.6
- OTA Type: Silent => Silent
- Update Group ID: <UUID>
- Dashboard: https://expo.dev/.../updates/<UUID>
♻️ EAS Update Prod 반영 완료
- Message: 결제 모듈 보안 패치 (필수)
- Channel: production
- Source Group ID: <UUID>
- Source Runtime: 4.9.6
- OTA Type: Silent => Force

포맷 디자인에서 신경 쓴 두 가지:

  • 이모지로 동작 종류를 가른다. 🚀 = deploy, ♻️ = republish. 3초 안에 어떤 동작인지 알아보게.
  • OTA Type을 전이로 표시. Silent => Force — 이전이 무엇이었고 새것이 무엇인지를 한 줄에. 운영자가 변화의 크기를 한눈에 봅니다.

좋은 알림은 추가 정보를 묻게 하지 않아요. 위 두 메시지에 있는 정보만으로 "지금 사용자에게 무슨 일이 일어났는가"를 80%쯤은 추론할 수 있어요. 나머지 20%는 Dashboard 링크를 누르면 알 수 있고요.

시리즈를 닫으며

gitlab-ci.yml은 길지 않아요. 두 job 정의 + 5줄짜리 preflight + 메시지 fallback 한 줄이 전부입니다. 그런데 그 짧음이 코드를 얼마나 정직하게 짜뒀는지의 반영이에요.

1편에서 우리는 왜 다시 OTA를 들였는가를 정직하게 말했어요. 2편에서는 한 단어로 사용자 경험을 가르는 4모드를 디자인했고, 3편에서는 같은 binary를 두 채널에 정직하게 묶는 republish를 만들었습니다. 그리고 4편의 파이프라인은 그 셋 위에 얇은 한 겹만 더 얹습니다 — 사람이 누르는 버튼, 환경 검증, 비밀 마스킹, 결과 알림.

좋은 파이프라인은 짧다. 짧기 위해서는, 그 위에 있는 코드가 정직해야 한다.

시리즈 본편은 여기서 닫아요. 이어서 같은 토픽으로 note 세 편을 따로 풀 예정이에요.

  • shim은 고치지 말고 우회한다 류로 — OTA 메시지의 정규식 강제는 왜 좋은가 한 토픽 한 페이지
  • Remote Config의 etag + validateOnly — 두 줄짜리 안전망이 막아주는 것들
  • codepush.release.js를 지우지 않은 이유 — 옅은 이유의 결정적 가치

그리고 시리즈와 별도로, QA가 staging 빌드에서 임의의 OTA preview를 골라 받는 트릭 (easPreviewUpdates.ts)에 대한 단독 글을 한 편 더 준비하고 있어요. 그건 EAS Update 위에 우리가 추가로 얹은 가장 흥미로운 한 줄이라, 시리즈에 묶기엔 너무 컸어요.

읽어주셔서 감사해요. 다음 글에서 뵙겠습니다.

related — in series · EAS Update Playbook