언제 한번 리팩토링을 수행하고 그 전후 차이를 비교하는 글을 남기고 싶었는데, 때가 온 것 같다.

수 개월에 걸친 개인 프로젝트를 완료하고 심사를 위해 앱을 제출한 상태에서 더러워진 코드를 정리하기 위해 리팩토링을 수행하려 한다. 그리고 그 전후 차이를 기록으로 남긴다. 

 

 

리팩토링 전

일단 매우 보기 싫은 리팩토링 전의 코드는 아래와 같다: (255줄)

import UIKit
import Firebase
import CodableFirebase
import CoreLocation


class ListStoreViewController: UIViewController {

    @IBOutlet weak var storeTableView: UITableView!
    @IBOutlet weak var categoryCollectionView: UICollectionView!
    @IBOutlet weak var titleLabel: UILabel!
    
    var ref: DatabaseReference!
    let storage = Storage.storage()
    let categories: [Category] =
        [Category.all,
         Category.koreanAsian,
         Category.japaneseCutlet,
         Category.chinese,
         Category.western,
         Category.chickenPizza,
         Category.bunsik,
         Category.mexican,
         Category.fastFood,
         Category.meat,
         Category.cafe]
    
    
    var stores = [Store]()
    var filteredStores = [Store]()
    var countForJustExecuteOnce: Bool = false
    
    let refreshControl = UIRefreshControl()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        LoadingService.showLoading()
        
