App Store 스크린샷과 릴리스 노트 자동화
DevOps

App Store 스크린샷과 릴리스 노트 자동화

2026-07-271

앱을 한 번 출시하는 것보다 반복해서 출시하는 것이 더 성가십니다. 기능이 바뀔 때마다 스크린샷을 다시 찍어야 하고, 릴리스 노트는 지원 언어 수만큼 웹 콘솔에서 복사·붙여넣기 해야 합니다.

이 글에서는 iPad/iPhone 앱의 App Store 자산 두 가지 — 스크린샷 캡처다국어 릴리스 노트 업로드 — 를 자동화한 과정을 정리합니다. 대상은 서버의 tmux에 붙어 원격으로 개발하려고 만든 터미널 앱입니다(어떻게 쓰는지는 muxpad 회고에 적었습니다).

스크린샷 자동화 파이프라인

문제: 스크린샷은 데이터가 있어야 찍힙니다

터미널 앱의 스크린샷을 찍으려면 서버에 접속된 상태여야 합니다. 호스트 목록이 채워져 있고, 세션이 붙어 있고, 화면에 그럴듯한 출력이 흐르고 있어야 합니다.

매번 실제 서버에 붙어서 찍으면 두 가지가 문제입니다. 실제 호스트명·경로가 노출되고, 재현이 안 됩니다.

그래서 데모 시드를 만들었습니다. 환경변수로만 켜지는 인메모리 데이터 주입입니다.

// 미설정 시 완전 no-op → 프로덕션 경로 100% 무접촉
guard isEnabled else { return }

MUXPAD_DEMO_SEED=1 일 때만 그룹 3개·호스트 7개·연결 배지·테마·SFTP 파일 목록·터미널 출력 버퍼를 인메모리 스토어에 채웁니다. 값이 없으면 아무 일도 하지 않으므로 릴리스 빌드 경로는 건드리지 않습니다.

📌 시드 데이터에는 유니코드·이모지·긴 경로를 일부러 섞었습니다. 스크린샷용이면서 동시에 렌더링 회귀 테스트가 됩니다.

화면 전환을 탭으로 하지 않습니다

UI 테스트로 스크린샷을 찍을 때 흔한 방식은 버튼을 눌러 화면을 이동하는 것입니다. 그런데 여기엔 함정이 있습니다. 버튼을 라벨로 찾으면 언어마다 테스트가 깨집니다. 영어 스크린샷과 한국어 스크린샷을 같은 코드로 찍을 수 없게 됩니다.

그래서 화면 전환도 환경변수로 넘겼습니다. 앱이 런치 시 스스로 해당 화면을 표시합니다.

let screens: [(name: String, screen: String?)] = [
    ("01-sidebar", nil),        // 기본 화면
    ("02-terminal", "terminal"),
    ("03-theme",    "theme"),
    ("04-sftp",     "sftp"),
    ("05-editor",   "editor"),
    ("06-overview", "overview"),
    ("07-upload",   "upload"),
]

for shot in screens {
    let app = XCUIApplication()
    app.launchEnvironment["MUXPAD_DEMO_SEED"] = "1"
    if let screen = shot.screen {
        app.launchEnvironment["MUXPAD_DEMO_SCREEN"] = screen
    }
    app.launchArguments = [
        "-MUXPAD_DEMO_SEED",
        "-AppleLanguages", "(\(lang))",
        "-AppleLocale", locale,
    ]
    app.launch()
    XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15))
    sleep(shot.screen == nil ? 4 : 5)   // 라우팅 지연 + 렌더 애니메이션
    attach(named: "\(lang)-\(shot.name)")
    app.terminate()
}

언어는 -AppleLanguages / -AppleLocale 런치 인자로 주입합니다. 기기 방향도 환경변수로 받아 iPad는 가로(사이드바+디테일 동시 표시), iPhone은 세로로 찍습니다.

결과는 XCTAttachment(screenshot:)lifetime = .keepAlways 로 붙여 결과 번들에 남기고, 나중에 .xcresult 에서 PNG만 추출해 screenshots/<기기>/<언어>/ 로 옮깁니다.

시뮬레이터가 협조하지 않을 때

여기까지 설계는 깔끔했지만, 실제로는 한 번 크게 막혔습니다. 특정 iPad 시뮬레이터에서 UI 테스트가 앱 런치 핸드셰이크 단계에서 계속 실패했습니다.

com.apple.mobile.notification_proxy
"The device is passcode protected." Code=-402653158

앱이나 테스트 코드 문제가 아니라 시뮬레이터의 디바이스 서비스 장애였습니다. simctl erase 로 복구할 수 있지만 다른 데이터까지 날아갑니다.

⚠ 그래서 디바이스 서비스를 우회하는 폴백 경로를 만들었습니다.

xcrun simctl install <device> MuxPad.app
SIMCTL_CHILD_MUXPAD_DEMO_SEED=1 xcrun simctl launch <device> com.example.app
xcrun simctl io <device> screenshot out.png

SIMCTL_CHILD_ 접두사를 붙이면 환경변수가 실행되는 앱으로 전달됩니다. 이 경로는 UI 테스트 인프라를 전혀 쓰지 않으므로 디바이스 서비스가 망가져 있어도 동작합니다.

대신 탭 조작을 못 합니다. 그래서 앱이 스스로 진입하는 화면만 찍을 수 있습니다. 앞에서 화면 전환을 환경변수로 뺀 설계가 여기서 결정적이었습니다. 라우팅이 런치 시점에 결정되므로, 탭 없이도 원하는 화면을 찍을 수 있었습니다.

