코드엘 iOS 프로젝트에서 채팅 기능을 구현하다 보면 자연스럽게 이런 질문을 만나게 돼요.
"앱이 켜져 있을 때는 WebSocket으로 실시간 메시지를 받을 수 있는데, 앱이 꺼져 있을 때는 채팅 알림이 어떻게 오는 걸까?"
앱이 실행 중일 때의 실시간 수신은 비교적 이해하기 쉬워요.
서버와 WebSocket 연결이 살아 있으면 새 메시지를 바로 받을 수 있으니까요.
그런데 앱이 백그라운드에 있거나 완전히 종료된 상태에서도 알림이 온다는 건, 분명 다른 경로가 있다는 뜻이에요.
코드엘은 이 문제를 APNs와 Firebase Messaging(FCM)을 함께 사용하는 구조로 해결하고 있었어요.
이번 글에서는 iOS 푸시 알림이 어떤 흐름으로 동작하는지, 왜 코드엘이 FCM을 함께 사용했는지, 그리고 실제로 토큰 등록부터 메시지 수신까지 어떤 순서로 이어지는지 정리해보려고 해요.
WebSocket이 있는데 왜 Push가 또 필요할까
코드엘에서는 채팅 기능에 WebSocket 기반 실시간 통신을 사용하고 있어요.
앱이 실행 중이고 소켓 연결이 유지되고 있다면 서버는 새 메시지를 바로 내려줄 수 있어요.
하지만 여기엔 전제가 있어요.
- 앱 프로세스가 살아 있어야 한다
- 소켓 연결이 유지되고 있어야 한다
- 앱이 실시간 수신이 가능한 상태여야 한다
즉 앱이 완전히 종료되면 WebSocket 연결도 함께 사라져요.
이 상태에서는 서버가 실시간 메시지를 보내고 싶어도 받을 클라이언트 연결 자체가 없어요.
그래서 채팅 기능은 보통 이렇게 역할이 나뉘어요.
- 앱 실행 중: WebSocket으로 실시간 메시지 수신
- 앱 백그라운드/종료 상태: Push 알림으로 새 메시지 도착 사실 전달
즉 WebSocket과 Push는 경쟁 관계가 아니라, 앱 상태에 따라 역할이 달라지는 구조예요.
APNs는 무엇일까
iOS 푸시 알림에서 가장 먼저 등장하는 개념이 APNs예요.
APNs는 Apple Push Notification service의 줄임말로, 애플이 운영하는 공식 푸시 알림 전달 시스템이에요.
iPhone에 푸시를 직접 꽂아 넣는 건 우리 서버가 아니라 APNs예요.
흐름은 단순해요.
- 앱이 APNs 등록을 시도한다
- APNs가 이 앱 인스턴스를 식별할 수 있는 토큰을 발급한다
- 누군가 이 기기로 푸시를 보내고 싶으면 APNs를 통해야 한다
- APNs가 실제 iPhone에 알림을 전달한다
즉, iOS 푸시 알림의 최종 전달자는 항상 APNs예요.
그럼 FCM은 왜 필요할까
여기서 제일 많이 헷갈리는 부분이 나와요.
"어차피 iOS 푸시는 APNs로 가는 거라면, FCM은 왜 필요한 걸까?"
결론부터 말하면 FCM은 iOS에서 필수는 아니에요.
서버가 APNs를 직접 호출해서 푸시를 보내는 것도 가능해요.
즉 구조는 두 가지가 가능해요.
- 서버 -> APNs 직접 호출
- 서버 -> FCM -> APNs
코드엘은 두 번째 구조를 사용하고 있어요.
이때 역할은 이렇게 나뉘어요.
- APNs: 실제 iPhone에 푸시를 전달하는 최종 통로
- FCM: 서버가 푸시를 더 쉽게 보내도록 도와주는 중간 허브
즉 FCM은 APNs를 대체하는 게 아니라, APNs 앞단에서 푸시 토큰 관리와 메시지 전송을 더 쉽게 해주는 계층이라고 이해하면 돼요.
코드엘에서 FCM을 함께 쓰는 이유
코드엘 같은 앱에서는 FCM을 쓰면 몇 가지 장점이 있어요.
1. 서버가 APNs를 직접 다루는 부담이 줄어든다
서버가 APNs를 직접 붙으려면 보통 이런 걸 신경 써야 해요.
- APNs 인증키 관리
- APNs 요청 포맷 구성
- 응답 에러 처리
- 토큰 상태 관리
FCM을 쓰면 서버는 상대적으로 "이 토큰으로 이 메시지를 보내줘"라는 요청에 더 집중할 수 있어요.
2. 토큰 관리가 단순해진다
코드엘은 서버에 APNs 토큰이 아니라 FCM 토큰을 저장해요.
그러면 서버는 그 토큰을 기준으로 FCM에 푸시를 보내면 되고, 실제 iPhone 전달은 FCM이 APNs를 통해 처리해줘요.
3. 멀티 플랫폼 확장에 유리하다
안드로이드까지 함께 생각하면 장점이 더 커져요.
iOS는 APNs, Android는 각자 다른 구조를 직접 운영하는 대신, 서버 입장에서는 FCM을 중심으로 푸시 발송 흐름을 가져갈 수 있어요.
즉 FCM은 "없으면 안 되는 기술"이라기보다, "직접 APNs를 다루는 복잡함을 줄여주는 중간 계층"에 더 가까워요.
APNs 토큰과 FCM 토큰은 무엇이 다를까
푸시를 이해할 때 가장 헷갈리는 건 토큰이 두 개라는 점이에요.
APNs 토큰
- 애플이 발급하는 토큰
- 이 iPhone의 이 앱 인스턴스를 APNs 입장에서 식별하는 값
FCM 토큰
- Firebase가 발급하는 토큰
- Firebase Messaging이 이 앱 인스턴스를 식별하는 값
중요한 건 FCM 토큰이 APNs 토큰과 같은 값은 아니라는 점이에요.
정확히는 이 흐름에 가까워요.
- 앱이 APNs 토큰을 받는다
- 앱이 그 토큰을 Firebase에 전달한다
- Firebase가 이 앱 인스턴스에 대한 FCM 토큰을 관리한다
즉 Firebase는 APNs 토큰을 알고 있고, 그걸 바탕으로 자기 쪽 토큰 체계를 운영하는 거예요.
코드엘에서는 푸시를 어떻게 초기화하고 있을까
코드엘의 푸시 기본 세팅은 AppDelegate에서 시작돼요.
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
) -> Bool {
FirebaseApp.configure()
Messaging.messaging().delegate = self
Messaging.messaging().isAutoInitEnabled = true
UNUserNotificationCenter.current().delegate = self
application.registerForRemoteNotifications()
return true
}
이 코드가 하는 일은 이 정도로 정리할 수 있어요.
- Firebase 초기화
- Firebase Messaging delegate 등록
- 알림 관련 delegate 등록
- APNs 원격 푸시 등록 시도
여기서 한 가지는 구분해서 보면 더 정확해요.
코드엘은 앱 시작 시점에 푸시 인프라를 준비하지만, 사용자에게 실제 알림 권한을 요청하는 흐름은 별도 화면에서 다루고 있어요.
예를 들면 PushNotificationClient를 통해 권한 요청을 수행해요.
try await UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .badge, .sound]
)
즉 코드엘의 푸시 구조는 이렇게 나뉘어요.
- 앱 시작 시: Firebase/FCM/APNs 연결 준비
- 사용자 인터랙션 시: 알림 권한 요청
APNs 등록은 언제 일어날까
코드엘 기준으로 APNs 등록 시도는 앱 실행 시점이에요.
AppDelegate에서 아래 코드가 실행되죠.
application.registerForRemoteNotifications()
이건 "이 앱은 원격 푸시를 받을 수 있으니 등록 상태를 확인하고 필요하면 토큰을 달라"는 요청에 가까워요.
중요한 건 앱이 실행될 때마다 이 요청을 할 수는 있어도, 그때마다 항상 완전히 새로운 APNs 토큰이 발급되는 건 아니라는 점이에요.
보통은 기존 토큰이 유지되고, 필요할 때만 시스템이 갱신해요.
즉 흐름은 이렇게 이해하면 돼요.
- 앱 실행 시 APNs 등록 시도
- 상태가 같으면 기존 토큰 유지
- 필요하면 새 토큰 갱신
FCM 토큰도 비슷해요.
매번 새로 만들어지는 게 아니라, 필요할 때 갱신될 수 있는 구조예요.
APNs 토큰은 어떻게 Firebase와 연결될까
iOS가 원격 푸시 등록에 성공하면 앱은 APNs device token을 받아요.
코드엘은 이 토큰을 Firebase Messaging에 넘기고 있어요.
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
Messaging.messaging().apnsToken = deviceToken
}
즉 흐름은 이래요.
- 앱이 APNs 등록 시도
- APNs 토큰 발급
- 앱이 APNs 토큰을 Firebase에 전달
이 과정이 있어야 Firebase가 이 앱 인스턴스를 iOS 푸시 흐름과 연결해서 관리할 수 있어요.
코드엘에서는 FCM 토큰을 어떻게 받고 있을까
Firebase Messaging은 이후 FCM 토큰을 발급하거나 갱신할 수 있어요.
코드엘은 이 값을 받아 키체인에 저장하고 있어요.
nonisolated func messaging(_: Messaging, didReceiveRegistrationToken fcmToken: String?) {
print("💬 FCM Token: \\\\(fcmToken ?? "")")
Task {
await keychain.save(fcmToken ?? "", .fcmToken)
}
}
즉 코드엘은 이 디바이스의 현재 FCM 토큰을 안전하게 보관해두고, 이후 서버에 업로드할 수 있게 준비하는 구조예요.
서버에는 왜 FCM 토큰을 저장할까
푸시 알림은 결국 서버가 보내야 해요.
그러면 서버는 "어느 기기에 보내야 하는지" 알아야 하죠.
코드엘은 이때 APNs 토큰이 아니라 FCM 토큰을 서버에 저장해요.
예를 들어 로그인 이후 이런 흐름이 있어요.
let fcmToken = await keychain.read(.fcmToken)
if let fcmToken {
try? await saveFcmToken(fcmToken)
}
그리고 이 API는 이렇게 정의돼 있어요.
enum MemberAPI: APIConfigurable {
case saveFcmToken(token: String)
var path: String {
switch self {
case .saveFcmToken:
return "/v1/member/fcmtoken"
}
}
}
즉 코드엘의 토큰 등록 흐름은 이렇게 볼 수 있어요.
- 앱이 APNs 토큰을 받는다
- 앱이 APNs 토큰을 Firebase에 전달한다
- Firebase가 FCM 토큰을 발급/관리한다
- 앱이 FCM 토큰을 키체인에 저장한다
- 로그인 후 서버에 FCM 토큰을 업로드한다
이렇게 되면 서버는 특정 사용자에게 푸시를 보내고 싶을 때, 그 사용자의 FCM 토큰을 기준으로 메시지를 보낼 수 있어요.
실제로 앱이 종료된 상태에서 메시지가 오면 어떤 일이 일어날까
이제 제일 중요한 수신 흐름을 볼게요.
예를 들어 A 사용자가 B 사용자에게 채팅 메시지를 보냈다고 해볼게요.
1. 앱이 실행 중인 경우
이 경우에는 WebSocket/STOMP 연결이 살아 있을 가능성이 커요.
그래서 서버는 새 메시지를 WebSocket으로 바로 내려줄 수 있어요.
- 서버가 WebSocket으로 새 메시지 전달
- 코드엘이 실시간으로 화면 갱신
즉 앱이 살아 있을 때는 WebSocket이 주 역할을 해요.
2. 앱이 종료되거나 백그라운드인 경우
이 경우에는 WebSocket 연결을 기대하기 어려워요.
그래서 서버는 저장된 FCM 토큰을 기준으로 Firebase에 푸시를 요청해요.
그 이후 흐름은 이래요.
- CodeL 서버가 FCM에 푸시 요청
- FCM이 APNs에 전달
- APNs가 실제 iPhone에 알림 전달
즉 서버는 직접 iPhone에 보내는 게 아니라, FCM을 통해 APNs로 전달하고, APNs가 최종적으로 기기에 알림을 띄우는 구조예요.

