포스트

Final (22)

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

내용이 많아 이어서 작성한다. 채팅 화면에서 상대방이 누군지 알 수 있도록, 메시지 옆에 보낸 사람의 프로필 사진을 띄웠다. 마이페이지에서 설정한 유저의 profileImageUrl 을 그대로 쓴다.


채팅방에 메시지를 보낸 사람 찾기

프로필 사진을 띄우려면 먼저 이 채팅방에 누가 메시지를 보냈는지 알아야 한다. 채팅 데이터는 Firestore 에 이런 구조로 저장되어 있다.

  • channels collection 안에 채팅방마다 document 가 하나씩 있고
  • 각 채팅방 document 아래 thread collection 에 메시지가 하나씩 쌓인다
  • 메시지마다 보낸 사람의 uid 가 senderId 로 들어 있다

그래서 채팅방 이름으로 document 를 찾고, 그 안의 thread 를 전부 읽어서 senderId 만 모으는 ChatManager 를 만들었다.

1
2
let channelCollection = Firestore.firestore().collection("channels")
let db_channelName = "name"
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
class ChatManager {

    func getSenders(channelName: String, completion: @escaping ([String]) -> Void) {
        channelCollection.whereField(db_channelName, isEqualTo: channelName).getDocuments { (snapshot, error) in
            guard let snapshot = snapshot, error == nil else {
                print("Error fetching channel: \(String(describing: error))")
                completion([])
                return
            }

            var senderIds = Set<String>()
            for document in snapshot.documents {
                let threadCollection = channelCollection.document(document.documentID).collection("thread")
                threadCollection.getDocuments { (threadSnapshot, error) in
                    guard let threadSnapshot = threadSnapshot, error == nil else {
                        print("Error fetching thread: \(String(describing: error))")
                        return
                    }

                    for threadDocument in threadSnapshot.documents {
                        if let senderId = threadDocument.data()["senderId"] as? String {
                            senderIds.insert(senderId)
                        }
                    }
                    completion(Array(senderIds))
                }
            }
        }
    }

}

한 사람이 메시지를 여러 번 보내도 uid 는 한 번만 있으면 되니까 Set 에 담아서 중복을 없앴다.


프로필 사진 URL 모으기

ChatVC 에는 uid 별로 프로필 사진 URL 을 담아 둘 dictionary 와, 이미 받은 이미지를 담아 둘 dictionary 를 추가했다.

1
2
3
4
let chatManager = ChatManager()

private var profileImageUrls = [String: String]()
private var imageCache = [String: UIImage]()

기존의 fetchDisplayName 은 닉네임만 가져왔는데, 닉네임과 프로필 사진 URL 을 같이 가져오도록 fetchUserDataAndProfileImage 로 바꿨다. uid 를 넘기면 그 사람의 정보를, 안 넘기면 내 정보를 가져온다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private func fetchUserDataAndProfileImage(for uid: String? = nil, completion: @escaping (String?, String?, [String: Any]?) -> Void) {
    let userId = uid ?? user?.uid
    guard let userId = userId else {
        completion(nil, nil, nil)
        return
    }

    let userManager = UserManager()
    userManager.fetchUserData(uid: userId) { error, snapshot in
        if let error = error {
            print(error)
            completion(nil, nil, nil)
            return
        }
        guard let dictionary = snapshot?.value as? [String: Any] else {
            completion(nil, nil, nil)
            return
        }
        let currentName = (dictionary[db_nickName] as? String) ?? "Unknown"
        let profileImageUrl = (dictionary["profileImageUrl"] as? String)
        completion(currentName, profileImageUrl, dictionary)
    }
}

이걸로 내 정보, 보낸 사람들 정보를 각각 가져온다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private func fetchDisplayNameAndProfileImage(completion: @escaping (String?, String?) -> Void) {
    fetchUserDataAndProfileImage { displayName, profileImageUrl, _ in
        completion(displayName, profileImageUrl)
    }
}

private func getSenderImage() {
    chatManager.getSenders(channelName: channel.name) {[weak self] senderIds in
        self?.fetchProfileImages(for: senderIds)
    }
}

private func fetchProfileImages(for senderIds: [String]) {
    senderIds.forEach { senderId in
        fetchUserDataAndProfileImage(for: senderId) { [weak self] _, imageUrl, _ in
            if let profileImageUrl = imageUrl {
                self?.profileImageUrls[senderId] = profileImageUrl
                self?.messagesCollectionView.reloadData()  // Reload to update avatars
            }
        }
    }
}

viewDidLoad 에서는 내 닉네임과 사진 URL 을 먼저 받고, getSenderImage() 로 채팅방 사람들의 사진 URL 을 받는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
override func viewDidLoad() {
    super.viewDidLoad()
    configureColor()

    fetchDisplayNameAndProfileImage { [weak self] displayName, imageUrl in
        self?.currentDisplayName = displayName ?? "Unknown"
        if let profileImageUrl = imageUrl, let userId = self?.user?.uid {
            self?.profileImageUrls[userId] = profileImageUrl
        }
        self?.messagesCollectionView.reloadData()
        DispatchQueue.main.async {
            self?.messagesCollectionView.scrollToLastItem()
        }
    }

    getSenderImage()
    // ... 생략
}

