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: "약관에 동의를 해주세요.")
}
}