본문 바로가기
App/SaveFood

푸시 알림을 구현하며 겪은 설정 지옥

by 조현성 2025. 6. 26.

썸네일

 

세이브푸드를 개발하면서 이 앱 기능 중 최고의 꽃은 푸시 알림(Push Notifications)이라고 생각했다. 사용자에게 보내지는 소중한 푸시 알림으로 냉장고 보관함에 다른 사용자를 초대하고, 유통기한이 얼마 남지 않았음을 알려야 하는 핵심 기능이라고 생각했기 때문이다.

세이브푸드 iOS앱

 

푸시 알림을 구현하고 테스트해보려면 반드시 애플 개발자 프로그램(Apple Developer Program)에 가입되어야 한다. 개발자 프로그램 가입 후 웹과 엑스코드(Xcode)에서 간단한 설정만 하면 쉽게 구현할 수 있을 줄 알았다. 때문에 요구사항과 설계내역을 검토한 후 더 어려워 보이는 보관함의 사용자 초대부터 구현하며 인고의 시간을 보내고 있었다.

인고의 시간이라고 표현한 이유는 무엇일까?

바이브코딩으로 ChatGPT와 대화를 많이 나누다 보니, 메인으로 사용하던 [ChatGPT4o] 모델의 대화 한도가 차기 시작 했고, 다른 모델을 사용하더라도 1개의 채팅 대화는 무한으로 할 수 있는 줄 알았는데 대화를 하다 보니 대화량이 너무 많아 새로운 창으로 대화해달라고 할 때는 프로젝트를 시작할 때부터 함께 했던 맥락(Context)이 끊어질까봐 벌벌 떨었다.

ChatGPT 한도 도달

 

그리고 ChatGPT의 개인 맞춤 설정 메뉴를 확인하면 아래와 같이 메모리를 확인할 수 있는데, 메모리 100%에 도달한 순간부터 지속적으로 필요 없는 것은 선택해서 삭제도 해주고 있다.

ChatGPT 개인 맞춤 설정 메뉴

 

그러던 어느 날, 왼쪽 메뉴창을 보니 [프로젝트]라는 항목이 눈에 밟혔다. "대화 내용을 프로젝트 단위로 묶어 관리할 수 있다고!?" 이것을 발견 한 후로는 아래 그림과 같이 프로젝트를 생성하여 대화를 관리하고 있다. 대화가 너무 길어지거나 새로운 주제에 대해서 이야기하고 싶다면, 새로운 대화를 시작하여 프로젝트 밑에 맥락을 표시하는 식이다.

SaveFood 프로젝트 바이브코딩 내역

 

이것들 또한 ChatGPT의 설정이라면 설정인데, 여기서부터 설정 지옥에 빠졌던 것 같다. 육아를 병행하며 약 2주간의 시간을 들여 보관함에 식자재를 저장하는 기능과 나의 보관함에 다른 사용자를 초대할 수 있는 기능 개발을 완료했다.

뭐든지 할 수 있을 것 같다!

이 어려운 기능을 개발하다니, 이 데이터 모델들의 연관 관계를 설계하고 정말 구현을 했다는 사실에 자신감이 치솟았다. 이제 푸시 알림만 간단히 개발하면 되는 순간이었고, 부랴부랴 129,000원을 납부하고 애플 개발자 프로그램에 가입했다. 여기서 한번 더 인고의 시간을 보내게 된다.

 

서둘러 푸시 알림을 구현해 보고 싶어서 발을 동동 구루고 있었는데 애플에서 개발자 계정 활성화가 되지 않았고, 48시간 안에 처리된다는 안내문구를 믿고 계속해서 기다리고 있었다. 4월 10일 멤버십 결제를 했는데, 4일이 지난 4월 14일까지 기다리다가 이건 아닌 것 같아 애플 개발자 지원센터에 문의를 넣어, 개발자 계정 활성화 조치가 이루어졌다. (애플 개발자웹사이트에서 앱으로 결제를 유도하는데, 이 과정에서 버그가 있는것 같다.)

애플 개발자 지원센터 지원 내역

 

"자, 이제 나만... 아니 나와 ChatGPT의 바이브코딩으로 잘하면 된다. 다시 시작하자"라는 마음을 먹으며 여전히 쉽게 생각하고 덤벼 들었다. 애플 개발자 계정이 활성화되는 동안 푸시 구현 관련 여러 유튜브 동영상도 섭렵했다고 생각했기 때문이었다.

 

