Skip to content

Conversation

@MinwooJe
Copy link
Member

@MinwooJe MinwooJe commented May 7, 2025

배경

기존 메인화면의 코드들에는 다음과 같은 문제점들이 있었습니다.

  • 중복된 로직 다수 존재
  • 비슷한 기능을 하는 메서드 다수 존재
  • 코드가 너무 길고 복잡해 기능 추가 또는 유지 보수 시 어려움이 존재했고 에러 대응이 힘듦.
  • 뷰와 뷰 모델의 책임 분리가 모호
    따라서 중복된 코드들을 제거하고, 로직을 개선하였습니다.

작업 내용

1. 오류 수정

(1) 메인 화면 데이터 fetch 후 화면 갱신이 되지 않는 문제 해결

  • 기존 코드
    func bind(to viewModel: MainSceneViewModel) {
        viewModel.fetchPopupImagesErrorPublisher = { [weak self] in
            guard let self else { return }
            self.mainCollectionView.reloadData()
        }

        viewModel.fetchPopupImagesErrorPublisher = { [weak self] in
            guard let self else { return }
            DispatchQueue.main.async {
                self.mainCollectionView.reloadData()
            }
        }
    }

MainSceneViewController의 bind 메서드에서 화면을 갱신하는 publisher인 fetchPopupDataPublisher에 바인딩하지 않고, fetchPopupImagesErrorPublisher에 두 번 바인딩 했습니다. 저는 바보입니다.

따라서 원래 의도대로 fetchPopupDataPublisher에 바인딩을 하고, 메인 스레드에서 reloadData()를 호출하도록 변경했습니다.

  • 수정된 코드
    func bind(to viewModel: MainSceneViewModel) {
        viewModel.fetchPopupDataPublisher = { [weak self] in
            guard let self else { return }
            DispatchQueue.main.async {
                self.mainCollectionView.reloadData()
            }
        }

        viewModel.fetchPopupImagesErrorPublisher = { [weak self] in
            guard let self else { return }
            DispatchQueue.main.async {
                self.mainCollectionView.reloadData()
            }
        }
    }

(2) 메인화면에서 캐러셀 이미지를 잘못 불러오던 오류 해결

MainSceneViewModel의 provideCarouselImageUrl 메서드에서 mainSceneDataSource.item()를 호출하여 캐러셀 이미지 url을 받아왔습니다.
그러나 해당 메서드는 오늘의 추천 팝업(캐러셀 이미지) 데이터를 반환하는 메서드가 아니라, 찜 목록, 관심사, 지금 놓치면 안 될 팝업스토어의 데이터를 반환하는 함수입니다. 저는 바보입니다.
따라서 MainSceneDataSource에 캐러셀 이미지를 반환하는 getCarouselImageUrl() 메서드를 추가하여 해결했습니다.

2. MainSceneSection 열거형 도입

(1) 도입 이유

기존에는 섹션 번호(indexPath.section)를 기반으로 직접 분기하여 데이터를 처리하고 있었기 때문에 각 섹션이 어떤 역할을 하는지 명확히 표현하기 어려웠습니다.
따라서 찜 목록, 관심사, 지금 놓치면 안 될 팝업스토어 섹션을 명시적으로 표현할 수 있는 MainSceneSection 열거형을 도입하였습니다.

아래와 같이 도메인 모델에 PopupSectionCategory라는 열거형이 있습니다.

enum PopupSectionCategory {
    case todayRecommend
    case userPick
    case userInterest(InterestCategory?)
    case closingSoon
}

이 열거형을 사용할까 고민했지만, 현재 구조에서는 userInterest의 연관값으로 인덱스가 들어가는 것이 컬렉션 뷰 데이터 소스 로직 처리에 편리하기에 프레젠테이션 모델 느낌으로 새로 추가했습니다.

또한 MainSceneSection에는 todayRecommend가 필요없기에 현재 구조에서는 분리하는게 적절하다고 판단했습니다.

(2) 변경된 부분

