← Swift & Foundation 로드맵 9 / 33

09 · 열거형과 패턴 매칭

enum이 메모리에서 실제로 어떤 모양을 하고 있는지, switch의 망라성은 컴파일러가 어떻게 증명하는지, 그리고 ~= 연산자 하나로 얼마나 강력한 패턴 매칭 DSL을 만들 수 있는지를 실측으로 다룬다.
진행률
0 / 0 완료

enum은 case north, south 몇 개 나열하고 switch로 분기하는 문법으로 익숙해서, 딱 그 정도로만 취급하고 넘어가기 쉽다. 하지만 실제로는 세 가지 결이 다른 질문이 숨어 있다. 첫째, enum 값은 메모리에서 정확히 몇 바이트를 차지하는가 — 그리고 indirect 하나로 왜 "자기 자신을 담는 타입"이 갑자기 가능해지는가. 둘째, switch가 "모든 경우를 다뤘다"고 컴파일 타임에 증명할 수 있는 근거는 무엇이고, 그 증명이 왜 어떤 enum에서는 깨지는가(@unknown default). 셋째, case 문법 전체를 떠받치는 연산자 하나(~=)를 알면 switch가 단순 분기문이 아니라 직접 확장 가능한 패턴 매칭 DSL이라는 게 보인다. 이 챕터는 이 세 갈래를 MemoryLayout과 실제 컴파일러 실험으로 확인하며 파고든다.

Q1. enum은 메모리에서 어떻게 표현되는가? indirect는 무엇을 바꾸는가?

🔑 30초 답변

enum의 메모리 표현은 케이스 모양에 따라 갈린다. 데이터 없는 케이스만 있으면 어느 케이스인지 구분할 태그 하나만 있으면 되고, 케이스가 딱 하나뿐이면 구분할 정보 자체가 필요 없어 크기가 0바이트가 된다. 데이터 있는 케이스가 1개뿐(single-payload)이면 컴파일러는 그 payload 타입이 이미 갖고 있는 "쓸모없는 비트 패턴"(spare bit/extra inhabitant, 03장에서 다룬 것과 같은 메커니즘)을 재활용해 태그를 공짜로 얹는다. 데이터 있는 케이스가 2개 이상(multi-payload)이면 대개 별도 태그 바이트가 붙는다. 그리고 enum이 자기 자신을 담는 case(재귀 타입, 수식 트리 등)를 가지면 크기가 무한대가 되어 컴파일 자체가 막히는데, indirect는 그 case의 payload를 힙에 박싱해 enum 값 자체는 포인터 하나짜리 고정 크기로 만들어준다.

원리

Swift ABI 공식 문서(docs/ABI/TypeLayout.rst)는 enum 레이아웃을 세 갈래로 분류한다. "no-payload"(모든 케이스가 데이터 없음), "single-payload"(데이터 있는 케이스가 정확히 1개, 나머지는 데이터 없음 — Optional이 정확히 이 모양이다), "multi-payload"(데이터 있는 케이스가 2개 이상). 각 분류마다 태그를 어디서 조달하는지가 다르다: no-payload는 케이스 수를 표현할 최소 비트만 있으면 되고, single-payload는 payload 타입의 extra inhabitant(그 타입으로는 나올 수 없는 비트 패턴)나 spare bit(항상 0/무시되는 비트)를 우선 재활용하며, 그마저 없으면 별도 태그 바이트를 추가한다. 값 타입은 컴파일 타임에 크기가 확정돼야 한다는 원칙 때문에, enum이 자기 자신을 필드로 담으면 크기 계산이 끝나지 않는다 — indirect는 그 case의 실제 데이터를 힙 박스 뒤로 숨겨, enum 자신은 "박스를 가리키는 포인터 하나"라는 유한한 크기로 만들어 이 문제를 해결한다.

내부 동작

말로 설명하는 것보다 직접 재보는 게 빠르다. 아래는 Swift 6.2.1에서 swiftc로 컴파일해 실측한 값이다.

enum 종류예시sizestride왜 이 값인가
no-payload, 케이스 1개enum OneCase { case only }01구분할 정보 자체가 없다 — Void와 같은 이치
no-payload, 케이스 4개enum Direction { .north/.south/.east/.west }112비트면 충분하지만 최소 단위인 1바이트로
single-payload, spare bit 재사용enum Maybe { case a(String); case b }1616String과 완전히 동일 — 오버헤드 0
single-payload, spare bit 없음enum MaybeInt { case a(Int); case b }916Int는 여분 비트가 0개라 태그 바이트 추가
multi-payloadenum MultiPayload { case a(Int); case b(Double) }916두 payload를 구분할 태그가 별도로 필요
indirect(enum 전체)indirect enum Expr { .number/.add/.negate }88모든 case가 힙 박스 뒤로 — 값 자체는 포인터 1개
indirect(case 단위)case .number(Int); indirect case .add(...)916재귀 case만 박싱, .number는 그대로 인라인

String은 이미 discriminator 비트를 내장하고 있어 Maybe가 그 여분을 그대로 물려받지만, Int는 8바이트 전부가 유효한 정수값이라 여분이 전혀 없다 — 그래서 MaybeInt는 태그 1바이트가 추가되고, Int의 정렬(8)에 맞춰 stride가 16으로 올림된다. MultiPayload(Int+Double)도 같은 이유로 9/16이 나온다. 흥미로운 건 indirect 두 형태의 차이다 — indirect enum으로 전체를 박싱하면 .number(Int)처럼 재귀와 무관한 case까지 전부 힙 뒤로 넘어가, enum 값 자체는 포인터 8바이트 그 자체가 된다. 반면 재귀 case에만 indirect를 붙이면 .number(Int)는 인라인으로 남고, enum은 "가장 큰 인라인 payload(Int 8B 또는 박스 포인터 8B) + 태그 1B"인 9/16이 된다 — 흔히 쓰는 leaf case(숫자 하나)에 대해서는 힙 역참조 없이 접근할 수 있다는 뜻이다.

