안녕하세요. Deah 입니다.
뭅챠 프로젝트를 시작한지도 3주 정도가 흘렀네요! (물론 작업 일자로 따지면 일주일 정도지만ㅋㅋ)
지난 작업들까지는 화면을 구현하고 기능을 연결하는 작업들이 거의 대부분이었던 거 같아요. 프로젝트에서 다루는 화면이 늘어날수록 화면을 구성하는 configuration 함수 실행 로직들이 동일하게 반복되거나, 테이블 뷰나 컬렉션 뷰에 사용할 셀을 만들 때에도 init , required init 함수가 계속 반복되고 있는 상태입니다.
그리고 API 통신을 할 때도 Alamofire 라이브러리를 통해 네트워크 요청을 보내고 응답값을 활용하는 부분이 매 통신마다 반복되고 있어요.
오늘은 이러한 부분들을 점검해보고, 범용적으로 사용할 수 있도록 리팩토링하여 코드의 중복을 최소화 해보려고 합니다.
✨ 작업일시: 2024-06-26 (Wed)
현재 뭅챠 프로젝트의 모든 ViewController는 viewDidLoad 시점에 다양한 configuration 함수들이 실행되고 있습니다. 매번 같은 시점에 같은 이름의 함수를 호출하고 있고, 함수명도 대부분 동일해요.
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 접근을 통해서 배경 색상을 처리해줄 수 있습니다.
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와 데이터를 다루는 일들을 함께 해주었는데요. 단순히 화면을 구현하는 작업만 하는 코드 친구들을 따로 분리해주려고 해요.
가장 간단하게 처리해줄 수 있는 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에서 실행해주고 있기 때문에 다시 코드를 작성해줄 필요가 없게 되는 거랍니다.
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 코드 중복을 줄여보겠습니다.
기존에는 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를 구분할 수 있도록 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를 모두 열거형으로 구분하여 쉽게 사용할 수 있도록 했어요.
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
그럼 다음 편으로 또 돌아오겠습니다! 🙋♀️