1) sections 배열과 buildSections, updateSections

sections 배열은 오늘의 추천, 찜 목록, 관심사, 지금 놓치면 안될 팝업 섹션들 중 현재 화면에 표시될 섹션들을 담는 상태 변수입니다.
뷰 모델에서 네트워킹 작업(fetchPopupList()) 이후 섹션 상태를 갱신할 수 있도록 updateSections()메서드를 호출합니다.

이 과정에서 책임이 분리된 구조로 설계하였습니다.

  • 섹션 계산: buildSections()

  • 상태 변경: updateSections()

  • 네트워킹: fetchPopupList()

  • buildSections()는 순수하게 섹션 구성을 계산만 하므로 테스트가 용이합니다.

  • updateSections()는 상태를 변경하는 책임만 가집니다.

  • fetchPopupList()는 데이터 fetch 이후 updateSections() 메서드에 상태 변경을 위임합니다.

또한 MainSceneViewController에서는 sections라는 상태 변수를 유일하게 신뢰하게 되므로, MVVM의 "뷰는 뷰모델의 상태만 바라봐야 한다"는 원칙에 부합하도록 설계하였습니다.

3. MainSceneDataSource 구조 개선

MainSceneDataSource가 원래의 역할인 데이터를 저장하는 역할보다 더 많은 역할을 하게 되었습니다.
도메인 모델을 받아 뷰모델로 변경하는 역할을 하고 있었기에 코드가 복잡해졌었습니다.

따라서 MainSceneDataSource는 도메인 모델 타입으로 데이터를 저장하도록 하고, 데이터를 저장만 하도록 구조를 변경했습니다.

이에 따라 MainSceneViewModel이 도메인 모델에서 뷰 모델로 가공하도록 하고, 뷰에게 데이터를 전달하는 역할도 수행하도록 변경했습니다.

기존에는 뷰 컨트롤러에서 MainSceneViewModel.getDataSource()를 호출하여 뷰 컨트롤러가 데이터 소스에 직접 접근해 데이터를 받아왔습니다.
이 구조에서는 뷰 컨트롤러는 MainSceneViewModel도 알아야하고, MainSceneDataSource도 알아야하기 때문에 의존성을 줄일 필요가 있었습니다.
따라서 뷰 컨트롤러가 데이터 소스에 접근하기 위해서는 MainSceneViewModel에 접근해 MainSceneDataSource에 간접 접근하도록 구조를 변경했습니다.
이를 통해 뷰 컨트롤러는 MainSceneDataSource의 구현과 관계없이 데이터를 받아올 수 있게 되었습니다.

3. UICollectionView DataSource 메서드 로직 개선

MainSceneViewControllerUICollectionView DataSource 메서드들은 중복된 코드도 많고, 길고 매우 복잡했습니다.
이를 개선하기 위해 다음의 작업을 진행했습니다.

  • 중복된 이미지 로딩 로직을 PopupPreviewViewDataconvertToImage 메서드를 추가해 개선
  • 셀의 데이터 구성(configureData) 책임을 뷰 컨트롤러가 아닌 셀에게 위임
  • PickOrInterestCellClosingSoonPopupCell의 공통 기능인 configureContents를 추상화하는 프로토콜을 정의해 cellForItemAt 메서드 간소화

(1) 중복된 이미지 로딩 로직 개선

기존에는 configurePickOrInterestCell, configureClosingSoonPopupCell 등 여러 셀 구성 메서드에 이미지 로딩 로직이 중복되어 존재했습니다.
따라서 각각의 메서드마다 fetchImage → Result 처리 → 이미지 fallback → main thread 업데이트 로직이 반복되어 가독성과 유지보수성 저하되었습니다.

이를 해결하기 위해
PopupPreviewViewData에 convertToImage(from:) 메서드를 추가하여 이미지 처리 로직을 ViewModelData 객체로 이동시켰습니다.
ViewController에서는 fetchImage 호출 후 convertToImage만 사용하도록 하여 책임을 분리하고 로직을 간결하게 했습니다.

