Neoself의 기술 블로그

Swift 프로토콜 기반 아키텍처로 리팩토링하기 본문

개발지식 정리/Swift

Swift 프로토콜 기반 아키텍처로 리팩토링하기

Neoself 2024. 10. 31. 21:30

Protocol은 뭔가요?

프로토콜(Protocol)은 특정 역할을 수행하기 위한 메서드, 프로퍼티, 기타 요구사항 등의 규약을 정의합니다. 쉽게 말해 "이런 기능이 있어야 해"라는 약속입니다. 

Typescript에서의 interface 프로퍼티를 사용했던 적이 있어서인지, 사전적인 의미보다 인터페이스의 역할을 한다는 설명이 제게는 좀 이해하기 쉬웠던 기억이 납니다. 프로토콜을 채택하고자 하는 클래스에서는 실제로 돌아가는 로직 그 자체를 구현하는 반면, 프로토콜은 각 기능이 어떤 인자를 받고, 어떤 것을 반환하는 지의 타입 정보만 명시하고, 나머지들은 다 비워놓습니다.

이렇게 프로토콜을 통해 구축한 핵심 틀만을 갖고, ViewModel과 같은 클래스에 상속시키는 것을 추상화한다고 합니다.

 

그래서 추상화가 왜 필요한거죠?

솔직히, 프로토콜의 역할을 처음 들었을 때에는 마땅히 사용해야하는 이유를 찾을 수 없었습니다. 사이 중간다리역할을 하는 매개체가 하나 더 생성되는 만큼 오히려 코드량이 늘어나게 되고, 클래스, 구조체와 같은 모듈 간에 직접적으로 의존하는 식으로 코드를 짜는 것이 가독성이나 유지보수 측면에서 더 유리할 것이라 생각했기 때문이죠.

기존 제 코드 또한 직접 모듈 간에 의존하는 구조로 ViewModel과 Service 레이어를 연결하고 있었습니다.

즉 고수준 모듈인 ViewModel 클래스에서 저수준 모듈인 TodoService 인스턴스를 직접 생성해 사용하고 있었습니다.

즉 직접적으로 의존하는 상태였습니다.

class TodoService {
    static let shared = TodoService()
    
    func fetchTodos() {
        // 네트워크 요청 직접 구현
        AF.request(...)
    }
}

class TodoViewModel {
    let todoService = TodoService.shared // 직접 참조
    
    func fetchTodos() {
        todoService.fetchTodos()
    }
}

 

여기서 추상화를 거치게 되면 어떻게 될까요?

protocol TodoServiceProtocol {
    func fetchTodos() -> AnyPublisher<[Todo], APIError>
}

class TodoService: TodoServiceProtocol {
    private let networkService: NetworkServiceProtocol
    
    init(networkService: NetworkServiceProtocol) {
        self.networkService = networkService
    }
    
    func fetchTodos() -> AnyPublisher<[Todo], APIError> {
        return networkService.request(.fetchTodos)
    }
}

class TodoViewModel {
    private let todoService: TodoServiceProtocol
    
    init(todoService: TodoServiceProtocol = TodoService()) {
        self.todoService = todoService
    }
}

기존에 구현했던 TodoService 클래스는 유지하되, 클래스 내부에 구현되어야 하는 핵심 메서드들의 타입을 TodoServiceProtocol이라는 프로토콜에 따로 명시하였습니다. 그 이후 TodoService 내부의 메서드를 사용하는 ViewModel 클래스에는 TodoService 타입의 속성 대신 TodoServiceProtocol 타입의 속성을 명시하였으며, 초기화 시에 TodoService를 주입시켰습니다.

 

현재까지의 리팩토링 과정을 보면, 추상화가 가져다 주는 이점이 크게 와닿지 않습니다. 결국 고수준 모듈에서 주입된 모듈이 TodoService임은 변함이 없기 때문입니다. 하지만, 여기서 상황에 따라 TodoService에 약간의 변형을 거친 모듈로 교체를 하고 싶으면 어떻게 될까요?

