Swift - Protocol(any, some)

Marble·2026년 3월 22일

프로토콜을 사용하다보면 다음 형태의 코드를 본적이 있죠?

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 { ... }

이 차이를 이해하기 위해 이번 글에서는 anysome에 대해 알아보겠습니다.

any — Existential Type

any Animal은 그 자체로 완성된 타입입니다. 내부적으로 Existential Container라는 박스를 만들어 실제 값과 타입 정보를 함께 보관합니다.

any Animal (40 bytes)                     
┌──────────────────────────────────────┐
│  Value Buffer (24 bytes)             │  ← 실제 값 저장 (또는 힙 포인터)Type Metadata Pointer (8 bytes)     │  ← "나는 Dog야" 런타임 타입 정보
│  Protocol Witness Table Ptr (8 bytes)│  ← DogAnimal 메서드 테이블
└──────────────────────────────────────┘
var animal: any Animal = Dog()
animal = Cat()  // 가능. 박스에 다른 걸 넣는 것

런타임에 타입이 바뀔 수 있으니 컴파일러는 "Animal 박스"로만 취급합니다.

any 키워드가 생긴 이유

핵심: Swift 5.7 이전에는 구분이 없었습니다.

// Swift 5.6 이하 — 둘 다 그냥 Animal
var x: Animal = Dog()           // existential (런타임 비용 있음)
func f<T: Animal>(_ t: T) { }  // generic constraint (런타임 비용 없음)

프로토콜 이름이 두 가지 완전히 다른 역할을 하는데, 코드만 봐서는 구분이 안 됐습니다.

문제 1 — "타입으로 쓴 건지, 제약으로 쓴 건지 모른다"

var animal: Animal = Dog()          // Animal이 타입 (existential)
func process<T: Animal>(_ t: T) { } // Animal이 제약 (generic)

위 두 줄은 겉보기엔 비슷해보이지만 동작 방식은 완전히 다릅니다.

var animal: AnimalAnimal 박스를 만들어서 Dog를 담음
→ 런타임에 타입 추적, 박스 비용 발생

func process<T: Animal>
→ T = Dog로 컴파일 타임 확정
→ 박스 없음, 직접 접근

같은 Animal인데 하나는 타입이고 하나는 제약입니다. 초보자는 물론 숙련자도 헷갈렸습니다.

문제 2 — 비용이 코드에서 보이지 않는다

// 개발자 입장
var animals: [Animal] = [Dog(), Cat()]  // 그냥 배열 아닌가?

// 실제로 일어나는 일
// 각 원소마다 Existential Container 박스 생성
// Dog, Cat이 3 word 초과면 힙 할당 발생
// 메서드 호출 시 witness table 간접 참조 2번

성능에 민감한 코드에서 아무 생각 없이 쓰다가 뒤늦게 병목을 발견하는 경우가 생겼습니다.

해결: any로 명시적으로 표시

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의 철학 — 비용은 눈에 보여야 한다

이건 Swift가 일관되게 지켜온 원칙입니다.

// 비용 및 의미가 있는 것들은 항상 명시적으로 표시
inout    // 참조로 전달, 원본 수정 가능
mutating // 값 타입의 self 수정
@escaping // 클로저가 함수 범위를 벗어남
try      // 에러 던질 수 있음
await    // 비동기, 일시 중단 가능
any      // Existential 박스, 런타임 비용

any도 같은 맥락입니다. "이 코드는 박스를 만들고, 런타임에 타입을 추적하고, 비용이 발생한다"는 사실을 코드에서 드러내는 것입니다.

some — Opaque Type

some Animal은 타입이 아닙니다. "컴파일 타임에 확정된 구체 타입이 있는데, 그 이름을 숨겨둔 것"입니다.

func makeAnimal() -> some Animal {
  return Dog()  // 컴파일러: "반환 타입은 Dog구나"
}

컴파일러는 Dog임을 알고 있습니다. 호출하는 쪽만 모릅니다. 박스가 없으니 런타임 비용도 없습니다.
대신 항상 하나의 구체 타입으로 고정되어야 합니다.

func makeAnimal(isCat: Bool) -> some Animal {
  if isCat { return Cat() }
  return Dog()  // ❌ 컴파일 에러: 분기마다 타입이 달라짐
}

some은 제네릭의 축약 문법

아래 두 코드는 완전히 동일합니다.

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 요청

SwiftUI에서 some View를 쓰는 이유

var body: some View {
	VStack {
    	Text("Hello")
    	Button("탭") { }
    }
}

실제 반환 타입은 VStack<TupleView<(Text, Button<Text>)>>입니다. some View로 숨겨두면 내부 구조가 바뀌어도 외부 인터페이스는 유지됩니다.

Associated Type과의 관계

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] ✅ 바로 사용 가능
}

Primary Associated Type

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 → 힙 할당 발생

정리

anysome
타입 결정런타임컴파일 타임
여러 타입 혼용
Associated Type소거됨완전 추론
성능간접 참조, 힙 가능직접 접근, 인라이닝
profile
개발자가 되고 싶은 공돌이

0개의 댓글