요즘 소마에서 프로젝트를 해보며 개발 기술들에 대해 배우고 적용해보고 있습니다. 이러한 기술들에 대해 개념을 자세히 설명하기보다는 실제로 어떻게 썼는지, 어떤 점이 좋았는지 정리해보고자 합니다!
클린 아키텍쳐를 검색하면 보통 원형태의 모형이 많이 나오는데, 저는 앱개발에 적용한 것이기 때문에 아래의 모형과 같이 설계를 하고 구현을 했습니다.

클린 아키텍쳐를 제 스스로 간단하게 정리를 하자면 이렇습니다.
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영역은 Domain을 import 합니다. 즉 Data 영역은 Domain 영역에 대한 의존성이 있다고 할 수 있습니다.
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는 서버와의 통신으로 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는 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 영역은 어느 영역도 import 하지 않기 때문에 의존성이 없습니다. 다시 말하자면 Data 영역이나 Presentation 영역에 어떤 함수나 객체가 있는지도 알 수가 없습니다.
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는 특이하게 프로토콜과 실제 구현체가 서로 다른 영역에 있다고 했습니다. 그 이유는 Domain 영역에서 Repository 함수를 가져다 쓸 수 있도록 하기 위해서입니다. Domain 영역, 그 중에서 UseCase는 Repository의 실제 구현체는 알지 못하지만, 같은 영역에 있는 Repository 프로토콜은 알고 있기에 해당 프로토콜을 따르는 객체를 주입 받을 수 있고 프로토콜에서 정의된 함수를 가져다 쓸 수 있습니다.
import Combine
// Domain 영역 - Repository 프로토콜
public protocol ProblemDetailRepository {
func getProblemDetail(id: Int) ->
}
실제로 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 영역은 Data 영역과 마찬가지로 Domain을 import 합니다. 즉 Presentation 영역은 Domain 영역에 대한 의존성이 있다고 할 수 있습니다.
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인데, 이 부분은 당연히 사용자에게 화면을 보여주는 역할을 맡고 있으며, 인터랙션에 따라서 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()
}
}
}
이상으로 제가 사용해본 클린아키텍쳐를 코드와 함께 설명드렸습니다. 사실 저도 현재 배우고 있는 입장이기 때문에 틀린 개념, 부족한 개념들이 있을 수 있습니다. 혹시라도 그런 부분이 있다면 언제든지 지적해주셔도 됩니다. 긴 글 읽어주셔서 감사합니다. 😊
좋은 글이네요. 공유해주셔서 감사합니다.