포스트

Final (28)

프로젝트 막바지에는 기능 구현에 쫓겨 블로그 정리를 제대로 하지 못했다. 그래서 이 글은 당시 GitHub 커밋 내역과, 프로젝트 기간에 직접 적어 둔 업무 내역 목록을 AI(Claude Code)에게 보여주고 그걸 바탕으로 내용을 다시 정리했다. 코드는 실제 커밋에 있던 것을 가져왔다.

배포를 앞두고 자잘한 예외처리가 많았다. 지도에서 커뮤니티로 갈 때의 닉네임 확인, 마이페이지 랭크 표시, 채널 목록의 메시지 수 동기화, 그리고 사용자 피드백으로 받은 “내 지역을 맨 위에” 를 했다. 저녁에는 커뮤니티 이용약관 동의도 붙였다.


지도에서 커뮤니티 접근할 때 닉네임 확인

커뮤니티 탭으로 들어가면 닉네임이 없을 때 입력 Alert 가 뜬다. 그런데 지도의 가게 정보창에 있는 “친구찾기” 버튼으로 들어가면 채널 목록을 거치지 않고 채팅방으로 바로 가기 때문에, 최초 가입 유저가 닉네임 없이 채팅방에 들어갈 수 있었다.

그래서 PinStoreView 에서도 채팅방으로 보내기 전에 닉네임을 확인하고, 비어 있으면 닉네임 설정을 하라는 Alert 를 띄웠다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private func checkNickname(uid: String, completion: @escaping(Bool) -> Void) {
    userManager.fetchUserData(uid: uid) { [self] error, snapshot in
        if let error = error {
            print(error)
        }
        guard let dictionary = snapshot?.value as? [String: Any] else { return }
        currentName = (dictionary[db_nickName] as? String) ?? "Unknown"
        if currentName! == ""  {
            completion(false)
        } else {
            completion(true)
        }
    }
}
1
2
3
4
5
6
7
8
checkNickname(uid: user.uid) { [weak self] result in
    switch result {
    case true :
        self?.currentViewController?.navigationController?.pushViewController(chatVC, animated: true)
    case false :
        self?.currentViewController?.showMessage(title: "안내", message: "닉네임 설정을 먼저 해주세요.")
    }
}

마이페이지 랭크 표시 예외처리

마이페이지에는 내가 쓴 리뷰 수에 따라 “떡볶이 순례길의 초행자” 같은 랭크가 붙는다. 그런데 화면에 들어오면 랭크가 실제 리뷰 수와 다르게 나오는 경우가 있었다.

리뷰 목록을 가져오는 getUserReview() 는 서버에서 읽어 오는 데 시간이 걸린다. 그런데 기존 코드는 이 함수를 부르자마자, 아직 비어 있을 수 있는 userReview.count 로 랭크를 정하고 있었다. 그래서 getUserReview 에 completion 을 붙이고, 리뷰를 다 가져온 다음에 label 을 업데이트하도록 바꿨다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private func getData() {
    reviewViewModel.getUserReview { [unowned self] in
        switch reviewViewModel.userReview.count {
        case 0...4:
            currentRank = "떡볶이 순례길의 초행자"
        case 5...9:
            currentRank = "떡볶길을 자유 여행하는 탐방자"
        case 10...29:
            currentRank = "떡볶길의 베테랑 탐험자"
        case 30...49:
            currentRank = "떡볶이 순례길의 지휘관"
        default:
            currentRank = "떡볶이의 모든 것을 통찰하는 대가"
        }

        myPageView.userRankLabel.text = currentRank
    }
}

내 리뷰 목록도 최신 글이 위에 오도록 order(by: db_createdAt, descending: true) 로 바꿨다.


채널 목록 메시지 수 동기화

채널 목록 cell 오른쪽에는 그 채팅방의 메시지 수가 표시된다. 그런데 처음 들어가면 숫자 대신 “…” 으로 뜨고, 목록을 스크롤해서 cell 이 움직여야 그제야 숫자가 맞춰지는 문제가 있었다.


cell 재사용이란?

