Final (30)
프로젝트 막바지에는 기능 구현에 쫓겨 블로그 정리를 제대로 하지 못했다. 그래서 이 글은 당시 GitHub 커밋 내역과, 프로젝트 기간에 직접 적어 둔 업무 내역 목록을 AI(Claude Code)에게 보여주고 그걸 바탕으로 내용을 다시 정리했다. 코드는 실제 커밋에 있던 것을 가져왔다.
이날 코드도 계정 정지 때문에 팀원이 7월 1일에 올려준 [Merge] 커밋에 들어 있다. 커뮤니티에 유저 차단 기능을 만들고, 차단한 유저를 관리하는 페이지와, 신고나 차단을 하면 채팅 화면이 바로 갱신되도록 했다.
커뮤니티 차단 기능
신고는 관리자가 확인하거나 3번이 쌓여야 처리되지만, 차단은 내 화면에서만 바로 그 사람의 메시지를 안 보이게 하는 기능이다. 메시지를 길게 누르면 나오는 메뉴에 “신고” 옆에 “차단” 을 추가했다.
1
2
let blockMenuItem = UIMenuItem(title: "차단", action: #selector(MessageCollectionViewCell.block(_:)))
UIMenuController.shared.menuItems = [blockMenuItem, reportMenuItem]
메뉴를 누르면 cell 에서 collectionView 의 delegate 로 액션을 넘긴다. 신고 메뉴와 같은 방식이다.
1
2
3
4
5
6
7
8
9
10
11
12
extension MessageCollectionViewCell {
@objc func block(_ sender: Any?) {
// CollectionView 가져오기
if let collectionView = self.superview as? UICollectionView {
// IndexPath 가져오기
if let indexPath = collectionView.indexPath(for: self) {
// 액션 트리거
collectionView.delegate?.collectionView?(collectionView, performAction: #selector(MessageCollectionViewCell.block(_:)), forItemAt: indexPath, withSender: sender)
}
}
}
}
ChatVC 는 그 액션을 받아서 한 번 더 확인을 받고, 자기 자신이나 게스트가 아니면 차단 목록에 추가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
} else if action == #selector(MessageCollectionViewCell.block(_:)) {
showMessageWithCancel(title: "안내", message: "해당 메세지를 보낸 유저를 차단하시겠습니까?\n차단 유저는 마이페이지의 설정에서\n해제가 가능합니다.") { [weak self] in
if let myUid = Auth.auth().currentUser?.uid {
let senderName = selectedMessage.sender.displayName
let senderId = selectedMessage.sender.senderId
if myUid == senderId {
self?.showMessage(title: "에러", message: "자기 자신은 차단할 수 없습니다.")
} else {
self?.viewModel.addBlockUser(myUid: myUid, senderName: senderName)
}
} else {
self?.showMessage(title: "안내", message: "게스트는 차단할 수 없습니다.")
}
}
}
차단 목록 저장
차단 목록은 Firestore 의 UserBlock collection 에 내 uid 를 document id 로 해서 저장한다. 안에는 차단한 사람의 닉네임 배열이 들어 있다.
1
2
3
let blockCollection = Firestore.firestore().collection("UserBlock")
let db_myUid = "myUid"
let db_blockSenderNames = "blockSenderNames"
1
2
3
4
5
6
7
8
9
10
11
12
struct UserBlock {
var myUid: String
var blockSenderNames: Set<String>
func toDictionary() -> [String: Any] {
return [
"myUid": myUid,
"blockSenderNames": Array(blockSenderNames) // Set을 Array로 변환
]
}
}
같은 사람을 두 번 차단해도 한 번만 들어가도록 Set 으로 다룬다. Firestore 에는 Set 을 그대로 저장할 수 없어서 저장할 때만 Array 로 바꾼다.
차단을 추가할 때는 내 document 가 이미 있으면 배열에 이름을 더하고, 없으면 새로 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func addBlockUser(myUid: String, senderName: String) {
let userBlockRef = blockCollection.document(myUid)
userBlockRef.getDocument { (document, error) in
if let document = document, document.exists {
// 문서가 존재하면 blockSenderName에 새로운 senderName 추가
if var blockData = document.data(), let blockSenderNameArray = blockData[db_blockSenderNames] as? [String] {
var blockSenderNameSet = Set(blockSenderNameArray)
blockSenderNameSet.insert(senderName)
blockData[db_blockSenderNames] = Array(blockSenderNameSet)
userBlockRef.setData(blockData, merge: true) { error in
// ...
}
}
} else {
// 문서가 존재하지 않으면 새로 생성
let newUserBlock = UserBlock(myUid: myUid, blockSenderNames: [senderName])
userBlockRef.setData(newUserBlock.toDictionary(), merge: true) { error in
// ...
}
}
}
}
차단 유저 관리 페이지
차단한 사람을 다시 보고 싶을 수도 있어서, 마이페이지의 설정 안에 “차단유저 관리” 를 추가했다. 들어가면 차단한 닉네임 목록이 나오고, 차단 해제를 누르면 다시 그 사람의 메시지를 볼 수 있다.
1
2
3
// SettingViewController
let blockVC = BlockManageViewController()
navigationController?.pushViewController(blockVC, animated: true)
목록은 내 UserBlock document 의 배열을 그대로 읽어 온다. document 가 없으면 빈 배열이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func fetchBlockedUsers(completion: @escaping (Result<[String], Error>) -> Void) {
guard let myUid = Auth.auth().currentUser?.uid else {
completion(.failure(NSError(domain: "AuthError", code: 0, userInfo: [NSLocalizedDescriptionKey: "User not logged in"])))
return
}
blockCollection.document(myUid).getDocument { (document, error) in
if let error = error {
completion(.failure(error))
return
}
guard let document = document, document.exists, let blockData = document.data(), let blockSenderNamesArray = blockData[db_blockSenderNames] as? [String] else {
completion(.success([]))
return
}
completion(.success(blockSenderNamesArray))
}
}
해제는 FieldValue.arrayRemove 로 배열에서 그 이름만 뺀다. 배열 전체를 읽어서 고쳐 쓰지 않아도 Firestore 가 알아서 그 값만 지워준다.
1
2
3
4
5
6
7
8
9
10
11
func unblockUser(myUid: String, senderName: String, completion: @escaping (Result<Void, Error>) -> Void) {
blockCollection.document(myUid).updateData([
db_blockSenderNames: FieldValue.arrayRemove([senderName])
]) { error in
if let error = error {
completion(.failure(error))
return
}
completion(.success(()))
}
}
BlockManageViewController 는 cell 의 “차단 해제” 버튼을 누르면 한 번 더 확인을 받고, 해제가 끝나면 목록에서 지운다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@objc private func unblockButtonTapped(_ sender: UIButton) {
guard let cell = sender.superview?.superview as? BlockedUserCell else { return }
guard let indexPath = tableView.indexPath(for: cell) else { return }
let userIdToUnblock = blockedUsers[indexPath.row]
showMessageWithCancel(title: "차단 해제", message: "정말 이 유저를 차단 해제하시겠습니까?") { [weak self] in
self?.unblockUser(userId: userIdToUnblock)
self?.showMessage(title: "안내", message: "선택하신 유저가 차단 해제 되었습니다.")
}
}
private func unblockUser(userId: String) {
guard let myUid = Auth.auth().currentUser?.uid else { return }
viewModel.unblockUser(myUid: myUid, senderName: userId) { [weak self] result in
switch result {
case .success:
self?.blockedUsers.removeAll { $0 == userId }
self?.tableView.reloadData()
case .failure(let error):
print("Error unblocking user: \(error)")
}
}
}
버튼은 cell 의 contentView 안에 있어서, sender.superview?.superview 로 두 단계 올라가야 cell 이 나온다.
신고와 차단에 따라 채팅 화면 Refresh
차단을 눌러도 채팅방을 나갔다 들어와야 메시지가 사라지면 소용이 없다. 신고로 block 된 메시지도 마찬가지다. 그래서 둘 다 바로 화면에 반영되도록 했다.
Snapshot Listener란?
Firestore 의 addSnapshotListener 는 한 번 읽고 끝나는 게 아니라, 그 데이터가 바뀔 때마다 새 값을 계속 보내준다. 택배 조회를 계속 새로고침하는 대신, 상태가 바뀌면 문자로 알려주는 알림을 신청해 두는 것과 같다.
채팅 메시지는 이미 이 방식으로 받고 있었다. 여기에 두 가지를 더했다.
- 메시지 listener 는
isActive가true인 것만 받는다. 신고로false가 되면 그 메시지는 빠진다 - 내
UserBlockdocument 에도 listener 를 붙인다. 차단 배열이 바뀌면 메시지를 다시 거른다
차단 목록 변화 감지
ChatFirestoreStream 의 subscribe 에 내 uid 를 넘기도록 바꿨다. 게스트는 차단 목록이 없으니 uid 없이 메시지만 구독한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
var blockListener: ListenerRegistration?
var messages = [Message]()
var blockSenderSet = Set<String>()
func subscribe(id: String, myUid: String?, completion: @escaping (Result<[Message], StreamError>) -> Void) {
guard let myUid = myUid else {
// 게스트 사용자의 경우 차단된 사용자 목록을 확인하지 않고 메시지를 구독
// ...
return
}
// 차단된 사용자 목록의 변화를 감지
blockListener = blockCollection.document(myUid).addSnapshotListener { [weak self] (snapshot, error) in
guard let self = self else { return }
guard let document = snapshot, document.exists, let blockData = document.data(), let blockSenderArray = blockData[db_blockSenderNames] as? [String] else {
self.blockSenderSet = Set<String>()
self.reloadMessages(completion: completion)
return
}
self.blockSenderSet = Set(blockSenderArray)
self.reloadMessages(completion: completion)
}
// 메시지 변화를 감지
listener = collectionListener?.whereField(db_isActive, isEqualTo: true)
.addSnapshotListener { snapshot, error in
guard let snapshot = snapshot else {
completion(.failure(StreamError.firestoreError(error)))
return
}
self.handleDocumentChanges(snapshot.documentChanges) { messages in
completion(.success(messages))
}
}
}
차단 배열이 바뀌면 reloadMessages 가 메시지를 처음부터 다시 읽어서, 차단한 사람의 메시지를 빼고 돌려준다. 차단을 해제하면 배열에서 이름이 빠지니까 그 사람의 메시지가 다시 나타난다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private func reloadMessages(completion: @escaping (Result<[Message], StreamError>) -> Void) {
collectionListener?.whereField(db_isActive, isEqualTo: true).getDocuments { snapshot, error in
if let error = error {
completion(.failure(StreamError.firestoreError(error)))
return
}
guard let snapshot = snapshot else {
completion(.failure(StreamError.noData(error)))
return
}
self.messages = snapshot.documents.compactMap { document in
let message = Message(document: document)
return self.blockSenderSet.contains(message?.sender.displayName ?? "") ? nil : message
}
self.messages.sort()
completion(.success(self.messages))
}
}
메시지 변화 처리
새 메시지가 오거나 기존 메시지가 바뀌면 handleDocumentChanges 가 변화의 종류별로 처리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
private func handleDocumentChanges(_ documentChanges: [DocumentChange], completion: @escaping ([Message]) -> Void) {
var newMessages = [Message]()
documentChanges.forEach { change in
if var message = Message(document: change.document) {
switch change.type {
case .added:
if !blockSenderSet.contains(message.sender.displayName), !messages.contains(where: { $0.id == message.id }) {
newMessages.append(message)
}
case .modified:
if let index = messages.firstIndex(where: { $0.id == message.id }) {
if blockSenderSet.contains(message.sender.displayName) {
messages.remove(at: index)
} else {
messages[index] = message
}
} else if !blockSenderSet.contains(message.sender.displayName) {
newMessages.append(message)
}
case .removed:
messages.removeAll { $0.id == message.id }
}
}
}
self.messages += newMessages
self.messages.sort()
// ... 이미지 메시지는 이미지를 받은 뒤 completion
}
.added: 새 메시지다. 차단한 사람이 아니고 이미 가진 메시지가 아니면 추가한다.modified: 내용이 바뀐 메시지다. 차단한 사람이면 빼고, 아니면 새 내용으로 바꾼다.removed: 조건에서 빠진 메시지다.isActive가false가 되면whereField(db_isActive, isEqualTo: true)조건에서 벗어나서 여기로 들어온다
채팅방을 나갈 때는 blockListener?.remove() 로 차단 목록 listener 도 같이 끊는다.
지금 보면 차단을 uid 가 아니라 닉네임(displayName)으로 하고 있어서, 상대가 닉네임을 바꾸면 차단이 풀린다. 차단 관리 페이지에 닉네임을 보여주려고 이렇게 했는데, 저장은 uid 로 하고 보여줄 때 닉네임을 찾아오는 게 맞다.