        self.storeTableView.delegate = self
        self.storeTableView.dataSource = self
        self.categoryCollectionView.delegate = self
        self.categoryCollectionView.dataSource = self
        
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(self.didReceiveCategorySelectedNotification(_:)),
                                               name: Notification.Name("categorySelected"), object: nil)
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(self.didReceiveFilterSelectedNotification(_:)),
                                               name: Notification.Name("filterSelected"), object: nil)
        ref = Database.database(url: "https://restaurants-e62b0-default-rtdb.asia-southeast1.firebasedatabase.app").reference()

        self.refreshControl.attributedTitle = NSAttributedString(string: "")
        self.refreshControl.addTarget(self, action: #selector(onPullToRefresh), for: .valueChanged)
        self.storeTableView.addSubview(refreshControl)
        
        getStoresInfoFromDatabase()
        setCollectionViewLayout()
    }
    
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        setUI() // view will appear에 두는 이유는 다음 화면에서 다시 돌아올 때를 대비하기 위해서.
        NotificationCenter.default.post(name: Notification.Name("changeToCurrentFilter"),
                                        object: nil)
    }
    

    @objc func didReceiveCategorySelectedNotification(_ noti: Notification) {
        guard let category = noti.userInfo?["category"] as? String,
              let currentCategory = self.titleLabel?.text
        else { print("can't handle didReceiveCategory Notification"); return; }
        
        if category != currentCategory { executeCategoryFiltering(category) }
        
        DispatchQueue.main.async { self.titleLabel.text = category }
    }
    
    private func executeCategoryFiltering(_ category: String) {
        switch category {
        case Category.all.rawValue:
            self.filteredStores = self.stores.sorted(by: { $0.storeInfo.createDate < $1.storeInfo.createDate })
        case Category.koreanAsian.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.koreanAsian.rawValue })
        case Category.japaneseCutlet.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.japaneseCutlet.rawValue })
        case Category.chinese.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.chinese.rawValue })
        case Category.western.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.western.rawValue })
        case Category.chickenPizza.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.chickenPizza.rawValue })
        case Category.bunsik.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.bunsik.rawValue })
        case Category.mexican.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.mexican.rawValue })
        case Category.fastFood.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.fastFood.rawValue })
        case Category.meat.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.meat.rawValue })
        case Category.cafe.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.cafe.rawValue })
        default:
            self.filteredStores = self.stores.sorted(by: { $0.storeInfo.createDate < $1.storeInfo.createDate })
            print("in default category filter")
        }
        
        DispatchQueue.main.async {
            NotificationCenter.default.post(name: Notification.Name("categoryDidChange"),
                                            object: nil,
                                            userInfo: nil)
            self.storeTableView.reloadData()
        }
    }
    
    @objc func didReceiveFilterSelectedNotification(_ noti: Notification) {
        guard let filter = noti.userInfo?["filter"] as? String
        else { print("can't handle didReceiveFilter noti"); return }
        
        switch filter {
        case Filter.latest.filterName:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.createDate > $1.storeInfo.createDate })
        case Filter.nearest.filterName:
            self.filteredStores = self.filteredStores.sorted(
                by: { self.getDistanceFromCurrentLocation($0.storeInfo.latitude, $0.storeInfo.longtitude) <
                    self.getDistanceFromCurrentLocation($1.storeInfo.latitude, $1.storeInfo.longtitude) })
        case Filter.ratingHigh.filterName:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.rating > $1.storeInfo.rating })
        case Filter.reviewCount.filterName:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.commentCount > $1.storeInfo.commentCount })
        case Filter.oldest.filterName:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.createDate < $1.storeInfo.createDate })
        case Filter.ratingLow.filterName:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.rating < $1.storeInfo.rating })
        default:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.createDate > $1.storeInfo.createDate })
            print("default filter notification in")
        }
        
        DispatchQueue.main.async { self.storeTableView.reloadData() }
    }
    
    private func getDistanceFromCurrentLocation(_ targetLatitude: Double, _ targetLongitude: Double) -> CLLocationDistance {
        let currentLocationLatitude = UserDefaults.standard.double(forKey: "currentLocationLatitude")
        let currentLocationLongitude = UserDefaults.standard.double(forKey: "currentLocationLongitude")
        
        let targetLocation = CLLocationCoordinate2D(latitude: targetLatitude, longitude: targetLongitude)
        let currentLocation = CLLocationCoordinate2D(latitude: currentLocationLatitude, longitude: currentLocationLongitude)
        
        return targetLocation.distance(from: currentLocation)
    }
    
    private func executeFilterCategoryFiltering(_ filter: String) {
        
    }

    private func setUI() {
        self.setStatusBarBackgroundColor()
        self.setNavigationBarBackgroundColor()
        self.storeTableView.backgroundColor = Config.shared.application60Color
        self.setNavigationBarHidden(isHidden: true)
        
    }

    private func setCollectionViewLayout() {
        if let layout = categoryCollectionView.collectionViewLayout as? UICollectionViewFlowLayout {
            //this value represents the minimum spacing between items in the same column.
            layout.minimumInteritemSpacing = 30
            //this value represents the minimum spacing between successive columns.
            layout.minimumLineSpacing = 30
            layout.sectionInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)
        }
    }
    
    @objc func onPullToRefresh() {
        if self.filteredStores.isEmpty == true {
            getStoresInfoFromDatabase()
        }
        DispatchQueue.main.async {
            self.refreshControl.endRefreshing()
        }
        
        
    }
    
    private func getStoresInfoFromDatabase() {
        self.ref.child("stores").observe(DataEventType.value, with: { (snapshot) in
            
            if snapshot.exists() {
                self.stores = []
                guard let value = snapshot.value else {return}
                do {
                    let storesData = try FirebaseDecoder().decode([String: StoreInfo].self, from: value)
                    
                    for storeData in storesData {
                        let store: Store = Store(storeKey: storeData.key, storeInfo: storeData.value)
                        self.stores.append(store)
                    }
                    self.filteredStores = self.stores.sorted(by: { $0.storeInfo.createDate < $1.storeInfo.createDate })
                    
                    DispatchQueue.main.async {
                        NotificationCenter.default.post(name: Notification.Name("changeToCurrentFilter"),
                                                        object: nil)
                    }
                } catch let err {
                    print(err.localizedDescription)
                }
            }
        })
    }
}

extension ListStoreViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.filteredStores.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = self.storeTableView.dequeueReusableCell(withIdentifier: StoreTableViewCell.reuseIdentifier) as? StoreTableViewCell
        else { return UITableViewCell() }
        
        let store = self.filteredStores[indexPath.row]
        
        cell.nameLabel?.text = store.storeInfo.name
        cell.addressLabel?.text = store.storeInfo.address
        cell.rateLabel?.text = "\(store.storeInfo.rating)(\(store.storeInfo.commentCount))"
        cell.categoryLabel?.text = store.storeInfo.category
        
        let storageRef = storage.reference()
        let reference = storageRef.child("\(store.storeInfo.mainImage)")
        let placeholderImage = UIImage(named: "placeholder.jpg")
        cell.storeImageView.sd_setImage(with: reference, placeholderImage: placeholderImage)
        
        hideLoadingWhenTableViewDidAppear(indexPath)
        return cell
    }
    
    private func hideLoadingWhenTableViewDidAppear(_ indexPath: IndexPath) {
        if indexPath.row == self.filteredStores.count - 1 { LoadingService.hideLoading() }
    }
}

