프로토콜을 사용하다보면 다음 형태의 코드를 본적이 있죠?
protocol Animal {
func sound() -> String
}
struct Dog: Animal { func sound() -> String { "멍" } }
struct Cat: Animal { func sound() -> String { "냐옹" } }
이 코드에서 makeAnimal() 함수를 작성한다고 할 때, 아래 두 개는 어떻게 다를까요?
func makeAnimal() -> any Animal { ... }
func makeAnimal() -> some Animal { ... }
이 차이를 이해하기 위해 이번 글에서는 any와 some에 대해 알아보겠습니다.
any Animal은 그 자체로 완성된 타입입니다. 내부적으로 Existential Container라는 박스를 만들어 실제 값과 타입 정보를 함께 보관합니다.
any Animal (40 bytes)
┌──────────────────────────────────────┐
│ Value Buffer (24 bytes) │ ← 실제 값 저장 (또는 힙 포인터)
│ Type Metadata Pointer (8 bytes) │ ← "나는 Dog야" 런타임 타입 정보
│ Protocol Witness Table Ptr (8 bytes)│ ← Dog의 Animal 메서드 테이블
└──────────────────────────────────────┘
var animal: any Animal = Dog()
animal = Cat() // 가능. 박스에 다른 걸 넣는 것
런타임에 타입이 바뀔 수 있으니 컴파일러는 "Animal 박스"로만 취급합니다.
// Swift 5.6 이하 — 둘 다 그냥 Animal
var x: Animal = Dog() // existential (런타임 비용 있음)
func f<T: Animal>(_ t: T) { } // generic constraint (런타임 비용 없음)
프로토콜 이름이 두 가지 완전히 다른 역할을 하는데, 코드만 봐서는 구분이 안 됐습니다.
var animal: Animal = Dog() // Animal이 타입 (existential)
func process<T: Animal>(_ t: T) { } // Animal이 제약 (generic)
위 두 줄은 겉보기엔 비슷해보이지만 동작 방식은 완전히 다릅니다.
var animal: Animal
→ Animal 박스를 만들어서 Dog를 담음
→ 런타임에 타입 추적, 박스 비용 발생
func process<T: Animal>
→ T = Dog로 컴파일 타임 확정
→ 박스 없음, 직접 접근
같은 Animal인데 하나는 타입이고 하나는 제약입니다. 초보자는 물론 숙련자도 헷갈렸습니다.
// 개발자 입장
var animals: [Animal] = [Dog(), Cat()] // 그냥 배열 아닌가?
// 실제로 일어나는 일
// 각 원소마다 Existential Container 박스 생성
// Dog, Cat이 3 word 초과면 힙 할당 발생
// 메서드 호출 시 witness table 간접 참조 2번
성능에 민감한 코드에서 아무 생각 없이 쓰다가 뒤늦게 병목을 발견하는 경우가 생겼습니다.
Swift 5.7에서 any 키워드를 도입하면서 existential 사용을 눈에 보이게 만들었습니다.
// Swift 5.7+
var animal: any Animal = Dog() // any → "나 박스야, 비용 있어"
func process<T: Animal>(_ t: T) { } // 키워드 없음 → 제네릭, 비용 없음
func process(_ t: some Animal) { } // some → 제네릭 축약, 비용 없음
any를 보는 순간 "아, 여기서 런타임 비용이 발생하는구나"가 코드에서 바로 보입니다.
이건 Swift가 일관되게 지켜온 원칙입니다.
// 비용 및 의미가 있는 것들은 항상 명시적으로 표시
inout // 참조로 전달, 원본 수정 가능
mutating // 값 타입의 self 수정
@escaping // 클로저가 함수 범위를 벗어남
try // 에러 던질 수 있음
await // 비동기, 일시 중단 가능
any // Existential 박스, 런타임 비용
any도 같은 맥락입니다. "이 코드는 박스를 만들고, 런타임에 타입을 추적하고, 비용이 발생한다"는 사실을 코드에서 드러내는 것입니다.
some Animal은 타입이 아닙니다. "컴파일 타임에 확정된 구체 타입이 있는데, 그 이름을 숨겨둔 것"입니다.
func makeAnimal() -> some Animal {
return Dog() // 컴파일러: "반환 타입은 Dog구나"
}
컴파일러는 Dog임을 알고 있습니다. 호출하는 쪽만 모릅니다. 박스가 없으니 런타임 비용도 없습니다.
대신 항상 하나의 구체 타입으로 고정되어야 합니다.
func makeAnimal(isCat: Bool) -> some Animal {
if isCat { return Cat() }
return Dog() // ❌ 컴파일 에러: 분기마다 타입이 달라짐
}
아래 두 코드는 완전히 동일합니다.
func process(animal: some Animal) { }
func process<T: Animal>(animal: T) { }
컴파일러가 호출 시점에 T가 뭔지 확정하고 그 타입으로 처리합니다.
// some: 함수 구현체가 타입 결정 (callee decides)
func makeAnimal() -> some Animal { Dog() }
// 제네릭 반환: 호출자가 타입 결정 (caller decides)
func makeAnimal<T: Animal>() -> T { ... }
let a: Dog = makeAnimal() // 호출자가 Dog 요청
let b: Cat = makeAnimal() // 호출자가 Cat 요청
var body: some View {
VStack {
Text("Hello")
Button("탭") { }
}
}
실제 반환 타입은 VStack<TupleView<(Text, Button<Text>)>>입니다. some View로 숨겨두면 내부 구조가 바뀌어도 외부 인터페이스는 유지됩니다.
Associated Type이 있는 프로토콜에서 any와 some의 차이가 극명해집니다.
protocol Repository {
associatedtype Entity
func fetch() -> [Entity]
}
Swift 5.7 이전에는 any Repository 자체가 불가능했습니다. 타입 소거 래퍼(AnyRepository, AnyPublisher 등)를 직접 만들어야 했습니다.
Swift 5.7부터 가능해졌지만, Entity가 소거됩니다.
var repo: any Repository = UserRepository()
repo.fetch() // [any Repository.Entity] ← 구체 타입 아님, 바로 쓰기 불편
some은 타입이 확정되므로 연관 타입도 완전히 추론됩니다.
func process(repo: some Repository) {
repo.fetch() // [User] ✅ 바로 사용 가능
}
any Repository<User> 처럼 주요 타입을 고정하면 소거 문제가 사라집니다.
protocol Repository<Entity> {
associatedtype Entity
func fetch() -> [Entity]
}
var repo: any Repository<User> = UserRepository()
repo.fetch() // [User] ✅ 소거 없이 바로 사용
// some도 간결해짐
func process(_ repo: some Repository<User>) { }
// 기존: func process<R: Repository>(_ r: R) where R.Entity == User { }
표준 라이브러리도 이 방식을 채택했습니다.
func sum(_ numbers: some Sequence<Int>) -> Int {
numbers.reduce(0, +)
}
메서드 호출 방식 자체가 다릅니다.
some / 제네릭
→ 컴파일 타임에 타입 확정
→ 메서드 주소 직접 알고 있음, 인라이닝 가능
any
→ 런타임에 Witness Table 조회 → 메서드 포인터 찾기 → 호출 (간접 참조 2번)
→ 인라이닝 불가
힙 할당도 고려해야 합니다. any 박스의 Value Buffer는 24바이트(3 word)입니다. 값 타입이 이보다 크면 힙에 할당됩니다.
struct Small: Animal { var id: Int } // 1 word → 힙 할당 없음
struct Large: Animal { var a, b, c, d: Int } // 4 word → 힙 할당 발생
| any | some | |
|---|---|---|
| 타입 결정 | 런타임 | 컴파일 타임 |
| 여러 타입 혼용 | ✅ | ❌ |
| Associated Type | 소거됨 | 완전 추론 |
| 성능 | 간접 참조, 힙 가능 | 직접 접근, 인라이닝 |