쉬운 설명
상자를 하나 만들되, 그 안에 사과도 넣고 책도 넣고 연필도 넣을 수 있게 하고 싶다. 그런데 그냥 "아무거나"라고 하면 나중에 꺼낼 때 뭐가 들었는지 모른다. generic은 "무슨 타입인지 잊지 않고" 같은 상자 구조를 여러 타입에 재사용하게 해 준다.
generic의 본질은 "재사용"이 아니라 타입 정보를 잃지 않고 추상화하는 것이다.
즉 여러 타입에서 같은 구조를 쓰되, Any처럼 타입을 지우지 않고
호출자와 구현 사이의 타입 관계를 컴파일러가 계속 이해하게 만드는 도구다.
2026-04-15
상자를 하나 만들되, 그 안에 사과도 넣고 책도 넣고 연필도 넣을 수 있게 하고 싶다. 그런데 그냥 "아무거나"라고 하면 나중에 꺼낼 때 뭐가 들었는지 모른다. 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를 쓰는 순간 컴파일러는 타입 관계를 잃고,
개발자는 그 관계를 나중에 캐스팅으로 복구해야 한다.
즉 타입 관계 상실 -> 캐스팅 증가 -> 런타임 위험 증가가 핵심 흐름이다.
Any와 any 차이| 구분 | 의미 |
|---|---|
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()
}
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)
}as?, as!로 개발자가 다시 타입을 확인하거나 강제 변환해야 함as!처럼 강제 캐스팅이 틀리면 실행 중 크래시 가능let value: Any = "hello"
let number = value as! Int // 런타임 크래시request<T: Decodable>(...)Repository<Entity>, Cache<Key, Value>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의 가치 는 "재사용"보다도 호출자가 원하는 타입을 명시하면 반환 타입도 정확히 그 타입으로 보장된다는 데 있다.
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로 정밀하게 모델링하는 것이다.
타입 파라미터가 실제 의미 없이 늘어나거나, 호출자가 타입 추론을 이해하기 어려워지거나, 프로토콜 + associated type + where clause가 겹치며 API가 지나치게 난해해지면 과하다.
이 구분은 실무에서 자주 질문 나온다.
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()
}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는 타입 관계를 잃게 만들고, 그 대가로 캐스팅과 런타임 검사 비용을 늘린다.
generic이 단순 재사용 문법이 아니라는 말은 무슨 뜻인가
진짜 핵심은 입력 타입과 출력 타입, 혹은 여러 파라미터 사이 타입 관계를 컴파일 타임에 유지하는 것이다. 재사용은 결과일 뿐이고, 본질은 타입 시스템을 더 강하게 쓰는 데 있다.
왜 Any로 해결하면 안 되나
Any는 문제를 푼 것이 아니라 타입 검사를 런타임으로 미룬 것이다.
캐스팅이 늘고, API 계약이 약해지고, 실패가 컴파일 타임이 아니라 런타임에 드러난다.
let values: [Any] = [1, "hello", true]
for value in values {
if let intValue = value as? Int {
print(intValue + 1)
}
}generic과 protocol-oriented programming은 어떻게 다르나
generic은 타입 파라미터를 통한 추상화이고, protocol은 타입이 가져야 할 능력을 모델링한다. 실무에서는 둘을 대체재로 보기보다, generic + protocol constraint를 함께 쓰는 경우가 많다.
protocol IdentifiableValue {
var id: Int { get }
}
func printID<T: IdentifiableValue>(_ value: T) {
print(value.id)
}왜 generic이 많아질수록 오히려 코드가 약해질 수 있나
타입 파라미터가 의미를 설명하지 못하고 단지 "유연해 보이기 위해" 늘어나면, API 표면이 과도하게 추상화된다. 그 순간 호출자는 유연성을 얻는 대신 추론 비용을 부담하게 된다.
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)generic과 existential 중 무엇을 먼저 선택해야 하나
타입 관계를 유지해야 하면 generic, 저장/전달 편의성이 더 중요하고 타입 관계가 이미 충분히 약해도 되면 existential을 본다. 이 구분 없이 섞어 쓰면 API가 금방 일관성을 잃는다.
func transform<T: Runner>(_ value: T) -> T {
value
}
func store(_ value: any Runner) {
print(value)
}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 표면에서 숨기는 용도다.
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은 반환 타입의 구체 구현을 감추는 데 초점이 있다.