케이스가 늘어날수록 태그를 어디서 조달하는가 무페이로드 Direction (4 cases) 1바이트 싱글페이로드 Maybe (String / none) +0바이트 (spare bit) 멀티페이로드 Int / Double 2 cases +1바이트(→16 정렬) indirect Expr (재귀, 힙 박싱) 포인터 1개 = 8바이트 재귀 case(자기 자신을 담는 case)가 있으면? indirect 없이 case add(Expr, Expr) 컴파일 에러 — 크기 무한대 vs indirect enum Expr add(l, r)의 실제 데이터는 힙 박스 안 값 자체는 유한(포인터) indirect case만 골라 붙이면 재귀와 무관한 case(예: .number(Int))는 인라인 유지 → 9/16바이트
no-payload는 태그만, single-payload는 spare bit로 공짜, multi-payload는 태그 추가, indirect는 재귀를 포인터 하나로 유한화한다.

실험 · 도구

아래 스크립트를 swift enum_layout.swift로 그대로 실행하면 위 표의 모든 값을 직접 재현할 수 있다.

enum 레이아웃 프로브 — MemoryLayout으로 직접 확인
enum OneCase { case only }
enum Direction { case north, south, east, west }
print(MemoryLayout<OneCase>.size, MemoryLayout<OneCase>.stride)      // 0 1
print(MemoryLayout<Direction>.size, MemoryLayout<Direction>.stride)  // 1 1

enum Maybe { case a(String); case b }
print(MemoryLayout<String>.size, MemoryLayout<Maybe>.size)          // 16 16 — 오버헤드 0

enum MaybeInt { case a(Int); case b }
print(MemoryLayout<Int>.size, MemoryLayout<MaybeInt>.size)          // 8  9  — 태그 추가

enum MultiPayload { case a(Int); case b(Double) }
print(MemoryLayout<MultiPayload>.size, MemoryLayout<MultiPayload>.stride) // 9 16

indirect enum Expr { case number(Int); case add(Expr, Expr); case negate(Expr) }
print(MemoryLayout<Expr>.size)   // 8 — 포인터 하나

// case 단위 indirect: 재귀 아닌 케이스는 인라인 유지
enum ExprPartial {
    case number(Int)
    indirect case add(ExprPartial, ExprPartial)
    indirect case negate(ExprPartial)
}
print(MemoryLayout<ExprPartial>.size)  // 9

재귀 enum에서 indirect를 빼면 컴파일러가 바로 막는다 — 직접 재현해보면 이렇다.

indirect 없는 재귀 enum — 컴파일 자체가 안 된다
enum Expr {
    case number(Int)
    case add(Expr, Expr)   // indirect 없음
}
// swiftc 실행 결과:
// error: recursive enum 'Expr' is not marked 'indirect'
// note: cycle beginning here: (Expr, Expr) -> (.0: Expr)

프로젝트 적용

수식 트리·연결 리스트·JSON 트리처럼 "자기 자신을 담는" 모델은 indirect 없이는 애초에 컴파일이 안 된다. 다만 힙 박싱은 공짜가 아니다 — .add 케이스에 접근할 때마다 힙 역참조와 ARC 비용이 붙는다는 걸 알고 써야 한다.

재귀 수식 트리 — indirect로 유한 크기를 만들고 재귀 평가
indirect enum Expr {
    case number(Int)
    case add(Expr, Expr)
    case negate(Expr)
}

func evaluate(_ e: Expr) -> Int {
    switch e {
    case .number(let n):
        return n
    case .add(let l, let r):
        return evaluate(l) + evaluate(r)   // 힙에 박싱된 두 서브트리를 각각 역참조
    case .negate(let e):
        return -evaluate(e)
    }
}

// (3 + (-4))
let tree = Expr.add(.number(3), .negate(.number(4)))
print(evaluate(tree))  // -1

// ⚠️ 노드마다 힙 할당 1회 + ARC. 성능이 중요한 대량 트리(파서 AST 등)라면
//    노드 수만큼 힙 할당이 늘어난다는 걸 감안해야 한다.

"성공/실패" 류 커스텀 enum을 설계할 때, payload 타입이 spare bit를 가졌는지 여부가 대량 배열의 메모리 사용량을 좌우한다. 참조 타입이나 String을 payload로 쓰면 "데이터 없음" 케이스가 공짜지만, Int·Double이면 그렇지 않다.

payload 타입 선택이 배열 메모리에 미치는 실질적 차이
// ❌ Int payload — 케이스 하나 늘리는 게 진짜로 1바이트+패딩을 늘린다
enum CountResult { case value(Int); case unavailable }
print(MemoryLayout<CountResult>.stride)  // 16 (Int 8B + 태그 1B → 정렬 패딩)

// ✅ 참조/String류 payload — "없음" 케이스가 사실상 공짜
enum TextResult { case value(String); case unavailable }
print(MemoryLayout<TextResult>.stride)  // 16 (String과 완전히 동일)

// 10,000개 배열이면 CountResult든 TextResult든 stride는 같지만,
// "Int만 담는 옵셔널 스러운 케이스"를 직접 설계할 때는
// 태그가 별도로 붙는지 MemoryLayout으로 먼저 재보는 습관이 감보다 정확하다.
⚠️ 흔한 오해

"enum 크기는 항상 가장 큰 case 크기 + 태그"라는 말은 절반만 맞다. Maybe(String payload)처럼 payload 타입이 spare bit를 이미 갖고 있으면 태그가 추가되지 않는다String과 완전히 같은 16바이트다. 태그가 실제로 늘어나는 건 payload 타입에 여분 비트가 없을 때(Int, Double 등)뿐이다. 또 "indirect를 붙이면 무조건 느려진다"도 과장이다 — indirect case로 재귀 case만 골라 붙이면 흔히 쓰는 leaf case는 여전히 인라인으로 남는다.

🧒 쉽게 이해하기

enum은 사물함이라고 생각하면 된다. 칸이 하나뿐이면 이름표조차 필요 없다(0바이트). 칸이 여러 개인데 다 빈 칸이면 번호표 하나만 붙이면 된다(1바이트). 그런데 어떤 칸(payload)은 원래 자물쇠 다이얼에 안 쓰는 숫자 조합이 남아있어서, 그 남는 조합 하나를 "이 칸은 비었다"는 신호로 그냥 재활용할 수 있다 — 그래서 번호표가 따로 안 붙는다. indirect는 "사물함 안에 또 사물함을 넣고 싶다"는 요청에 대한 답이다 — 사물함 안에 사물함 실물을 넣으면 크기를 잴 수가 없으니, 대신 "창고 몇 번 칸에 있음"이라는 쪽지(포인터) 한 장만 넣어두는 것이다. 비유가 깨지는 곳: 실제로는 컴파일러가 어떤 타입에 "남는 조합"이 있는지를 정수·포인터 같은 몇몇 정해진 규칙으로만 알아본다 — 아무 사물함에나 마음대로 여분을 찾아주는 건 아니다.

