디자인 시안의 제목에는 커스텀 폰트가 적용되어 있고 로컬 디버깅에서도 정상적으로 표시되지만, 자동 빌드로 생성한 App을 테스트 기기에 설치하면 시스템 폰트로 조용히 대체되는 경우가 있습니다. 이런 문제는 대개 컴파일 오류를 일으키지 않습니다. 폰트가 타깃에 포함되지 않았거나, Info.plist의 상대 경로가 잘못되었거나, 코드에서 폰트 내부의 PostScript 이름 대신 파일명을 사용했을 수 있습니다. 가장 확실한 방법은 프로젝트 디렉터리를 계속 확인하는 것이 아니라, 클라우드 Mac에서 빌드를 완료한 뒤 최종 App 번들을 직접 검사하는 것입니다.
폰트 CI 게이트의 검사 항목 정의
폰트 게이트는 최소한 네 가지 질문에 답해야 합니다. 선언된 파일이 실제로 존재하는가, Core Text가 파일을 해석할 수 있는가, 내부 이름이 코드의 규칙과 일치하는가, 여러 파일에서 중복된 이름이 생성되는가입니다. 검사 대상은 소스의 Resources 폴더가 아니라 .app 디렉터리여야 합니다. 타깃 멤버십과 Copy Bundle Resources 단계에서 최종 결과가 달라질 수 있기 때문입니다.
| 검사 항목 | 실패가 의미하는 것 | 권장 조치 |
|---|---|---|
UIAppFonts 항목 |
빌드 설정에 폰트가 선언되지 않음 | 타깃의 Info 설정 수정 |
| App 번들 내부 파일 | 폰트가 산출물에 복사되지 않음 | 타깃 멤버십과 리소스 단계 확인 |
| PostScript 이름 | 코드에서 사용하는 이름이 잘못되었을 수 있음 | 폰트 디스크립터에서 실제 이름 읽기 |
| 이름 고유성 | 폰트 패밀리 또는 버전 충돌 발생 | 중복 파일을 제거하거나 명시적인 교체 관계 정의 |
화면에서 폰트가 “비슷해 보인다”는 이유만으로 검수를 통과시켜서는 안 됩니다. 폴백이 발생해도 짧은 영문 텍스트에서는 차이가 잘 드러나지 않을 수 있지만, 글자 폭이 달라지면 줄바꿈, 버튼 크기, 스크린샷 비교 결과에 영향을 줍니다.
저장소에는 ci/expected-fonts.txt 파일을 두고, 사용을 허용한 PostScript 이름을 한 줄에 하나씩 기록하는 것이 좋습니다. 이 파일은 코드에서 사용하는 이름과 리소스 파일 사이의 안정적인 계약이며, 개발자 컴퓨터에 설치된 모든 폰트를 기록하는 목록이 아닙니다.
최종 App 번들에서 실제 정보 추출
먼저 기존 빌드 절차로 App 번들을 생성한 뒤 그 경로를 검사 스크립트에 전달합니다. Debug와 Release는 서로 다른 리소스 설정을 사용할 수 있으므로, 릴리스 파이프라인에서는 실제 배포할 구성을 검사해야 합니다. 검증 전에 스크립트가 폰트를 시스템에 등록해서는 안 됩니다. 머신에 이미 설치된 같은 이름의 폰트가 App 번들의 파일 누락을 가릴 수 있기 때문입니다.
다음 Swift 스크립트는 Info.plist의 UIAppFonts를 읽고, 각 파일이 존재하는지 확인한 다음 Core Text를 통해 내부 이름을 추출합니다. 이 스크립트를 ci/check_fonts.swift로 저장합니다.
import Foundation
import CoreText
let arguments = CommandLine.arguments
guard arguments.count == 2 else {
FileHandle.standardError.write(Data("usage: check_fonts.swift /path/App.app
".utf8))
exit(64)
}
let appURL = URL(fileURLWithPath: arguments[1], isDirectory: true)
let plistURL = appURL.appendingPathComponent("Info.plist")
guard
let data = try? Data(contentsOf: plistURL),
let plist = try? PropertyListSerialization.propertyList(from: data) as? [String: Any],
let declared = plist["UIAppFonts"] as? [String],
!declared.isEmpty
else {
FileHandle.standardError.write(Data("UIAppFonts is missing or empty
".utf8))
exit(1)
}
var failed = false
var seen = Set<String>()
for relativePath in declared {
let url = appURL.appendingPathComponent(relativePath)
guard FileManager.default.fileExists(atPath: url.path) else {
FileHandle.standardError.write(Data("missing: \(relativePath)
".utf8))
failed = true
continue
}
let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] ?? []
if descriptors.isEmpty {
FileHandle.standardError.write(Data("unreadable: \(relativePath)
".utf8))
failed = true
}
for descriptor in descriptors {
let value = CTFontDescriptorCopyAttribute(descriptor, kCTFontNameAttribute)
guard let name = value as? String, !name.isEmpty else {
FileHandle.standardError.write(Data("unnamed: \(relativePath)
".utf8))
failed = true
continue
}
if !seen.insert(name).inserted {
FileHandle.standardError.write(Data("duplicate: \(name)
".utf8))
failed = true
}
print("\(name) \(relativePath)")
}
}
exit(failed ? 1 : 0)
실행할 때는 빌드 산출물의 실제 경로를 사용합니다.
xcrun swift ci/check_fonts.swift "$APP_PATH" | sort > build/actual-fonts.txt
diff -u ci/expected-fonts.txt build/actual-fonts.txt
허용 목록에 이름만 저장한다면 출력 뒤에 cut -f1을 추가할 수 있습니다. 핵심은 차이가 발견되었을 때 0이 아닌 종료 코드를 반환하여 파이프라인을 즉시 중단하는 것입니다.
파일명이 아닌 PostScript 이름 고정
Headline-Bold.otf는 디스크에 저장된 파일명일 뿐이며, 폰트 내부 이름은 HeadlinePro-Bold일 수 있습니다. SwiftUI의 Font.custom, UIKit의 폰트 초기화 코드, 테스트 코드에서는 모두 내부 이름을 사용해야 합니다. 폰트 파일의 이름을 바꿔도 내부 이름은 자동으로 변경되지 않지만, 공급업체가 폰트 버전을 업데이트하면 내부 이름이 달라질 수 있습니다.
이름 변경에 명시적인 검토 절차 적용
actual-fonts.txt가 허용 목록과 일치하지 않더라도 파이프라인에서 새 값을 자동으로 승인해서는 안 됩니다. 먼저 계획된 업그레이드로 인한 변경인지 확인한 다음, 프로젝트 전체에서 해당 폰트 이름을 사용하는 코드를 검색합니다. 폰트 이름은 하나의 소스 상수 파일에 모아 두어 뷰 코드와 테스트 픽스처 곳곳에 흩어지지 않도록 할 수 있습니다.
하나의 폰트 파일에 여러 디스크립터가 포함될 수도 있습니다. 스크립트는 첫 번째 항목만 가져오지 말고 모든 이름을 유지해야 합니다. 팀에서 가변 폰트를 실제로 사용한다면 자주 쓰는 굵기에 대한 UI 수준의 스모크 테스트도 추가하여 축 설정이 디자인 기준과 일치하는지 확인해야 합니다.
런타임 및 UI 검수 보완
정적 게이트는 리소스 구조가 올바르다는 사실은 증명할 수 있지만, 모든 화면에서 정확한 굵기를 사용한다는 것까지 보장하지는 못합니다. 테스트 타깃에서만 사용하는 폰트 목록 화면을 추가해 제목, 본문, 숫자, 한국어 문장부호, 긴 영단어를 모두 표시하고, 고정된 화면 크기의 시뮬레이터에서 스크린샷 검수를 수행하는 것이 좋습니다.
런타임 어설션에서는 point size만 비교하지 말고 폰트 인스턴스의 fontName을 검사해야 합니다. 동적 폰트를 사용하는 경우 최소 두 단계의 콘텐츠 크기 카테고리를 테스트하여 확대 시 잘림이 발생하는지도 확인해야 합니다. 폰트가 없을 때 UI에서 폴백을 허용한다면 해당 로직을 코드에 명시적으로 기록해야 하며, 핵심 브랜드 폰트는 테스트 환경에서 즉시 실패하도록 해야 합니다.
병렬 테스트에서는 각 작업이 별도의 빌드 디렉터리와 결과 디렉터리를 사용해야 합니다. 그래야 한 브랜치에서 생성한 허용 목록이 다른 브랜치의 목록을 덮어쓰지 않습니다. 폰트 파일은 입력 리소스이므로 빌드 스크립트가 원본 위치에서 직접 수정해서는 안 됩니다.
재현 가능한 파이프라인에 검사 통합
전체 실행 순서는 격리된 빌드 디렉터리 정리, 대상 구성 빌드, 유일한 App 번들 찾기, 폰트 구조 검사, 허용 목록 비교, UI 테스트 실행 순이어야 합니다. 산출물을 선택할 때 모호한 find | head -1을 사용해서는 안 됩니다. 같은 디렉터리에 여러 App이 있으면 테스트 호스트나 이전 빌드를 잘못 검사할 수 있습니다.
게이트가 실패하면 actual-fonts.txt, App 번들 내부의 폰트 파일 목록, 관련 빌드 로그를 보존해야 하지만 불필요한 자격 증명은 업로드하지 않아야 합니다. 문제를 조사할 때는 먼저 누락된 파일을 확인하고, 그다음 UIAppFonts, 마지막으로 내부 이름을 확인합니다. 이 순서를 따르면 “패키징되지 않음”과 “호출 이름이 잘못됨”을 빠르게 구분할 수 있습니다.
폰트 업그레이드가 승인되면 같은 변경 사항에 폰트 파일, 허용 목록, 중앙화된 이름 상수, 스크린샷 기준선을 함께 커밋해야 합니다. 그러면 어떤 커밋을 롤백하더라도 완전한 상태로 복원할 수 있으며, 클라우드 Mac의 빌드 결과가 특정 개발자 머신에 미리 설치된 폰트에 의존하지 않게 됩니다.
자주 묻는 질문
프로젝트 폴더에 폰트가 있는지만 확인하면 안 되나요?
대상 멤버십이나 리소스 복사 단계가 빠지면 최종 App에 포함되지 않습니다. 따라서 빌드된 번들과 UIAppFonts 항목을 직접 확인해야 합니다.
SwiftUI custom 폰트에는 파일 이름을 사용하나요?
항상 그렇지는 않습니다. 보통 폰트 내부의 PostScript 이름이 필요하므로 설명자에서 읽은 이름을 허용 목록과 비교해야 합니다.
CI의 어느 단계에서 폰트 검사를 실행해야 하나요?
설치 가능한 App 번들을 만든 뒤 업로드나 배포 단계로 넘어가기 전에 실행하는 것이 적절합니다.
클라우드에서 Mac mini 워크플로를 계속 실행하세요
RunAMac M4와 대여 기간, 배포 노드를 선택하고 개발, 빌드 또는 실험 작업을 전용 물리 머신에서 실행하세요.