TableView 는 cell 을 행마다 새로 만들지 않고, 화면 밖으로 나간 cell 을 가져와 내용만 바꿔서 다시 쓴다. 식당에서 손님이 나간 테이블을 치우고 다음 손님을 앉히는 것과 같다.

그래서 cell 에 값을 비동기로 넣으면, 값이 도착했을 때 그 cell 이 이미 다른 행으로 재사용됐을 수도 있다. 반대로 값이 늦게 오면 cell 은 이전 손님이 남긴 내용을 그대로 보여준다. 스크롤해서 cell 이 다시 그려져야 숫자가 맞춰진 것도, 그때 cell 이 새 값으로 다시 채워졌기 때문이다. 메모에는 이렇게 정리해 두었다. 완료가 된 후에 cell 을 업데이트하도록 Completion Handler 로 변경한다.


목록 갱신이 끝난 뒤 개수 가져오기

먼저 채널 목록이 갱신되는 updateCell 에 completion 을 붙였다. 목록 갱신이 끝나면 모든 채널의 메시지 수를 한 번에 가져온다.

1
2
3
self?.updateCell(to: data) { [weak self] in
    self?.fetchDocumentCounts()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private func fetchDocumentCounts() {
    let group = DispatchGroup()

    for channel in channels {
        guard let channelId = channel.id else { continue }
        group.enter()
        getDocumentCount(id: channelId) { [weak self] result in
            guard let self = self else { return }
            switch result {
            case .success(let count):
                self.documentCounts[channelId] = count
            case .failure(let error):
                print("Error fetching document count: \(error)")
            }
            group.leave()
        }
    }

    group.notify(queue: .main) {
        self.channelTableView.reloadData()
    }
}

DispatchGroup 은 여러 비동기 작업을 묶어서 “전부 끝났을 때” 를 알려준다. 채널마다 시작할 때 enter(), 끝날 때 leave() 를 부르고, 모두 leave() 하면 notify 안의 코드가 실행된다. 개수를 전부 받은 다음에 한 번만 reloadData() 를 하는 것이다.

받은 개수는 documentCounts 에 채널 id 별로 담아 두고, cellForRowAt 에서는 여기 있으면 바로 쓴다.

1
2
3
4
5
6
7
if let channelId = channels[indexPath.row].id {
    if let count = documentCounts[channelId] {
        updateCellCountLabel(cell, with: count)
    } else {
        // 아직 없으면 그 채널만 따로 받아서 갱신
    }
}

숫자를 표시하는 부분도 한 함수로 모았다. 10개 이상이면 “10+”, 0개면 숫자 칸을 숨긴다. 기존에는 0개일 때 숨기기만 하고 다시 보이게 하는 코드가 없어서, 재사용된 cell 에서 숫자 칸이 사라진 채로 남을 수 있었다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private func updateCellCountLabel(_ cell: ChannelTableViewCell, with count: Int?) {
    if let count = count {
        if count >= 10 {
            cell.threadCountLabel.text = "10+"
            cell.countView.isHidden = false
        } else if count == 0 {
            cell.threadCountLabel.text = ""
            cell.countView.isHidden = true
        } else {
            cell.threadCountLabel.text = "\(count)"
            cell.countView.isHidden = false
        }
    } else {
        cell.threadCountLabel.text = "Error"
        cell.countView.isHidden = false
    }
}

cell 쪽에는 재사용되기 직전에 불리는 prepareForReuse 에서 이전 내용을 비우도록 했다.

1
2
3
4
5
override func prepareForReuse() {
    super.prepareForReuse()
    threadCountLabel.text = nil
    myLabel.text = nil
}

내 지역을 채널 목록 맨 위에

사용자 피드백으로 받은 내용이다. 채널 목록이 이름순이라 내 지역 채팅방을 찾으려면 스크롤을 해야 했다. 메시지를 보낼 수 있는 건 내 지역 채팅방뿐이니, 그걸 맨 위에 올렸다.


내 지역 채널을 맨 앞에 넣기

목록을 정렬할 때 currentAddress 와 이름이 같은 채널을 먼저 빼 두고, 나머지를 이름순으로 정렬한 뒤 맨 앞에 다시 넣는다.

1
2
3
4
5
6
7
8
// currentAddress와 일치하는 채널을 찾음
if let currentAddressIndex = channels.firstIndex(where: { $0.name == currentAddress }) {
    let currentAddressChannel = channels.remove(at: currentAddressIndex)
    channels.sort(by: { $0.name < $1.name }) // 나머지 채널 이름 기준으로 정렬
    channels.insert(currentAddressChannel, at: 0) // currentAddress 채널을 맨 앞에 삽입
} else {
    channels.sort(by: { $0.name < $1.name }) // 나머지 채널 이름 기준으로 정렬
}

그리고 그 채널 cell 에는 “현재 지역” 이라고 주황색으로 표시했다.

1
2
3
if channels[indexPath.row].name == currentAddress {
    cell.myLabel.text = "현재 지역"
}

위치를 받은 다음에 목록 불러오기

처음에는 정렬이 적용되지 않았다. viewDidLoad 에서 바로 setupListener() 로 채널 목록을 불러오는데, 그 시점에는 아직 위치를 받기 전이라 currentAddress 가 비어 있었기 때문이다.

그래서 getAddress 에 completion 을 붙이고, 지역 이름이 정해진 뒤에 setupListener() 를 부르도록 순서를 바꿨다.

1
2
3
4
5
6
7
8
9
10
11
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
    if let location = locations.last {
        userLocation = location
        getAddress(coordinate: userLocation) { [weak self] address in
            guard let self = self else { return }
            self.currentAddress = address ?? ""
            self.locationManager.stopUpdatingLocation()  // 위치 업데이트 멈추기
            self.setupListener()  // currentAddress가 설정된 후 setupListener 호출
        }
    }
}

