본문 바로가기

회고록

MovieApp 회고록

개요

유지 보수 및 테스트에 용이하도록 앱 개발에 이용할 수 있는 구조를 찾다보니

클린아키텍쳐 글을 기반으로 작성한 다음 레포지토리를 발견해서 코드를 분석하려다가

UIKit으로 개발된 앱을 그냥 SwiftUI 앱으로 직접 다시 작성해 보자라는 생각을 가지고 프로젝트를 시작하게 되었다

 

또한 기존 비동기 코드의 구현 방식을 Swift Concurrency를 이용해 다시 작성하였다

개발을 하면서 느꼈던 것들을 몇 가지 글로 남겨본다

 

💡 정리 내용

  • 클린 아키텍쳐MVVM
  • SwiftUI의 UIKit Integration(SearchBar 구현)

링크

https://github.com/hot666666/MovieApp

 

클린 아키텍쳐와 MVVM

다음 글에 클린 아키텍처에 대해 정리를 했다

클린 아키텍쳐

내용이 길지만 내가 생각하기에 가장 핵심은 다음과 같다

 

💡 클린 아키텍처 핵심

  • 관심사의 분리
  • 인터페이스
  • 의존규칙

관심사의 분리

하나의 비즈니스 로직을 수행하는데 여러 요소들이 사용되기도 한다

이때 각 관련 요소들을 묶어서 따로 정의해서 주입받아 사용하면 인지적인 부담도 줄어들고

추후 테스트에서 Mock으로 이용하기도 편하다

인터페이스

다른곳에서 해당 요소가 사용될 때, 사용될 요소들을 미리 정의해둔다면

이후 내용을 바꾸더라도 동일하게 이용될 수 있기에 꼭 인터페이스를 미리 정의하고 구현하자

 

의존규칙

의존성은 상위 계층으로부터 하위 계층으로 방향이 진행된다

바깥쪽에서 선언된 어떠한 것도 안쪽에선 사용할 수 없음을 인지하고 코드를 작성해야 한다

그렇지만 생각해보면 UseCase를 구현 시, 결국 외부 계층으로부터 주입이 필요하다

그럼 이전에 말한 규칙을 못지키게 되는데, 이런경우 의존성 컨테이너를 만들어서 의존성 역전 제어를 하면 된다(스프링도 이런 컨테이너다!)

 

MVC와 MVVM

위의 클린 아키텍쳐에서 Presenters와 UI가 묶인 부분으로 볼 수 있고, 다음 글에서 관련 내용에 대해 정리를 했다

한 줄 요약

 

MVC는 Controller가 비즈니스 로직을 수행(Model 업데이트)하여 View에 반영시킨다

 

 

MVVM은 ViewModel이 비즈니스 로직을 수행(Model 업데이트)하면 이 변화를 감지하여 View가 업데이트된다

 

SwiftUI의 UIKit Integration(SearchBar 구현)

사실 해당 프로젝트를 하면서 가장 시간을 많이 쏟은 부분은 영화를 검색하는 SearchBar 부분이다

SwiftUI로 프로젝트를 구현하기에 처음엔 SearchBar도 SwiftUI로 만들었다

@FocusState

SearchBar를 SwiftUI로 만들면서 생각보다 복잡했던 부분은 다음과 같았다

SwiftUI에서 특정 View가 포커즈 되었는지 확인하려면 다음 과정을 거친다

 

  1. 해당 View의 상위 뷰에서 @FocusState를 정의
  2. 이를 확인하려는 View의 .focused 수정자에 정의한 변수를 넘김
  3. 해당 변수를 통해 확인
struct MoviesHomeView: View {
    ...
  @FocusState private var isTextFieldFocused: Bool