정리하면 이 문장으로 설명할 수 있어요.
서버는 FCM에 보내고, 실제 iPhone 전달은 APNs가 한다.
코드엘은 푸시 payload를 어떻게 읽고 있을까
채팅 푸시는 단순히 "새 메시지가 도착했어요"라는 텍스트만 보내는 게 아니에요.
코드엘은 푸시 payload 안에서 채팅 진입에 필요한 값을 꺼내고 있어요.
struct PushNotificationPayload {
let type: PushNotificationType?
let chatRoomId: Int?
let lastReadChatId: Int?
init?(userInfo: [AnyHashable: Any]) {
if let typeString = userInfo["type"] as? String {
self.type = PushNotificationType(rawValue: typeString)
} else {
self.type = nil
}
if let roomIdString = userInfo["chatRoomId"] as? String {
self.chatRoomId = Int(roomIdString)
} else if let roomId = userInfo["chatRoomId"] as? Int {
self.chatRoomId = roomId
} else {
self.chatRoomId = nil
}
if let lastChatIdString = userInfo["lastReadChatId"] as? String {
self.lastReadChatId = Int(lastChatIdString)
} else if let roomId = userInfo["lastReadChatId"] as? Int {
self.lastReadChatId = roomId
} else {
self.lastReadChatId = nil
}
}
}
즉 코드엘의 채팅 푸시는 payload에 이런 정보를 담고 있어요.
- 이 푸시가 채팅인지 공지인지
- 어느 채팅방으로 들어가야 하는지
- 마지막으로 읽은 채팅 ID가 무엇인지
이렇게 하면 사용자가 푸시를 눌렀을 때 앱만 여는 게 아니라, 정확한 채팅방으로 진입할 수 있어요.
포그라운드에서 알림을 받으면 어떻게 동작할까
코드엘은 포그라운드에서 푸시를 받았을 때도 payload를 보고 동작을 나눠요.
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
let userInfo = notification.request.content.userInfo
Messaging.messaging().appDidReceiveMessage(userInfo)
guard let payload = PushNotificationPayload(userInfo: userInfo),
let type = payload.type,
let roomId = payload.chatRoomId else {
completionHandler([.banner, .sound, .list])
return
}
let currentRoomId = UserDefaults.standard.object(forKey: "ActiveChatRoomId") as? Int
if type == .chat, roomId == currentRoomId {
completionHandler([])
} else {
completionHandler([.banner, .sound, .list])
}
}
이미 같은 채팅방을 보고 있을 때 같은 방 메시지로 배너가 또 뜨면 오히려 방해가 될 수 있어요.
그래서 코드엘은 현재 보고 있는 채팅방과 푸시의 roomId가 같으면 배너를 띄우지 않도록 처리하고 있어요.
사용자가 알림을 탭하면 어디로 이동할까
푸시 알림은 단순히 알림을 띄우는 것에서 끝나지 않아요.
사용자가 탭했을 때 올바른 화면으로 이동해야 해요.
코드엘에서는 알림 탭 이벤트를 AppDelegate에서 받아 Coordinator로 넘기고 있어요.
nonisolated func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let userInfo = response.notification.request.content.userInfo
Messaging.messaging().appDidReceiveMessage(userInfo)
let payload = PushNotificationPayload(userInfo: userInfo)
guard let notificationType = payload?.type else {
completionHandler()
return
}
switch notificationType {
case .notice:
Task { @MainActor in
self.coordinatorStore?.send(.view(.refreshMemberStatusFromPush))
}
case .chat:
guard let chatRoomId = payload?.chatRoomId else {
completionHandler()
return
}
Task { @MainActor in
self.coordinatorStore?.send(
.view(.enterChatRoomFromPush(
roomId: chatRoomId,
lastChatId: payload?.lastReadChatId
))
)
}
}
completionHandler()
}
즉 코드엘은 푸시를 탭하면
- payload를 파싱하고
- 채팅 푸시면 chatRoomId를 꺼내고
- Coordinator에게 해당 채팅방으로 이동하라고 전달하는
식으로 라우팅하고 있어요.
앱이 종료된 상태에서 푸시로 시작되면 어떻게 될까
앱이 완전히 종료된 상태에서 푸시를 눌러 실행되면, 아직 화면 스택이나 데이터가 다 준비되지 않았을 수 있어요.
코드엘은 이 상황도 고려하고 있어요.
if let remoteNotification = launchOptions?[.remoteNotification] as? [AnyHashable: Any],
let payload = PushNotificationPayload(userInfo: remoteNotification),
let chatRoomId = payload.chatRoomId {
UserDefaults.standard.set(chatRoomId, forKey: "PendingChatRoomId")
}
즉 종료 상태에서 푸시로 시작되면 바로 무리하게 채팅방에 진입하지 않고, 먼저 PendingChatRoomId로 저장해둬요.
그리고 이후 앱 상태가 준비되면 이 값을 기준으로 진입을 이어갈 수 있게 해요.
코드엘의 푸시 흐름을 한 번에 정리해보면

