클린 아키텍쳐

Dophi·2023년 7월 25일

개발 기술

목록 보기
1/12

소개글

요즘 소마에서 프로젝트를 해보며 개발 기술들에 대해 배우고 적용해보고 있습니다. 이러한 기술들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!

클린 아키텍쳐란

클린 아키텍쳐를 검색하면 보통 원형태의 모형이 많이 나오는데, 저는 앱개발에 적용한 것이기 때문에 아래의 모형과 같이 설계를 하고 구현을 했습니다.

클린 아키텍쳐를 제 스스로 간단하게 정리를 하자면 이렇습니다.

개념

  • 코드를 여러 계층으로 나눠서 책임을 분리하는 것
  • 계층간의 의존성 관계를 정의하는 것

쓰는 이유

  • 계층마다 책임이 구분되어있어서 버티컬 피쳐들을 어디에 추가해야하는지 빠르게 파악 가능하고 안정적으로 구현 가능
  • 계층간의 의존성을 낮춰서 스펙의 변화에 따른 코드의 변화를 최소화할 수 있음

특징

  • 계층마다, 그리고 그 계층에서 쓰이는 데이터마다 명확한 역할과 책임을 가지고 있음

Data 영역

  • DTO - 데이터 (계층 간 전달 용도)
  • Datasource - 서버와의 통신 등으로 직접적으로 DTO 형식으로 데이터를 가져옴 or 업로드함
  • Repository - DTO를 VO로 변환 or VO를 DTO로 변환

Domain 영역

  • VO - 데이터 (실제로 활용하기 위한 용도)
  • Usecase - VO를 활용하여 비즈니스 로직을 구현

Presentation 영역

  • View - 실제 화면을 보여주고 사용자와 상호작용
  • Presenter - 내려온 데이터를 어떻게 화면에 보여줄 것인지 결정 or 상호작용 이벤트를 어떻게 위쪽에 전달할건인지 결정

코드

클린아키텍처에 대해 좀 더 공부를 하고 돌아왔습니다!

현재 예시는 모두 1대1 매칭으로 하지만, 추가적으로 공부하면서 느낀 바로는 아래 관계가 맞다고 생각합니다.

View : ViewModel = 1 : N
ViewModel : Usecase = 1 : N
Usecase : Repository = 1 : N
Respository : Datasource = 1 : 1

물론 프로젝트 상황에 맞춰서 충분히 달라질 수 있고 위의 관계가 무조건 정답은 아닙니다!

사실 개념으로만 봐서는 전혀 이해가 안될 수도 있습니다. 그래서 직접 구현해본 코드와 함께 설명드리고자 합니다. 참고로 개발환경은 아래와 같습니다.

클린아키텍쳐, MVVM, Swinject, SwiftUI, Combine

Data 영역

Data영역은 Domain을 import 합니다. 즉 Data 영역은 Domain 영역에 대한 의존성이 있다고 할 수 있습니다.

DTO

DTO는 위에서 말했듯이 계층 간의 전달 용으로 쓰이는 데이터 형식입니다. 쉽게 말하자면, 여기서는 서버가 내려주는 JSON 파일을 그대로 디코딩할 수 있는 형태라고 보면 될 것 같습니다.

특이한 점은 모든 변수값이 옵셔널로 되어있는데, 서버가 혹시라도 실수를 해서 null 값을 내려주더라도 앱이 터지지 않도록 하기 위해 옵셔널로 지정한 것입니다. 다만 저같은 경우는 null이여도 기본값을 줄 수 있는, 치명적인 값은 아니라서 모두 옵셔널로 했지만, 만약 중요한 값이라면 옵셔널로 지정하면 안된다고 생각합니다.

toVO라는 함수도 있는데, 말그대로 VO 객체로 바꾸는 역할을 합니다. VO가 무엇인지는 좀이따가 다시 설명하겠습니다.

import Domain

// Data 영역 - DTO
public struct ProblemDetailDTO: Decodable {
    public let problemId: Int?
    public let problemQuestion: String?
    public let problemAnswer: String?
    public let problemKeyword: String?
    public let favorite: Bool?
    public let faqs: [ProblemFAQDTO]?
    
    func toVO() -> ProblemDetailVO {
        let faqVO = faqs?.map({ $0.toVO() })
        return ProblemDetailVO(problemId: 0, problemQuestion: problemQuestion ?? "Unknown", problemAnswer: problemAnswer ?? "Unknown", problemKeyword: problemKeyword ?? "Unknown", favorite: ProblemFavoriteStatus(isFavorite: favorite), faqs: faqVO)
    }
}

public struct ProblemFAQDTO: Decodable {
    public let faqQuestion: String?
    public let faqAnswer: String?
    
    func toVO() -> ProblemFAQVO {
        return ProblemFAQVO(faqQuestion: faqQuestion ?? "Unknown", faqAnswer: faqAnswer ?? "Unknown")
    }
}

DataSource

