티스토리 뷰

조금 시간이 지났지만, 제가 현재 진행하고 있는 프로젝트에 적용했던 클린 아키텍처에 대해서 소개해드리고자 합니다.

 

고고!

 

클린 아키텍처


우선, 클린 아키텍처란 무엇일까요?

 

클린 아키텍처란 로버트 C. 마틴이 제안한 시스템 아키텍처로, 시스템의 각 요소들을 명확하게 분리하고 유연하게 연결될 수 있도록 디자인하는 아키텍처입니다.

 

클린 아키텍처의 이점은 다음과 같습니다.

- 비즈니스 로직의 재활용성이 높아짐.
- 각각 분리된 계층의 역할과 책임이 명확해짐에 따라 코드 응집도가 높아짐.
- 책임과 구조가 명확히 나눠져서 개발속도가 빨라지고, 테스트에 용이해짐.
- 백엔드가 내려주는 데이터와 프론트엔드가 관심있는 데이터를 분리함으로써 서로의 관심사 분리가 쉬워짐.
- 부수적인 효과로 서버의 잘못된 response 에 대한 대응을 DTO 와 VO 로 나눠서 앱의 안정성을 높일 수 있음.

 

 로버트 C. 마틴은 개발자의 바이블이라고 손꼽히는 '클린 아키텍처' 책에서 이 개념을 소개하였는데요, 처음 읽을 당시에 저는 사실 이해를 거의 못했습니다. 책에서 설명하는 개념들이 추상적이고, 어려워서였습니다.

 

하지만 여러 자료를 참고하고 실제 프로젝트에 적용해봄으로써 이해할 수 있었습니다.

특히 참고가 된 자료는 [Android] 요즘 핫한 Clean Architecture 왜 쓰는 거야? 이 글인데요,

 

여기서 모바일 엔지니어링에서 적용된 클린 아키텍처를 잘 설명하는 그림을 하나를 가져와봤습니다.

 

출처: NHN Cloud

 

보시는바와 같이 프레젠테이션, 도메인, 데이터 세가지 계층으로 나눠져있습니다.

 

그리고 데이터의 흐름이

-  데이터 -> 도메인 -> 프레젠테이션
-  프레젠테이션 -> 도메인 -> 데이터

일관성 있게 구성되어있습니다.

 

이렇게 계층을 나누고, 일관성 있는 데이터 흐름을 만듦으로써 위에 설명드린 이점을 얻을 수 있습니다.

 

그럼 제가 어떻게 클린 아키텍처를 프로젝트에 구성했는지 설명 드리겠습니다.

 

클린 아키텍처 적용

 

방금 설명드린 아키텍처를 프로젝트에 적용한 모습은 다음과 같습니다.

 

 

여기서 계층의 역할은 다음과 같습니다.

  • Presentation: UI 관련 로직 처리
  • Domain: 비즈니스 로직을 처리
  • Data: 데이터를 네트워킹을 통해 가져옴

 

보시는바와 같이 RepositoryProtocol 이 Domain 계층에 위치하도록 만들어 Domain 과 Data 를 의존성 역전 하도록 설계하였습니다. 

이로인해  도메인 계층은 다른 계층을 의존하지않고, 비즈니스 로직을 응집할 수 있습니다. 즉 순수한 Swift 언어로 구성될 수 있습니다. 이는 현재 서비스의 비즈니스 계층을 그대로 사용하고 프레젠테이션 계층을 교체한다면 (ex AppKit, Web) 여러 플랫폼 지원을 쉽게 할 수 있다는 뜻입니다.

 

이러한 비즈니스 로직의 재활용성이 클린 아키텍처의 가장 큰 이점이라고 생각합니다.

 


Respository 와 DataSource 가 나눠진 이유는 프로젝트 내에서 서버와 로컬 데이터 두개를 동시에 사용하는 것을 고려했기 때문입니다. 서버에 데이터 요청 -> 실패 시 로컬에 데이터 요청을 처리하기 위해서 데이터를 가져오는 진입점을 Repository 와 DataSource로 나눴습니다. 이에 대해서는 추후에 자세하게 포스팅 해보겠습니다!


그림에 ModelEntity 두가지가 있는데요, 이는 각각 VO 와 DTO 입니다.

