[뭅챠! 개발일지] DAY 8 : 제네릭(Generic)과 Base 코드를 활용한 중복 최소화

Deah (김준희)·2024년 6월 28일
0
post-thumbnail

안녕하세요. Deah 입니다.

뭅챠 프로젝트를 시작한지도 3주 정도가 흘렀네요! (물론 작업 일자로 따지면 일주일 정도지만ㅋㅋ)

지난 작업들까지는 화면을 구현하고 기능을 연결하는 작업들이 거의 대부분이었던 거 같아요. 프로젝트에서 다루는 화면이 늘어날수록 화면을 구성하는 configuration 함수 실행 로직들이 동일하게 반복되거나, 테이블 뷰나 컬렉션 뷰에 사용할 셀을 만들 때에도 init , required init 함수가 계속 반복되고 있는 상태입니다.

그리고 API 통신을 할 때도 Alamofire 라이브러리를 통해 네트워크 요청을 보내고 응답값을 활용하는 부분이 매 통신마다 반복되고 있어요.

오늘은 이러한 부분들을 점검해보고, 범용적으로 사용할 수 있도록 리팩토링하여 코드의 중복을 최소화 해보려고 합니다.

작업일시: 2024-06-26 (Wed)

Base 코드 만들기

현재 뭅챠 프로젝트의 모든 ViewController는 viewDidLoad 시점에 다양한 configuration 함수들이 실행되고 있습니다. 매번 같은 시점에 같은 이름의 함수를 호출하고 있고, 함수명도 대부분 동일해요.

BaseViewController

import UIKit

class BaseViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
    
        configureHierarchy()
        configureLayout()
        configureUI()
    }
    
    // 계층
    func configureHierarchy() { }
    
    // 레이아웃
    func configureLayout() { }
    
    // 디자인 & 변하지 않는 데이터 바인딩
    func configureUI() {
        view.backgroundColor = Constants.Color.Primary.white
    }
    
}

UIViewController를 상속받는 BaseViewController 파일을 생성해서 반복 사용되는 로직을 넣어주었습니다. viewDidLoad 시점에 configuration 함수들이 호출될 수 있도록요!

그리고 configureUI 함수에서는 기본적으로 해당 뷰의 backgroundColor를 지정해주는 코드를 넣어두었습니다. 이렇게 해주면 추후 BaseViewController를 상속받도록 처리할 때 해당 뷰컨의 viewDidLoad 시점에 모든 함수의 실행 처리를 해줄 필요가 없어지고, configureUI 함수에서는 super 접근을 통해서 배경 색상을 처리해줄 수 있습니다.

BaseView

import UIKit

class BaseView: UIView {
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        configureViewHierarchy()
        configureViewLayout()
        configureViewUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    func configureViewHierarchy() { }
    
    func configureViewLayout() { }
    
    func configureViewUI() { }
    
}

BaseView도 만들어주겠습니다.

지금까지는 ViewController 안에서 뷰 객체를 생성하고 UI와 데이터를 다루는 일들을 함께 해주었는데요. 단순히 화면을 구현하는 작업만 하는 코드 친구들을 따로 분리해주려고 해요.

Base 코드 적용

가장 간단하게 처리해줄 수 있는 HomeViewController에 만들어두었던 Base 코드를 적용해보겠습니다.

현재 HomeViewController에는 mainTitle 레이블 객체 하나만 있는 상태입니다. 그래서 변경된 구조 파악을 더 쉽게 하실 수 있으실 거 같아요!

  • 기존
import UIKit
import SnapKit

class HomeViewController: UIViewController {

    let mainTitle = UILabel()
    
    override func viewDidLoad() {
        super.viewDidLoad()

        configureHierarchy()
        configureLayout()
        configureUI()
        configureData()
        setBarButtons()
    }

    func configureHierarchy() {
        let subviews = [mainTitle]
        subviews.forEach {
            view.addSubview($0)
        }
    }

