2025/07/21

15. Swift 제네릭(Generics): 유연하고 재사용 가능한 코드 작성하기

안녕하세요! 오늘은 Swift의 강력한 타입 시스템을 한 단계 더 끌어올려, 여러분의 코드를 더욱 유연하고 재사용 가능하게 만들어 줄 제네릭(Generics)에 대해서 정리해 보겠습니다. 💡

프로그래밍을 하다 보면 다양한 타입에 대해 동일한 기능을 수행해야 하는 경우가 많습니다. 예를 들어, 두 값을 교환하는 함수를 만든다고 생각해봅시다. Int 타입 두 개를 교환하는 함수, String 타입 두 개를 교환하는 함수... 이렇게 매번 타입별로 함수를 따로 만들어야 한다면 코드가 매우 길어지고 비효율적이겠죠? 제네릭은 이러한 문제점을 해결하여, 어떤 특정 타입에도 얽매이지 않고 유연하게 동작하면서도, 컴파일 시점에 타입 안전성을 보장하는 코드를 작성할 수 있게 해줍니다.

1. 제네릭(Generics)이란 무엇인가요?

제네릭은 함수, 클래스, 구조체, 열거형 등이 특정 타입에 얽매이지 않고 모든 타입(또는 특정 조건을 만족하는 타입)에서 동작하도록 코드를 작성하는 방법입니다. 이를 통해 코드의 중복을 줄이고 재사용성을 높이며, 동시에 컴파일 타임에 타입 안전성을 보장합니다.

💡 왜 제네릭이 필요한가요?

  • 코드 중복 제거: 여러 타입에 대해 동일한 로직을 반복해서 작성할 필요가 없습니다.
  • 타입 안전성: 실행 시간(런타임)이 아닌 컴파일 시간에 타입 불일치 오류를 감지하여 앱의 안정성을 높입니다.
  • 유연성: 다양한 타입과 함께 작동하는 유연한 API를 설계할 수 있습니다.
  • 표현력: 코드의 의도를 더 명확하게 표현할 수 있습니다.

1.1. 타입 파라미터(Type Parameters)의 사용

제네릭 코드를 작성할 때는 T, U, Key, Value 등과 같은 플레이스홀더 타입 이름(타입 파라미터)을 사용합니다. 이들은 실제 사용될 때 구체적인 타입(예: Int, String)으로 대체됩니다. 타입 파라미터는 함수 이름 뒤나 타입 이름 뒤에 꺾쇠 괄호(< >) 안에 넣어 선언합니다.

2. 제네릭 함수 (Generic Functions): 어떤 타입이든 교환하기

두 값을 교환하는 함수를 예로 들어 제네릭 함수의 강력함을 알아봅시다.


// 비-제네릭 함수 (Int 타입만 교환 가능)
func swapTwoInts(_ a: inout Int, _ b: inout Int) {
    let temporaryA = a
    a = b
    b = temporaryA
}

var int1 = 10
var int2 = 20
swapTwoInts(&int1, &int2)
print("int1: \(int1), int2: \(int2)") // 출력: int1: 20, int2: 10

// 💡 제네릭 함수: 어떤 타입이든 교환 가능!
func swapTwoValues<T>(_ a: inout T, _ b: inout T) { // <T>는 타입 파라미터를 의미
    let temporaryA = a
    a = b
    b = temporaryA
}

var string1 = "Hello"
var string2 = "World"
swapTwoValues(&string1, &string2) // T는 String으로 추론됨
print("string1: \(string1), string2: \(string2)") // 출력: string1: World, string2: Hello

var double1 = 1.23
var double2 = 4.56
swapTwoValues(&double1, &double2) // T는 Double로 추론됨
print("double1: \(double1), double2: \(double2)") // 출력: double1: 4.56, double2: 1.23

swapTwoValues<T> 함수는 Int, String, Double 등 어떤 타입의 두 값이라도 안전하게 교환할 수 있습니다. Swift 컴파일러는 함수가 호출되는 시점에 T가 어떤 타입인지 추론하여 해당 타입에 맞춰 함수를 컴파일합니다.

3. 제네릭 타입 (Generic Types): 유연한 데이터 구조 만들기

클래스, 구조체, 열거형과 같은 사용자 정의 타입도 제네릭으로 만들 수 있습니다. Swift 표준 라이브러리의 Array와 Dictionary가 대표적인 제네릭 타입입니다. (예: Array<Element>, Dictionary<Key, Value>)

3.1. 제네릭 스택(Stack) 구조체

스택은 '나중에 들어온 것이 먼저 나가는(LIFO - Last-In, First-Out)' 구조로 데이터를 저장하는 컬렉션입니다. 이 스택을 제네릭으로 만들어 어떤 타입의 요소든 저장할 수 있게 해보겠습니다.


struct Stack<Element> { // <Element>는 스택에 저장될 요소의 타입을 의미
    var items: [Element] = []

    mutating func push(_ item: Element) { // Element 타입의 항목을 스택에 추가
        items.append(item)
    }

    mutating func pop() -> Element { // Element 타입의 항목을 스택에서 제거하고 반환
        return items.removeLast()
    }
}

// Int 타입의 스택
var intStack = Stack<Int>()
intStack.push(10)
intStack.push(20)
print(intStack.pop()) // 출력: 20
print(intStack.pop()) // 출력: 10

