Swift 제네릭 | Generic 함수, 타입, 제약
이 글의 핵심
같은 로직을 Int용, String용으로 반복해서 쓰는 대신 타입을 매개변수로 받으면 중복을 없애면서도 컴파일 타임 타입 검사를 유지할 수 있습니다. 제네릭 함수에서 ==를 쓰면 컴파일 에러가 나는 이유가 제약 부족에 있다는 점을 짚고, 제네릭과 프로토콜 타입 중 무엇을 고를지 판단하는 기준도 함께 정리합니다.
들어가며
제네릭은 Array<Element>처럼 틀은 하나로 두며, 구체 타입은 호출부에서 정하는 방식입니다. 연관 타입(associatedtype)으로 프로토콜과 조합하기도 합니다.
제네릭이 없다면 선택지는 두 가지뿐입니다. swapInts, swapStrings처럼 타입마다 함수를 복사하거나, Any를 받아 꺼낼 때마다 as? Int로 캐스팅하는 것입니다. 앞의 방법은 중복이 늘고, 뒤의 방법은 잘못된 타입을 넣어도 컴파일러가 막지 못해 런타임 크래시로 이어집니다. 제네릭은 “어떤 타입이든 받되, 한 번 정해진 타입은 끝까지 지킨다”는 방식으로 두 문제를 동시에 해결합니다. 우리가 매일 쓰는 Array, Dictionary, Optional, Result가 모두 제네릭 타입이라, 제네릭을 이해하면 표준 라이브러리 문서를 읽는 것도 훨씬 쉬워집니다.
제네릭 함수로 타입별 중복 없애기
기본 제네릭 함수
func swap<T>(_ a: inout T, _ b: inout T) {
let temp = a
a = b
b = temp
}
var x = 10
var y = 20
swap(&x, &y)
print("x: \(x), y: \(y)") // x: 20, y: 10
var str1 = "Hello"
var str2 = "World"
swap(&str1, &str2)
print("\(str1), \(str2)") // World, Hello
<T>는 타입 매개변수 선언으로, 함수 이름 바로 뒤에 둡니다. 호출할 때 타입을 적지 않아도 컴파일러가 인자에서 T를 추론합니다. 첫 번째 호출에서는 T가 Int, 두 번째에서는 String입니다. 두 매개변수가 같은 T이므로 swap(&x, &str1)처럼 서로 다른 타입을 넘기면 Cannot convert value of type 'String' to expected argument type 'Int' 컴파일 에러가 납니다. Any를 썼다면 통과했을 실수를 막아 주는 부분입니다.
이 예제는 설명을 위해 직접 구현했지만 표준 라이브러리에 같은 이름의 swap(_:_:)이 이미 있어서, 실제 프로젝트에서 이렇게 정의하면 모듈 안에서는 표준 함수를 가립니다. 배열의 두 원소를 바꿀 때는 array.swapAt(i, j)를 쓰는 것이 권장되며, swap(&array[i], &array[j])는 같은 배열에 대한 동시 접근이라 Overlapping accesses to 'array' 에러가 납니다.
제네릭 함수 예제
func findIndex<T: Equatable>(of value: T, in array: [T]) -> Int? {
for (index, item) in array.enumerated() {
if item == value {
return index
}
}
return nil
}
let numbers = [1, 2, 3, 4, 5]
if let index = findIndex(of: 3, in: numbers) {
print("인덱스: \(index)") // 인덱스: 2
}
let strings = ["a", "b", "c"]
if let index = findIndex(of: "b", in: strings) {
print("인덱스: \(index)") // 인덱스: 1
}
<T: Equatable>의 제약이 이 함수의 핵심입니다. 제약 없이 <T>로만 선언하면 함수 본문의 item == value에서 Binary operator '==' cannot be applied to two 'T' operands 에러가 납니다. 컴파일러는 T가 무엇이 될지 모르므로 “모든 타입이 할 수 있는 일”만 허용하는데, 비교는 모든 타입이 지원하는 연산이 아니기 때문입니다. 제약은 “이 함수는 Equatable인 타입만 받는다”고 약속하는 대가로, 본문에서 ==를 쓸 수 있는 권한을 얻는 거래라고 보면 이해하기 쉽습니다.
참고로 이 함수는 표준 라이브러리의 array.firstIndex(of: value)와 같은 동작입니다. 표준 라이브러리 쪽도 Element: Equatable 제약이 걸린 확장 메서드로 정의되어 있어서, Equatable이 아닌 구조체 배열에서는 firstIndex(of:)가 자동완성에 나오지 않고 대신 firstIndex(where:)만 쓸 수 있습니다. 직접 만든 구조체를 비교하고 싶다면 struct User: Equatable처럼 선언만 하면, 모든 저장 속성이 Equatable인 경우 컴파일러가 ==를 자동으로 만들어 줍니다.
Stack과 Pair로 보는 제네릭 타입
Stack 구현
struct Stack<Element> {
private var items: [Element] = []
mutating func push(_ item: Element) {
items.append(item)
}
mutating func pop() -> Element? {
return items.popLast()
}
func peek() -> Element? {
return items.last
}
var isEmpty: Bool {
return items.isEmpty
}
var count: Int {
return items.count
}
}
var intStack = Stack<Int>()
intStack.push(1)
intStack.push(2)
intStack.push(3)
print(intStack.pop()) // Optional(3)
print(intStack.peek()) // Optional(2)
var stringStack = Stack<String>()
stringStack.push("Hello")
stringStack.push("World")
타입 이름 뒤의 <Element>는 이 구조체 전체에서 쓸 타입 매개변수입니다. Stack<Int>()로 만들면 모든 Element가 Int로 정해져 intStack.push("text")는 컴파일 에러가 됩니다. Element라는 이름은 표준 라이브러리 컬렉션의 관례를 따른 것으로, 의미가 있는 이름을 붙이면 T보다 읽기 쉽습니다. 딕셔너리의 Key와 Value도 같은 관례입니다.
push와 pop에 mutating이 붙은 이유는 Stack이 구조체(값 타입)이기 때문입니다. 구조체의 메서드는 기본적으로 자기 속성을 바꿀 수 없어서, 바꾸는 메서드에는 mutating을 명시해야 합니다. 그래서 let stack = Stack<Int>()처럼 상수로 선언하면 push를 호출할 수 없습니다. 값 타입이라서 var copy = intStack으로 복사한 뒤 copy.push(4)를 해도 원본은 바뀌지 않는데, 내부 배열은 쓰기 시점 복사(copy-on-write) 덕분에 실제로 수정하기 전까지는 메모리를 공유하므로 복사 비용도 거의 없습니다. pop()과 peek()이 옵셔널을 반환하는 것은 빈 스택에서 호출해도 크래시 대신 nil을 돌려주기 위해서이고, 출력 결과에 Optional(3)이 찍힌 이유이기도 합니다.
Pair 구현
struct Pair<T, U> {
let first: T
let second: U
}
let pair1 = Pair(first: 1, second: "one")
let pair2 = Pair(first: "name", second: 25)
print("\(pair1.first): \(pair1.second)")
타입 매개변수는 여러 개일 수 있고, 각각 독립적으로 추론됩니다. pair1은 Pair<Int, String>, pair2는 Pair<String, Int>로 서로 다른 타입이라 같은 배열에 넣을 수 없습니다. 이런 단순한 묶음은 튜플 (first: 1, second: "one")로도 표현할 수 있는데, 튜플은 프로토콜을 채택하거나 메서드를 추가할 수 없어서 Hashable이 되어야 하는 딕셔너리 키나 확장이 필요한 경우에는 이렇게 이름 있는 제네릭 구조체를 만듭니다.
프로토콜 제약과 where 절
프로토콜 제약
func largest<T: Comparable>(_ array: [T]) -> T? {
guard !array.isEmpty else { return nil }
var largest = array[0]
for item in array {
if item > largest {
largest = item
}
}
return largest
}
print(largest([1, 5, 3, 9, 2])) // Optional(9)
print(largest(["a", "z", "m"])) // Optional("z")
Comparable은 <, > 같은 순서 비교를 제공하는 프로토콜이며 Equatable을 상속합니다. 그래서 T: Comparable만 적어도 ==까지 쓸 수 있습니다. 빈 배열을 받으면 array[0]에서 Index out of range로 크래시가 나므로, guard로 먼저 확인하고 반환 타입을 옵셔널로 만들었습니다. 표준 라이브러리의 array.max()도 같은 이유로 옵셔널을 반환합니다.
Double 배열에 쓸 때는 주의할 점이 하나 있습니다. Double은 Comparable이지만 .nan은 어떤 값과 비교해도 false라서, 배열의 첫 원소가 NaN이면 item > largest가 항상 거짓이 되어 결과가 NaN으로 남습니다. 제약이 “비교 연산이 있다”는 것만 보장할 뿐, 그 비교가 수학적으로 완전한 순서라는 것까지 보장하지는 않는다는 좋은 예입니다.
where 절
func allEqual<T: Equatable>(_ array: [T]) -> Bool {
guard let first = array.first else { return true }
for item in array {
if item != first {
return false
}
}
return true
}
print(allEqual([1, 1, 1])) // true
print(allEqual([1, 2, 1])) // false
이 함수는 <T: Equatable>로 제약을 꺾쇠 안에 적었는데, 같은 제약을 func allEqual<T>(_ array: [T]) -> Bool where T: Equatable처럼 where 절로 옮겨 쓸 수도 있습니다. 제약이 하나일 때는 차이가 없지만, where 절은 꺾쇠 안에 쓸 수 없는 조건을 표현할 때 필요합니다. 대표적인 것이 연관 타입에 대한 조건입니다. 아래 예제의 Container는 바로 다음 연관 타입 절에서 정의하는 프로토콜입니다.
func allItemsMatch<C1: Container, C2: Container>(_ a: C1, _ b: C2) -> Bool
where C1.Item == C2.Item, C1.Item: Equatable {
guard a.count == b.count else { return false }
for i in 0..<a.count where a[i] != b[i] { return false }
return true
}
두 컨테이너의 타입은 달라도 되지만(IntStack과 다른 Container 구현), 담긴 원소의 타입은 같고 비교 가능해야 한다는 조건입니다. 이런 관계는 꺾쇠 안의 T: Protocol 문법으로는 표현할 수 없습니다. 실무에서 더 자주 쓰는 형태는 확장에 거는 where 절입니다. extension Stack where Element: Equatable { func contains(_ x: Element) -> Bool { ... } }처럼 쓰면 원소가 비교 가능한 스택에만 contains 메서드가 생기고, 그렇지 않은 스택에서는 메서드가 아예 보이지 않습니다. 표준 라이브러리가 firstIndex(of:)를 제공하는 방식이 바로 이것입니다.
프로토콜의 연관 타입(Associated Type)
protocol Container {
associatedtype Item
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}
struct IntStack: Container {
typealias Item = Int
private var items: [Int] = []
mutating func append(_ item: Int) {
items.append(item)
}
var count: Int {
return items.count
}
subscript(i: Int) -> Int {
return items[i]
}
}
연관 타입은 프로토콜 버전의 타입 매개변수입니다. Container 프로토콜은 “원소를 추가하고, 개수를 알려주고, 인덱스로 꺼낼 수 있다”는 요구만 정하고, 원소가 무슨 타입인지는 채택하는 쪽에 맡깁니다. IntStack의 typealias Item = Int는 사실 생략해도 됩니다. append(_ item: Int) 시그니처를 보고 컴파일러가 Item이 Int임을 추론하기 때문입니다. 제네릭 타입 절의 제네릭 Stack<Element>도 메서드만 맞추면 extension Stack: Container {} 한 줄로 이 프로토콜을 채택할 수 있고, 이때 Item은 Element로 추론됩니다.
연관 타입이 있는 프로토콜을 처음 쓸 때 가장 흔히 막히는 곳은 이 프로토콜을 변수 타입으로 쓰려고 할 때입니다. let containers: [Container] = [...]처럼 쓰면 Swift 5.6 이전에는 Protocol 'Container' can only be used as a generic constraint because it has Self or associated type requirements 에러가 났습니다. 원소 타입이 다른 컨테이너들을 한 배열에 넣으면 containers[0][0]의 타입을 정할 수 없기 때문입니다. Swift 5.7부터는 [any Container]로 쓸 수 있지만, 꺼낸 원소의 타입은 알 수 없는 상태가 되어 쓸 수 있는 연산이 제한됩니다. 원소 타입까지 고정하고 싶다면 Swift 5.7의 기본 연관 타입 문법(protocol Container<Item>)을 선언해 두고 any Container<Int>처럼 쓰면 됩니다.
예제: 제네릭 캐시
class Cache<Key: Hashable, Value> {
private var storage: [Key: Value] = [:]
func set(_ value: Value, forKey key: Key) {
storage[key] = value
}
func get(_ key: Key) -> Value? {
return storage[key]
}
func remove(_ key: Key) {
storage.removeValue(forKey: key)
}
func clear() {
storage.removeAll()
}
}
let cache = Cache<String, Int>()
cache.set(100, forKey: "score")
cache.set(25, forKey: "age")
if let score = cache.get("score") {
print("점수: \(score)")
}
Key: Hashable 제약이 필요한 이유는 내부 저장소가 Dictionary이고, 딕셔너리의 키는 해시값을 계산할 수 있어야 하기 때문입니다. 제약을 빼면 [Key: Value] 선언부에서 Type 'Key' does not conform to protocol 'Hashable' 에러가 납니다. 반면 Value에는 아무 제약이 없어 어떤 타입이든 저장할 수 있습니다. 이처럼 타입 매개변수마다 필요한 만큼만 제약을 거는 것이 제네릭 설계의 기본입니다.
class로 만든 이유는 여러 곳에서 같은 캐시 인스턴스를 공유하기 위해서입니다. struct였다면 넘길 때마다 복사되어 한쪽에서 저장한 값이 다른 쪽에 보이지 않습니다. 대신 참조 타입이라 여러 스레드에서 동시에 set을 호출하면 딕셔너리가 손상되어 크래시가 날 수 있습니다. 이미지 캐시처럼 백그라운드 작업에서 접근하는 캐시라면 actor Cache<Key: Hashable, Value>로 바꿔 접근을 직렬화하거나, 메모리가 부족할 때 자동으로 항목을 비워 주는 Foundation의 NSCache를 감싸는 편이 안전합니다. 또 이 캐시는 크기 제한과 만료가 없어서 오래 실행되는 앱에서는 메모리가 계속 늘어난다는 점도 기억해 둘 만합니다.
Decoder 기반 제네릭 로더
네트워크·파일에서 한 번만 디코딩 로직을 제네릭으로 묶으면 테스트와 재사용이 쉬워집니다.
import Foundation
enum LoadError: Error {
case badURL
case emptyData
}
struct Loader {
static func load<T: Decodable>(_ type: T.Type, from url: URL, decoder: JSONDecoder = JSONDecoder()) async throws -> T {
let (data, _) = try await URLSession.shared.data(from: url)
guard !data.isEmpty else { throw LoadError.emptyData }
return try decoder.decode(T.self, from: data)
}
}
struct Article: Codable {
let id: Int
let title: String
}
func example() async throws {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts/1") else {
throw LoadError.badURL
}
let post: Article = try await Loader.load(Article.self, from: url)
print(post.title)
}
load는 반환 타입 T를 인자로도 받습니다(_ type: T.Type). 반환값만으로 T를 추론할 수도 있지만, Loader.load(Article.self, from: url)처럼 타입을 명시적으로 넘기면 호출부에서 무엇을 디코딩하는지 바로 보이고, let post: Article = 같은 타입 표기가 없어도 됩니다. JSONDecoder.decode(_:from:)이 같은 스타일을 쓰는 것도 이 이유입니다. 디코더를 매개변수로 받아 기본값을 둔 덕분에, 날짜 형식이나 keyDecodingStrategy = .convertFromSnakeCase가 다른 API에도 같은 함수를 쓸 수 있습니다.
실무에서 이 코드를 그대로 쓰면 아쉬운 부분은 HTTP 상태 코드를 확인하지 않는다는 점입니다. URLSession은 404나 500 응답도 에러로 취급하지 않고 정상적으로 데이터를 돌려주므로, 서버가 에러 JSON이나 HTML을 보내면 decode 단계에서 DecodingError.keyNotFound 같은 에러가 나고 원래 원인(서버 오류)이 가려집니다. let (data, response) = ... 후 (response as? HTTPURLResponse)?.statusCode가 200번대인지 먼저 확인하고, 아니라면 상태 코드를 담은 별도 에러를 던지는 것이 좋습니다. 또 URLSession.shared를 직접 쓰면 테스트에서 네트워크를 대체하기 어려우므로, URLSession이나 데이터를 가져오는 함수를 주입받게 만들면 이 제네릭 로더의 장점인 테스트 용이성이 제대로 살아납니다.
연관 타입·Equatable·existential에서 막히는 지점
- 프로토콜에 연관 타입이 있는데
[Container]처럼 일반 타입으로 쓰려다 에러가 나는 경우 (Swift 5.7+에서는[any Container], 원소 타입 고정이 필요하면 기본 연관 타입 선언 후any Container<Int>). Equatable제약 없이==를 쓰려다 제네릭 함수에서 컴파일 에러가 나는 경우.- existential
any Protocol과 제네릭을 혼동해 성능·표현력을 잃는 경우. - 이진 크기: 제네릭 특수화는 대체로 효율적이지만, 타입 파라미터가 많아지면 컴파일 시간이 늘 수 있습니다.
extension where와 제네릭 뷰
- 공통 제약은
extension+where로 묶어 가독성을 높입니다. - SwiftUI에서는 @ViewBuilder와 제네릭 뷰를 함께 쓸 때 타입 추론 한계를
Group/AnyView로 풀되, 남발은 피합니다.
제네릭, 존재형, typealias 비교
| 패턴 | 설명 |
|---|---|
| 제네릭 | 컴파일 타임 특수화, 타입 안전 |
| 프로토콜 존재형 | 런타임 다형성, 타입 이레이저 비용 |
typealias | 복잡한 제약의 이름 부여 |
표의 앞 두 줄이 이 글의 마지막 판단 기준입니다. func draw<S: Shape>(_ shape: S)처럼 제네릭으로 받으면 컴파일러가 호출하는 타입마다 특수화된 코드를 만들 수 있어 직접 호출처럼 빠르고, 반환 타입도 정확히 유지됩니다. 반면 func draw(_ shape: any Shape)처럼 존재형(existential)으로 받으면 값을 상자(existential container)에 담아 동적 디스패치로 호출하므로 약간의 비용이 들지만, [any Shape]처럼 서로 다른 타입을 한 컬렉션에 섞을 수 있습니다. 한 번에 한 가지 타입을 다룬다면 제네릭(또는 some Shape), 여러 타입을 섞어야 한다면 any를 고르는 것이 기본입니다.
제가 제네릭을 쓰면서 자주 경계하는 것은 추상화를 너무 일찍 하는 것입니다. 쓰는 곳이 하나뿐인데 “나중에 다른 타입도 쓸 수 있으니” 제네릭으로 만들면, 제약과 where 절이 쌓이면서 에러 메시지가 길어지고 읽는 사람이 구체 타입을 머릿속에서 대입해 가며 읽어야 합니다. 실제로 두 번째, 세 번째 사용처가 생겼을 때 공통점을 뽑아 제네릭으로 바꾸는 편이 대개 더 좋은 설계로 이어집니다.
참고 자료
제네릭 요약
- 제네릭 함수:
<T>타입 매개변수 - 제네릭 타입: Stack, Pair 등
- 제약: Equatable, Comparable 등
- where 절: 복잡한 제약 조건
- 연관 타입: 프로토콜의 제네릭
다음 단계
같이 보면 좋은 글
- C++ Generic Lambda
- C++ 템플릿 기초
- C++ Template Lambda
- Rust 트레이트 | Trait, 제네릭, 트레이트 바운드
- Swift 시작하기: iOS 개발 언어의 특징과 Xcode 환경 설정
- Swift 프로토콜과 확장 | Protocol, Extension
- Swift 에러 처리 | do-catch, throw, Result
- Swift Combine
자주 묻는 질문 (FAQ)
Q. 제네릭 함수에서 ==를 쓰면 컴파일 에러가 나는 이유는 무엇인가요?
A. 타입 파라미터 T에 아무 제약이 없으면 컴파일러는 T가 비교 연산을 지원하는지 알 수 없어서 ==를 허용하지 않습니다. <T: Equatable>처럼 프로토콜 제약을 걸거나 where 절로 조건을 붙여야 합니다. 공통 제약이 여러 메서드에 반복된다면 extension과 where로 묶어 두면 가독성이 좋아집니다.