푸시 알림을 구현하기 전까지, 자동생성 되는 프로젝트App.swift 즉, SaveFoodApp.swift 파일에 AppDelegate 클래스를 다룬 적이 없었다. 알림을 위해서는 무조건 AppDelegate 클래스를 선언해야 하기에 작성을 시작했고, info.plist 파일에도 FirebaseAppDelegateProxyEnabled 옵션을 YES로 할 것인지 NO로 할 것인지에 따라 코드 구현도 달라졌다. 이렇게 설정 지옥이 시작 됐다.

FirebaseAppDelegateProxyEnabled 옵션 질문 내용

 

또, Xcode의 [Signing & Capabilities] 옵션, 애플 개발자 사이트의 [Certificates, Identifiers & Profiles] 메뉴의 모든 하위 메뉴들, 파이어베이스 콘솔의 프로젝트 설정과 클라우드 메시징 설정에서 APN 인증키를 사용할 것이냐 APN 인증서를 사용할 것이냐 까지... 모든 설정들이 혼돈의 집약체 였다.

 

그 어렵게 생각했던 보관함 기능까지 구현을 완료했는데, 뭐 든지 할 수 있을 것 같았는데, 이 설정들 속에 앱은 터지며 크래시(Crash)가 나기 일쑤였고, 알 수 없는 에러(Error) 메시지들도 쏟아졌다.

그냥 포기할까...?

스멀스멀 또 그놈이 찾아왔다. 포기... 이미 여러 번 포기를 해서 포기를 포기하기로 하고 다시 마음을 잡았다. 회사에서 일을 할 때 가끔 사람들에게 "뭘 그렇게 하나하나 모든 걸 알아야 돼요?" 라는 말을 듣고는 했다. 나는 이 말이 다른 사람들에게 위임을 하지 못한다거나 신뢰를 하지 않는다는 뉘앙스로 받아들여져서 최근 3~4년간은 관리자로서 위임할 건 위임하고, 내가 할 건 내가 하고, 시킬 건 시키자는 식이었다.

 

그런데 이제 본연의 나로 돌아갈 시간이었다. 다시 모든 것을 하나씩 짚어 보기로 했다. 지금은 회사에서 일하는게 아니라 혼자서 취미(?)로 해보는 나만의 시간이기 때문이다. 더군다나 ChatGPT는 아직 까지 참 신뢰 할 수가 없다. 바이브코딩 과정에서도 툭툭 던져지는 할루시네이션(Hallucination)으로 이미 고생을 톡톡히 했기 때문이다.

1. 애플 개발자 웹 설정

출처 : https://developer.apple.com/account

 

애플 개발자웹에서 모든 인증서(Certificates), 식별자(Identifiers), 프로파일(Profiles), 키(Keys)를 지우고 새로 만들었다.

 

✅ 인증서 (Certificates)

아래 그림과 같이 2개의 인증서가 존재해야 한다. TYPE으로 보면 구분할 수 있듯이 1개는 개발자 인증서이고, 1개는 푸시 알림용 인증서이다.

애플 개발자 웹 인증서 설정

✅ 식별자(Identifiers)

식별자에서 Push Notifications를 찾아서 활성화해야 한다. 체크해서 활성화했다고 끝이 아니었다. 처음 설정 하는 것이면 아마 [Config] 버튼으로 되어 있을 텐데 설정을 들어가서 위에서 생성한 인증서와 매핑해야 한다. 그럼 아래 그림과 같이 Certificates(1) 표시가 있음을 확인할 수 있다.

애플 개발자 웹 식별자 설정

✅ 프로파일(Profiles)

프로파일 메뉴에서 프로비저닝 프로파일(Provisioning Profile)을 생성해야 한다.

애플 개발자 웹 프로파일 설정

생성이 완료된 후 아래와 같이 다운로드하면 Provisioning_Profile.mobileprovision 파일 (여기서는 SaveFood_Provisioning_Profile.mobileprovision 파일)이 생기는데, 이것을 더블 클릭해서 Xcode에 인식시켜 두어야 한다. 참고 사항으로 테스트할 기기를 추가할 경우 여기서 Edit 버튼을 눌러 기기를 추가시킨 후 재발급해서 다시 다운로드하고 다시 더블 클릭해서 Xcode에 인식시켜야 한다.

프로비저닝 프로파일

