포스트

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 는 한 번 읽고 끝나는 게 아니라, 그 데이터가 바뀔 때마다 새 값을 계속 보내준다. 택배 조회를 계속 새로고침하는 대신, 상태가 바뀌면 문자로 알려주는 알림을 신청해 두는 것과 같다.

채팅 메시지는 이미 이 방식으로 받고 있었다. 여기에 두 가지를 더했다.

  1. 메시지 listener 는 isActive 가 true 인 것만 받는다. 신고로 false 가 되면 그 메시지는 빠진다
  2. 내 UserBlock document 에도 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 로 하고 보여줄 때 닉네임을 찾아오는 게 맞다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.