채팅 서비스를 구현하면서 고민했던 지점들에 대해 정리해보았습니다.
시간적으로 여유가 없어서, 일단 글로 풀어서 정리했습니다. 추후 시각 자료를 더 첨부하겠습니다.
제가 잘못 이해하고 있는 내용이 있거나 질문이 있으시면 편하게 알려주세요 🙂
UIKit, RxSwift
MVVM - C, Input / Output
Realm, Alamofire, Socket.IO
Sooda 깃허브 레포
채팅방 진입 시, 초기 데이터를 불러오는 로직은 다음과 같다.
Realm
realm 탐색 후 저장된 채팅의 마지막 날짜 조회HTTP
해당 날짜를 cursor_date 파라미터로 추가하여 읽지 않은 채팅 데이터 요청Realm
응답받은 읽지 않은 채팅 데이터 디비에 저장Realm
읽은 채팅 n개, 읽지 않은 채팅 n개 디비에서 조회 후 VM 배열에 추가View
뷰 업데이트위 과정에서 소켓을 오픈하는 적절한 시점이 언제일지 고민했다
HTTP 통신 + 소켓 오픈
/* HTTP 통신 + 소켓 오픈 */
private func fetchRecentChatting(completion: @escaping () -> Void) {
// 요청 모델 생성 (cursor date 포함)
var requestModel = /* ... */
// HTTP 요청 - Repo에서 DB에 넣어주는 작업까지 진행
channelChattingUseCase.fetchRecentChatting(
channelChattingRequestModel: requestModel,
completion: completion
)
// 소켓 오픈 및 응답 대기 <- HTTP 응답이 오기 전에 실행 (completion x)
self.openSocket()
self.receiveSocket()
}
DB에 채팅 데이터 저장
/* DB에 채팅 데이터 저장 */
func addChannelChattingData(dtoData: ChannelChattingDTO, workSpaceId: Int) {
guard let realm else { return }
// 0. 디비에 저장하려고 하는 채팅이 이미 디비에 있는 채팅인지 확인하는 작업
// - 서버 오류로 인해 중복된 채팅을 받을 가능성이 있음
// - request를 보냄과 동시에 소켓이 오픈되기 때문에, 서버 입장에서 request를 받기 전, 소켓을 통해 이미 디비에 채팅이 저장될 가능성이 있음.
if let _ = realm.objects(ChannelChattingInfoTable.self).filter("chat_id == %@", dtoData.chat_id).first {
print("디비에 이미 있는 채팅. 걸러")
return
}
/* ... */
}
기본적으로 채팅 화면이 나타났을 때 open, 화면이 사라졌을 때 close가 되어야 하기 때문에 viewWillAppear 와 viewWillDisappear 에 해당 코드를 작성하였다.
override func viewWillAppear(_ animated: Bool) {
super.viewDidAppear(animated)
loadData() // connectSocket 포함
startObservingSocket()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
viewModel.disconnectSocket()
removeObservingSocket()
}
NotificationCenter
를 활용한다./* VC */
// 노티 등록
private func startObservingSocket() {
// SceneDidBecomeActive에서 소켓 재연결의 필요성을 확인하고, 노티를 보낸다 -> loadData
NotificationCenter.default.addObserver(
self,
selector: #selector(socketReconnectAndReloadData),
name: NSNotification.Name("socketShouldReconnect"),
object: nil
)
}
@objc
private func socketReconnectAndReloadData() {
print("Scene에서 연락 받음 : 다시 소켓 연결해야 함!\n")
self.loadData()
}
// 노티 제거
private func removeObservingSocket() {
NotificationCenter.default.removeObserver(
self,
name: NSNotification.Name("socketShouldReconnect"),
object: nil
)
}
/* SceneDelegate */
func sceneDidEnterBackground(_ scene: UIScene) {
// 소켓이 연결되어 있는 상태에서 백그라운드로 앱을 보내면,
// 1. 일단 소켓 끊어주고
// 2. 다시 포그라운드 진입 시 소켓 연결하도록 한다
if socketManager.isOpen {
socketManager.closeConnection() // 1.
socketManager.shouldReconnect = true // 2.
} else {
socketManager.shouldReconnect = false
}
}
func sceneDidBecomeActive(_ scene: UIScene) {
if socketManager.shouldReconnect {
// Notification Center로 해당 화면(채팅 화면 - 소켓이 열리는 화면)에 노티 보내기
NotificationCenter.default.post(
name: NSNotification.Name("socketShouldReconnect"),
object: nil
)
}
}
이 과정에서 같은 소켓에 대해 중복된 handler를 등록할 수가 있기 때문에, 소켓 연결 해제 시 반드시 removeAllHandler
를 실행시킨다.
/* SocketIOManager */
func closeConnection() {
socket.disconnect()
socket.removeAllHandlers()
self.isOpen = false
}
중복된 handler로 인해 여러 개의 채팅을 받는 것처럼 동작 |
따라서 채팅방에 들어간 시점에, 항상 채널 정보와 유저 정보 api를 통해 최신 데이터를 받고, 로컬 DB를 업데이트한다!
한계 : 결국 업데이트 되는 시점은 정해져 있기 때문에, 해당 시점을 통하지 않는다면 동기화가 되지 않는다.
즉, 채팅방 내에 있을 때 변경되는 데이터에 대해서는 반영할 수 없다.
동기화되지 않은 경우와 동기화(해결)한 경우
동기화 o | 동기화 x |
socketChatData
) 의 발신자 user ID 와 현재 계정 user ID 를 비교한다socketChatData
)는 현재 계정이 보낸 채팅이므로, 전송한 채팅 이라고 할 수 있다.하나의 기기로만 테스트했을 때는 위 방법에 전혀 문제가 없었지만, 여러 계정에 동시에 로그인한 경우 문제가 발생한다.
두 기기에 접속했을 때, 하나의 기기에서 채팅을 보내더라도 실시간으로 다른 기기에서도 업데이트가 되어야 한다.
하지만, 소켓으로 응답받은 데이터의 user ID와 현재 계정 user ID가 같으면 수신 처리를 하지 않았기 때문에,
현재 계정이 보낸 데이터라고 판단하고, 응답 처리를 하지 않는다.
이러면 로컬 DB에 들어가는 채팅 순서도 엉키기 때문에, 예외처리에 대한 추가적인 기준 이 필요하다.
단순히 user ID로만 비교하면 위에 보이듯이 멀티 디바이스에 대한 대응이 되지 않기 때문에 user ID로 조건 처리를 하는 것은 적절하지 않다.
그렇다면 결국, 채팅 데이터 가 정확히 현재 계정이 보낸 채팅인지 확인해야 한다.
즉, 채팅 데이터의 고유한 값인 chat ID 를 이용한다.
내가 생각한 로직은 다음과 같다
httpChatData
)httpChatData
의 chat ID를 UserDefaults 에 저장socketChatData
)socketChatData
의 chat ID와 UserDefaults의 chat ID 비교 후 현재 계정에서 보낸 채팅인지 분기 처리위 로직대로면 발생한 문제가 모두 해결되고, 정상적으로 멀티 디바이스 대응이 가능해진다