✅ 키(Keys)

키 메뉴에서는 파이어베이스에 업로드할 APNs(Apple Push Notification service) 키를 발급할 수 있다.

애플 개발자 웹 키 설정

다운로드한 파일을 KEY-ID와 TEAM-ID와 함께 기재하여 파이어베이스에 업로드할 수 있다. 참고로 키는 운영용(Production)과 개발용(Sandbox)을 따로 발급할 수도, 혼합형으로 발급할 수도 있는데 나중에 운영용 키를 따로 발급하기 위해 현재 개발 중에는 개발용(Sandbox) 키로 발급했다.

애플 개발자 웹 키 상세 설정

2. 파이어베이스 콘솔 설정

파이어베이스 콘솔

처음에는 파이어베이스 일반 설정의 환경유형이 프로덕션 모드가 아닌, 미지정되었다는 것에 의심하고 프로덕션 모드로 변경도 해봤는데 결국 미지정으로도 되는 것을 확인하였다. (이거에 맞춰서 위 애플 웹에서 설정하는 키도 운영용으로 다시 발급하고, 개발용으로 또 재발급하는 노가다를...) 결국 체크해야 할 것은 클라우드 메시징 설정이다.

 

✅ 클라우드 메시징(Cloud)

어느 곳에서는 FCM(Firebase Cloud Messaging)으로 설정이 있고, 어느 곳에서는 그냥 '클라우드메시징'으로 설정이 있고, 또 어느 곳에서는 'Messaging'메뉴가 있다. 다 같은 것이다. 설정은 간단하다. 위에 애플 개발자웹의 키(Keys) 메뉴에서 발급받은 APNs키를 업로드시키기만 하면 된다.

파이어베이스 프로젝트 클라우드 메시징 설정

메뉴만 보면 APN 인증서라는 것이 있고, 개발 APN과 프로덕션 APN을 업로드하는 곳이 있어서 뭔가 조치를 해야 할 것 같지만 할 필요 없다. APN 인증 키 업로드 만으로 동작한다. 문제는 유튜브 자료들이 대부분 1~2년 지난 영상들이다 보니 APN 인증서로 가이드가 있어서 여기서도 삽질을 많이 했다.

3. 엑스코드 Signing & Capabilities 설정

엑스코드 Signing & Capabilities 메뉴

✅ 사인잉(signing)

애플 개발자웹에서 설정했던 인증서와 프로파일을 수동으로 관리하기 위해 Automatically manage signing 옵션을 체크 해제 했다. 앞으로 여러 인증서와 프로비저닝 프로파일을 다룰 것이라서 수동으로 하는것이 맘 편하다고 생각했다.

 

✅ 백그라운드모드(Background Modes)

좌측 상단의 [Capability] 플러스(+) 버튼을 누르면 추가할 수 있다. 알림 수신을 위해 Background fetch, Background processing, Remote notifications 옵션을 체크했다. 유튜브 영상들 및 검색하면 나오는 자료들에 공통적으로 가이드가 있는 사항이다.

엑스코드 백그라운드모드 및 푸시 알림 설정

✅ 푸시알림(Push Notifications)

대망의 푸시 알림 설정이다. 간단하다. 마찬가지로 좌측 상단의 [Capability] 플러스(+) 버튼을 누르면 추가 할 수 있다.

 

드디어 설정 지옥에서 빠져나올 수 있을 것 같지만 아직 한발 더 남았다. 이것을 간과하면 매우 큰 괴롭힘을 당하게 된다.

4. info.plist 파일

FirebaseAppDelegateProxyEnabled 옵션

✅ 파이어베이스 앱 딜리게이트 프록시 인에이블 (FirebaseAppDelegateProxyEnabled)

AppDelegate 클래스를 다룰 것이므로 NO로 설정한다.

 

이제 모든 설정은 끝났다고 생각했다. 애플에서 플랫폼 제공자(Platform Provider)로서 가이드하고 있는 모든 지침을 따랐고, 구글의 클라우드 서비스 제공자(Cloud Service Provider)로서 가이드하고 있는 설정도 모두 했다고 생각했다. 이제 남은 건 코드 수정뿐이었다.

 

AppDelegate 클래스를 별도 AppDelegate.swift파일로 분리시켰다가, 다시 SaveFoodApp.swift파일에 포함시키기를 반복했고, Delegate의 일부 기능을 별도 Protocol과 Service로 만들어서도 시도했지만, 수많은 크래시 속에 다시 원점으로 돌아왔고, 폴더 구조부터 앱이 실행되면 동작하는 코드 한 줄 한 줄을 다시 보기로 했다.

 