extension ListStoreViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        StoreSingleton.shared.store = self.filteredStores[indexPath.row]
        
        guard let viewController = self.storyboard?.instantiateViewController(withIdentifier: "DetailViewController") as? DetailViewController
        else { return }
            
        tableView.deselectRow(at: indexPath, animated: true)
        self.navigationController?.pushViewController(viewController, animated: true)
    }
}

 

 

다들 공감하겠지만 처음부터 끝까지 읽기도 싫다. 그런데 이게 리팩토링을 한다고 읽기 좋을 지.. 한번 비교해보고 각자 판단해보자.

 

 

리팩토링 후

먼저 코드 줄 수는 얼마나 바뀌었을 것 같은가? 

10% 이상 줄었다, 20% 이상 줄었다 라는 생각을 했겠지만 그 반대다.

코드 줄 수는 263줄로 거의 변하지 않았다. 아니, 조금 더 늘었다.

 

 

그럼 대체 리팩토링을 왜 했냐고 물을 수도 있는데, 

그 의미의 첫 번째는 지저분한 코드들을 항목별로 묶어 함수화하여 가독성을 좋게 만들었다는 것이다.

예를 들어 viewDidLoad() 함수는 아래와 같이 변경됐다.

 

 

위 코드를 보면 대충 어떤 느낌으로 코드가 실행될지 감이 오지 않는가?

카테고리를 설정하고, 로딩서비스의 로딩을 표시한다. 그리고 DelegateDataSource를 세팅하고 Notification을 세팅하고..

카테고리가 뭔지, 로딩서비스가 뭔지 잘 몰라도 일단 어떤 흐름인지 충분히 파악이 된다. 

 

 

이렇게 함수들을 순서대로 써놓으면 아래처럼 함수들을 보기좋게 묶어놓을 수도 있다.

 

 

 

코드양은 오히려 늘었지만 가독성 측면에서 훨씬 나아졌다. 오류가 난 부분을 발견하고 해당 부분을 찾아내고, 언제 실행되는지 알아내고 고치는 데에 좀더 용이해지는 것이다. 물론 이를 위해서는 함수 하나가 한 가지 일만 해야 한다는 것이 전제되어야 한다. (언제나 그럴 수는 없겠지만.)

 

 

결론

파일 하나를 리팩토링하는 것으로 글을 썼기에 예상보다 훨씬 글이 짧았다. 

아무튼 결론을 내리자면 리팩토링은

- 1. 코드양에서 변화를 주는 것이 아니다.

- 2. 가독성을 높이는 행위이다. 

정도로 볼 수 있겠다. 그래서 유지보수하는 것을 좀더 용이하게 만들 수 있다.

 

여기에 더해 디자인 패턴 아키텍쳐를 적용한다면 유지보수하기가 더욱 수월해질 것이다.

 

 

 

* 리팩토링 후의 전체 코드

import UIKit
import Firebase
import CodableFirebase
import CoreLocation


class ListStoreViewController: UIViewController {

    @IBOutlet weak var storeTableView: UITableView!
    @IBOutlet weak var categoryCollectionView: UICollectionView!
    @IBOutlet weak var titleLabel: UILabel!
    
    var ref: DatabaseReference!
    let storage = Storage.storage()
    var categories: [Category] = []
    
    
    var stores = [Store]()
    var filteredStores = [Store]()
    var countForJustExecuteOnce: Bool = false
    
    let refreshControl = UIRefreshControl()

