← Swift & Foundation 로드맵 32 / 33

32 · Result Builder와 DSL

SwiftUI의 body가 왜 쉼표도 return도 없이 동작하는가. @resultBuilder가 클로저를 buildBlock·buildOptional·buildEither·buildArray 호출로 바꾸는 과정을 AST dump로 직접 확인하고, 개수 제한을 없애는 두 가지 길(buildPartialBlock과 가변 제네릭)을 SDK 인터페이스로 대조하며, result builder에서 return이 금지되는 이유, 그리고 리터럴 열 개짜리 한 줄이 컴파일 자체에 실패하는 실측까지 다룬다.
진행률
0 / 0 완료

SwiftUI를 처음 쓸 때 가장 이상한 것은 body의 문법이다. 뷰를 여러 개 나열하는데 쉼표가 없고, 여러 개를 나열했는데 반환은 하나이고, if를 썼는데 그것이 값처럼 동작한다. 이것은 SwiftUI가 받은 특별 대우가 아니라 언어 기능 하나가 만들어내는 것이다 — SE-0289의 result builder다.

이 챕터는 그 변환을 컴파일러에게 직접 물어 확인한다. AST를 덤프해 어떤 메서드가 몇 번 호출되는지 세고, 각 빌더 메서드가 어떤 문법을 담당하는지 실행 결과로 가른다. 마지막 Q4는 이 기능의 대가에 관한 것인데, 여기서 이 강의에서 가장 짧은 코드가 가장 극적인 실패를 낸다 — 열 토큰짜리 한 줄이 컴파일되지 않는다.

📝 노트

본문의 출력·AST 덤프·컴파일 시간은 Swift 6.2.1 (swiftlang-6.2.1.4.8) / macOS 26.6.1 / arm64에서 직접 컴파일·실행한 실측값이다. 컴파일 시간은 기기와 캐시 상태에 따라 달라지므로 절대값보다 비율을 보면 된다(같은 조건에서 반복 측정했고, 기준선은 0.16~0.17초다).

Q1. @resultBuilder는 클로저를 어떤 코드로 바꾸는가?

🔑 30초 답변

@resultBuilder를 붙인 타입은 정적 메서드 이름으로 문법을 담당한다. 컴파일러는 빌더가 붙은 클로저 본문을 훑으며 나열된 문장은 buildBlock, if(else 없음)는 buildOptional, if/else·switchbuildEither(first:)/buildEither(second:), for 루프는 buildArray 호출로 바꾼다. 즉 이것은 매크로가 아니라 구문 트리 변환이며, 어떤 메서드를 정의했는지가 곧 내 DSL이 지원하는 문법이 된다. 실측으로 AST를 덤프하면 네 메서드가 실제로 호출되는 것을 셀 수 있다.

원리

Part A 13장에서 본 매크로(SE-0382)와 혼동하기 쉬운데 다른 기능이다. 매크로는 별도 컴파일러 플러그인 프로세스가 소스를 생성하고, result builder는 타입 검사 단계에서 컴파일러 내장 규칙이 구문을 재작성한다. 그래서 result builder는 SwiftSyntax 의존성이나 빌드 플러그인이 필요 없고, 대신 변환 규칙을 내가 정할 수 없다 — 메서드 이름이 계약이다.

이 계약의 목록이 곧 지원 문법이다. 필요한 메서드를 정의하지 않으면 그 문법이 컴파일 에러가 된다 — 즉 내 DSL이 허용할 문장을 내가 고르는 구조다.

클로저 안의 문법호출되는 메서드없으면
문장 나열buildBlock(_:)빌더가 성립하지 않는다 (필수)
if (else 없음)buildOptional(_:)else 없는 if 사용 불가
if/else · switchbuildEither(first:) / buildEither(second:)분기 사용 불가
for … inbuildArray(_:)루프 사용 불가
각 표현식 (전처리)buildExpression(_:)생략 가능 — 값이 그대로 전달됨
최종 결과 (후처리)buildFinalResult(_:)생략 가능 — buildBlock 결과가 그대로
if #availablebuildLimitedAvailability(_:)생략 가능 — 가용성 정보가 소실될 수 있다

내부 동작

네 가지 문법을 모두 쓰는 빌더를 하나 만들어 실제로 무엇이 호출되는지 확인한다.

rb.swift — 네 문법을 모두 담당하는 빌더
@resultBuilder
enum TextBuilder {
    static func buildBlock(_ parts: String...) -> String { parts.joined(separator: " ") }
    static func buildOptional(_ p: String?) -> String { p ?? "(없음)" }
    static func buildEither(first: String) -> String { "first:" + first }
    static func buildEither(second: String) -> String { "second:" + second }
    static func buildArray(_ parts: [String]) -> String { parts.joined(separator: ",") }
}

func render(@TextBuilder _ body: () -> String) -> String { body() }

let flag = true
print(render {
    "안녕"
    "Swift"
    if flag { "조건참" }          // buildOptional
    if flag { "A" } else { "B" }  // buildEither
    for i in 1...3 { "\(i)" }     // buildArray
})
실행 결과
안녕 Swift 조건참 first:A 1,2,3

출력의 각 조각이 어느 메서드에서 왔는지 그대로 읽힌다. "조건참"buildOptional을 통과했고(flagfalse였다면 (없음)), first:AbuildEither(first:)가 접두사를 붙인 것이고, 1,2,3buildArray가 쉼표로 이었다. 내가 정의한 메서드의 구현이 그대로 문법의 의미가 된다.

추측이 아니라는 것을 컴파일러에게 확인받을 수 있다. AST를 덤프해 빌더 메서드 참조를 세면 된다.

컴파일러가 실제로 삽입한 호출을 센다
$ swiftc -dump-ast rb.swift 2>/dev/null \
    | grep -oE 'build(Block|Optional|Either|Array)' | sort | uniq -c

   3 buildArray
  10 buildBlock
   6 buildEither
   4 buildOptional

네 메서드가 전부 등장한다. 개수가 소스의 등장 횟수보다 많은 것은 AST에 선언 참조와 호출 노드가 함께 잡히기 때문이며, 요점은 내가 소스에 buildBlock이라고 쓴 적이 없는데도 컴파일러가 그 호출을 만들어 넣었다는 것이다.

내가 쓴 클로저 본문 컴파일러가 만든 호출 "안녕" "Swift" buildBlock("안녕", "Swift", …) if flag { "조건참" } buildOptional(Optional("조건참")) if flag { "A" } else { "B" } buildEither(first: "A") for i in 1...3 { "\(i)" } buildArray(["1","2","3"]) 핵심 — 변환은 타입 검사 단계에서 일어난다 매크로처럼 별도 플러그인 프로세스가 소스를 만드는 것이 아니라, 컴파일러 내장 규칙이 구문 트리를 재작성한다. 그래서 정의한 메서드 목록이 곧 그 DSL이 허용하는 문법의 목록이 된다. 없는 메서드에 해당하는 문법은 컴파일 에러다.
왼쪽은 사람이 쓰는 선언적 문법, 오른쪽은 컴파일러가 만들어 넣는 실제 호출. 이 대응이 result builder의 전부다.

실험 · 도구

메서드를 빼면 그 문법이 막힌다는 것을 확인하면 "메서드 목록 = 문법 목록"이 실감된다.

buildArray를 정의하지 않은 빌더에서 for 루프를 쓰면
@resultBuilder
enum NoLoop {
    static func buildBlock(_ p: String...) -> String { p.joined() }
    // buildArray 를 정의하지 않았다
}
func f(@NoLoop _ body: () -> String) -> String { body() }

