iOS에서 실행 중에 폰트를 등록할 때 정해야 하는 것

앱에 늘 들어 있는 폰트라면 실행 중에 등록할 일이 없어요. Xcode에 파일을 추가하고 Info.plist의 UIAppFonts에 선언하면 시스템이 실행할 때 불러와요. 앱에 커스텀 폰트를 추가하는 안내가 이 방법을 설명해요.

실행 중 등록을 고려할 만한 경우는 따로 있어요. 서버에서 내려받는 폰트, 테마나 언어나 콘텐츠에 따라 고르는 폰트, Swift Package나 framework나 별도 bundle에 든 폰트, 빌드한 뒤에 목록이 바뀌는 폰트, 테스트와 프리뷰에서 바꿔 끼우는 폰트예요. 두 방식은 요구에 맞춰 함께 써요.

아래는 API의 동작과 운영에서 정할 것을 예제 코드와 함께 정리한 것이에요. API가 어떻게 동작하는지는 Apple 문서에 있는 내용이에요. 등록을 한곳에서 관리하는 방식, 파일의 소유권과 삭제 순서, 파일 URL을 기준으로 묶는 운영 권고는 문서의 요구가 아니고 제 해석이에요. 예제 코드는 2026년 10월 4일에 macOS에서 컴파일해 실행해 봤고, 그 결과를 «macOS에서 실행해 본 결과» 절에 실었어요. iOS 앱에서 실행해 화면에 적용한 결과는 이 글에 없어요.

등록은 프로세스 안에서만 유지돼요

파일 URL을 CTFontManagerRegisterFontsForURL에 넘기면 그 파일의 폰트가 descriptor 검색에 들어가요. 그 뒤로는 UIFont(name:size:)나 Font.custom(_:size:)로 쓸 수 있어요. 앱 안에서만 쓸 폰트에는 scope로 CTFontManagerScope.process를 줘요. 이 등록은 프로세스가 끝나면 사라지므로 다음 실행에서 다시 등록해야 해요.

가장 단순한 등록 코드예요.

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
        }
    }
}

이 코드에는 빠진 것이 셋이에요. 파일이 있는지 검사하지 않고, 폰트의 이름을 알아내지 않고, 같은 파일을 두 번 등록할 때의 대응이 없어요.

파일 이름은 폰트 이름이 아니에요

Pretendard-Regular.otf라는 파일을 등록했다고 해서 Pretendard-Regular라는 이름으로 부를 수 있다고 확정할 수는 없어요. UIFont(name:size:)가 받는 것은 fully specified name이에요.

이름은 파일에서 직접 읽어요. CTFontManagerCreateFontDescriptorsFromURL로 descriptor를 얻고, kCTFontNameAttribute에서 PostScript name을 꺼내요. .ttc처럼 파일 하나에 face가 여러 개 든 경우가 있어서 결과는 배열이에요.

파일 안의 descriptor에서 PostScript name을 모으는 함수예요.

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)

실제로 실행한 출력은 «macOS에서 실행해 본 결과» 절에 있어요. 읽은 이름은 디자인 시스템이나 registry 한곳에 모아 둬요. 뷰마다 문자열을 직접 적으면 폰트를 바꾸거나 오류를 추적하기 어려워요.

등록 상태를 한곳에서 관리하는 예제

셀을 만들 때마다, SwiftUI의 body가 계산될 때마다, 화면에 들어올 때마다 등록을 호출하면 중복 등록 오류와 불필요한 비용이 생길 수 있어요. 그래서 등록을 맡는 객체를 하나 둬요. RuntimeFontRegistry.shared가 파일 검사, 이름 수집, URL별 등록 기록을 맡아요.

먼저 상태를 담는 부분이에요. URL을 키로, 그 파일에서 읽은 이름 배열을 값으로 저장해요. 이어지는 세 블록은 이 클래스의 나머지예요. 하나로 합칠 때에는 이 블록의 // …와 그 아래의 닫는 중괄호를 지우고 세 블록을 순서대로 이어 붙여요. 마지막 블록이 클래스를 닫고, 클래스 밖에 오류 타입을 선언해요.

