iOS/Refactoring

[UIKit/Refactoring] MyPageProfileEditViewModel 코드 개선하기 (1)

suee97 2024. 7. 13. 01:46
기존 코드

 

수정 전의 기존 코드는 다음과 같습니다.

더보기

 

import UIKit
import Combine
import Alamofire
import Kingfisher

final class MyPageProfileEditViewModel {
    
    // MARK: - Properties
    private let coordinator: MyPageCoordinator
    private var interactor = MyPageDownloadInteractor()
    
    @Published var categoryTagItemSelectCount: Int = 0
    var categoryTagItems = [TagItem(tag: "제품 디자이너", isSelected: false), TagItem(tag: "시각 디자이너", isSelected: false),TagItem(tag: "UX 디자이너", isSelected: false), TagItem(tag: "패션 디자이너", isSelected: false), TagItem(tag: "3D 아티스트", isSelected: false), TagItem(tag: "크리에이터", isSelected: false), TagItem(tag: "일러스트레이터", isSelected: false), TagItem(tag: "공예가", isSelected: false), TagItem(tag: "화가", isSelected: false)]
    
    @Published var recommendTagItemSelectCount: Int = 0
    @Published var tagFieldString: String = ""
    var recommendTagItems = [TagItem(tag: "제품", isSelected: false), TagItem(tag: "공예", isSelected: false), TagItem(tag: "그래픽", isSelected: false), TagItem(tag: "회화", isSelected: false), TagItem(tag: "UX", isSelected: false), TagItem(tag: "UI", isSelected: false), TagItem(tag: "모던", isSelected: false), TagItem(tag: "클래식", isSelected: false), TagItem(tag: "오브제", isSelected: false), TagItem(tag: "감성적인", isSelected: false), TagItem(tag: "심플", isSelected: false), TagItem(tag: "귀여운", isSelected: false), TagItem(tag: "키치한", isSelected: false), TagItem(tag: "힙한", isSelected: false), TagItem(tag: "레트로", isSelected: false), TagItem(tag: "트렌디", isSelected: false), TagItem(tag: "부드러운", isSelected: false), TagItem(tag: "몽환적인", isSelected: false), TagItem(tag: "실용적인", isSelected: false), TagItem(tag: "빈티지", isSelected: false), TagItem(tag: "화려한", isSelected: false), TagItem(tag: "재활용", isSelected: false), TagItem(tag: "친환경", isSelected: false), TagItem(tag: "지속가능한", isSelected: false), TagItem(tag: "밝은", isSelected: false), TagItem(tag: "어두운", isSelected: false), TagItem(tag: "차가운", isSelected: false), TagItem(tag: "따뜻한", isSelected: false)]
    
    
    // 원본 유저 데이터
    let user: PlainUser
    
    @Published var isLoading: Bool = false
    var originalProfileImage: UIImage?
    var originalBackgroundImage: UIImage?
    let shouldUpdateImageView: PassthroughSubject<Void, Never> = .init()
    
    var isProfileCropEnabled: Bool {
        user.profileImageUrl != nil
    }
    
    var isBackgroundCropEnabled: Bool {
        user.backgroundImageUrl != nil
    }
    
    init(coordinator: MyPageCoordinator, user: PlainUser) {
        self.coordinator = coordinator
        self.user = user
        initCategoryTags()
        initRecommendTags()
        downloadProfileImage()
        downloadBackgroundImage()
    }
    
    
    // MARK: - Functions
    private func initCategoryTags() {
        for i in 0..<categoryTagItems.count {
            guard let tag = categoryTagItems[i].tag else { return }
            if user.jobs.contains(tag) {
                categoryTagItems[i].isSelected = true
            }
        }
        updateCategoryTagItemSelectCount()
    }
    
    private func initRecommendTags() {
        for i in 0..<recommendTagItems.count {
            guard let tag = recommendTagItems[i].tag else { return }
            if user.tags.contains(tag) {
                recommendTagItems[i].isSelected = true
            }
        }
        
        for e in user.tags {
            if e != user.tags.last {
                tagFieldString += "\(e), "
            } else {
                tagFieldString += "\(e)"
            }
        }
        
        updateRecommendTagItemSelectCount()
    }
    
    func updateCategoryTagSelection(indexPath: IndexPath, isSelected: Bool) {
        categoryTagItems[indexPath.row].isSelected = isSelected
        updateCategoryTagItemSelectCount()
    }
    