_ = f {
    for i in 1...3 { "\(i)" }   // ❌ 이 빌더는 루프를 지원하지 않는다
}
swiftc 실제 진단 — 빌더 이름까지 짚어준다
error: closure containing control flow statement cannot be used with
       result builder 'NoLoop'

이것이 DSL 설계의 핵심 도구다. 예컨대 "이 DSL에서는 분기를 허용하지 않겠다"고 정하려면 buildEither정의하지 않으면 된다 — 문법 수준에서 강제된다.

프로젝트 적용

① 쓸 자리는 "같은 종류의 것을 여러 개 모아 하나로 만드는" 구조다. 뷰 트리, 쿼리 조건, 테스트 시나리오, 검증 규칙 목록 등이다.

검증 규칙 DSL — 실제로 쓸모 있는 최소 예제
struct Rule {
    let check: (String) -> String?      // nil 이면 통과, 아니면 에러 메시지
}

@resultBuilder
enum RuleBuilder {
    // 컴포넌트 타입을 [Rule] 하나로 통일한다 — 아래 함정 콜아웃 참고
    static func buildExpression(_ r: Rule) -> [Rule] { [r] }
    static func buildBlock(_ parts: [Rule]...) -> [Rule] { parts.flatMap { $0 } }
    static func buildOptional(_ r: [Rule]?) -> [Rule] { r ?? [] }
    static func buildEither(first: [Rule]) -> [Rule] { first }
    static func buildEither(second: [Rule]) -> [Rule] { second }
    static func buildArray(_ parts: [[Rule]]) -> [Rule] { parts.flatMap { $0 } }
}

func validator(@RuleBuilder _ body: () -> [Rule]) -> (String) -> [String] {
    let rules = body()
    return { input in rules.compactMap { $0.check(input) } }
}

let strict = true
let validate = validator {
    Rule { $0.isEmpty ? "비어 있습니다" : nil }
    Rule { $0.count > 20 ? "너무 깁니다" : nil }
    if strict {
        Rule { $0.contains(" ") ? "공백이 있습니다" : nil }
    }
}
print(validate("hello world"))
실행 결과
["공백이 있습니다"]
🔴 함정 — 컴포넌트 타입을 통일하지 않으면 컴파일되지 않는다

위 빌더에서 buildExpression을 빼고 buildBlock(_ rules: Rule...)로 쓰면 컴파일에 실패한다. 이 강의를 쓰면서 실제로 부딪힌 에러다.

buildExpression 없이 — 실제 진단
error: cannot pass array of type '[Rule]' as variadic arguments of type 'Rule'

이유는 if 블록이 buildOptional을 통과하면서 [Rule]이 되는데, buildBlockRule을 가변 인자로 받으려 하기 때문이다. 빌더의 중간 컴포넌트 타입은 모든 메서드에서 하나로 일치해야 하며, 여기서는 [Rule]이다. 그래서 buildExpression(_ r: Rule) -> [Rule]입구에 두어 개별 Rule[Rule]로 승격시키고, buildBlock[Rule]...을 받아 flatMap으로 합친다. Q2의 buildExpression이 "선택적 편의 기능"이 아니라 대개 필수인 이유가 이것이다 — 분기·루프를 지원하는 순간 컴포넌트 타입이 컬렉션이 되고, 그러면 입구에서 승격이 필요해진다.

② 쓰지 말아야 할 자리가 더 중요하다. result builder는 읽는 사람이 이미 그 DSL을 아는 경우에만 가독성을 높인다. 팀에서 한 번만 쓰이는 구조에 빌더를 만들면, 배열 리터럴 한 줄로 끝날 일을 위해 새 문법을 학습하게 만드는 셈이다.

빌더가 과잉인 경우 — 배열이 더 낫다
// ❌ 빌더까지 만들 이유가 없다
let rules1 = validator { Rule { ... }; Rule { ... } }

// ✅ 두세 개 모으는 일이면 배열이 더 명확하고 학습 비용이 0이다
let rules2: [Rule] = [Rule { ... }, Rule { ... }]

③ 판단 기준은 셋이다. 조건 분기·루프가 선언 안에 자연스럽게 들어가야 하는가(배열로는 if를 쓰기 어렵다) 같은 구조가 코드베이스에서 여러 곳에 반복되는가 그 DSL을 쓰는 사람이 내부 구조를 몰라도 되는가. 셋 다 예면 빌더가 값을 만든다.

⚠️ 흔한 오해

"result builder는 매크로다"는 흔한 혼동이다 — 다른 기능이며, 매크로(Part A 13장)는 플러그인 프로세스가 소스를 생성하지만 result builder는 컴파일러 내장 구문 변환이다. 또 "SwiftUI만의 기능"도 아니다 — @resultBuilder는 공개 언어 기능이고 Swift 5.4부터 누구나 쓸 수 있다. 마지막으로 "buildBlock만 정의하면 된다"도 틀렸다 — 그러면 if·for가 전부 컴파일 에러가 된다. 지원하려는 문법마다 해당 메서드가 필요하다.

🧒 쉽게 이해하기

레고 설명서를 쓰는 규칙과 같다. 내가 "설명서에 이런 문장을 쓸 수 있다"는 목록을 미리 정해두는 것이다 — "부품을 여러 개 나열하기"(buildBlock), "조건이 맞으면 이 부품 추가하기"(buildOptional), "둘 중 하나 고르기"(buildEither), "여러 번 반복하기"(buildArray). 그리고 목록에 없는 문장은 아예 쓸 수 없다. 그래서 "반복하기"를 목록에 안 넣으면, 설명서에 반복 문장을 쓰려는 순간 거부당한다. 비유가 깨지는 곳: 레고 설명서는 사람이 읽고 해석하지만, 이 규칙은 컴파일러가 내 문장을 실제 조립 명령으로 바꿔준다 — 내가 정한 방식 그대로.

꼬리 질문