VO 는 서비스에서 사용하는 데이터이고, DTO 는 데이터 소스에서 내려주는 데이터 원형 그대로를 뜻합니다.

때문에 DTO -> VO 로 변환하는 과정이 필요할 수 있습니다. (데이터 소스에서 내려주는 데이터 그대로 사용한다면 변환 과정이 필요없음)

 

하지만 저희 프로젝트는 VO와 DTO의 프로퍼티가 똑같더라도, 서버에서 내려주는 데이터가 불완전하다고 가정하고 모든 DTO 의 프로퍼티를 옵셔널로 설정하였습니다. 행여 잘못된 값이 와도 앱이 터지는 것을 방지하고, 빠른 테스트를 가능하게 하기 위해서요.

 

// Data Layer

import Domain

public struct ChatGPTMessageDTO: Decodable {
    let role: String?
    let message: String?
    
    func toVO() -> ChatGPTMessageVO {
        return ChatGPTMessageVO(
            role: role ?? "Unknown",
            message: message ?? "Unknown"
        )
    }
}

// Domain Layer

public struct ChatGPTMessageVO {
    public let role: String
    public let message: String
    
    public init(role: String, message: String) {
        self.role = role
        self.message = message
    }
}

 

보시는바와 같이 데이터 레이어에서 DTO 구조체의 프로퍼티가 옵셔널로 되어있고, toVO 메소드로 도메인 레이어의 VO 로 변환되면서 Default 값을 Unknown 으로 설정해줍니다.  이로인해 앱의 크래쉬를 방지하고 오류를 빠르게 확인할 수 있습니다.

 

 

그럼 이제 프레젠테이션, 도메인, 데이터 레이어가 코드로 어떻게 구성되어있는지 보여드리겠습니다.

 

프레젠테이션 레이어

 

 

저희 프로젝트는 MVVM 디자인 패턴을 사용하고 있기 떄문에 View 와 ViewModel 두가지로 나눠져 있습니다.

MVVM + Combine 으로 ReactiveProgramming 을 구현하였는데요, 이와 관련한 내용은 다음 포스팅에서 자세히 하도록 하겠습니다!

 

여기서 View는 ViewModel을 의존하고 있습니다, ViewModel 은 Domain의 UseCase를 의존하구요.

 

// Presentation Layer
// View

public struct ChattingView: View {
    
    @StateObject private var viewModel: ChattingViewModel
    
 }

// ViewModel

import Domain

public class ChattingViewModel: BaseViewModel {

	private let useCase: ChattingUseCase

}

 

이때 ViewModel 에서 의존하는 UseCase 는 Protocol 입니다. Domain 에 있는 실제 구현체를 의존하는 것이 아닌, 추상화된 객체를 의존함으로써 안정성을 확보하고 쉽게 테스트 더블을 만들 수 있습니다.

public protocol ChattingUseCase {
    func sendQuestion(sendingQuestionDTO: SendingQuestionDTO) -> AnyPublisher<ChatGPTAnswerVO, Error>
}

public final class DefaultChattingUseCase: ChattingUseCase {
    private let repository: ChatRepository
    
    public init(repository: ChatRepository) {
        self.repository = repository
    }
    
    public func sendQuestion(sendingQuestionDTO: SendingQuestionDTO) -> AnyPublisher<ChatGPTAnswerVO, Error> {
        repository.sendQuestion(sendingQuestionDTO: sendingQuestionDTO)
    }
}

 

예시의 UseCase 에서는 필요한 비즈니스 로직이 없어서 데이터를 전달해주는 역할만 수행하고 있습니다. 하지만 만약 어떠한 비즈니스 로직 처리가 필요하다면 이곳에서 이루어집니다.

 

 

 

도메인 레이어

 

앞서 보여드린 UseCase 코드에 ChatRepository 가 있었는데요

 

 

// Domain Layer

public protocol ChatRepository {
    func sendQuestion(sendingQuestionDTO: SendingQuestionDTO) -> AnyPublisher<ChatGPTAnswerVO, Error>
}

 

이 ChatRepository 는 실제 구현체가 아닌, Protocol 입니다.

그리고 이 Repository Protocol 은 데이터 레이어가 아닌, 도메인 레이어에 있습니다.

 

