본문 바로가기
IOS App Programming/IOS 연습

FCM 푸시알림 사용기1 - 특정 사용자에게 푸시알림 보내기

by B_Tori 2024. 2. 21.

프로젝트를 진행하면서 FCM을 이용한 푸시 알림을 구현하게 되었다.

고민했던 부분을 바탕으로 구현 내용을 정리해

참고로 프로젝트는 백엔드 개발 없이 파이어베이스의 firestore 서비스를 이용했다. 또한 파이어베이스 등록 및 인증키 등록 과정은 다른 블로그들에 자세히 나와있어 코드 구현 부분 중심으로 작성할 예정이다.

 

원격 알림 등록, FCM 토큰 얻기

Notification 서비스 함수에 다음과 같은 등록 함수를 작성하고

func registerRemoteNotification() {
    if #available(iOS 10.0, *) {
         let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound]
         UNUserNotificationCenter.current().requestAuthorization(
           options: authOptions,
           completionHandler: {_, _ in })
       } else {
         let settings: UIUserNotificationSettings =
         UIUserNotificationSettings(types: [.alert, .badge, .sound], categories: nil)
           UIApplication.shared.registerUserNotificationSettings(settings)
       }
    UIApplication.shared.registerForRemoteNotifications()
}
  • iOS 10.0 이상의 버전 **UNUserNotificationCenter**의 requestAuthorization 메서드를 사용하여 알림 권한을 요청할 때 사용할 옵션을 설정
  • iOS 10.0 미만의 버전 **UIUserNotificationSettings**를 사용하여 알림 권한을 요청하고 설정 (사실상 앱 타겟이 15.0 이상이기 때문에 실행될 일은 없다.)

알림에 대한 알림 창, 배지, 사운드에 대해 설정 후 권한을 요청해준다.

AppDelegate의 didFinishLaunchingWithOptions 함수에 다음과 같이 작성해 주었다.

import UIKit
import FirebaseCore
import FirebaseMessaging
import UserNotifications

@main
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    var window: UIWindow?
    
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        
        ...
        
        FirebaseApp.configure()
        Messaging.messaging().delegate = self
        UNUserNotificationCenter.current().delegate = self
        UIApplication.shared.registerForRemoteNotifications()
				
				...
        return true
    }
...
}

권한 요청을 앱 시작점이 아닌 로그인 후 홈 화면에 들어갔을 때 띄울 예정이라 따로 서비스 함수에 작성하였지만,

개발하면서 여러 이유로 다시 앱을 시작하면 권한 요청을 받도록 수정되어 앱 델리게이트 함수에 적어두었다.

따라서 현재 상황의 경우 굳이 함수로 뺄 이유는 없을 수 있지만

푸시 알림 관련 함수를 모두 서비스 클래스에 모아두었기 때문에 재사용성 및 모듈화 부분의 이점을 챙길 수 있도록 남겨두었다.

 

FCM 토큰 얻기

extension AppDelegate: MessagingDelegate {
    func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) {
        guard let token = fcmToken else { return }
        print("FCM등록 토큰 : \\(token)")
        let dataDict: [String: String] = ["token": token]
        NotificationCenter.default.post(name: Notification.Name("FCMToken"), object: nil, userInfo: dataDict)
    }
}

 

MessagingDelegate 프로토콜에서 요구하는 메서드이며 FCM에서 푸시 알림 등록 토큰을 수신할 때 호출된다.

FCM 토큰은 기기마다 고유한 값으로 특정 사용자(디바이스)에게 알림을 보낼 때 토큰 값으로 사용자를 식별하게 된다.

토큰 값은 앱을 재설치하거나 사용자가 디바이스에서 앱의 데이터를 지울 경우에 재생성될 수 있다.

그러곤 Notification 서비스 함수에 다음과 같이 작성해 주었다.