꼬리 질문

케이스가 딱 1개뿐인 no-payload enum(OneCase)은 왜 size가 0인가?
enum의 태그는 "지금 어느 케이스인가"를 구분하기 위해 존재한다. 그런데 케이스가 하나뿐이면 애초에 구분할 대상이 없다 — 그 값은 항상 유일하게 하나뿐이므로 어떤 정보도 저장할 필요가 없다. 이건 Swift의 Void(())가 0바이트인 것과 정확히 같은 이치다. 실제로 나올 수 있는 값이 하나뿐인 타입은 "그 값이 존재한다"는 사실 자체가 타입 시스템 수준의 정보이고, 런타임에 굳이 비트를 소비할 이유가 없다.
쉽게 말하면 사물함이 딱 한 칸뿐이면 번호표를 붙일 필요가 없다. 어차피 갈 곳이 거기 하나뿐이니까.
indirect enum(전체 박싱)과 indirect case(케이스 단위 박싱)는 실제로 무엇이 다른가?
indirect enum Expr처럼 enum 전체에 붙이면 .number(Int)처럼 재귀와 무관한 케이스까지 전부 힙 박스 뒤로 넘어간다 — 그 결과 enum 값 자체는 "박스를 가리키는 포인터 하나"로 수렴해 8바이트가 된다(실측). 반면 재귀 케이스에만 indirect case를 붙이면 .number(Int)는 그대로 인라인으로 남고, enum은 "가장 큰 인라인 표현(Int 8B 또는 박스 포인터 8B) + 태그 1B"인 9바이트(stride 16, 실측)가 된다. 즉 흔히 압도적으로 많이 나오는 leaf case(단순 숫자)를 자주 다루는 트리라면, case 단위 indirect가 그 leaf에 대해서는 힙 역참조 비용을 아예 없애준다.
쉽게 말하면 사물함 전체를 창고 위탁으로 돌리면 뭘 꺼내든 항상 창고까지 가야 한다. 반면 "가끔 쓰는 큰 짐만" 위탁하고 자주 쓰는 작은 물건은 사물함에 그대로 두면, 작은 물건 꺼낼 때는 창고까지 갈 필요가 없다.
페이로드가 둘 다 클래스 참조(AnyObject)인 멀티페이로드 enum도 spare bit로 공짜가 될 수 있는가?
직접 재보면 enum { case a(AnyObject); case b(AnyObject) }도 9바이트(stride 16)가 나온다 — 즉 두 case 모두 포인터라 여유 비트가 많아 보여도, 실제로는 태그가 그대로 추가된다. ABI 문서는 spare bit 계산이 "주로 primitive integer 타입의 상위 비트" 위주로 이뤄진다고 명시하는데, 이건 03장에서 커스텀 struct의 여분 비트가 항상 인식되는 건 아니라고 짚었던 것과 같은 한계다 — 포인터라고 해서 멀티페이로드 조합에서 자동으로 최적화되는 건 아니라는 뜻이다. 결론은 같다: 감으로 짐작하지 말고 MemoryLayout으로 직접 재는 수밖에 없다.
쉽게 말하면 열쇠가 남는 조합이 많아 보여도, 사물함 규칙이 "이 조합은 특별한 몇 가지 경우만 재활용해준다"고 정해져 있으면 나머지는 그냥 새 번호표를 만든다. 여유가 있어 보인다고 항상 공짜가 되는 건 아니다.

Q2. switch의 망라성(exhaustiveness)은 어떻게 보장되며 @unknown default(SE-0192)는 왜 필요한가?

🔑 30초 답변

망라성은 컴파일 타임에 컴파일러가 그 enum의 전체 case 목록을 완벽히 알고 있다는 전제 위에서 성립하는 정적 증명이다. 자기 모듈 enum이나, 일반적으로 배포된(library evolution 없이 컴파일된) 의존성의 enum은 클라이언트가 항상 그 정확한 정의로 재컴파일되므로 목록이 확정적이다. 문제는 library evolution(ABI 안정성)이 켜진 모듈의 non-frozen(고정되지 않은) enum이다 — Apple 시스템 프레임워크가 대표적인데, 클라이언트를 재컴파일하지 않고도 나중 OS 버전에서 새 case가 조용히 추가될 수 있다. 이런 enum을 @unknown default 없이 switch하면 컴파일러가 "네가 놓칠 수 있는 케이스가 있다"고 경고한다 — 실측 결과 Swift 5 모드에서는 경고지만 Swift 6 언어 모드에서는 아예 컴파일 에러다. @frozen은 그 enum의 case 목록을 ABI 계약으로 영구히 고정하겠다는 약속이라, 붙이면 외부에서도 @unknown default 없이 망라적으로 switch할 수 있다.

원리

SE-0192는 이 문제를 "resilient(유연한) 라이브러리"의 관점에서 제기한다. 라이브러리가 ABI를 깨지 않고 계속 진화하려면, "이 enum에 나중에 케이스가 추가될 수도 있다"는 가능성을 열어둬야 한다. 하지만 그렇게 하면 클라이언트의 switch가 "이 케이스들만 있다"고 가정하고 쓴 코드가, 훗날 새 케이스가 추가되는 순간 조용히 오동작할 위험이 생긴다. @unknown default는 이 둘을 절충한다 — "지금 아는 케이스는 다 개별로 처리하되, 모르는 미래의 케이스가 와도 여기서 명시적으로 받아라"를 문법으로 강제한다. @unknowndefault:case _: 앞에만 붙을 수 있다.

내부 동작

직접 두 모듈을 만들어 확인했다. -enable-library-evolution으로 컴파일한 프레임워크에 non-frozen Status@frozen FixedStatus를 두고, 클라이언트에서 각각 @unknown default 없이 switch해봤다.

library evolution 모듈 빌드 — 실제로 -enable-library-evolution 플래그로
// Framework.swift
public enum Status {            // non-frozen(기본값) — 나중에 케이스가 늘 수 있다
    case active
    case inactive
}

@frozen
public enum FixedStatus {       // frozen — 케이스 추가 자체를 금지하는 약속
    case on
    case off
}

