Starbucks Caramel Frappuccino
본문 바로가기
  • 그래 그렇게 조금씩
SwiftUI/SwiftUI(Advanced)

13. Dependency Injection vs Singleton / 의존성 주입

by Toughie 2023. 6. 27.

🦁Dependency Injection vs Singleton🦁

지난 시간에 공부한 프로토콜의 개념을 활용해서 의존성 주입을 연습해보자.

의존성 주입을 하는 이유는 코드의 유연성(확장 용이), 테스트 용이성'에 있다고 볼 수 있다.

아래는 이전에 공부했던 참고내용

https://toughie-ios.tistory.com/242

 

의존성 주입 (DI, Dependency Injection)

한참 전에 의존성 주입에 대해 공부하며 포스팅을 했었는데.. 오늘 다시 공부하면서 훨씬 많이 이해한 거 같아서 다시 정리하기 위함. 의존성 주입. DI라고 하는데.. 말이 참 어렵다. 의존성? (Depen

toughie-ios.tistory.com


먼저 간단하게 JSON 파싱을 통해 데이터를 받아오는 상황을 만들어보자.

여기서 먼저 2가지 요소를 고려해본다. (싱글톤? / 생성자)

 

보통 데이터 통신을 위한 객체는 싱글톤 패턴을 자주 사용한다.

하지만 싱글톤 패턴은 사용하기 편리하지만 몇몇 단점들도 존재한다.

그리고 그 단점들을 상쇄하기 위한 구조로 작성해본다.

 

<싱글톤의 단점>

Problem1

싱글톤은 static으로 인스턴스를 생성하기 때문에, 즉 글로벌 객체이기 때문에 어디서든 접근이 가능하다.

이것이 장점으로 다가올 수도 있지만, 만약 여러 객체에서 동시에 이 싱글톤 객체에 접근하면 race condition 즉 경쟁상태의 문제가 발생할 수 있다. ex. 싱글톤 객체의 프로퍼티에 다른 여러객체가 동시에 쓰기(set) 하려는 경우.. 

즉 thread safe 하지 않다는 문제가 있다.

또한 여러 객체가 싱글톤 객체에 접근하는 경우 싱글톤 객체에 의존성이 너무 높다는 문제도 있다.

Solution

모든 객체가 접근 가능하게 하는 것이 아니라, 생성자를 통해 필요한 객체들에만 접근성을 부여한다.(의존성을 주입하자)

 

Problem2

생성자를 커스텀하지 못한다.

싱글톤 패턴에서는 private init() { } 와 같은 형태로 보통 생성자를 막아두는데,

생성자를 커스텀 하는 것의 의의는 생성하는 단계에서 원하는 데이터를 넘겨줄 수 있다는 것이다.

다만 private 접근제어 이기 때문에 이것이 불가능하고, 결국 내부에서 초기화 할 때 결정되어 버린다는 것이다.

즉 초기화 단계에서 전부 결정해야 한다는 말. 

Solution

1번의 솔루션, 즉 의존성 주입을 활용해서 싱글톤 패턴을 사용하지 않으며 생성자를 마음대로 커스텀 하자.

 

싱글톤 객체의 단점을 고려, 생성자의 커스텀 가능성을 열어두고

json파싱을 담당하는 클래스를 만든다면 아래와 같다. 
(지난 시간 데이터 다운로드 연습에서 사용했던 사이트에서 url을 받아왔다.)

struct PostModel: Codable, Identifiable {
    let userId: Int
    let id: Int
    let title: String
    let body: String
}

//https://jsonplaceholder.typicode.com/posts
final class ProductionDataService {
    // 만약 싱글톤으로 구현한다면?
    //    static let shared = ProductionDataService() //singleton
    //    private init() { }
    
    //URL은 계속 바뀔 수도 있기 때문에 생성자로부터 받아오도록.
    let url: URL
    
    //싱글톤 패턴이 아니기 때문에 생성자 커스텀이 가능하다.
    init(url: URL) {
        self.url = url
    }
    
    func getData() -> AnyPublisher<[PostModel], Error> {
        //        guard let url = URL(string: "https://jsonplaceholder.typicode.com/posts") else { return Fail(error: URLError(.badURL)).eraseToAnyPublisher() }
        
        //네트워크 요청을 수행하는 Publisher 생성
        return URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: [PostModel].self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
        //Publisher 타입을 AnyPublisher로 변환하는 역할
        //최종 결과로 어떤 구체적인 Publisher 탕비이 반환되는지에 대한 세부 사항을 숨길 수 있음.
        //구체적인 Publisher 타입에 의존하지 않고, AnyPublisher를 사용 -> 구체적인 타입의 세부 사항을 감추고
        // AnyPublisher로 변환해서 반환하는 역할 (코드의 유연성과 추상화 수준을 높일 수 있음)
            .eraseToAnyPublisher()
    }
}

getData 메서드를 자세히 살펴보자.

[1]  URLSession.shared.dataTaskPublisher(for: url)을 사용해서 

url에 대한 테이터 작업을 수행하는 Publisher를 생성. 

 

[2] .map을 통해서 Publisher의 output에서 data만 추출.

 

[3] 해당 url의 형태와 맞게 모델을 만들었기 때문에, [PostModel]로 디코딩을 한다.

 

[4] - 해당 데이터는 뷰 렌더링과 관련이 있기 때문에 메인 큐에서 처리하도록 한다.

 

[5] - .eraseToAnyPublisher()를 활용해서 AnyPublisher<[PostModel], Error> 타입으로 변환한다.

-> 필수적인 코드는 아니지만 이를 사용하지 않는다면, 실제 Publisher 타입인 URLSession.DataTaskPublisher를 그대로 반환하게 될 것이고, getData()를 호출하는 코드, 즉 클라이언트 코드에서는 URLSession.DataTaskPublisher 타입에 의존해야 한다.

-> 이는 추상화 수준이 낮고, 코드의 유연성이 떨어질 수 잇다.

따라서 .eraseToAnyPublisher()를 활용하면 반환되는 Publisher의 구체적인 타입 세부 사항을 감추고

AnyPublisher 타입으로 다루도록 코드의 유연성과 추상화 수준을 높일 수 있다.


이제 데이터를 받아오는 클래스를 참조하는 뷰모델을 만들어보자.

import SwiftUI
import Combine

final class DependencyInjectionViewModel: ObservableObject {
    
    @Published var dataArray: [PostModel] = []
    var cancellables =  Set<AnyCancellable>()
    
    let dataService: ProductionDataService
    
    init(dataService: ProductionDataService) {
        self.dataService = dataService
        loadPosts()
    }
    
    private func loadPosts() {
        dataService.getData()
            .sink(receiveCompletion: { _ in
               
            }, receiveValue: { [weak self] returnedPosts in
                self?.dataArray = returnedPosts
            })
            .store(in: &cancellables)
    }
}

먼저 받아온 데이터를 처리하는 loadPost() 메서드에 대해 알아보자.

[1] dataService.getData()를 호출해서 데이터를 받아온다.

이 메서드는 네트워크 요청을 수행하고, AnyPublisher<[PostModel], Error> 타입을 반환한다.

[2] .sink를 사용해서 데이터 수신 및 완료를 처리하는 클로저를 전달한다. 

receiveCompletion -> 데이터 요청이 완료되었을 때 호출됨.

receiveValue -> 데이터를 수신할 때 호출됨. 강한 순환 참조 방지를 위해 [weak self] 캡처리스트 활용.

데이터를 dataArray에 할당함.

[3] .store를 활용해서 sink의 구독을 관리함. 구독을 취소할 수 있도록 Set<AnyCancellable>인 cancellables에 sink를 저장.

 

데이터를 받아오는 클래스를 활용할 때

let dataService: ProductionDataService = ProductionDataService() 이렇게 할 수도 있지만,

이러면 뷰모델이 ProductionDataService에 너무 의존적이게 된다.

그래서 의존성을 덜어내기 위해 생성자를 통해 객체를 주입하지만  좀 부족하다.

-> 프로토콜을 활용해서 의존성 주입을 해주자.

 

데이터를 다운 받는 클래스에서 꼭 필요한 메서드는 getData()이다. 이를 요구사항으로 정의하자.

protocol DataServiceProtocol {
    func getData() -> AnyPublisher<[PostModel], Error>
}

프로토콜을 적용하면 아래와 같다.

final class ProductionDataService: DataServiceProtocol {

    let url: URL
    
    init(url: URL) {
        self.url = url
    }
    
    func getData() -> AnyPublisher<[PostModel], Error> {
        return URLSession.shared.dataTaskPublisher(for: url)
            .map { $0.data }
            .decode(type: [PostModel].self, decoder: JSONDecoder())
            .receive(on: DispatchQueue.main)
            .eraseToAnyPublisher()
    }
}

뷰모델도 아래와 같이 변경할 수 있다.

final class DependencyInjectionViewModel: ObservableObject {
    
    @Published var dataArray: [PostModel] = []
    var cancellables =  Set<AnyCancellable>()
    
    let dataService: DataServiceProtocol
    
    init(dataService: DataServiceProtocol) {
        self.dataService = dataService
        loadPosts()
    }
    
    private func loadPosts() {
        dataService.getData()
            .sink(receiveCompletion: { completion in
                print("hi")
            }, receiveValue: { [weak self] returnedPosts in
                self?.dataArray = returnedPosts
            })
            .store(in: &cancellables)
    }
}

이제 뷰모델이 ProductionDataService 클래스에 강하게 의존하는 것이 아니라,

DataServiceProtocol로 의존성 역전이 일어났다.

즉 dataService에는 DataServiceProtocol을 준수하는 타입은 뭐든 할당 가능하다는 것

-> 테스트를 위한 다른 데이터 서비스 클래스도 만들 수 있겠네?

class MockDataService: DataServiceProtocol {
    
    let testData: [PostModel]
    
    init(data: [PostModel]?) {
        self.testData = data ?? [
            PostModel(userId: 1, id: 1, title: "one", body: "one"),
            PostModel(userId: 2, id: 2, title: "two", body: "two")
        ]
    }
    
    func getData() -> AnyPublisher<[PostModel], Error> {
        Just(testData)
            .tryMap({ $0 })
            .eraseToAnyPublisher()
    }
}

임의의 테스트 데이터를 넣을 수 있도록 생성자 부분에서 파라미터의 타입이 [PostModel]?이다.

직접 생성하는 부분에서 데이터를 넣어도 되고, 안 넣으면 닐코얼레싱을 통해 기본 데이터가 들어간다.

getData() 부분을 보면..

[1] Just

testData 배열을 단일 값으로 포장한 Just Publisher를 생성한다.

Just는 한 번만 값을 방출하고 완료되는 Publisher이다.

 

[2] DataServiceProtocol의 요구사항인 getData()의 반환타입은 AnyPublsiher<[PostModel], Error>이다.

근데 여기서는 에러가 발생하지 않는다.. 그래서 trymap을 통해서 타입을 맞춰 매핑해준다.

여기서는 testData를 변환 없이 그대로 반환하고 있다.

 

[3]그리고 기존과 같이 .eraseToAnyPublisher()를 사용해서 AnyPublisher<[PostModel], Error>타입으로 변환한다.


이렇게 데이터를 받아오는 클래스, 테스트용 클래스가 준비되었다.

이 클래스들은 프로토콜을 통해 뷰모델에 주입된다.

다운받은 데이터를 간단하게 뷰에서 확인한다면 아래와 같다.

struct DependencyInjectionPrac: View {
    
    @StateObject private var vm: DependencyInjectionViewModel
    
    init(dataService: DataServiceProtocol) {
        _vm = StateObject(wrappedValue: DependencyInjectionViewModel(dataService: dataService))
    }
    
    var body: some View {
        ScrollView {
            VStack {
                ForEach(vm.dataArray) { post in
                    Text(post.title)
                }
            }
        }
    }
}

뷰모델을 초기화 할 때 뷰모델 생성자를 통해 dataService를 초기화 시켜줘야 한다.

따라서 뷰의 생성자에서 DataServiceProtocol 타입의 파라미터(dataService)를 받는다.

dataService를 가지고 DependencyInjectionViewModel 인스턴스를 생성한다.

이 인스턴스를 StateObject로 감싸서 vm에 할당한다.

여기서 뷰모델을 @StateObejct 프로퍼티 래퍼를 사용해서 선언했기 때문에

뷰모델의 라이프사이클이 뷰의 라이프사이클과 연결되어 자동으로 치기화,해제가 된다. 

즉 뷰의 수명 동안 뷰모델 객체의 상태가 일관되게 유지되며, 뷰모델의 변경 사항이 뷰에 적절하게 반영된다.


이를 프리뷰에서 확인한다면 아래와 같다.

URL이 옵셔널을 반환하기 때문에 if let 바인딩을 해줬다. 

previews 코드에서 다른 테스트 객체를 넣고 싶다면 dataService2, dataService3를 넣을 수 있다.

struct DependencyInjectionPrac_Previews: PreviewProvider {
    //URL 변경이 필요햔 경우 등, 테스트 가능성이 좋아짐.
    
//    static let dataService = ProductionDataService(url: URL(string: "https://jsonplaceholder.typicode.com/posts")!)
    
    static let dataService: ProductionDataService? = {
        if let url = URL(string: "https://jsonplaceholder.typicode.com/posts") {
            return ProductionDataService(url: url)
        }
        return nil
    }()
    
    static let dataService2 = MockDataService(data: nil)

    static let dataService3 = MockDataService(data: [
        PostModel(userId: 7, id: 7, title: "test", body: "test")

    ])
    
    static var previews: some View {
        //injected
        if let unwrappedDataService  = dataService {
            DependencyInjectionPrac(dataService: unwrappedDataService)
        } else {
            AnyView(EmptyView())
        }
    }
}

*참고*

지금까지는 프로토콜을 통한 의존성 주입 객체가 하나였지만, 실제 앱에서는 다양한 객체에 대해 의존성 주입이 필요할 수 있다.

그러면 생성자의 파라미터가 엄청나게 늘어날 수도 있다.. 

그래서 그런 경우에는 아래와 같이 의존성들을 클래스로 모아서 관리할 수도 있다.

class Dependencies {
    let dataService: DataServiceProtocol
    let dataService2: DataServiceProtocol

    init(dataSerivce: DataServiceProtocol, dataService2: DataServiceProtocol) {
        self.dataService = ProductionDataService(url: URL)
        self.dataService2 = MockDataService(data: [PostModel]?)
    }
}