protocol TodoServiceProtocol {
    func createTodo(text: String) -> AnyPublisher<[Todo], APIError>
}

class HappyTodoService: TodoServiceProtocol {
    private let networkService: NetworkServiceProtocol
    
    init(networkService: NetworkServiceProtocol) {
        self.networkService = networkService
    }
    
    func createTodo(text: String) -> AnyPublisher<[Todo], APIError> {
        return networkService.request(
            .createTodo,
            method: .post,
            parameters: ["text": "\(text) :)"] // 기입한 텍스트 뒤에 :) 항상 추가
        )
    }
}

class SadTodoService: TodoServiceProtocol {
    private let networkService: NetworkServiceProtocol
    
    init(networkService: NetworkServiceProtocol) {
        self.networkService = networkService
    }
    
    func createTodo(text: String) -> AnyPublisher<[Todo], APIError> {
        return networkService.request(
            .createTodo,
            method: .post,
            parameters: ["text": "\(text) :("] // 기입한 텍스트 뒤에 :( 항상 추가
        )
    }
}

여기에 같은 TodoServiceProtocol을 채택한 서로 다른 두개의 TodoService 모듈이 존재합니다. 두 모듈 둘다 프로토콜에 명시된 createTodo 메서드를 구현해놓았지만, HappyTodoService는 뒤에 :)를 고정적으로 붙이고, SadTodoService는 :(를 고정적으로 붙여줍니다.

 

여기서 TodoViewModel이 기존과 같이 SadTodoService나 HappyTodoService 중 하나를 직접 의존하는 구조라면, 클래스명이 다른 아예 별개의 인스턴스이기 때문에 초기화 단계에서 두개의 Service 레이어를 오고갈 수 없을 것입니다.

class TodoViewModel {
    private let todoService: HappyTodoService
    
    init(isSad: Bool = true) {
       if isSad {
           self.todoService = SadTodoService() // 속성에 명시한 클래스가 아니기에 에러
       }
    }
    ...
}

하지만, TodoServiceProtocol은 두개의 TodoService가 모두 채택하고 있기 때문에, 초기화 시에 상황에 따라 유동적으로 변경이 가능합니다.

class TodoViewModel {
    private let todoService: TodoServiceProtocol // 프로토콜로 타입 명시
    
    init(isSad: Bool = true) {
       if isSad {
           self.todoService = SadTodoService() // 에러없이 주입됨
       }
    }
    ...
}

 

 

아무리 저수준 모듈인 TodoService에서의 로직이 변경되어도 TodoServiceProtocol을 충족하고 있는 한, 이를 사용하는 상위 레이어는 TodoService라는 모듈이 아닌 프로토콜에 의존하고 있기 때문에 변경사항에 대해 알 필요가 없으며, 이에 맞춰 동작이 변경될 필요가 없습니다. 이로써, 각 모듈간의 결합도가 낮아지는, 즉 의존성 역전 원칙(DIP)이 준수되는 것이죠.

 

이처럼 프로토콜을 바탕으로 하는 추상화의 유연성을 잘 사용하면, 프리미엄/일반 사용자 구분, 실시간/일반 업데이트 모드 전환, A/B 테스트 실행과 같은 환경에서 유동적으로 실행로직을 변경할 수 있게 됩니다.

 

이처럼 코드의 유연성이 향상된다는 이점 외에도, 프로토콜 기반 아키텍처는 아래와 같은 장점을 제공합니다.
  1. 테스트 용이성 확보
  2. SOLID 원칙 준수 용이
위 두개의 이점은 제가 아직 직접 시도해보지 않아 아직 잘 이해가 되질 않으니, 나중에 기회가 되면 자세히 다뤄보도록 하겠습니다.

 

 

감사합니다.