마치며
코드엘에서 채팅 푸시 흐름을 따라가보면서 느낀 건,
"앱이 꺼져 있어도 알림이 온다"는 사용자 경험 뒤에는 생각보다 역할이 잘 나뉜 구조가 있다는 점이었어요.
- WebSocket은 앱이 살아 있을 때의 실시간 수신
- APNs는 iPhone에 알림을 최종 전달하는 통로
- FCM은 서버와 APNs 사이에서 푸시를 더 쉽게 관리하게 해주는 중간 허브
정리하면 코드엘의 채팅 푸시는 이렇게 이해할 수 있어요.
- 앱이 APNs 등록을 시도한다
- APNs 토큰을 Firebase에 연결한다
- Firebase가 FCM 토큰을 관리한다
- 앱은 FCM 토큰을 서버에 저장한다
- 앱이 종료된 상태에서 메시지가 오면 서버는 FCM으로 푸시를 보낸다
- FCM은 APNs를 통해 실제 iPhone에 알림을 전달한다
- 사용자가 알림을 탭하면 payload를 기반으로 해당 채팅방으로 이동한다
이번 글에서는 WebSocket 바깥에서 채팅 경험을 이어주는 푸시 구조에 집중해봤어요.
실시간 채팅을 구현할 때는 "앱이 켜져 있을 때"뿐 아니라, "앱이 꺼져 있을 때도 사용자 경험이 어떻게 이어지는가"까지 함께 설계해야 한다는 걸 코드엘을 보면서 많이 느낄 수 있었어요.