[새싹 iOS] 28주차_채팅 기능을 구현하며 고민했던 지점들

임승섭·2024년 1월 29일
1

새싹 iOS

목록 보기
44/45

채팅 서비스를 구현하면서 고민했던 지점들에 대해 정리해보았습니다.
시간적으로 여유가 없어서, 일단 글로 풀어서 정리했습니다. 추후 시각 자료를 더 첨부하겠습니다.
제가 잘못 이해하고 있는 내용이 있거나 질문이 있으시면 편하게 알려주세요
🙂

기술 스택

UIKit, RxSwift
MVVM - C, Input / Output
Realm, Alamofire, Socket.IO
Sooda 깃허브 레포


1. 소켓 연결 / 해제 시점

1 - 1. 초기 데이터 불러오는 과정 중 소켓 오픈 시점

초기 채팅 데이터 불러오는 로직

채팅방 진입 시, 초기 데이터를 불러오는 로직은 다음과 같다.

  1. Realm realm 탐색 후 저장된 채팅의 마지막 날짜 조회
  2. HTTP 해당 날짜를 cursor_date 파라미터로 추가하여 읽지 않은 채팅 데이터 요청
  3. Realm 응답받은 읽지 않은 채팅 데이터 디비에 저장
  4. Realm 읽은 채팅 n개, 읽지 않은 채팅 n개 디비에서 조회 후 VM 배열에 추가
  5. View 뷰 업데이트

위 과정에서 소켓을 오픈하는 적절한 시점이 언제일지 고민했다

  • HTTP에서 응답받은 데이터 이후의 채팅 데이터를 소켓으로 받는 것이 가장 이상적이다
  • 2에서의 HTTP 통신을 간단하게 그려본다면 다음과 같다
    • 1. send request 에서 소켓 오픈
      : 1 ~ 3 사이에 오는 채팅을 중복으로 받게 된다. (HTTP, 소켓)

    • 4. receive response 에서 소켓 오픈
      : 3 ~ 4 사이에 오는 채팅이 누락된다. (HTTP, 소켓 어디서도 받을 수 없다)

    • 2 receive request, 3. send response 시점에 소켓을 열어주는 게 가장 좋다고 생각했지만, 이 시점을 클라이언트에서 파악할 수는 없다..

  • 최종적으로 내가 소켓 오픈으로 결정한 시점은 1. send request 이다.
    • 중복으로 받는 채팅은 DB에 추가할 때 예외처리를 해서 걸러줄 수 있지만,
      누락되는 채팅은 다시 받아올 방법이 없다.
    • 따라서 응답받은 채팅을 DB에 저장할 때, 이미 저장된 채팅 데이터인지 확인하는 로직을 추가한다
    • 이를 통해 소켓 통신과 HTTP 통신의 데이터 중복 이슈를 해결하고,
      서버 오류로 인한 중복 데이터 응답에 대해서도 대응할 수 있다

  • 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
        }
    
        /* ... */
    }

1 - 2. 소켓 open / close 시점

  • 기본적으로 채팅 화면이 나타났을 때 open, 화면이 사라졌을 때 close가 되어야 하기 때문에 viewWillAppearviewWillDisappear 에 해당 코드를 작성하였다.

    override func viewWillAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
    
        loadData()	// connectSocket 포함
        startObservingSocket()
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
    
        viewModel.disconnectSocket()
        removeObservingSocket()
    }
    • 채팅 화면에서 push 로 화면 전환이 더 일어날 수 있기 때문에 viewDidLoad 에서 실행하지 않았다.

  • 추가적으로 앱이 백그라운드로 나갔을 때 close, 포그라운드로 들어왓을 때 open이 되어야 한다. 이 작업을 하지 않으면, 불필요하게 계속 소켓이 연결되어 있을 수도 있다.
    • SceneDelegate의 sceneDidBecomeActivesceneDidEnterBackground 에 해당 코드를 작성한다.
    • 채팅 화면에서 해당 시점을 알게 하기 위해 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로 인해 여러 개의 채팅을 받는 것처럼 동작