    func updateRecommendTagSelection(indexPath: IndexPath, isSelected: Bool) {
        recommendTagItems[indexPath.row].isSelected = isSelected
        
        // 선택된 추천태그 문자열
        let tagString = recommendTagItems[indexPath.row].tag ?? ""
        
        if isSelected {
            tagFieldString += tagFieldString.isEmpty ? tagString : ", \(tagString)" // 추천태그 추가
        } else {
            // 추천태그 제거
            if let range = tagFieldString.range(of: tagString) {
                var lb = range.lowerBound
                var ub = range.upperBound
                
                // 추천태그가 중간, 마지막 위치일 때 좌측 ","를 만나는 범위까지 문자열 삭제
                if lb != tagFieldString.startIndex {
                    while true {
                        if lb == tagFieldString.startIndex {
                            break
                        }
                        if tagFieldString[lb] != "," {
                            lb = tagFieldString.index(lb, offsetBy: -1)
                        } else {
                            break
                        }
                    }
                    tagFieldString.replaceSubrange(lb..<range.upperBound, with: "")
                } else {
                    // 추천태그가 첫번째 위치일 때 우측 "," & 공백을 제외한 문제열을 만날 때까지 삭제
                    while true {
                        if ub == tagFieldString.endIndex {
                            break
                        }
                        if tagFieldString[ub] == "," || tagFieldString[ub] == " " {
                            ub = tagFieldString.index(ub, offsetBy: +1)
                        } else {
                            break
                        }
                    }
                    tagFieldString.replaceSubrange(tagFieldString.startIndex..<ub, with: "")
                }
            }
        }
        updateRecommendTagItemSelectCount() // 추천태그 선택 변경으로 인한 count update
    }
    
    // 텍스트 필드 ',' 기준으로 태그 개수 카운트
    private func updateRecommendTagItemSelectCount() {
        if tagFieldString.isEmpty {
            recommendTagItemSelectCount = 0
            return
        }
        recommendTagItemSelectCount = tagFieldString.components(separatedBy: ",").count
    }
    
    private func updateCategoryTagItemSelectCount() {
        categoryTagItemSelectCount = categoryTagItems.filter{$0.isSelected}.count
    }
    
    // 유저의 텍스트필드 입력에 따른 뷰모델 및 UI 동기화
    func tagFieldChangedFromUser() {
        if !tagFieldString.isEmpty {
            let tags = tagFieldString.components(separatedBy: ",").map{$0.trimmingCharacters(in: .whitespaces)}
            
            for i in 0..<recommendTagItems.count {
                let idx = tags.firstIndex(of: recommendTagItems[i].tag ?? "") ?? -1
                recommendTagItems[i].isSelected = (idx != -1)
            }
        }
        updateRecommendTagItemSelectCount()
    }
    
    func validateEmail(text: String) -> Bool {
        if text.isEmpty { return true }
        let pattern = "[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}\\@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+"
        guard let _ = text.range(of: pattern, options: .regularExpression) else {
            return false
        }
        return true
    }
    
}


// MARK: - API
extension MyPageProfileEditViewModel {
    
    // true: 중복, false: 사용가능
    func checkDuplicateNickname(nickname: String) async throws -> Bool {
        try await interactor.checkDuplicateNickname(nickname: nickname)
    }
    