채팅방에 들어온 뒤에 새 사람이 메시지를 보낼 수도 있다. 그래서 메시지를 받을 때마다 아직 이미지가 없는 사람의 URL 도 미리 받아 두도록 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
private func preloadProfileImages(for messages: [Message]) {
    let uniqueSenderIds = Set(messages.map { $0.sender.senderId })

    uniqueSenderIds.forEach { senderId in
        guard imageCache[senderId] == nil else { return }
        fetchUserDataAndProfileImage(for: senderId) { [weak self] _, imageUrl, _ in
            if let profileImageUrl = imageUrl {
                self?.profileImageUrls[senderId] = profileImageUrl
                self?.messagesCollectionView.reloadData()  // Reload to update avatars
            }
        }
    }
}

listenToMessages 에서 메시지를 받으면 loadImageAndUpdateCells 다음에 이 함수를 부른다.


메시지 옆에 프로필 사진 띄우기

URL 을 모았으니 이제 실제 사진을 받아서 메시지 옆에 붙인다. 같은 사진을 여러 번 받지 않도록 캐시를 같이 썼다.


캐시란?

캐시는 한 번 가져온 것을 가까운 곳에 보관해 두고 다음에 다시 쓰는 것이다. 자주 보는 책을 도서관에 매번 빌리러 가지 않고 책상 위에 올려 두는 것과 같다.

채팅 화면은 스크롤할 때마다 cell 을 다시 그린다. 같은 사람이 메시지를 20개 보냈으면 같은 사진을 20번 그려야 하는데, 매번 인터넷에서 받으면 느리고 데이터도 낭비된다. 그래서 한 번 받은 사진은 imageCache 에 uid 를 key 로 넣어 두고, 다음부터는 거기서 꺼내 쓴다.


MessageKit 의 avatar 설정

MessageKit 은 메시지 옆의 동그란 사진(avatar)을 delegate 로 물어본다. 캐시에 사진이 있으면 그걸, 없으면 닉네임 앞 두 글자를 보여준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func avatarFor(message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> MessageKit.Avatar {
    let sender = message.sender
    let initials = String(sender.displayName.prefix(2))

    if let cachedImage = imageCache[sender.senderId] {
        return MessageKit.Avatar(image: cachedImage, initials: initials)
    } else {
        // Preloaded images should already be in the cache if available
        return MessageKit.Avatar(initials: initials)
    }
}

func avatarSize(for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) -> CGSize {
    return CGSize(width: 30, height: 30)
}

실제 사진은 cell 을 만들 때 Kingfisher 로 받아서 넣는다. 받는 데 성공하면 캐시에 넣고, 실패하면 이니셜을 보여준다.

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
override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    let cell = super.collectionView(collectionView, cellForItemAt: indexPath) as! MessageContentCell
    let message = messages[indexPath.section]
    configureAvatarView(cell.avatarView, for: message, at: indexPath, in: collectionView as! MessagesCollectionView)
    return cell
}

func configureAvatarView(_ avatarView: AvatarView, for message: MessageType, at indexPath: IndexPath, in messagesCollectionView: MessagesCollectionView) {
    let sender = message.sender
    let initials = String(sender.displayName.prefix(2))

    // 다른 사용자의 프로필 이미지 설정
    if let imageUrl = profileImageUrls[sender.senderId], let url = URL(string: imageUrl) {
        avatarView.kf.setImage(with: url, placeholder: nil, options: nil, progressBlock: nil) { result in
            switch result {
            case .success(let value):
                self.imageCache[sender.senderId] = value.image
                DispatchQueue.main.async {
                    avatarView.set(avatar: MessageKit.Avatar(image: value.image, initials: initials))
                }
            case .failure(let error):
                print("Error downloading image: \(error.localizedDescription)")
                DispatchQueue.main.async {
                    avatarView.set(avatar: MessageKit.Avatar(initials: initials))
                }
            }
        }
    } else {
        avatarView.set(avatar: MessageKit.Avatar(initials: initials))
    }
}

정리하면 흐름은 이렇다.

  1. 채팅방에 들어오면 보낸 사람들의 uid 를 모은다
  2. uid 마다 프로필 사진 URL 을 받아 profileImageUrls 에 넣는다
  3. cell 을 그릴 때 URL 로 사진을 받아 avatar 에 넣고 imageCache 에 보관한다
  4. 다음에 같은 사람의 cell 을 그릴 때는 캐시에서 꺼낸다

지금 보면 getSenders 는 thread 를 전부 읽어 오기 때문에, 메시지가 많은 방일수록 들어갈 때 읽는 양이 많아진다. 어차피 preloadProfileImages 가 받은 메시지 기준으로 사람을 모으니, 둘 중 하나로 합칠 수 있었을 것 같다.

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