RunWay에 테스트를 붙이고 스토어 자산을 코드로 그렸다
RunWay 1.1 부터는 Claude Code 와 함께 만든다. 코드와 글의 문장은 거기서 나오고, 방향을 정하고 실기기에서 확인해 틀린 걸 찾아오는 건 직접 한다. 그래도 과정을 글로 남기는 건 업데이트를 AI 에게 맡기고 끝내지 않으려는 것이다. 거기서 배울 점을 찾아 적어 두고 나중에 공부하려고 한다.
1.4.1까지 오면서 테스트를 한 줄도 안 썼다. 실기기로 뛰어보고 로그를 읽는 게 전부였다.
그걸로 못 잡는 게 있다는 걸 56번 글을 쓰면서 알았다. 스플릿 음성이 첫 러닝만 되고 두 번째부터 죽던 문제는 로그 다섯 개를 이틀에 걸쳐 모아서야 보였다. 자동으로 도는 뭔가가 있었으면 훨씬 빨리 알았을 것이다.
그래서 테스트를 붙였다. 대상은 PaceFormatter 하나다. 기기도 앱도 필요 없는 순수 계산이라 시작점으로 적당했다.
아무것도 증명하지 않는 초록불
PaceFormatter는 이미 배포된 코드다. 테스트를 쓰면 당연히 통과한다. 그런데 그게 테스트가 제 일을 한다는 뜻은 아니다.
1
2
3
4
5
@Test func 소수점_오차로_초가_하나_모자라지_않는다() {
let (minutes, seconds) = PaceFormatter.minuteSecond(440.0 / 60.0)
#expect(minutes == 7)
#expect(seconds == 20)
}
Test와 expect로 테스트 한 개 쓰기
@Test를 붙인 함수가 테스트 하나다. 예전 XCTest 처럼 클래스를 만들고 메서드 이름을 test 로 시작할 필요가 없다. 함수 이름을 한글로 써도 되는 게 이 때문이다.
#expect는 조건이 참인지 본다. 거짓이어도 그 자리에서 멈추지 않고 기록만 남기고 계속 간다. 그래서 한 함수 안에 여러 개를 두면 틀린 것을 한 번에 다 볼 수 있다.
초록불이 떴다. 그래서? 이 테스트가 틀린 값을 잡아낼 수 있는지는 아직 모른다. #expect(true)를 써놔도 초록불은 뜬다.
확인하는 방법은 하나뿐이다. 프로덕션 코드를 일부러 망가뜨려 보는 것.
minuteSecond는 원래 이렇게 돼 있었다.
1
2
let minutes = Int(pace)
let seconds = Int((pace - Double(minutes)) * 60)
분을 먼저 떼고 남은 소수에 60을 곱하는 방식이다. 이게 왜 틀렸냐면, 440 / 60을 Double로 다루면 분을 뗀 나머지가 0.3333...이 되고 여기에 60을 곱하면 19.999999999999982가 나온다. Int()로 버리면 19다. 7분 20초가 7분 19초로 찍힌다.
지금 코드는 전체 초를 먼저 반올림하고 나서 쪼갠다.
1
2
let total = Int((pace * 60).rounded())
return (total / 60, total % 60)
고치기 전 모양으로 되돌리고 테스트를 돌렸다.
1
2
Test case 'PaceFormatterTests/소수점_오차로_초가_하나_모자라지_않는다()' failed
** TEST FAILED **
이제야 이 테스트가 진짜다. 빨강을 본 다음의 초록이라야 의미가 있다.
가드를 지웠을 때의 크래시
같은 방법으로 format도 확인했다. 이 함수 맨 앞에 가드가 하나 있다.
1
2
3
4
5
static func format(_ pace: Double) -> String {
guard pace.isFinite, pace > 0 else { return "--:--" }
let totalSeconds = Int(round(pace * 60))
// 생략
}
0이나 음수, 무한대가 들어오면 --:--를 돌려준다. 화면에 이상한 숫자 대신 빈 눈금을 보여주려는 거라고 생각했다.
가드를 무력화하고 테스트를 돌렸더니 테스트가 실패한 게 아니라 앱이 죽었다.
1
2
3
4
5
Exception Type: EXC_BREAKPOINT (SIGTRAP)
Thread 0 Crashed:
2 RunWay.debug.dylib static PaceFormatter.format(_:) + 336 (PaceFormatter.swift:23)
3 RunWayTests PaceFormatterTests.읽을_수_없는_값은_빈_눈금으로(pace:)
23행은 Int(round(pace * 60))이다. Swift에서 Int(NaN)과 Int(infinity)는 트랩이다. 값이 이상하게 나오는 게 아니라 프로세스가 끝난다.
저 가드는 서식을 다듬는 코드가 아니라 크래시를 막는 코드였다. 테스트를 쓰기 전에는 그냥 “빈 값 처리”라고만 알고 있었다.
1
2
3
4
@Test(arguments: [0.0, -1.0, .infinity, .nan])
func 읽을_수_없는_값은_빈_눈금으로(pace: Double) {
#expect(PaceFormatter.format(pace) == "--:--")
}
arguments로 값마다 테스트를 따로 만들기
@Test(arguments:)에 모음을 넘기면 값 하나당 독립된 테스트가 하나씩 생긴다. 함수는 한 번만 쓰고 안에서 반복문을 돌리는 것과 결과가 다르다.
반복문이면 중간에 하나가 터졌을 때 그 뒤가 안 돌고, 결과 목록에도 한 줄만 남는다. arguments: 로 나누면 넷 중 하나가 깨져도 나머지 셋의 결과를 그대로 볼 수 있고, 어떤 값에서 깨졌는지가 목록에 그대로 뜬다.
Swift Testing의 arguments:는 값 하나마다 테스트를 따로 만들어준다. 네 값이 각각 독립된 케이스로 돈다. 하나가 깨져도 나머지 셋의 결과를 볼 수 있다.
아무것도 잡지 못하는 테스트
getPaces는 기준 페이스 앞뒤로 0.25 간격 눈금 다섯 개를 만든다.
1
2
let center = (pace / 0.25).rounded()
return [-2, -1, 0, 1, 2].map { format((center + Double($0)) * 0.25) }
여기서 .rounded()를 빼봤다. 격자에 맞추는 걸 없앤 셈이다.
처음 쓴 테스트는 이거였다.
1
2
3
@Test func 기준_페이스_앞뒤로_0_25_간격_다섯_개를_만든다() {
#expect(PaceFormatter.getPaces(5.0) == ["4:30", "4:45", "5:00", "5:15", "5:30"])
}
이건 못 잡는다. 5.0 / 0.25는 정확히 20.0이라 반올림을 해도 안 해도 같은 값이다. 테스트가 통과하는데 코드는 망가져 있다.
그래서 격자에 안 맞는 값을 하나 더 넣었다.
1
2
3
@Test func 격자에_안_맞는_값은_가까운_격자를_중심으로_삼는다() {
#expect(PaceFormatter.getPaces(5.1) == PaceFormatter.getPaces(5.0))
}
5.1 / 0.25는 20.4다. 반올림하면 20이 되어 5.0일 때와 같은 눈금이 나오고, 반올림을 빼면 20.4가 그대로 쓰여 다른 눈금이 나온다.
테스트를 쓸 때 고른 입력값이 테스트의 전부다. 딱 떨어지는 예쁜 값만 넣으면 아무것도 못 잡는다.
CI 를 살린 배포 타깃 하나
Xcode에서 유닛 테스트 번들을 만들면 배포 타깃이 현재 Xcode 기본값으로 잡힌다. 내 Xcode가 27.0이라 테스트 타깃도 27.0이 됐다.
앱은 26.0이다. 설치된 시뮬레이터는 26.5라 바로 걸렸다.
1
2
3
Cannot test target "RunWayTests" on "iPhone 17 Pro":
iPhone 17 Pro's iOS Simulator 26.5 doesn't match
RunWayTests's iOS Simulator 27.0 deployment target.
앱에 맞춰 26.0으로 내렸다. 당장은 그냥 버전 맞추기였는데, 나중에 보니 이게 CI를 살렸다.
GitHub Actions의 macOS 러너에는 iOS 27 시뮬레이터가 없다. macos-latest 기준으로 26.2, 26.4, 26.5 셋뿐이다. 27.0으로 뒀으면 로컬에서는 잘 돌다가 CI에서만 깨졌을 것이다. 그런 건 원인 찾기가 제일 번거롭다.
기기 이름을 박았을 때의 위험
CI에서 테스트를 돌리려면 시뮬레이터를 지정해야 한다. 제일 쉬운 건 이름을 적는 것이다.
1
-destination 'platform=iOS Simulator,name=iPhone 17 Pro'
이러면 러너 이미지가 바뀌어 그 기기가 없어지는 날 조용히 깨진다. 내가 아무것도 안 건드렸는데 어느 날 갑자기 빨간불이 뜬다.
그래서 이름 대신 조건으로 골랐다.
1
2
3
4
5
6
- name: 시뮬레이터 고르기
id: sim
run: |
udid=$(xcrun simctl list devices available --json | python3 -c "import json,sys,re; d=json.load(sys.stdin)['devices']; c=[x for r,v in sorted(d.items()) for x in v if re.search(r'iOS-(2[6-9]|[3-9][0-9])', r) and 'iPhone' in x['name'] and x.get('isAvailable')]; sys.exit('iOS 26 이상 아이폰 시뮬레이터 없음') if not c else print(c[0]['udid'])")
echo "고른 시뮬레이터: $udid"
echo "udid=$udid" >> "$GITHUB_OUTPUT"
iOS 26 이상인 아이폰을 찾아 UDID를 넘긴다. 없으면 거기서 멈추고 이유를 말한다.
실제로 로컬과 러너가 다른 기기를 골랐다. 로컬은 87209DF8..., 러너는 B34C2F57...이다. 이름을 박았으면 이 차이를 못 넘겼다.
그리고 환경을 찍는 단계를 맨 앞에 뒀다.
1
2
3
4
5
- name: 환경 확인
run: |
xcodebuild -version
echo "---- iOS 시뮬레이터 런타임 ----"
xcrun simctl list runtimes | grep -i ios || true
깨졌을 때 제일 먼저 보고 싶은 게 이거다. 러너에 Xcode가 몇이고 시뮬레이터가 뭐가 있었는지.
비공개 저장소의 macOS 러너 비용
RunWay는 비공개 저장소다. GitHub Actions 무료 한도가 있는데 macOS 러너는 분당 소모가 리눅스의 열 배로 잡힌다. 푸시할 때마다 돌리면 금방 쓴다.
그래서 도는 조건을 좁혔다.
1
2
3
4
5
6
7
8
9
10
on:
push:
branches: ["feature/**"]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: tests-$
cancel-in-progress: true
작업 브랜치와 main으로 가는 PR에서만 돈다. main에 직접 푸시할 일은 거의 없고, 있어도 그건 이미 PR에서 돌아본 코드다.
concurrency는 같은 브랜치에 연달아 밀어넣었을 때 앞선 실행을 취소한다. 결과를 볼 일도 없는 실행에 러너 시간을 쓰지 않는다.
첫 실행의 초록불
1
2
3
4
5
✓ actions/checkout@v4
✓ 환경 확인
✓ 시뮬레이터 고르기
✓ 테스트
conclusion: success
로그를 보니 러너는 Xcode 26.6으로 돌았고 시뮬레이터 런타임은 26.2, 26.4, 26.5 셋이 있었다.
운이 좋았다기보다, 깨질 만한 자리를 미리 확인한 덕이 크다. 러너 이미지에 뭐가 설치돼 있는지 문서로 먼저 읽었고, 시뮬레이터 고르는 로직은 로컬에서 돌려보고 넣었다.
테스트가 늘어도 그대로인 빌드
변이를 넣어 확인하면서 알게 된 게 하나 더 있다.
테스트 파일만 고치면 빌드가 40초인데, 프로덕션 파일을 한 글자라도 고치면 몇 배로 늘어난다. 앱 모듈이 통째로 다시 빌드되고 그걸 쓰는 테스트 번들도 따라 빌드되기 때문이다.
변이 검증은 프로덕션 코드를 건드리는 일이라 매번 전체 빌드를 탄다. 그래서 한 번에 여러 개를 연달아 돌리면 꽤 오래 걸린다.
다음에 쓸 테스트의 기준
이번에 쓴 건 스무 개다. 다섯 함수에 경계값을 넣었다.
쓰면서 세운 기준이 하나 있다. “이 테스트는 어떤 변경을 잡아내는가”를 먼저 말할 수 있어야 한다. 말 못 하면 그 테스트는 통과하는 글자일 뿐이다.
getPaces(5.0) 테스트가 그랬다. 쓸 때는 멀쩡해 보였는데 정작 격자 반올림을 지워도 통과했다. 5.1을 넣은 테스트를 추가하고 나서야 그 변경을 잡게 됐다.
다음은 PolylineSimplifier와 RunAnalysis다. 둘 다 입력과 출력이 분명한 순수 계산이라 같은 방식으로 갈 수 있다.
코드로 그린 스토어 자산
같은 날 한 작업이 하나 더 있다. 2026년 10월에 App Store에 제품 페이지 헤더와 검색 결과 자산이 열렸다. 제품 페이지 맨 위에 깔리는 가로로 긴 그림과, 검색 탭에서 앱을 찾았을 때 목록에 뜨는 그림이다.
| 자리 | 비율 | 크기 |
|---|---|---|
| 제품 페이지 헤더 | 21:9 | 3840 × 1646 |
| 검색 결과 | 3:2 | 1920 × 1280 부터 3840 × 2560 까지 |
둘 다 영상을 받는다. 5초에서 30초, 30 또는 60fps다. 알파 채널은 못 넣는다.
처음엔 앱 스크린샷을 폰 프레임에 넣고 옆에 문구를 올렸다. 만들고 보니 제품 페이지를 조금만 내리면 나오는 스크린샷과 같은 그림이었다. 헤더가 바로 아래 있는 걸 한 번 더 보여줄 이유가 없다.
그래서 계기를 직접 그리기로 했다. 앱 화면을 베끼는 게 아니라 같은 언어로 다시 짓는 것이다. 수평선, 피치 래더, 좌우 테이프, 방위 호를 전부 좌표로 계산했다.
제일 재미있던 건 활주로다. 이륙을 보여주려면 바닥이 있어야 한다.
바닥 평면 위의 점을 화면에 올리는 식은 생각보다 간단하다. 카메라에서 z만큼 떨어지고 중심선에서 x만큼 벗어난 점은 수평선 아래로 f / z만큼 내려오고, 옆으로 x * f / z만큼 벌어진다. 내려온 거리를 below라고 두면 옆으로 벌어진 거리는 그냥 x * below가 된다.
나눗셈이 사라진다. 그래서 활주로 세로선은 소실점에서 기울기 x로 뻗는 직선 네 개면 끝이다.
가로선은 다르다. z를 같은 간격으로 두면 below = f / z라서 아래로 갈수록 간격이 벌어진다. 눈에 보이는 원근이 여기서 나온다.
1
2
3
4
5
step = 0.07
for n in range(0, 64):
z = 0.11 + (n + phase) * step
below = base + deep * 0.16 / z
# 생략
phase를 시간에 따라 줄이면 z가 같이 줄어서 가로선이 아래로 흘러내린다. 다가오는 것처럼 보인다. 처음에 부호를 반대로 넣었더니 활주로가 뒤로 밀려갔다. 달리는 게 아니라 후진하는 그림이 됐다.
그리고 선이 많은 그림이라 두 배로 그린 다음 줄였다. 3840 폭이면 7680으로 그려서 절반으로 축소한다. 선 그리기에 계단을 없애는 기능이 없어서, 크게 그려 줄이는 게 제일 간단하다. 프레임마다 그 작업을 하니 8초짜리 두 편에 10분쯤 걸렸다.
반복되는 영상의 이음매
애플 문서에 이런 줄이 있다.
Ensure your video is polished and loops smoothly without visible jumps or breaks.
이 영상은 자동 재생되고 무한 반복된다. 마지막 프레임 다음에 첫 프레임이 온다. 그런데 헤더는 이륙이다. 8초 동안 고도가 8m에서 31m로 올라간다. 끝에서 바로 처음으로 돌아가면 고도가 순간이동한다.
장면을 되감는 방법도 있지만 이륙이 역재생되면 그게 더 이상하다.
양 끝을 어둡게 빼는 쪽으로 갔다.
1
live = min(ease(ramp(t, 0.0, 1.0)), 1 - ease(ramp(t, 7.0, 8.0)))
처음 1초에 걸쳐 밝아지고 마지막 1초에 걸쳐 어두워진다. 마지막 프레임과 첫 프레임이 둘 다 검정이라 이어 붙여도 튀지 않는다.
소리는 아예 넣지 않았다. 문서가 이렇게 적는다.
In most cases, audio is muted by default, so your creative must work silently first.
검색 결과에서는 음소거가 고정이고 보는 사람이 해제할 수도 없다. 소리로 전할 수 있는 게 없는 셈이다. 빠른 장면 전환도 피하라고 되어 있어서 8초 안에 컷을 하나도 넣지 않았다. 계기 하나가 움직이는 걸 끝까지 보여준다.
검색 결과 쪽은 주제를 다르게 잡았다. 헤더가 인상이라면 검색은 설명이다. 문서도 그렇게 적어뒀다.
State the obvious. Since people tend to look for something specific when using search, be sure your app or game’s purpose is obvious at a glance.
그래서 검색용은 경고가 바뀌는 장면으로 만들었다. ON COURSE 에서 OVERSPEED, SINK RATE 로 넘어가면서 계기 전체가 그 색으로 물든다.
움직이는 방향도 상태마다 다르게 했다. 과속이면 기수가 올라가고 고도가 붙는다. 침하율 경고면 기수가 내려가고 고도가 0으로 떨어진다. 실제 계기가 그렇게 움직인다. 색만 바꾸고 나머지를 똑같이 두면 계기가 아니라 색 필터가 된다.
1
2
3
4
5
PHASES = (
(0.9, 3.0, "ON COURSE", CYAN, 0),
(3.3, 5.1, "OVERSPEED", RED, 1),
(5.4, 7.1, "SINK RATE", AMBER, -1),
)
마지막 숫자가 기수 방향이다.
올린 다음 따로 넣는 심사
다 만들고 나서 안 건데, 이 자산은 올린다고 바로 쓰이지 않는다.
App Store Connect 에 자산 보관함이 따로 있다. 파일을 올리고 각각의 적용 영역을 제품 페이지 헤더나 검색 결과로 지정한다. 한 영역에 여러 개를 올려두고 함께 심사받을 수 있다.
상태가 셋으로 흐른다.
- 처리 중. 올린 직후다. 영상은 변환 때문에 시간이 걸린다
- 심사에 추가. 파란 링크다. 직접 눌러야 심사에 들어간다
- 심사 대기 중
두 번째가 함정이다. 올려두기만 하면 아무 일도 일어나지 않는다. 네 개 중 하나가 저기 멈춰 있는 걸 뒤늦게 봤다.
승인이 나면 그때 제품 페이지 정보에서 승인된 자산 중 하나를 골라 적용한다. 올리기와 심사와 적용이 전부 따로 돈다.
다행히 부담은 적다. 한 번 승인되면 재심사 없이 교체할 수 있다. 그리고 없어도 그만이다. 검색 결과 자산을 안 쓰면 기존 스크린샷과 앱 미리보기가 그대로 나온다. iOS 27 이상에서만 보이기도 한다.
지켜야 하는 것도 몇 개 있다.
- 가격, 할인, 웹사이트 주소, 저작권 기호를 넣을 수 없다
- 받지 않은 상을 적을 수 없다
- 다른 플랫폼이나 마켓의 로고와 언급이 들어가면 안 된다
- Editor’s Choice 처럼 애플이 주는 명칭도 안 된다
- 앱 등급과 무관하게 자산 자체는 4+ 기준을 지켜야 한다
그리고 초점을 가운데 둬야 한다. 기기와 방향에 따라 잘리기 때문이다. 글자를 양옆 끝에 붙이면 어떤 기기에서는 안 보인다. 세이프 에어리어 수치는 규격 페이지에 없고 애플이 주는 Figma, Photoshop, Pixelmator, Sketch 템플릿 파일에만 들어 있다.
이번에도 AI 와 함께
이번엔 성격이 좀 달랐다.
테스트도 CI도 처음 해본다. 1.4.1까지 오면서 테스트 코드를 한 줄도 안 썼고, GitHub Actions는 열어본 적도 없다. 그러니까 이번엔 “내가 아는 걸 더 빨리”가 아니라 “내가 모르는 걸”이었다.
AI가 한 것은 테스트 코드, 워크플로 파일, 그리고 변이를 넣어 확인하는 절차다. Swift Testing의 arguments: 도, 러너에서 기기 이름 대신 조건으로 시뮬레이터를 고르는 방식도 내가 먼저 알고 꺼낸 게 아니다.
내가 한 것은 어디서부터 할지 정하는 일이었다. PaceFormatter 하나로 시작하자고 한 것, 테스트 타깃을 main이 아니라 따로 브랜치를 파서 올리자고 한 것, 1.5 토글은 로드맵이 더 남았으니 아직 합치지 말자고 한 것이 그렇다. 테스트 타깃 자체도 Xcode에서 내가 만들었다. 프로젝트 파일을 자동으로 건드리려다 안 되는 게 확인돼서 그 편이 안전했다.
자산 쪽도 같았다. 바닥 평면을 화면에 올리는 식도, 반복되는 영상의 이음매를 양 끝 페이드로 없애는 방법도 내가 알고 꺼낸 게 아니다. 내가 한 건 스크린샷을 오려 붙인 첫 결과를 물리고 다시 만들자고 한 것이다.
그리고 이번에 제일 크게 배운 건 변이 검증이다. 통과하는 테스트를 믿지 말고 일부러 망가뜨려 보라는 것. 이게 없었으면 getPaces 테스트가 아무것도 안 잡는다는 걸 몰랐을 것이고, format의 가드가 크래시 방지용이라는 것도 몰랐을 것이다. 테스트를 쓰는 법보다 이쪽이 오래 남을 것 같다.