    override func viewDidLoad() {
        super.viewDidLoad()
        
        setCategories()
        LoadingService.showLoading()
        setDelegateDataSource()
        addNotifications()
        self.ref = Database.database(url: "https://restaurants-e62b0-default-rtdb.asia-southeast1.firebasedatabase.app").reference()
        addRefreshControl()
        getStoresInfoFromDatabase()
        setCollectionViewLayout()
    }
    
    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        
        setUI() // view will appear에 두는 이유는 다음 화면에서 다시 돌아올 때를 대비하기 위해서.
        adaptFilter()
    }
    
    private func setCategories() {
        self.categories = [Category.all,
                           Category.koreanAsian,
                           Category.japaneseCutlet,
                           Category.chinese,
                           Category.western,
                           Category.chickenPizza,
                           Category.bunsik,
                           Category.mexican,
                           Category.fastFood,
                           Category.meat,
                           Category.cafe]
    }
    
    private func setDelegateDataSource() {
        self.storeTableView.delegate = self
        self.storeTableView.dataSource = self
        self.categoryCollectionView.delegate = self
        self.categoryCollectionView.dataSource = self
    }

    private func addNotifications() {
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(self.didReceiveCategorySelectedNotification(_:)),
                                               name: Notification.Name("categorySelected"), object: nil)
        NotificationCenter.default.addObserver(self,
                                               selector: #selector(self.didReceiveFilterSelectedNotification(_:)),
                                               name: Notification.Name("filterSelected"), object: nil)
    }
    
    @objc func didReceiveCategorySelectedNotification(_ noti: Notification) {
        guard let category = noti.userInfo?["category"] as? String,
              let currentCategory = self.titleLabel?.text
        else { print("can't handle didReceiveCategory Notification"); return; }
        
        if category != currentCategory { executeCategoryFiltering(category) }
        DispatchQueue.main.async { self.titleLabel.text = category }
    }
    
    private func executeCategoryFiltering(_ category: String) {
        switch category {
        case Category.all.rawValue:
            self.filteredStores = self.stores.sorted(by: { $0.storeInfo.createDate < $1.storeInfo.createDate })
        case Category.koreanAsian.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.koreanAsian.rawValue })
        case Category.japaneseCutlet.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.japaneseCutlet.rawValue })
        case Category.chinese.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.chinese.rawValue })
        case Category.western.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.western.rawValue })
        case Category.chickenPizza.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.chickenPizza.rawValue })
        case Category.bunsik.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.bunsik.rawValue })
        case Category.mexican.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.mexican.rawValue })
        case Category.fastFood.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.fastFood.rawValue })
        case Category.meat.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.meat.rawValue })
        case Category.cafe.rawValue:
            self.filteredStores = self.stores.filter({ $0.storeInfo.category == Category.cafe.rawValue })
        default:
            self.filteredStores = self.stores.sorted(by: { $0.storeInfo.createDate < $1.storeInfo.createDate })
            print("in default category filter")
        }
        
        DispatchQueue.main.async {
            NotificationCenter.default.post(name: Notification.Name("categoryDidChange"),
                                            object: nil,
                                            userInfo: nil)
            self.storeTableView.reloadData()
        }
    }
    
    @objc func didReceiveFilterSelectedNotification(_ noti: Notification) {
        guard let filter = noti.userInfo?["filter"] as? String
        else { print("can't handle didReceiveFilter noti"); return }
        
        switch filter {
        case Filter.latest.filterName:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.createDate > $1.storeInfo.createDate })
        case Filter.nearest.filterName:
            self.filteredStores = self.filteredStores.sorted(
                by: { self.getDistanceFromCurrentLocation($0.storeInfo.latitude, $0.storeInfo.longtitude) <
                    self.getDistanceFromCurrentLocation($1.storeInfo.latitude, $1.storeInfo.longtitude) })
        case Filter.ratingHigh.filterName:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.rating > $1.storeInfo.rating })
        case Filter.reviewCount.filterName:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.commentCount > $1.storeInfo.commentCount })
        case Filter.oldest.filterName:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.createDate < $1.storeInfo.createDate })
        case Filter.ratingLow.filterName:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.rating < $1.storeInfo.rating })
        default:
            self.filteredStores = self.filteredStores.sorted(by: { $0.storeInfo.createDate > $1.storeInfo.createDate })
            print("default filter notification in")
        }
        
        DispatchQueue.main.async { self.storeTableView.reloadData() }
    }

    private func getDistanceFromCurrentLocation(_ targetLatitude: Double, _ targetLongitude: Double) -> CLLocationDistance {
        let currentLocationLatitude = UserDefaults.standard.double(forKey: "currentLocationLatitude")
        let currentLocationLongitude = UserDefaults.standard.double(forKey: "currentLocationLongitude")
        
        let targetLocation = CLLocationCoordinate2D(latitude: targetLatitude, longitude: targetLongitude)
        let currentLocation = CLLocationCoordinate2D(latitude: currentLocationLatitude, longitude: currentLocationLongitude)
        
        return targetLocation.distance(from: currentLocation)
    }
    
    private func addRefreshControl() {
        self.refreshControl.attributedTitle = NSAttributedString(string: "")
        self.refreshControl.addTarget(self, action: #selector(onPullToRefreshFetchDataWhenNoData), for: .valueChanged)
        self.storeTableView.addSubview(refreshControl)
    }
    
    @objc func onPullToRefreshFetchDataWhenNoData() {
        if self.filteredStores.isEmpty == true {
            getStoresInfoFromDatabase()
        }
        DispatchQueue.main.async {
            self.refreshControl.endRefreshing()
        }
    }
    
    private func getStoresInfoFromDatabase() {
        self.ref.child("stores").observe(DataEventType.value, with: { (snapshot) in
            
            if snapshot.exists() {
                self.stores = []
                guard let value = snapshot.value else {return}
                do {
                    let storesData = try FirebaseDecoder().decode([String: StoreInfo].self, from: value)
                    
                    for storeData in storesData {
                        let store: Store = Store(storeKey: storeData.key, storeInfo: storeData.value)
                        self.stores.append(store)
                    }
                    self.filteredStores = self.stores.sorted(by: { $0.storeInfo.createDate < $1.storeInfo.createDate })
                    
                    DispatchQueue.main.async {
                        NotificationCenter.default.post(name: Notification.Name("changeToCurrentFilter"),
                                                        object: nil)
                    }
                } catch let err {
                    print(err.localizedDescription)
                }
            }
        })
        
    }
    
    private func setCollectionViewLayout() {
        if let layout = categoryCollectionView.collectionViewLayout as? UICollectionViewFlowLayout {
            //this value represents the minimum spacing between items in the same column.
            layout.minimumInteritemSpacing = 30
            //this value represents the minimum spacing between successive columns.
            layout.minimumLineSpacing = 30
            layout.sectionInset = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20)
        }
    }
    
    private func setUI() {
        self.setStatusBarBackgroundColor()
        self.setNavigationBarBackgroundColor()
        self.storeTableView.backgroundColor = Config.shared.application60Color
        self.setNavigationBarHidden(isHidden: true)
        
    }
    
    private func adaptFilter() {
        NotificationCenter.default.post(name: Notification.Name("changeToCurrentFilter"),
                                        object: nil)
    }
    
}

