[Swift] Protocol의 제네릭!

룰루날라·2022년 7월 6일
0
post-thumbnail

범용성 있는 코드를 만들어보려고 이리저리 고민하다보면,
프로토콜을 제네릭을 사용해 구현하고 싶은 경우가 있다.

하지만?

바~로 이런 컴파일 에러를 만나게 된다.

❌ Protocol은 generic parameter를 사용할 수 없다; 대신 associated typ을 사용해라.

에러 메세지는 언제나 피가 되고 살이 되지ㅎ
아직 뭔지는 모르겠지만 프로토콜에서는 제네릭 대신 associated type이라는 걸 사용하는 것 같다.
한 번 알아보즈아~~

🤹‍♀️ Associated type

위에서 에러 메세지가 말해준 것처럼 프로토콜에서는 제네릭을 구현하기 위해 associated type을 사용한다.

associated type은 제네릭처럼 프로토콜에서 쓰이는 타입에 placeholder name을 준다.
프로토콜을 제네릭하게 만들고 싶은 경우는 어떤 때일까?

예를 들어,
모든 식당은 '음식을 제공한다'는 공통점이 있기 때문에 serve(food:) 메소드를 가지고 있는RestaurantProtocol을 만들고 싶다.
그런데 식당에 따라 한식, 일식, 양식, 분식, 디저트 등 어떤 음식을 제공할지는 아직 알 수 없다.
어떤 음식 종류가 오든 해당 프로토콜을 사용할 수 있도록 프로토콜을 제네릭하게 만들고 싶을 때, associatedtype을 사용한다!

예제 코드를 보자

protocol RestaurantProtocol {

    associatedtype Food
    func serve(_ food: Food)
}

아직 Food 타입이 어떤 타입인지는 모르지만 어떤 식당이든 '음식'을 제공할 것이기 때문에
일단 Food라는 placeholder 이름을 주었다.
실제로 Food 타입에 어떤 타입이 들어올지는 RestaurantProtocol을 채택하는 타입에서 정해줄 것이다.

struct Cafe: RestaurantProtocol {

    typealias Food = Dessert
    
    func serve(_ food: Dessert) {
        print("\(food.name)을 팔아요~")
    }
}

struct Dessert {

    let name: String
}

RestaurantProtocol을 채택하는 Cafe 구조체를 만들어주었다.
카페는 디저트를 팔기 때문에 Food의 타입을 Dessert로 정해주었다.

이렇게 한식집이라면 typealias Food = KoreanFood로,
피자가게라면 typealias Food = Pizza로 프로토콜을 채택하는 타입에서 정해주면 된다!

사실 Swift의 타입 추론덕분에 typealias Food = Dessert를 작성하지 않아도
Cafe가 프로토콜의 요구조건을 구현해놓았기 때문에 알아서 매칭해서 추론해준다.
하지만 확실히 명시를 해주는 것이 코드를 읽는 사람 입장에서 알아보기 쉽기 때문에
작성해 주는 것이 더 좋을 것 같다👍

또한 typealias를 쓸 게 아니라면 Cafe 타입을 제네릭으로 만들어주면 된다.

struct Cafe<Dessert>: RestaurantProtocol {

    func serve(_ food: Dessert) {
        print("\(food.name)을 팔아요~")
    }
}

짜잔.
요렇게 작성해줘도 똑같이 작동한다.

이제 프로토콜도 제네릭하게 사용할 수 있게 됐다!

그런데 이 associated type 프로토콜을 사용하다보면 한 가지 문제를 더 만난다.
바로 해당 프로토콜이 특정 변수의 타입일 때, 주로 의존성 주입을 할 때다.

💉 Associated Type 프로토콜 의존성 주입

프로젝트에서 Clean Architecture를 적용해보면서 Repository에 대한 범용성 있는 Protocol을 만들고자 associated type 프로토콜을 사용해보았다.

protocol RepositoryProtocol {

    associatedtype EndPoint
    
    func download(from endPoint: EndPoint) async throws -> Data
    func upload(to endPoint: EndPoint) async throws
    func delete(at endPoint: EndPoint) async throws
}

저장소로 서버를 쓰든, Core Data를 쓰든, Firebase를 쓰든
변경이 되더라도 의존성 주입만 바꿔주면 되도록 RepositoryProtocol을 만들었다.

저장소의 종류에 따라 데이터를 받아오는 EndPoint는 달라질 수 있기에 EndPoint란 이름으로 associatedtype을 만들어줬다.

이제 이 RepositoryProtocol를 의존성주입을 위해서 사용해줘야 하는데...?

여기에서도 똑같이 typealias를 주면 될거라고 생각했는데..
이번엔 이런 에러를 만난다😏

프로토콜 RepositoryProtocol은 Self나 associated type에 대한 요구조건이 있기 때문에 generic constraint로만 쓰일 수 있다.

이 에러를 해결할 방법도 역시 에러 메세지 자체에서 찾을 수 있다.

associatedtype을 사용한 프로토콜은 해당 타입에 실제 어떤 타입을 넣어줘야 할지에 대한 요구조건이 있다. 그렇기 때문에 generic constraint로만 쓸 수 있다.

generic constraint는 용어는 낯설지만 우리가 아주 잘 아는 것이다.
JSON 데이터를 파싱하는 Parser의 디코딩 메서드를 만든다고 해보자.

func decode<Model: Decodable>(data: Data, to model: Model.Type) -> Data {
    let decodedData = try! JSONDecoder().decode(model, from: data)
    return decodedData
}

우리는 JSONDecoderdecode(:from:)메소드에 모델 타입으로 넘겨줄 타입이 Decodable을 채택하길 바란다.
이때 제네릭 타입(eg. Model)이 상속해야할 클래스나 채택해야할 프로토콜(eg. Decodable)의 제한을 주는 것이 바로 generic constraint다.

즉, associatedtype을 사용한 프로토콜은 특정 제네릭 타입이 채택해야할 프로토콜로만 사용 가능하다.
말이 복잡하지만 코드로 보면 바로 이해가 간다.

final class UseCase<Repository: RepositoryProtocol> 

    var repository: Repository
    
    init(repository: Repository) {
        self.repository = repository
    }
}

바로 이렇게만 사용 가능하다는 것!
생각보다 간단하게 사용이 가능하다ㅎㅎ

후후 앞으로는 의존성 주입따위 가볍게 해줄 수 있겠다.

associatedtype을 사용한 프로토콜을 의존성 주입에 적용해본 적은 처음이라
문법을 알아보면서 매우 흥미로웠다😋



참고
https://docs.swift.org/swift-book/LanguageGuide/Generics.html
https://stackoverflow.com/questions/48048190/how-to-inject-protocol-with-associated-types

profile
즐거운 인생 (~-_-)~ ~(-_-~)

0개의 댓글