iOS 프로젝트/클론

[iOS] Jetflix 5. 리팩토링 - ViewModel 개선

유정주 2023. 5. 16. 19:58
반응형

* 진행 코드는 https://github.com/jeongju9216/Jetflix에서 볼 수 있고, PR에서 에피소드 단위로 코드를 확인할 수 있습니다.

 

개선 내용

첫 번째 리팩토링은 코드와 구조 수정에 집중했습니다.

  1. MVC 구조를 MVVM으로 변경
  2. Enum을 이용해 ViewModelActions 정의
  3. Enum을 이용해 API 콜 메서드 정리

 

1.

클론 코딩 영상에서는 MVVM이라고 설명하고 있었지만, 제가 느끼기에는 MVC의 구조를 가지고 있다고 느꼈습니다.

ViewController에서 데이터를 직접 API Call을 하고 데이터를 생성, 조작했기 때문입니다.

따라서 이부분을 개선하여 ViewModel에서 데이터를 생성, 관리했습니다.

 

이 과정에서도 고민점은 있었는데요.

과연 변화가 없는 데이터도 데이터 바인딩을 해야하는가? 입니다.

데이터 바인딩은 비동기적으로 데이터를 가져올 때, View가 비동기 순서에 관심을 가지지 않아도 된다는 장점이 있습니다.

ViewModel에서 데이터를 변화시키면 ViewController는 그 데이터를 binding하고 변화가 발생했을 때 자연스럽게 로직을 수행할 수 있기 때문입니다.

 

그렇다면 화면이 나올 때 단 한 번만 fetch하고 이후에는 변화가 없는 데이터에 과연 바인딩이 필요할까? 라는 고민이 생겼습니다.

예를 들어, Top 10의 데이터를 가져올 때 이 데이터는 높은 확률로 앱이 꺼지기 전까지 변화가 없을 것입니다.

이런 데이터를 바인딩해서 쓴다면 타인이 코드를 봤을 때 "혹시 변화가 있나?"라는 의심이 들 수 있을 것입니다.

비동기로 인한 디버깅도 힘들겠고요.

 

불필요한 데이터 바인딩을 줄여서 고민거리를 줄인다면 어떨까? 라는 생각을 해보았습니다.

이에 대해 정답(?)을 아시는 분이 계시다면 댓글로 한 수 알려주시면 감사하겠습니다.

 

2.

제 포스팅 중 Enum을 이용해 ViewModel을 개선하는 방법에 대해 작성한 포스팅이 있습니다.

(Enum을 이용한 ViewModel Action 정의, Enum을 이용한 ViewModel Output 정의)

 

이번 개선을 진행하면서 도입을 해보았는데요.

enum HomeViewModelActions {
    case getContents(ContentType)
    case getRandomTrendingContent
    case save(Content)
}

enum HomeViewModelActionsOutput {
    case contents([Content])
    case isSuccess(Bool)
    case none(Void)
    
    var value: Any {
        switch self {
        case .contents(let contents):
            return contents
        case .isSuccess(let isSuccess):
            return isSuccess
        case .none():
            return ()
        }
    }
}

@discardableResult
func action(_ actions: HomeViewModelActions) async throws -> HomeViewModelActionsOutput {
    switch actions {
    case .getContents(let contentType):
        return .contents(try await getContents(type: contentType))
    case .getRandomTrendingContent:
        getRandomTrendingContent()
        return .none(())
    case .save(let content):
        return .isSuccess(save(content: content))
    }
}

작성하며 느낀 점은 형식을 맞추는게 생각보다 까다롭다는 것이었습니다.

 

"action 메서드를 분리하지 않는다"라는 것을 지킨채 Output을 구현하려고 하니 반환 값이 없는 경우에 대한 처리가 별도로 필요하고,

async/await가 필요한 Action과 불필요한 Action이 하나의 action 메서드에 있어서 항상 Task 안에서 호출해야 한다는 단점도 있었습니다.

 

예를 들어 위 코드에서 getRandomTrendingContent 액선은 데이터 바인딩으로 데이터를 관리하기 때문에 반환값이 필요가 없는데 Output 형태를 지키기 위해 불필요하게 반환을 하는 예시입니다.

또한, save 액션은 async하지 않아도 되는데 getContents( ), getRandom...( )이 async해야 해서 불필요하게 async에 포함된 예시입니다.

 

이렇게 하나 둘 예외가 생기면서 배보다 배꼽이 더 커진 느낌이 들었습니다.

너무 action 통합에 꽂힌 나머지 숲을 보지 못하고 있는 것인지, 어떻게 개선을 할 수 있을지 고민해봐야겠다고 느낀 경험이었습니다.

이것도 좋은 의견이 있으시다면 댓글로 알려주시면 정말 감사하겠습니다.

 

3.

Enum을 이용해 API 콜 메서드를 정리했습니다.

 

개선 전의 코드를 먼저 보겠습니다.

동일한 파라미터, 반환 값을 가지고 있고 역할도 모두 같다는 것을 알 수 있습니다.

 

비슷한 특성을 가지고 있을 때 Enum을 이용하면 효과적으로 코드를 정리할 수 있습니다.

enum ContentType {
    case trending(MediaType)
    case upcoming
    case popular
    case topRated
    case discover
}

enum MediaType: String {
    case movie
    case tv
}

//TMDB API get
func getContents(type: ContentType) async throws -> [Content]

두 개의 Enum을 이용해 단 하나의 메서드로 통합할 수 있었습니다.

 

다만, APICaller 클래스의 메서드는 일부러 수정하지 않았습니다.

func getTrendingContent(type: MediaType) async throws -> [Content] {
    ...
}

func getUpcomingMovie() async throws -> [Content] {
    ...
}

func getPopularMovie() async throws -> [Content] {
    ...
}

URL의 형태(?)가 동일해서 충분히 합칠 수 있었습니다만,

만약 서비스가 확장되서 예외가 생기는 API가 생긴다면 다시 분리하는 일이 생길 수 있기 때문입니다.

그러면 메서드 구조에 통일성이 사라지기 때문에 변경에 용이하도록 API마다 메서드를 따로 따로 구현했습니다.

(물론 클론 코딩이기 때문에 확장될 가능성은 없지만... 실전이다 생각하고 고민해봤습니다 ㅎㅎ;)

 

마무리

다음 리팩토링에서는 View를 바꿔보려고 합니다.

대표적으로 Flow Layout을 Compositinal Layout으로 변경해볼까 합니다.

 

이번 클론 코딩에서는 UseCase를 정의하지 않고 ViewModel에서 Repository를 가지고 있도록 구현해보았는데요.

UseCase에 대한 고민점도 다음 포스팅에서 작성해보겠습니다.

 

감사합니다.


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

 

반응형