본문으로 건너뛰기

EAS staging 프로필과 TestFlight dev 서버 배포 구조 정리

·25 min read

서버가 dev와 prod 두 벌로 분리된 앱을 맡고 있었다. 외부 테스터에게 TestFlight 공개 링크로 앱을 나눠주고 싶었는데, 테스터가 봐야 하는 건 dev 서버였다. prod 데이터를 건드리면 안 되니까.

App Store Connect 목록에 preview 빌드가 보이지 않았다

App Store Connect의 "테스트할 빌드 선택" 목록을 열었더니 prod로 배포한 빌드 하나만 있었다. 사내 배포용으로 만들어 둔 빌드, 그러니까 EAS의 preview 프로필로 만든 빌드는 목록에 아예 나타나지 않았다.

App Store Connect > TestFlight > 외부 그룹 > 빌드(0) > +
 
  테스트할 빌드 선택
  ─────────────────────────────
  ○  8   제출 준비 완료   2026-09-03
  ─────────────────────────────

처음엔 TestFlight가 prod 빌드만 받는 건가 싶었다. 원인도 해법도 그게 아니었다.

eas.json의 원래 프로필은 이랬다. 두 프로필의 차이는 distribution 한 줄이었다.

"preview": {
  "autoIncrement": true,
  "distribution": "internal",
  "ios": { "simulator": false, "buildConfiguration": "Release" }
},
"production": {
  "autoIncrement": true,
  "distribution": "store",
  "ios": { "simulator": false, "buildConfiguration": "Release" }
}

distribution: "internal"은 iOS에서 ad hoc 배포다. 엔터프라이즈 계정이면 enterpriseProvisioning으로 사내 배포를 고를 수도 있다. EAS가 ipa를 만들어 자체 서버에 올려두고 설치 링크와 QR을 주는 방식이다. 프로파일을 만들려고 Apple Developer Portal에 UDID를 등록하기는 하지만, ipa 자체는 App Store Connect로 올라가지 않는다. App Store Connect에 존재하지 않는 빌드가 TestFlight 목록에 뜰 리가 없었다.

선택이 막혀 있던 게 아니라 목록에 들어간 적이 없었다. 두 경로를 나란히 그려보니 분명했다.

이미 만든 preview 빌드를 나중에 올릴 수는 없었다

서명이 빌드 시점에 ipa 안에 봉인되기 때문이다. .ipa는 사실 zip이고, 풀어보면 안에 이런 게 들어 있다.

Payload/myapp.app/
├── myapp                         실행 바이너리 (서명이 바이너리 자체에 새겨짐)
├── embedded.mobileprovision      프로비저닝 프로파일
├── _CodeSignature/CodeResources  모든 리소스 파일의 해시 목록
└── Info.plist, 에셋들...

embedded.mobileprovision은 애플이 서명한 plist다. 여기에 배포 방식이 박혀 있다.

developmentad hoc (internal)App Store (store)
ProvisionedDevicesUDID 목록 있음UDID 목록 있음키 자체가 없음
get-task-allowtrue (디버거 붙음)없음없음
사용 인증서Apple DevelopmentApple DistributionApple Distribution
ASC 업로드거부거부통과

App Store Connect는 업로드된 ipa의 embedded.mobileprovision을 검사해서, ProvisionedDevices 키가 있으면 거부한다. 특정 기기 전용 빌드라는 뜻이기 때문이다. 업로드 단계에서 "Invalid Provisioning Profile — A Distribution Provisioning profile should be used" 류의 에러가 나는 게 이 경우다.

파일을 바꿔치기할 수도 없다. _CodeSignature/CodeResources가 앱 번들 안 모든 리소스 파일의 해시를 들고 있고, 그 파일의 해시가 다시 실행 바이너리 안의 code directory에 담긴다. 서명이 걸려 있는 건 이 code directory다. 프로파일 하나만 갈아끼워도 CodeResources가 바뀌고, code directory 해시가 어긋나 서명이 깨진다. 재서명(codesign -f)으로 억지로 맞출 수는 있지만 EAS 워크플로에서 할 일은 아니고, entitlements 정합성까지 직접 책임져야 한다.

배포 방식을 바꾸려면 다시 빌드하는 수밖에 없었다.

TestFlight가 요구한 건 출시된 빌드가 아니었다

prod로 배포된 빌드만 TestFlight에 쓸 수 있다는 건 사실이 아니었다. 업로드부터 공개 링크까지의 경로는 이렇게 이어진다.