convertToImage 메서드는 이미지 fetch의 result: <Data, ImageFetchError>를 파라미터로 받아 내부에서 성공, 실패를 처리합니다.

PopupPreviewViewData의 익스텐션을 MainSceneViewController에서 선언한 이유는 다음과 같습니다.
UIImage를 반환하기 위해 UIKit이 필요 -> PopupPreviewViewData가 선언된 MainSceneViewModel에서 선언하면 뷰 모델이 UI 프레임워크에 의존하게 됨.
-> 따라서 뷰 컨트롤러 파일 내부에서 선언해 해당 메서드는 UIKit 전용임을 명시하고자.. 했습니다!

  • 변경 전
     private func configurePickOrInterestCell(
        _ collectionView: UICollectionView,
        for indexPath: IndexPath,
        _ popupData: PopupPreviewViewData
    ) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: PickOrInterestCell.reuseIdentifier,
            for: indexPath
        ) as? PickOrInterestCell else {
            return UICollectionViewCell()
        }

        if let popupDDay = popupData.popupDDay {
            mainViewModel.fetchImage(url: popupData.popupImageUrl) { result in
                switch result {
                case .success(let imageData):
                    let image = UIImage(data: imageData) ?? UIImage(resource: .popupPreviewPlaceHolder)
                    DispatchQueue.main.async {
                        cell.configureContents(
                            popupImage: image,
                            popupTitle: popupData.popupTitle,
                            dDay: popupDDay
                        )
                    }
                case .failure:
                    DispatchQueue.main.async {
                        cell.configureContents(
                            popupImage: UIImage(resource: .popupPreviewPlaceHolder),
                            popupTitle: popupData.popupTitle,
                            dDay: popupDDay
                        )
                    }
                }
            }
        } else {
            cell.configureContents(
                popupImage: UIImage(resource: .popupPreviewPlaceHolder),
                popupTitle: PopupPreviewViewData.placeholder.popupTitle,
                dDay: PopupPreviewViewData.placeholder.popupDDay!
            )
        }

        return cell
    }
  • 변경 후
// MainSceneViewController.swift
    private func configurePickOrInterestCell(
        _ collectionView: UICollectionView,
        for indexPath: IndexPath,
        _ popupData: PopupPreviewViewData
    ) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(
            withReuseIdentifier: PickOrInterestCell.reuseIdentifier,
            for: indexPath
        ) as? PickOrInterestCell else {
            return UICollectionViewCell()
        }

        mainViewModel.fetchImage(url: popupData.popupImageUrl) { result in
            let image = popupData.convertToImage(from: result)
            DispatchQueue.main.async {
                cell.configureContents(with: popupData, image: image)
            }
        }

        return cell
    }


// MARK: - Extensions
extension PopupPreviewViewData {
    func convertToImage(from result: Result<Data, ImageFetchError>) -> UIImage {
        switch result {
        case .success(let data):
            return UIImage(data: data) ?? UIImage(resource: .popupPreviewPlaceHolder)
        case .failure(let error):
            print("팝업 프리뷰 이미지 로딩 실패: \(error.localizedDescription)")
            return UIImage(resource: .popupPreviewPlaceHolder)
        }
    }
}

(2) PickOrInterestCellClosingSoonPopupCell의 공통 기능인 configureContents를 추상화하는 프로토콜을 정의해 cellForItemAt 메서드 간소화

cellForItemAt에서 섹션에 따라 PickOrInterestCell 또는 ClosingSoonPopupCell 셀을 구성했습니다.
이를 위해 switch 문으로 분기하였고, 중복된 코드가 많아 코드 길이를 늘린 주범이었습니다.

따라서 셀 구성 메서드인 configureContents를 추상화한 프로토콜인 ConfigurablePopupPreviewCell 프로토콜을 정의해 PickOrInterestCellClosingSoonPopupCell에서 채택했습니다.