DataSource는 서버와의 통신으로 DTO를 가져오거나 보내는 곳으로서, 오직 데이터를 주고받는 역할만 합니다. 실제로 함수라고는 Moya를 사용해서 API 통신을 하고 response는 Combine의 Publisher로 받아오는 것뿐입니다.

또한, 프로토콜과 실제 구현체가 각각 존재하는데, 이렇게 함으로써 DIP (의존성 역전 법칙) 을 만족할 수 있습니다. DIP에 대해서는 여기서 따로 설명을 하지는 않겠습니다.


import Combine
import Domain

// Data 영역 - DataSource 프로토콜
public protocol ProblemDetailDataSource {
    func getProblemDetail(id: Int) -> AnyPublisher<ProblemDetailDTO, Error>
}

// Data 영역 - DataSource
final public class DefaultProblemDetailDataSource: ProblemDetailDataSource {
    public init() {}
    
    private let moyaProvider = MoyaWrapper<ProblemAPI>()
    
    // Moya를 이용한 API 통신
    public func getProblemDetail(id: Int) -> AnyPublisher<ProblemDetailDTO, Error> {
        moyaProvider.call(target: .problemDetail(id: id))
    }
}

Repository

Repository는 DTO를 VO로 변환해서 UseCase에 넘겨주는 역할을 합니다. (response를 내려받을 때는 이렇고, request를 할 때는 반대로 VO를 DTO로 변환해줘야 합니다) 함수를 봐보면 실제로 앞서 정의했던 toVO를 통해 DTO 객체를 VO 객체로 바꾸는 것을 확인할 수 있습니다.

Repository도 마찬가지로 프로토콜과 실제 구현체로 나뉘어지면서 DIP를 만족하는데, 특이한 점은 구현체는 Data영역에, 프로토콜은 Domain 영역에 있습니다. 이 이유에 대해서는 밑에서 설명을 드리겠습니다.

import Combine
import Domain

// Data 영역 - Repository
final public class DefaultProblemDetailRepository: ProblemDetailRepository {
    
    private let dataSource: ProblemDetailDataSource
    
    public init(dataSource: ProblemDetailDataSource) {
        self.dataSource = dataSource
    }
    
    // VO로 변환해서 내려주기
    public func getProblemDetail(id: Int) -> AnyPublisher<ProblemDetailVO, Error> {
        dataSource.getProblemDetail(id: id)
            .map { $0.toVO() }
            .eraseToAnyPublisher()
    }
}

Domain 영역

Domain 영역은 어느 영역도 import 하지 않기 때문에 의존성이 없습니다. 다시 말하자면 Data 영역이나 Presentation 영역에 어떤 함수나 객체가 있는지도 알 수가 없습니다.

VO

VO는 실제로 우리 개발자들이 활용하기 위한 데이터 형식으로서, 이 데이터 형식을 가지고 비즈니스 로직을 구현하며 변하지 않는 값이라고 알려져 있습니다.

위에서 정의했던 DTO와 비교했을 때 크게 다른 점은 몇가지 변수가 옵셔널 처리가 되었다는 점인데, 비즈니스 로직상 무조건 값이 있어야 하는 것이라면 VO도 그렇게 정의가 되어야하기 때문입니다. 다만 faqs 변수는 옵셔널 타입인데, 실제로 앱에서도 faqs는 있어도 정상, 없어도 정상이 될 수 있기 때문입니다.

따라서 DTO를 VO로 바꿔줄 때는 여러 처리가 들어갈 수 있지만, 저는 주로 옵셔널 처리를 했으며, 값이 null일 때는 기본값을 넘겨주도록 했습니다.

또 한가지 특이한 점은 favorite 변수가 var이라는 것입니다. 저희 앱에서는 어떤 동작을 할 때마다 favorite 값이 변할 수 있어서 이렇게 정의를 했지만, 사실 VO는 불변성이라는 특성을 가진 객체기 때문에 var 변수가 있어서는 안됩니다. 멘토님께 여쭤보니 원래대로라면 let으로 정의하고 값이 변하면 서버와 통신해서 아예 새로운 VO 객체를 또다시 받아와야 합니다. 따라서 이 부분은 나중에 서버측과 합의하여 리팩터링 해볼 예정입니다.

// Domain 영역 - VO
public struct ProblemDetailVO {
    public let problemId: Int
    public let problemQuestion: String
    public let problemAnswer: String
    public let problemKeyword: String
    public var favorite: ProblemFavoriteStatus // 원래는 let이 되어야함
    public let faqs: [ProblemFAQVO]? // 값이 없어도 정상
    
    public init(problemId: Int, problemQuestion: String, problemAnswer: String, problemKeyword: String, favorite: ProblemFavoriteStatus, faqs: [ProblemFAQVO]?) {
        self.problemId = problemId
        self.problemQuestion = problemQuestion
        self.problemAnswer = problemAnswer
        self.problemKeyword = problemKeyword
        self.favorite = favorite
        self.faqs = faqs
    }
}

public struct ProblemFAQVO: Hashable {
    public let faqQuestion: String
    public let faqAnswer: String
    