// 빌드: swiftc -emit-module -emit-library -module-name Framework \
//              -enable-library-evolution Framework.swift -o libFramework.dylib

클라이언트에서 @unknown default 없이 switch하면, Status에서만 경고가 뜬다 — FixedStatus는 조용하다.

실측 컴파일 결과 — non-frozen만 경고, frozen은 조용함
import Framework
func describe(_ s: Status) -> String {
    switch s {
    case .active: return "on"
    case .inactive: return "off"
    // @unknown default 없음
    }
}
// swiftc(기본/Swift 5 모드) 결과:
// warning: switch covers known cases, but 'Status' may have additional
//          unknown values; this is an error in the Swift 6 language mode
// note: handle unknown values using "@unknown default"

func describeFixed(_ s: FixedStatus) -> String {
    switch s {
    case .on: return "on"
    case .off: return "off"
    // 경고 없음 — @frozen이라 컴파일러가 케이스 목록을 '영구 확정'으로 신뢰한다
    }
}

// swiftc -swift-version 6 로 같은 코드를 컴파일하면:
// error: switch covers known cases, but 'Status' may have additional unknown values
// -> 경고가 아니라 빌드 실패

흥미로운 대조 실험 하나 더: 같은 non-frozen enum이라도 -enable-library-evolution 없이 컴파일된 평범한 모듈에서 가져오면 @unknown default가 없어도 경고 자체가 뜨지 않는다(직접 확인됨). 클라이언트가 항상 그 모듈의 정확한 소스/스냅샷으로 재컴파일되는 이상, 컴파일러 입장에서는 그 case 목록이 "지금 보이는 그대로 확정"이기 때문이다. 즉 @unknown default가 실질적 의미를 갖는 건 "내 모듈 밖 enum"이라서가 아니라, 클라이언트를 재컴파일하지 않고도 라이브러리만 새 버전으로 교체될 수 있는 ABI 경계를 넘을 때뿐이다.

실험 · 도구

위 실험은 로컬에서 그대로 재현 가능하다. 두 모듈을 만들고 -I . -L . -lFramework로 링크해 컴파일한 뒤, -swift-version 5/-swift-version 6를 바꿔가며 경고가 에러로 바뀌는 지점을 직접 봐야 실감이 난다. Xcode에서는 프로젝트의 Swift Language Version 설정이 이 경계에 해당한다 — Swift 6 모드로 마이그레이션하는 프로젝트가 시스템 프레임워크 enum을 switch하는 곳에서 갑자기 빌드가 깨지는 흔한 원인이 바로 이것이다.

프로젝트 적용

Apple 프레임워크(예: 권한 상태, 네트워크 상태류 enum)를 switch할 때는 습관적으로 @unknown default를 붙이고, 거기서 원격 로깅이라도 남겨 "OS가 새 케이스를 추가했다"는 신호를 놓치지 않게 한다.

@unknown default — 놓치는 대신 알아채기
import Foundation

func describe(_ status: Status) -> String {
    switch status {
    case .active: return "활성"
    case .inactive: return "비활성"
    @unknown default:
        // 지금 빌드가 모르는 케이스 — 조용히 무시하지 않고 신호를 남긴다
        assertionFailure("Status에 처리하지 않은 새 케이스가 추가됨: \(status)")
        return "알 수 없음"
    }
}

내가 만든 라이브러리를 library evolution으로 배포한다면, "이 enum엔 절대 케이스를 안 늘린다"고 확신할 때만 @frozen을 붙인다. 한번 붙이면 이후 버전에서 케이스 추가가 ABI를 깨는 변경이 된다는 걸 팀 전체가 알고 있어야 한다.

@frozen은 되돌릴 수 없는 약속 — 신중하게
// ✅ 절대 안 늘어날 확신이 있을 때만 (예: 나침반 방향처럼 개념 자체가 고정)
@frozen
public enum CompassDirection { case north, south, east, west }

// ❌ 앞으로 케이스가 늘 가능성이 있는 도메인이면 frozen을 붙이지 않는다
//    (그래야 다음 버전에서 case를 추가해도 클라이언트 ABI가 안 깨진다.
//     대신 클라이언트는 @unknown default를 강제로 마주하게 된다.)
public enum PaymentMethod { case card, cash /* 나중에 .applePay 추가 예정 */ }
⚠️ 흔한 오해

"@unknown default가 없으면 컴파일이 안 된다"는 절반만 맞다 — Swift 5 언어 모드에서는 경고일 뿐이고, Swift 6 언어 모드로 넘어가야 비로소 에러가 된다(실측). 또 "모든 외부(다른 모듈) enum엔 @unknown default가 필요하다"도 부정확하다 — library evolution 없이 컴파일된 일반 SPM 의존성의 enum은 자기 모듈 enum과 똑같이 취급되어 경고조차 뜨지 않는다(실측). 이 규칙이 실제로 적용되는 대상은 "내 모듈 밖"이 아니라 "재컴파일 없이 새 버전으로 바뀔 수 있는 ABI 경계 너머"다.

🧒 쉽게 이해하기

학교 급식 메뉴판을 생각해보자. 우리 반이 직접 손으로 써 붙인 메뉴판(내 모듈 enum)은 반 애들이 다 같이 보고 있으니 메뉴가 바뀌면 바로 다 안다. 그런데 급식실 벽에 걸린, 학교 전체가 공유하는 메뉴판(라이브러리 진화 모듈의 enum)은 다르다 — 급식실이 미리 인쇄해둔 안내문을 며칠째 계속 붙여놓고 있는데, 정작 주방에서는 이미 새 메뉴를 추가했을 수도 있다. 그래서 "지금 안내문에 있는 메뉴 말고 딴 게 나올 수도 있다"는 문구(@unknown default)를 넣어두라는 것이다. 비유가 깨지는 곳: @frozen은 "이 메뉴는 학교를 졸업할 때까지 절대 안 바뀐다"고 인쇄물에 도장을 찍어버리는 것과 같다 — 한번 도장을 찍으면 나중에 메뉴를 늘리고 싶어도 이미 나간 인쇄물들과 약속을 어기게 된다.

꼬리 질문

