iOS Development Guide

generic이 왜 필요한지

generic의 본질은 "재사용"이 아니라 타입 정보를 잃지 않고 추상화하는 것이다. 즉 여러 타입에서 같은 구조를 쓰되, Any처럼 타입을 지우지 않고 호출자와 구현 사이의 타입 관계를 컴파일러가 계속 이해하게 만드는 도구다.

Swift type system Practical API design Knowledge item #9
학습 날짜

2026-04-15

쉬운 설명

상자를 하나 만들되, 그 안에 사과도 넣고 책도 넣고 연필도 넣을 수 있게 하고 싶다. 그런데 그냥 "아무거나"라고 하면 나중에 꺼낼 때 뭐가 들었는지 모른다. generic은 "무슨 타입인지 잊지 않고" 같은 상자 구조를 여러 타입에 재사용하게 해 준다.

한 줄 요약

generic은 코드 중복을 줄이기 위한 문법이 아니라, 타입 관계를 보존한 추상화를 가능하게 하는 도구다.

왜 generic이 필요한가

같은 로직을 여러 타입에 적용하고 싶을 때 선택지는 크게 세 가지다. 타입마다 복붙하든가, Any로 뭉개든가, generic으로 타입 관계를 유지하든가다.

func printInt(_ value: Int) {
    print(value)
}

func printString(_ value: String) {
    print(value)
}

func printValue<T>(_ value: T) {
    print(value)
}
방법 장점 문제
복붙 단순하다 유지보수 비용이 커진다
Any 표면상 유연해 보인다 타입 정보 손실, 캐스팅, 런타임 오류 위험
generic 추상화하면서 타입 안전 유지 설계를 잘못하면 복잡해질 수 있다

가장 작은 예시

func identity<T>(_ value: T) -> T {
    value
}

let number = identity(3)
let text = identity("hello")

이 함수는 어떤 타입이 들어오든 그대로 돌려준다. 중요한 것은 반환 타입이 Any가 아니라 "들어온 바로 그 타입"이라는 점이다.

Any와의 차이

func identityAny(_ value: Any) -> Any {
    value
}

let value = identityAny(3)
// value는 Any라서 Int처럼 바로 쓸 수 없다.

Any는 값을 담을 수는 있지만, 타입 관계를 잃는다. generic은 "입력 타입과 출력 타입이 동일하다"는 사실을 컴파일러가 계속 추적한다.

Any의 핵심 문제

Any를 쓰는 순간 컴파일러는 타입 관계를 잃고, 개발자는 그 관계를 나중에 캐스팅으로 복구해야 한다. 즉 타입 관계 상실 -> 캐스팅 증가 -> 런타임 위험 증가가 핵심 흐름이다.

여기서 "타입 정보 손실"이란 컴파일러가 더 이상 이 값이 Int인지, String인지, User인지 정적으로 활용할 수 없게 된다는 뜻이다. 값 자체가 사라지는 것이 아니라, 타입 시스템이 그 관계를 잃는 것이다.

Anyany 차이

구분 의미
Any 정말 아무 타입 값이나 담을 수 있는 최상위 타입 박스
any Protocol 특정 프로토콜을 따르는 값만 담는 existential 타입
let a: Any = 3
let b: Any = "hello"

protocol Animal {
    func sound()
}

struct Dog: Animal {
    func sound() { print("멍멍") }
}

let pet: any Animal = Dog()

Any는 타입 제약이 전혀 없다. 반면 any Animal은 최소한 "Animal을 따른다"는 정보는 유지한다.

func useAny(_ value: Any) {
    print(value)
}

func useAnimal(_ animal: any Animal) {
    animal.sound()
}

왜 둘 다 generic의 대체가 아닌가

Any는 타입 정보를 거의 다 잃고, any Protocol도 프로토콜 수준 정보만 남긴다. 반면 generic은 구체 타입을 호출 시점에 유지한 채 추상화한다.

즉 차이는 이렇다.
  • Any: 아무 값이나 담기
  • any P: 프로토콜 기준으로 담기
  • <T>: 구체 타입 관계를 유지하며 추상화하기
func echoAny(_ value: Any) -> Any {
    value
}

func echoAnimal(_ animal: any Animal) -> any Animal {
    animal
}

func echoGeneric<T>(_ value: T) -> T {
    value
}

some은 어디에 들어가나

some Protocol은 "프로토콜을 따르는 어떤 구체 타입 하나"를 숨겨서 반환하거나 전달하는 방식이다. existential처럼 타입을 박스로 지우는 것이 아니라, 구체 타입은 유지하되 호출자에게 노출하지 않는 모델이다.

protocol Shape {
    func draw()
}

struct Circle: Shape {
    func draw() { print("circle") }
}

func makeShape() -> some Shape {
    Circle()
}

