본문 바로가기

난 iOS개발자/iOS

클린아키텍처 적용하기-01 개념과 흐름이해

728x90

배경

최근 진행한 iOS프로젝트에 클린아키텍처를 적용했습니다. 이 글은 클린아키텍처에 대해 학습했던 내용을 복습하고, 실전에 써먹으면서 애매모호 했던 부분들을 나름대로 정리한 글입니다. 배우면서 적용한 첫 프로젝트이기 때문에 중간중간 잘못된 정보가 전달될 수 있으니 이상하다~ 싶은점은 댓글로 남겨주길 바랍니다.
 
진행했던 프로젝트는 iOS, Android 두 플랫폼에서 동시에 개발이 진행되었습니다. 모바일 아키텍처는 서로 비슷한 부분이 굉장히 많으므로 각 플랫폼 담당자가 아키텍처를 함께 논하고 비즈니스 로직을 맞춘다면 시너지가 있을거라 생각됐습니다.
 
화면단위로 쪼개어 각 화면에서 필요한 유스케이스를 함께 정의하고 플로우를 맞춘다면 플랫폼이 달라도 담당자끼리 건설적인 대화를 할수 있을거라... 그래서 저와 안드로이드 담당자는 클린아키텍처를 함께 적용하며 맞출수 있는 것들을 맞추려 노력했습니다.
 
이 전에 나는 왜 클린아키텍처를 적용하려 했는지 떠올려봤습니다.
 
여태 저는 MVVM을 사용해왔습니다. MVVM은 ViewModel과 View사이 데이터 바인딩에 중점을 두고 있습니다.
클린아키텍처는 앱 전반에 걸친 구조설계에 관한 내용이며, MVVM에서 채울수 없는 허전함을 채우려 했던것 같습니다.
 
이번 프로젝트를 진행하면서 MVVM구조를 완전히 벗어난것은 아닙니다.
클린아키텍처는 계층구조로 분리된 관심사들이 서로간의 의존성을 줄이는것이 중요합니다. 의존성이 줄어들면서 당연히 유연한 프로그램이 될테고, 이는 MVVM에도 적용할 수 있는 것들이죠. 
 
개발을 하면서 반드시 이래야 한다! 저래야 한다! 하는것은 옳지 못합니다. 아키텍처는 상황에 따라 유연하게 변화할 수 있어야 하며, 실무자들의 협의가 필요한 내용입니다. 다행스럽게도 이 프로젝트는 플랫폼별 담당자가 1인이었고, 내가 하고 싶은 기술은 마음껏 적용해도 되는 상황이었습니다.
 
클린아키텍처의 장점인 재사용성이 높은 코드, 유연성, 테스트 등을 경험해보고 싶은 욕구가 있었고 때마침 좋은 기회가 있었기 때문에 즐거운 마음으로 도입을 시도해봤습니다.
 

클린아키텍처란?

써보니 이런게 좋더라~ 얘기 하기 전에 지식의 동기화를 위해 클린아키텍처에 대한 설명이 필요할 것 같습니다.
 


클린아키텍처의 청사진입니다.
복잡해보이지만 대원칙은 다음과 같습니다.

1. 의존성 규칙: 
    
    의존성은 바깥쪽 계층에서 안쪽 계층으로만 향해야 합니다. 안쪽 계층이 외부 요소에 의존하지 않도록해야 합니다.
    
2. 계층 분리: 
    
    각 계층은 독립적이고 특정 책임을 갖고 있어야 합니다. 또 의존성 규칙을 따라야합니다. 계층분리와 의존성 규칙만으로도 시스템의 유연성이 향상됩니다.
    
3. 의존성 역전 원칙: 
    
    고수준의 모듈이 저수준 모듈에 의존하지 않아야 합니다. 여기엔 추상화 개념이 필요합니다. 아래 설명은 [해당 링크](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)를 번역하고 간추린 내용입니다.


Entity

entity 는 전사적인 비즈니스 규칙을 캡슐화 합니다. 이는 객체일수도 데이터 구조나 함수의 집합일 수도 있습니다. 그 형태는 중요하지 않습니다. 
외부 환경이 바뀌어도 변경될 가능성이 가장 낮습니다. 

Use Cases

