코드엘 iOS 프로젝트를 개발하면서, 채팅 푸시 알림을 눌렀을 때의 진입 흐름이 생각보다 쉽게 흔들릴 수 있다는 걸 느꼈어요.
겉으로 보면 단순한 기능처럼 보여요. 푸시를 누르면 해당 채팅방으로 이동하면 되니까요. 그런데 실제 서비스에서는 그렇게 단순하지 않았어요. 앱이 이미 실행 중일 수도 있고, 백그라운드에 있다가 돌아오는 상황일 수도 있고, 완전히 종료된 상태에서 푸시로 시작될 수도 있었거든요.
여기에 사용자가 이미 다른 채팅방을 보고 있거나, 아직 채팅 목록이 준비되지 않은 경우까지 겹치면 같은 채팅 푸시라도 전혀 다른 결과가 나올 수 있었어요.
그래서 이번 글에서는 코드엘 iOS에서 채팅 푸시 진입 흐름을 어떻게 정리했는지, 그리고 왜 이 개선이 필요했는지 정리해보려고 해요.
문제의 발견
처음에는 저도 “푸시 payload에서 roomId만 꺼내서 바로 해당 채팅방으로 보내면 되는 것 아닌가?”라고 생각했어요.
하지만 실제 서비스에서는 이 방식이 금방 흔들렸어요.
예를 들어 사용자가 이미 같은 채팅방을 보고 있는 상태에서 같은 푸시를 다시 누를 수도 있고, 다른 채팅방을 보고 있다가 새로운 채팅방 푸시를 누를 수도 있어요. 또 앱이 완전히 종료된 상태라면 아직 채팅 목록이 메모리에 없어서, 바로 진입할 대상 채팅방 모델을 찾을 수 없을 수도 있었어요.
문제는 이런 차이가 사용자 경험에서는 모두 “채팅 푸시 진입이 일관되지 않다”로 느껴질 수 있다는 점이었어요.
- 같은 채팅방 푸시를 반복 탭하면 화면이 중복으로 쌓일 수 있었어요.
- 다른 채팅방으로 이동할 때 기존 상태가 충분히 정리되지 않을 수 있었어요.
- 앱 종료 상태에서는 채팅 목록이 아직 준비되지 않아 오진입 가능성이 있었어요.
- 이미 보고 있는 채팅방에서도 푸시 배너가 다시 떠 현재 대화 흐름을 방해할 수 있었어요.
결국 필요한 건 “채팅방으로 이동하는 코드 한 줄”이 아니라, 앱 상태와 무관하게 같은 규칙으로 동작하는 공통 진입 흐름이었어요.
하나의 roomId로는 상태를 설명하기 어려웠어요
이 문제를 풀면서 먼저 정리한 건, 채팅 푸시 진입을 하나의 상태로 보지 않는 것이었어요.
처음에는 “지금 들어가야 할 채팅방 ID 하나만 관리하면 되지 않을까?”라고 생각할 수 있어요. 그런데 실제로는 지금 화면에서 보고 있는 채팅방과, 아직 열지는 못했지만 조금 뒤에는 반드시 열어야 하는 채팅방은 전혀 다른 상태였어요.
이 둘을 하나로 다루기 시작하면 흐름이 금방 꼬여요.
- 지금 이미 보고 있는 방인지
- 진입 예약만 된 방인지
- 지금 바로 열 수 있는지
- 채팅 목록이 준비된 뒤 열어야 하는지
이 기준이 섞이면 같은 roomId라도 처리 방식이 달라질 수밖에 없었어요.
그래서 코드엘에서는 채팅 푸시 진입 상태를 active와 pending으로 분리했어요.
extension SharedKey where Self == AppStorageKey<Int?>.Default {
/// 현재 실제로 화면 최상단에서 보고 있는 채팅방 ID예요.
/// 같은 채팅방 푸시를 다시 눌렀을 때 중복 진입을 막는 기준으로 사용해요.
static var chatRoomId: Self {
Self[.appStorage("ActiveChatRoomId"), default: nil]
}
}
extension SharedKey where Self == AppStorageKey<Int?>.Default {
/// 아직 채팅 목록이 준비되지 않아 바로 열 수는 없지만,
/// 목록이 로드되면 즉시 열어야 하는 채팅방 ID예요.
static var pendingChatRoomId: Self {
Self[.appStorage("PendingChatRoomId"), default: nil]
}
}
이렇게 나누고 나니 기준이 훨씬 선명해졌어요.
- activeChatRoomId는 지금 보고 있는 방
- pendingChatRoomId는 나중에 열어야 하는 방
즉, “현재 상태”와 “예약된 상태”를 분리한 거예요. 이 차이가 이후 라우팅 규칙을 훨씬 단순하게 만들어줬어요.
바로 열 수 없을 때는 먼저 보관했어요
앱이 완전히 종료된 상태에서 푸시를 눌러 실행되면, 아직 루트 탭도 준비되지 않았고 채팅 목록 데이터도 메모리에 없을 수 있어요. 이 시점에 무리해서 바로 채팅방으로 진입하려고 하면 오히려 타이밍이 꼬일 수 있었어요.
그래서 종료 상태에서는 곧바로 화면 전환을 시도하지 않고, 먼저 pendingChatRoomId에 저장해두는 방식을 선택했어요.
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// 이전 실행에서 남아있을 수 있는 채팅방 상태를 먼저 비워줘요.
// 그래야 오래된 값 때문에 잘못 진입하는 상황을 막을 수 있어요.
UserDefaults.standard.removeObject(forKey: "ActiveChatRoomId")
UserDefaults.standard.removeObject(forKey: "PendingChatRoomId")
// 앱이 완전히 종료된 상태에서 푸시로 시작된 경우예요.
if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any],
let payload = PushNotificationPayload(userInfo: remoteNotification),
let chatRoomId = payload.chatRoomId {
// 이 시점에는 아직 채팅 목록이 준비되지 않았을 수 있어요.
// 그래서 바로 열지 않고, 나중에 소비할 수 있도록 pending 값으로 저장해둬요.
UserDefaults.standard.set(chatRoomId, forKey: "PendingChatRoomId")
}
return true
}
이렇게 해두면 앱은 먼저 정상적인 초기화 흐름을 타고, 이후 채팅 목록이 준비된 뒤 해당 채팅방으로 안전하게 진입할 수 있어요.
개인적으로 이 부분에서 느낀 건, 종료 상태 푸시 진입은 “얼마나 빨리 여는가”보다 “앱이 준비된 뒤 정확하게 여는가”가 더 중요하다는 점이었어요.
같은 방은 막고, 다른 방은 정리한 뒤 이동했어요
그다음으로 정리한 건, 푸시를 눌렀을 때 현재 네비게이션 스택을 어떻게 다룰지였어요.
여기서 먼저 세운 기준은 단순했어요.
- 이미 같은 채팅방을 보고 있다면 아무 것도 하지 않기
- 다른 채팅방을 보고 있다면 기존 상태를 정리한 뒤 이동하기
- 아직 채팅 목록에 대상 채팅방이 없다면 pending으로 보관하기
코드로는 이런 흐름이었어요.
case let .enterChatRoomFromPush(roomId, lastChatId):
// 채팅 푸시를 눌렀다면 먼저 채팅 탭으로 전환해요.
state.rootTab?.$rootTab.withLock { $0 = .chat }
// 기존 채팅방을 정리해야 할 수도 있어서 종료용 이펙트를 모아둬요.
var finishEffects: [Effect<Action>] = []
if let last = state.path.last {
switch last {
case let .chatRoom(chatRoomState):
// 이미 같은 채팅방을 보고 있다면 다시 쌓지 않아요.
if chatRoomState.roomId == roomId {
return .none
}
// 다른 채팅방이라면 기존 채팅방의 읽음 처리와 구독 해제를 먼저 수행해요.
finishEffects.append(
.send(.socket(.chatDidFinish(
roomdId: chatRoomState.roomId,
lastReadChatId: chatRoomState.lastReadChatId
)))
)
// 기존 채팅방 화면은 스택에서 제거해요.
state.path.removeLast()
default:
// 마지막 화면이 채팅방이 아니면 path를 정리해
// 진입 흐름을 단순하게 유지해요.
state.path.removeAll()
}
}
let cleanupEffect: Effect<Action> = finishEffects.isEmpty ? .none : .merge(finishEffects)
// 이미 채팅 목록에 대상 채팅방이 있으면 바로 진입할 수 있어요.
if let chatRoom = state.rootTab?.chat.chatRooms.data.first(where: { $0.id == roomId }) {
// 푸시에서 전달된 마지막 읽음 정보를 반영한 새 모델을 만들어요.
var newChatRoom = chatRoom
newChatRoom.lastReadChatId = lastChatId
return .concatenate(
cleanupEffect,
.send(.navigation(.chatRoom(newChatRoom)))
)
} else {
// 아직 목록이 없으면 바로 열지 않고 pending으로 저장해둬요.
state.$pendingChatRoomId.withLock { $0 = roomId }
}
return .none
이 규칙을 먼저 세우고 나니 푸시 진입 흐름이 훨씬 단순해졌어요.
같은 방이면 유지하고, 다른 방이면 정리 후 전환하고, 지금 열 수 없으면 보관하면 됐거든요.
채팅 목록이 준비된 뒤에 pending 채팅방을 열었어요
푸시로 받은 roomId가 이미 채팅 목록에 있으면 바로 진입할 수 있어요. 하지만 앱 초기화 직후처럼 목록이 아직 비어 있는 순간에는 그럴 수 없어요.
그래서 pendingChatRoomId는 채팅 목록 로드가 끝난 시점에 소비하도록 연결했어요.
case .consumePendingChatRoomIfNeeded:
// 나중에 열어야 할 채팅방이 있는지 먼저 확인해요.
guard let pendingChatRoomId = state.$pendingChatRoomId.wrappedValue else {
return .none
}
// 목록이 준비된 뒤 해당 roomId와 일치하는 채팅방을 찾으면
// 그때 실제 화면 전환을 수행해요.
guard let pendingChatRoom = state.chatRooms.data.first(where: { $0.id == pendingChatRoomId }) else {
return .none
}
// 중복 진입을 막기 위해 실제 진입 직전에 pending 값을 비워줘요.
state.$pendingChatRoomId.withLock { $0 = nil }
return .send(.delegate(.showChatRoom(pendingChatRoom)))
그리고 채팅 목록을 받아온 뒤에는 바로 이 로직을 호출했어요.
case let .chatRooms(model):
// 첫 페이지라면 기존 목록을 비우고 새로 구성해요.
if model.page == 0 {
state.chatRooms.data.removeAll()
}
// 페이지 정보와 채팅방 목록을 상태에 반영해요.
state.chatRooms.page = model.page + 1
state.chatRooms.size = model.size
state.chatRooms.totalElements = model.totalElements
state.chatRooms.totalPages = model.totalPages
state.chatRooms.hasNext = model.hasNext
state.chatRooms.data.append(contentsOf: model.data)
// 최신 대화가 위로 오도록 정렬해요.
state.chatRooms.data.sort { $0.updatedAt > $1.updatedAt }
// 목록이 준비되었으면 pending 채팅방이 있는지 바로 확인해요.
return .send(.consumePendingChatRoomIfNeeded)
이 방식 덕분에 채팅 목록이 아직 준비되지 않은 상태에서도 푸시 진입 흐름이 깨지지 않았어요. 즉시 열 수는 없어도, 정확한 시점에 자연스럽게 이어지도록 만들 수 있었어요.
포그라운드에서는 현재 보고 있는 채팅방 알림을 숨겼어요
마지막으로 손본 건 포그라운드 UX였어요.
이미 어떤 채팅방을 보고 있는 중에도 같은 방의 푸시 배너가 계속 뜨면, 사용자 입장에서는 새로운 정보라기보다 현재 대화를 방해하는 요소에 더 가까웠어요. 그래서 포그라운드 수신 시에는 현재 보고 있는 채팅방과 푸시의 roomId를 비교해, 같은 방이면 배너를 띄우지 않도록 처리했어요.
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let userInfo = notification.request.content.userInfo
// 푸시 payload를 파싱해요.
guard let payload = PushNotificationPayload(userInfo: userInfo),
let type = payload.type,
let roomId = payload.chatRoomId else {
// 필요한 값이 없으면 일반 알림처럼 그대로 노출해요.
completionHandler([.banner, .sound, .list])
return
}
// 현재 실제로 보고 있는 채팅방 ID를 가져와요.
let currentRoomId = UserDefaults.standard.object(forKey: "ActiveChatRoomId") as? Int
if type == .chat, roomId == currentRoomId {
// 이미 같은 채팅방에 들어와 있다면 불필요한 배너는 숨겨요.
completionHandler([])
} else {
completionHandler([.banner, .sound, .list])
}
}
여기에 더해 채팅방에 실제로 진입했을 때는, 해당 방에 남아 있던 전달 완료 알림도 함께 제거했어요. 이렇게 해야 사용자가 이미 들어온 방의 알림을 다시 보지 않게 되거든요.
case .bodyTask:
return .merge(
// 채팅방에 진입하면 이 방과 관련된 전달 완료 알림을 제거해요.
.run { [roomId = state.roomId] _ in
notificationCenter.removePendingChatNotifications(roomId)
}
)
이 부분은 기능 구현이라기보다 UX 정리에 더 가까웠어요.
“지금 이 사용자에게 이 알림이 정말 필요한가?”를 기준으로 다시 보면서, 현재 대화 흐름을 덜 방해하는 방향으로 개선할 수 있었어요.
결과
이 구조를 적용한 뒤에는 채팅 푸시 진입 흐름이 이전보다 훨씬 일관돼졌어요.
- 앱이 포그라운드, 백그라운드, 종료 상태 중 어디에 있든 같은 기준으로 채팅 푸시를 처리할 수 있었어요.
- 같은 채팅방 푸시를 반복 탭해도 화면이 중복으로 쌓이지 않게 됐어요.
- 다른 채팅방으로 이동할 때는 기존 상태를 먼저 정리한 뒤 전환하도록 만들어 흐름이 덜 꼬이게 됐어요.
- 채팅 목록이 아직 준비되지 않은 순간에도 pendingChatRoomId를 통해 안정적으로 진입을 이어갈 수 있었어요.
- 현재 대화 중인 채팅방의 포그라운드 알림을 억제해 불필요한 배너 노출도 줄일 수 있었어요.
개인적으로는 이 작업을 하면서, 푸시 진입 안정화는 단순히 화면 하나를 잘 여는 문제가 아니라는 걸 더 분명히 느꼈어요.
앱 상태, 네비게이션 스택, 데이터 준비 시점, 포그라운드 알림 경험까지 함께 봐야 비로소 사용자가 예상하는 흐름이 만들어지더라고요.
마치며
처음에는 채팅 푸시를 누르면 채팅방만 잘 열리면 된다고 생각했어요. 하지만 실제 서비스에서는 “잘 열린다”보다 “언제 눌러도 같은 규칙으로 안정적으로 열린다”가 더 중요했어요.
코드엘 iOS에서는 이번 작업을 통해 채팅 푸시 진입을 예외 처리들의 조합이 아니라, 현재 상태와 다음 상태를 분리해서 관리하는 흐름으로 다시 정리하려고 했어요. 그 결과 중복 스택과 오진입 가능성을 줄이고, 앱 생명주기 변화에도 덜 흔들리는 채팅 진입 경험을 만들 수 있었어요.
'iOS > 트러블슈팅' 카테고리의 다른 글
| 채팅 소켓 통신에서 메시지 누락 대응하기 (1) | 2026.04.20 |
|---|---|
| 커스텀 이미지 캐시로 성능개선하기 (1) | 2026.04.16 |
| 앱 생명주기를 고려한 딥링크 흐름 만들기 (0) | 2026.04.15 |
| 페이지 단위 조회로 인증목록 응답성 개선하기 (0) | 2026.04.13 |
| 친절한 에러 UX 만들기 (2) | 2026.04.12 |