호출자는 반환값이 Shape 계열이라는 것은 알지만, 구체적으로 Circle인지 다른 타입인지는 API 표면에서 직접 보지 않는다.

some vs any vs generic

구분 핵심
some P 구체 타입은 유지하지만 숨김
any P 프로토콜 existential 박스
generic <T: P> 호출 시점의 구체 타입 관계를 API 전체에서 유지
func makeOpaqueShape() -> some Shape {
    Circle()
}

func makeExistentialShape() -> any Shape {
    Circle()
}

func echoShape<T: Shape>(_ shape: T) -> T {
    shape
}

타입 정보 손실, 캐스팅, 런타임 오류 위험이란 무엇인가

이 문장은 값이 사라진다는 뜻이 아니라, 컴파일러가 그 값을 안전하게 다룰 근거를 잃고, 개발자가 나중에 직접 캐스팅으로 복구해야 한다는 뜻이다.

func makeValue() -> Any {
    3
}

let value = makeValue()

// value + 1  // 불가. 컴파일러는 value가 Int인지 모른다.

if let intValue = value as? Int {
    print(intValue + 1)
}
let value: Any = "hello"
let number = value as! Int // 런타임 크래시
generic을 쓰면 이런 복구 비용을 줄일 수 있다. 컴파일러가 처음부터 타입 관계를 알고 있기 때문이다.

실무에서 generic이 진짜 중요한 이유

func decode<T: Decodable>(_ type: T.Type, from data: Data) throws -> T {
    try JSONDecoder().decode(T.self, from: data)
}
struct User: Decodable {
    let id: Int
}

let user = try decode(User.self, from: data)

이런 API의 가치 는 "재사용"보다도 호출자가 원하는 타입을 명시하면 반환 타입도 정확히 그 타입으로 보장된다는 데 있다.

constraint가 붙는 이유

generic은 "아무 타입이나" 받는 것 같지만, 실제 실무에서는 대부분 제약과 함께 쓴다. 왜냐하면 구현이 특정 능력을 요구하기 때문이다.

func isEqual<T: Equatable>(_ lhs: T, _ rhs: T) -> Bool {
    lhs == rhs
}
isEqual(1, 2)          // 가능
isEqual("a", "b")      // 가능
// isEqual(1, "a")     // 불가. 같은 T가 아니다.

여기서 T: Equatable이 없으면 ==를 쓸 근거가 없다. 즉 generic의 핵심은 범용성만이 아니라, 필요한 능력을 constraint로 정밀하게 모델링하는 것이다.

generic은 언제 과한가

타입 파라미터가 실제 의미 없이 늘어나거나, 호출자가 타입 추론을 이해하기 어려워지거나, 프로토콜 + associated type + where clause가 겹치며 API가 지나치게 난해해지면 과하다.

generic은 "고급"이 아니라 "타입 설계 도구"다. 추상화 이득보다 읽기 비용이 커지면 오히려 설계를 약하게 만든다.

generic vs protocol existential

이 구분은 실무에서 자주 질문 나온다. generic은 "구체 타입은 호출 시점마다 달라도, 그 호출 안에서는 하나의 구체 타입이 유지된다"는 모델이다. 반면 any Protocol은 "프로토콜 타입 박스"를 다루는 모델이다.

구분 generic any Protocol
핵심 구체 타입을 유지한 추상화 프로토콜 타입으로 감싼 값
타입 정보 컴파일러가 더 많이 안다 일부 정보가 지워진다
대표 장점 타입 안전성과 최적화 가능성 저장과 전달이 단순할 때가 있음
protocol Runner {
    func run()
}

struct DogRunner: Runner {
    func run() { print("dog") }
}

func useGeneric<T: Runner>(_ value: T) {
    value.run()
}

func useExistential(_ value: any Runner) {
    value.run()
}

실무 판단 기준

  • 입력과 출력 타입 관계를 보존해야 하면 generic을 먼저 본다.
  • 단순히 "프로토콜을 따르는 아무 값 하나"를 담아야 하면 existential이 나을 수 있다.
  • 캐스팅이 늘기 시작하면 generic 설계를 먼저 의심한다.
  • 반대로 API 서명이 지나치게 복잡하면 generic 남용도 의심한다.
struct Cache<Key: Hashable, Value> {
    private var storage: [Key: Value] = [:]

    mutating func set(_ value: Value, for key: Key) {
        storage[key] = value
    }

    func value(for key: Key) -> Value? {
        storage[key]
    }
}

한 줄 요약

generic은 타입을 지우지 않고 추상화하고 싶을 때 쓰는 도구다. 즉 "같은 구조"보다 "보존해야 하는 타입 관계"가 있을 때 진짜 가치가 생긴다.

반대로 Any는 타입 관계를 잃게 만들고, 그 대가로 캐스팅과 런타임 검사 비용을 늘린다.

