코드엘 iOS 프로젝트를 개발하면서, 채팅 기능은 생각보다 훨씬 “정상 동작하기 어려운 기능”이라는 걸 자주 느꼈어요.
채팅은 겉으로 보면 단순해 보여요.
메시지를 보내고, 상대 메시지를 실시간으로 받고, 읽음 상태만 잘 맞으면 될 것 같죠.
그런데 실제 서비스에서는 그렇게 단순하지 않았어요.
앱은 계속 포그라운드에만 머물러 있지 않고, 네트워크도 항상 안정적이지 않아요. 사용자는 채팅방을 보다가 잠깐 홈으로 나갔다가 돌아오기도 하고, 앱을 백그라운드에 두었다가 다시 진입하기도 하고, 네트워크 연결이 끊기는 구간을 지나가기도 해요.
문제는 이런 순간들이 모두 채팅의 연속성을 깨뜨릴 수 있다는 점이었어요.
- 소켓 연결이 잠깐 끊기는 동안 들어온 메시지를 놓칠 수 있었어요.
- 연결이 다시 살아나더라도 이전 구독 상태가 복구되지 않으면 실시간 수신이 멈출 수 있었어요.
- 백그라운드에 있던 동안 누적된 메시지는 실시간 이벤트만으로는 다시 맞추기 어려웠어요.
- 읽음 상태와 실제 대화 상태가 어긋나면 채팅 정합성이 깨질 수 있었어요.
그래서 이번 글에서는 코드엘 iOS에서 이 문제를 어떻게 다뤘는지, 그리고 왜 단순한 소켓 재연결만으로는 부족했는지 정리해보려고 해요.
문제의 발견
처음에는 저도 “소켓만 잘 연결해 두면 되는 것 아닌가?”라고 생각했어요.
실제로 코드엘 iOS 채팅은 STOMP 기반으로 실시간 메시지를 받고 있었고, 채팅방에 들어가면 해당 room을 구독하고 publisher로 메시지를 받는 구조였어요.
// 채팅방 화면에 들어오면 먼저 해당 room을 구독해요.
// 그래야 이 채팅방으로 들어오는 실시간 메시지를 받을 수 있어요.
.run { [roomId = state.roomId] _ in
await stompClient.subscribeChat(roomId: roomId)
}
// STOMP에서는 여러 채팅방 메시지가 하나의 스트림으로 들어올 수 있어요.
// 그래서 현재 화면에서 보고 있는 roomId의 메시지만 걸러서 상태에 반영해요.
.publisher { [roomId = state.roomId] in
return stompClient.chatPublisher()
.filter { $0.chatRoomId == roomId }
.receive(on: DispatchQueue.main)
.map { Action.receiveMessage($0) }
}
.cancellable(id: CancelId.realtimeChat, cancelInFlight: true)
이 구조만 보면 채팅은 잘 동작하는 것처럼 보여요.
연결돼 있으면 받고, 끊기면 다시 붙으면 되니까요.
그런데 실제 서비스에서 문제는 “소켓이 언젠가 끊긴다”는 사실보다,
끊겼던 사이에 무슨 일이 일어났는가였어요.
예를 들어 이런 상황들이 있었어요.
- 사용자가 채팅방을 보고 있는 동안 네트워크가 잠깐 흔들리는 경우
- 앱이 백그라운드로 내려갔다가 다시 포그라운드로 올라오는 경우
- 연결은 복구됐지만 구독이 바로 살아나지 않는 경우
- 사용자가 보지 않는 동안 상대가 여러 메시지를 보낸 경우
이런 상황에서는 소켓이 다시 연결되더라도, 이미 지나간 메시지는 실시간 스트림만으로 복구되지 않았어요.
즉, 필요한 건 “연결 유지”가 아니라 대화 상태를 다시 맞추는 흐름이었어요.
첫 번째 정리, 재연결은 무작정 빠르게 하지 않았어요
가장 먼저 손본 건 재연결 방식이었어요.
연결이 끊기면 바로 다시 시도하는 방식은 얼핏 좋아 보여요.
하지만 네트워크가 불안정한 구간에서는 이 방식이 오히려 더 불안정하게 느껴질 수 있었어요. 계속 연결을 시도하다가 실패하고, 다시 시도하고, 또 실패하는 흐름이 짧은 시간 안에 반복될 수 있으니까요.
그래서 코드엘에서는 재연결에 exponential backoff와 jitter를 함께 적용했어요.
private func scheduleReconnect() {
// 사용자가 직접 끊은 연결이라면 자동 재연결을 시도하지 않아요.
guard !isManuallyDisconnected else { return }
// 이미 연결된 상태라면 중복 재연결을 막아요.
guard !swiftStomp.isConnected else { return }
// 재시도 횟수가 늘어날수록 대기 시간을 점점 늘려요.
retryCount += 1
let delay = min(baseDelay * pow(2, Double(retryCount - 1)), maxDelay)
// 모든 클라이언트가 같은 타이밍에 재접속하지 않도록
// 약간의 랜덤 지연(jitter)을 섞어줘요.
let jitter = Double.random(in: 0...(delay / 2))
let finalDelay = min(delay + jitter, maxDelay)
// 계산된 시간 뒤에 다시 연결을 시도해요.
DispatchQueue.main.asyncAfter(deadline: .now() + finalDelay) { [weak self] in
self?.attemptReconnect()
}
}
핵심은 단순했어요.
- 한 번 실패했다고 즉시 같은 간격으로 계속 붙지 않기
- 재시도 간격을 점점 늘리기
- 여러 클라이언트가 같은 타이밍에 동시에 재연결을 시도하지 않도록 조금씩 분산하기
이렇게 하니 네트워크가 흔들리는 구간에서도 소켓 재시도 흐름이 훨씬 차분해졌어요.
즉, 재연결을 “빨리 많이 시도하는 문제”가 아니라 “불안정한 상황에서 어떻게 덜 흔들리게 붙을 것인가”의 문제로 보게 됐어요.
하지만 연결만 복구하면 끝이 아니었어요
재연결을 넣고 나면 채팅이 꽤 안정될 것 같았어요.
그런데 실제로는 여기서 끝이 아니었어요.
이유는 채팅에서 중요한 게 소켓 연결 자체보다, 무엇을 구독하고 있었는가이기 때문이에요.
코드엘에서는 채팅 목록 구독과 개별 채팅방 구독이 나뉘어 있었어요.
- 앱 전역에서는 채팅방 목록 구독
- 채팅방 화면에서는 특정 room 구독
이 구조에서 소켓이 끊기면, 연결이 다시 살아났다고 해서 이전 구독까지 자동으로 다 복원되는 건 아니었어요. 구독이 빠지면 연결은 살아 있어도 메시지를 못 받게 되죠.
그래서 activeSubscriptions와 pendingSubscriptions를 나눠서 관리했어요.
// activeSubscriptions:
// 현재 실제로 살아 있는 구독 목록이에요.
//
// pendingSubscriptions:
// 연결이 끊기면서 잠시 보관해둔 구독 목록이에요.
// 재연결에 성공하면 이 목록을 다시 subscribe해요.
private var pendingSubscriptions: Set<STOMPEvent> = []
private var activeSubscriptions: Set<STOMPEvent> = []
연결이 끊기면 현재 활성 구독을 pending으로 옮기고,
func onDisconnect(
swiftStomp: SwiftStomp,
disconnectType: StompDisconnectType
) {
// 연결이 끊기면 기존 활성 구독을 그대로 버리지 않고
// 재연결 후 복구할 수 있도록 pending 쪽으로 옮겨둬요.
if !activeSubscriptions.isEmpty {
self.pendingSubscriptions = activeSubscriptions
self.activeSubscriptions.removeAll()
}
// 사용자가 명시적으로 끊은 경우가 아니라면 자동 재연결을 예약해요.
guard !isManuallyDisconnected else { return }
scheduleReconnect()
}
다시 연결되면 pending에 있던 구독을 복원했어요.
func onConnect(
swiftStomp: SwiftStomp,
connectType: StompConnectType
) {
if swiftStomp.isConnected {
// 정상 연결이 되면 백오프 재시도 횟수는 초기화해요.
retryCount = 0
// 끊기기 전 살아 있던 구독들을 다시 요청해요.
// 즉, 연결 복구 이후 구독 복구까지 함께 수행하는 단계예요.
for subscription in pendingSubscriptions {
request(event: subscription)
}
}
}
이 부분을 정리하고 나서야 “다시 연결은 됐는데 왜 실시간 메시지가 안 오지?” 같은 종류의 문제가 줄어들었어요.
개인적으로는 여기서 많이 느꼈어요.
채팅에서 “연결 복구”는 결국 구독 복구까지 포함해야 의미가 있다는 걸요.
그래도 실시간 스트림만으로는 빈 구간을 메울 수 없었어요
재연결과 구독 복구까지 넣었는데도 한계는 남아 있었어요.
문제는 소켓이 끊겨 있던 동안, 혹은 앱이 백그라운드에 있던 동안 들어온 메시지였어요.
이 메시지들은 실시간 이벤트가 지나간 뒤라서, 연결이 복구됐다고 해서 자동으로 다시 오지 않았어요.
즉, 실시간 스트림은 “지금 들어오는 것”에는 강하지만,
놓쳤을 수 있는 구간을 되짚어 복구하는 데는 약했어요.
그래서 여기서 방향을 바꿨어요.
실시간 소켓 하나로 모든 걸 해결하려고 하지 말고,
실시간은 실시간대로 두고, 누락 가능성이 있는 구간은 서버 기준으로 다시 맞추자고요.
코드엘에서는 앱이 포그라운드로 복귀할 때 unread 메시지를 REST로 다시 가져오는 흐름을 추가했어요.
// 앱이 다시 포그라운드로 올라오는 순간을 감지해요.
// 이 시점은 백그라운드 동안 놓친 메시지가 있을 가능성이 커서
// unread 보완 동기화를 수행하기에 적절했어요.
.publisher {
NotificationCenter.default
.publisher(for: UIApplication.willEnterForegroundNotification)
.map { _ in Action.syncUnreadChats }
}
.cancellable(id: CancelId.appLifecycle, cancelInFlight: true)
case .syncUnreadChats:
// 마지막으로 읽은 메시지 위치가 있어야
// 어디부터 다시 받아와야 할지 기준을 잡을 수 있어요.
guard let oldLastChatId = state.lastReadChatId else { return .none }
return .run { [roomId = state.roomId, size = state.unreadChats.size] send in
// oldLastChatId 이후의 unread 메시지를 다시 조회해
// 실시간으로 놓쳤을 수 있는 구간을 보완해요.
let model = try await chats(roomId, oldLastChatId, 0, size)
// 새로 확인된 마지막 메시지가 더 뒤라면
// 서버의 읽음 위치도 함께 최신 상태로 맞춰줘요.
if let newLastChatId = model.data.last?.id,
newLastChatId > oldLastChatId {
try? await updateLastRead(roomId, newLastChatId)
}
// 복구한 unread 메시지를 상태에 반영해요.
await send(.state(.unreadChats(model)))
}
.withLoading()
여기서 핵심 기준은 lastReadChatId였어요.
- 내가 마지막으로 읽었다고 알고 있는 메시지 ID를 기준으로
- 그 이후의 unread 메시지를 다시 받아오고
- 새롭게 확인된 마지막 메시지를 서버에 다시 반영하는 구조였어요
이렇게 하니 채팅방 복귀 시점에 비어 있던 구간을 다시 메울 수 있었어요.
이 시점부터 채팅을 “실시간”이 아니라 “복구 가능한 상태”로 보기 시작했어요
이전까지는 채팅을 거의 실시간 이벤트 흐름으로만 보고 있었어요.
메시지가 들어오면 append하고, 읽음 상태를 갱신하고, 화면을 보여주는 식이었죠.
그런데 unread 보완 동기화를 넣고 나면서 관점이 달라졌어요.
채팅은 단순히 “이벤트를 잘 받는 시스템”이 아니라,
어느 순간 다시 들어와도 대화 상태를 복원할 수 있는 시스템이어야 했어요.
그래서 실시간 메시지를 처리할 때도 중복을 막는 로직이 중요해졌어요.
이미 실시간으로 받은 메시지를 unread 동기화에서 다시 받아올 수 있었기 때문이에요.
let newChats = model.data.filter { newChat in
// 실시간 수신으로 이미 반영된 메시지가 있다면
// unread 동기화에서 다시 append되지 않도록 걸러줘요.
!state.unreadChats.data.contains(where: { $0.id == newChat.id })
}
실시간 수신에서도 같은 메시지가 다시 들어오면 무시했어요.
case let .realtimeChat(chat):
// 소켓 수신과 unread 보완 동기화가 겹칠 수 있어서
// 이미 상태에 있는 메시지라면 중복 반영하지 않아요.
if state.unreadChats.data.contains(where: { $0.id == chat.id }) {
return .none
}
이렇게 해야 실시간 스트림과 보완 동기화가 서로 충돌하지 않았어요.
즉, 누락을 막기 위해 보완 동기화를 넣었더니 이번에는 중복이 생기고, 그걸 또 상태 기준으로 걸러내는 식으로 구조가 점점 정리되어 갔어요.
읽음 상태도 같이 맞춰야 대화 정합성이 유지됐어요
메시지를 다시 가져오는 것만으로는 충분하지 않았어요.
채팅에서 사용자가 느끼는 이상함은 “메시지가 없다”뿐 아니라, “읽음 상태가 이상하다”에서도 많이 발생하거든요.
코드엘에서는 unread 동기화 과정에서 새 마지막 메시지가 확인되면 서버의 읽음 위치도 갱신했어요.
if let newLastChatId = model.data.last?.id,
newLastChatId > oldLastChatId {
// 보완 동기화 과정에서 더 최신 메시지를 확인했다면
// 서버에도 읽음 위치를 다시 반영해 정합성을 맞춰요.
try? await updateLastRead(roomId, newLastChatId)
}
실시간 메시지를 받았을 때도 마지막 읽음 상태를 업데이트했고,
case let .realtimeChat(chat):
...
// 새 메시지를 정상 반영했다면
// 현재 클라이언트가 알고 있는 마지막 읽음 위치도 갱신해요.
return .send(.state(.updateLastReadChatId(chat.id)))
채팅방을 나갈 때도 구독 해제와 함께 마지막 읽음 위치를 서버에 반영했어요.
case let .chatDidFinish(roomId, lastReadChatId):
// 더 이상 현재 보고 있는 채팅방이 아니므로 active 상태를 비워줘요.
state.$activeChatRoomId.withLock { $0 = nil }
return .run(priority: .high) { _ in
// 채팅방을 벗어날 때는 먼저 실시간 구독을 정리하고,
await stompClient.unsubscribeChat(roomId: roomId)
// 마지막으로 확인한 메시지 위치가 있다면 서버에도 읽음 상태를 반영해요.
guard let lastReadChatId else { return }
try? await updateLastRead(roomId, lastReadChatId)
}
이 부분을 정리하면서 느낀 건, 채팅에서 메시지 정합성은 결국 읽음 처리까지 포함한 상태 정합성이라는 점이었어요.
메시지만 맞고 읽음 위치가 틀리면 사용자 입장에서는 여전히 이상하게 느껴지니까요.
결국 저희가 만든 건 “재연결”이 아니라 “복구 흐름”이었어요
처음에는 이 작업을 소켓 안정화 정도로 생각했어요.
그런데 끝나고 보니 실제로 만든 건 단순한 재연결 로직이 아니라, 채팅 상태를 복구하는 전체 흐름에 더 가까웠어요.
정리하면 코드엘 iOS 채팅은 이렇게 역할을 나눠 갖게 됐어요.
- 소켓 재연결은 불안정한 구간에서 연결을 다시 살리는 역할
- 구독 복구는 재연결 후 실시간 수신을 다시 정상화하는 역할
- unread 보완 동기화는 놓쳤을 수 있는 메시지를 REST로 다시 맞추는 역할
- 읽음 위치 반영은 실제 대화 상태와 서버 상태를 정합하게 유지하는 역할
이렇게 나누고 나니 채팅은 훨씬 안정적으로 느껴졌어요.
특히 “실시간”과 “복구”를 분리해서 본 것이 가장 큰 전환점이었어요.
마치며
채팅은 잘 동작할 때는 너무 자연스러워서, 구현도 단순할 것처럼 보이곤 해요. 메시지를 보내고, 받고, 화면에 잘 보여주면 끝인 것처럼 느껴지니까요.
하지만 실제 서비스에서는 그렇지 않았어요. 네트워크는 흔들릴 수 있고, 앱은 백그라운드와 포그라운드를 오가고, 사용자는 언제든 대화 흐름을 끊었다가 다시 돌아와요. 그래서 채팅에서 더 중요했던 건 “실시간으로 잘 받는가”만이 아니라, 연결이 흔들리거나 잠시 비어 있는 구간이 생겼을 때 그 상태를 얼마나 자연스럽게 다시 맞출 수 있는가였어요.
코드엘 iOS에서는 그래서 채팅을 하나의 실시간 스트림으로만 다루지 않았어요. 소켓 재연결로 연결을 다시 살리고, 구독 복구로 실시간 흐름을 이어 붙이고, unread 보완 동기화로 놓쳤을 수 있는 구간을 메우고, 읽음 상태 반영으로 서버와 클라이언트의 기준을 다시 맞추는 식으로 복구 흐름 전체를 함께 설계했어요.
완벽하게 끊기지 않는 채팅을 보장하는 건 어렵다고 생각해요. 대신 끊길 수 있다는 전제 위에서, 그 끊김이 사용자에게는 최대한 드러나지 않도록 다시 자연스럽게 이어지는 경험을 만드는 건 충분히 설계할 수 있는 문제라고 느꼈어요.
사용자가 채팅의 끊김 자체를 의식하기보다, 대화가 계속 자연스럽게 이어지고 있다고 느끼게 만드는 것.
이번 작업은 그 경험을 만들기 위해 채팅 복구 흐름을 다듬어가는 과정이었어요.
'iOS > 트러블슈팅' 카테고리의 다른 글
| 채팅 푸시 진입 흐름 안정화하기 (1) | 2026.04.17 |
|---|---|
| 커스텀 이미지 캐시로 성능개선하기 (1) | 2026.04.16 |
| 앱 생명주기를 고려한 딥링크 흐름 만들기 (0) | 2026.04.15 |
| 페이지 단위 조회로 인증목록 응답성 개선하기 (0) | 2026.04.13 |
| 친절한 에러 UX 만들기 (2) | 2026.04.12 |