하루를살자

[iOS] MVVM + Clean Architecture [2] - Domain Layer 본문

Architecture

[iOS] MVVM + Clean Architecture [2] - Domain Layer

Kai1996 2022. 8. 16. 23:49

Domain Layer

예제 프로젝트에서 Domain Layer 에 속하는 Domain 폴더를 살펴보자.

크게 3가지 분류: Entities, UseCase, Repository 의 Interface 로 나눠서 볼수 있다.

 

Entity : 프로젝트에 사용되는 도메인 정보.

 

Usecase : 

  • 어플리케이션 안에서 사용자가 사용할수 있는 메인 기능 의 인터페이스와 구현체.
  • UseCase 는 또 다른 Usecase 에 의존할수 있다
  • UseCase 는 interactor 라고도 부른다

Repository Interface : Dependency Inversion 을 위한 인터페이스. 

(Dependency Inversion 은 이 아티클에서 잘 설명해줌) 

 

protocol SearchMoviesUseCase {
    func execute(requestValue: SearchMoviesUseCaseRequestValue,
                 completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}

final class DefaultSearchMoviesUseCase: SearchMoviesUseCase {

    private let moviesRepository: MoviesRepository
    private let moviesQueriesRepository: MoviesQueriesRepository
    
    init(moviesRepository: MoviesRepository, moviesQueriesRepository: MoviesQueriesRepository) {
        self.moviesRepository = moviesRepository
        self.moviesQueriesRepository = moviesQueriesRepository
    }
    
    func execute(requestValue: SearchMoviesUseCaseRequestValue,
                 completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable? {
        return moviesRepository.fetchMoviesList(query: requestValue.query, page: requestValue.page) { result in
            
            if case .success = result {
                self.moviesQueriesRepository.saveRecentQuery(query: requestValue.query) { _ in }
            }

            completion(result)
        }
    }
}

// Repository Interfaces
protocol MoviesRepository {
    func fetchMoviesList(query: MovieQuery, page: Int, completion: @escaping (Result<MoviesPage, Error>) -> Void) -> Cancellable?
}

protocol MoviesQueriesRepository {
    func fetchRecentsQueries(maxCount: Int, completion: @escaping (Result<[MovieQuery], Error>) -> Void)
    func saveRecentQuery(query: MovieQuery, completion: @escaping (Result<MovieQuery, Error>) -> Void)
}

 

SeachMoviesUsecase,DefaultSearchMoviesUseCase

SeachMoviewsUsecase 로 어떤 Usecase 의 인터페이스를 정의하고 DefaultSeachMoviesUsecase 에 구현한다. 

이 구현체에는 Repository 의 인터페이스 타입을 가지는 프로퍼티를 할당하여 Dependency inversion 에 사용하는것 을 볼수있다. 

 

또한 Usecase 를 두가지 방법으로 설계를 할수 있다고 설명한다.

1.0 각각의 Usecase 를 interface 로 만들고 Conform 해주는 구현체를 만드는 방법

2.0 Start() 라는 메소드를 가진 Usecase Interface 를 Conform 하는 구현체를 만드는 방법.


진행중인 Picteresting 프로젝트 에서 리팩토링 가능한 요소들 @Domain Layer

현재 프로젝트는 Domain Layer 를 따로 두긴 했지만 Entity 만 사용하고 있다.

분석

View 에 필요한 데이터 요청을 처리하고 동시에 ImageConfigurable 이라는 Usecase 를 채택하고 Repository 또한 프로퍼티로 가지고 있다. 즉, ViewModel 이 Usecase 와 Viewmodel 역할을 동시에 하고 있다.

final class SaveViewModel: ImageConfigurable {

  var didUpdateLikeStatusAt: ((Int) -> Void)?
  var imageList: Observable<[ImageEntity]> = Observable([])
  let repository = HomeRepository()

  subscript(index: IndexPath) -> ImageEntity? {
    return imageList.value[index.row]
  }
  
  func resetList() {
  //...
  }
  