내부 테스터는 심사 없이 즉시 설치할 수 있다. 외부 테스터 쪽에만 베타 앱 심사라는 관문이 하나 있고, 이게 링크 배포에 필수인 유일한 단계다. App Store 정식 심사와 베타 앱 심사는 완전히 별개라서, 앱을 한 번도 출시한 적이 없어도 TestFlight 공개 링크는 돌릴 수 있다. 애플 문서 기준으로 심사가 필수인 건 외부 그룹에 추가하는 첫 빌드이고, 이후 빌드는 전체 심사를 거치지 않는 경우가 많다.

심사 전에 채워야 했던 칸들

반려 사유의 대부분이 이 목록에서 나온다.

  • 테스트 정보: 베타 앱 설명, 피드백 이메일, 연락처(이름/전화/이메일), 개인정보처리방침 URL
  • 데모 계정 — 로그인이 필요한 앱이면 필수다. 심사자가 못 들어가면 그대로 반려된다
  • 빌드의 "테스트할 내용"
  • 수출 규정 준수(암호화) 답변

암호화 답변은 Expo라면 app.json에 미리 박아두면 매번 묻지 않는다.

"ios": { "infoPlist": { "ITSAppUsesNonExemptEncryption": false } }

업로드된 빌드는 90일 후 만료된다. 만료되면 테스터가 설치도 실행도 못 한다. 그리고 외부 그룹에 승인된 빌드가 있어야 테스터가 실제로 설치할 수 있다. 빌드 없이 링크만 있으면 들어가도 설치가 안 된다.

서명 축과 환경 축은 따로 놀고 있었다

문제를 다시 정의하면, 원하는 조합이 기존 프로필에 없었을 뿐이었다.

서명dev 서버prod 서버TestFlight
ad hoc (internal)preview없음불가
App Store (store)비어 있던 자리production가능

preview에는 dev 서버가 있는데 서명이 틀렸고, production은 서명이 맞는데 서버가 틀렸다. 두 축에서 원하는 쪽을 각각 고르면 된다. EAS에서는 프로필 하나 추가로 끝났다.

"staging": {
  "extends": "production",
  "environment": "preview"
}

extends는 단순 병합 상속이다. 위 두 줄은 아래를 전부 쓴 것과 같다.

"staging": {
  "autoIncrement": true,
  "distribution": "store",
  "ios": {
    "simulator": false,
    "buildConfiguration": "Release"
  },
  "environment": "preview"
}

distribution과 ios 블록은 production에서 상속받아 TestFlight 업로드가 되고, environment 한 줄만 덮어써서 dev 서버를 본다. 빌드를 돌리지 않고도 병합 결과를 확인할 수 있다.

$ eas config --platform ios --profile staging
 
Build profile "staging"
  "distribution": "store",
  "autoIncrement": true,
  "environment": "preview",
  "buildConfiguration": "Release"

environment 한 줄을 빠뜨리면 조용히 prod를 본다

environment는 EAS 서버에 저장된 환경변수 세트를 가리킨다. 이 이름은 기본적으로 development / preview / production 세 개다. Production·Enterprise 플랜에서는 커스텀 환경 이름을 추가할 수 있지만, 그 아래 플랜에서는 이 세 개가 전부다.

$ eas env:list --environment preview
EXPO_PUBLIC_API_BASE_URL=https://dev-api.example.com
EXPO_PUBLIC_SOCKET_URL=wss://dev-socket.example.com
...
 
$ eas env:list --environment production
EXPO_PUBLIC_API_BASE_URL=https://api.example.com
EXPO_PUBLIC_SOCKET_URL=wss://socket.example.com
...

빌드 프로필 이름과 환경 이름은 서로 다른 네임스페이스다. 우연히 preview, production이라는 이름이 양쪽에 다 있어서 헷갈렸다. 빌드 프로필 이름은 eas.json의 build 아래 키라서 staging이든 qa든 demo든 아무거나 지어도 된다. 환경 이름은 그럴 수 없다.

environment를 생략하면 프로필의 distribution과 developmentClient 설정을 보고 환경이 정해진다. distribution이 store면 production, developmentClient가 true면 development, 그 밖에는 전부 preview다.

$ eas config --platform ios --profile preview
Resolved "preview" environment for the build.
Environment variables ... loaded from the "preview" environment on EAS: EXPO_PUBLIC_API_BASE_URL, ...

기본 preview 프로필이 preview 환경을 받은 건 이름이 같아서가 아니었다. distribution이 internal이라 "그 밖에는 preview" 규칙에 걸린 것이고, 이름은 우연히 일치했을 뿐이다.