2. 서버와 로컬 DB 데이터 동기화

  • 현재 로컬 DB (realm) 에는 채팅 정보 외에도
    채팅이 이루어지는 채널 정보와 채팅을 전송/수신 하는 유저 정보 가 저장되어 있다.
  • 즉, 채팅 데이터를 DB에 저장할 때, channel_IDuser_ID 를 DB에서 탐색해서, 이미 저장된 데이터라면 해당 데이터를 FK로 연결하여 채팅 데이터를 저장한다
  • 하지만, 만약 유저 정보 (user_name, user_profileImage) 또는 채널 정보 (channel_name) 이 서버에서 수정되었다면 문제가 생긴다.
  • 로컬 DB 데이터가 업데이트되지 않으면 같은 유저인데도 채팅방에서 다른 유저처럼 보이는 이슈가 생긴다
  • 따라서 채팅방에 들어간 시점에, 항상 채널 정보와 유저 정보 api를 통해 최신 데이터를 받고, 로컬 DB를 업데이트한다!

  • 한계 : 결국 업데이트 되는 시점은 정해져 있기 때문에, 해당 시점을 통하지 않는다면 동기화가 되지 않는다.
    즉, 채팅방 내에 있을 때 변경되는 데이터에 대해서는 반영할 수 없다.

  • 동기화되지 않은 경우와 동기화(해결)한 경우

    동기화 o동기화 x

3. 같은 디바이스에 다른 계정으로 로그인하는 경우 - 로컬 DB 공유 이슈

  • 프로젝트에서는 사용자가 읽은 채팅 을 로컬 DB (realm) 로 관리한다.
  • 프로젝트에서는 로그인/로그아웃 이 가능하기 때문에 여러 계정이 로그인할 수 있다.
  • 위의 2가지 기획을 구현 중, 여러 계정이 로컬 DB를 공유하는 이슈를 경험하였다.
    • 단순히 기기의 realm 파일을 하나 생성해서 채팅 데이터를 다 저장하면,
      여러 계정의 데이터가 당연히 섞여서 들어가게 된다.
    • 이렇게 되면 A 계정에서 읽은 채팅을 B 계정에서는 읽지 않았는데, 읽음 처리가 된다.
    • 서버에 요청하는 pagination date도 비정상적으로 받아오게 된다.
  • 위 이슈를 해결하기 위해 여러 방법을 생각해 보았다.

1. 새 계정 로그인 시 DB 초기화

  • 데이터 공유 이슈 해결
  • 새로운 계정으로 로그인 시 이전 계정의 데이터가 모두 손실되기 때문에 서비스적으로 좋은 방법은 아니라고 생각

2. 채팅 테이블에 '수신자' column 추가

  • 각 유저의 고유한 user ID 를 이용해서 채팅 데이터가 realm에 저장되는 테이블에 column을 하나 추가한다.
  • 유저 별 데이터가 분리되기 때문에 데이터 공유 이슈 해결
  • DB 데이터를 지워야 할 필요 없기 때문에 데이터 손실 문제 해결
  • PK 변경 필요 : (chat ID) -> (chat ID, receive user ID)
  • 데이터 추출할 때마다 receive user ID 필요 -> 코드 유지보수 측면에서 좋은 방법은 아니라고 생각

3. 계정 간 별개의 DB 파일 생성 (채택)

  • realm의 configuration을 이용하여 계정 별 별개의 realm 파일 생성
  • 파일 명 : "SoodaRealmuser(userId).realm"
  • DB CRUD를 위한 RealmManager 인스턴스 생성 시점에
    키체인에 저장된 user ID 이용해서 realm 파일 새로 생성 or 기존 파일 식별
  • 만약 너무 많은 계정이 로그인한다면 그 때마다 계속해서 파일 생성 -> 메모리 오버헤드 발생
  • 따라서, 최대 5개의 파일만 생성이 가능하도록 설정
  • 5개 초과 시 수정일이 가장 오래된 파일 제거 (LRU)