Then 1

generic이 단순 재사용 문법이 아니라는 말은 무슨 뜻인가

진짜 핵심은 입력 타입과 출력 타입, 혹은 여러 파라미터 사이 타입 관계를 컴파일 타임에 유지하는 것이다. 재사용은 결과일 뿐이고, 본질은 타입 시스템을 더 강하게 쓰는 데 있다.

Then 2

Any로 해결하면 안 되나

Any는 문제를 푼 것이 아니라 타입 검사를 런타임으로 미룬 것이다. 캐스팅이 늘고, API 계약이 약해지고, 실패가 컴파일 타임이 아니라 런타임에 드러난다.

let values: [Any] = [1, "hello", true]

for value in values {
    if let intValue = value as? Int {
        print(intValue + 1)
    }
}

Then 3

generic과 protocol-oriented programming은 어떻게 다르나

generic은 타입 파라미터를 통한 추상화이고, protocol은 타입이 가져야 할 능력을 모델링한다. 실무에서는 둘을 대체재로 보기보다, generic + protocol constraint를 함께 쓰는 경우가 많다.

protocol IdentifiableValue {
    var id: Int { get }
}

func printID<T: IdentifiableValue>(_ value: T) {
    print(value.id)
}

Then 4

왜 generic이 많아질수록 오히려 코드가 약해질 수 있나

타입 파라미터가 의미를 설명하지 못하고 단지 "유연해 보이기 위해" 늘어나면, API 표면이 과도하게 추상화된다. 그 순간 호출자는 유연성을 얻는 대신 추론 비용을 부담하게 된다.

Then 5

func request<T: Decodable>(...) -> T 같은 API가 실무에서 왜 강력한가

"이 요청은 결국 어떤 모델을 돌려주는가"를 호출 지점에서 타입으로 고정할 수 있기 때문이다. 즉 네트워크 레이어가 단순히 데이터를 운반하는 것이 아니라, 타입 계약을 제공하는 계층이 된다.

func request<T: Decodable>(_ type: T.Type) async throws -> T {
    fatalError("network")
}

let user: User = try await request(User.self)

Then 6

generic과 existential 중 무엇을 먼저 선택해야 하나

타입 관계를 유지해야 하면 generic, 저장/전달 편의성이 더 중요하고 타입 관계가 이미 충분히 약해도 되면 existential을 본다. 이 구분 없이 섞어 쓰면 API가 금방 일관성을 잃는다.

func transform<T: Runner>(_ value: T) -> T {
    value
}

func store(_ value: any Runner) {
    print(value)
}

Then 7

some은 왜 필요한가

반환 타입의 구체 구현을 숨기고 싶지만, existential처럼 타입을 완전히 지우고 싶지는 않을 때 필요하다. 즉 API 표면은 단순하게 유지하면서도 구현 쪽에서는 구체 타입 이점을 살릴 수 있다.

protocol Shape {
    func draw()
}

struct Circle: Shape {
    func draw() { print("circle") }
}

struct Square: Shape {
    func draw() { print("square") }
}

func makeCircle() -> some Shape {
    Circle()
}

let shape = makeCircle()
shape.draw()

여기서 호출자는 반환값이 Shape를 따른다는 것만 알면 된다. 하지만 구현 쪽에서는 실제로 Circle이라는 하나의 구체 타입을 유지한다. 즉 "프로토콜을 따르는 값"을 돌려주되, 구체 타입 이름은 API 표면에서 숨기는 용도다.

Then 8

some과 generic은 비슷해 보이는데 무엇이 다른가

generic은 호출자가 타입 파라미터 관계를 함께 가져가는 모델이고, some은 구현자가 하나의 구체 타입을 정해 두고 그것을 감춘 채 노출하는 모델이다. 즉 타입을 누가 선택하고 누가 아는지가 다르다.

protocol Runner {
    func run()
}

struct DogRunner: Runner {
    func run() { print("dog") }
}

struct CatRunner: Runner {
    func run() { print("cat") }
}

// 구현자가 반환 구체 타입을 정하고 숨긴다.
func makeRunner() -> some Runner {
    DogRunner()
}

// 호출자가 넣은 구체 타입을 그대로 유지한다.
func echoRunner<T: Runner>(_ value: T) -> T {
    value
}

let a = makeRunner()          // some Runner
let b = echoRunner(DogRunner()) // DogRunner
let c = echoRunner(CatRunner()) // CatRunner

makeRunner()에서는 구현자가 "DogRunner를 주겠다"를 정해 놓고 숨긴다. 반면 echoRunner는 호출자가 넣은 타입을 그대로 되돌려 준다. 즉 generic은 입력과 출력의 타입 관계를 유지하고, some은 반환 타입의 구체 구현을 감추는 데 초점이 있다.