import Foundation
import CoreText

final class RuntimeFontRegistry {
    static let shared = RuntimeFontRegistry()

    private var registeredURLs: [URL: [String]] = [:]
    private let lock = NSLock()

    private init() {}

    // …
}

등록 메서드는 standardizedFileURL로 키를 통일하고, 파일이 있는지 확인하고, descriptor와 이름이 유효한지 확인해요. 그다음 NSLock으로 캐시를 조회해서 이미 등록한 URL이면 저장된 이름을 돌려주고, 아니면 .process로 등록한 뒤 이름 배열을 캐시에 넣어요.

    @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
    }

해제 메서드는 등록할 때와 같은 scope로 CoreText를 호출하고 캐시에서 지워요.

    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()
    }

이름을 읽는 부분과 오류 타입이에요. 파일이 없는 경우, 폰트 파일이 아닌 경우, PostScript name이 없는 경우를 구분해요.

    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가 돌려주는 오류 코드의 해석은 추가로 해야 해요. 같은 URL을 다시 요청하면 성공으로 취급하고, 다른 파일이 같은 PostScript name을 가지면 충돌로 취급할 것을 권해요. 잘못됐거나 손상된 파일과, 중복이나 이름 충돌은 따로 처리해야 해요.

코드에서 읽히는 한계도 있어요. NSLock은 캐시를 조회할 때와 저장할 때에만 걸리고 CoreText 호출은 잠금 밖에 있어요. 그래서 같은 URL을 동시에 요청하면 두 호출이 모두 등록에 들어갈 수 있어요. 또 success가 false인데 error가 nil이면 예외를 던지지 않고 다음 줄로 넘어가요. 이 두 가지는 실행해서 재현한 것이 아니고 코드에서 읽은 것이에요.

내려받은 폰트는 파일의 수명을 정해야 해요

임시 위치의 파일을 등록하면, 파일이 지워진 뒤에도 시스템에 참조가 남을 위험이 있어요. 그래서 앱이 관리하는 위치로 옮긴 뒤에 등록해요.

Application Support 아래에 RuntimeFonts 디렉터리를 만드는 함수예요.

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
}

내려받은 파일을 그 디렉터리로 복사하고 registry에 등록하는 함수예요. 파일 이름은 temporaryURL의 마지막 경로 요소를 그대로 쓰고, 같은 이름의 파일이 있으면 지운 뒤 복사해요.

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)
}

이 설치 예제는 기존 파일을 지우기 전에 해제하지 않아요. 아래에서 권하는 «지우기 전에 해제»와 맞지 않는 부분이에요. 이미 등록한 URL의 파일을 바꿔 넣으면 registry가 예전에 캐시한 이름을 돌려줄 수도 있어요. 이것도 코드에서 읽은 한계예요. descriptor 검사는 복사한 뒤에 registry 안에서 하고, 다운로드의 무결성, 라이선스, 이름 충돌을 검사하는 코드는 없어요.

운영에서 정해야 할 것은 이 예제 밖에 있어요. 파일을 언제 지울지, 다시 실행했을 때 어떻게 등록할지, 다운로드가 실패했을 때의 fallback과 사용자에게 보여 줄 것, 서버 응답과 배포, 캐시 무효화, 앱 버전과의 관계, 라이선스 검토예요.

macOS에서 실행해 본 결과

이 글의 Swift 코드를 그대로 이어 붙여서 컴파일하고 실행해 봤어요. macOS 27.0.1에서 Swift 6.4로 실행했어요. CoreText는 macOS에도 있어서 iOS 기기 없이 등록과 해제를 확인할 수 있었어요. 다만 UIKit이 없는 환경이라 UIFont(name:size:) 대신 CTFontCreateWithName으로 이름을 확인했어요. 폰트는 이 Mac에 있던 PretendardStd-Bold.otf를 임시 폴더에 복사해서 썼고, 시스템에는 설치돼 있지 않은 폰트였어요.