  func updateLikeStatus() {
  //...
  }
  
  func resetLikeStatus() {
  //...
  }
  
  func fetchImages() {
  //...
  }
  
  func toogleLikeState(item entity: ImageEntity, completion: @escaping ((Error?) -> Void)) {
  //...
  }
  
}

protocol ImageConfigurable {
  var imageList: Observable<[ImageEntity]> {get set}
  func fetchImages()
  func resetList()
  func updateLikeStatus()
  func resetLikeStatus()
  func toogleLikeState(item entity: ImageEntity, completion: @escaping ((Error?) -> Void))
  subscript(index: IndexPath) -> ImageEntity? { get }
}

문제점

Usecase

1.0 ImageConfigurable 이라는 Usecase 를 채택하고 있던 ViewModel 들은 해당 화면에 필요없는 기능까지 구현해야했어야 했다. 

2.0 두 Viewmodel 에 합당한 ImageConfigurable 이라는 모호한 Usecase 네이밍을 사용해야했기 때문에 프로젝트의 정확한 기능을 이해하기가 어렵다. 

3.0 Usecase 를 테스트 하려면 ViewModel 까지 만들어야 한다. (유닛 테스트가 길고 복잡해진다) 

 

Repository 

HomeRepository 의 경우 인터페이스 사용을 하지 않고 구현체 하나에서 메소드를 여러개 사용하고 있다. 각 메소드 하나하나가 Repository 의 역할을 하게 되는데  ViewModel (Presenter) 에 Usecase 와 같은 비즈니스로직 이 embed 되어 있으면 어떤 Repository 가 있는지 다른개발자가 봤을때 직관적으로 아키텍처의 흐름과 기능을 이해하기 힘들다. 반면에 Repository 를 따로 인터페이스로 만들어두면 똑같은 인터페이스의 사용이 많이질시 재사용성이 높아진다. 또한 Unit Testing 을 진행시 짧고 간결한 테스트를 진행할수 있는 장점이 생긴다. 

 

개선 사항 

1.0 ImageConfigurable 의 프로토콜 분리작업 

  • Presentation Layer 를 공부한이후에 살펴볼 내용이지만, ImageConfigurable 은 너무 많은 책임을 가지고 있다. 사용자의 Input 에 따른 Output 작업 + Usecase 의 책임 등 을 가지고 있다. 지금은 어떤 인터페이스를 Usecase 로 뺄수 있을지에 대한 집중을 먼저 해봤다. 

현재 ImageConfigurable  사용하고 있는 인터페이스 

  • fetchImages(): 사용자는 어플리케이션을 시작하면 Home 화면에 이미지를 볼수있다. 첫 Home 화면이 메모리에 할당되는 순간, 어플리케이션은 "이미지를 가지고와" 라는 사용자의 행위를 대신 처리하게 된다. 따라서 "FetchImageUsecase"  라는 Usecase 를 만들것이다. 
  • resetList(): Repository 를 사용해서 처리하는 메소드가 아니고 Viewmodel 의 "imageList" 프로퍼티를 사용하기 때문에 Usecase 로 따로 빼진 않을것 같다. 
  • resetLikeStatus(): ViewController 의 willDisappear 메소드에서 호출되고, ImageEntity 의 isLiked 의 상태가 true 인 경우 false 로 만들어준다. 이 인터페이스 또한 Repository 를 사용하지 않고 ViewModel 의 프로퍼티를 사용하기 때문에 Usecase 로 사용하지 않을것 같다. 
  • updateLikeStatus(): ViewController 의 willAppear 메소드에서 호출되고 Core data 에 저장된 이미지 정보들을 가져와서 ImageList 에 있는 imageEntity 의 id 와 비교하여 매칭되는 entity 의 isliked 상태를 반전시킨다 (false -> true). 사용자의 Input 에 의해 직접적인 영향을 미치는 기능이 아니기 때문에 보류 해둔다.  (Presentation Layer 에서 Output 분류로 빠질수 있을것같다)
  • toogleLikeState(): 사용자가 별모양 버튼을 눌렀을때 해당 entity 의 isLiked 의 상태를 바꾸고 Core data 와 disk cache 된 이미지 데이터를 추가/삭제한다. 사용자가 이미지를 선택하면 저장/삭제 한다는 행위 가 분명히 들어났기 때문에 "ChangeImageLikeState" 라는 인터페이스를 하나만들고, 이것의 구현체인 "SaveImageUsecase" , "UndoSaveImageUsecase" 라는 Usecase 를 만들것이다. 

2.0 Repository 분리작업 