library evolution 없이 컴파일된 일반 의존성의 enum에도 @unknown default가 필요한가?
직접 실험해보면 아니다. -enable-library-evolution 없이 빌드한 모듈의 public enum을 @unknown default 없이 switch해도 경고 자체가 뜨지 않는다. 이유는 명확하다 — 그런 모듈은 소스 호환 배포 형태라, 라이브러리 쪽 코드가 바뀌면 클라이언트도 그 새 정의로 다시 컴파일된다. 애초에 "클라이언트는 옛 정의를 그대로 실행 중인데 라이브러리만 새 버전"이라는 시나리오 자체가 성립하지 않으므로, 컴파일러가 case 목록을 확정적으로 신뢰해도 안전하다.
쉽게 말하면 매번 새로 복사해서 나눠주는 유인물은 항상 최신판이라 걱정할 필요가 없다. 문제가 되는 건 예전에 인쇄해서 이미 배포해버린, 다시 걷어올 수 없는 안내문 쪽이다.
@frozen이 붙은 public enum에 실수로 케이스를 추가하면 어떻게 되는가?
@frozen은 "이 case 목록이 그대로 ABI 계약이 된다"는 선언적 약속이다. 로컬 컴파일 한 번만으로는 "과거에 배포된 바이너리와의 호환"까지 검증할 수 없고, 이건 별도의 ABI 안정성 검사 도구(예: 공개된 .swiftinterface 베이스라인과의 diff)로 확인하는 영역이다. 실무적으로는 "이미 세상에 풀린 이전 버전의 앱이, 새 버전의 라이브러리 바이너리만 교체해서 실행돼도 깨지지 않아야 한다"는 계약을 어기는 것과 같다 — 그래서 @frozen은 정말로 앞으로 절대 안 늘어난다고 확신하는 enum에만 붙여야 한다.
쉽게 말하면 "이 메뉴는 절대 안 바뀐다"고 도장 찍어 이미 온 동네에 뿌린 전단지를, 나중에 몰래 다시 걷어서 고칠 수는 없다. 약속을 지키는 유일한 방법은 애초에 그 약속을 신중하게 하는 것뿐이다.
Swift 6 언어 모드로 마이그레이션할 때 이 규칙이 왜 갑자기 빌드를 깨뜨릴 수 있는가?
Swift 5 모드에서는 시스템 프레임워크 enum을 @unknown default 없이 switch해도 경고만 뜨고 빌드는 통과했다. 그런데 프로젝트를 Swift 6 언어 모드로 올리는 순간, 실측 확인대로 같은 코드가 컴파일 에러로 바뀐다. 오랫동안 경고를 무시해온 코드베이스일수록, 정작 로직과 무관한 이 하나의 규칙 때문에 Swift 6 마이그레이션 시점에 빌드가 무더기로 깨지는 경우가 흔하다 — 마이그레이션 전에 경고 로그에서 이 패턴을 미리 검색해두는 게 실무적으로 유용하다.
쉽게 말하면 예전엔 선생님이 "이거 조심해" 하고 한마디만 하고 넘어갔는데, 학년이 올라가니 똑같은 실수를 하면 이제는 감점(에러) 처리를 하는 것과 같다. 규칙은 그대로인데 봐주는 정도가 달라진 것이다.

Q3. if case / for case / where / 튜플 패턴은 각각 무슨 문제를 푸는가?

🔑 30초 답변

전부 switch를 떠받치는 같은 패턴 매칭 문법을 다른 위치에 재사용한 것들이다. if case는 "여러 케이스 중 딱 하나만 관심 있을 때" 굳이 전체 switch를 쓰지 않아도 되게 해주고, for case ... where는 시퀀스를 순회하며 특정 패턴에 맞는 원소만, 그것도 추가 조건까지 걸어서 한 줄로 걸러낸다. 튜플 패턴은 여러 값을 한 번에 구조 분해하면서 -2...2 같은 레인지까지 매칭에 섞을 수 있게 하고, 옵셔널 패턴(case let x?)은 옵셔널 시퀀스에서 nil이 아닌 원소만 바로 언래핑해 순회한다. is/as 패턴은 Any 컬렉션에서 런타임 타입으로 분기하고, 이 모든 패턴은 중첩(.shape(.circle(let r)))해서 여러 단계를 한 번에 파고들 수 있다.

원리

Swift 레퍼런스 매뉴얼(Patterns.md)은 패턴을 와일드카드·식별자·값 바인딩·튜플·enum case·옵셔널·타입 캐스팅(is/as)·표현식(~=) 이렇게 정리한다. if casefor case는 별도 메커니즘이 아니라 switch의 단일 케이스 버전이다 — 실패하면 그냥 조건이 거짓인 if이거나 그 원소를 건너뛰는 for로 컴파일된다(ControlFlow.md). 그래서 문법은 다양해 보여도 배후의 매칭 규칙은 switch와 완전히 동일하다.

내부 동작

여섯 갈래 패턴이 실무에서 정확히 어떤 문제를 푸는지 표로 정리했다. 전부 아래 실험 · 도구 코드로 직접 실행해 확인한 것이다.

패턴푸는 문제예시
if caseenum의 한 케이스만 관심 있고 나머지는 무시하고 싶을 때if case .connected(let ssid) = event { ... }
for case ... where시퀀스에서 특정 케이스 + 추가 조건을 만족하는 원소만 골라내기for case .failed(let c, _) in events where c >= 500
튜플 + 레인지여러 값의 조합 + 범위를 switch 한 번으로 분기case (-10...10, -10...10):
옵셔널 패턴 x?옵셔널 시퀀스에서 nil이 아닌 원소만 바로 순회for case let n? in maybeNumbers
is / asAny 컬렉션에서 런타임 타입별로 분기 + 캐스팅case let d as Double:
중첩 enum 패턴여러 단계의 enum을 한 번에 파고들기case .shape(.circle(let r)):

실험 · 도구

위 표의 각 패턴은 아래 코드를 swift patterns.swift로 실행해 실제로 확인한 것이다.

패턴 6종 — 실제로 컴파일·실행해 확인
enum NetworkEvent {
    case connected(String)
    case failed(code: Int, message: String)
    case idle
}
let events: [NetworkEvent] = [.connected("a"), .idle, .failed(code: 500, message: "x")]

// for case let ... where — 특정 케이스 + 조건
for case .failed(let code, let msg) in events where code >= 500 {
    print("서버 에러:", code, msg)   // 서버 에러: 500 x
}

// 튜플 패턴 + 레인지
let point = (3, 7)
switch point {
case (0, 0): print("origin")
case (-10...10, -10...10): print("근처")   // 여기 매치
default: print("멀리")
}