    var body: some View {
        NavigationStack(path: $container.navigationRouter.destinations) {
            VStack(spacing: 0){
                HeaderView(title: "Movies")

                SearchBarView(moviesListVM: moviesHomeVM, defocus: defocus)
                    .focused($isTextFieldFocused)

위와같은 코드라면 isTextFieldFocused로 포커즈 상태를 확인할 수 있다

나는 SearchBar의 포커즈 상태에 따라 우측 Cancel 버튼이 나왔다 들어가는 애니메이션 효과를 원하고

만약 Cancel 버튼을 누른다면 SearchBar가 focused된 것이 풀리기를 원했다

이를 위해 다음과 같이 구현했다

 

  1. 애니메이션을 위한 상태를 따로 ViewModel에서 관리
  2. 외부에서 focused를 푸는 defocus 메서드를 전달

 

struct MoviesHomeView: View {
    ...
  @FocusState private var isTextFieldFocused: Bool

  var body: some View {
      NavigationStack(path: $container.navigationRouter.destinations) {
          VStack(spacing: 0){
              HeaderView(title: "Movies")

              SearchBarView(moviesListVM: moviesHomeVM, defocus: defocus)
                  .focused($isTextFieldFocused)

              BodyView()
          }
          .onChange(of: isTextFieldFocused){ _, newValue in
              withAnimation {
                  moviesHomeVM.isFocusedSearchBar = newValue
              }
          }
          .onAppear{
              moviesHomeVM.isFocusedSearchBar = isTextFieldFocused
          }

     ...

    func defocus(){
        isTextFieldFocused = false
    }

하지만 다시 생각해보니 단순히 다른 곳에 터치하면 알아서 isTextFieldFocused가 풀리기에 다음과 같이 구현했다면 굳이 defocus 없이 구현할 수 있지 않았을까 싶다

  • SearchBarView
    • TextField - isTextFieldFocused를 이전과 달리 Button과 별개의 View로 정의
    • Button - Cancel을 isTextFieldFocused 상태에 따라 랜더링

 

UISearchBar

찾다보니 UIKit의 UISearchBar를 UIViewRepresentable을 통해 SwiftUI View로 사용하는 방법도 알게되었다

@FocusState로 관리하고 따로 defocus 메서드를 만들어서 전달해야 했던 것과 달리

이미 Delegate로 관련 동작 메서드가 제공되기에 이를 통해 상태로 업데이트하면 돼서 훨씬 편리했다

struct SearchBarView: UIViewRepresentable {
    @Binding var text: String
    @Binding var isFocused: Bool
    var onSearchButtonClicked: (String) -> Void

    func makeUIView(context: Context) -> UISearchBar {
        let searchBar = UISearchBar(frame: .zero)
        searchBar.delegate = context.coordinator
        return searchBar
    }

    func updateUIView(_ uiView: UISearchBar, context: Context) {
        uiView.text = text
    }

    func makeCoordinator() -> Coordinator {
        return Coordinator(text: $text, isFocused: $isFocused, onSearchButtonClicked: onSearchButtonClicked)
    }

    class Coordinator: NSObject, UISearchBarDelegate {

        @Binding var text: String
        @Binding var isFocused: Bool
        var onSearchButtonClicked: (String) -> Void

        init(text: Binding<String>, isFocused: Binding<Bool>, onSearchButtonClicked: @escaping (String) -> Void) {
            _text = text
            _isFocused = isFocused
            self.onSearchButtonClicked = onSearchButtonClicked
        }

        func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
            if searchText.isEmpty {  /// x 버튼 터치 시 수행
                isFocused = true
            }
            text = searchText
        }

        func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
            if !text.isEmpty {
                onSearchButtonClicked(text)  /// 검색 버튼을 누르면 전달받은 함수 실행
            }
            searchBar.resignFirstResponder()
            isFocused = false
        }
        
        func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
            searchBar.showsCancelButton = false
            searchBar.resignFirstResponder()
            isFocused = false
        }
        
        func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
            searchBar.showsCancelButton = true
            isFocused = true
        }
    }
}

UIKit Integration에 대한 자세한 내용은 해당 글에 있다

 

결론

처음에 클린 아키텍쳐에 대해 정리했는데, 그래서 결국 내가 새로 작성한 앱은 이런 모든 규칙을 잘 지켜서 구현했는가?

 

아쉽게 그러진 못했지만 다음과 같은 것들을 깨달았다

나름의 규칙을 가지고 관심사를 분리하고,

자세한 내용이 변경되어도 이를 준수하기만 하면 되도록 하는 공통의 인터페이스를 잘 작성하고,

구현하는 데 있어서 의존방향을 생각하며 외부 요소를 끌고오지 않는지 생각하며 구현하면

유지 보수 및 테스트에 용이하도록 앱 개발을 진행할 수 있을 것 같다고 생각한다

 

또한, 많이 잘 사용되는 UIKit 요소를 SwiftUI에 가져와 꿀빨기 위해선 UIKit에 대해서도 어느정도 공부해둬야겠다는 생각이 들어 따로 공부를 시작하게 되었다

 

참고