한 가지 더. 헤드리스로 부팅한 시뮬레이터는 하드웨어 키보드 연결 설정이 반영되지 않아 소프트 키보드가 올라옵니다. 터미널 앱이라 어색하지 않아 그대로 뒀지만, 필요하면 시뮬레이터 GUI에서 하드웨어 키보드를 연결한 뒤 다시 찍으면 됩니다.

릴리스 노트는 스크립트로 올립니다

두 번째 반복 작업은 "새로운 기능" 텍스트입니다. 언어가 둘뿐이어도 매번 콘솔에 들어가 두 번 붙여넣어야 합니다.

fastlane에 포함된 spaceship 으로 App Store Connect API를 직접 호출했습니다. 같은 스크립트를 노그미 개발기에도 붙여 씁니다. 출시하는 앱이 늘수록 이득이 커지는 종류의 자동화입니다.

릴리스 노트 업로드

require "spaceship"

LOCALE_FILES = {
  "en-US" => "docs/appstore/metadata/en-US/release_notes.txt",
  "ko"    => "docs/appstore/metadata/ko/release_notes.txt"
}.freeze

Spaceship::ConnectAPI.auth(
  key_id:   ENV["APP_STORE_CONNECT_KEY_ID"],
  issuer_id: ENV["APP_STORE_CONNECT_ISSUER_ID"],
  filepath:  File.expand_path(ENV["APP_STORE_CONNECT_KEY_PATH"])
)

설계에서 신경 쓴 부분은 세 가지입니다.

1) 노트 본문은 파일이 정본입니다. 스크립트 안에 텍스트를 넣지 않고 로케일별 .txt 를 읽습니다. 버전 관리도 되고, 리뷰도 됩니다.

2) 검증을 먼저 합니다. 파일 없음, 빈 내용, 4,000자 초과(App Store 제한)를 미리 걸러 실패시킵니다. API를 때려보고 알게 되는 것보다 낫습니다.

die("노트 파일 없음: #{rel}")            unless File.exist?(path)
die("노트 비어 있음: #{rel}")            if body.empty?
die("노트 4000자 초과(#{body.length})")  if body.length > 4000

3) --dry-run 을 기본 습관으로 둡니다. 어떤 버전의 어느 로케일에 무엇이 들어갈지 출력만 하고 끝냅니다. 편집 중인 버전이 없으면 --create 로 새로 만들 수도 있게 했습니다.

ruby scripts/asc_release_notes.rb --version 1.2.0 --dry-run

비대화형 셸에는 환경변수가 없습니다

여기서 사소하지만 자주 걸리는 문제를 만났습니다. API 자격증명(키 ID·발급자 ID·키 파일 경로)은 셸 프로필에 환경변수로 두는데, 비대화형 셸에서는 프로필이 자동으로 로드되지 않습니다. 스크립트를 자동화에서 호출하면 환경변수가 비어 있습니다.

그래서 래퍼 스크립트를 한 겹 뒀습니다.

#!/bin/sh
set -e
# 자격증명은 셸 프로필에 상주 — 비대화형 셸엔 자동 로드 안 되므로 명시적으로 source
[ -f "$HOME/.zshrc" ] && . "$HOME/.zshrc" >/dev/null 2>&1 || true
exec ruby "$ROOT/scripts/asc_release_notes.rb" "$@"

✅ 자격증명을 저장소에 넣지 않으면서도 자동화에서 호출할 수 있습니다. .p8 키 파일만으로는 발급자 ID를 알 수 없으므로, 세 값을 함께 환경에서 읽는 구조가 필요합니다.

남은 수작업

자동화했다고 전부 끝난 것은 아닙니다.

  • 캡션 오버레이 디자인 — 스크린샷 위에 마케팅 문구를 얹는 작업은 여전히 디자인 영역입니다.
  • 심사 정보 — 데모 계정, 리뷰 노트, 연령 등급, 개인정보 설문은 사람이 판단할 항목입니다.
  • 최종 제출 버튼 — 자동화하지 않았습니다. 마지막 확인은 사람이 합니다.

그리고 스크린샷 규격은 계속 바뀝니다. 필수 기기 크기와 해상도는 App Store Connect의 현행 요구사항을 매번 확인하는 편이 안전합니다.

스토어가 다르면 요구도 완전히 달라집니다. 앱인토스 미니앱 개발기에서는 노출 이미지 규격과 검수 반려 대응이 여기와는 전혀 다른 종류의 일이었습니다. 자동화는 스토어별로 다시 짜야 한다고 보는 편이 맞습니다.

정리

  • 스크린샷용 데이터는 환경변수로만 켜지는 데모 시드로 만듭니다. 미설정 시 완전 no-op이어야 프로덕션이 안전합니다.
  • 화면 전환을 탭이 아니라 런치 인자로 하면 다국어 캡처가 언어에 의존하지 않고, UI 테스트가 막혔을 때 simctl 폴백도 가능해집니다.
  • 릴리스 노트 본문은 파일이 정본이고, 스크립트는 검증 후 업로드만 합니다.
  • 자격증명은 환경변수에 두고, 비대화형 실행을 위해 래퍼에서 프로필을 명시적으로 로드합니다.

출시 자체는 여전히 긴장되는 일이지만, 적어도 같은 작업을 세 번씩 반복하지는 않게 됐습니다.

같은 앱의 기기 간 동기화를 어떻게 설계했는지는 동기화하면 안 되는 설정 분리하기에 따로 정리했습니다.

참고링크