앞서 클린 아키텍처 적용 부분에서 말씀 드렸듯, 이렇게 도메인 레이어에 Repository Protocol 를 두었기 때문에 의존성 역전이 이루어졌고, 도메인 레이어는 의존성 없이 비즈니스 로직만으로 이루어질 수 있습니다.

 

 

데이터 레이어

데이터 레이어에는 Repository 의 실제 구현체와 DataSource 가 존재합니다.

 

// Data Layer
// Repository 구현체

import Domain

final public class DefaultChatRepository: ChatRepository {
    
    private let dataSource: ChatDataSource
    
    public init(dataSource: ChatDataSource) {
        self.dataSource = dataSource
    }
    
    public func sendQuestion(sendingQuestionDTO: SendingQuestionDTO) -> AnyPublisher<ChatGPTAnswerVO, Error> {
        dataSource.sendQuestion(sendingQuestionDTO: sendingQuestionDTO)
            .map { $0.toVO() }
            .eraseToAnyPublisher()
    }
}

// ChatDataSource protocol 과 구현체
import Combine
import Domain

public protocol ChatDataSource {
    func sendQuestion(sendingQuestionDTO: SendingQuestionDTO) -> AnyPublisher<ChatGPTAnswerDTO, Error>
}

final public class DefaultChatDataSource: ChatDataSource {
    
    public init() {}
    
    private let moyaProvider = MoyaWrapper<ChatAPI>()
    
    public func sendQuestion(sendingQuestionDTO: SendingQuestionDTO) -> AnyPublisher<ChatGPTAnswerDTO, Error> {
        moyaProvider.call(target: .chattingWithChatCPT(sendingQuestionDTO))
    }

}

 

앞서 소개해드린 ViewModel 과 UseCase 에서 처럼 Repository 또한 추상화된 DataSource 를 의존합니다. 얻는 이점은 같습니다.

 

그리고 보시다시피 Repository 에서 DataSource 에서 DTO 를 전달받고 VO 로 전환하고 있습니다. 앞서 말씀 드렸듯, 이 변환 과정을 통해 앱과 백엔드 데이터의 관심사 분리가 이루어지고 안정성을 높일 수 있습니다.

 


앞서 Respository 와 DataSource 가 나눠진 이유가 서버와 로컬 데이터를 동시에 사용하는 것을 고려하였기 때문이라 말씀 드렸는데요, 아쉽게도 아직 작업이 진행되지 않아 코드에는 나와있지 않습니다.

 

하지만 대략적으로 구성을 해보자면 다음과 같이 될 것 입니다.

 

final public class DefaultChatRepository: ChatRepository {
    
    private let serverDataSource: ChatServerDataSource
    private let localDataSource: ChatLocalDataSource
    
    public init(serverDataSource: ChatServerDataSource, localDataSource: ChatLocalDataSource) {
        self.serverDataSource = serverDataSource
        self.localDataSource = localDataSource
    }
    
    public func sendQuestion(sendingQuestionDTO: SendingQuestionDTO) -> AnyPublisher<ChatGPTAnswerVO, Error> {
        serverDataSource.sendQuestion(sendingQuestionDTO: sendingQuestionDTO)
            .catch ({
            	if 서버에서 정상적으로 데이터를 불러올 수 없는 경우, local 데이터 소스 사용
                return localDataSource.sendQuestion(sendingQuestionDTO: sendingQuestionDTO)
                		.map {$0.toVO()}
                        .eraseToAnyPublisher()
            })
            .map { $0.toVO() }
            .eraseToAnyPublisher()
    }
}

 

 

마치며


이렇게 해서 클린 아키텍처의 개념과 이점과 저의 실제 프로젝트에서 적용한 예시를 코드 레벨에서 설명 드렸습니다.

모바일에서의 클린 아키텍처의 개념과 실제 레이어 구성 방법을 이해할때 도움이 되었으면 좋겠습니다!

 

끝~

'iOS-Development' 카테고리의 다른 글

Moya 에러 - 리팩토링  (0) 2023.09.27
Moya 에러 정리  (0) 2023.09.26
Lottie  (0) 2022.05.08
CoreData  (0) 2022.04.24
UserDefaults  (0) 2022.04.19
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/08   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함