그렇다면 environment를 아예 생략하면 어떻게 되는지 궁금해져서, "distribution": "store" 한 줄만 있는 빈 프로필을 임시로 만들어 돌려봤다.

# "tmpcheck": { "distribution": "store" }
$ eas config --platform ios --profile tmpcheck
Resolved "production" environment for the build.
Environment variables ... loaded from the "production" environment on EAS: ...

이름이 비표준인데도 distribution 한 줄 때문에 production이 선택됐다. 경고도 에러도 없었다. staging 프로필을 만들면서 environment 한 줄을 빠뜨리면, dev 서버를 보여주려던 테스터 빌드가 조용히 prod 서버를 보게 된다. 빌드는 성공하고, 로그에 붉은 글씨도 없고, 앱을 실행해 네트워크를 봐야만 알 수 있다.

그래서 staging에는 environment: "preview"가 반드시 필요했다. 반대로 production 프로필의 "environment": "production"은 동작상 아무것도 바꾸지 않지만, 추론과 폴백 어느 쪽에도 기대지 않으려고 명시해뒀다.

environment는 extends로 상속된다. eas-cli가 상위 프로필과 하위 프로필을 얕은 병합으로 합치므로, 부모에 environment가 있으면 그대로 내려오고 자식이 같은 키를 쓰면 덮어쓴다. staging이 두 줄로 동작하는 것도 이 덮어쓰기 덕분이다.

EXPO_PUBLIC_*은 런타임 값이 아니다

babel-preset-expo는 번들링 중에 process.env.EXPO_PUBLIC_* 참조를 문자열 리터럴로 바꿔 넣는다.

const baseURL = process.env.EXPO_PUBLIC_API_BASE_URL;
 
const baseURL = "https://dev-api.example.com";

React Native 앱에는 Node의 process.env가 없다. .env 파일이 기기로 전달되지도 않는다. 빌드 시점의 값이 JS 번들 안에 문자열로 박혀서 나간다.

그래서 출고 후에는 값을 못 바꾼다. 앱을 다시 빌드하는 것 말고는 방법이 없다. 비밀값을 넣어서도 안 된다. ipa를 풀어 번들을 열면 그대로 보인다. 접두사가 PUBLIC인 이유다.

서버 전환이 빌드 산출물 단위의 결정이라서 빌드 프로필로 나누는 게 자연스러웠다.

값의 출처가 .env라고 착각하기도 쉽다. EAS CLI는 기본적으로 .gitignore에 걸리지 않은 파일만 빌드 컨텍스트로 업로드한다. 추적 여부가 아니라 ignore 규칙이 기준이라, 커밋하지 않은 새 파일이라도 .gitignore에 없으면 올라간다. .env는 대개 .gitignore에 있으므로 EAS 빌드에는 전달되지 않는다. 로컬에서 expo start로 띄울 때만 .env를 읽고, EAS 빌드는 EAS 서버의 environment 변수를 읽는다.

--local 빌드도 마찬가지다. 로컬 머신에서 컴파일할 뿐, 환경변수 해석은 동일하게 EAS 환경에서 이뤄진다. 다만 가시성이 Secret인 변수는 로컬 빌드에서 지원되지 않아 로컬 환경에 직접 넣어야 한다. 어느 환경이 주입될지는 빌드를 돌리기 전에 미리 볼 수 있다.

$ eas config --platform ios --profile staging
Environment variables with visibility "Plain text" and "Sensitive"
loaded from the "preview" environment on EAS: EXPO_PUBLIC_API_BASE_URL, EXPO_PUBLIC_SOCKET_URL, ...

배포 빌드의 서버 주소를 바꾸려면 .env가 아니라 eas env:update로 서버 쪽을 고쳐야 한다. 로컬 .env와 EAS 환경변수는 조용히 어긋나 있을 수 있다.

빌드 번호는 프로필이 달라도 한 카운터를 쓴다

cli.appVersionSource가 이 동작을 지배한다.

"cli": { "appVersionSource": "remote" }

iOS에는 숫자가 두 개 있다.

Info.plist 키의미여기서의 소유자
마케팅 버전CFBundleShortVersionString사용자에게 보이는 1.1.9app.json의 version (로컬)
빌드 번호CFBundleVersion같은 버전 내 n번째 업로드 8EAS 서버 카운터 (remote)

