클린 아키텍쳐의 주요 원칙은 내부 Layer 에서 외부 Layer 로 종속성을 갖지 않는 것
반대로 외부 Layer 에서는 내부로만 종속성이 있을 수 있습니다.
모든 계층을 그룹화하면 아래와 같이 3가지의 계층으로 나뉩니다.
Entity
, UseCase
, Repository Interface
가 포함되어 있음.재사용성
과 테스트
가 용이MVVM 패턴에서 ViewModel이 가지고 있던 네트워크 통신, 비즈니스 로직을 Domain Layer 로 분리합니다.
이를 통해 ViewModel은 Presentation Layer 로직에만 집중할 수 있습니다. 즉, UI와 관련된 상태 관리 및 이벤트 처리에 집중 할 수 있습니다.
Entity는 "Enterprise wide business rules" 를 캡슐화 한다.
이는 메서드가 포함된 개체일 수도 있고, 데이터 구조 및 함수의 집합일 수도 있다.
가장 일반적이고 높은 수준의 규칙을 캡슐화하고, 외부 변화가 있을 때, 변화할 가능성이 가장 적다.
솔직히 이것만 보면, Entity가 대체 뭔지 감도 안잡힙니다.
이해를 돕기 위해 iOS 클린 아키텍처 예제와 함께 보도록 하겠습니다.
Github: https://github.com/kudoleh/iOS-Clean-Architecture-MVVM
간단한 영화 검색 앱 입니다.
이 앱에서 Entity 는 무엇일까요?
프로젝트의 Domain/Entity 폴더를 보면 Movie.swift 파일이 있습니다.
struct Movie: Equatable, Identifiable {
typealias Identifier = String
enum Genre {
case adventure
case scienceFiction
}
let id: Identifier
let title: String?
let genre: Genre?
let posterPath: String?
let overview: String?
let releaseDate: Date?
}
영화의 정보를 담는 단순한 데이터 구조
를 가지고 있습니다. 추가로 함수들도 가질 수 있겠죠.
그냥 단순하게 데이터를 담는 데이터 구조, 함수 집합이라고 생각하면 될 것 같습니다.
영화 앱에서는 영화에 대한 정보를 보여줘야 하니까 Movie 라는 데이터 구조체를 선언해서 데이터를 담아서 사용하는 것 입니다.
UseCase는 애플리케이션에서 수행되는 특정한 작업
이나행동
을 의미합니다.
영화 검색 앱에서 특정한 작업이나 행동은 무엇일까요?
protocol SearchMoviesUseCase {
func execute(
requestValue: SearchMoviesUseCaseRequestValue,
cached: @escaping (MoviesPage) -> Void,
completion: @escaping (Result<MoviesPage, Error>) -> Void
) -> Cancellable?
}
영화를 검색하는 행위가 있을 것입니다.
이러한 특정한 작업이나 행동에 해당하는 애플리케이션의 모든 비즈니스 로직을 UseCase라고 합니다.
예를 들어, '사용자 정보를 조회하는' 작업 또한 UseCase 입니다.
MVVM 패턴에서는 이러한 비즈니스 로직이 ViewModel 에 있었지만, 클린 아키텍처에서는 비즈니스 로직을 UseCase로 분리하여 역할 분할을 더욱 확실하게 한 모습입니다.
Repository Interface는 UseCase(Domain) 와 Repository(Data) 사이의 Interface 를 제공합니다.
클린 아키텍처의 주요 원칙으로서 '내부 계층은 외부 계층으로의 종속성을 갖지 않는다' 라고 했었죠?
따라서 원칙상 UseCase에서 Repository에 의존성을 가져서는 안됩니다.
그래서 UseCase 에서는 Repository 구현체를 직접적으로 가지지 않고, Interface 를 통해서 접근합니다.
// MoviesRepository.swift
protocol MoviesRepository {
@discardableResult
func fetchMoviesList(
query: MovieQuery,
page: Int,
cached: @escaping (MoviesPage) -> Void,
completion: @escaping (Result<MoviesPage, Error>) -> Void
) -> Cancellable?
}
// DefaultMoviesRepository.swift
final class DefaultMoviesRepository: MoviesRepository { ... }
//SearchMoviesUseCase.swift
final class DefaultSearchMoviesUseCase: SearchMoviesUseCase {
private let moviesRepository: MoviesRepository
...
}
위와 같이 protocol을 통해 추상화를 하면, Domain Layer 는 Data Layer의 변경에 영향을 받지 않게 됩니다. 즉, 데이터 소스가 변경되더라도 이에 대한 변경 사항은 Data Layer 내에서만 처리되며, Domain Layer 는 이에 대해 알 필요가 없습니다.
Presentation Layer 는 사용자와 직접적으로 상호작용하는 계층으로, UI와 관련된 작업을 담당합니다.
ViewController 와 ViewModel 이 Presentation Layer 에 속합니다.
ViewModel에서 UseCase로부터 데이터를 요청하고, 받은 데이터를 통해 ViewController에서 UI를 업데이트 합니다.
Data Layer 는 애플리케이션의 데이터에 관련된 모든 작업을 처리하는 계층입니다.
네트워크 통신, 로컬 DB 가 이 계층에 포함됩니다.
final class DefaultMoviesRepository {
private let dataTransferService: DataTransferService
private let cache: MoviesResponseStorage
private let backgroundQueue: DataTransferDispatchQueue
init(
dataTransferService: DataTransferService,
cache: MoviesResponseStorage,
backgroundQueue: DataTransferDispatchQueue = DispatchQueue.global(qos: .userInitiated)
) {
self.dataTransferService = dataTransferService
self.cache = cache
self.backgroundQueue = backgroundQueue
}
}
extension DefaultMoviesRepository: MoviesRepository {
func fetchMoviesList(
query: MovieQuery,
page: Int,
cached: @escaping (MoviesPage) -> Void,
completion: @escaping (Result<MoviesPage, Error>) -> Void
) -> Cancellable? {
let requestDTO = MoviesRequestDTO(query: query.query, page: page)
let task = RepositoryTask()
cache.getResponse(for: requestDTO) { [weak self, backgroundQueue] result in
if case let .success(responseDTO?) = result {
cached(responseDTO.toDomain())
}
guard !task.isCancelled else { return }
let endpoint = APIEndpoints.getMovies(with: requestDTO)
task.networkTask = self?.dataTransferService.request(
with: endpoint,
on: backgroundQueue
) { result in
switch result {
case .success(let responseDTO):
self?.cache.save(response: responseDTO, for: requestDTO)
completion(.success(responseDTO.toDomain()))
case .failure(let error):
completion(.failure(error))
}
}
}
return task
}
}
네트워크 통신을 통해 영화 목록을 가져오는 Repository 구현체는 Data Layer 에 해당합니다. UseCase 에서는 이 Repository 의 Interface를 통해 데이터를 요청합니다.
struct MoviesResponseDTO: Decodable {
private enum CodingKeys: String, CodingKey {
case page
case totalPages = "total_pages"
case movies = "results"
}
let page: Int
let totalPages: Int
let movies: [MovieDTO]
}
JSON 형태의 데이터를 Swift 데이터 타입으로 변환하기 위한 DTO(Data Transfer Object) 입니다.
DTO 또한 Data Layer 에 속합니다.
extension MoviesResponseDTO {
func toDomain() -> MoviesPage {
return .init(page: page,
totalPages: totalPages,
movies: movies.map { $0.toDomain() })
}
}
extension MoviesResponseDTO.MovieDTO {
func toDomain() -> Movie {
return .init(id: Movie.Identifier(id),
title: title,
genre: genre?.toDomain(),
posterPath: posterPath,
overview: overview,
releaseDate: dateFormatter.date(from: releaseDate ?? ""))
}
}
extension MoviesResponseDTO.MovieDTO.GenreDTO {
func toDomain() -> Movie.Genre {
switch self {
case .adventure: return .adventure
case .scienceFiction: return .scienceFiction
}
}
}
Domain Layer 또는 Presentation Layer 에서는 Data Layer 에 종속성을 가져서는 안됩니다.
따라서 Domain Layer에 해당하는 Entity로 변환해주는 메서드를 extension 으로 구현한 모습입니다.