// 옵셔널 패턴 — nil 아닌 것만
for case let n? in [1, nil, 3, nil, 5] as [Int?] {
    print("값 있음:", n)   // 1, 3, 5만 출력
}

// 중첩 enum 패턴 — 한 번에 파고들기
enum Shape { case circle(radius: Double) }
enum Layer { case shape(Shape) }
if case .shape(.circle(let r)) = Layer.shape(.circle(radius: 5)) {
    print("반지름:", r)   // 5.0
}

프로젝트 적용

특정 케이스 + 조건으로 필터링해야 하면 for case ... where 한 줄이, 중첩 if보다 "무엇을 걸러내는지"가 코드만 봐도 바로 보인다.

for case where vs 중첩 if — 조건이 늘어날수록 차이가 벌어진다
// ❌ 나쁜 예 — 케이스 확인과 조건이 중첩되어 "뭘 걸러내는지"가 들여쓰기 속에 묻힌다
for event in events {
    if case .failed(let code, let message) = event {
        if code >= 500 {
            print("서버 에러:", code, message)
        }
    }
}

// ✅ 좋은 예 — 한 줄에 "어떤 케이스"와 "어떤 조건"이 나란히 드러난다
for case .failed(let code, let message) in events where code >= 500 {
    print("서버 에러:", code, message)
}

enum 안에 enum이 들어있는 구조(레이어·트리·중첩 상태)는 중첩 패턴으로 한 번에 파고드는 게, 단계마다 if case를 반복하는 것보다 훨씬 납작해진다.

중첩 enum 패턴 — 단계별 if case를 한 줄로 접기
enum Shape { case circle(radius: Double); case rectangle(w: Double, h: Double) }
enum Layer { case shape(Shape); case group([Layer]) }
let layer = Layer.shape(.circle(radius: 5))

// ❌ 나쁜 예 — 파고드는 단계 수만큼 들여쓰기가 깊어진다
if case .shape(let shape) = layer {
    if case .circle(let r) = shape {
        print("원 반지름:", r)
    }
}

// ✅ 좋은 예 — 몇 단계를 파고들든 패턴 하나로 표현
if case .shape(.circle(let r)) = layer {
    print("원 반지름:", r)
}

여러 값의 조합을 범위로 분기해야 하면 튜플 패턴 + 레인지가, 개별 if/else if를 겹겹이 쌓는 것보다 "경우의 수"를 한눈에 보여준다.

튜플 + 레인지 패턴 vs if-else 사슬
let point = (3, 7)

// ❌ 나쁜 예 — 조합이 늘어날수록 조건식이 길어지고 순서에 의존하게 된다
if point.0 == 0 && point.1 == 0 {
    print("origin")
} else if (-10...10).contains(point.0) && (-10...10).contains(point.1) {
    print("근처")
} else {
    print("멀리")
}

// ✅ 좋은 예 — 각 케이스가 독립된 한 줄로 나열되어 경우의 수가 한눈에 보인다
switch point {
case (0, 0): print("origin")
case (-10...10, -10...10): print("근처")
default: print("멀리")
}
⚠️ 흔한 오해

"옵셔널 패턴(case let x?)은 옵셔널 체이닝(?.)과 관련된 문법이다"는 틀렸다 — 서로 완전히 무관하다. x?는 그냥 Optional<Wrapped>.some(Wrapped) case를 매칭하는 enum case 패턴의 문법 설탕일 뿐이다. 또 "if caseswitch보다 기능이 부족한 하위 문법이다"도 부정확하다 — 매칭 규칙은 완전히 동일하고, 단지 "케이스 하나만 보고 싶다"는 상황에 맞춘 축약형일 뿐이다.

🧒 쉽게 이해하기

학교에서 반 전체 출석을 부르는 게 switch라면, if case는 "혹시 철수 왔니?" 딱 한 명만 확인하는 것이다. for case ... where는 출석부를 쭉 훑으면서 "결석한 애들 중에서도 사유서를 안 낸 애"처럼 조건 두 개를 동시에 만족하는 사람만 골라내는 것과 같다. 옵셔널 패턴(x?)은 "빈 자리는 그냥 건너뛰고 실제로 앉아있는 애들만" 세는 것이다. 비유가 깨지는 곳: 이 모든 패턴은 사람이 순서대로 훑어보는 것과 달리, 컴파일러가 애초에 "이 자리에 앉을 수 있는 사람 목록"을 미리 다 알고 있다는 전제 위에서 작동한다(그래서 exhaustiveness 같은 정적 보장이 가능하다).

꼬리 질문

is/as 패턴은 as? 캐스팅과 무엇이 다른가?
메커니즘은 같다 — 둘 다 런타임 동적 타입 체크(dynamic cast)를 수행하며, x is T(x as? T) != nil과 수학적으로 동치다(DynamicCasting.md). 차이는 문맥이다 — as?는 값을 만들어내는 표현식이고, is/as 패턴은 switch/if case 안에서 "이 케이스로 분기하며 동시에 캐스팅된 값을 바인딩"하는 역할을 한다. 다만 Any 타입의 switch는 타입 공간이 열려 있어(어떤 타입이든 들어올 수 있어) 컴파일러가 망라성을 증명할 수 없으므로 default가 항상 필요하다.
쉽게 말하면 "이 상자 안에 강아지가 들어있나요?"라고 묻는 것(is)과, "강아지면 꺼내서 이름을 붙여주세요"(as 패턴)는 같은 확인 작업 위에서 결과를 쓰느냐 마느냐만 다르다.
guard case도 되는가? if case와 언제 갈라 쓰는가?
된다 — guard case .ok(let v) = e else { return -1 }처럼 써서, 매치에 실패하면 조기 종료(early exit)하고 성공한 바인딩(v)을 이후 스코프에서 계속 쓸 수 있다. if case는 매치됐을 때만 좁은 블록 안에서 값을 쓰고 싶을 때, guard case는 "매치 안 되면 여기서 끝, 매치되면 나머지 함수 전체에서 그 값을 쓰겠다"는 흐름 제어가 필요할 때 쓴다. 실무에서는 초반에 여러 전제 조건을 검사하고 내려가는 함수에서 guard case 쪽이 들여쓰기를 훨씬 얕게 유지해준다.
쉽게 말하면 if case는 "맞으면 이 블록 안에서만 쓸게"이고, guard case는 "안 맞으면 아예 여기서 그만둘게, 맞으면 이 뒤로 계속 쓸게"다.
옵셔널 패턴(case let x?)은 정확히 무엇의 문법 설탕인가?
Optional<Wrapped>.some(Wrapped)/.none 두 케이스를 가진 평범한 enum이므로(03장 참고), case let x?는 실제로는 case let x = .some(x)에 해당하는 enum case 패턴을 짧게 쓴 것뿐이다. 그래서 for case let n? in maybeNumbers는 "maybeNumbers를 순회하며 .some 케이스에만 매치하고 그 값을 n에 바인딩, .none이면 건너뛴다"는 뜻이 된다 — 옵셔널 체이닝의 ?.과는 이름만 비슷할 뿐 완전히 다른 문법 계열이다.
쉽게 말하면 물음표가 붙어 있어서 헷갈리기 쉽지만, 여기서 ?는 "상자 안에 물건이 들어있는 경우만"이라는 뜻의 라벨이지, 체이닝에서 쓰는 "혹시 비어있으면 건너뛰기" 물음표와는 다른 자리에서 다른 일을 하는 기호다.