4. 다른 디바이스에 같은 계정으로 로그인하는 경우 - 소켓 응답 예외처리

  • 채팅 화면에서 화면이 업데이트되는 경우는 채팅 전송채팅 수신 이 있다.
  • 채팅 전송 : 채팅 전송 (HTTP 통신) 이 성공했을 때, 200 응답과 함께 받은 데이터 이용
  • 채팅 수신 : 채팅 수신 (소켓 통신) 이 성공했을 때, 수신한 데이터 이용
  • 이 때, 전송한 채팅 은 해당 채팅방의 모든 유저에게 전송되기 때문에, 중복해서 소켓으로 응답받게 된다.
    이에 대한 예외처리가 필요하다.

구현 방법

  • 소켓으로 응답받은 데이터(socketChatData) 의 발신자 user ID현재 계정 user ID 를 비교한다
  • 두 값이 같다면, 해당 데이터(socketChatData)는 현재 계정이 보낸 채팅이므로, 전송한 채팅 이라고 할 수 있다.
  • 즉, 두 값이 같을 때는 소켓으로 응답을 받았더라도, 데이터 처리를 해주지 않는다.

이슈

  • 하나의 기기로만 테스트했을 때는 위 방법에 전혀 문제가 없었지만, 여러 계정에 동시에 로그인한 경우 문제가 발생한다.

  • 두 기기에 접속했을 때, 하나의 기기에서 채팅을 보내더라도 실시간으로 다른 기기에서도 업데이트가 되어야 한다.

  • 하지만, 소켓으로 응답받은 데이터의 user ID와 현재 계정 user ID가 같으면 수신 처리를 하지 않았기 때문에,
    현재 계정이 보낸 데이터라고 판단하고, 응답 처리를 하지 않는다.

  • 이러면 로컬 DB에 들어가는 채팅 순서도 엉키기 때문에, 예외처리에 대한 추가적인 기준 이 필요하다.

해결 방법 & 실패

  • 단순히 user ID로만 비교하면 위에 보이듯이 멀티 디바이스에 대한 대응이 되지 않기 때문에 user ID로 조건 처리를 하는 것은 적절하지 않다.

  • 그렇다면 결국, 채팅 데이터 가 정확히 현재 계정이 보낸 채팅인지 확인해야 한다.
    즉, 채팅 데이터의 고유한 값인 chat ID 를 이용한다.

  • 내가 생각한 로직은 다음과 같다

    1. 채팅 전송
    2. 전송 성공 응답 (200) + 성공한 채팅 데이터 (httpChatData)
    3. httpChatData 의 chat ID를 UserDefaults 에 저장
    4. 소켓 응답 (socketChatData)
    5. socketChatData의 chat ID와 UserDefaults의 chat ID 비교 후 현재 계정에서 보낸 채팅인지 분기 처리
  • 위 로직대로면 발생한 문제가 모두 해결되고, 정상적으로 멀티 디바이스 대응이 가능해진다

  • 하지만, 실패했다.
    • 4. 소켓 응답 이 2. HTTP 응답 보다 빠르게 온다
    • 애초에 UserDefaults에 전송한 채팅 데이터를 저장하기도 전에 소켓 응답이 와버리기 때문에 분기처리 자체가 불가능했다.
    • chat ID 는 서버에서 정해서 주기 때문에, HTTP 응답이 오기 전까지는 절대 알 수가 없다.
  • 결과적으로 멀티 디바이스 대응 이슈 에 대해서는 해결할 수 없었다...
    찾아보니 멀티 디바이스에 대한 대응을 위해서는 이 정도 수준보다 훨씬 많은 내용을 더 알아야 하는 것 같다.
    추후 기회가 된다면, 이 쪽 내용에 대해서도 공부를 좀 해봐겠다.

0개의 댓글