    func configureLayout() {
        mainTitle.snp.makeConstraints { make in
            make.top.equalTo(view.safeAreaLayoutGuide)
            make.leading.equalTo(view.safeAreaLayoutGuide).offset(16)
            make.height.equalTo(50)
        }
    }

    func configureUI() {
        mainTitle.font = .systemFont(ofSize: 40, weight: .black)
    }

    func configureData() {
        mainTitle.text = Constants.Text.Title.home
    }

    func setBarButtons() {
        addImgBarBtn(title: nil, image: Constants.SystemImage.search, target: self, action: #selector(searchBtnClicked), type: .right, color: Constants.Color.Primary.pink)
    }
    
    @objc func searchBtnClicked() {
        navigationController?.pushViewController(SearchViewController(), animated: true)
    }
    
}

기존에는 뷰 객체 선언, 레이아웃 잡기, UI 설정 함수, 이벤트 핸들러 등이 모두 선언되어있는 상태라는게 보이시죠? 👀

  • 적용 후
import UIKit
import SnapKit

class HomeView: BaseView {
    
    // 메인 타이틀
    let mainTitle = UILabel()
    
    override func configureViewHierarchy() {
        self.addSubview(mainTitle)
    }
    
    override func configureViewLayout() {
        mainTitle.snp.makeConstraints {
            $0.top.equalTo(self.safeAreaLayoutGuide)
            $0.leading.equalTo(self.safeAreaLayoutGuide).offset(16)
            $0.height.equalTo(50)
        }
    }
    
    override func configureViewUI() {
        mainTitle.font = Constants.Font.title
        mainTitle.text = Constants.Text.Title.home
    }
    
}
import UIKit

class HomeViewController: BaseViewController {
    
    let homeView = HomeView()
    
    override func loadView() {
        self.view = homeView
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        setBarButtons()
    }
    
    func setBarButtons() {
        addImgBarBtn(title: nil, image: Constants.SystemImage.search, target: self, action: #selector(searchBtnClicked), type: .right, color: Constants.Color.Primary.pink)
    }

    @objc func searchBtnClicked() {
        navigationController?.pushViewController(SearchViewController(), animated: true)
    }
    
}

변경 후에는 UI를 담당할 HomeView 파일을 생성해서 BaseView를 상속받을 수 있도록 만들었습니다. (BaseView가 UIView를 상속 받고 있어요!)

그리고 화면을 구성할 뷰 객체들을 선언하고 초기화 설정을 하는 코드를 모두 HomeView로 옮겨주었어요. HomeViewController에서는 HomeView의 인스턴스를 만들어 loadView 내에서 뷰를 바꿔주고, 데이터를 수정해야하는 등 HomeView에 있는 뷰 객체에 접근해야 할 때에는 인스턴스로 생성한 homeView를 통해 접근할 수 있습니다.

homeView.mainTitle.text = "변경할 문구를 여기에~!"

Base 코드를 적용하니 viewDidLoad 시점에 매번 반복되던 configuration 함수 실행 코드가 없어진게 보이시나요? 상속 받고있는 BaseViewController에서 실행해주고 있기 때문에 다시 코드를 작성해줄 필요가 없게 되는 거랍니다.

BaseCell

import UIKit

class BaseCollectionViewCell: UICollectionViewCell {
    
    override init(frame: CGRect) {
        super.init(frame: frame)
        
        configureCellHierarchy()
        configureCellLayout()
        configureCellUI()
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // 계층
    func configureCellHierarchy() { }
    
    // 레이아웃
    func configureCellLayout() { }
    
    // 디자인 & 변하지 않는 데이터
    func configureCellUI() { }
}

뭅챠에서는 검색 결과나 추천 콘텐츠 등을 노출할 때 컬렉션 뷰를 많이 사용하고 있어요. 컬렉션 뷰에서 사용할 셀도 UICollectionViewCell을 상속 받는 BaseCollectionViewCell을 만들어 적용해줄 수 있습니다.


API 통신 로직에 제네릭 활용하기

이제 API 코드 중복을 줄여보겠습니다.
기존에는 NetworkManager에서 각 API마다 호출하는 함수를 생성해준 모습이었어요.

class NetworkManager {

