iOS 런타임 폰트 등록, CTFontManagerRegisterFontsForURL 실무 정리
iOS 앱에서 커스텀 폰트를 쓰는 가장 익숙한 방식은 Info.plist의 UIAppFonts에 폰트 파일을 등록하는 것이다. 앱 번들에 고정된 폰트라면 이 방식이 가장 단순하다.
하지만 모든 폰트가 빌드 시점에 결정되는 것은 아니다. 서버에서 내려받은 폰트, Swift Package나 별도 번들에 들어 있는 폰트, 특정 화면이나 콘텐츠에서만 필요한 폰트는 런타임에 등록하는 방식이 더 적합할 수 있다. 이때 사용하는 CoreText API가 CTFontManagerRegisterFontsForURL과 CTFontManagerUnregisterFontsForURL이다.
이번 글에서는 이 API를 iOS 앱에서 어떻게 다뤄야 하는지 정리해본다. 단순히 “등록 함수 한 번 호출하면 된다”로 끝낼 수 있는 주제는 아니다. 폰트 이름, 등록 scope, 중복 등록, 해제 시점, 캐싱 정책까지 같이 봐야 한다.
UIAppFonts와 런타임 등록은 목적이 다르다
먼저 구분이 필요하다.
앱에 항상 포함되는 브랜드 폰트라면 UIAppFonts가 더 낫다. Xcode 프로젝트에 폰트 파일을 추가하고, Info.plist에 파일명을 선언하면 시스템이 앱 실행 시 해당 폰트를 로드한다. 코드에서 별도 등록 로직을 관리하지 않아도 된다.
반대로 런타임 등록은 다음 상황에서 의미가 있다.
- 서버에서 폰트 파일을 내려받아야 한다.
- 특정 테마, 언어, 콘텐츠에서만 폰트를 사용한다.
- 앱 본체가 아니라 Swift Package, framework, 별도 bundle에 폰트가 들어 있다.
- 폰트 목록이 빌드 시점에 고정되지 않는다.
- 테스트나 프리뷰 환경에서 폰트를 동적으로 바꿔야 한다.
여기서 중요한 건 유지보수 기준이다. 모든 커스텀 폰트를 런타임 등록으로 처리할 필요는 없다. 앱 전체에서 항상 쓰는 폰트는 정적으로 선언하고, 실제로 동적인 요구가 있는 폰트만 CoreText로 등록하는 쪽이 관리하기 좋다.
CTFontManagerRegisterFontsForURL의 기본 흐름
CTFontManagerRegisterFontsForURL은 특정 font file URL의 폰트를 Font Manager에 등록한다. 등록된 폰트는 font descriptor matching 대상이 된다. UIKit의 UIFont(name:size:), SwiftUI의 Font.custom(_:size:)에서 사용할 수 있는 상태가 된다고 보면 된다.
iOS 앱 내부에서만 사용할 폰트라면 일반적으로 CTFontManagerScope.process를 사용한다. 이 scope는 현재 프로세스 동안 폰트를 사용할 수 있게 한다. 앱이 종료되면 등록 상태도 사라진다. 그래서 앱을 다시 실행하면 다시 등록해야 한다.
기본 코드는 다음과 같다.
import CoreText
func registerFont(at url: URL) throws {
var error: Unmanaged<CFError>?
let success = CTFontManagerRegisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() {
throw error
}
}
}
이 코드는 동작은 하지만 실무 코드로는 부족하다. 파일 존재 여부도 확인하지 않고, 폰트 이름도 알 수 없고, 중복 등록도 처리하지 않는다. 특히 UIFont(name:size:)에 넣어야 하는 이름은 파일명이 아니다.
예를 들어 파일명이 Pretendard-Regular.otf라고 해서 항상 Pretendard-Regular를 그대로 쓰면 된다고 가정하면 안 된다. 폰트 내부의 PostScript name을 확인해야 한다.
파일명보다 중요한 것은 PostScript name이다
커스텀 폰트 적용에서 자주 발생하는 실수는 파일명을 폰트명으로 착각하는 것이다.
UIFont(name:size:)는 “폰트 파일명”을 받는 API가 아니다. 폰트의 fully specified name을 기준으로 font object를 만든다. CoreText 관점에서는 kCTFontNameAttribute를 통해 font descriptor의 PostScript name을 확인할 수 있다.
런타임 등록을 안정적으로 하려면 등록 전에 CTFontManagerCreateFontDescriptorsFromURL로 font descriptor를 읽고, 그 안에서 PostScript name을 추출하는 것이 좋다. .ttc처럼 하나의 파일 안에 여러 face가 들어 있는 경우도 있기 때문이다.
import CoreText
func postScriptNames(in url: URL) -> [String] {
guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor] else {
return []
}
return descriptors.compactMap {
CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
}
}
이 값을 로그로 남겨두면 디버깅이 훨씬 편해진다.
let names = postScriptNames(in: fontURL)
print("Font PostScript names:", names)
실제 앱에서는 이 이름을 디자인 시스템이나 font registry에서 관리하는 편이 좋다. 뷰 코드 곳곳에서 문자열로 직접 UIFont(name:size:)를 호출하면 폰트 교체나 오류 추적이 어려워진다.
실무에서는 FontRegistry를 두는 편이 안전하다
런타임 폰트 등록은 한 번만 하면 된다. 셀 생성, SwiftUI body, 화면 진입 시점마다 반복해서 호출하면 중복 등록 에러와 불필요한 비용이 생긴다.
그래서 실무에서는 폰트 등록을 담당하는 작은 registry를 두는 편이 좋다.
import Foundation
import CoreText
final class RuntimeFontRegistry {
static let shared = RuntimeFontRegistry()
private var registeredURLs: [URL: [String]] = [:]
private let lock = NSLock()
private init() {}
@discardableResult
func registerFont(at url: URL) throws -> [String] {
let url = url.standardizedFileURL
guard FileManager.default.fileExists(atPath: url.path) else {
throw FontRegistrationError.fileNotFound(url)
}
let names = try Self.readPostScriptNames(from: url)
lock.lock()
if let cached = registeredURLs[url] {
lock.unlock()
return cached
}
lock.unlock()
var error: Unmanaged<CFError>?
let success = CTFontManagerRegisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() as Error? {
throw error
}
}
lock.lock()
registeredURLs[url] = names
lock.unlock()
return names
}
func unregisterFont(at url: URL) throws {
let url = url.standardizedFileURL
var error: Unmanaged<CFError>?
let success = CTFontManagerUnregisterFontsForURL(
url as CFURL,
.process,
&error
)
if !success {
if let error = error?.takeRetainedValue() as Error? {
throw error
}
}
lock.lock()
registeredURLs.removeValue(forKey: url)
lock.unlock()
}
private static func readPostScriptNames(from url: URL) throws -> [String] {
guard let descriptors = CTFontManagerCreateFontDescriptorsFromURL(url as CFURL) as? [CTFontDescriptor],
!descriptors.isEmpty else {
throw FontRegistrationError.invalidFontFile(url)
}
let names = descriptors.compactMap {
CTFontDescriptorCopyAttribute($0, kCTFontNameAttribute) as? String
}
guard !names.isEmpty else {
throw FontRegistrationError.missingPostScriptName(url)
}
return names
}
}
enum FontRegistrationError: Error {
case fileNotFound(URL)
case invalidFontFile(URL)
case missingPostScriptName(URL)
}
이 정도만 해도 중복 호출과 폰트명 추적 문제를 많이 줄일 수 있다. 실제 서비스 코드에서는 여기에 CoreText 에러 코드를 해석하는 로직을 추가하는 것이 좋다.
예를 들어 이미 등록된 폰트라면 실패로 볼지, 성공으로 간주할지 결정해야 한다. 대부분의 앱 내부 registry에서는 같은 URL을 다시 등록하려는 상황을 성공으로 처리하는 편이 자연스럽다. 반면 동일한 PostScript name을 가진 다른 폰트 파일이 들어오는 경우는 충돌로 보는 것이 맞다.
다운로드 폰트는 저장 위치까지 설계해야 한다
서버에서 폰트를 내려받아 등록하는 경우에는 파일 위치가 중요하다.
임시 디렉터리에 저장한 폰트를 등록하고, 나중에 시스템이 그 파일을 참조해야 하는 상황에서 파일이 삭제되면 문제가 생길 수 있다. 폰트 파일은 앱이 관리하는 안정적인 위치에 저장한 뒤 등록하는 편이 좋다. 보통 Application Support 하위에 앱 전용 디렉터리를 만들고 관리한다.
func fontStorageDirectory() throws -> URL {
let baseURL = try FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = baseURL.appendingPathComponent("RuntimeFonts", isDirectory: true)
if !FileManager.default.fileExists(atPath: directory.path) {
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
}
return directory
}
다운로드 후에는 파일을 검증하고, 저장하고, 등록한다.
func installDownloadedFont(from temporaryURL: URL) throws -> [String] {
let directory = try fontStorageDirectory()
let destinationURL = directory.appendingPathComponent(temporaryURL.lastPathComponent)
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try FileManager.default.copyItem(at: temporaryURL, to: destinationURL)
return try RuntimeFontRegistry.shared.registerFont(at: destinationURL)
}
여기서도 중요한 건 정책이다. 폰트 파일을 언제 삭제할지, 앱 재실행 시 어떻게 다시 등록할지, 다운로드 실패 시 fallback 폰트를 무엇으로 둘지 정해야 한다.
런타임 폰트는 UI 문제이기도 하지만 운영 문제이기도 하다. 서버 응답, 캐시 무효화, 앱 버전, 라이선스 정책까지 같이 봐야 한다.
Unregister는 가능하지만 남용할 기능은 아니다
CTFontManagerUnregisterFontsForURL은 등록된 font URL을 Font Manager에서 해제한다. 해제된 폰트는 더 이상 font descriptor matching 대상이 아니다.
하지만 앱 코드에서 이미 만들어진 UIFont, CTFont, NSAttributedString, SwiftUI view가 있을 수 있다. 따라서 unregister를 호출했다고 해서 이미 생성된 모든 UI 객체가 즉시 안전하게 정리된다고 가정하면 안 된다.
실무적으로는 다음 기준이 낫다.
- 앱 실행 중 계속 필요한 폰트는 unregister하지 않는다.
- 일시적으로 필요한 다운로드 폰트만 명확한 소유권을 두고 unregister한다.
- 파일 삭제 전에는 unregister를 시도한다.
- unregister 실패 시 파일 삭제를 무리하게 진행하지 않는다.
- 같은 scope로 등록하고 같은 scope로 해제한다.
등록할 때 .process를 썼다면 해제할 때도 .process를 써야 한다. scope가 달라지면 기대한 폰트가 해제되지 않을 수 있다.
CTFontManagerRegisterGraphicsFont와 혼동하지 않기
CoreText에는 CTFontManagerRegisterGraphicsFont도 있다. 이름만 보면 비슷하지만 목적이 다르다.
파일에 기반한 폰트라면 CTFontManagerRegisterFontsForURL을 쓰는 것이 맞다. CTFontManagerRegisterGraphicsFont는 CGFont를 등록하는 API이고, 문서에서도 file-backed font는 URL 기반 등록 API를 사용하라고 안내한다.
앱에서 폰트 파일을 직접 가지고 있다면 URL 기반으로 처리하는 쪽이 구조가 명확하다. 파일 URL을 기준으로 저장, 등록, 해제, 캐싱을 관리할 수 있기 때문이다.
그래서 무엇부터 보면 좋을까
런타임 폰트 등록을 도입하기 전에 먼저 아래 항목을 확인하는 것이 좋다.
- 앱에 포함된 고정 폰트와 런타임 등록이 필요한 폰트를 구분한다.
- 고정 폰트는
UIAppFonts로 처리할 수 있는지 먼저 확인한다. - 런타임 폰트는 파일 저장 위치를
Application Support등으로 명확히 정한다. - 등록 전에
CTFontManagerCreateFontDescriptorsFromURL로 PostScript name을 읽는다. UIFont(name:size:)나Font.custom(_:size:)에는 파일명이 아니라 실제 폰트 이름을 사용한다.- 폰트 등록은 view 코드가 아니라 registry 계층에서 한 번만 수행한다.
- 중복 등록, 잘못된 파일, 손상된 파일, 이름 충돌에 대한 에러 처리를 분리한다.
- 앱 재실행 시 필요한 폰트를 다시 등록하는 흐름을 만든다.
- 다운로드 폰트라면 fallback 폰트와 실패 UX를 준비한다.
- unregister가 필요한 경우 등록 scope와 해제 scope를 동일하게 관리한다.
- 폰트 라이선스, 캐시 정책, 서버 배포 정책을 함께 확인한다.
마무리
CTFontManagerRegisterFontsForURL은 런타임에 폰트를 등록할 수 있게 해주는 유용한 API다. 하지만 실제 앱에 넣을 때는 단순 유틸 함수 하나로 끝내기 어렵다.
핵심은 폰트 파일을 안정적인 위치에 두고, PostScript name을 확인하고, 등록 상태를 앱 내부에서 관리하는 것이다. 그리고 앱에 항상 포함되는 폰트까지 무리하게 런타임 등록으로 바꿀 필요는 없다.
먼저 프로젝트의 폰트 사용 방식을 정리해보는 것이 좋다. 어떤 폰트는 정적으로 선언하고, 어떤 폰트는 런타임 등록으로 가져갈지 나누는 것부터 시작하면 된다.
출처
-
Apple Developer Documentation - CTFontManagerRegisterFontsForURL(::_:)
- font URL을 Font Manager에 등록하고 descriptor matching 대상으로 만드는 API 동작을 참고했다.
-
Apple Developer Documentation - CTFontManagerUnregisterFontsForURL(::_:)
- 등록된 font URL을 해제하고 descriptor matching 대상에서 제외하는 동작을 참고했다.
-
Apple Developer Documentation - CTFontManagerCreateFontDescriptorsFromURL(_:)
- font URL에 포함된 각 폰트의 descriptor를 읽어오는 방식과 PostScript name 추출 흐름을 참고했다.
-
Apple Developer Documentation - CTFontManagerScope.process
- 현재 프로세스 범위에서 폰트를 등록하는 scope의 의미를 참고했다.
-
Apple Developer Documentation - kCTFontNameAttribute
- font descriptor에서 사용하는 폰트 이름 attribute를 참고했다.
-
Apple Developer Documentation - UIFont init(name:size:)
UIFont(name:size:)가 받는 font name의 의미를 참고했다.
-
Apple Developer Documentation - UIAppFonts
- 앱 번들에 포함된 app-specific font files를 시스템이 런타임에 로드하는 Info.plist 키를 참고했다.
-
Apple Developer Documentation - Adding a custom font to your app
- 앱에 커스텀 폰트를 추가하고 사용하는 기본 흐름을 참고했다.
-
Apple Developer Documentation - CTFontManagerRegisterGraphicsFont(::)
- file-backed font는 URL 기반 등록 API를 사용해야 한다는 구분을 참고했다.
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.