  • 문제점에서 발견했듯이 Repository 는 프로토콜 을 사용하지 않고 하나의 Repository 클래스에 여러개의 책임을 부여해서 singleton 처럼 사용하고 있었다. 이때 문제점은 다른클래스들 간의 결합도가 높아지는데, 이는 개방 폐쇄 원칙 을 위반하여 유닛 테스트와 수정이 힘들어진다. 
  • 따라서 Repository 현재 메소드 를 분석하고 어떤 interface 로 나눌수 있을지에 대해서 중심적으로 고민 해볼것이다. 

현재 Repository 클래스에서 사용하고 있는 메소드 

 

fetchImages(endPoint: EndPoint, completion: @escaping (Result<[ImageEntity], NetworkError>) -> Void)

NetworkService 를 이용하여 주어진 이미지 정보 API endPoint 로 request 를 보내고 response 를 받아 decoding 및 EntityMapping 까지 진행한다. 이 메소드 는 이미지 데이터를 받아오는 핵심 기능을 cache 되어 있는 이미지 데이터를 찾거나 네트워크 요청을 보내기 때문에 "ImageRepository" 라는 Repository Protocol 을 만들고 해당 메소드를 인터페이스로 뺄 예정이다. 

 

fetchSavedImageData() -> [ImageData]

ImageManager 를 사용하여 CoreData 에 저장되어있는 ImageData 를 fetch 해온다. 이 함수는 SaveImageUsecase 에 사용될 것이다. "savedImageRepository" 라고 새로 인터페이스를 만들어 줄지 아니면 "imageRepository" 에 같이 사용할지 고민을 했다. Usecase 가 다르다고 repository 를 새로 만들면 "savedImageRepository","undoSaveImageRepository" 이런식으로 리포지토리의 개수가 많이질 것이다. 하지만 이 Repository 들 안에서 사용되는 모델은 imageEntity 하나뿐이고, 같은 모델을 사용하는 Repository 가 많아진다면 더 헷갈릴것 같다는 생각이들었다. 따라서 "ImageEntity" 라는 모델 을 사용하는 Repository 는 "ImageRepository" 하나만 사용하는게 더 직관적이라는 생각이 들었고 아래 메소드 들도 같은 리포지토리의 인터페이스로 구현 할것이다. 

 

saveImage(imageEntity: ImageEntity, completion: @escaping ((Error?) -> Void))

1.0 ImageManager 를 사용하여 매개변수로 들어온 ImageEntity 의 이미지를 특정 디렉토리에 저장시킨다. 

2.0 ImageData 로 변환뒤 1.0 처리가 완료되면 CoreDataManager 로 imageData 를 저장한다.

 

 

deleteImage(imageEntity: ImageEntity, completion: @escaping ((Error?) -> Void))

1.0 fileManager 를 사용해 ImageEntity 가 저장된 디렉토리에서 이미지 데이터를 삭제 시킨다. 

2.0 CoreDataManager 로 imageData 를 삭제 시킨다. 

 

 

resetRepository(completion: @escaping ((Error?) -> Void)

1.0 CoreDataManager 로 CoreData 에 저장되어있던 ImageData 들을 가져온다. 

2.0 모든 ImageData 의  이미지를 fileManager 를 통해서 저장된 디렉토리를  구한다음 삭제 한다. 

3.0 CoreDataManager 로 CoreData 에 저장되어있던 모든 ImageData 를 삭제 한다.

 

개선된 Usecase, Repository

 

Comments