    static let shared = NetworkManager()

    private init() {}

    let headers: HTTPHeaders = [
        "Authorization": // API.KEY
        "accept": "application/json"
    ]


    // 검색
    func getSearchContents(type: Int, query: String, completionHandler: @escaping ([SearchResults]) -> Void) {
        var URL = ""

        switch type {
        case 0:
            URL = "\(API.URL.TMDB.Search.movie)\(query)"
            break
        case 1:
            URL = "\(API.URL.TMDB.Search.tv)\(query)"
            break
        case 2:
            URL = "\(API.URL.TMDB.Search.person)\(query)"
            break
        default:
            print("검색 카테고리 선택 오류")
        }

        AF.request(URL, headers: headers)
            .responseDecodable(of: Search.self) { res in
            switch res.result {
            case .success(let value):
                    print("검색 성공")
                    print(value.results)
                    completionHandler(value.results)
            case .failure(let error):
                    print("검색 실패")
                    print(error)
            }
        }
    }
    
    .
    .
    .
    
}

하지만 이렇게되면 각 API 호출 함수마다 Alamofire를 통해 구현하는 로직이 계속 반복되게 됩니다.

  • AF.request(...).responseDecodable(...) → 이 부분이 반복!

그래서 공통 로직을 만들고 바뀌어야 하는 부분을 제네릭을 활용할 수 있도록 변경해보려고 합니다.

API 구분하기

API를 구분할 수 있도록 Enum과 연관값을 활용하는 파일을 만들어주겠습니다.
TMDb API를 사용할 때 영화와 TV 두 종류로 구분하는 경우와 영화인까지 합해 총 3종류로 구분하는 API가 달라서, 2가지의 타입을 활용해볼게요!

(TwoType, ThreeType 하기 싫어서 GenreType, SearchType으로 해놨는데 직관적이지 않는단 생각이 드네요 ㅎ_ㅎ..)

import Foundation
import Alamofire

enum GenreType: String {
    case movie = "movie"
    case tv = "tv"
}

enum SearchType: String {
    case movie = "movie"
    case tv = "tv"
    case person = "person"
}

enum TmdbAPI {
    case trending
    case genre(type: GenreType)
    case search(type: SearchType, query: String)
    case similar(type: GenreType, id: Int)
    case recommend(type: GenreType, id: Int)
    case image
    
    var headers: HTTPHeaders {
        return [
            "Authorization": API.KEY.tmdb,
            "accept": "application/json"
        ]
    }
    
    var baseURL: String {
        return API.URL.TMDB.base
    }
    
    var endPoint: URL {
        switch self {
        case .trending:
            return URL(string: baseURL + API.URL.TMDB.Trending.all)!
        case .genre(let type):
            return URL(string: baseURL + "genre/\(type.rawValue)/list")!
        case .search(let type, _):
            return URL(string: baseURL + "search/\(type.rawValue)")!
        case .similar(let type, let id):
            return URL(string: baseURL + "\(type.rawValue)/\(id)/similar")!
        case .recommend(let type, let id):
            return URL(string: baseURL + "\(type.rawValue)/\(id)/recommendations")!
        case .image:
            return URL(string: API.URL.TMDB.img)!
        }
    }
    
    var method: HTTPMethod {
        return .get
    }
    