appVersionSource: "remote"면 빌드 번호가 로컬 파일 어디에도 없다. App Store Connect에 8이 떠 있어도 저장소를 뒤지면 안 나온다. 조정은 eas build:version:set으로 한다.

autoIncrement: true는 빌드마다 이 카운터를 1씩 올린다. staging, production, staging 순으로 번갈아 돌려보니 번호가 9, 10, 11로 이어졌다. 프로필이 달라도 같은 카운터를 쓴다. 충돌은 안 나지만 목록만 봐서는 어느 서버를 보는 빌드인지 구분되지 않는다.

submit 프로필은 ipa 내용을 보지 않는다

submit은 build와 완전히 별개 섹션이다. 빌드를 만드는 설정이 아니라 이미 만들어진 ipa를 어디로 올릴지의 설정이다.

"submit": {
  "production": { "ios": { "ascAppId": "1234567890" } },
  "staging":    { "ios": { "ascAppId": "1234567890" } }
}

ascAppId는 App Store Connect의 앱 레코드 ID다. 앱스토어 URL apps.apple.com/app/id1234567890의 끝 숫자와 같다. 적어두면 제출할 때 어느 앱이냐를 대화형으로 묻지 않는다.

어느 프로필로 부르든 store 서명 ipa면 올라간다. ad hoc 빌드는 eas submit이 제출 후보를 조회할 때 store 배포 빌드만 걸러 오기 때문에 목록에 뜨지도 않는다. --profile staging이 "dev 서버 빌드만 받는다"를 보장해주지는 않는다. 빌드와 제출의 짝은 사용자가 맞춰야 한다. 다만 eas build --auto-submit을 쓰면 빌드 프로필과 같은 이름의 submit 프로필이 자동으로 선택되므로, 그 경로에서는 이름이 짝을 실제로 결정한다.

원래 있던 submit.preview는 ad hoc 빌드를 가리키는 이름이라 절대 성공할 수 없는 조합이었다. 그래서 staging으로 바꿨다.

프로필 4개로 정리한 eas.json

두 축을 다 반영한 최종 eas.json은 이렇게 됐다.

{
  "cli": {
    "version": ">= 16.28.0",
    "appVersionSource": "remote"
  },
  "build": {
    "development": {
      "developmentClient": true,
      "distribution": "internal",
      "ios": { "simulator": true }
    },
    "preview": {
      "autoIncrement": true,
      "distribution": "internal",
      "ios": { "simulator": false, "buildConfiguration": "Release" }
    },
    "production": {
      "autoIncrement": true,
      "distribution": "store",
      "environment": "production",
      "ios": { "simulator": false, "buildConfiguration": "Release" }
    },
    "staging": {
      "extends": "production",
      "environment": "preview"
    }
  },
  "submit": {
    "production": { "ios": { "ascAppId": "1234567890" } },
    "staging":    { "ios": { "ascAppId": "1234567890" } }
  }
}

프로필 4개를 두 축으로 늘어놓으면 이렇게 된다.

프로필distribution환경서버산출물TestFlight
developmentinternal + simulatordevelopment (자동)dev시뮬레이터 .app불가
previewinternal (ad hoc)preview (자동)dev실기기 .ipa (UDID 필요)불가
productionstoreproduction (명시)prod배포 .ipa가능
stagingstore (상속)preview (명시)dev배포 .ipa가능

클라우드에서 돌릴 때는 두 줄이면 끝난다.

eas build --platform ios --profile staging
eas submit --platform ios --profile staging

로컬에서 빌드하면 산출물이 루트에 .ipa로 만들어지고, 그 경로를 직접 넘겨야 한다.

EXPO_NO_CAPABILITY_SYNC=1 eas build --platform ios --profile staging --local
eas submit --platform ios --profile staging --path ./build-XXXX.ipa

--local도 store 서명이 필요하지만, 인증서와 프로비저닝 프로파일은 EAS가 서버에서 내려주므로 Xcode 계정 설정 없이 진행됐다.

같은 번들 ID로 가면서 감수한 것들

앱이 공존하지 않는다

이 구조의 가장 큰 대가다. staging과 production은 번들 ID가 같으므로 iOS 입장에서 같은 앱이다. 테스터 기기에서 App Store 설치본을 덮어쓰고, 한 기기에 dev용과 prod용을 동시에 둘 수 없다. 앱 그룹, 키체인, UserDefaults, 로컬 DB도 공유한다. prod 세션으로 로그인돼 있던 상태에서 dev 빌드를 깔면 서버만 바뀌고 저장된 토큰은 그대로라, 인증 오류나 이상한 혼합 상태가 나올 수 있다.