앱이 크래시 나는 원인은 파이어베이스의 구성(FirebaseApp.configure)과 FCM(Firebase Cloud Messaging) 토큰을 얻어오거나 갱신하는 시점, APNs(Apple Push Notification service)의 등록 요청 및 완료 여부의 타이밍에 따라 발생했기 때문이다. 최종 소스코드는 아래와 같다.

5. 소스코드

✅ SaveFoodApp.swift

import UIKit
import SwiftUI
import FirebaseCore
import FirebaseMessaging
import UserNotifications
@preconcurrency import FirebaseAuth
@preconcurrency import FirebaseFirestore

// MARK: - 🚀 SwiftUI 앱 진입점
@main
struct SaveFoodApp: App {
    // ✅ AppDelegate는 Firebase 초기화 및 delegate 설정 담당
    class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate, MessagingDelegate {
        
        func application(
            _ application: UIApplication,
            didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
        ) -> Bool {
            // ✅ Firebase 초기화
            FirebaseApp.configure()
            log("✅ FirebaseApp.configure() 완료")
            
            UNUserNotificationCenter.current().delegate = self
            Messaging.messaging().delegate = self
            log("✅ Delegate 설정 완료")
            
            return true
        }
        
        // ✅ APNs 토큰 등록 성공 시 Firebase에 전달
        func application(
            _ application: UIApplication,
            didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
        ) {
            Messaging.messaging().apnsToken = deviceToken
            let tokenString = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()
            log("✅ APNs 토큰 등록 완료: \(tokenString)")
        }
        
        // ✅ APNs 토큰 등록 실패 시
        func application(
            _ application: UIApplication,
            didFailToRegisterForRemoteNotificationsWithError error: Error
        ) {
            log("❌ APNs 토큰 등록 실패: \(error.localizedDescription)")
        }
        
        // ✅ FCM 토큰 갱신 시 호출됨 → Firestore에 저장 처리
        nonisolated func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
            log("📲 FCM 토큰 갱신됨: \(fcmToken ?? "없음")")
            
            Task { @MainActor in
                guard let uid = Auth.auth().currentUser?.uid,
                      let token = fcmToken else { return }
                do {
                    try await Firestore.firestore()
                        .collection("users")
                        .document(uid)
                        .setData(["fcmToken": token], merge: true)
                    log("✅ Firestore에 FCM 토큰 업데이트 완료 (from AppDelegate)")
                } catch {
                    log("❌ FCM 토큰 업데이트 실패 (from AppDelegate): \(error.localizedDescription)")
                }
            }
        }
        
        // ✅ 앱이 포그라운드일 때 알림 표시 방식 지정
        nonisolated func userNotificationCenter(
            _ center: UNUserNotificationCenter,
            willPresent notification: UNNotification,
            withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
        ) {
            completionHandler([.banner, .sound, .badge])
        }
        
        // ✅ 알림 클릭 시 처리
        nonisolated func userNotificationCenter(
            _ center: UNUserNotificationCenter,
            didReceive response: UNNotificationResponse,
            withCompletionHandler completionHandler: @escaping () -> Void
        ) {
            log("🔔 알림 클릭됨: \(response.notification.request.identifier)")
            completionHandler()
        }
    }
    
    // ✅ AppDelegate 연결
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    // ✅ ViewModel 공유 상태값
    @StateObject private var notificationVM: NotificationViewModel
    @StateObject private var authenticationVM: AuthenticationViewModel
    @StateObject private var storageVM: StorageViewModel
    
    // ✅ 앱 초기화 시 서비스 및 뷰모델 구성
    init() {
        // 🔗 서비스 인스턴스 생성
        let authService = AuthenticationService()
        let userService = UserService()
        let notificationService = NotificationService()
        let storageService = StorageService()
        
        // 📦 ViewModel 인스턴스 생성
        let notificationVM = NotificationViewModel(notificationService: notificationService)
        let authenticationVM = AuthenticationViewModel(
            authService: authService,
            userService: userService,
            notificationService: notificationService
        )
        let storageVM = StorageViewModel(storageService: storageService)
        
        // 💾 ViewModel 상태 주입
        _notificationVM = StateObject(wrappedValue: notificationVM)
        _authenticationVM = StateObject(wrappedValue: authenticationVM)
        _storageVM = StateObject(wrappedValue: storageVM)
    }
    
    
    // ✅ 앱 시작 시 SplashView에 모든 ViewModel 주입
    var body: some Scene {
        WindowGroup {
            SplashView()
                .environmentObject(notificationVM)
                .environmentObject(authenticationVM)
                .environmentObject(storageVM)
        }
    }
}

 