func saveToken() {
        Messaging.messaging().token { token, error in
            if let error = error {
                print("토큰 가져오기 실패 : \\(error)")
                return
            } else if let token = token {
                let newToken = Token(fcmToken: token, badgeCount: 0)
                if UserDefaultsManager.shared.isLogin() {
                    FirestoreService.shared.saveDocument(collectionId: .tokens, documentId: UserDefaultsManager.shared.getUserData().userId, data: newToken) { result in
                        switch result {
                        case .success:
                            print("토큰 저장 성공 Token: \\(token)")
                        case .failure(let error):
                            print("토큰 저장 실패: \\(error)")
                        }
                    }
                }
            }
        }
    }

우선 토큰 값을 저장하는 모델 구조는 다음과 같다.

struct Token: Codable {
    let fcmToken: String
    let badgeCount: Int
}

처음에는 토큰값만 저장했었지만, 이후 뱃지 카운팅 핸들링을 위해 현재 사용자의 뱃지 카운팅 값도 같이 저장하였다.

 

이때 앱델리게이트에서 호출한 saveToken()은 자동로그인 시 토큰이 저장되도록 하기 위함이다.

saveToken에서 UserDefaultsManager.shared.isLogin() 로그인 여부를 확인하고 로그인이 되어있다면 그 유저 id값을 이용해 저장한다.

만약 로그인이 되지 않은 상태라면 토큰이 저장되지 않는다.

따라서 이 경우는 로그인 시 saveToken()을 한 번 더 호출해 주어 로그인을 하여 접속할 때도 토큰을 저장할 수 있도록 하였다.

앱이 실행되고 호출되기 때문에 뱃지카운트는 0으로 설정한다.

로그인을 할 때마다 (자동로그인 포함) 사용자 id값에 현재 디바이스의 토큰을 저장한다.

푸시 알림 보내기

서비스 함수에 sendNotification 함수를 정의하였다.

함수 파라미터는 다음과 같이 정의되어 있다.

  • userId: 알림을 받을 UserId
  • sendUserName: 보내는 사람(자신) 닉네임
  • notiType: 노티 타입
    • like : 사용자가 좋아요를 눌렀을 때
    • message: 사용자가 메시지를 보냈을 때
    • matching: 다른 사용자와 매칭이 됐을 때
    3가지 상황에 대해서 정의하였다.
  • notiType은 푸시알림이 울리는 상황을 정의하였다.
  • messageContent: 메시지의 경우 메시지 내용
더보기

sendNotification 전체 코드 보기

/// 푸쉬알림 보내기
    /// userId: 알림을 받을 UserId, sendUserName: 보내는 사람(자신) 닉네임, notiType: like, message, matching
    func sendNotification(userId: String, sendUserName: String, notiType: PushNotiType, messageContent: String? = nil) {
        let title: String = "Pico"
        var subTitle: String?
        var body: String = ""
        FirestoreService.shared.loadDocument(collectionId: .tokens, documentId: userId, dataType: Token.self) { [weak self] result in
            guard let self = self else { return }
            switch result {
            case .success(let data):
                switch notiType {
                case .like:
                    body = "\(sendUserName)님이 좋아요를 누르셨습니다."
                case .message:
                    subTitle = "\(sendUserName)님이 메시지를 보냈습니다."
                    body = messageContent ?? "메일함을 확인해보세요."
                case .matching:
                    body = "\(sendUserName)님과 매칭이 되었습니다."
                }
                guard let token = data else { return }
                let urlString = "https://fcm.googleapis.com/fcm/send"
                let url = NSURL(string: urlString)!
                var paramString: [String: Any] = ["to": token.fcmToken,
                                   "notification": ["title": title, "body": body, "sound": "default", "badge": token.badgeCount + 1],
                                   "data": ["title": title, "body": body],
                                   "content_available": true
                    ]
                if let subTitle = subTitle {
                    paramString["notification"] = ["title": title, "body": body, "sound": "default", "badge": token.badgeCount + 1, "subtitle": subTitle]
                }
                let request = NSMutableURLRequest(url: url as URL)
                request.httpMethod = "POST"
                request.httpBody = try? JSONSerialization.data(withJSONObject: paramString, options: [.prettyPrinted])
                request.setValue("application/json", forHTTPHeaderField: "Content-Type")
                request.setValue("key=\(Bundle.main.notificationKey)", forHTTPHeaderField: "Authorization")
                
                let task = URLSession.shared.dataTask(with: request as URLRequest) { (data, _, _) in
                    do {
                        if let jsonData = data {
                            if let jsonDataDict = try JSONSerialization.jsonObject(with: jsonData, options: JSONSerialization.ReadingOptions.allowFragments) as? [String: AnyObject] {
                                NSLog("Received data:\n\(jsonDataDict))")
                            }
                        }
                    } catch let err as NSError {
                        print(err.debugDescription)
                    }
                }
                task.resume()
                updateBadgeCount(userId: userId)
            case .failure(let error):
                print("토큰 불러오기 실패: \(error)")
            }
        }
    }