extension ListStoreViewController: UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return self.filteredStores.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let cell = self.storeTableView.dequeueReusableCell(withIdentifier: StoreTableViewCell.reuseIdentifier) as? StoreTableViewCell
        else { return UITableViewCell() }
        
        let store = self.filteredStores[indexPath.row]
        
        cell.store = store
        
        // cell 안에 Firebase를 import하면 용량이 훨씬 커지지 않을까..
        let storageRef = storage.reference()
        let reference = storageRef.child("\(store.storeInfo.mainImage)")
        let placeholderImage = UIImage(named: "placeholder.jpg")
        cell.storeImageView.sd_setImage(with: reference, placeholderImage: placeholderImage)
        
        hideLoadingWhenTableViewDidAppear(indexPath)
        return cell
    }
     
    private func hideLoadingWhenTableViewDidAppear(_ indexPath: IndexPath) {
        if indexPath.row == self.filteredStores.count - 1 { LoadingService.hideLoading() }
    }
}

extension ListStoreViewController: UITableViewDataSource {
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        StoreSingleton.shared.store = self.filteredStores[indexPath.row]
        
        guard let viewController = self.storyboard?.instantiateViewController(withIdentifier: "DetailViewController") as? DetailViewController
        else { return }
            
        tableView.deselectRow(at: indexPath, animated: true)
        self.navigationController?.pushViewController(viewController, animated: true)
        
    }
}

 

  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기
// custom