✅ Views/SplashView.swift

import SwiftUI
import FirebaseMessaging
import UserNotifications

/// 앱 실행 시 표시되는 초기 로딩 화면입니다.
/// - Firebase 초기화 이후 사용자 로그인 여부 확인 → ContentView로 전환
struct SplashView: View {
    
    // ✅ 환경에서 주입받는 ViewModel
    @EnvironmentObject var authenticationVM: AuthenticationViewModel
    @EnvironmentObject var storageVM: StorageViewModel
    @EnvironmentObject var notificationVM: NotificationViewModel
    
    // ✅ 뷰 전환 제어용 상태값
    @State private var isReady = false
    
    var body: some View {
        ZStack {
            if isReady {
                ContentView()
                    .environmentObject(authenticationVM)
                    .environmentObject(storageVM)
                    .environmentObject(notificationVM)
                    .transition(.opacity)
            } else {
                // ✅ 스플래시 화면 UI
                Color.white.ignoresSafeArea()
                VStack(spacing: 20) {
                    Image(systemName: "leaf.circle.fill")
                        .resizable()
                        .frame(width: 80, height: 80)
                        .foregroundColor(.green)
                    Text("SaveFood")
                        .font(.title.bold())
                        .foregroundColor(.green)
                    ProgressView("음식을 구하러 가고 있어요!")
                        .padding(.top, 8)
                }
            }
        }
        .task {
            // MARK: - 🔔 1. 푸시 알림 권한 요청
            do {
                log("✅ 알림 권한 요청 시작")
                let granted = try await UNUserNotificationCenter.current()
                    .requestAuthorization(options: [.alert, .badge, .sound])
                log("📲 권한 요청 결과 → granted: \(granted)")
            } catch {
                log("❌ 알림 권한 요청 실패: \(error.localizedDescription)")
            }
            
            // MARK: - 📡 2. APNs 등록
            await MainActor.run {
                UIApplication.shared.registerForRemoteNotifications()
                log("📡 APNs 등록 요청됨")
            }
            
            // MARK: - 👤 3. 사용자 로그인 정보 로드
            await authenticationVM.loadCurrentUser()
            
            // ✅ FCM 토큰 저장은 AppDelegate에서 처리됨
            
            // MARK: - 🌐 4. 모든 사용자 토픽 구독 (전체 알림용)
            do {
                try await Messaging.messaging().subscribe(toTopic: "allUsers")
                log("✅ 전체 사용자 토픽 구독 완료")
            } catch {
                log("❌ 전체 사용자 토픽 구독 실패: \(error.localizedDescription)")
            }

            
            // MARK: - 🚀 4. ContentView로 전환
            withAnimation {
                isReady = true
            }
        }
    }
}

 

소스코드 수정 완료 하고, 다시 빌드하고, 다시 테스트하는 수고를 거쳐 다음 로직이 완성 됐따. 위 코드를 순서도로 표현해 보면 아래 그림과 같다.

푸시알림 플로우 #1
푸시알림 플로우 #2

스위프트UI의 앱 구조체는 자기 자신을 먼저 초기화한 다음 그 안에서 앱딜리게이트(AppDelegate)를 등록하고 유아이킷(UIKit)이 그 뒤를 이어받아 처리한다.

 

다잡은 마음으로 모든 설정을 마치고, 모든 코드를 정리해서 다시 빌드했다. 결과는 성공이었다. 드디어 원하던 푸시 알림을 구현해 냈다. (Functions 이야기는 나중에 따로...)

ChatGPT와의 바이브코딩 내용

바이브코딩을 하며 이렇게 까지 구현을 할 수 있다니 다시 놀라웠다. ChatGPT에서 답변 주는 내용도 참 매력적이었다. 테스트 성공을 축하해 주고, 기록용 문서나 블로그 포스팅도 해보라고 권한다. 그래서 이 글을 작성했다. 이제...커피를 한잔 하러 갈 차례다.