    func applyChanges(userData: ProfileEditUserDataModel) {
        Task {
            do {
                isLoading = true
                var userData = userData
                
                if let profileFileName = userData.profileFileName {
                    // presigned url + image url 요청
                    let response = try await interactor.requestPresignedUrl(filename: profileFileName, category: "profile")
                    userData.profileImageUrl = response.imageUrl
                    
                    // S3 업로드
                    guard let file = userData.profileImage?.jpegData(compressionQuality: 0.5) else { return }
                    guard let presignedUrl = URL(string: response.presignedUrl) else { return }
                    var request = URLRequest(url: presignedUrl)
                    request.httpMethod = "PUT"
                    request.setValue("image/jpeg", forHTTPHeaderField: "Content-type")
                    request.httpBody = file

                    AF.request(request).response { _ in }
                }
                
                if let backgroundFileName = userData.backgroundFileName {
                    // presigned url + image url 요청
                    let response = try await interactor.requestPresignedUrl(filename: backgroundFileName, category: "background")
                    userData.backgroundImageUrl = response.imageUrl
                    
                    // S3 업로드
                    guard let file = userData.backgroundImage?.jpegData(compressionQuality: 0.5) else { return }
                    guard let presignedUrl = URL(string: response.presignedUrl) else { return }
                    var request = URLRequest(url: presignedUrl)
                    request.httpMethod = "PUT"
                    request.setValue("image/jpeg", forHTTPHeaderField: "Content-type")
                    request.httpBody = file

                    AF.request(request).response { _ in }
                }
                
                let categoryTags: [String] = categoryTagItems.filter{$0.isSelected}.compactMap{$0.tag}
                let recommendTags: [String] = tagFieldString.components(separatedBy: ",").map{$0.trimmingCharacters(in: .whitespaces)}.filter{!$0.isEmpty}
                
                let _ = try await interactor.profileEdit(profileEditRequestBodyModel: ProfileEditRequestBodyModel(nickname: userData.nickname, profileImageUrl: userData.profileImageUrl, backgroundImageUrl: userData.backgroundImageUrl, tags: recommendTags, jobs: categoryTags, email: userData.email, openChatUrl: userData.link))
            
                // 유저 정보 갱신
                Authentication.shared.user = try await interactor.getUser()
                isLoading = false
                await showMain()
            } catch {
                print(error.localizedDescription)
                isLoading = false
            }
        }
    }
}


// MARK: - Navigation
extension MyPageProfileEditViewModel {
    
    @MainActor
    func showMain() {
        coordinator.navigate(to: .MyPageMain)
    }
    
}