UseCase는 애플리케이션 계층이라고도 하는데 해당 앱 규모의 비즈니스 규칙을 포함합니다. 이 레이어의 변경사항은 저수준 정책에 영향을 받지 말아야합니다. 시스템의 모든 사용 사례를 캡슐화하고 구현합니다.
애플리케이션 운영에 대한 변경 사항이 useCase 에 영향을 미칠수 있습니다.

Interface Adapter

UseCase와 Entity 또는 데이터베이스나 웹과 같은 외부 모듈에 가장 편리한 형식으로 데이터를 변환하는 어댑터 집합입니다. 

Framworks and Drivers

가장 바깥쪽 계층은 일반적으로 데이터베이스, 웹 프레임워크 등과 같은 프레임워크와 도구로 구성됩니다.
이 계층은 모든 세부 사항이 이루어지는 곳입니다. 웹은 세부 사항입니다. 데이터베이스도 세부 사항입니다. 우리는 이러한 것들을 거의 해를 끼치지 않는 외부에 보관합니다.

설명이 와닿지 않을 수 있습니다. 여기 참고할만한 자료가 있습니다.


버터플라이 라고 정의한 아키텍처가 있습니다.
클린아키텍처를 iOS에 알맞게 적용한 내용입니다. 용어 자체도 익숙하기 때문에 [링크] 통해 한 번 보시는걸 추천드립니다.
클린아키텍처의 대원칙을 준수하고 있기때문에 이 그림을 참고하여 본문을 읽으셔도 좋습니다.

경계넘기

다이어그램의 오른쪽 하단에는 원 경계를 넘나드는 방법의 예가 나와 있습니다. 제어의 흐름에 주목하세요.  Controller에서 시작하여 UseCase를 통해 이동한 다음 Presenter에서 실행됩니다. 
각 종속성은 사용 사례를 향해 안쪽을 가리킵니다. 

일반적으로 의존성 역전 원칙을 사용하여 이러한 모순을 해결합니다. 예를 들어 Java와 같은 언어에서는 소스 코드 종속성이 경계를 가로지르는 적절한 지점에서 제어 흐름에 반대되도록 인터페이스와 상속 관계를 정렬합니다. 예를 들어 UseCase에서 Presenter 호출해야 한다고 가정해 보겠습니다. 그러나 이 호출은 종속성 규칙을 위반할 수 있으므로 직접 호출해서는 안 됩니다. (외부 원에 있는 이름은 내부 원에서 언급할 수 없습니다.) 따라서 UseCase는 내부 원에서 인터페이스(Swift는 Protocol을 통해서)를 호출하고 외부 원에 있는 Presenter가 Protocol을 채택/구현하도록 합니다. 예시와 그림으로 이해해보겠습니다.
뷰는 사용자 정보를 보여주고 싶습니다. 사용자 정보가 필요하기 때문에 DB로부터 정보를 받습니다.

흐름은 다음과 같습니다.


그러나 여기에 오류가 있습니다. UseCase가 DB 의 구현체를 직접 언급했기 때문이죠.
따라서 UseCase는 내부 Interface를 호출하고 DB가 이를 채택 구현하도록 합니다.


이렇게 하면 UseCase가 외부원의 존재를 언급하지 않고 의존성 규칙을 유지할 수 있습니다.
이 외 모든 경계를 넘나 드는데 동일한 기법을 사용합니다.
 

경계를 넘는 데이터

일반적으로 경계를 넘나드는 데이터는 단순한 데이터 구조입니다. 원하는 경우 기본 구조체나 간단한 데이터 전송 객체를 사용할 수 있습니다. 중요한 것은 분리된 단순한 데이터 구조가 경계를 넘어 전달된다는 것입니다. 우리는  Entity나 데이터베이스 행을 그대로 전달하고 싶지 않습니다. 예를 들어, 데이터베이스 쿼리에 대한 응답으로 데이터 형식을 반환합니다. 이 행 구조를 경계를 넘어 내부로 전달하고 싶지 않습니다.  내부 원이 외부 원에 대해 무언가를 알아야 하므로 종속성 규칙을 위반하게 만들기 때문입니다.

따라서 경계를 넘어 데이터를 전달할 때는 항상 내부 원에 가장 편리한 형태로 전달합니다.