// String 타입의 스택
var stringStack = Stack<String>()
stringStack.push("First")
stringStack.push("Second")
print(stringStack.pop()) // 출력: Second
print(stringStack.pop()) // 출력: First

Stack<Element> 구조체는 Element라는 타입 파라미터를 사용하여, Int 스택이든 String 스택이든 하나의 코드로 구현할 수 있습니다. 이는 코드의 재사용성을 극대화합니다.

4. 타입 제약 (Type Constraints): 특정 조건을 만족하는 타입만 허용하기

모든 타입에 대해 동작하는 제네릭 코드가 필요할 수도 있지만, 때로는 특정 프로토콜을 준수하거나 특정 클래스를 상속하는 타입만 허용하고 싶을 때가 있습니다. 이때 타입 제약(Type Constraints)을 사용합니다.

타입 제약은 타입 파라미터 뒤에 콜론(:)을 붙이고 제약 조건을 명시합니다.


// Equatable 프로토콜을 준수하는 타입만 비교할 수 있음
func findIndex<T: Equatable>(of valueToFind: T, in array: [T]) -> Int? {
    for (index, value) in array.enumerated() {
        if value == valueToFind { // == 연산자를 사용하려면 T는 Equatable이어야 함
            return index
        }
    }
    return nil
}

let strings = ["apple", "banana", "cherry"]
if let index = findIndex(of: "banana", in: strings) {
    print("Banana의 인덱스: \(index)") // 출력: Banana의 인덱스: 1
}

let numbers = [10, 20, 30, 40]
if let index = findIndex(of: 30, in: numbers) {
    print("30의 인덱스: \(index)") // 출력: 30의 인덱스: 2
}

// Equatable을 준수하지 않는 타입은 에러 발생
// struct MyStruct {}
// let myStructs = [MyStruct(), MyStruct()]
// findIndex(of: MyStruct(), in: myStructs) // 컴파일 에러! MyStruct는 Equatable이 아니므로 == 연산자 사용 불가

findIndex 함수는 T 타입이 Equatable 프로토콜을 준수해야 한다는 제약을 가지고 있습니다. 이는 == 연산자를 사용할 수 있게 보장하며, 컴파일러가 타입 안전성을 확인할 수 있도록 돕습니다.

5. 연관 타입 (Associated Types): 프로토콜 내에서 제네릭 사용하기

연관 타입은 프로토콜을 정의할 때 제네릭 플레이스홀더를 사용하는 방법입니다. 이는 프로토콜이 정의하는 동작에 사용될 타입을 프로토콜 자체에서는 명시하지 않고, 프로토콜을 채택하는 타입이 결정하도록 위임합니다. associatedtype 키워드를 사용하여 선언합니다.


// Item을 저장하고 가져올 수 있는 컨테이너 프로토콜
protocol Container {
    associatedtype Item // 컨테이너가 저장할 요소의 타입은 나중에 결정됨
    mutating func append(_ item: Item)
    var count: Int { get }
    subscript(i: Int) -> Item { get }
}

// Int 요소를 저장하는 스택 구조체가 Container 프로토콜을 준수
struct IntStack: Container {
    var items: [Int] = []
    mutating func append(_ item: Int) { // Item은 Int로 결정됨
        items.append(item)
    }
    var count: Int {
        return items.count
    }
    subscript(i: Int) -> Int { // Item은 Int로 결정됨
        return items[i]
    }
    // pop()과 같은 Stack 고유의 메서드는 그대로 유지
    mutating func push(_ item: Int) { items.append(item) }
    mutating func pop() -> Int { return items.removeLast() }
}

var myIntStack = IntStack()
myIntStack.push(100)
myIntStack.append(200) // Container 프로토콜의 append 메서드 사용
print(myIntStack.count) // 출력: 2
print(myIntStack[0])    // 출력: 100

Container 프로토콜은 Item이라는 연관 타입을 정의하여, 이 프로토콜을 준수하는 타입이 어떤 종류의 요소를 저장할지 스스로 결정하도록 합니다. IntStack은 Int를 Item으로 결정했습니다.

정리하며

오늘은 Swift의 코드 재사용성과 타입 안전성을 극대화하는 제네릭(Generics)에 대해 자세히 알아보았습니다.

  • 제네릭: 함수, 타입(클래스, 구조체, 열거형)을 특정 타입에 얽매이지 않고 유연하게 작성하는 방법입니다.
  • 제네릭 함수: <T>와 같은 타입 파라미터를 사용하여 다양한 타입의 인자를 처리합니다.
  • 제네릭 타입: <Element>와 같은 타입 파라미터를 사용하여 다양한 타입의 요소를 저장하는 컬렉션 등을 만듭니다.
  • 타입 제약: : Equatable, : AnyObject 등과 같이 특정 프로토콜 준수 또는 클래스 상속을 요구하여 제네릭 타입의 범위를 제한하고 특정 기능을 사용 가능하게 합니다.
  • 연관 타입: associatedtype을 사용하여 프로토콜이 준수하는 타입이 스스로 사용될 타입을 결정하도록 합니다.

제네릭은 Swift 표준 라이브러리와 프레임워크 전반에 걸쳐 광범위하게 사용되는 핵심 개념입니다. 제네릭을 숙달하면 더욱 견고하고 재사용 가능한 추상적인 코드를 작성할 수 있게 될 것입니다.

궁금한 점이 있다면 언제든지 댓글로 남겨주세요. 감사합니다.

0 comments:

댓글 쓰기