- 프로토콜 지향과 객체 지향은 무엇이 다른가요?
- 실제로 상속 대신 프로토콜을 쓴 사례가 있나요? 반대로 클래스 상속이 맞았던 경우는?
- 프로토콜 extension의 기본 구현은 오버라이드되나요?
30초 답변 🔥 먼저 이 문장
Swift에서 POP가 실용적인 이유는 둘입니다 — ① 값 타입도 다형성에 참여할 수 있고, ② retroactive conformance로 내가 만들지 않은 타입에도 나중에 능력을 붙일 수 있습니다. 상속으로는 둘 다 불가능합니다.
다만 POP가 만능은 아니고, 정확히 알아야 할 함정이 하나 있습니다 — 프로토콜 extension의 기본 구현은 요구사항이 아니면 오버라이드되지 않습니다.
L1개념 — 계층이 깨지는 지점
L2언어 설계 — POP가 실제로 주는 다섯 가지
- 값 타입 다형성 — struct·enum도 프로토콜로 추상화된다. 상속은 class 전용이다.
- retroactive conformance —
extension Int: MyProtocol {}처럼 남의 타입에 나중에 능력을 붙인다. - 기본 구현 — protocol extension으로 공통 로직 제공. 상속의 코드 재사용 이점을 값 타입에서도.
- 제네릭과 결합 —
func f<T: Drawable>(_ x: T)는 특수화되어 정적 디스패치가 가능하다. - 테스트 경계 — 프로토콜로 경계를 그으면 Mock 주입이 쉬워진다.
@retroactive내가 소유하지 않은 타입에 내가 소유하지 않은 프로토콜을 채택시키면 Swift 6에서 경고가 납니다. 이유는 충돌 위험입니다 — 나중에 원 라이브러리가 같은 채택을 추가하면 어느 구현이 쓰일지 정의되지 않습니다.
// ⚠️ 경고: extension declares a conformance of imported type 'URL'
// to imported protocol 'Identifiable'
extension URL: Identifiable { /* ... */ }
// ✅ "알고 하는 것"임을 명시
extension URL: @retroactive Identifiable { /* ... */ }
// ✅ 더 안전한 대안 — 내 타입으로 감싼다
struct IdentifiedURL: Identifiable { let id: URL }이걸 알고 있으면 "retroactive conformance가 좋습니다"에서 한 층 더 내려간 답이 됩니다.
언제 class 상속이 여전히 맞는가
| 상황 | 이유 |
|---|---|
| 프레임워크가 요구 | UIViewController, UIView를 상속하지 않을 방법이 없다 |
| identity가 본질 | "이 객체가 그 객체와 같은가"가 도메인 의미를 가질 때 |
| ObjC 상호운용 | NSObject 계열, KVO, target-action |
| 의도적 공유 | 하나의 인스턴스를 여러 곳이 봐야 할 때 (캐시, 세션) |
L3구현 — witness table과 "오버라이드되지 않는" 함정
이 질문은 POP를 이해했는지 가르는 대표 문제입니다. 그리고 답이 구현에 있습니다.
protocol Greeter {
func hello() // 요구사항 O
}
extension Greeter {
func hello() { print("protocol hello") }
func bye() { print("protocol bye") } // 요구사항 X — extension 전용
}
struct Person: Greeter {
func hello() { print("Person hello") }
func bye() { print("Person bye") }
}
let p: Greeter = Person()
p.hello() // "Person hello" ← 예상대로
p.bye() // "protocol bye" ← ⚠️ Person.bye 가 안 불린다!
let q = Person() // 정적 타입이 Person
q.bye() // "Person bye" ← 같은 객체인데 결과가 다르다앞의 그림은 어떻게 그렇게 되는지를 보여 줬습니다. 면접에서 한 층 더 가려면 왜 그렇게 두기로 했는지를 말해야 합니다.
Swift는 프로토콜 선언부와 extension에 서로 다른 의미를 부여했습니다.
- 프로토콜 본문 = 계약(contract) — "채택자가 반드시 제공한다. 그리고 채택자마다 다를 수 있다." 그래서 witness table에 올라가고, 런타임에 구체 타입 구현을 찾아갑니다.
- extension = 편의(convenience) — "계약 위에 얹은 공용 도구. 모든 채택자에게 동일하다." 달라질 수 없으니 런타임에 찾아볼 이유가 없고, 그래서 정적으로 굳습니다.
만약 extension 메서드까지 전부 witness table에 넣었다면 어떻게 될까요? 세 가지를 잃습니다 — ① 테이블이 커지고, ② 특수화·인라이닝 기회가 사라지며(05번), ③ 무엇보다 "이 프로토콜이 요구하는 것이 무엇인지"가 선언부만 봐서는 알 수 없게 됩니다.
그래서 규칙이 이렇게 정리됩니다 — "채택자마다 달라져야 한다면 그건 계약이다. 계약은 선언부에 적어라."
func track()을 오버라이드하고 싶다면, 그건 편의가 아니라 계약이라는 뜻이니 프로토콜 본문으로 올려야 합니다.
다만 정직하게 덧붙이면 — 이 설계는 배우기 어렵다는 비판을 오래 받아 왔습니다. 같은 객체가 어떤 타입으로 참조되느냐에 따라 다르게 동작하는 건 직관에 반하니까요. 면접에서 "이 동작이 합리적이라고 보시나요?"를 물으면, 의도는 명확하지만 학습 비용이 큰 선택이라고 답하는 게 정확합니다.
로깅·분석처럼 "기본 동작을 제공하고 필요하면 커스터마이즈" 패턴에서 자주 터집니다.
프로토콜에 func track()을 요구사항으로 선언하지 않고 extension에만 두면, 구체 타입에서 아무리 override 해도 프로토콜 타입으로 담긴 순간 기본 구현이 불립니다.
규칙: 커스터마이즈를 허용할 메서드는 반드시 프로토콜 본문에 요구사항으로 선언한다. extension에는 그 요구사항의 기본값만 둔다.
protocol Trackable {
func track() // ✅ 요구사항으로 승격 → witness table 진입
var name: String { get }
}
extension Trackable {
func track() { // 여기는 "기본값"일 뿐
Analytics.log(name)
}
}
struct Checkout: Trackable {
let name = "checkout"
func track() { // 이제 프로토콜 타입으로 담아도 이게 불린다
Analytics.log(name, extra: ["revenue": true])
}
}L4CS 근본 — 다형성 3종과 표현 문제
① 다형성에는 세 종류가 있다
프로그래밍 언어 이론에서 다형성은 세 가지로 분류됩니다(Strachey / Cardelli-Wegner 분류). "POP vs OOP" 논쟁은 사실 어느 다형성을 주력으로 쓸 것인가의 문제입니다.
| 종류 | 정의 | Swift에서 | 디스패치 |
|---|---|---|---|
| 파라메트릭 (parametric) | 타입에 무관하게 같은 코드가 동작 | func f<T>(_ x: T), Array<T> | 특수화되면 정적 |
| 애드혹 (ad-hoc) | 타입마다 다른 구현을 골라 씀 | 오버로딩, 프로토콜 준수 | 특수화되면 정적, any면 PWT |
| 서브타입 (subtype) | 하위 타입이 상위 타입 자리를 대신함 | 클래스 상속, existential(any P) | vtable / witness table |
OOP는 서브타입 다형성 중심이고, POP는 파라메트릭 + 애드혹 조합입니다. 그래서 POP가 성능상 유리할 수 있는 이유가 여기서 나옵니다 — 앞의 둘은 특수화로 정적 디스패치가 될 수 있지만, 서브타입 다형성은 원리상 런타임 조회가 필요합니다.
② Swift 프로토콜은 인터페이스가 아니라 타입 클래스에 가깝다
자바 인터페이스와 Swift 프로토콜을 같은 것으로 보면 여러 설명이 안 됩니다. 실제로는 Haskell의 타입 클래스(type class)에 훨씬 가깝습니다.
| Java 인터페이스 | Haskell 타입 클래스 / Swift 프로토콜 | |
|---|---|---|
| 채택 시점 | 타입 선언 시점에만 | 나중에도 가능 (retroactive) |
| 연관 타입 | 제네릭 파라미터로 우회 | associatedtype / type family |
| Self 요구 | 표현 어려움 | Self 반환 가능 (Equatable) |
| 정적 멤버 요구 | 불가 | static func 요구 가능 |
| 구현 방식 | itable / invokeinterface | 딕셔너리 패싱 (= witness table) |
③ witness table = 딕셔너리 패싱
Haskell 컴파일러가 타입 클래스를 구현하는 고전적 방법이 딕셔너리 패싱입니다 — 제약이 붙은 함수에 "함수 표"를 숨은 인자로 하나 더 넘기는 것. Swift의 witness table이 정확히 같은 물건입니다.
// 우리가 쓴 것
func describe<T: Drawable>(_ x: T) -> String {
x.draw()
}
// 특수화가 안 될 때, 개념적으로 컴파일러가 만드는 것
// Drawable 딕셔너리(= witness table)를 숨은 인자로 받는다
func describe(_ x: OpaqueValue, _ witness: DrawableWitnessTable) -> String {
witness.draw(x) // 표에서 함수 포인터를 꺼내 호출
}
// ─── 그래서 두 가지가 자동으로 설명된다 ───
// (1) extension 전용 메서드가 표에 없으니 동적으로 못 찾는다 → L3의 함정
// (2) 특수화되면 표 자체가 사라져 직접 호출이 된다 → 05번의 정적 디스패치이 한 장의 그림으로 L3의 함정과 05번의 디스패치가 같은 사실의 두 얼굴이라는 게 드러납니다.
④ 표현 문제 (Expression Problem) — OOP와 FP가 서로 못 하는 것
1998년 Philip Wadler가 정리한 고전 문제입니다. 데이터에는 두 축의 확장이 있습니다.
새 연산 추가 → area() perimeter() describe()
새 타입 Circle ... ... ...
↓ Square ... ... ...
Triangle(새 타입) ... ... ...
OOP(클래스): 새 "타입" 추가는 쉽다 (클래스 하나 추가하면 끝)
새 "연산" 추가는 어렵다 (모든 클래스를 열어 메서드 추가)
FP(패턴매칭): 새 "연산" 추가는 쉽다 (함수 하나 추가하면 끝)
새 "타입" 추가는 어렵다 (모든 함수의 switch 를 수정)Swift의 프로토콜 extension은 이 문제의 한쪽을 실질적으로 완화합니다. 새 연산을 추가할 때 기존 타입을 수정하지 않고 extension으로 붙일 수 있고, 새 타입을 추가할 때도 프로토콜만 채택하면 기존 연산이 전부 따라옵니다.
protocol Shape { var area: Double { get } }
// 새 연산 추가 — 기존 타입 코드를 건드리지 않는다
extension Shape {
var description: String { "면적 \(area)" }
}
// 새 타입 추가 — 기존 연산 코드를 건드리지 않는다
struct Triangle: Shape { var area: Double { /* ... */ } }
// description 은 자동으로 따라온다이게 개방-폐쇄 원칙(OCP)의 실제 구현입니다 — 확장에는 열려 있고 수정에는 닫혀 있다. "POP가 왜 좋은가"에 대한 가장 깊은 답이 이겁니다. 다형성 이야기가 아니라 변경의 파급 범위 이야기예요.
⑤ 리스코프 치환 원칙과 다이아몬드 — 상속이 어긋나는 지점
-
LSP: 하위 타입은 상위 타입을 대체할 수 있어야 한다. 상속은 구현까지 물려받기 때문에 위반하기 쉽습니다.
고전 예가 정사각형/직사각형 — 수학적으로는
Square is-a Rectangle이지만,width를 바꾸면height도 바뀌므로 Rectangle이 보장하던 계약이 깨집니다. POP는 능력만 요구하고 구현을 물려주지 않으니 이 위반 여지가 구조적으로 작습니다. - 다이아몬드 문제: 다중 상속에서 같은 조상을 두 경로로 물려받으면 저장된 상태가 중복됩니다(C++이 virtual 상속으로 씨름하는 그 문제). Swift는 클래스 다중 상속을 아예 금지해 원천 차단했고, 프로토콜은 다중 채택이 가능하지만 저장 프로퍼티가 없어서 상태 다이아몬드가 성립하지 않습니다. 남는 건 기본 구현 충돌뿐인데, 이건 컴파일 에러로 만들어 개발자가 명시적으로 해소하게 합니다.
protocol A { func run() }
protocol B { func run() }
extension A { func run() { print("A") } }
extension B { func run() { print("B") } }
struct S: A, B { }
// ❌ error: type 'S' does not conform to protocol 'A' / 'B'
// → 애매한 채로 조용히 하나를 고르지 않는다. 명시하라고 요구한다.
struct T: A, B { func run() { print("T") } } // ✅ 직접 해소Swift는 명목적(nominal) 타이핑입니다. 메서드 시그니처가 우연히 맞아도 "이 프로토콜을 채택한다"고 선언해야 준수로 인정됩니다. Go의 인터페이스나 TypeScript는 구조적(structural) 타이핑이라 모양만 맞으면 자동입니다.
명목적 타이핑의 장점은 우연한 일치로 인한 오적용을 막는 것입니다. func draw()를 가진 타입이 총을 뽑는(draw) 것일 수도 있으니까요.
단점은 경직성이고, retroactive conformance가 정확히 그 경직성을 푸는 장치입니다. 두 개가 짝이라는 걸 알면 "왜 Swift에 이 기능이 있는가"가 설명됩니다.
CS 정본으로 더 내려가기
이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.
경험으로 말하기
걷어낸 쪽 — "공통 로직을 BaseViewController에 넣었더니 700줄이 됐고, 자식들이 자기가 안 쓰는 기능까지 물려받아 어떤 화면에 무슨 동작이 붙어 있는지 추적이 안 됐습니다.
이걸 LoadingPresentable, ErrorHandling, KeyboardAdjustable 세 프로토콜 + 기본 구현으로 쪼갰습니다. 화면마다 필요한 것만 선언하니 클래스 선언 한 줄만 봐도 그 화면의 능력이 보입니다."
남긴 쪽 — "반대로 UITableViewCell은 상속을 유지했습니다. 프레임워크가 prepareForReuse 같은 훅을 상속 전제로 설계했고, 프로토콜로 우회하면 그 훅을 재구현해야 해서 얻는 것보다 잃는 게 컸습니다."
이렇게 양쪽을 다 말하면 "유행을 따라간 게 아니라 판단했다"가 증명됩니다.
꼬리 질문 대비
L2 프로토콜을 너무 많이 만들면 문제 없나요?
있습니다. 그리고 이걸 인정하는 게 중요합니다 — 추상화는 공짜가 아닙니다.
- 탐색 비용 — 구현이 하나뿐인 프로토콜을 만들면, 코드를 읽는 사람이 정의 → 프로토콜 → 구현으로 두 번 점프해야 합니다. 이해에 드는 시간이 늘어납니다.
- 가짜 유연성 — "나중에 바뀔 수도 있으니" 만든 추상화는 대부분 실제로 안 바뀝니다. 그리고 진짜 바뀔 때는 그 추상화가 맞는 모양이 아닌 경우가 많습니다.
- existential 비용 —
any P는 witness table 경유 + 값이 크면 힙 박싱입니다. (05번)
판단 기준: 프로토콜은 경계에서만 만듭니다 — 네트워크, 저장소, 시계, 로거처럼 테스트에서 바꿔 끼울 지점. 내부 구현끼리는 구체 타입으로 직접 부릅니다.
L3 some P와 any P는 뭐가 다른가요?
한 문장으로: some은 "이름을 감춘 하나의 구체 타입", any는 "여러 타입을 담을 수 있는 상자"입니다.
func makeSome() -> some Shape { Circle() } // 항상 Circle. 타입이 하나로 고정
func makeAny() -> any Shape { Bool.random() ? Circle() : Square() } // 둘 중 하나
// some: 컴파일 타임에 타입 확정 → 특수화·정적 디스패치·박싱 없음
// any : 런타임에 결정 → existential container, witness table 경유
let a: [any Shape] = [Circle(), Square()] // ✅ 섞을 수 있다
// let s: [some Shape] = [Circle(), Square()] // ❌ 타입이 하나여야 한다선택 규칙: 하나의 타입이면 some, 여러 타입을 섞어야 하면 any. 그리고 대부분의 경우 하나입니다 — 그래서 SwiftUI의 some View가 기본형인 겁니다.
CS 관점으로는 some은 존재 타입의 "이름만 숨기기"이고 타입 정체성(type identity)이 유지되는 반면, any는 정체성을 지우고 값을 상자에 넣는 것입니다. 정체성이 남아 있으면 컴파일러가 특수화할 수 있고, 지워지면 못 합니다.
여기까지가 이 페이지(추상화 설계)의 관점이고, 그 선택이 실행 시점에 얼마를 물리는지(existential container 레이아웃, 힙 박싱 조건, witness table 경유 비용)는 05번 디스패치에서 실측과 함께 다룹니다.
some은 "이 상자에는 사과만 들었는데 무슨 품종인지는 말 안 할게"이고, any는 "이 상자에는 과일이면 아무거나 들 수 있어"입니다. 앞쪽은 열어 보기 전에도 사과용 기계를 준비할 수 있습니다.L4 POP가 OOP보다 우월한 건가요?
아닙니다. 서로 잘하는 확장 축이 다릅니다 — 표현 문제로 설명하면 정확합니다.
- 새 타입을 자주 추가하는 도메인(결제 수단이 계속 늘어남) → 서브타입 다형성/프로토콜 채택이 편합니다. 타입 하나 추가하면 끝.
- 새 연산을 자주 추가하는 도메인(같은 데이터에 리포트가 계속 늘어남) → 패턴 매칭 + 자유 함수가 편합니다. enum + switch가 오히려 낫습니다.
그래서 Swift가 프로토콜과 enum을 둘 다 1급으로 둔 게 우연이 아닙니다. 케이스가 고정되고 연산이 늘어나는 것은 enum으로, 케이스가 늘어나고 연산이 고정된 것은 protocol로 모델링하는 것이 표현 문제의 실전 적용입니다.
덧붙여, 상속에는 프로토콜이 못 주는 것이 하나 있습니다 — 저장 프로퍼티 상속. 프로토콜은 상태를 물려줄 수 없어서, 공통 저장 상태가 진짜로 필요하면 컴포지션(멤버로 갖기)으로 풀어야 하고 그건 위임 코드가 늘어납니다. 트레이드오프가 없는 쪽은 없습니다.
쉽게 이해하기
OOP는 생물 분류표입니다. 포유류 → 개과 → 개. 부모가 하나뿐이라 깔끔하지만 유연하지 않아요. "로봇 강아지"를 넣으려면 표가 이상해집니다. 기계이기도 하고 강아지이기도 한데, 부모는 하나만 고를 수 있으니까요.
POP는 자격증입니다. "운전 가능", "수영 가능", "충전 가능". 사람이든 로봇이든 필요한 자격증만 따면 됩니다. 몇 개를 따든 상관없고, 나중에 새 자격증을 만들어서 이미 있는 사람에게 발급할 수도 있어요(retroactive conformance).
그런데 여기 함정이 있습니다. 자격증에 "기본 요령" 안내문을 붙일 수 있는데(protocol extension), 이 안내문이 자격증 항목(요구사항)에 없는 내용이면 이상한 일이 생깁니다. 사람을 "자격증 소지자"로만 보고 부르면 안내문대로 하고, "그 사람"으로 보고 부르면 자기 방식대로 합니다. 같은 사람인데 어떻게 부르느냐에 따라 행동이 달라지는 거죠.
이유는 단순합니다. 자격증 발급 대장(witness table)에는 자격증 항목만 적혀 있어서, 대장을 뒤져 보는 방식으로는 "그 사람 고유 방식"을 찾을 수가 없습니다. 그래서 대장에 없으면 안내문으로 바로 갑니다.
해결도 여기서 나옵니다. 사람마다 다르게 하고 싶은 항목이라면, 안내문에만 적지 말고 자격증 항목 자체에 넣으세요. 그러면 대장에 올라가고, 어떻게 부르든 그 사람 방식이 나옵니다.
POP는 다형성 기법이 아니라 변경의 파급 범위를 줄이는 기법이다. 그리고 그 대가로 witness table에 없는 것은 정적으로 굳는다는 규칙을 받아들여야 한다.
설계 근거 · 1차 자료
이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.
- WWDC15 — "Swift is a protocol-oriented language" 선언의 원전
- SE-0364 — retroactive conformance를 왜 경고 대상으로 바꿨는가
- Wadler — 새 타입 vs 새 연산 확장 축의 정의