    var params: Parameters {
        switch self {
        case .trending, .genre, .similar, .recommend:
            return ["language": "ko-KR"]
        case .search(_, let query):
            return [
                "language": "ko-KR",
                "include_adult": false,
                "query": query
            ]
        case .image:
            return ["": ""]
        }
    }
    
}

이렇게 BaseURL과 각 API의 엔드포인트, HTTP Method, Headers, Parameters를 모두 열거형으로 구분하여 쉽게 사용할 수 있도록 했어요.

NetworkManager

API를 구분할 수 있게 만들어두었으니 이제 써봐야겠죠 🤧
NetworkManager에서는 getSearch~, getTrending~ 이런 식으로 모두 각각의 함수로 구분되어 있었다고 말씀 드렸었습니다. 이 부분을 하나로 통일시켜줄게요!


import Foundation
import Alamofire

class NetworkManager {
    
    static let shared = NetworkManager()
    
    private init() {}
    
    let headers: HTTPHeaders = [
        "Authorization": API.KEY.tmdb,
        "accept": "application/json"
    ]
    
    typealias completion = (T?, String?) -> Void)
    func callRequest<T: Decodable>(api: TmdbAPI, completionHandler: @escaping completion {
        AF.request(api.endPoint,
                   method: api.method,
                   parameters: api.params,
                   encoding: URLEncoding(destination: .queryString),
                   headers: api.headers
        ).responseDecodable(of: T.self) { res in
            switch res.result {
            case .success(let value):
                completionHandler(value, nil)
            case .failure(let error):
                completionHandler(nil, error.errorDescription?.debugDescription)
            }
        }
    }

}

callRequest 함수를 만들어 매개변수로 api를 받도록 해주었습니다. 그리고 api 타입을 아까 만들어둔 TMDbAPI 열거형으로 설정했어요. 그리고 callRequest는 탈출 클로져를 통해 성공값과 실패값을 반환해줄 거기 때문에, 제네릭을 통해 네트워크 통신이 성공했을 때 응답 데이터를 디코딩해줄 구조체를 받을 수 있도록 하였습니다. (Error에 관련한 부분도 제네릭으로 받을 수 있지만 우선은 String으로 처리할게요!)

사용해보기

  • 기존
func callRequest() {
    let header: HTTPHeaders = [
        "Authorization": API.KEY.tmdb,
        "accept": "application/json"
    ]
        
    AF.request(API.URL.TMDB.Trending.all,
               method: .get,
               encoding: JSONEncoding.default,
               headers: header)
    .responseDecodable(of: Trending.self) { res in
        switch res.result {
        case .success(let value):
            // ...
        case .failure(let error):
            // ...
        }
    }
}
  • 변경 후
func callRequest() {
	NetworkManager.shared.callRequest(api: .trending) { (trendingList: Trending? , error: String?) in
        guard error == nil else {
           // 예외처리
           return
        }
            
        guard let trendingList = trendingList else {
            // 예외처리
            return
        }

        self.trendingList = trendingList.results
        self.trendingTableView.reloadData()
    }
}

매변 성공/실패에 대한 핸들을 하거나 매번 Header, Parameters를 적용해주지 않아도 한 번에 처리해줄 수 있고 코드 가독성도 훨씬 좋아졌습니다. 😆


마무리

오늘은 Base코드와 제네릭을 통해 중복을 줄이는 작업을 해보았어요.
습관적으로 술술 써내려갔던 코드들을 익숙하지 않은 시선으로 바라보고, 어떻게 하면 더 효율적으로 작성할 수 있을지 고민해볼 수 있는 시간이었던 거 같습니다.

특히 네트워크 통신 로직을 재구성할 때 싱글톤 패턴이나 제네릭을 활용하면서 모듈화, 재사용, 아키텍쳐에 대한 이해가 부족해 이 부분을 보완해야겠다는 생각도 들었네요 (ㅎㅎ)

혹시라도 코드가 궁금하신 분들은 아래 링클르 참고해 주세용.
피드백 할 부분이 보인다면 댓글 남겨주세요...S2

뭅챠(MOVCHA) 전체 코드

그럼 다음 편으로 또 돌아오겠습니다! 🙋‍♀️

profile
기록 중독 개발자의 기록하는 습관

0개의 댓글