뷰 컨트롤러에서는 configure 메서드에 popupData와 로딩한 이미지를 넘겨주고, 각 셀에서 필요한 데이터를 꺼내 구성하도록 변경했습니다.

refactor: cellForItemAt 메서드 간소화 - 8993fc2 커밋에서 코드가 엄청 줄었음을 확인하실 수 있습니다!!

4. 모델 필드 이름 변경

타입 이름과 필드 이름에 중복이 있었습니다.
프레젠테이션 레이어는 아니지만, 생각난김에 이름을 간소화했습니다!

ex) PopupPreview.popupId -> PopupPreview.id

테스트 방법 및 리뷰 노트

리팩토링 할게 몇 개 더 남았습니다..ㅠ 대규모 공사라 여기저기 많이 건드려서 아직 화면이 정상 동작하지는 않아 테스트는 불가능합니다ㅠㅠ
다음 리팩토링은 아마 캐러셀 뷰 구조 변경일 것 같은데 이번 주 내로 끝내보겠습니다!

MinwooJe added 9 commits May 6, 2025 15:16
데이터 fetch 후 화면 갱신이 안되던 문제 해결

문제 원인: fetchPopupDataPublisher에 바인딩을 하지 않고, fetchPopupImagesErrorPublisher에 중복으로 바인딩해버렸음.
MainSceneViewModel의 `provideCarouselImageUrl` 메서드에서 `mainSceneDataSource.item`를 호출하여 캐러셀 이미지 url을 받아옴.

그런데 `mainSceneDataSource.item에서는 캐러셀 데이터를 반환하지 않고, 다른 팝업스토의 데이터만 반환함.

따라서 mainSceneDataSource에서 캐러셀 이미지 url을 반환하는 메서드를 추가하여 해결
분기 처리 기준 변경:`IndexPath`, `index` 기준 -> `MainSceneSection` 기준
- MainSceneDataSource: 데이터를 저장하는 역할만 수행.
- MainSceneViewModel: 데이터 가공, 뷰에게 데이터 전달
- MainSceneViewController
  - MainSceneDataSource에 의존하지 않고, ViewModel에 의존하도록 구현
  - MainSceneSection 도입에 따라 컬렉션 뷰 메서드 분기 처리 개선: 숫자 리터럴이 기준이 아닌, 섹션을 계산하고 이를 이용해 분기처리.
  - cellForItemAt 메서드의 중복 코드 함수로 분리
- configureClosingSoonPopupCell, configurePickOrInterestCell에서 중복된 DispatchQueue 호출 제거 및 nil 병합 연산자로 옵셔널 바인딩 간소화
이미지 fetch 결과에 따라 셀 구성 분기처리가 중복되어 코드가 불필요하게 길어졌음.
따라서 PopupPreviewViewData에 extension으로 `convertToImage` 메서드를 구현해, fetchImage의 result에 따라 UIImage 타입으로 변환까지 수행하거나 플레이스홀더 이미지를 제공.

또한 셀 구성 책임을 각 셀이 수행하도록 하여 뷰 컨트롤러 코드 간소화

이외의 옵셔널 타입의 팝업 정보들도 nil 병합 연산자를 이용해 플레이스 홀더 이미지를 간단하게 얻을 수 있도록 변경.
PickOrInterestCell, ClosingSoonPopupCell의 공통 기능인 configureContents를 추상화한 프로토콜 정의

cellForItem 메서드를 간소화하기 위함.
@MinwooJe MinwooJe requested a review from snughnu May 7, 2025 09:25
@MinwooJe MinwooJe self-assigned this May 7, 2025
@MinwooJe MinwooJe added the 👨‍🏭 Refactor 리팩토링 label May 7, 2025
타입 이름과 필드 이름에 중복이 있어 필드 이름 간소화

ex) PopupPreview.popupId -> PopupPreview.id
Copy link
Contributor

@snughnu snughnu left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

라이브 코드 리뷰 좋았습니다!!

@MinwooJe MinwooJe merged commit ba99034 into GDSC-Popcorn:develop May 19, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants