본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

Swift 패키지 하나, 앱과 플랫폼은 여럿

~16 min · packages-projects, shared-code, platform-fences, build-matrix, kit

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"iOS 코드는 컴파일조차 안 되는데, 테스트 443개가 네 번이나 따로 통과했어."

공유 코드를 두는 곳

애플 앱 가족에서 코드 공유는 두 가지 형태로 나타나. 한 제품 안에선 Mac 앱과 그 아이폰 앱이 모델, 전송 프로토콜, 클라이언트 코어를 나눠 써. 제품끼리는 앱 열두어 개가 같은 보안 강화 HTTP 전송, 문서 저장소, 아웃박스, 워치 연결을 나눠 쓰고. 가족은 두 경우 모두 복사본이 아니라 Swift 패키지로 풀어. 예를 들어 Mac 터미널 겸 에이전트 클라이언트에는 모듈이 둘 있어. 플랫폼 가드가 하나도 없는 프로토콜 모듈, 그리고 전송만 플랫폼마다 다른 클라이언트 코어 모듈이야. 전송은 Mac에선 자식 프로세스, 폰에선 집 게이트웨이로 가는 WebSocket이야. 폰 클라이언트가 생겼을 때 다시 짜지 않고 그 두 모듈을 그대로 링크했어.

가족 공유 킷은 제품이 여럿인 패키지 하나고(코어, 웹 호스트, 음성, 미디어, Mac 크롬, 워치 연결, 푸시, 서버, 서버 테스트 도우미), 앱들은 path로 가져다 써. 아주 작은 단일 파일 헬퍼 몇 개는 아직 복사본으로 나눠줘. 대신 동기화 스크립트와 사용처마다 둔 드리프트 테스트가 복사본을 바이트 단위로 똑같이 유지해. 어느 방식을 쓸지 정하는 기준은 단순해. 그 파일이 다른 킷 파일에 기대느냐야. 킷 파일은 킷에서 고치고, 사용처에선 절대 안 고쳐.

무엇이 컴파일될지는 #if가 정해

플랫폼마다 다른 코드는 #if os(macOS), #if canImport(UIKit) && !os(watchOS), #if canImport(WatchConnectivity) 같은 컴파일 조건으로 감싸. 이 조건은 실행할 때 코드를 건너뛰는 게 아니야. 지금 플랫폼에 해당하지 않는 코드는 아예 컴파일이 안 돼. 가족 킷이 스스로를 속인 지점이 바로 여기야. Mac에서 swift test는 패키지를 macOS용으로만 빌드해. 하루는 테스트 443개가 네 번이나 통과했어. 그런데 조건 안쪽의 iOS와 watchOS 코드에는 문제가 줄줄이 있었어. iOS에서 컴파일 안 되는 손목 enum, Swift 6가 거부한 델리게이트 준수, 데이터 경합으로 표시된 완료 핸들러가 있었고, watchOS엔 없는 API까지 쓰고 있었어. 마지막 건 커밋 30분 뒤에, 스위트가 통과한 채로 들어갔어.

확인하는 법은 제품마다 자기가 지원한다는 플랫폼으로 전부 빌드하는 거야. 패키지 디렉터리에서 일반 대상(generic destination)으로 xcodebuild를 돌려. -derivedDataPath를 같이 쓰면 매트릭스가 증분으로 돌아. 가족 매트릭스는 제품 아홉 개를 iOS용으로, 그중 여덟 개를 watchOS용으로, 모두 17번 빌드해. 처음엔 49초쯤, 다시 돌리면 24초쯤 걸려. 킷 자체 테스트 실행기가 이 매트릭스를 불러.

앱이 먼저, 킷은 지켜보기

앱이 기다려야 하는 공유 계층은 아무것도 출시 못 하는 공유 계층이야. 가족의 결론은 이래. 킷에 없는 기능은 앱이 바로 로컬로 구현해. 이때 앱 소유, 마이그레이션 대기라고 표시하고, 킷 복사본인 척하면 안 돼. 그러고 나서 킷을 계속 지켜봐. 킷 버전이 로컬 구현을 진짜로 대신할 수 있게 되면 그걸 가져다 쓰고, 중복을 지우고, 동작이 그대로인지 확인해. 잠깐의 중복은 괜찮아. 근데 표시 없는 중복은 안 돼. 아무도 모르는 복사본은 어떤 드리프트 검사에도 안 걸리거든.

Code

제품마다 지원하는 플랫폼 전부로 빌드하기(패키지 디렉터리에서 실행)·bash
#!/bin/zsh
set -euo pipefail
DERIVED="${TMPDIR}spark-kit-matrix"

typeset -A platforms
platforms=(
  SparkCore  "macOS iOS watchOS"
  SparkMac   "macOS"
  SparkPhone "iOS"
)

for product in ${(k)platforms}; do
  for platform in ${=platforms[$product]}; do
    log="${DERIVED}-${product}-${platform}.log"
    set +e
    xcodebuild -scheme "$product" -destination "generic/platform=$platform" \
      -derivedDataPath "$DERIVED" build > "$log" 2>&1
    code=$?
    set -e
    if (( code != 0 )) || grep -q '\*\* BUILD FAILED \*\*' "$log"; then
      print "HARD_FAIL $product on $platform (see $log)"; exit 1
    fi
    print "ok  $product  $platform"
  done
done
조건부 컴파일: 지금 플랫폼에 해당하지 않는 코드는 아예 컴파일이 안 돼·swift
// Sources/SparkPhone/Haptics.swift
#if canImport(UIKit) && !os(watchOS)
import UIKit

@MainActor
public enum CaptureHaptics {
    public static func saved() {
        UINotificationFeedbackGenerator().notificationOccurred(.success)
    }
}
#endif
// `swift test` on a Mac never type-checks the lines above.
// `xcodebuild -scheme SparkPhone -destination 'generic/platform=iOS' build` does.

External links

Exercise

SparkPhone에 Haptics 파일을 넣고, #if canImport(UIKit) 블록 안에 일부러 타입 에러를 하나 넣어. Mac에서 swift test를 돌려서 통과하는지 확인해. 매트릭스 스크립트를 SparkKit의 scripts/build-matrix.sh로 저장해서 돌려봐. iOS용 SparkPhone을 가리키는 HARD_FAIL로 실패하는지 확인해. 에러를 고친 뒤, 매트릭스를 처음 돌린 시간과 다시 돌린 시간을 적어.
Hint
clean 없이 매트릭스를 두 번 연달아 돌려. 두 번째 실행은 공유하는 derived data 폴더를 재사용해. xcodebuild가 스킴을 못 찾으면 지금 패키지 디렉터리에 있는지 확인해. xcodebuild는 플래그가 아니라 현재 디렉터리를 보고 패키지를 찾거든.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.