Q4. ~= 연산자를 오버로딩하면 무엇이 가능해지는가?

🔑 30초 답변

switchcase pattern:은 사실 pattern ~= switchValue를 호출하는 것과 동치다. 그래서 ~=를 커스텀 타입에 오버로딩하면, 범위 검사·정규식 매칭 같은 임의의 로직을 switch/if case/for case/guard case 안에 자연스러운 case 문법으로 끼워 넣을 수 있다. 다만 두 가지를 정확히 알아야 한다. 첫째, 시그니처는 반드시 (pattern: PatternType, value: ValueType) -> Bool 순서여야 한다 — 순서를 바꾸면 case Pattern: 문법과 타입이 안 맞아 컴파일 에러가 난다(실측). 둘째, ~=는 패턴 매칭 문법(case) 안에서만 컴파일러가 암묵적으로 호출해준다 — 평범한 if 조건문에서는 자동으로 불리지 않고, 그냥 일반 연산자처럼 명시적으로 호출해야 한다.

원리

Swift 레퍼런스 매뉴얼은 이를 "표현식 패턴(expression pattern)"이라 부르며, ~= 연산자로 값을 비교한다고 정의한다 — 레인지(-2...2)와의 비교가 대표적 내장 사례이고, 이 연산자는 오버로드 가능하다고 명시한다. 표준 라이브러리는 Equatable 타입 전반에 func ~= <T: Equatable>(a: T, b: T) -> Bool { a == b } 형태의 기본 오버로드를 깔아두어, case 42:처럼 흔한 값 매칭이 별도 선언 없이도 동작하게 한다. 커스텀 ~=는 바로 이 자리에 새 오버로드를 끼워 넣는 것이다.

내부 동작

HTTP 상태 코드 범위를 의미 있는 이름으로 매칭하는 예로 직접 확인했다.

~= 오버로딩 — 상태 코드 범위를 이름으로 매칭
struct StatusClass { let range: ClosedRange<Int> }
func ~= (pattern: StatusClass, value: Int) -> Bool {
    pattern.range.contains(value)
}
let success = StatusClass(range: 200...299)
let clientError = StatusClass(range: 400...499)
let serverError = StatusClass(range: 500...599)

func classify(_ code: Int) -> String {
    switch code {
    case success: return "성공"
    case clientError: return "클라이언트 에러"
    case serverError: return "서버 에러"
    default: return "기타(\(code))"
    }
}
print(classify(204), classify(404), classify(503))  // 성공 클라이언트 에러 서버 에러

// ~=는 그냥 연산자라서 switch 밖에서도 직접 호출된다
print(success ~= 250)   // true

