# [Swift - UIKit]  BaseView, APIService 싱글턴 패턴, DispatchGroup 등을 활용한 간단한 영화 평점 매기기 서비스를 구현해보았다.

![Image](https://upload.cafenono.com/image/slashpagePost/20240630/115322_J01PI3vtTRDm8pmsYq?q=75&s=1280x180&t=outside&f=webp)

> 영화 앱에서는 TMDB API를 활용하여 다음과 같은 페이지를 구현했습니다. 자세한 코드는 아래 깃헙에서 확인 가능합니다.

- 메인페이지 - 현재 상영작 / 요즘 주목받는 작품 / 역대 최고의 평가를 받은 작품 리스트를 테이블뷰 + 가로 컬랙션뷰 형태로 노출

- 영화 상세 페이지 -  컬랙션 아이템 터치시 해당 영화 id값을 기반으로 내가 매긴 별점, 줄거리, 출연진, 유사한 영화 리스트가 한 페이지에서 노출

- 영화 검색 페이지 - 텍스트 필드에서 키워드로 영화를 검색한 결과를 테이블뷰로 노출 (검색 결과가 없을 경우 핸들링)

[GitHub - hankyeol-dev/movie-app: swift movie information app](https://github.com/hankyeol-dev/movie-app)

### BaseView를 만들어 뷰의 상속 관계 이용하기

UIView를 상속받는 기본적인 BaseView를 만들어서 해당 뷰를 상속받는 뷰 객체들이 미리 정의된 메서드를 오버라이딩하여 UI와 기능을 보다 빠르고 편하게 구축할 수 있게 코드를 작성했다.

```javascript
class BaseView: UIView {
    @available(*, unavailable)
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override init(frame: CGRect) {
        super.init(frame: frame)

        configureSubView()
        configureLayout()
        configureUI()
    }

    func configureSubView() {}
    func configureLayout() {}
    func configureUI() {}
}
```

- 프로젝트 코드에서 UI의 레이아웃을 잡거나 서브뷰를 구성한다던지, UI와 관련된 코드를 작성하는 경우에서 반복이 많다. 

- 위의 코드처럼 베이스가 되는 뷰 클래스를 하나 만들어두면, 어떤 메서드에서 레이아웃을 잡고, 어떤 메서드에서 UI 코드를 잡을지 따로 신경쓰지 않아도 된다. (제약을 미리 하나 만들어두는 느낌이라 Protocol을 활용하는 것과 비슷하다.)

자주 사용되는 UI 객체 역시 BaseView를 만들어서 활용했다. 특히, 라벨이라던지 박스 형태의 컴포넌트는 프로젝트 페이지별로 많이 활용되기 때문에(=인스턴스를 생성할 때가 많음) 베이스가 되는 클래스를 만들어 두는 것이 정말 유용했다.

- 인스턴스로 생성된 뷰 객체들은 외부에서 되도록이면 속성을 직접 건드리지 않고, 내부에 구현된 메서드를 이용할 수 있도록 제어자를 만드는 것에 집중했다.

BaseLabel.swift

```javascript
// BaseLabel

class BaseLabel: UILabel {
    
    enum LabelType {
        case normal, title, subTitle, date, error
    }
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    convenience init(_ t: String, size: CGFloat, type: LabelType) {
        self.init(frame: .zero)
        
        text = t
        configureFontByType(size: size, type: type)
    }
    
    private func configureFontByType(size: CGFloat, type: LabelType) {
        switch type {
        case .normal, .date:
            font = .systemFont(ofSize: size)
            textColor = .systemGray
        case .title, .subTitle:
            font = .boldSystemFont(ofSize: size)
            textColor = .black
        case .error:
            font = .boldSystemFont(ofSize: size)
            textColor = ._grayDark
            textAlignment = .center
        }
    }
    
    func changeText(_ t: String) {
        text = t
    }
    
    func changeColor(_ c: UIColor) {
        textColor = c
    }
}
```

BaseBoxItem.swift

```javascript
class BaseBoxItem: BaseView {
    let back = UIView()
    let sectionTitle = BaseLabel("", size: 14, type: .title)
    
    override func configureSubView() {
        self.addSubview(back)
        back.addSubview(sectionTitle)
    }
    
    override func configureLayout() {
        back.snp.makeConstraints {
            $0.top.equalTo(self.safeAreaLayoutGuide).inset(12)
            $0.bottom.equalTo(self.safeAreaLayoutGuide)
            $0.horizontalEdges.equalTo(self.safeAreaLayoutGuide).inset(16)
        }
        sectionTitle.snp.makeConstraints {
            $0.top.equalTo(back.safeAreaLayoutGuide).inset(8)
            $0.horizontalEdges.equalTo(back.safeAreaLayoutGuide).inset(20)
            $0.height.equalTo(22)
        }
    }
    
    override func configureUI() {
        back.backgroundColor = .systemGray6
        back.layer.cornerRadius = 8
    }
}
```

ViewController에서 컨트롤할 뷰 객체 전체를 감싸는 mainView를 분리하였다. VC에서는 해당 전체 뷰 객체의 인스턴스를 만들어서 loadView 메서드 내부에서 기존의 root view를 mainView로 대치하여 사용했다. 즉, 커스텀한 뷰로 nil 상태의 root view를 대체해주는 것이 [loadView](https://developer.apple.com/documentation/uikit/uiviewcontroller/1621454-loadview/)의 역할인 것이다.

```javascript
class SomeViewController: BaseViewController {
    private let mainView = SomeMainView()

    override func loadView() {
        self.view = mainView
    } 

    override func viewDidLoad() {
        super.viewDidLoad()
        mainView.label.xxx // 변경된 뷰 객체의 속성들에 접근하는 형식으로 코드를 작성한다.
    }
}
```

- loadView 메서드를 오버라이드 할 때 주의해야 할 점은 (어떻게 보면 당연한 이야기 일 수 있는데) super의 loadView 구현부를 상속받지 않는 것이다.

- ViewController에서 View의 레이아웃이나 UI 코드에 대한 관심사를 분리시킬 수 있어 아주 유용해 보인다.

- 다만, 뷰 컴포넌트들의 구조가 복잡해지거나, 한 페이지에 정말 다양한 뷰 요소들이 들어가는 앱이라면 클래스의 뎁스를 많이 두는 것이 코드를 유지보수 하는 관점에서 좋을지는 계속 고민해볼 필요가 있을 것 같다.

    - 특히, 뷰를 그리기 위한 데이터를 넘겨줄 때, 뷰에 대한 접근 뎁스가 깊어지면 깊어질 수록 번거러워질 수 있을 것 같다.

### 싱글턴 패턴+API Route 패턴으로 네트워크 통신 서비스 로직 구성하기

TMDB API를 이용해서 영화와 관련된 다양한 정보를 받아오기 위해서 여러 엔드포인트로 서로 다른 요청을 보내야 했다. 요청에 따라 엔드포인트가 달라야했고, 영화 id를 요청 url path값에 반영해야 하는 경우도 있었다. 그래서 이런 API 네트워크 통신을 담당해줄 APIService를 싱글턴 패턴으로 만들어 반복적인 코드 작성을 단순하게 관리할 수 있었다.

```javascript
class APIService {
    private init() {}
    
    static let manager = APIService()
    
    private let base = BASE
    private let headers = HEADERS
    
    enum ServiceType {
        case current
        case trend
        case search(query: String)
        case detail(id: Int)
    }
    
    private func serviceEndPoint(_ type: ServiceType) -> String {
        switch type {
        case .current:
            return "movie/now_playing"
        case .trend:
            return "trending/movie/day"
        case .search(let query):
            return "search/movie?query=\(query)"
        case .detail(let id):
            return "movie/\(String(id))"
        }
    }
    
    private func serviceMethod(_ type: ServiceType) -> HTTPMethod {
        switch type {
        case .current, .trend, .search, .detail:
            return .get
        }
    }
    
    private func genRequest(_ type: ServiceType) -> DataRequest {
        return AF.request(
            self.base + self.serviceEndPoint(type),
            method: self.serviceMethod(type),
            parameters: ["language": "ko-KR"],
            encoding: URLEncoding(destination: .queryString),
            headers: self.headers
        )
    }
    
    typealias SuccessHandler<T: Decodable> = (T) -> ()
    typealias ErrorHandler = (any Error) -> ()
    
    func fetch<T: Decodable>(
      _ serviceType: ServiceType, 
      successHandler: @escaping SuccessHandler<T>, 
      errorHandler: @escaping ErrorHandler) {
        
        self.genRequest(serviceType).responseDecodable(of: T.self) { response in
            do {
                let data = try response.result.get()
                successHandler(data)
            } catch {
                errorHandler(error)
            }
        }
      
    }
}
```

- `manager`라고 하는 타입 멤버에 클래스 인스턴스 자체를 담아서 외부에서 쉽게 내부 메서드에 접근할 수 있도록 했다.

    - Swift 프로젝트에서는 이런 멤버를 보통 `shared`라고 명칭하는 것 같았다.

- 내부에 ServiceType 이라고하는 열거형을 두어서 해당 열거형의 케이스별로 내부 로직들이 쉽게 분기를 태울 수 있도록 설정했다. 

    - 케이스를 요청 타입별로 구분해서 Endpoint를 맵핑할 수 있게하고, 열거형 케이스에 연관값을 부여해서 요청 타입별로 영화 아이디를 쉽게 받아올 수 있도록 설정했다. `case search(query: String)`

- Alamofire에서 제공하는 AF.request 메서드의 반환값을 미리 맵핑해서 반환하는 메서드도 구성해봤다.

- 외부에서 접근할 수 있는 유일한 메서드는 fetch<T: Decodable> 이다. 

    - 받아올 데이터 형식이 Decodable 프로토콜을 채용한 경우만 활용할 수 있도록 제네릭으로 감싸줬다.

    - 내부에서 요청에 따른 처리를 do - catch 구문으로 감싸서 에러 핸들링을 할 수 있도록 구현했다.
    - (Alamofire의 response.result.get 메서드가 throws 메서드라 try로 에러를 잡을 수 있었다.)

    - fetch 메서드의 인자로 각각 successHandler, errorHandler를 클로저로 받을 수 있게 했다. 데이터 fetching이 성공할 경우 do 구문에서 외부의 successHandler가 내부 컨텍스트에 접근할 수 있도록 `@escaping` 컨트리뷰트를 잊지 않았다.

### DispatchGroup을 이용해서 테이블뷰+컬랙션뷰 조합의 뷰에 데이터 반영하기

위에서 만든 APIService로 데이터를 fetching해오고, 
이전에 기록해두었던 DispatchQueue와 [DispatchGroup](https://slashpage.com/hankyeol/7916x82rq3g67m4kpyg3)으로 비동기+동시, 비동기+직렬의 방식을 컨트롤하는 방법으로 메인 페이지에서 테이블 뷰 안의 가로 스크롤을 가진 컬랙션 뷰를 핸들링 할 수 있었다.

테이블 뷰의 각 셀에 서로 다른 API 요청으로 서로 다른 데이터를 반영한 가로 스크롤이 있는 컬랙션 뷰를 넣어줘야 했다. 그래서, 데이터를 받아오는 각각의 비동기 로직이 끝나는 시점을 캐치해서 view가 한 번에 메인 스레드에서 업데이트 될 수 있도록 반복문 내에서 DispatchGroup이 일을 하도록 했다.

```javascript
override func viewDidLoad() {
    super.viewDidLoad()
    fetchData()
}

func fetchData() {
    let group = DispatchGroup()
  
    [.current, .trend, .topRated].enumerated().forEach { (index, type) in
            group.enter()
            DispatchQueue.global().async(group: group) {
                APIService.manager.fetch(type) { (data: Movie) in
                    self.listDatas[index] = data.results
                    group.leave()
                } errorHandler: { error in
                    group.leave()
                }

            }
        }
        
    group.notify(queue: .main) {
        self.mainView.table.reloadSections(IndexSet(integer: 0), with: .none)
    }
}
```

- API 요청을 보낼 요청 타입 배열을 순회하면서 각 요청 타입을 APIService의 fetch 메서드에 넣어준다.

- 응답 결과에 따라서 successHandler, errorHandler 클로저가 각각 실행될 수 있게 구문을 작성해준다.

- `group.enter()`, `group.leave()` 메서드로 동일 그룹으로 묶인 비동기 처리 로직의 시작/종료를 notify 메서드가 감지할 수 있게 해준다. 

- `group.notify()` 메서드로 그룹의 모든 비동기 처리가 종료되면 .main 스레드를 호출해서 UI를 업데이트 해주는 형식으로 한 번에 뷰가 업데이트 되는 것처럼 보여준다.

간단한 영화 앱을 통해서 비동기를 다루는 방법과 BaseView를 통해 뷰와 로직을 쉽게 분리시키는 방법에 대해 고민해볼 수 있었다. CoreData, SwiftData와 같은 로컬 데이터베이스를 다루는 기술과 async-await Task를 조금 더 익숙하게 다룰 수 있으면 해당 개념들도 이 프로젝트에 적용해볼 수 있도록 해나갈 예정이다.

For the site tree, see the [root Markdown](https://slashpage.com/hankyeol.md).