// MARK: - Image
extension MyPageProfileEditViewModel {
    func downloadImage(from urlString: String) async throws -> UIImage? {
        guard let url = URL(string: urlString) else {
            return nil
        }
        
        return try await withCheckedThrowingContinuation { continuation in
            KingfisherManager.shared.retrieveImage(with: url) { result in
                switch result {
                case .success(let imageResult):
                    continuation.resume(returning: imageResult.image)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
    
    func downloadProfileImage() {
        Task {
            guard let profileImageUrl = user.profileImageUrl else { return }
            let profileImage = try await downloadImage(from: profileImageUrl)
            self.originalProfileImage = profileImage
            self.shouldUpdateImageView.send()
        }
    }
    
    func downloadBackgroundImage() {
        Task {
            guard let backgroundImageUrl = user.backgroundImageUrl else { return }
            let backgroundImage = try await downloadImage(from: backgroundImageUrl)
            self.originalBackgroundImage = backgroundImage
            self.shouldUpdateImageView.send()
        }
    }
}

 

- 주석 정리, extension 통합

필요 없는 주석을 제거하고, 구분을 위해 사용한 extension을 제거하고 통합하겠습니다.

그리고, 클래스와 프로퍼티 간의 세로 간격(1줄)과 프로퍼티 간의 세로 간격(1줄) 도 삭제하겠습니다.

 

수정한 코드는 다음과 같습니다.

더보기
import UIKit
import Combine
import Alamofire
import Kingfisher

final class MyPageProfileEditViewModel {
    private let coordinator: MyPageCoordinator
    private var interactor = MyPageDownloadInteractor()
    @Published var categoryTagItemSelectCount: Int = 0
    var categoryTagItems = [TagItem(tag: "제품 디자이너", isSelected: false), TagItem(tag: "시각 디자이너", isSelected: false),TagItem(tag: "UX 디자이너", isSelected: false), TagItem(tag: "패션 디자이너", isSelected: false), TagItem(tag: "3D 아티스트", isSelected: false), TagItem(tag: "크리에이터", isSelected: false), TagItem(tag: "일러스트레이터", isSelected: false), TagItem(tag: "공예가", isSelected: false), TagItem(tag: "화가", isSelected: false)]
    @Published var recommendTagItemSelectCount: Int = 0
    @Published var tagFieldString: String = ""
    var recommendTagItems = [TagItem(tag: "제품", isSelected: false), TagItem(tag: "공예", isSelected: false), TagItem(tag: "그래픽", isSelected: false), TagItem(tag: "회화", isSelected: false), TagItem(tag: "UX", isSelected: false), TagItem(tag: "UI", isSelected: false), TagItem(tag: "모던", isSelected: false), TagItem(tag: "클래식", isSelected: false), TagItem(tag: "오브제", isSelected: false), TagItem(tag: "감성적인", isSelected: false), TagItem(tag: "심플", isSelected: false), TagItem(tag: "귀여운", isSelected: false), TagItem(tag: "키치한", isSelected: false), TagItem(tag: "힙한", isSelected: false), TagItem(tag: "레트로", isSelected: false), TagItem(tag: "트렌디", isSelected: false), TagItem(tag: "부드러운", isSelected: false), TagItem(tag: "몽환적인", isSelected: false), TagItem(tag: "실용적인", isSelected: false), TagItem(tag: "빈티지", isSelected: false), TagItem(tag: "화려한", isSelected: false), TagItem(tag: "재활용", isSelected: false), TagItem(tag: "친환경", isSelected: false), TagItem(tag: "지속가능한", isSelected: false), TagItem(tag: "밝은", isSelected: false), TagItem(tag: "어두운", isSelected: false), TagItem(tag: "차가운", isSelected: false), TagItem(tag: "따뜻한", isSelected: false)]
    let user: PlainUser
    @Published var isLoading: Bool = false
    var originalProfileImage: UIImage?
    var originalBackgroundImage: UIImage?
    let shouldUpdateImageView: PassthroughSubject<Void, Never> = .init()
    var isProfileCropEnabled: Bool {
        user.profileImageUrl != nil
    }
    var isBackgroundCropEnabled: Bool {
        user.backgroundImageUrl != nil
    }
    
    init(coordinator: MyPageCoordinator, user: PlainUser) {
        self.coordinator = coordinator
        self.user = user
        initCategoryTags()
        initRecommendTags()
        downloadProfileImage()
        downloadBackgroundImage()
    }
    
    private func initCategoryTags() {
        for i in 0..<categoryTagItems.count {
            guard let tag = categoryTagItems[i].tag else { return }
            if user.jobs.contains(tag) {
                categoryTagItems[i].isSelected = true
            }
        }
        updateCategoryTagItemSelectCount()
    }
    
    private func initRecommendTags() {
        for i in 0..<recommendTagItems.count {
            guard let tag = recommendTagItems[i].tag else { return }
            if user.tags.contains(tag) {
                recommendTagItems[i].isSelected = true
            }
        }
        
        for e in user.tags {
            if e != user.tags.last {
                tagFieldString += "\(e), "
            } else {
                tagFieldString += "\(e)"
            }
        }
        
        updateRecommendTagItemSelectCount()
    }
    
    func updateCategoryTagSelection(indexPath: IndexPath, isSelected: Bool) {
        categoryTagItems[indexPath.row].isSelected = isSelected
        updateCategoryTagItemSelectCount()
    }
    
    func updateRecommendTagSelection(indexPath: IndexPath, isSelected: Bool) {
        recommendTagItems[indexPath.row].isSelected = isSelected
        
        // 선택된 추천태그 문자열
        let tagString = recommendTagItems[indexPath.row].tag ?? ""
        
        if isSelected {
            tagFieldString += tagFieldString.isEmpty ? tagString : ", \(tagString)" // 추천태그 추가
        } else {
            // 추천태그 제거
            if let range = tagFieldString.range(of: tagString) {
                var lb = range.lowerBound
                var ub = range.upperBound
                
                // 추천태그가 중간, 마지막 위치일 때 좌측 ","를 만나는 범위까지 문자열 삭제
                if lb != tagFieldString.startIndex {
                    while true {
                        if lb == tagFieldString.startIndex {
                            break
                        }
                        if tagFieldString[lb] != "," {
                            lb = tagFieldString.index(lb, offsetBy: -1)
                        } else {
                            break
                        }
                    }
                    tagFieldString.replaceSubrange(lb..<range.upperBound, with: "")
                } else {
                    // 추천태그가 첫번째 위치일 때 우측 "," & 공백을 제외한 문제열을 만날 때까지 삭제
                    while true {
                        if ub == tagFieldString.endIndex {
                            break
                        }
                        if tagFieldString[ub] == "," || tagFieldString[ub] == " " {
                            ub = tagFieldString.index(ub, offsetBy: +1)
                        } else {
                            break
                        }
                    }
                    tagFieldString.replaceSubrange(tagFieldString.startIndex..<ub, with: "")
                }
            }
        }
        updateRecommendTagItemSelectCount() // 추천태그 선택 변경으로 인한 count update
    }
    
    // 텍스트 필드 ',' 기준으로 태그 개수 카운트
    private func updateRecommendTagItemSelectCount() {
        if tagFieldString.isEmpty {
            recommendTagItemSelectCount = 0
            return
        }
        recommendTagItemSelectCount = tagFieldString.components(separatedBy: ",").count
    }
    
    private func updateCategoryTagItemSelectCount() {
        categoryTagItemSelectCount = categoryTagItems.filter{$0.isSelected}.count
    }
    
    // 유저의 텍스트필드 입력에 따른 뷰모델 및 UI 동기화
    func tagFieldChangedFromUser() {
        if !tagFieldString.isEmpty {
            let tags = tagFieldString.components(separatedBy: ",").map{$0.trimmingCharacters(in: .whitespaces)}
            
            for i in 0..<recommendTagItems.count {
                let idx = tags.firstIndex(of: recommendTagItems[i].tag ?? "") ?? -1
                recommendTagItems[i].isSelected = (idx != -1)
            }
        }
        updateRecommendTagItemSelectCount()
    }
    
    func validateEmail(text: String) -> Bool {
        if text.isEmpty { return true }
        let pattern = "[a-zA-Z0-9\\+\\.\\_\\%\\-\\+]{1,256}\\@[a-zA-Z0-9][a-zA-Z0-9\\-]{0,64}(\\.[a-zA-Z0-9][a-zA-Z0-9\\-]{0,25})+"
        guard let _ = text.range(of: pattern, options: .regularExpression) else {
            return false
        }
        return true
    }
    
    // true: 중복, false: 사용가능
    func checkDuplicateNickname(nickname: String) async throws -> Bool {
        try await interactor.checkDuplicateNickname(nickname: nickname)
    }
    
    func applyChanges(userData: ProfileEditUserDataModel) {
        Task {
            do {
                isLoading = true
                var userData = userData
                
                if let profileFileName = userData.profileFileName {
                    // presigned url + image url 요청
                    let response = try await interactor.requestPresignedUrl(filename: profileFileName, category: "profile")
                    userData.profileImageUrl = response.imageUrl
                    
                    // S3 업로드
                    guard let file = userData.profileImage?.jpegData(compressionQuality: 0.5) else { return }
                    guard let presignedUrl = URL(string: response.presignedUrl) else { return }
                    var request = URLRequest(url: presignedUrl)
                    request.httpMethod = "PUT"
                    request.setValue("image/jpeg", forHTTPHeaderField: "Content-type")
                    request.httpBody = file

                    AF.request(request).response { _ in }
                }
                
                if let backgroundFileName = userData.backgroundFileName {
                    // presigned url + image url 요청
                    let response = try await interactor.requestPresignedUrl(filename: backgroundFileName, category: "background")
                    userData.backgroundImageUrl = response.imageUrl
                    
                    // S3 업로드
                    guard let file = userData.backgroundImage?.jpegData(compressionQuality: 0.5) else { return }
                    guard let presignedUrl = URL(string: response.presignedUrl) else { return }
                    var request = URLRequest(url: presignedUrl)
                    request.httpMethod = "PUT"
                    request.setValue("image/jpeg", forHTTPHeaderField: "Content-type")
                    request.httpBody = file

                    AF.request(request).response { _ in }
                }
                
                let categoryTags: [String] = categoryTagItems.filter{$0.isSelected}.compactMap{$0.tag}
                let recommendTags: [String] = tagFieldString.components(separatedBy: ",").map{$0.trimmingCharacters(in: .whitespaces)}.filter{!$0.isEmpty}
                
                let _ = try await interactor.profileEdit(profileEditRequestBodyModel: ProfileEditRequestBodyModel(nickname: userData.nickname, profileImageUrl: userData.profileImageUrl, backgroundImageUrl: userData.backgroundImageUrl, tags: recommendTags, jobs: categoryTags, email: userData.email, openChatUrl: userData.link))
            
                // 유저 정보 갱신
                Authentication.shared.user = try await interactor.getUser()
                isLoading = false
                await showMain()
            } catch {
                print(error.localizedDescription)
                isLoading = false
            }
        }
    }
    
    @MainActor
    func showMain() {
        coordinator.navigate(to: .MyPageMain)
    }
    
    func downloadImage(from urlString: String) async throws -> UIImage? {
        guard let url = URL(string: urlString) else {
            return nil
        }
        
        return try await withCheckedThrowingContinuation { continuation in
            KingfisherManager.shared.retrieveImage(with: url) { result in
                switch result {
                case .success(let imageResult):
                    continuation.resume(returning: imageResult.image)
                case .failure(let error):
                    continuation.resume(throwing: error)
                }
            }
        }
    }
    
    func downloadProfileImage() {
        Task {
            guard let profileImageUrl = user.profileImageUrl else { return }
            let profileImage = try await downloadImage(from: profileImageUrl)
            self.originalProfileImage = profileImage
            self.shouldUpdateImageView.send()
        }
    }
    
    func downloadBackgroundImage() {
        Task {
            guard let backgroundImageUrl = user.backgroundImageUrl else { return }
            let backgroundImage = try await downloadImage(from: backgroundImageUrl)
            self.originalBackgroundImage = backgroundImage
            self.shouldUpdateImageView.send()
        }
    }
}

 

이렇게 함으로써 다른 사람(독자)가 제 코드를 읽을 때, 프로퍼티 선언 부분, 메소드 선언 부분을 예측하고 보다 쉽게 찾을 수 있습니다.

 

- 프로퍼티 순서 변경

다음 개선사항은 프로퍼티의 순서입니다.

개념적으로 엮인 프로퍼티를 가깝게 배치해야, 클래스를 이해하는 데 보다 수월할 것입니다.

 

MyPageProfileEditViewModel 이라는 클래스의 프로퍼티는 총 14개가 있습니다.

'하나의 클래스가 너무 많은 일을 하는거 아닌가?' 하는 생각이 들지만, 클래스 분리 관련해서는 따로 작업을 할 예정이기 때문에, 오늘은 코드 스타일, 변수명, 주석 등에 대해서만 개선하도록 하겠습니다.

 

프로퍼티 목록은 다음과 같습니다. (순서 동일)

1. coordinator // 화면 이동을 수행

2. interactor // API 수행

3. categoryTagItemSelectCount // 선택된 카테고리 태그 아이템 수

4. categoryTagItems // 카테고리 정보

5. recommendTagItemSelectCount // 선택된 추천 카테고리 태그 아이템 수

6. tagFieldString // 태그 텍스트 필드에 입력된 문자열

7. recommendTagItems // 추천 태그 아이템 정보

8. user // 유저 정보

9. isLoading // 네트워크 처리 중임을 나타내는 플래그

10. originalProfileImage // 기존 프로필 이미지

11. originalBackgroundImage // 기존 백그라운드 이미지

12. shouldUpdateImageView // 이미지 업데이트 수행을 지시하는 퍼블리셔

13. isProfileCropEnabled // 프로필 이미지 크롭 기능 On Off 여부

14. isBackgroundCropEnabled // 백그라운드 이미지 크롭 기능 On Off 여부

 

14개의 프로퍼티를 카테고리 별로 묶는다면 다음과 같습니다.

태그 관련 : 3, 4, 5, 6, 7

이미지 수정 관련 : 10, 11, 12, 13, 14

기타: 1, 2, 8. 9

 

기타 프로퍼티를 먼저 배치하고, 이후 순서대로 태그, 이미지 수정 관련 프로퍼티를 배치하는 것이 보기 편할 것 같습니다.

따라서, 1, 2, 8, 9 - 3, 4, 5, 6, 7 - 10, 11, 12, 13, 14 순으로 배치 순서를 변경하겠습니다.

수정 이후 프로퍼티들의 상태는 다음과 같습니다.

더보기
private let coordinator: MyPageCoordinator
private var interactor = MyPageDownloadInteractor()
let user: PlainUser
@Published var isLoading: Bool = false
@Published var categoryTagItemSelectCount: Int = 0
var categoryTagItems = [TagItem(tag: "제품 디자이너", isSelected: false), TagItem(tag: "시각 디자이너", isSelected: false),TagItem(tag: "UX 디자이너", isSelected: false), TagItem(tag: "패션 디자이너", isSelected: false), TagItem(tag: "3D 아티스트", isSelected: false), TagItem(tag: "크리에이터", isSelected: false), TagItem(tag: "일러스트레이터", isSelected: false), TagItem(tag: "공예가", isSelected: false), TagItem(tag: "화가", isSelected: false)]
@Published var recommendTagItemSelectCount: Int = 0
@Published var tagFieldString: String = ""
var recommendTagItems = [TagItem(tag: "제품", isSelected: false), TagItem(tag: "공예", isSelected: false), TagItem(tag: "그래픽", isSelected: false), TagItem(tag: "회화", isSelected: false), TagItem(tag: "UX", isSelected: false), TagItem(tag: "UI", isSelected: false), TagItem(tag: "모던", isSelected: false), TagItem(tag: "클래식", isSelected: false), TagItem(tag: "오브제", isSelected: false), TagItem(tag: "감성적인", isSelected: false), TagItem(tag: "심플", isSelected: false), TagItem(tag: "귀여운", isSelected: false), TagItem(tag: "키치한", isSelected: false), TagItem(tag: "힙한", isSelected: false), TagItem(tag: "레트로", isSelected: false), TagItem(tag: "트렌디", isSelected: false), TagItem(tag: "부드러운", isSelected: false), TagItem(tag: "몽환적인", isSelected: false), TagItem(tag: "실용적인", isSelected: false), TagItem(tag: "빈티지", isSelected: false), TagItem(tag: "화려한", isSelected: false), TagItem(tag: "재활용", isSelected: false), TagItem(tag: "친환경", isSelected: false), TagItem(tag: "지속가능한", isSelected: false), TagItem(tag: "밝은", isSelected: false), TagItem(tag: "어두운", isSelected: false), TagItem(tag: "차가운", isSelected: false), TagItem(tag: "따뜻한", isSelected: false)]
var originalProfileImage: UIImage?
var originalBackgroundImage: UIImage?
let shouldUpdateImageView: PassthroughSubject<Void, Never> = .init()
var isProfileCropEnabled: Bool {
    user.profileImageUrl != nil
}
var isBackgroundCropEnabled: Bool {
    user.backgroundImageUrl != nil
}

 

이제 메소드들을 하나씩 보면서 이름 또는 코드를 수정하겠습니다.

 

- initCategoryTags() 함수 개선

private func initCategoryTags() {
    for i in 0..<categoryTagItems.count {
        guard let tag = categoryTagItems[i].tag else { return }
        if user.jobs.contains(tag) {
            categoryTagItems[i].isSelected = true
        }
    }
    updateCategoryTagItemSelectCount()
}

 

유저의 카테고리 태그 정보를 기반으로, 카테고리 태그 아이템의 상태를 변경해주는 함수입니다.

init(initialize)는 초기화를 뜻해서 모든 카테고리 태그들을 false로 만드는 것처럼 보이게 합니다.

따라서 init 대신 setUp으로 변경하겠습니다.

또한, 카테고리 태그가 아니라 카테고리 태그 "아이템"을 setUp하는 것이기 때문에 이를 명시하는 것이 좋아보입니다.

 

initCategoryTags() -> setUpCategoryTagItems()

보다 직관적인 함수명이 되었습니다.

 

구현 부분에서는, user.jobs.contains(tag)를 guard let 선언부에 같이 묶어 두는게 보기 좋을 것 같습니다.

 

함수명, 함수 구현 변경 후 코드는 다음과 같습니다.

private func setUpCategoryTagItems() {
    for i in 0..<categoryTagItems.count {
        guard let tag = categoryTagItems[i].tag, user.jobs.contains(tag) else { continue }
        categoryTagItems[i].isSelected = true
    }
    updateCategoryTagItemSelectCount()
}

 

 

- initRecommendTags() 함수 개선

다음 변경 대상의 함수입니다.

private func initRecommendTags() {
    for i in 0..<recommendTagItems.count {
        guard let tag = recommendTagItems[i].tag else { return }
        if user.tags.contains(tag) {
            recommendTagItems[i].isSelected = true
        }
    }

    for e in user.tags {
        if e != user.tags.last {
            tagFieldString += "\(e), "
        } else {
            tagFieldString += "\(e)"
        }
    }

    updateRecommendTagItemSelectCount()
}

 

이전 함수의 변경과 비슷하게 몇 가지 개선점이 보입니다.

1. 함수명 변경

2. 코드 개선

3. tagField의 문자열 수정하는 부분 함수 따로 만들기

 

3과 관련해서, setUpRecommendTagItems는 추천 태그 아이템들의 상태를 변경하는 것인데, tagField 문자열을 수정하는 두 가지의 일을 하고 있습니다.

따라서, setUpTagFieldText와 같은 함수로 따로 빼는 것이 낫습니다.

 

함수명 변경, 코드 개선, 함수 분리 이후의 코드는 다음과 같습니다.

private func setUpRecommendTagItems() {
    for i in 0..<recommendTagItems.count {
        guard let tag = recommendTagItems[i].tag, user.tags.contains(tag) else { continue }
        recommendTagItems[i].isSelected = true
    }
    setUpTagFieldText()
    updateRecommendTagItemSelectCount()
}

private func setUpTagFieldText() {
    var tagFieldString = ""
    for e in user.tags {
        tagFieldString += (e == user.tags.last) ? "\(e)" : "\(e), "
    }
    self.tagFieldString = tagFieldString
}

 

setUpTagFieldText() 함수에서 지역 변수를 만들고 활용한 이유는, tagFieldString이 Publishing하기 때문에, 반복문을 돌면서 여러 번 Publishing하는 오버헤드를 줄이기 위해서입니다.

 

 

- updateRecommendTagSelection 함수 개선

func updateRecommendTagSelection(indexPath: IndexPath, isSelected: Bool) {
    recommendTagItems[indexPath.row].isSelected = isSelected

    // 선택된 추천태그 문자열
    let tagString = recommendTagItems[indexPath.row].tag ?? ""

    if isSelected {
        tagFieldString += tagFieldString.isEmpty ? tagString : ", \(tagString)" // 추천태그 추가
    } else {
        // 추천태그 제거
        if let range = tagFieldString.range(of: tagString) {
            var lb = range.lowerBound
            var ub = range.upperBound

            // 추천태그가 중간, 마지막 위치일 때 좌측 ","를 만나는 범위까지 문자열 삭제
            if lb != tagFieldString.startIndex {
                while true {
                    if lb == tagFieldString.startIndex {
                        break
                    }
                    if tagFieldString[lb] != "," {
                        lb = tagFieldString.index(lb, offsetBy: -1)
                    } else {
                        break
                    }
                }
                tagFieldString.replaceSubrange(lb..<range.upperBound, with: "")
            } else {
                // 추천태그가 첫번째 위치일 때 우측 "," & 공백을 제외한 문제열을 만날 때까지 삭제
                while true {
                    if ub == tagFieldString.endIndex {
                        break
                    }
                    if tagFieldString[ub] == "," || tagFieldString[ub] == " " {
                        ub = tagFieldString.index(ub, offsetBy: +1)
                    } else {
                        break
                    }
                }
                tagFieldString.replaceSubrange(tagFieldString.startIndex..<ub, with: "")
            }
        }
    }
    updateRecommendTagItemSelectCount() // 추천태그 선택 변경으로 인한 count update
}

 

보기만해도 아찔한 이 함수의 역할은 추천 태그를 선택하거나 해제할 때 이를 tagFieldString에 반영하는 것입니다.

아래 gif는 텍스트 입력도 포함되어 있지만, 해당 함수는 이 기능을 포함하지 않습니다.

 

분명 함수명은 "추천 태그 선택을 업데이트한다" 인데, 추가 시 tagFieldString 업데이트, 제거 시 tagFieldString 등 문자열 관련해서 여러 가지 일을 하고있습니다.

따라서, 함수를 여러 개로 분리하고 핵심 로직을 이해하기 편한 방식으로 수정하겠습니다.

 

func updateRecommendTagSelection(indexPath: IndexPath, isSelected: Bool) {
    recommendTagItems[indexPath.row].isSelected = isSelected
    let tagString = recommendTagItems[indexPath.row].tag ?? ""
    if isSelected {
        addTagToTagFieldString(with: tagString)
    } else {
        removeTagFromTagFieldString(target: tagString)
    }
    updateRecommendTagItemSelectCount()
}

private func addTagToTagFieldString(with tagString: String) {
    if tagFieldString.isEmpty {
        tagFieldString += tagString
    } else {
        tagFieldString += ", \(tagString)"
    }
}

private func removeTagFromTagFieldString(target tagString: String) {
    let currentTags = tagFieldString
        .components(separatedBy: ",")
        .map {
            $0.trimmingCharacters(in: [" "])
        }
    var newTags = [String]()
    for tag in currentTags {
        if tag.trimmingCharacters(in: [" "]) != tagString {
            newTags.append(tag)
        }
    }
    var newTagFieldString = ""
    for tag in newTags {
        if tag == newTags.last {
            newTagFieldString += tag
        } else {
            newTagFieldString += "\(tag), "
        }
    }
    tagFieldString = newTagFieldString
}

 

updateRecommendTagSelection 함수는 태그 아이템의 selection을 바꿔주는 역할을 합니다.

addTagToTagFieldString 함수는 추가된 태그 정보를 기반으로 tagFieldString을 최신화합니다.

removeTagFromTagFieldString 함수는 삭제된 태그 정보를 기반으로 tagFieldString을 최신화합니다.

 

 

리팩토링 할게 많아서 일단 여기까지 하고 다음 포스팅에 이어서 작성하겠습니다!