buildBlock을 가변 인자(String...)로 정의하는 것과 고정 인자 오버로드 여러 개로 정의하는 것은 무엇이 다른가?
가변 인자는 개수 제한이 없고 원소 타입이 모두 같아야 한다. 고정 인자 오버로드(buildBlock(_:_:), buildBlock(_:_:_:), …)는 각 위치의 타입을 다르게 할 수 있어 제네릭으로 서로 다른 타입을 조합할 수 있지만, 정의해둔 개수까지만 받는다. SwiftUI의 ViewBuilder역사적으로 후자를 택한 이유가 여기 있다 — 뷰 트리는 Text·Button·Image처럼 서로 다른 타입을 한 블록에 담아야 하므로 위치별로 다른 제네릭 파라미터가 필요했다. 그 대가가 유명한 "뷰 10개 제한"이었다. 그 제한을 없애는 길은 두 가지이고, SwiftUI가 실제로 택한 것은 Q2에서 SDK 인터페이스로 확인한다 — 흔히 알려진 것과 다르다.
쉽게 말하면 "같은 종류 물건을 몇 개든 담는 상자"(가변 인자)와 "칸마다 다른 종류를 담을 수 있지만 칸 수가 정해진 상자"(고정 오버로드)의 차이다. 서로 다른 종류를 담아야 하면 후자를 쓸 수밖에 없었다.
ifelse가 없을 때 buildOptional을 쓰는 이유는? buildEither로 통일할 수 없는가?
else가 없으면 "아무것도 생성하지 않는 경우"를 표현해야 하는데, buildEither두 분기가 모두 값을 만든다고 전제한다. 없는 쪽에 무엇을 넣을지 컴파일러가 정할 수 없다 — 빈 값이 무엇인지는 DSL마다 다르다(문자열이면 "", 뷰면 EmptyView, 배열이면 []). 그래서 Optional로 감싸 넘기고 "없을 때 무엇으로 대체할지를 빌더가 결정"하게 한 것이다. 실측 예제에서 buildOptionalp ?? "(없음)"로 대체값을 정했던 부분이 정확히 그 결정 지점이다.
쉽게 말하면 "조건이 안 맞으면 뭘 넣지?"라는 질문에 컴파일러는 답할 수 없다. 빈칸을 무엇으로 채울지는 이 설명서를 만든 사람이 정해야 하니, 컴파일러는 "비었음" 표시만 넘겨주고 결정을 맡긴다.
switch에 케이스가 세 개 이상이면 buildEither가 두 개뿐인데 어떻게 처리되는가?
중첩된다. 컴파일러는 n개 분기를 이진 트리로 접어 buildEither를 겹쳐 호출한다 — 세 케이스면 buildEither(first:) 안에 다시 buildEither가 들어가는 형태가 된다. 그래서 케이스가 많은 switch중첩 깊이가 늘어나고, 결과 타입이 제네릭이면(SwiftUI의 _ConditionalContent처럼) 그 타입 이름이 지수적으로 길어진다. 이것이 Q4에서 다룰 타입 검사 비용의 한 원인이며, 실무적으로 "케이스가 많은 switch를 뷰 본문에 직접 쓰지 말고 함수로 빼라"는 조언의 근거다.
쉽게 말하면 "둘 중 하나 고르기"만 할 수 있는 도구로 다섯 중 하나를 고르려면, "둘 중 하나 고르기"를 여러 번 겹쳐야 한다. 겹칠수록 구조가 깊어지고 컴파일러가 풀어야 할 문제도 커진다.

Q2. buildExpression · buildFinalResult · buildPartialBlock은 각각 무슨 문제를 푸는가?

🔑 30초 답변

세 메서드가 각각 다른 층을 연다. buildExpression입구에서 각 표현식을 전처리해 서로 다른 타입을 받아들이게 한다 — 실측으로 IntString을 같은 블록에 섞어 쓸 수 있었다. buildFinalResult출구에서 최종 변환을 하여 내부 표현과 반환 타입을 분리한다(내부는 [String], 반환은 String). buildPartialBlock(SE-0348)은 원소를 둘씩 접어 누적하므로 개수 제한을 없앤다 — 실측으로 12개를 문제없이 처리했다. 다만 "SwiftUI의 뷰 10개 제한이 이 메서드로 풀렸다"는 널리 퍼진 설명은 사실이 아니다. SDK 인터페이스를 직접 열어 보면 ViewBuilderbuildPartialBlock쓰지 않고 가변 제네릭(parameter pack)으로 해결했다 — 실측으로 확인했고 아래에서 원문을 인용한다.

원리

buildBlock만으로는 두 가지가 불가능했다. 첫째, 서로 다른 타입을 섞는 것 — 가변 인자는 한 타입만 받고, 고정 오버로드는 개수마다 정의해야 한다. 둘째, 개수 제한 없이 서로 다른 타입을 섞는 것 — 이 둘을 동시에 만족하려면 오버로드가 무한히 필요하다.

buildPartialBlock은 이 문제를 재귀로 바꿔 해결한다. "n개를 한 번에 합치는 함수"가 아니라 "지금까지 누적한 것다음 하나를 합치는 함수" 두 개만 정의하면, 컴파일러가 그것을 반복 적용해 임의 개수를 처리한다. Part A 11장의 reduce와 같은 발상이다.

내부 동작

buildExpressionbuildFinalResult를 함께 쓰면 입구와 출구가 모두 열린다.

bx.swift — 서로 다른 타입을 받아 하나의 문자열로
@resultBuilder
enum Report {
    static func buildExpression(_ n: Int) -> [String] { ["int:\(n)"] }
    static func buildExpression(_ s: String) -> [String] { ["str:\(s)"] }
    static func buildBlock(_ parts: [String]...) -> [String] { parts.flatMap { $0 } }
    static func buildFinalResult(_ parts: [String]) -> String { parts.joined(separator: ", ") }
}
func render(@Report _ body: () -> String) -> String { body() }

print(render { 1; "둘"; 3 })
실행 결과
int:1, str:둘, int:3

주목할 점 두 가지. ① 클로저 안에 IntString섞여 있는데 컴파일된다 — buildExpression 오버로드가 각각을 공통 표현([String])으로 바꿨기 때문이다. ② body의 반환 타입은 String인데 buildBlock[String]을 다룬다 — buildFinalResult가 마지막에 변환했다. 내부 작업 타입과 외부 인터페이스 타입을 분리할 수 있다는 뜻이며, 이것이 실전 DSL에서 매우 유용하다.

이제 buildPartialBlock이다. 메서드 만 정의하고 원소를 12개 넣는다.

pb.swift — buildPartialBlock으로 개수 제한을 없앤다
@resultBuilder
enum Concat {
    static func buildPartialBlock(first: String) -> String { first }
    static func buildPartialBlock(accumulated: String, next: String) -> String {
        accumulated + "|" + next
    }
}
func make(@Concat _ body: () -> String) -> String { body() }

print(make {
    "a"; "b"; "c"; "d"; "e"; "f"; "g"; "h"; "i"; "j"; "k"; "l"   // 12개
})
실행 결과 — 12개가 모두 처리된다
a|b|c|d|e|f|g|h|i|j|k|l

대조군으로 고정 arity 오버로드만 정의한 빌더에 개수를 초과해 넣으면 실패한다.

arity.swift — 오버로드가 3개까지인 빌더에 4개를 넣으면
@resultBuilder
enum Fixed {
    static func buildBlock(_ a: String) -> String { a }
    static func buildBlock(_ a: String, _ b: String) -> String { a + b }
    static func buildBlock(_ a: String, _ b: String, _ c: String) -> String { a + b + c }
}
func f(@Fixed _ body: () -> String) -> String { body() }
print(f { "a"; "b"; "c"; "d" })   // 4개
swiftc 실제 진단
arity.swift:8:26: error: extra argument in call

extra argument in call — SwiftUI에서 뷰를 11개 넣었을 때 나오던 그 계열의 에러다. 에러 문구가 "10개를 넘었다"가 아니라 그냥 "인자가 많다"였기 때문에 원인을 짐작하기 어려웠던 것이다.

🔴 널리 퍼진 오정보 — SwiftUI는 buildPartialBlock을 쓰지 않는다

"SwiftUI의 뷰 10개 제한은 SE-0348 buildPartialBlock 덕분에 사라졌다"는 설명을 자주 볼 수 있다. 확인해 보면 사실이 아니다. SDK에 동봉된 ViewBuilder의 실제 선언을 그대로 인용한다.

SwiftUICore.swiftinterface — ViewBuilder의 빌더 메서드 전부 (Xcode 26.1.1 SDK)
@_functionBuilder public struct ViewBuilder {
  buildExpression<Content>(_ content: Content) -> Content where Content: View
  buildBlock() -> EmptyView
  buildBlock<Content>(_ content: Content) -> Content where Content: View
  @_disfavoredOverload
  buildBlock<each Content>(_ content: repeat each Content)
      -> TupleView<(repeat each Content)>          ← 가변 제네릭
  buildIf<Content>(_ content: Content?) -> Content?   ← buildOptional 이 아니다
  buildEither<T, F>(first:)  -> _ConditionalContent<T, F>
  buildEither<T, F>(second:) -> _ConditionalContent<T, F>
  buildLimitedAvailability(_:)
}
실측 — 직접 세어 본 결과
$ CORE=".../SwiftUICore.swiftmodule/arm64e-apple-macos.swiftinterface"
$ grep -c "buildPartialBlock" "$CORE"
6                       # 파일 전체에는 6건 있지만…

$ # 그 6건이 속한 빌더를 확인하면
KeyframesBuilder · KeyframeTrackContentBuilder    # ViewBuilder 가 아니다

정리하면 세 가지가 통념과 다르다. ① ViewBuilder의 개수 제한을 없앤 것은 가변 제네릭(each Content · repeat each ContentSE-0393, Swift 5.9 탑재)이며 buildPartialBlock(SE-0348, Swift 5.7 탑재)이 아니다. ② SwiftUI가 buildPartialBlock을 쓰기는 하는데 KeyframesBuilder·KeyframeTrackContentBuilder에서다. ③ ViewBuilderbuildOptional이 아니라 레거시 이름 buildIf를 쓰고, 인터페이스에는 애트리뷰트가 @_functionBuilder로 인쇄된다 — ViewBuilder가 SE-0289로 정식화되기 이전부터 존재했기 때문에 남은 흔적으로 보인다(이 해석은 필자의 추정이고, 인쇄된 문자열 자체는 실측이다).

개수 제한을 없애는 길은 두 가지이고(가변 제네릭 · buildPartialBlock), 둘은 서로 대체재다. SwiftUI가 왜 앞쪽을 골랐는지는 이 문항의 꼬리 질문에서 다룬다.

실험 · 도구

세 메서드의 역할을 파이프라인으로 보면 정리된다.

각 표현식 1 · "둘" · 3 buildExpression 입구 — 타입을 통일한다 buildBlock 또는 buildPartialBlock 누적 — 전자는 한 번에, 후자는 둘씩 접어 후자는 개수 제한이 없다 buildFinalResult 출구 — 내부 표현 → 반환 타입 분기·루프는 누적 단계로 들어가기 전에 각각 buildOptional · buildEither · buildArray 입구와 출구는 생략 가능하다. 생략하면 표현식이 그대로 전달되고, buildBlock 결과가 그대로 반환된다.
입구(buildExpression) → 누적(buildBlock/buildPartialBlock) → 출구(buildFinalResult). 각 단계가 독립적으로 열고 닫을 수 있다.

프로젝트 적용

① 새 빌더를 만든다면 buildPartialBlock을 기본으로 고른다. 개수 제한이 없고 정의할 메서드가 둘뿐이며, 타입을 제네릭으로 열면 서로 다른 타입도 조합할 수 있다.

제네릭 buildPartialBlock — 개수 제한 없이 타입도 다르게
struct Pair<A, B> { let a: A; let b: B }

@resultBuilder
enum TupleBuilder {
    static func buildPartialBlock<A>(first: A) -> A { first }
    static func buildPartialBlock<A, B>(accumulated: A, next: B) -> Pair<A, B> {
        Pair(a: accumulated, b: next)
    }
}
func build<T>(@TupleBuilder _ body: () -> T) -> T { body() }

let t = build { 1; "둘"; 3.0 }      // Pair<Pair<Int, String>, Double>
print(type(of: t))
실행 결과 — 왼쪽으로 접힌 타입이 만들어진다
Pair<Pair<Int, String>, Double>

출력된 타입 이름이 중요한 힌트다. Pair<Pair<Int, String>, Double>처럼 중첩이 원소 수에 비례해 깊어진다. SwiftUI에서 뷰를 많이 나열하면 타입 이름이 화면을 넘기도록 길어지는 이유이고, 동시에 Q4의 컴파일 시간 문제로 이어지는 경로다.

② buildFinalResultAPI 표면을 깔끔하게 유지하는 데 쓴다. 내부는 다루기 편한 배열로 작업하고, 밖으로는 도메인 타입만 노출한다.

내부는 배열, 외부는 도메인 타입
struct Query { let clauses: [String]; var sql: String { "WHERE " + clauses.joined(separator: " AND ") } }

@resultBuilder
enum QueryBuilder {
    static func buildExpression(_ s: String) -> [String] { [s] }        // 입구에서 [String]으로
    static func buildBlock(_ parts: [String]...) -> [String] { parts.flatMap { $0 } }
    static func buildOptional(_ p: [String]?) -> [String] { p ?? [] }
    static func buildFinalResult(_ parts: [String]) -> Query { Query(clauses: parts) }
}
func query(@QueryBuilder _ body: () -> Query) -> Query { body() }

let onlyActive = true
let q = query {
    "age >= 18"
    if onlyActive { "status = 'active'" }
}
print(q.sql)
실행 결과
WHERE age >= 18 AND status = 'active'

③ buildLimitedAvailability를 빼먹지 않는다. if #available을 지원하는 DSL이라면 이 메서드가 없으면 가용성 정보가 소실되어 낡은 OS에서 문제가 될 수 있다. 30장 Q4에서 본 것처럼 가용성은 실무에서 결정적인 변수다.

⚠️ 흔한 오해

"buildPartialBlock을 정의하면 buildBlock도 함께 정의해야 한다"는 틀렸다 — 실측 예제는 buildPartialBlock 둘만으로 동작했다. 다만 빈 블록({ })을 허용하려면 인자 없는 buildBlock()이 별도로 필요하다. 또 "10개 제한은 언어의 한계였다"도 부정확하다 — SwiftUI가 선택한 오버로드 개수였고 언어 제약이 아니었다. 그리고 가장 널리 퍼진 오해는 "그 제한을 buildPartialBlock이 없앴다"인데, 위 함정 콜아웃의 SDK 실측대로 ViewBuilder가변 제네릭을 쓴다. 마지막으로 "buildExpression은 성능 최적화용"이라는 오해도 있다. 실제 역할은 타입 변환과 문법 확장이며, 이것이 있어야 이질적인 타입을 한 블록에 담을 수 있다.

🧒 쉽게 이해하기

공장 조립 라인이라고 생각하자. 입구 검사대(buildExpression)에서는 들어오는 부품이 나사든 나무든 다 같은 규격 상자에 담아 라인에 올린다. 조립 구간에서는 두 방식이 있다 — 옛 방식(buildBlock)은 "부품 10개를 한 번에 조립하는 기계"라서 11개가 오면 멈춘다. 새 방식(buildPartialBlock)은 "지금까지 조립한 것 + 새 부품 하나"를 붙이는 기계라서, 같은 동작을 반복하면 몇 개든 처리한다. 마지막 출구 포장대(buildFinalResult)에서 완성품을 보기 좋게 포장해 내보낸다. 비유가 깨지는 곳: 새 방식으로 붙일 때마다 결과물의 이름이 길어진다 — "부품A와부품B를붙인것과부품C를붙인것"처럼. 사람은 그냥 "완성품"이라 부르면 되는데 컴파일러는 그 긴 이름을 실제로 다 관리해야 하고, 그게 Q4에서 문제가 된다.

꼬리 질문

buildPartialBlockbuildBlock보다 항상 나은가?
새로 만드는 빌더에서는 대체로 그렇지만 예외가 있다. buildPartialBlock은 원소를 왼쪽으로 접으므로 결과 타입이 중첩된다 — 실측에서 Pair<Pair<Int, String>, Double>이 나온 것처럼. 원소 타입이 모두 같아서 결과가 단일 타입으로 수렴하는 DSL(예: [Rule]을 모으는 경우)이라면 가변 인자 buildBlock더 단순하고 타입 이름도 짧다 — 그러면 컴파일러 부담도 적다. 정리하면 이렇다. 원소 타입이 이질적이고 개수 제한이 곤란하면 buildPartialBlock, 원소 타입이 동일하면 가변 인자 buildBlock이 낫다. 실측 예제 중 TextBuilder·RuleBuilder가 후자에 해당한다.
쉽게 말하면 부품 종류가 다양하면 "하나씩 붙이는 기계"가 유리하고, 부품이 다 똑같으면 "한꺼번에 담는 상자"가 더 간단하다.
SwiftUI의 ViewBuilder는 지금 어느 방식을 쓰는가?
가변 제네릭이다. 위 함정 콜아웃에서 SDK 인터페이스로 확인한 대로, ViewBuilder에는 buildPartialBlock없고 buildBlock<each Content>(_ content: repeat each Content) -> TupleView<(repeat each Content)>가 있다. 남아 있는 고정 arity 오버로드는 0개짜리(EmptyView 반환)와 1개짜리 둘뿐이고, 나머지는 가변 제네릭 하나가 전부 처리한다 — 그 오버로드에 @_disfavoredOverload가 붙은 것은 1개짜리 특수 케이스를 먼저 고르게 하기 위해서다. buildPartialBlock이 아니라 가변 제네릭인가? 결과 타입 때문이라고 보는 것이 자연스럽다. buildPartialBlock은 Q2 본문 실측처럼 Pair<Pair<A,B>,C>왼쪽으로 접힌 중첩을 만드는데, SwiftUI는 기존 TupleView<(A, B, C)>라는 평평한 타입을 이미 공개 ABI로 갖고 있었다. 가변 제네릭은 그 평평한 형태를 유지하면서 개수만 열어준다(이 인과 해석은 필자의 추정이고, 인터페이스 선언 자체는 실측이다). 실무적 함의는 그대로다 — 개수 제한 우회용 Group 래핑은 이제 불필요하고, 이미 있는 것을 급히 제거할 이유도 없다.
쉽게 말하면 상자를 여러 개 겹쳐 담는 방식(buildPartialBlock)과, 칸이 몇 개든 되는 한 줄짜리 상자(가변 제네릭)가 있는데 SwiftUI는 원래 쓰던 한 줄짜리 상자의 모양을 그대로 유지하려고 후자를 골랐다.
내부 표현과 반환 타입을 분리하면 구체적으로 무엇이 좋아지는가?
세 가지가 좋아진다. 첫째, 작업이 쉬워진다 — 블록 안에서는 [String]이나 [Rule]처럼 flatMap·filter가 되는 컬렉션으로 다루고, 밖으로는 Query·Validator 같은 도메인 타입만 내보낸다. 둘째, 불변식을 한 곳에서 강제할 수 있다buildFinalResult가 유일한 출구이므로 여기서 정렬·중복 제거·검증을 하면 모든 경로가 그것을 통과한다. 셋째, API가 안정된다 — 내부 표현을 나중에 [String]에서 다른 것으로 바꿔도 반환 타입이 그대로면 호출자 코드가 깨지지 않는다. 이는 Part A 13장의 접근제어·resilience 논의와 같은 원리다 — 내부 구현과 공개 표면을 분리하는 것.
쉽게 말하면 주방에서는 재료를 큰 그릇에 담아 편하게 섞고, 손님상에는 완성된 접시만 내는 것이다. 주방 방식을 바꿔도 손님이 받는 접시는 그대로다.

Q3. result builder 안에서 쓸 수 없는 것은 무엇이며 왜인가?

🔑 30초 답변

가장 중요한 제약은 return을 쓸 수 없다는 것이다. 실측 진단문이 정확히 그렇게 말한다 — error: cannot use explicit 'return' statement in the body of result builder. 이유는 빌더 클로저의 반환값이 컴파일러가 조립하는 것이기 때문이다. 개발자가 return으로 직접 값을 돌려주면 그 조립 과정과 충돌한다. 같은 이유로 guard … else { return } 형태의 조기 탈출이 불가능하고, break·continue·throw로 블록을 빠져나가는 것도 안 된다. 대신 중간에 let 선언은 가능하며(값을 만들지 않는 문장은 그냥 실행된다), 조기 탈출이 필요하면 빌더 밖으로 로직을 빼 해결한다.

원리

빌더가 붙은 클로저는 겉보기에 함수 본문이지만 의미는 다르다. 일반 클로저에서 return x는 "이 클로저의 값은 x"라는 뜻인데, 빌더 클로저에서는 값을 만드는 주체가 컴파일러다. 컴파일러가 문장들을 모아 buildBlock(...) 호출을 만들어 그것을 반환하도록 재작성하므로, 개발자의 return이 들어갈 자리가 없다.

이 제약은 결과적으로 선언적 성질을 보장한다. 조기 탈출이 가능하면 "이 블록이 무엇을 만드는가"가 실행 경로에 따라 달라지고, 그러면 컴파일러가 결과 타입을 정적으로 조립할 수 없다.

내부 동작

no.swift — guard로 조기 탈출을 시도한다
@resultBuilder
enum B {
    static func buildBlock(_ p: String...) -> String { p.joined(separator: " ") }
    static func buildOptional(_ p: String?) -> String { p ?? "" }
}
func f(@B _ body: () -> String) -> String { body() }
let flag = true
print(f {
    "시작"
    guard flag else { return }     // ❌
    "끝"
})
swiftc 실제 진단
no.swift:10:23: error: cannot use explicit 'return' statement in the body of
                result builder 'B'

진단문이 빌더 이름까지 알려준다(result builder 'B'). 여러 빌더가 중첩된 상황에서 어느 빌더가 문제인지 짚어주는 배려다.

반면 값을 만들지 않는 문장은 자유롭게 쓸 수 있다. let 선언, print 호출 같은 것들이다.

중간 let 선언은 가능하다
let flag = true
print(f {
    "시작"
    let mid = flag ? "가운데-참" : "가운데-거짓"   // 값을 만들지 않는 선언 — OK
    mid
    "끝"
})
실행 결과
시작 가운데-참 끝

이것이 실전에서 매우 유용하다. 복잡한 조건 계산을 let으로 미리 꺼내두면 블록 안이 훨씬 읽기 쉬워지고, Q4의 타입 검사 비용도 줄어든다.

실험 · 도구

문법빌더 안에서이유 / 대안
문장 나열가능buildBlock
if · if/else · switch가능 (해당 메서드 필요)buildOptional / buildEither
for … in가능 (buildArray 필요)
let · var 선언가능값을 만들지 않는 문장은 그대로 실행
if #available가능buildLimitedAvailability 권장
return불가반환값은 컴파일러가 조립한다
guard … else { return }불가if로 감싸거나 로직을 밖으로
while 루프불가반복 횟수를 정적으로 알 수 없다
break · continue불가조립 흐름을 벗어난다

while이 불가능하고 for는 가능한 이유가 흥미롭다. for … in시퀀스를 순회하므로 컴파일러가 "각 원소마다 값 하나"라는 구조를 알고 buildArray로 모을 수 있다. while은 반복 구조가 조건에 달려 있어 그 대응이 성립하지 않는다.

프로젝트 적용

① 조기 탈출이 필요하면 빌더 밖으로 뺀다. 빌더는 "무엇을 만들지" 선언하는 자리이고, "만들지 말지"를 판단하는 로직은 그 위에 있어야 한다.

조기 탈출은 빌더 호출 지점에서 처리한다
// ❌ 빌더 안에서 탈출하려는 시도
// let v = f { "시작"; guard ok else { return }; "끝" }

// ✅ 빌더를 부를지 말지를 밖에서 결정한다
func content(ok: Bool) -> String? {
    guard ok else { return nil }        // 여기서는 평범한 함수라 return 자유
    return f {
        "시작"
        "끝"
    }
}
print(content(ok: true) as Any, content(ok: false) as Any)
실행 결과
Optional("시작 끝") nil

② 복잡한 조건은 let으로 이름을 붙여 꺼낸다. 가독성과 컴파일 시간을 동시에 개선하는, 이 챕터에서 가장 실용적인 습관이다.

조건에 이름을 붙인다 — 읽기 쉽고 타입 검사도 가볍다
let items = ["a", "b", "c"]
let isEmpty = items.isEmpty
let summary = "\(items.count)개"

print(f {
    "목록"
    if isEmpty { "비어 있음" }      // 조건이 단순해서 읽기 쉽다
    summary
})
실행 결과
목록  3개

③ 중첩이 깊어지면 함수로 쪼갠다. Q1 꼬리 질문에서 본 것처럼 케이스가 많은 switchbuildEither 중첩을 만들고, 그것이 타입 이름과 검사 시간을 키운다. 블록 안의 한 덩어리를 별도 함수(또는 별도 뷰)로 빼는 것이 표준 해법이다.

⚠️ 흔한 오해

"빌더 안에서는 아무 코드도 실행할 수 없다"는 과한 해석이다 — let 선언과 일반 함수 호출은 가능하고, 실제로 실행된다. 반대로 "return이 안 되니 값을 하나만 넣어야 한다"도 틀렸다 — 여러 문장을 나열하는 것이 바로 이 기능의 목적이다. 또 "guard 자체를 쓸 수 없다"도 부정확하다. 금지되는 것은 return으로 빠져나가는 것이며, guardelse에서 fatalError()처럼 절대 반환하지 않는 호출을 하는 형태는 성립할 수 있다 — 다만 선언적 코드에서 권할 패턴은 아니다.

🧒 쉽게 이해하기

주문서를 쓰는 것과 같다. 종이에 "커피, 케이크, 물"이라고 적기만 하면 되고, 마지막에 "이상입니다"라고 쓸 필요가 없다 — 종이를 넘기면 점원이 알아서 합계를 낸다. 그런데 여기서 "이상입니다!"(return)라고 적어버리면 점원이 혼란스러워진다. 합계를 내는 건 점원의 일인데 손님이 끼어든 것이다. 그래서 "여기서 그만"이라고 적는 건 금지되어 있고, 정말 주문을 안 할 거면 종이를 아예 넘기지 않으면 된다(빌더 밖에서 판단). 비유가 깨지는 곳: 주문서에 메모를 적는 건 괜찮다 — let으로 계산 결과에 이름을 붙여두는 건 허용되고, 오히려 점원이 읽기 편해진다.

꼬리 질문

for는 되고 while은 안 되는가?
구조를 정적으로 아는지가 갈림길이다. for x in seq { … }는 "seq의 각 원소마다 본문을 한 번 평가한다"는 구조이므로, 컴파일러는 결과를 "본문 타입의 배열"로 표현할 수 있고 그것을 buildArray에 넘긴다. 반복 횟수는 런타임에 결정되지만 결과의 타입 구조는 컴파일 타임에 정해진다. while cond { … }는 그렇지 않다 — 반복이 조건에 달려 있어 "몇 번 평가되는가"뿐 아니라 결과를 어떤 구조로 모을지 대응시킬 자연스러운 방법이 없고, 무엇보다 buildArray 같은 단일 진입점으로 환원되지 않는다. 실무적으로는 필요한 반복을 for로 표현 가능한 시퀀스로 먼저 계산해 두고(let items = …) 그것을 순회하면 된다.
쉽게 말하면 "이 목록에 있는 것마다 하나씩"은 목록을 보면 몇 칸이 필요한지 구조를 알 수 있다. "조건이 맞는 동안 계속"은 칸이 몇 개가 될지 구조 자체를 모르니 미리 틀을 짤 수 없다.
빌더 안의 let 선언은 언제 실행되는가? 조건에 따라 건너뛸 수 있는가?
클로저가 호출될 때 선언된 위치 순서대로 실행된다 — 일반 클로저 본문과 같다. 컴파일러의 변환은 "값을 만드는 문장"을 빌더 호출로 바꾸는 것이고, let 선언 같은 문장은 그 자리에 그대로 남는다. 그래서 위 실측에서 let mid = flag ? … : …가 실제로 평가되어 결과에 반영됐다. 조건에 따라 건너뛰는 것은 if 블록 안에 선언을 넣으면 되지만, 그러면 그 이름은 블록 안에서만 보인다(일반 스코프 규칙). 주의할 점은 부수효과다 — 빌더 블록은 선언적으로 읽히기 때문에 그 안에 부수효과가 있는 호출을 넣으면 "선언인데 뭔가 실행된다"는 혼란을 만든다. SwiftUI의 body처럼 여러 번 재평가될 수 있는 빌더에서는 특히 위험하다.
쉽게 말하면 주문서 위쪽에 "오늘은 할인 적용"이라고 메모해두면 그 메모는 실제로 적히고 아래 주문에 반영된다. 다만 주문서를 여러 번 다시 쓰게 되면 그 메모 작업도 매번 반복된다는 걸 알아둬야 한다.
이 제약들이 SwiftUI body에서 겪는 불편과 어떻게 연결되는가?
body@ViewBuilder이므로 이 챕터의 제약이 전부 그대로 적용된다. 실무에서 자주 부딪히는 세 가지가 여기서 설명된다. ① "early return을 못 해서" if로 감싸야 하는 것 — Q3의 return 금지 때문이다. ② 뷰 안에서 값을 계산하려면 계산 프로퍼티나 let을 써야 하는 것 — 문장은 값을 만들어야 하고, 만들지 않는 계산은 선언으로 빼야 한다. ③ 복잡한 switch가 컴파일을 느리게 하는 것buildEither 중첩이 타입을 깊게 만든다(Q1 꼬리 질문·Q4). 그래서 SwiftUI 커뮤니티의 표준 조언인 "뷰를 작게 쪼개라"는 스타일 취향이 아니라 이 언어 기능의 구조에서 나오는 결론이다.
쉽게 말하면 SwiftUI가 특별히 까다로운 게 아니라, 주문서 규칙을 그대로 쓰고 있어서 그 규칙의 제약이 다 따라온 것이다. 규칙을 알면 왜 그렇게 써야 하는지가 납득된다.

Q4. 타입 추론 비용 — 왜 컴파일이 느려지고 어떻게 진단하는가?

🔑 30초 답변

이 챕터에서 가장 짧은 코드가 가장 극적인 실패를 낸다. let x: Double = 1.0 + 1 * 2 - 1 + 1 * 2 - 1 — 이 한 줄이 실측으로 3.25초를 쓰고 나서 컴파일에 실패한다(error: the compiler is unable to type-check this expression in reasonable time). 원인은 result builder가 아니라 그것과 같은 뿌리다 — 연산자 오버로드와 리터럴의 타입 후보가 곱해지면서 타입 검사 탐색 공간이 폭발한다. 진단 도구는 -Xfrontend -warn-long-expression-type-checking=<ms>이고, 이것이 expression took 3290ms to type-check처럼 표현식별 시간을 정확히 알려준다. 해법은 타입을 앵커링하는 것 — 실측에서 0.16초로 떨어졌다.

원리

Swift의 타입 검사는 제약 해결(constraint solving)이다. 1 + 2 같은 표현식조차 컴파일러 입장에서는 "+의 수많은 오버로드 중 어느 것인가", "리터럴 1Int인가 Double인가 Float인가 다른 ExpressibleByIntegerLiteral 타입인가"를 함께 푸는 문제다. 후보가 각 위치에서 k개이고 위치가 n개면 최악의 조합은 k^n이다.

result builder가 이 문제를 키우는 이유는 제네릭 중첩이다. Q2 실측에서 Pair<Pair<Int, String>, Double>이 나온 것처럼, 원소가 늘어날 때마다 타입이 한 겹 깊어지고 각 겹마다 제약이 추가된다. SwiftUI 뷰 본문이 느린 이유가 정확히 이 조합이다 — 깊은 제네릭 중첩 + 수많은 오버로드(모디파이어) + 리터럴.

내부 동작

가장 순수한 형태로 재현한다. result builder도 SwiftUI도 없는 산술 한 줄이다.

그룹 개수를 늘려가며 측정 — let x: Double = 1.0 [+ 1 * 2 - 1] × n
  그룹 1개 →  0.17초  ✅ 성공
  그룹 2개 →  3.25초  ❌ 실패
  그룹 3개 →  3.44초  ❌ 실패
그룹 2개 — 실제로 컴파일되지 않는 한 줄
let x: Double = 1.0 + 1 * 2 - 1 + 1 * 2 - 1
print(x)
swiftc 실제 진단
th_2.swift:1:5: error: the compiler is unable to type-check this expression in
               reasonable time; try breaking up the expression into distinct
               sub-expressions

토큰 열 개짜리 한 줄이 컴파일되지 않는다. 그리고 진단이 권하는 "표현식을 쪼개라"는 조언이 이 경우에는 통하지 않는다 — 실측으로 두 줄로 쪼개도 각 줄이 같은 패턴을 유지하면 여전히 실패했다(3.23초).

무엇이 방아쇠인지 다섯 경우로 가른다.

원인 특정 — 다섯 경우 실측
(a) let d: Double = 1.0
    let x: Double = d + 1 * 2 - 1 + 1 * 2 - 1 + 1 * 2 - 1 + 1 * 2 - 1
                                              →  0.17초  ✅

(b) let x: Double = 1.0 + 1.0 * 2.0 - 1.0 + 1.0 * 2.0 - 1.0 + …
                                              →  0.16초  ✅

(c) let x: Double = 1.0 + 1 * 2 - 1 + 1 * 2 - 1 + 1 * 2 - 1 + 1 * 2 - 1
                                              →  3.51초  ❌

(d) (c)를 두 줄로 쪼갬                          →  3.23초  ❌

(e) let x: Int = 1 + 1 * 2 - 1 + 1 * 2 - 1 + …  →  0.16초  ✅

결론이 명확하다. 부동소수점 리터럴과 정수 리터럴이 한 체인에 섞이는 것이 방아쇠다. 1Int일 수도 Double일 수도 있고(31장 Q5의 ExpressibleByIntegerLiteral), +·*·-는 수많은 오버로드를 가지므로 솔버가 조합을 탐색한다. (b)·(e)처럼 리터럴 종류를 통일하거나, (a)처럼 타입이 확정된 변수를 체인에 넣으면 후보가 잘려 즉시 해결된다.

🟣 한 걸음 더 — 결과 타입 어노테이션은 왜 도움이 안 되는가 실측 근거

(c)에는 : Double 어노테이션이 이미 붙어 있다. 그런데도 실패한다. 이유는 결과 타입을 안다고 해서 중간 리터럴 각각의 타입이 결정되지는 않기 때문이다 — Double 결과를 만드는 조합이 여러 가지 있고(정수로 계산해 마지막에 변환, 처음부터 실수로 계산, 중간에 변환 …) 솔버는 그것들을 모두 후보로 본다. 반면 (a)는 체인 안쪽에 타입이 확정된 d가 있어, 그 지점에서 연산자 선택이 확정되고 그 결정이 양옆으로 전파된다. 어노테이션은 밖에서 감싸는 것보다 안에 심는 것이 강하다는 것이 이 실측의 교훈이다.

실험 · 도구

진단 플래그가 이 문제를 정량화한다. 임계값(ms)을 주면 그보다 오래 걸린 표현식을 위치와 시간과 함께 보고한다.

표현식별 타입 검사 시간 측정
$ swiftc -Xfrontend -warn-long-expression-type-checking=100 -c file.swift -o /dev/null

file.swift:1:17: warning: expression took 3290ms to type-check (limit: 100ms)

함수 본문 단위로 보는 짝도 있다.

함수 본문 단위 — 어느 함수가 느린지
$ swiftc -Xfrontend -warn-long-function-bodies=200 -c file.swift -o /dev/null

Xcode 프로젝트에서는 Other Swift Flags에 넣어 쓰고, SPM에서는 Package.swiftswiftSettings에 넣는다.

프로젝트에 상시 켜두기 — 회귀를 조기에 잡는다
# Xcode: Build Settings → Other Swift Flags (Debug 구성)
-Xfrontend -warn-long-expression-type-checking=200
-Xfrontend -warn-long-function-bodies=300

# SPM
.target(name: "App", swiftSettings: [
    .unsafeFlags(["-Xfrontend", "-warn-long-expression-type-checking=200",
                  "-Xfrontend", "-warn-long-function-bodies=300"])
])

프로젝트 적용

① 해법은 "타입을 안에 심는다"다. 실측 (a)가 그 형태였고, 같은 원리를 리터럴에 적용하면 이렇게 된다.

앵커링 — 3.5초에서 0.16초로
let one: Double = 1.0
let two: Double = 2.0
let x: Double = one + one * two - one + one * two - one + one * two - one
print(x)
실행 결과 — 0.16초에 컴파일되고 정상 실행
4.0

② result builder / SwiftUI 맥락의 실전 규칙 세 줄. Q1~Q3에서 본 구조가 왜 이런 조언으로 이어지는지가 이제 설명된다.

🔑 핵심

블록 안의 계산은 let이나 계산 프로퍼티로 꺼내 이름을 붙인다 — Q3에서 let이 허용되는 이유가 여기서 값을 만든다. 숫자 리터럴을 섞지 않는다 — 특히 CGFloat·Double·Int가 한 식에 섞이면 후보가 곱해진다. 블록이 길어지면 함수나 별도 타입으로 쪼갠다 — 제네릭 중첩 깊이가 줄어 타입 검사가 가벼워진다.

계산을 꺼내 이름을 붙인다 — 가독성과 컴파일 시간을 동시에
@resultBuilder
enum Layout {
    static func buildBlock(_ p: String...) -> String { p.joined(separator: " / ") }
}
func layout(@Layout _ body: () -> String) -> String { body() }