InterfaceAdaptor 에 속하는 Repository 에서 데이터 변환 역할을 하게 됩니다.
코드로 보면 다음과 같습니다.

class UserView {
    private let userUseCase: UserUseCase

    init(userUseCase: UserUseCase) {
        self.userUseCase = userUseCase
    }

    /// 유저 정보를 가져옵니다.
    func fetchData() {
        let user = userUseCase.getUserData()
        displayUserData(user)
    }

    func displayUserData(_ user: User) {
        print("UserID: \(user.id)")
    }
}

protocol UserUseCase {
    func getUserData() -> User
}

class UserUseCaseImpl: UserUseCase {
    private let userRepository: UserRepository
	
    init(userRepository: UserRepository) {
        self.userRepository = userRepository
    }

    func getUserData() -> User {
        return userRepository.getUserData
    }
}

protocol UserRepository {
    func getUserData() -> User
}

class UserRepositoryImpl: UserRepository {
    private let dataSource: UserDataSource 
	
    init(dataSource: UserDataSource) {
        self.dataSource = dataSource
    }

    func getUserData() -> User {
        let dto = dataSource.getUserData()
        return UserMapper.mapToEntity(from: dto)
    }
}

enum UserMapper {
    static func mapToEntity(from dto: UserDTO) -> User {
    	return User(id: dto.id, username: dto.username, email: "")
    }   
}

protocol UserDataSource {
	func getUserData() -> UserDTO
}

 
 


구현하기

아래 설명할 코드는 프로젝트의 코드와 완벽히 일치하지 않지만, 매우 비슷한 형태를 갖고 있습니다. 
스플래시화면 구현을 함께 차근차근따라가며 흐름을 익혀보겠습니다.
스플래시에서 버전정보를 표시해야한다고 가정합시다. 화면 진입과 동시에 ViewModel를 호출하여 버전을 가져오도록 합니다.  ViewModel은 View의 onAppear()에서 호출하도록 하고 있습니다. 

final class SplashViewModel {
    @Published var version: String
    func onAppear() {
        showVersion(...)
    }

    func showVersion(_ version: String) {
        self.version = version
    }
}


첫 구조는 위와 같은 형태일 것입니다.

앱의 버전정보를 가져온다는 첫번째 요구사항을 위해 UseCase 를 생성해야 합니다.

GetInstalledVersionUseCase 라는 이름으로 UseCase를 만들어보겠습니다.  GetInstalledVersionUseCase는 추상화된 개념입니다.
버전정보를 필요로하는 ViewModel은 버전정보가 어디서 오는지 알 필요는 없습니다. 그러므로 GetInstalledVersionUseCase는 Protocol로 정의합니다.  

일반적으로 UseCase는 실행 외 다른 메서드를 필요로 하지 않습니다.
단일행위를 나타내는 이름만으로도 다른 메서드가 필요하지 않도록 강제하는 느낌이 강하기 때문입니다.
기능의 단일 책임을 위해 다른 메서드는 추가하지 않는것을 권장합니다. 이 실행메서드의 이름은 흔히들 execute 또는 invoke을 사용합니다. 저는 execute를 사용했습니다. 

Use Case

이렇게 GetInstalledVersionUseCase를 정의했습니다.

protocol GetInstalledVersionUseCase {
    func execute() -> String
}


이제 ViewModel은 위 프로토콜을 채택한 구현체를 이용해 버전정보를 가져올수 있게 되었습니다.

final class SplashViewModel {
    @Published var version: String
    private let getInstalledVersion: GetInstalledVersionUseCase

    func onAppear() {
        let version = getInstalledVersion.execute()
        showVersion(version)
    }

    func showVersion(_ version: String) {
        self.version = version
    }
}


요구사항을 만족하는 SplashViewModel의 구현이 끝났습니다.
실제로 GetInstalledVersionUseCase를 구현한 구현체가 없음에도 SplashViewModel 자체로 기능이 완성되었습니다.

주어진 UseCase를 활용하여 맡은 요구사항의 구현을 끝냈기 때문에 GetInstalledVersionUseCase의 상세구현은 뷰모델이 신경쓰지 않아도 될 일입니다.  SplashViewModel 은 이 자체로 사용 가능하지만, UseCase 의 구현체가 없기 때문에 완벽한 프로그램은 아닙니다. 계속해서 UseCase 의 구현을 해보겠습니다.