“현재 지역” 표시가 다른 cell 에 남는 문제는 위의 prepareForReuse 에서 myLabel.text = nil 로 같이 해결했다.


커뮤니티 이용약관 동의

커뮤니티처럼 사용자가 직접 글을 올리는 앱은 Apple 심사 지침 1.2 에 따라, 부적절한 콘텐츠를 걸러내는 장치와 이용약관 동의가 있어야 한다. 그래서 설정에 커뮤니티 이용약관 화면을 만들고, 처음 로그인한 유저는 약관에 동의해야 앱을 쓸 수 있게 했다.

유저 정보에 동의 여부 isAgree 를 추가하고, 로그인할 때 이 값이 false 면 약관 동의 화면(VerifyViewController)을 띄운다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private func validateUserData(completion: @escaping(Bool) -> Void) {
    guard let uid = Auth.auth().currentUser?.uid else { return }
    userManager.fetchUserData(uid: uid) { [self] error, snapshot in
        if let error = error {
            print(error)
        }
        guard let dictionary = snapshot?.value as? [String: Any] else { return }
        isAgree = (dictionary[db_isAgree] as? Bool)!

        if isAgree {
            completion(true)
        } else {
            completion(false)
        }
    }
}
1
2
3
4
5
6
7
8
self?.validateUserData { result in
    switch result {
    case true :
        // 메인 화면으로
    case false :
        sd.showVerifyVC()
    }
}

약관 화면에서 체크하고 “가입” 을 누르면 isAgree 를 저장하고 메인 화면으로 보낸다. 체크하지 않았으면 동의해 달라고 알려준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@objc func submitTapped() {
    if isOn {
        guard let uid = Auth.auth().currentUser?.uid else { return }
        let scene = UIApplication.shared.connectedScenes.first
        if let sd: SceneDelegate = (scene?.delegate as? SceneDelegate) {
            userManager.setAgreeProfile(uid: uid, isAgree: isOn) { result in
                switch result {
                case .success(()):
                    sd.switchToMainTabBarController()
                case .failure(let error):
                    print(error)
                    return
                }
            }
        }
    } else {
        showMessage(title: "안내", message: "약관에 동의를 해주세요.")
    }
}
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.