완전한 분리는 번들 ID를 따로 가져가는 것(app variants)뿐이다. 비용이 꽤 든다. App Store Connect 앱 레코드를 추가해야 하고, 번들 ID에 묶인 것 전부를 두 벌로 만들어야 한다. GoogleService-Info.plist(Firebase 앱 추가), 앱 그룹, HealthKit과 Apple 로그인과 푸시 capability, 워치 앱·위젯 익스텐션 번들 ID까지. app.json도 app.config.js로 바꿔 환경변수로 bundleIdentifier와 앱 이름, 아이콘을 분기해야 한다.

상시로 두 앱을 운영할 게 아니라면 과하다. 테스터 소수에게 한시적으로 dev를 보여주는 목적이라 같은 번들 ID에 staging 프로필을 얹는 쪽을 골랐다.

TestFlight 목록에서 dev와 prod 빌드가 구분되지 않는다

빌드 번호 카운터를 공유하므로 목록에는 9, 10, 11이 나란히 놓일 뿐이다. 방어 수단으로 외부 그룹 하나를 dev 빌드 전용으로 정해두고 규율로 지키기로 했다. 빌드의 "테스트할 내용"에는 서버: dev를 명시한다. 같은 버전 선택 목록에 dev 서버 빌드가 같이 뜨니 App Store 정식 심사에 실수로 제출하지 않도록 조심해야 한다. 더 확실히 하려면 app.config.js로 CFBundleDisplayName을 MyApp (dev)처럼 분기해 홈 화면에서 구분되게 할 수 있다.

TestFlight는 항상 production APNs를 쓴다

개발 빌드가 아니라 배포 서명이므로 TestFlight 앱의 푸시 토큰은 production APNs 환경 토큰이다. dev 백엔드가 sandbox APNs로만 보내도록 돼 있으면 푸시가 조용히 도착하지 않는다. FCM에 APNs 인증키(.p8)를 올린 방식이면 양쪽 환경을 자동 처리해서 대개 그냥 되지만, 인증서(.p12) 기반이거나 백엔드가 환경을 하드코딩했다면 손봐야 한다. 공개 링크를 뿌리기 전에 dev staging 빌드로 푸시를 실제로 한 번 받아봐야 한다.

OTA 업데이트 채널을 공유하면 서버가 뒤바뀐다

eas update를 쓴다면 특히 위험하다. EXPO_PUBLIC_*은 JS 번들에 박혀 있고, OTA 업데이트는 그 JS 번들을 통째로 교체한다. prod 환경으로 발행한 OTA가 dev 테스터의 앱에 내려가면, 앱을 다시 설치하지도 않았는데 서버가 prod로 바뀐다.

channel은 빌드 시점에 ipa에 박히고, eas update --channel로 발행한 업데이트는 그 채널의 설치본에만 간다. 그래서 채널을 분리해야 한다.

"production": { ..., "channel": "production" },
"staging":    { "extends": "production", "environment": "preview", "channel": "staging" }

OTA를 안 쓴다면 이 필드는 없어도 된다.

로컬 .env와 EAS 환경변수가 어긋난다

둘은 별개 저장소라서 .env만 고치고 배포 빌드가 바뀌길 기대하면 안 된다. 주기적으로 eas env:list --environment <name>로 대조해야 한다.

빌드 전후로 다시 확인한 것들

빌드 전에 확인한 것.

  • eas config --platform ios --profile staging로 distribution: store와 environment: preview 확인. Resolved "production" environment가 찍히면 environment 한 줄이 빠진 것이다
  • eas env:list --environment preview로 서버 주소가 dev인지 확인
  • app.json의 version이 의도한 마케팅 버전인지 확인

빌드 후에 확인한 것.

  • 앱 실행 후 네트워크 요청이 dev 서버로 가는지 육안 확인. 번들에 박힌 값이라 사후 수정이 안 된다
  • 푸시 알림이 실제로 도착하는지 확인

TestFlight 제출 단계에서 확인한 것.

  • 테스트 정보와 데모 계정 입력, 데모 계정으로 실제 로그인되는지 먼저 확인
  • "테스트할 내용"에 서버: dev 명시
  • 외부 그룹에 빌드 추가 후 베타 앱 심사, 승인되면 설정 탭에서 공개 링크 켜기
  • 90일 만료 인지

서명도 서버 주소도 빌드 시점에 봉인되니, 조합을 바꾸려면 언제나 다시 빌드해야 한다.