재미있는 확인: Swift 표준 라이브러리는 RegexString/Substring과 직접 ~=로 매칭할 수 있게 해주지 않는다 — 직접 실험해보면 switch someString { case #/^[a-z]+\d+$/#: ... }는 "expression pattern of type 'Regex<Substring>' cannot match values of type 'String'"로 컴파일 에러가 난다. wholeMatch(of:)/contains(_:)로 우회하거나, 아래처럼 직접 ~=를 오버로딩해야 switch에 정규식을 자연스럽게 끼워 넣을 수 있다.

Regex는 기본으로 switch에서 안 된다 — 직접 오버로딩해서 가능하게 만들기
// 표준 라이브러리는 Regex<Substring>과 String을 잇는 ~=를 공짜로 주지 않는다(실측 확인).
// 그래서 switch에서 쓰려면 직접 오버로드를 정의해야 한다.
func ~= (regex: Regex<Substring>, value: String) -> Bool {
    value.wholeMatch(of: regex) != nil
}

let inputs = ["hello123", "BAD", "42world"]
for input in inputs {
    switch input {
    case #/^[a-z]+\d+$/#: print("[\(input)] 소문자+숫자")
    case #/^\d+[a-z]+$/#: print("[\(input)] 숫자+소문자")
    default: print("[\(input)] 매치 안 됨")
    }
}
// 출력: hello123 -> 소문자+숫자 / BAD -> 매치 안 됨 / 42world -> 숫자+소문자
🕳️ 함정

~=의 인자 순서를 반대로 정의하면(func ~= (value: Int, pattern: Weird) -> Bool) case Weird():가 컴파일조차 안 된다 — "expression pattern of type 'Weird' cannot match values of type 'Int'"(실측). 컴파일러는 case Pattern:을 볼 때 정확히 (pattern: PatternType, value: ValueType) -> Bool 시그니처를 찾는다. 그리고 ~=를 정의했다고 ==가 같이 생기는 것도 아니다(실측: Even() == 4는 별개로 컴파일 에러) — 둘은 완전히 독립된 연산자다.

실험 · 도구

~=는 평범한 infix 연산자라서 switch 밖에서도 pattern ~= value로 직접 호출해 참/거짓을 바로 확인할 수 있다. 이 성질을 이용하면 커스텀 매칭 로직을 유닛 테스트하기도 쉽다 — switch 문을 매번 실행하지 않고 #expect(success ~= 204)처럼 직접 검증하면 된다.

프로젝트 적용

API 응답 코드처럼 "범위별 의미"가 반복적으로 쓰이는 값은 ~= 오버로딩으로 도메인 이름을 가진 switch를 만들 수 있다.

응답 라우팅에 상태 코드 범위 매칭 적용
enum RetryPolicy { case retry, giveUp, refreshTokenThenRetry }

func policy(for statusCode: Int) -> RetryPolicy {
    switch statusCode {
    case StatusClass(range: 200...299):
        return .giveUp  // 성공이니 재시도할 필요 없음
    case StatusClass(range: 401...401), StatusClass(range: 403...403):
        return .refreshTokenThenRetry
    case StatusClass(range: 500...599):
        return .retry
    default:
        return .giveUp
    }
}

파서·검증 로직에서 여러 정규식 후보 중 첫 매치를 고르는 코드는, ①에서 만든 ~= 오버로드를 재사용하면 if/else 사슬 없이 switch 하나로 깔끔하게 쓸 수 있다.

여러 정규식 후보를 switch 하나로 — if/else 사슬 대신
func classifyToken(_ s: String) -> String {
    switch s {
    case #/^\d+$/#:            return "숫자"
    case #/^[a-zA-Z]+$/#:      return "영문자"
    case #/^[a-zA-Z]+\d+$/#:   return "영문+숫자"
    default:                   return "기타"
    }
}
// (앞서 정의한 Regex<Substring> -> String용 ~= 오버로드가 이 switch를 가능하게 한다)
⚠️ 흔한 오해

"~=switch 전용 문법이다"는 반쯤만 맞다. ~=는 평범한 연산자 함수라 Even() ~= 4처럼 직접 호출도 되고 if 조건에도 쓸 수 있다 — 다만 패턴으로 자동 호출되는 자리는 case이다. 인자 순서도 자주 틀린다: func ~= (pattern:value:)이고 왼쪽이 case에 쓴 패턴, 오른쪽이 switch에 넘긴 값이다. 순서를 뒤집어도 컴파일은 되므로 컴파일러가 잡아주지 않는다.

더 위험한 오해는 "이미 Equatable인 타입에는 커스텀 ~=가 안 먹는다"는 것이다. 실제로는 커스텀 오버로드가 기본 == 기반 매칭을 이긴다. func ~= (pattern: String, value: String) -> Bool { value.hasPrefix(pattern) }를 정의해두면 switch "hello world" { case "hello": … }매치된다(Swift 6.2.1 실측). 전역 연산자라서 모듈 전체의 모든 문자열 switch 의미가 조용히 바뀐다 — 내가 쓰지 않은 남의 코드까지. 그래서 ~= 오버로드는 String·Int 같은 흔한 타입이 아니라 전용 패턴 타입(위의 Even처럼)에만 붙이는 게 안전하다.

🧒 쉽게 이해하기

switch의 각 case는 뒤에서 전부 "이 값이 맞아?"라고 묻는 판정관 함수(~=) 하나를 부르는 것뿐이다. 기본 판정관은 "완전히 똑같아?"만 물어보는데(==), 우리가 직접 새 판정관을 만들어 끼워 넣으면 "이 숫자, 200번대 범위 안에 들어?"처럼 훨씬 똑똑한 질문도 case 한 줄로 물어볼 수 있다. 비유가 깨지는 곳: 이 판정관은 아무 데서나 자동으로 불려나오는 게 아니다 — switch/if case처럼 "지금부터 판정관을 부를 자리"라고 명시된 문법 안에서만 컴파일러가 대신 불러준다. 평범한 if 문에서는 판정관이 있어도 알아서 나서지 않는다.

꼬리 질문

~=switch·if case에서는 자동으로 불리는데 일반 if 조건에서는 안 불리는가?
~=가 암묵적으로 호출되는 건 컴파일러가 "여기는 패턴 위치"라고 문법적으로 인식하는 자리, 즉 switchcase·if case·for case·guard case 뿐이다. 이 자리에서 case Pattern:을 보면 컴파일러가 스스로 Pattern ~= 매칭대상으로 바꿔 호출한다. 반면 if someCondition은 그냥 Bool 표현식을 요구할 뿐 패턴 문법이 전혀 아니므로, ~=가 저절로 끼어들 자리가 없다 — 원한다면 if Pattern ~= value처럼 연산자를 직접 써주면 똑같이 동작한다(실측 확인).
쉽게 말하면 판정관은 "여기서 판정해주세요"라고 팻말이 붙은 자리에서만 자동으로 나온다. 팻말이 없는 보통 질문(if)에서는 직접 불러야 나와서 대답해준다.
~=의 인자 순서(pattern ~= value)를 외우지 않고 기억하는 방법이 있는가?
case 뒤에 쓰는 게 항상 왼쪽(패턴), switch 뒤에 쓰는 게 항상 오른쪽(값)이라고 기억하면 된다 — switch value { case pattern: }를 그대로 가로로 펴면 pattern ~= value가 된다. 실제로 순서를 바꿔 정의하면 컴파일러가 "expression pattern of type X cannot match values of type Y"로 정확히 알려주므로, 헷갈려도 컴파일 타임에 바로 드러난다는 점은 안전장치가 된다.
쉽게 말하면 switch 문을 옆으로 눕히면 그대로 물음이 된다고 생각하면 외울 필요가 없다.
정규식을 switch에서 쓰려면 왜 표준 라이브러리가 기본으로 안 해주고 직접 ~=를 정의해야 하는가?
직접 실험해 확인한 바로는, 현재 표준 라이브러리는 Regex<Output>String/Substring을 잇는 ~= 오버로드를 제공하지 않는다 — 정규식 리터럴이 switch에서 곧바로 동작할 거라 기대하기 쉽지만, 실제로는 타입 불일치로 컴파일 에러가 난다. 대신 wholeMatch(of:)/firstMatch(of:)/contains(_:) 같은 명시적 메서드로 매치 여부를 얻거나, 이 챕터에서 한 것처럼 직접 ~=를 오버로딩해 switch 문법 안에 끌어와야 한다. 이건 표준 라이브러리의 빈틈이라기보다, ~= 오버로딩이 애초에 "필요하면 라이브러리 사용자가 직접 끼워 넣는" 확장 지점으로 설계됐다는 증거로 볼 수 있다.
쉽게 말하면 정규식이라는 새 판정관은 기본으로 세팅되어 있지 않다. 필요하면 내가 직접 그 판정관을 만들어서 switch 자리에 꽂아 넣어야 한다.

출처 · 참고자료