iOS/Swift

Swift) 프로토콜과 의존성 주입, 의존성 분리 원칙 (feat. DI & DIP)

Dev.Andy 2023. 12. 12. 19:40

목차

    I. 머리말

    (0) 요약

    📝 4개의 주제표

    아래와 같이 요약할 수 있다. 긴 글을 읽기 전에 일단 살펴 보고 가자.

      용어 정의
    문제 의존 관계 발생 1. 하위 모듈의 인스턴스를 상위 모듈 내부에 직접 생성할 경우 발생
    2. 하위 모듈이 변경될 때마다 상위 모듈 또한 매번 대응해야 함
    해결책 의존성 분리 1. Swift의 인터페이스인 프로토콜(Protocol)을 이용한 추상화 진행
    2. 정의한 프로토콜을 실제 모듈에 채택하여 요구사항을 준수하도록 요구
    수단 의존성 주입
    (Dependency Injection)
    1. 하위 모듈의 인스턴스를 외부에서 생성
    2. 상위 모듈 내부에는 프로토콜을 프로퍼티로 정의하고 생성자를 통해 프로퍼티를 생성
    원칙 의존성 역전 원칙
    (DIP; Dependency Inversion Principle)
    1. 의존성 주입을 이용하여 다음과 같은 의존성의 방향 역전
    화면 → 비즈니스 로직 → 데이터
    화면 → 비즈니스 로직  데이터 

    (1) 상위 모듈과 하위 모듈

    (1-0) 예시 - 점심을 먹으려는 방문객과 레스토랑, 주인

    상황

    • 점심을 먹고 싶은 Andy는 햄버거를 먹거나 분식을 먹으려 한다.
    • 주변에는 2개의 레스토랑이 있다. 이곳에서 점심 메뉴를 선택할 수 있다.
    • (1) NoahRestaurant라는 햄버거 집과 (2) CallieRestaurant이라는 분식 집이 있다.
    • 또한 각각의 레스토랑은 주인이 (1) Noah, (2) Callie이다.

    설명

    1. 여기서 상위 모듈은 방문객(Andy)이고,
    2. 하위 모듈은 레스토랑(NoahRestaurant & CallieRestaurant)과 주인(Noah & Callie)이다.
    3. 상대적으로 레스토랑도 주인의 상위 모듈이다.

    (1-1) 상위 모듈 - 방문객(Andy)

    class Andy {
    
        var hamburgerRestaurant = NoahRestaurant()
        var bunsikRestaurant = CallieRestaurant()
    
        func selectHamburger() -> String {
            hamburgerRestaurant.makeLunchMenu()
        }
    
        func selectBunsik() -> String {
            selectBunsik.makeLunchMenu()
        }
    }

    (1-2) 하위 모듈 A - 레스토랑 (NoahRestaurant & CallieRestaurant)

    각 레스토랑은 주인이 있는데, 해당 주인의 메뉴에 따라서 점심 메뉴를 만든다.

    class NoahRestaurant {
    
        private let owner = Noah()
    
        func makeLunchMenu() -> String {
            return owner.makeSignatureHamburger() + owner.makeFrenchFries() + owner.makeSoftDrink()
        }
    }
    
    class CallieRestaurant {
    
        private let owner = Callie()
    
        func makeLunchMenu() -> String {
            return owner.makeGimbap() + owner.makeRamyeon() + owner.makeMandu()
        }
    }

    (1-3) 하위 모듈 B - 주인(Noah & Callie)

    각 식당은 주인이 있는데, 이름 그대로 Noah와 Callie가 주인이다. 각 주인은 본인의 레시피 대로 만들어 본인의 레스토랑에 음식을 제공한다.

    class Noah {
    
        func makeSignatureHamburger() -> String {
            return "Signature Hamburger"
        }
    
        func makeFrenchFries() -> String {
            return "FrenchFries"
        }
    
        func makeSoftDrink() -> String {
            return "Soft Drink"
        }
    }
    
    class Callie {
    
        func makeGimbap() -> String {
            return "Beef Gimbap"
        }
    
        func makeRamyeon() -> String {
            return "Hot Ramyeon"
        }
    
        func makeMandu() -> String {
            return "Kimchi Mandu"
        }
    }

    (2) 문제 발생 🔥

    (2-1) 의존성 문제 발생

    • 이제 아래와 같은 의존성 문제가 발생한다.
    • 오른쪽으로 갈수록 하위 모듈인데, low-level의 모듈의 변경이 생길 때마다 그의 상위의 모듈에 매번 변경을 해야하는 문제가 발생할 것이다.
    • 방문객은 레스토랑을, 레스토랑은 주인에게 의존하는 관계가 성립된다

    의존성 문제 발생
    방문객과 레스토랑, 주인 사이에 의존성 문제가 발생하였다

    (2-2) 예시 - 주인의 레시피 변경

    • 아래처럼 Noah라는 주인이 메뉴 방식을 프랜치프라이가 아니라 어니언링으로 변경했다면,
    • 이를 인스턴스로 갖고 있는 NoahRestaurant라는 상위 모듈에 적용을 해줘야 할 것이다.
    class Noah {
        // ❓ 주인의 레시피 변경 (makeFrenchFries -> makeOnionRing)
        func makeOnionRing() -> String {
            return "OnionRing"
        }
    }
    
    class NoahRestaurant {
    
        private let owner = Noah()
    
        // 🔥 ERROR: 레스토랑의 메뉴를 변경해야 한다
        func makeLunchMenu() -> String {
            return owner.makeSignatureHamburger() + owner.makeFrenchFries() + owner.makeSoftDrink()
        }
    
    }
    • 나아가 해당 레스토랑에 주인이 바뀐다거나, 구제척인 메뉴가 바뀌는 등 해당 클래스 인스턴스를 갖고 있는 상위 모듈에 직접적으로 영향을 미치게 된다.

    II. 의존성 주입 (Dependency Injection) by Protocol

    (1) 프로토콜을 이용한 의존성 문제 해결

    프로토콜을 Swift에서 정의한 인터페이스이자, 추상화의 수단이다.

    (1-1) 프로토콜 정의 (인터페이스 생성)

    방문객과, 레스토랑과 주인을 위한 각각의 프로토콜을 정의한다.

    //MARK: - ⭐️ (0) Protocol
    protocol Customer {
        func selectHamburger() -> String
        func selectBunsik() -> String
    }
    
    protocol HamburgerRestaurant {
        func makeLunchMenu() -> String
    }
    
    protocol BunsikRestaurant {
        func makeLunchMenu() -> String
    }
    
    protocol Owner {
        func makeMainMenu() -> String
        func makeSideMenu() -> String
        func makeDrink() -> String
    }

    (1-2) 기존 구조체/클래스에 프로토콜 채택

    기존 커스텀 타입인 구조체/클래스에 (1)의 프로토콜을 채택하면, 해당 프로토콜의 프로퍼티/메서드를 준수(conform to) 해야 한다.

    방문객

    //MARK: - (1) Customer
    final class Andy: Customer {
    
        private var hamburgerRestaurant: HamburgerRestaurant
        private var bunsikRestaurant: BunsikRestaurant
    
        init(
            hamburgerRestaurant: HamburgerRestaurant,       
            bunsikRestaurant: BunsikRestaurant
        ) {
            self.hamburgerRestaurant = hamburgerRestaurant
            self.bunsikRestaurant = bunsikRestaurant
        }
    
        func selectHamburger() -> String {
            hamburgerRestaurant.makeLunchMenu()
        }
    
        func selectBunsik() -> String {
            bunsikRestaurant.makeLunchMenu()
        }
    }

    주인과 레스토랑

    //MARK: - (2-1) Owners
    final class Noah: Owner {
    
        func makeMainMenu() -> String {
            return "Noah's Signature Hamburger"
        }
    
        func makeSideMenu() -> String {
            return "Noah's FrenchFries"
        }
    
        func makeDrink() -> String {
            return "Noah's Soft Drink"
        }
    
    }
    
    final class Callie: Owner {
    
        func makeMainMenu() -> String 
            return "Callie's Gimbap"
        }
    
        func makeSideMenu() -> String {
            return "Callie's Ramyeon"
        }
    
        func makeDrink() -> String {
            return "Callie's EomukSoup"
        }
    }
    
    //MARK: - (2-2) Restaurants
    final class NoahRestaurant: HamburgerRestaurant {
    
        private let owner: Owner
    
        init(
            owner: Owner
        ) {
            self.owner = owner
        }
    
        func makeLunchMenu() -> String {
            return owner.makeMainMenu() + owner.makeSideMenu() + owner.makeDrink()
        }
    }
    
    final class CallieRestaurant: BunsikRestaurant {
    
        private let owner: Owner
    
        init(
            owner: Owner
        ) {
            self.owner = owner
        }
    
        func makeLunchMenu() -> String {
            return owner.makeMainMenu() + owner.makeSideMenu() + owner.makeDrink()
        }
    }

    (1-3) 의존성 주입(Dependency Injection) (외부 → 내부)

    실제 인스턴스를 외부에 생성하여 이를 코드 내부에 할당한다.

    //MARK: - (3) 의존성 주입 (Dependency Injection)
    let noah = Noah()
    let noahRestaurant = NoahRestaurant(owner: noah)
    
    let callie = Callie()
    let callieRestaurant = CallieRestaurant(owner: callie)
    
    let andy = Andy(
        hamburgerRestaurant: noahRestaurant,
        bunsikRestaurant: callieRestaurant
    )

    III. DI vs DIP (Injection vs Inversion)

    (0) 수단 vs 원칙

    (0-1) 주입 vs 역전

    • 줄임말만 보면 너무 비슷하게 보여 헷갈리는 DI와 DIP는 엄연히 다른 말이다.
    • 심지어 각각의 "I"는 주입(Injection)과 역전(Inversion)으로 전혀 다른 의미를 갖고 있다.
    • 또한 하나는 추상화를 이용한 수단이고, 다른 하나는 추상화에 대한 원칙이다.

    (0-2) DI를 하면 DIP를 지킨 것인가?

    • 그렇다면 의존성 주입을 하면 의존성 역전 원칙을 지킨 것일까?
      • 그것은 아니다. 단지, 인스턴스를 밖에서 생성하여 주입하는 것일 뿐, 의존 관계가 뒤바뀌는 것은 아니다.
      • 다만, 📝 여기서 DIP는 SOLID로 대표되는 객체지향의 5원칙 중 하나이다.
      • DI라는 수단을 통해 DIP라는 원칙을 지킬 수 있다
    • 이제 실제로 진행하고 있는 프로젝트(LSLP)의 코드르 통해 알아 보자.

    (1) 의존성 주입(Injection)

    (1-1) LSLP 프로젝트 - Coordinator

    외부(Coordinator)에 인스턴스(VC, VM, UseCase & Repo)를 생성하여 내부로 주입했다.

    final class StoryContentCoordinator: Coordinator {
        // ...
    }
    
    extension StoryContentCoordinator {
    
        // Coordinator 클래스라는 외부에서 인스턴스(VC, VM, UseCase & Repo)를 생성하여 이를 주입
        func showStoryListViewController() {
            self.navigationController.pushViewController(
                StoryListViewController(
                    viewModel: StoryListViewModel(
                        coordinator: self,
                        storyListUseCase: StoryListUseCase(
                            storyPostRepository: StoryPostRepository(),
                            keychainRepository: KeychainRepository()
                        )
                    )
                ),
                animated: true
            )
        }
    }

    (2) 의존성 역전 원칙 (Inversion Principle)

    (2-0) LSLP 프로젝트 - Repository Protocol

    현재 진행 중인 LSLP(Light Service Level Project)의 구조 일부이다.

    의존성 역전 원칙 (Inversion Principle)
    프로토콜을 통한 의존성 역전 원칙(DIP) 성공

    가운데 위치한 빨간 화살표와 별(⭐️)이 의존성 역전이다

    • 여기서 Repositories는 Data Layer에 위치해 있는데, 기존의 방식으로 인스턴스를 매번 직접 생성한다면 아래와 같은 의존 관계가 성립 된다.
    • 하지만 여기서, Domain의 Repository Protocol을 정의하여 Data Repositories에 해당 프로토콜을 채택하게 한다면 의존성이 역전(Inversion)된다.
      • (이전) (Data) DataSources ← (Data) Repositories  (Domain) UseCases ← ViewModel ← View
      • (이후) (Data) DataSources ← (Data) Repositories  ⭐️ (Domain) Repository Protocol ←(Domain) UseCases ← ViewModel ← View

    (2-1) KeychainRepositoryProtocol

    프로토콜에 대해 정의했다.

    enum KeyType: String {
        case userID
        case accessToken
        case refreshToken
    }
    
    protocol KeychainRepositoryProtocol {
        func save(key: String, value: String, type: KeyType) -> Bool
        func find(key: String, type: KeyType) -> String?
        func delete(key: String, type: KeyType) -> Bool
    }

    (2-2) KeychainRepository

    프로토콜을 채택하여 이를 준수한 실제 구현체를 만들었다.

    final class KeychainRepository: KeychainRepositoryProtocol {
    
        private let keySecurityClass = kSecClassGenericPassword
    
        //MARK: - 비즈니스 로직 (1)
        private func logError(_ status: OSStatus) {
            //...
        }
    
        //MARK: - 비즈니스 로직 (2)
        private func update(account: String, value: Data) -> Bool {
            //...
        }
    
        //MARK: - (1) save - KeychainRepositoryProtocol
        func save(key: String, value: String, type: KeyType) -> Bool {
            // 자세한 구현...
        }
    
        //MARK: - (2) find - KeychainRepositoryProtocol
        func find(key: String, type: KeyType) -> String? {
            // 자세한 구현...
        }
    
        //MARK: - (3) delete - KeychainRepositoryProtocol
        func delete(key: String, type: KeyType) -> Bool {
            // 자세한 구현...
        }
    }

    (2-3) StoryListUseCase

    UseCase 또한 프로토콜을 프로퍼티로 갖고 있기에, 생성자(initializer)를 통해 이를 생성하였다.

    final class StoryListUseCase: StoryListUseCaseProtocol {
    
        // 프로토콜을 프로퍼티로 정의
        private let storyPostRepository: StoryPostRepositoryProtocol
        private let keychainRepository: KeychainRepositoryProtocol
    
        // 프로토콜을 클래스의 생성자로 호출
        init(
            storyPostRepository: StoryPostRepositoryProtocol,
            keychainRepository: KeychainRepositoryProtocol
        ) {
            self.storyPostRepository = storyPostRepository
            self.keychainRepository = keychainRepository
        }
    
        // 자세한 비즈니스 로직 구현...
    }

    IV. 꼬리말

    정리

    머리말에 있는 주제표를 다시 한번 살펴 보자 :)

    1. (문제) 의존 관계 문제 발생
    2. (해결책) 의존성 분리
    3. (수단) 의존성 주입(Dependency Injection)
    4. (원칙) 의존성 역전 원칙(Dependency Inversion Principle)