    public init(faqQuestion: String, faqAnswer: String) {
        self.faqQuestion = faqQuestion
        self.faqAnswer = faqAnswer
    }
}

Repository

위에서 Repository는 특이하게 프로토콜과 실제 구현체가 서로 다른 영역에 있다고 했습니다. 그 이유는 Domain 영역에서 Repository 함수를 가져다 쓸 수 있도록 하기 위해서입니다. Domain 영역, 그 중에서 UseCase는 Repository의 실제 구현체는 알지 못하지만, 같은 영역에 있는 Repository 프로토콜은 알고 있기에 해당 프로토콜을 따르는 객체를 주입 받을 수 있고 프로토콜에서 정의된 함수를 가져다 쓸 수 있습니다.

import Combine

// Domain 영역 - Repository 프로토콜
public protocol ProblemDetailRepository {
    func getProblemDetail(id: Int) ->
}

UseCase

실제로 UseCase를 보면 repository 변수의 타입이 프로토콜입니다. 그리고 DI를 통해 Data 영역에 있는 Repository 구현체를 주입 받아서 사용 가능합니다.

그러면 이제 UseCase에 대해 설명을 하자면, VO를 활용해 비즈니스 로직을 구현하는 곳입니다.

여기서 비즈니스 로직이 무엇인지 인터넷에 찾아보면 프로그램의 핵심 로직이라고 많이 나옵니다. 다만 저는 이게 잘 와닿지 않아서 제 나름대로 정의를 내렸는데, "개발자 뿐만 아니라 기획자 또는 디자이너가 알법한 로직" 이라고 단순하게 생각을 하고 있습니다. 물론 이 정의가 100% 정답은 아니기 때문에 좀 더 찾아보시는 것을 권장드립니다.

듣기로는 로깅 같은 작업도 이 곳에서 많이 한다고 합니다.

import Combine

// Domain 영역 - UseCase 프로토콜
public protocol ProblemDetailUseCase {
    func getProblemDetail(id: Int) -> AnyPublisher<ProblemDetailVO, Error>
}

// Domain 영역 - UseCase
public final class DefaultProblemDetailUseCase: ProblemDetailUseCase {
    
    private let repository: ProblemDetailRepository
    
    public init(repository: ProblemDetailRepository) {
        self.repository = repository
    }
    
    public func getProblemDetail(id: Int) -> AnyPublisher<ProblemDetailVO, Error> {
        repository.getProblemDetail(id: id)
    }
}

Presentation 영역

Presentation 영역은 Data 영역과 마찬가지로 Domain을 import 합니다. 즉 Presentation 영역은 Domain 영역에 대한 의존성이 있다고 할 수 있습니다.

Presentor

Presentor을 구현하는 방법도 여러가지가 있겠지만, 저는 MVVM 아키텍쳐를 사용하여 ViewModel이 이와 비슷한 역할을 해줍니다.

Domain 영역의 UseCase를 가져다 사용하는데, Combine의 Publisher로 쭉쭉 내려온 response가 이 곳에서 풀어헤쳐지게 됩니다. 그래서 결과가 success 이면 받아온 VO 객체를 할당한 후 뭔가 작업을 시작하고, failure면 또 나름대로 error 처리를 해줬습니다.

import Domain
import SwiftUI

// Presentation 영역 - ViewModel
public class ProblemDetailViewModel: BaseViewModel {
    private let useCase: ProblemDetailUseCase
    private var problemId: Int
    @Published var problemDetailVO: ProblemDetailVO?
    
    public init(problemId: Int, useCase: ProblemDetailUseCase) {
        self.useCase = useCase
        self.problemId = problemId
    }

    // API 통신해서 문제 세부 정보 가져오기
    func getProblemDetail() {
        useCase.getProblemDetail(id: problemId)
            .sinkToResult { result in
                switch result {
                case .success(let problemDetailVO):
                    self.problemDetailVO = problemDetailVO
                case .failure(let error):
                    ...
                }
            }
            .store(in: cancelBag)
    }
}

View

마지막으로 View인데, 이 부분은 당연히 사용자에게 화면을 보여주는 역할을 맡고 있으며, 인터랙션에 따라서 ViewModel의 함수를 호출하게 됩니다.

import SwiftUI

// Presentation 영역 - View
public struct ProblemDetailView: View {
    @StateObject private var viewModel: ProblemDetailViewModel
    
    public init(viewModel: ProblemDetailViewModel) {
        self._viewModel = StateObject(wrappedValue: viewModel)
    }
    
    public var body: some View {
        VStack {
            ...
        }
        .onAppear {
            viewModel.getProblemDetail()
        }
    }
}

마무리

이상으로 제가 사용해본 클린아키텍쳐를 코드와 함께 설명드렸습니다. 사실 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊

profile
개발을 하며 경험한 것들을 이것저것 작성해보고 있습니다!

1개의 댓글

comment-user-thumbnail
2023년 7월 25일

좋은 글이네요. 공유해주셔서 감사합니다.

답글 달기