최근에 간단한 작업을 하면서 타입 안전성(Type Safety)을 높이기 위해 고민하고 구현한 과정을 기록해 보려 합니다.
단순히 "동작하는 코드"를 넘어, "컴파일러 레벨에서 실수를 차단하고 빌드 시스템을 효율적으로 제어하는 법"에 관심이 있는 iOS 개발자분들께 도움이 되었으면 좋겠습니다
0. 시작점: 왜 Type-Safe ID가 필요했는가?
가전제품 쇼핑몰 앱을 만든다고 가정해 봅시다. 앱에는 다음과 같이 다양한 가전 카테고리 모델들이 존재합니다.
TV(텔레비전)WashingMachine(세탁기)AirConditioner(에어컨)
가볍게 생각하면 각 가전제품의 요소를 구별하기 위한 식별자로 UUID를 떠올리게 됩니다.
그래서 각 가전제품의 식별자(ID) 타입을 단순히 UUID로 지정하면 다음과 같은 실수의 가능성이 존재하게 됩니다.
struct TV {
let id: UUID
}
// TV를 삭제하는 함수
func deleteTV(id: UUID) { ... }
struct WashingMachine {
let id: UUID
}
let myWashingMachine = WashingMachine(id: UUID())
// ❌ 가전제품 카테고리 ID가 잘못 입력될 수 있는 가능성 존재
// ⚠️ TV를 삭제하는 함수에 세탁기 id가 들어갔지만 타입이 동일한 UUID로 문제를 발견하지 못합니다.
deleteTV(id: myWashingMachine.id)
myWashingMachine.id도 UUID이고, 함수가 원하는 id도 UUID이기 때문에 컴파일러는 에러를 내지 않고 빌드를 패스합니다.
하지만 실제 운영 환경에서는 TV를 삭제하려다 엉뚱한 세탁기 정보가 꼬이는 상황이 생길 수 있습니다.
이러한 실수를 방지하기 위한 방법으로 타입에 안전한 식별자(Type-Safe ID) 를 적용하려 했습니다.
Phase 1. 가장 원초적인 방법: 중복 구조체 작성 (Boilerplate)
가장 단순하고 원초적인 방법은 각 모델마다 내부에 고유한 ID 구조체를 일일이 타이핑하는 것입니다.
public struct TV {
public struct ID: Hashable, Sendable {
public let rawValue: UUID
public init(rawValue: UUID = UUID()) {
self.rawValue = rawValue
}
}
public let id: ID
}
public struct WashingMachine {
public struct ID: Hashable, Sendable {
public let rawValue: UUID
public init(rawValue: UUID = UUID()) {
self.rawValue = rawValue
}
}
public let id: ID
}
이제 삭제 함수는 고유한 타입을 요구하게 됩니다.
func deleteTV(id: TV.ID) { ... }
// 🛑 컴파일 에러 발생! (실수 원천 차단)
deleteTV(id: myWashingMachine.id)
// Error: Cannot convert value of type 'WashingMachine.ID' to expected argument type 'TV.ID'
🤦♂️ 하지만 고통스러운 상황
- 타입 안전성은 보장됩니다.
- 하지만 모든 대상에 동일한 6줄의 ID 정의 코드(Boilerplate) 가 필요하게 됩니다
Phase 2. 우회책: 제네릭과 팬텀 타입 (Phantom Type)
"이 중복 구조체 코드를 하나로 통일할 수 없을까?"
Swift의 제네릭(Generic)과 팬텀 타입(Phantom Type)을 활용했습니다.
// Core/Identifier.swift (공통 폴더에 딱 한 번 선언)
public struct Identifier<Value>: Hashable, Sendable {
public let rawValue: UUID
public init(rawValue: UUID = UUID()) {
self.rawValue = rawValue
}
}
이 제네릭 구조체를 사용하여 각 모델의 ID를 다음과 같이 정의했습니다.
public struct TV {
public typealias ID = Identifier<TV> // ➔ 한 줄로 대체
public let id: ID
}
public struct WashingMachine {
public typealias ID = Identifier<WashingMachine> // ➔ 한 줄로 대체
public let id: ID
}
💡 왜 팬텀 타입(Phantom Type)인가?
Identifier<Value> 내부에서 제네릭 매개변수 Value는 실제 데이터나 프로퍼티로 전혀 사용되지 않습니다.
이 방법은 특정 Swift 기술이 아니라, 하스켈(Haskell), 러스트(Rust), 스칼라(Scala) 등 강한 타입 시스템을 갖춘 프로그래밍 언어들에서 쓰여 온 패턴입니다.
실제 변수로는 존재하지 않고 오직 컴파일러에게 이름표 역할만 해준다고 해서 유령(Phantom) 타입이라 부릅니다.
- 🥤 현실 세계 비유 (이름표가 붙은 컵):
동일하게 생긴 두 개의 종이컵(실제 메모리를 차지하는 실물 데이터UUID)에 네임펜으로 각각 "가솔린", "마실 물"이라는 가상의 이름표(Value제네릭 타입)를 붙인 것과 같습니다. 두 컵은 물리적으로 똑같지만, 이름표 덕분에 우리는 가솔린을 커피머신에 붓는 대형 사고를 피할 수 있습니다.
Phase 3. 자동화 유혹: Swift 매크로 직접 구현
제네릭 방식도 훌륭했지만, 자동화를 위한 발표된 기술인 Swift 매크로(Swift Macro)를 사용해보기로 했습니다.
목표는 모델 선언 위에 @TypeSafeID 마크만 붙이면 컴파일 시점에 public struct ID 코드를 컴파일러가 대신 적어주게 만드는 것이었습니다.
@TypeSafeID
public struct TV {
public let id: ID
}
구현 과정
1. 매크로 호출 선언부
- 컴파일러에게
TypeSafeID매크로가 있고, 이 매크로를 위해서 외부 컴파일러 플러그인을 사용하겠다는 내용의 코드를 작성합니다.
// Macro.swift
import Foundation
@attached(member, names: named(ID))
public macro TypeSafeID() = #externalMacro(
module: "itopMacros",
type: "TypeSafeIDMacro"
)
2. 실제 코드를 생성하는 매크로 플러그인
컴파일러가 멤버 위치에 문자열을 주입하도록 하는 코드입니다.
// `TypeSafeIDMacro.swift`
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct TypeSafeIDMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
let idDeclaration: DeclSyntax = """
public struct ID: Hashable, Sendable {
public let rawValue: UUID
public init(rawValue: UUID = UUID()) {
self.rawValue = rawValue
}
}
"""
return [idDeclaration]
}
}
3. 컴파일러에 매크로를 등록해 주는 진입점
컴파일러에 TypeSafeIDMacro 클래스를 제공하겠다고 알려주는 코드입니다.
// Plugin.swift
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct ITOPMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
TypeSafeIDMacro.self
]
}
매크로 구현 중 트러블슈팅
Tuist에서 매크로 패키지 -> 타겟 사용
애플의 Xcode는 공식적으로 Xcode 프로젝트 내부에 'Swift 매크로 타겟'을 지원하지 않습니다. Swift Package Manager(SPM) 패키지로만 만들 수 있도록 합니다.
하지만 우리는 프로젝트를 Tuist로 관리하고 있었기에 Tuist쪽 문서를 찾아보기로 했습니다.
"Apple이 공식적으로 매크로 타겟을 지원하지 않기 때문에, 우리는 이를 macOS Command Line Tool 타겟으로 우회 빌드한 뒤 빌드 세팅에 컴파일러 플러그인 로드 플래그를 자동으로 심는 방식으로 구현 했습니다."
Tuist가 Xcode를 속여주는 편법을 쓰고 있었습니다.Project.swift 에 product: .macro 으로 설정해서 사용할 수 있습니다.
이제 Swift Syntax 의존성 등록을 해야합니다.
Tuist/Package.swift에 apple/swift-syntax.git을 등록합니다.https://github.com/swiftlang/swift-syntax에서 확인할 수 있습니다.
dependencies: [
.package(url: "https://github.com/swiftlang/swift-syntax.git", from: "509.0.0"),
],
Project.swift에서 매크로 타겟의 dependencies에 .external(name: "SwiftSyntaxMacros")와 .external(name: "SwiftCompilerPlugin")을 엮어주었습니다.
이후 tuist install과 tuist generate로 실행하면 됩니다.
⚖️ 실무적 관점
1. 런타임 성능 및 메모리 관점
제네릭을 쓰면 방법과 매크로를 쓰는 방법, 두가지 방법에서 성능에 차이가 있는지 궁금했습니다. 결론은 컴파일 타임을 제외한 순수 앱 구동 성능은 동일합니다.
- 메모리 점유: 두 방식 모두 메모리 상에는 순수한 16바이트 UUID 값 자체로만 기록됩니다.
- CPU 속도: 현재 방식에서는 제네릭이지만 구체적인 타입을 명시해서 호출하기 때문에 런타임 실행 속도나 CPU 연산 비용 차이가 존재하지 않습니다.
2. 컴파일러 동작
- 컴파일러 연산:
- 제네릭 방식: 컴파일러가
Identifier<TV>를 읽으면 *"제네릭 틀을 가져와서 TV 타입에 맞게 구체 코드로 해독해야지"* 하고 머리를 굴리는 연산 단계가 추가됩니다. 하지만 매우 미미합니다. - 매크로 방식: 이미 매크로 로봇이 글자판으로
struct ID를 다 찍어서 대령해 놨기 때문에 컴파일러는 다른 연산 과정 없이 곧바로 컴파일합니다.
- 제네릭 방식: 컴파일러가
- 빌드 오버헤드:
- 제네릭 방식: 머리는 조금 더 쓰지만 모든 작업이 컴파일러 메모리 내부에서 즉시 처리되므로 실제 빌드 속도는 압도적으로 빠릅니다.
- 매크로 방식: 머리는 덜 쓰지만, 컴파일러가 매번 매크로 외부 의존성을 통해서 소스코드를 주고받고, 글자를 반환받을 때까지 대기해야 하는 컴파일러 외부 의존성 오버헤드가 큽니다. 결정적으로 거대한
swift-syntax패키지를 컴파일하는 시간이 추가되어 실제 빌드 속도는 매우 느려집니다.
3. 개발 생산성과 Tuist의 캐싱 솔루션
빌드 속도가 느려진다는 것은 피드백 루프가 길어져 개발자의 생산성에 큰 손해가 됨을 의미합니다. 고작 코드 한 줄 안 타이핑하려다가 매번 빌드할 때 수십 초씩 기다리는 것은 매우 비효율입니다.
실무적인 관점에서 빌드 시간이 늘어나는 것을 피하기 위해 단순히 Identifier<Self> 형태로 정의해 쓰는 것이 생산성 면에서 훨씬 이로울 수 있습니다.
Tuist는 이 빌드 타임 문제를 바이너리 캐싱(tuist cache)으로 해결하려 합니다.
- 한 번 빌드된 매크로 실행 파일을 이미 완제품(바이너리) 상태로 캐시에 박아두어, 개발자나 CI 서버가 프로젝트를 빌드할 때 무거운
swift-syntax를 매번 처음부터 컴파일하지 않고 캐싱된 결과물을 쏙 빼다 쓰게 만듭니다. 이렇게 하면 매크로의 빌드 타임 패널티를 지울 수 있습니다.
새로 나온 매력적인 기술이 항상 정답은 아닙니다.
멋진 매크로 어노테이션 뒤에는 무거운 의존성(SwiftSyntax)이 반전으로 숨겨져 있습니다.
기술을 보는 눈을 한 층 넓혀준 유의미한 경험이었습니다.
'programming > Swift(iOS)' 카테고리의 다른 글
| [Swift/iOS] DispatchQueue와 Task의 차이 — 두 AI와 대화로 정리한 Swift 동시성 (0) | 2026.05.23 |
|---|---|
| [Swift/iOS] (Base) XML Parser를 만드는 과정, 설계부터 고민까지 (0) | 2026.05.09 |
| [Swift/iOS] Jailbreak Detection(탈옥 감지)의 필요성 (1) | 2026.04.25 |
| [macOS] .pkg(.dmg) 배포를 위한 서명 및 공증(Notarization) (0) | 2026.04.11 |
| [Swift/iOS] 토큰 데이터를 KeyChain에 안전하게 저장하기 (0) | 2023.05.06 |