final class GetInstalledVersionUseCaseImpl: GetInstalledVersionUseCase {
    func execute() -> String {
        return //
    }
}


필수 구현 메서드는 프로토콜을 준수하기 위한 실행메서드 하나뿐입니다.
만약 내부적으로 필요한 기능이 더 있다면 적절히 추가해도 무방합니다. 다만 목적을 잃어버리지 않는지, 과도한 책임을 준것은 아닌지 주의하세요.
또 UseCase는 도메인 영역에 포함되기 때문에  도메인 원 밖의 무언가를 포함하지 않도록 주의합시다.

Repository

Repository는 Interface Adaptor의 속합니다.
DataSource 의 구체적인 구현을 추상화하고 이를 비즈니스 로직에게 제공합니다. 또 애플리케이션에서 사용할 도메인 모델로 데이터를 변환하는 역할을 맡고 있습니다. 코드를 작성해보겠습니다.

final class VersionRepository {
    func getInstalledVersion() -> String {
        "1.0.0"
    }
}

final class GetInstalledVersionUseCaseImpl: GetInstalledVersionUseCase {
    private let repository: VersionRepository
    init(repository: VersionRepository) {
        self.repository = repository
    }

    func execute() -> String {
        repository.getInstalledVersion()
    }
}


VersionRepository 는 동작에는 이상이 없지만 경계넘기에서 언급했던 오류가 있습니다. GetInstalledVersionUseCase에서 버전 정보를 가져오기 위해 더 외부계층에 위치한  VersionRepository를 참조하려합니다. 이는 의존성 규칙을 위배합니다.
설계 의존성 방향을 헤치고 있기 때문에 다음과 같이 개선합니다.

protocol VersionRepository {
    func getInstalledVersion()
}

final class VersionRepositoryImpl: VersionRepository {
    func getInstalledVersion() -> String {
        "1.0.0"
    }
}

final class GetInstalledVersionUseCaseImpl: GetInstalledVersionUseCase {
    private let repository: VersionRepository
    init(repository: VersionRepository) {
        self.repository = repository
    }

    func execute() -> String {
        repository.getInstalledVersion()
    }
}


의존성 역전 원칙을 사용하여 저수준 모듈과의 의존성을 분리했습니다.

아직 끝이 아닙니다. VersionRespository 는 더 외부 원에 위치한 Device로부터 정보를 가져와야합니다. Datasource가 필요합니다.

DataSource

protocol InstalledVersionDataSource {
    func getVersion() -> String
}

final class InstalledVersionDataSourceImpl: InstalledVersionDataSource {
    func getVersion() -> String {
        "1.0.0" // from info.plist
    }
}

 
경계를 넘기위한 기법은 여전합니다. Protocol 을 사용합니다.
DataSource는 데이터를 생성하거나 검색하는 역할을 하며, 데이터를 저장하고 검색할 수 있는 방법을 제공합니다.  Datasource는 계층상 가장 바깥에 위치합니다. 
InstalledVersionDataSourceImpl는 infoPlist에서 버전정보를 가져오는것으로 가정했습니다.

마지막 객체까지 주입을 받은 Repository의 구현은 아래와 같습니다.

final class VersionRepositoryImpl: VersionRepository {
    private let dataSource: InstalledVersionDataSource
    init(dataSource: InstalledVersionDataSource) {
        self.dataSource = dataSource
    }

    func getInstalledVersion() -> String {
        datasource.getVersion()
    }
}

 
이로써 스플래시화면에서 버전정보를 표시해야한다는 요구사항 구현이 끝났습니다.
스플래시 화면의 버전정보 표시 과정 다이어그램을 클린아키텍처의 계층으로 나누면 다음과 같습니다.

 
지금까지 ViewModel에서 시작해서 계층 반대편의 Frameworks & Drivers까지 도달하는 일련의 흐름을 코드로 구현해봤습니다.
이어지는 다음 포스팅에선 클린아키텍처를 적용한 후, 어떻게 테스트 코드를 작성할 수 있는지 알아보겠습니다. 

728x90