페이로드 구조

var paramString: [String: Any] = ["to": token.fcmToken,
                                   "notification": ["title": title, "body": body, "sound": "default", "badge": token.badgeCount + 1],
                                   "data": ["title": title, "body": body],
                                   "content_available": true
                                  ]
if let subTitle = subTitle {
    paramString["notification"] = ["title": title, "body": body, "sound": "default", "badge": token.badgeCount + 1, "subtitle": subTitle]
}

페이로드를 나타내는 딕셔너리이다. 메시지의 경우만 메시지 내용을 보여주기 위해 subTitle을 추가하였다.

  • to: 메시지를 받을 FCM 토큰
  • notification: 푸시 알림의 내용. "title", "body", "sound", "badge”, “subtitle”(메시지의 경우만)를 추가하였다.
    • title: 푸시 알림 상단에 뜨는 타이틀
    • body: 푸시알림 본문
    • sound: 알림 소리 (default로 설정 시 앱 기본 알림음으로 감)
    • badge: 푸시 알림 수신 시 앱 아이콘에 표시될 배지 숫자를 지정
    • subtitle: 푸시 알림에 서브 타이틀 지정 본문 위에 볼드체로 위치함 메시지의 경우 메시지 내용도 같이 표현하기 위해 기본 “메시지를 보냈습니다.” 를 서브타이틀로 지정하고 body로 메시지 내용을 기록하였다
  • data: 추가적인 데이터를 담는 딕셔너리
    1. notification : 사용자에게 직접적으로 보여지는 정보
    2. data: 사용자에게 직접적으로 보여지지 않지만 앱 내부에서 사용될 추가적인 정보, 개발자가 자유롭게 정의
    data 정보의 경우 제대로 활용해보지 못해 추가적인 공부가 필요하다.
  • notification VS data
  • content_available : 메시지가 사용 가능한지 여부, true로 설정해줌

FCM서버로 요청 보내기

let urlString = "<https://fcm.googleapis.com/fcm/send>"
let url = NSURL(string: urlString)!

let request = NSMutableURLRequest(url: url as URL)
request.httpMethod = "POST"
request.httpBody = try? JSONSerialization.data(withJSONObject: paramString, options: [.prettyPrinted])
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("key=\\(Bundle.main.notificationKey)", forHTTPHeaderField: "Authorization")
                
let task = URLSession.shared.dataTask(with: request as URLRequest) { (data, _, _) in
    do {
        if let jsonData = data {
            if let jsonDataDict = try JSONSerialization.jsonObject(with: jsonData, options: JSONSerialization.ReadingOptions.allowFragments) as? [String: AnyObject] {
                NSLog("Received data:\\n\\(jsonDataDict))")
            }
        }
    } catch let err as NSError {
        print(err.debugDescription)
    }
}
task.resume()

URLSession을 통해서 FCM 서버로 요청을 보낸다.

푸시알림 서비스의 Key의 경우 유출 방지를 위해 따로 plist에 저장해 두어 gitignore에 추가하여 깃이 추적하지 않도록 하고 Bundle 익스텐션에 프로퍼티로 정의해 두어 코드에서 사용할 수 있도록 하였다.

(URLSession의 공부 내용은 따로 정리해서 올릴 예정 - 추후 링크 첨부)

댓글