코드는 경고 없이 컴파일됐어요. 등록 전후에 이름으로 폰트를 찾을 수 있는지를 보는 호출 코드를 덧붙여 실행한 출력이에요.

Font PostScript names: ["PretendardStd-Bold"]
before register: discoverable=false
registry.registerFont: ["PretendardStd-Bold"]
after register : discoverable=true
CTFontCreateWithName -> PretendardStd-Bold
registry.registerFont again (cached): ["PretendardStd-Bold"]
plain registerFont again: error com.apple.CoreText.CTFontManagerErrorDomain code=105
after unregister: discoverable=false
unregister again: error com.apple.CoreText.CTFontManagerErrorDomain code=201
missing file: fileNotFound(<url>)

읽을 수 있는 것은 넷이에요.

  • 등록 전에는 이름으로 찾을 수 없었고, 등록한 뒤에는 찾을 수 있었고, 해제한 뒤에는 다시 찾을 수 없었어요.
  • registry는 두 번째 등록에서 캐시한 이름을 돌려줬어요. registry를 거치지 않고 같은 URL을 다시 등록하면 code 105 오류가 났어요. CoreText의 kCTFontManagerErrorAlreadyRegistered에 해당하는 값이에요.
  • 해제를 두 번 하면 code 201(kCTFontManagerErrorNotRegistered) 오류가 났어요.
  • 없는 파일은 CoreText를 부르기 전에 fileNotFound로 걸러졌어요.

이미 시스템에 설치된 폰트 파일로 같은 실행을 하면 등록 전과 해제 뒤에도 이름으로 찾을 수 있었어요. 다른 경로에 같은 폰트가 남아 있기 때문이에요. 등록 여부를 이름으로 판단하는 코드는 이런 경우를 구분하지 못해요. installDownloadedFont는 호출해 보지 않았어요.

해제는 검색에서 빼는 것까지예요

CTFontManagerUnregisterFontsForURL을 호출하면 그 폰트는 descriptor 검색에서 빠져요. 이미 만들어진 UIFont, CTFont, NSAttributedString, SwiftUI 뷰까지 곧바로 정리된다고 해석하면 안 돼요.

수명은 폰트의 쓰임에 따라 정해요. 실행 내내 필요한 폰트는 등록한 채로 둬요. 임시로 내려받은 리소스는 누가 소유하는지 정하고 그쪽에서 해제해요. 파일을 지우기 전에 해제를 시도하고, 해제에 실패하면 삭제를 미뤄요. 등록과 해제의 scope는 같아야 하므로 .process로 등록했으면 .process로 해제해요.

CTFontManagerRegisterGraphicsFont는 CGFont 객체를 등록하는 API예요. 파일에 든 폰트에는 URL 등록을 쓰라고 Apple 문서가 안내해요. 다시 확인한 문서(2026년 10월 4일)에는 이 API가 iOS 18.0과 macOS 15.0부터 폐기(deprecated)된 것으로 표시돼 있고, 대신 CTFontManagerCreateFontDescriptorsFromData나 CTFontManagerRegisterFontsForURL을 쓰라고 적혀 있어요. 파일 URL을 기준으로 보관, 등록, 해제, 캐시를 한 묶음으로 설계하는 편이 다루기 쉽다는 것은 제 판단이에요.

.process의 수명, URL 등록과 CGFont 등록 가운데 무엇을 고를지, 해제가 이미 만들어진 객체에 미치는 영향은 SDK와 OS 버전에 따라 확인이 필요해요. 오류 코드별 분기, 충돌 처리, 안전한 파일 교체와 캐시 무효화, 재시작 뒤의 등록, fallback 정책은 이 글에서 설명만 했고 예제로 구현하지 않았어요. 예제에 있는 것은 URL별 캐시와 단순한 파일 교체까지예요.