let base: Double = 8.0
let scale: Double = 1.5

// ✅ 계산을 먼저, 선언은 단순하게
let padding = base * scale
let margin  = base * scale * 2

print(layout {
    "padding=\(padding)"
    "margin=\(margin)"
})
실행 결과
padding=12.0 / margin=24.0

③ 진단을 CI에 넣는다. 이 문제의 성질은 "천천히 나빠진다"는 것이다 — 뷰에 한 줄씩 추가하다가 어느 날 갑자기 빌드가 몇 분 늘어난다. 임계값 경고를 Debug 빌드에 켜두면 그 한 줄이 들어오는 PR 시점에 드러난다. 29장 Q5에서 unsafe 개수로 위험 표면을 셌던 것과 같은 발상 — 정량화해 추적 가능하게 만드는 것이다.

🟣 한 걸음 더 — Part C를 관통하는 한 가지 종합

29·30·31·32장이 서로 다른 주제를 다뤘지만 같은 구조를 반복했다. 편리한 추상이 비용을 감추고, 그 비용은 조용한 실패로 나타난다. 29장은 포인터가 수명을 감춰 exit 0으로 통과했고, 31장은 Double이 근사를 감춰 sorted()가 정렬된 척했고, 이 챕터는 result builder가 제네릭 중첩을 감춰 컴파일이 갑자기 실패했다. 대응도 같은 모양이다 — 감춰진 것을 드러내는 도구를 켜고, 수치로 추적한다. 29장은 -strict-memory-safety, 31장은 isFinite·exactly: 검증, 이 챕터는 -warn-long-expression-type-checking이다. 세 도구 모두 런타임 비용 0이고, 셋 다 기본으로 꺼져 있다.

⚠️ 흔한 오해

"표현식을 쪼개면 항상 해결된다"는 컴파일러 진단문 자체가 주는 인상인데, 실측 (d)가 반례다 — 쪼갠 각 줄이 같은 패턴이면 여전히 실패한다. 쪼개는 게 아니라 타입을 확정하는 것이 해법이다. 또 "느린 컴파일은 result builder 탓"도 부정확하다 — 실측 재현에는 빌더가 전혀 없었다. 빌더는 증폭기이지 원인이 아니다. 마지막으로 "Release 빌드에서는 괜찮다"도 틀렸다. 타입 검사는 최적화와 무관한 프론트엔드 단계라 모든 빌드에서 같은 비용이 든다.

🧒 쉽게 이해하기

퍼즐을 맞추는데 조각마다 뒷면이 여러 색으로 칠해질 수 있다고 상상하자. 컴파일러는 "어떤 조합이면 전체가 맞아떨어지는가"를 찾아야 하는데, 조각이 늘어날수록 따져볼 조합이 곱하기로 늘어난다. 그래서 조각 열 개짜리 아주 작은 퍼즐도 3초를 쓰고 "못 풀겠다"고 포기할 수 있다. 해결책은 조각을 나눠 담는 게 아니라(그래도 각 무더기가 여전히 애매하다), 몇 조각의 색을 미리 정해주는 것이다. 하나만 확정해주면 그 옆 조각의 색도 자동으로 정해지고, 연쇄적으로 전체가 순식간에 풀린다. 비유가 깨지는 곳: 사람은 "이거 딱 봐도 Double이잖아"라고 생각하지만, 컴파일러는 가능한 모든 답을 규칙대로 다 따져봐야 하고 그게 규칙을 지키는 유일한 방법이다.

꼬리 질문

그룹 2개에서 실패하는데 그룹 3개는 왜 비슷한 시간(3.44초)인가? 지수적으로 늘어나야 하지 않나?
컴파일러에 내부 한도가 있기 때문이다. 타입 검사기는 탐색한 해 후보 수나 소요 시간이 임계를 넘으면 탐색을 포기하고 진단을 낸다 — 그것이 unable to type-check this expression in reasonable time 메시지다. 그래서 실측 시간이 3.2~3.5초 부근에서 평탄해진다. 즉 관측된 값은 "이 표현식을 푸는 데 걸린 시간"이 아니라 "포기하기까지 걸린 시간"이다. 실무적 함의가 있다 — 이 종류의 문제는 임계 아래에서는 아무 신호가 없고(그룹 1개는 0.17초로 정상) 임계를 넘는 순간 컴파일 실패로 바뀐다. 점진적으로 느려지는 게 아니라 절벽이라서, 진단 플래그를 켜서 임계에 다가가는 것을 미리 보는 것이 중요하다.
쉽게 말하면 퍼즐을 3초 동안 풀어보고 안 되면 그냥 손을 놓는 규칙이 있는 것이다. 그래서 퍼즐이 얼마나 어렵든 포기하는 시간은 비슷하다.
CGFloatDouble이 섞이면 특히 문제가 되는 이유는?
두 타입 사이에 암묵적 변환이 언어 차원에서 특별 대우로 허용되기 때문이다(SE-0307). 편의성을 위한 조치지만, 타입 검사기 입장에서는 각 위치에서 후보가 하나 더 늘어나는 것과 같다 — "이 자리는 CGFloat인가 Double인가, 아니면 변환이 끼는가". 31장 Q5에서 본 정수/실수 리터럴 혼합에 이 축이 곱해지면 탐색 공간이 더 커진다. UIKit·Core Graphics API가 CGFloat를 쓰고 순수 Swift 코드는 Double을 쓰므로, 두 경계에서 계산하는 레이아웃 코드가 이 문제의 주 무대다. 실무 규칙은 경계에서 한 번 변환해 이름을 붙이고, 계산은 한 타입으로만 하는 것이다 — 이것이 Q4 ①의 앵커링과 같은 처방이다.
쉽게 말하면 두 나라 화폐를 자유롭게 섞어 쓸 수 있게 해두면 편하긴 한데, 계산할 때 "이 숫자는 어느 화폐지?"를 매번 따져야 해서 계산이 훨씬 복잡해지는 것과 같다.
SwiftUI에서 이 진단을 켜면 프레임워크 내부 코드 경고까지 쏟아지지 않는가?
경고는 내가 컴파일하는 소스에 대해서만 나온다 — 프레임워크는 이미 컴파일된 바이너리이므로 그 내부 표현식이 측정되지 않는다. 다만 내 코드의 한 줄이 프레임워크의 수많은 제네릭·오버로드 때문에 오래 걸리는 것으로 보고될 수 있고, 그것이 정확히 알고 싶은 정보다. 실전 도입 순서는 이렇다. ① 임계값을 높게(500ms 이상) 시작해 최악의 자리만 본다. ② 그 자리들을 let 추출·뷰 분리로 정리한다. ③ 임계값을 단계적으로 낮춘다(300 → 200 → 100). 처음부터 100ms로 켜면 경고가 수백 개 나와 아무도 보지 않게 되므로, 임계값을 예산처럼 관리하는 것이 핵심이다. 29장에서 -strict-memory-safety를 모듈 단위로 점진 도입하라고 한 것과 같은 전략이다.
쉽게 말하면 내 집만 점검하는 것이고, 남의 집(프레임워크)은 이미 지어져 들어온 것이라 점검 대상이 아니다. 다만 내가 쓴 한 줄이 남의 집 구조 때문에 느려진 것일 수는 있어서, 결국 내 쪽에서 손을 봐야 한다.

출처 · 참고자료