본문 바로가기
iOS/개발

[iOS] SOPT - 푸시 알림 딥링크 라우팅 개발 여정 2

by 바등쪼 2024. 1. 5.

이전 글인

1. [iOS] SOPT - 푸시 알림 딥링크 라우팅 개발 여정 1

에서 이어지는 내용입니다!

 

 

앞서 요구사항의 발생과 설계에 대해 살펴보았습니다.

오늘은 코드 레벨에서 상세한 구현 내용에 대해 공유하겠습니다!

 

 

코드 설명

앞선 글에서 다이어그램으로 표현한 객체와 인터페이스(프로토콜)들에 대해 소개하겠습니다!

 

1. NotificationPayload 구조체

public struct NotificationPayload: Codable {
    public let aps: APS
    public let id: String
    public let category: String?
    public let deepLink: String?
    public let webLink: String?
    
    public var hasLink: Bool {
        self.hasWebLink || self.hasDeepLink
    }
    
    public var hasWebLink: Bool {
        guard let webLink, !webLink.isEmpty else {
            return false
        }
        return true
    }
    
    public var hasDeepLink: Bool {
        guard let deepLink, !deepLink.isEmpty else {
            return false
        }
        return true
    }
    
    public init?(dictionary: [AnyHashable: Any]) {
        do {
            self = try JSONDecoder().decode(NotificationPayload.self, from: JSONSerialization.data(withJSONObject: dictionary))
        } catch {
            SentrySDK.capture(error: error)
            return nil
        }
    }
}

public struct APS: Codable {
    public let alert: Alert
}

public struct Alert: Codable {
    public let body: String
    public let title: String
}

 

푸시알림의 페이로드 또한 일반적인 API 호출 Response로 받는 JSON과 같은 접근 방식으로 바라보았습니다.

즉, NotificationPayload 는 일종의 DTO이며 푸시알림의 페이로드로 넘어오는 딕셔너리를 Json 형태로 직렬화하고 이것을 Swift 모델로 디코드합니다.

 

이 작업은 실패할 수 있기 때문에 Failable Initializer로 구현했습니다.

여기서 deepLink 필드에 앞서 규격을 정한 딥링크 URL이 들어가게 됩니다.

 

2. NotificationHandler 클래스

public final class NotificationHandler: NSObject, UNUserNotificationCenterDelegate {
    
    // 1
    public let deepLink = CurrentValueSubject<DeepLinkComponentsExecutable?, Never>(nil)
    public let notificationLinkError = CurrentValueSubject<NotificationLinkError?, Never>(nil)

    // 2
    private let deepLinkParser = DeepLinkParser()
    
    public override init() {}
    
    // 3
    @MainActor
    public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
        let userInfo = response.notification.request.content.userInfo
        print("APNs 푸시 알림 페이로드: \(userInfo)")
        
        guard let payload = NotificationPayload(dictionary: userInfo) else { return }
        guard payload.hasLink else {
            self.deepLink.send(makeComponentsForEmptyLink(notificationId: payload.id))
            return
        }
        
        if payload.hasDeepLink {
            self.parseDeepLink(with: payload.deepLink)
        }
    }
    
    public func receive(deepLink: String) {
        self.parseDeepLink(with: deepLink)
    }
}

extension NotificationHandler {
    // 4
    private func parseDeepLink(with deepLink: String?) {
        guard let deepLink else { return }
        
        do {
            let deepLinkData = try deepLinkParser.parse(with: deepLink)
            let deepLinkComponents = DeepLinkComponents(deepLinkData: deepLinkData)
            self.deepLink.send(deepLinkComponents)
        } catch {
            self.handleLinkError(error: error)
        }
    }
    
    // 5
    private func makeComponentsForEmptyLink(notificationId: String) -> DeepLinkComponents? {
        let deepLinkData = try? deepLinkParser.parse(with: "home/notification/detail?id=\(notificationId)")
        return DeepLinkComponents(deepLinkData: deepLinkData)
    }
    
    // 6
    private func handleLinkError(error: Error) {
        guard let error = error as? NotificationLinkError else { return }
        self.notificationLinkError.send(error)
    }
    
    // 7
    public func clearNotificationRecord() {
        self.deepLink.send(nil)
        self.notificationLinkError.send(nil)
    }
}

 

  • UNUserNotificationCenterDelegate를 채택하여 userNotificationCenter 메서드를 구현합니다.
// SceneDelegate
let notificationHandler = NotificationHandler()

func scene(_ scene: UIScene,
               willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        guard let scene = (scene as? UIWindowScene) else { return }
        
        UNUserNotificationCenter.current().delegate = self.notificationHandler

	// 생략
}

 

SceneDelegate에서 푸시알림의 수신을 처리할 delegate로 NotificationHandler를 설정해야 합니다.

 

 

다시 NotificationHandler 구현부로 돌아와서 살펴보면 (주석 번호 순서대로 설명)

  1. 푸시 알림을 수신하여 파싱된 결과를 CurrentValueSubject를 통해 방출합니다.
    로직 수행 과정에서 발생하는 에러도 CurrentValueSubject를 통해 방출합니다.
  2. 파싱을 수행할 객체를 프로퍼티로 가집니다.
  3. didReceive를 통해 푸시 알림 수신 이벤트를 가져오고 NotificationPayload 형태로 역직렬화합니다.
    Swift 모델 형태로 바뀐 딥링크 스트링을 파싱하도록 메서드를 호출합니다.
    라우팅은 UI 로직이기 때문에 메인 스레드에서 발생해야 합니다. 이를 위해 @MainActor로 지정했습니다.
  4. 딥링크 URL을 받아서 Parser에서 parse를 요청합니다.
    그 결과로 받은 데이터를 DeepLinkComponents 타입으로 감싸서 Subject에 send합니다.
  5. 딥링크가 없는 푸시 알림의 경우 앱의 푸시 알림 보관함 뷰로 이동시키기 위해 사전에 정의된 푸시 알림 보관함 딥링크를 생성합니다.
  6. 에러가 발생한 경우 해당 에러를 구체 에러 타입(NotificationLinkError)로 캐스팅하고 send합니다.
  7. CurrentValueSubject를 사용하기 때문에 저장되는 기존 푸시알림 처리 내역들을 clear합니다.

 

3. NotificationLinkError 열거형

public enum NotificationLinkError: Error {
    case invalidLink
    case linkNotFound
    case expiredLink
    case invalidScheme
}

 

라우팅 로직 처리 과정에서 발생할 수 있는 에러들을 정의한 에러 타입입니다.

 

4. DeepTreeNode 프로토콜

public protocol DeepLinkTreeNode {
    var name: String { get }
    var children: [DeepLinkExecutable] { get }
    var isDestination: Bool { get set }
    func findChild(name: String) -> DeepLinkExecutable?
}

 

앞서 딥링크는 트리 구조를 구성한다고 했습니다.

이 트리를 구현하기 위해 각 노드가 채택하게 될 프로토콜을 선언했습니다.

  • name이 id의 역할을 수행하며 children에 하위 노드들이 들어가게 됩니다.
  • isDestination은 이 노드가 딥링크의 종착지를 의미하는지를 나타냅니다.
    • "home/soptamp/entire-ranking"이 딥링크 URL이라면 entier-ranking에 대응하는 구현체만 isDestination이 true가 됩니다.
  • child를 찾기 위한 메서드인 findChild를 추가했습니다.

 

5. DeepExecutable 프로토콜

public protocol DeepLinkExecutable: DeepLinkTreeNode {
    @discardableResult
    func execute(with coordinator: Coordinator, queryItems: [URLQueryItem]?) -> Coordinator?
}

public extension DeepLinkExecutable {
    func findChild(name: String) -> DeepLinkExecutable? {
        return children.first(where: { $0.name == name })
    }
}

 

딥링크 라우팅 로직의 핵심 프로토콜입니다.

  • execute 함수를 선언하며 이 함수가 곧 파라미터로 받는 coordinator와 협력하며 뷰를 라우팅하는 로직을 담당합니다.
  • "home/soptamp"의 딥링크가 도착하면 총 2개의 DeepLinkExecutable 구현체가 생성됩니다.
    • HomeDeepLink, SoptampDeepLink 같이 구조체를 만들고 이 구조체가 DeepLinkExecutable를 구현합니다.
  • 각 구현체가 곧 트리 노드이기 때문에 앞서 선언한 DeepLinkTreeNode 프로토콜을 상속(채택)합니다.
    • findChild 메서드는 기본 구현으로 제공하여 재사용성을 높입니다.

 

public typealias DeepLinkData = (deepLinks: [DeepLinkExecutable], queryItems: [URLQueryItem]?)

 

DeepLinkExecutable의 구현체들 (저는 딥링크 구현체라고 부릅니다.)과 쿼리 아이템을 tuple로 묶어서 DeepLinkData라는 타입 별칭을 별도로 생성했습니다. 

 

이것이 곧 딥링크를 수신했을 때 파싱 결과로 리턴하게 될 데이터 타입이 됩니다.

 

6. DeepExecutable 구현체(= 딥링크 구현체)

"home/soptamp"의 딥링크를 수신했을 때 동작하는 딥링크 구현체들의 예시입니다.

 

값 타입인 struct로 구현했습니다.

public struct HomeDeepLink: DeepLinkExecutable {
    public let name = "home"
    public let children: [DeepLinkExecutable] = [NotificationDeepLink(), SoptampDeepLink(), MyPageDeepLink(), AttendanceDeepLink()]
    public var isDestination: Bool = false
    
    public func execute(with coordinator: Coordinator, queryItems: [URLQueryItem]?) -> Coordinator? {
        guard let coordinator = coordinator as? ApplicationCoordinator else { return nil }
        
        if self.isDestination == true {
            coordinator.runMainFlow()
        }
        
        return coordinator
    }
}

 

딥링크의 path에 home이 포함되면 파싱 결과에 들어가게 되는 HomeDeepLink입니다.

앱의 메인 화면으로의 라우팅을 담당합니다.

앱의 메인 화면

 

Coordinator와 QueryItems를 파라미터로 받습니다.

 

만약 Home이 라우팅의 종착지 뷰라면 코디네이터로부터 runMainFlow()를 호출하여 메인 화면으로의 전환을 발생시킵니다.

 

만약 경유하는 뷰라면 그대로 코디네이터만 리턴하고 종료합니다.

 

여기서 중요한 것은 children에 미리 정적으로 자식 노드(딥링크 구현체)를 넣어둔 것입니다.

딥링크 트리의 경우 런타임이 수정될 일이 없기 때문에 이렇게 트리를 컴파일 타임에 미리 형성을 하여 앱 실행 중에 딥링크가 도착한다면 이 트리를 탐색하여 적절한 구현체를 찾게 됩니다.

 

 

public struct SoptampDeepLink: DeepLinkExecutable {
    public let name = "soptamp"
    public let children: [DeepLinkExecutable] = [SoptampEntireRankingDeepLink(), SoptampCurrentGenerationRankingDeepLink()]
    public var isDestination: Bool = false
    
    public func execute(with coordinator: Coordinator, queryItems: [URLQueryItem]?) -> Coordinator? {
        guard let coordinator = coordinator as? ApplicationCoordinator else { return nil }
        
        let stampCoordinator = coordinator.runStampFlow()
        return stampCoordinator
    }
}

 

SoptampDeepLink도 마찬가지입니다.

 

적합한 코디네이터를 받아서 라우팅을 발생시킵니다.

Soptamp 화면

 

 

7. DeepLinkParser 구조체

struct DeepLinkParser: NotificationLinkParser {
    private var defaultDeepLinks: [DeepLinkExecutable] {
        return [HomeDeepLink()]
    }
    
    func parse(with link: String) throws -> DeepLinkData {
        guard let components = URLComponents(string: link) else {
            SentrySDK.capture(message: "푸시 알림 DeepLink Parse 에러: \(link)")
            return (defaultDeepLinks, nil)
        }
        
        let pathComponents = components.path.split(separator: "/").map { String($0) }
        let queryItems = components.queryItems
        
        if isExpiredLink(queryItems: queryItems) {
            throw NotificationLinkError.expiredLink
        }
        
        let deepLinkList = try makeDeepLinkList(with: pathComponents)
        
        return (deepLinkList, queryItems)
    }
    
    private func makeDeepLinkList(with pathComponents: [String]) throws -> [DeepLinkExecutable] {
        var deepLinks = [DeepLinkExecutable]()
        
        for component in pathComponents {
            if deepLinks.isEmpty {
                guard let root = findRootDeepLink(name: component) else {
                    throw NotificationLinkError.linkNotFound
                }
                
                deepLinks.append(root)
                continue
            }
            
            guard let parent = deepLinks.last, let child = parent.findChild(name: component) else {
                throw NotificationLinkError.linkNotFound
            }
            
            deepLinks.append(child)
        }
        
        if !deepLinks.isEmpty {
            deepLinks[deepLinks.count-1].isDestination = true
        }
        
        return deepLinks
    }
    
    private func findRootDeepLink(name: String) -> DeepLinkExecutable? {
        switch name {
        case "home":
            return HomeDeepLink()
        default:
            return nil
        }
    }
}

 

딥링크 URL(String)을 파싱하는 역할을 수행합니다.

  • parse(with: String) 함수
    • 딥링크를 애플에서 제공하는 URLComponents 형태로 캐스팅합니다. 실패한다면 올바르지 않은 형태로 판단합니다.
    • "/" 기준으로 split하여 path 배열을 만듭니다.
    • QueyItem 배열을 생성합니다.
    • 만료된 링크인지 검사합니다.
    • makeDeepLinkList(with:)를 호출하여 파싱을 진행합니다.
  • makeDeepLinkList(with: [String]) 함수
    • path 배열을 받아서 이 path들에 대응하는 딥링크 구현체를 찾는 함수입니다.
    • 첫 요소의 경우 트리에서 root 노드이기 때문에 fintRootDeepLink 함수를 호출하여 대응하는 루트 구현체를 찾습니다.
    • 이 루트를 시작으로 하여 차례로 트리를 탐색하여 자식 노드 (= 딥링크 구현체)를 찾아 배열에 append 합니다.
    • 모든 구현체들을 다 찾고 마지막 구현체에 대해서는 종착지로 판단하여 isDestination을 true로 설정합니다.

 

8. DeepLinkComponentsExecutable 프로토콜

public protocol DeepLinkComponentsExecutable {
    var queryItems: [URLQueryItem]? { get }
    var isEmpty: Bool { get }
    func execute(coordinator: Coordinator)
    func addDeepLink(_ deepLink: DeepLinkExecutable)
    func getQueryItemValue(name: String) -> String?
}

 

DeepLinkParser가 파싱한 결과물들을 담게 될 객체(일종의 자료구조)의 인터페이스입니다.

 

 

 

9. DeepLinkComponentsExecutable 구현체 (DeepLinkComponents)

public class DeepLinkComponents: DeepLinkComponentsExecutable {
    private var deepLinks: [DeepLinkExecutable]
    public let queryItems: [URLQueryItem]?
    
    public var isEmpty: Bool {
        self.deepLinks.isEmpty
    }
    
    public init(deepLinkData: DeepLinkData) {
        self.deepLinks = deepLinkData.deepLinks
        self.queryItems = deepLinkData.queryItems
    }
    
    public convenience init?(deepLinkData: DeepLinkData?) {
        guard let deepLinkData = deepLinkData else {
            return nil
        }
        self.init(deepLinkData: deepLinkData)
    }
    
    // deepLink 배열을 재귀적으로 돌며 각 단계의 딥링크 뷰로 이동시킨다.
    public func execute(coordinator: Coordinator) {
        var nextCoordinator: Coordinator? = coordinator
        while !self.isEmpty, let coordinator = nextCoordinator {
            let deepLink = popFirstDeepLink()
            nextCoordinator = deepLink?.execute(with: coordinator, queryItems: self.queryItems)
        }
    }
    
    public func addDeepLink(_ deepLink: DeepLinkExecutable) {
        self.deepLinks.append(deepLink)
    }
    
    @discardableResult
    private func popFirstDeepLink() -> DeepLinkExecutable? {
        if deepLinks.isEmpty { return nil }
        return deepLinks.removeFirst()
    }
    
    public func getQueryItemValue(name: String) -> String? {
        guard let queryItems else { return nil }
        
        for item in queryItems {
            if item.name == name {
                return item.value
            }
        }
        
        return nil
    }
}

 

DeepLinkParser가 리턴하는 DeepLinkData를 받아서 저장합니다.

  • Queue 형태로 동작합니다.
    • deepLinks 배열에 딥링크 구현체들을 저장하고 popFirstDeepLink() 메서드를 통해 1개씩 dequeue하여 실행합니다.
  • 전체 실행은 execute 메서드에서 담당합니다.
  • getQueryItemValue 메서드를 통해 원하는 key에 해당되는 쿼리 value를 가져올 수 있도록 했습니다.

 

 

10. ApplicationCoordinator 클래스

프로젝트에 구현되어 있던 ApplicationCoordinator (루트 코디네이터)에 바인딩을 추가합니다.

public
final class ApplicationCoordinator: BaseCoordinator {
    
    private let notificationHandler: NotificationHandler
    private var cancelBag = CancelBag()
    
    // 생략
    
    private func bindNotification() {
        self.cancelBag.cancel()
        
        self.notificationHandler.deepLink
            .compactMap { $0 }
            .receive(on: DispatchQueue.main)
            .filter { _ in
                self.childCoordinators.contains(where: { $0 is MainCoordinator })
            }
            .sink { [weak self] deepLinkComponent in
                self?.handleDeepLink(deepLink: deepLinkComponent)
                self?.notificationHandler.clearNotificationRecord()
            }.store(in: cancelBag)
        
        self.notificationHandler.notificationLinkError
            .compactMap { $0 }
            .receive(on: DispatchQueue.main)
            .filter { _ in
                self.childCoordinators.contains(where: { $0 is MainCoordinator })
            }.sink { [weak self] error in
                self?.handleNotificationLinkError(error: error)
                self?.notificationHandler.clearNotificationRecord()
            }.store(in: cancelBag)
    }
    
     private func handleDeepLink(deepLink: DeepLinkComponentsExecutable) {
        self.router.dismissModule(animated: false)
        deepLink.execute(coordinator: self)
    }
    
    private func handleNotificationLinkError(error: NotificationLinkError) {
       // 팝업 발생
    }
}
  • 푸시 알림을 수신하게 되는 NotificationHandler와 바인딩하여 DeepLinkComponent를 수신하도록 합니다.
  • receive(on: DispatchQueue.main)을 선언하여 메인 스레드에서 작업이 진행되도록 합니다. (UI 작업이므로)
  • DeepLinkComponent에 execute를 호출하면서 파라미터로 self를 넣습니다.
    • 이제 DeepLink 구현체들이 연쇄적으로 execute를 실행하고 코디네이터의 메서드를 실행하여 화면을 전환합니다.

 

정리

이렇게 코드로만 설명을 하면 어렵게 느껴질 것 같아 전체 플로우를 순서대로 정리해보겠습니다!

  1. 서버에서 푸시 알림 전송
  2. APNs를 통해 아이폰에 푸시 알림 도착
  3. 푸시 알림 터치 시 NotificationHandler의 didReceive 메서드 트리거
  4. 푸시 알림 페이로드를 NotificationPayload로 역직렬화
  5. DeepLinkParser를 이용해 Parsing 진행
  6. 파싱을 통해 DeepLinkExecutable 구현체(딥링크 구현체) 배열과 QueryItem 배열 확보
  7. 이 데이터를 DeepLinkComponentsExecutable 프로토콜의 구현체인 DeepLinkComponents 로 감싸서 Subject에 send
  8. ApplicationCoordinator는 이 DeepLinkComponents를 수신해서 execute 메서드 호출
  9. DeepLinkComponents가 저장하고 있던 딥링크 구현체들이 큐 형태로 dequeue되며 차례로 execute 호출
  10. execute에서는 대응되는 Coordinator에게 요청하여 화면 전환

 

 

새로운 딥링크가 추가된다면?

전반적인 코드 설명은 여기까지입니다.

상당히 복잡하고 많아 보이지만 역할과 책임에 맞게 객체를 작게 쪼개도록 노력했기 때문에 이해하기에는 어렵지 않을 것이라 생각됩니다.

 

이렇게 구현된 코드는 새로 딥링크 스펙이 추가되었을 때 확장에 용이합니다.

 

최근에 "home/poke/notification-list"라는 딥링크 요구사항이 추가되었는데 이 딥링크의 라우팅 로직을 추가할 때 어떻게 했는지 살펴보겠습니다!

 

(코디네이터는 이미 있다고 가정합니다.)

 

1. DeepLinkExecutable을 채택한 구현체를 생성합니다.

전체 path 배열이 ["home", "poke", "notification-list"]로 구성되어 있고 "home"에 대응되는 구현체는 이미 있으니 나머지 2개에 대한 구현만 추가하면 됩니다.

 

딥링크 트리 업데이트

딥링크 트리에는 연두색으로 칠한 노드들이 추가되어야 합니다.

 

public struct PokeDeepLink: DeepLinkExecutable {
    public let name = "poke"
    public let children: [DeepLinkExecutable] = [PokeNotificationListDeepLink()]
    public var isDestination: Bool = false
    
    public func execute(with coordinator: Coordinator, queryItems: [URLQueryItem]?) -> Coordinator? {
        guard let coordinator = coordinator as? ApplicationCoordinator else { return nil }
        
        let pokeCoordinator = coordinator.makePokeCoordinator()
        
        if self.isDestination == true {
            pokeCoordinator.showPokeMain(isRouteFromRoot: true)
        }
        
        return pokeCoordinator
    }
}
public struct PokeNotificationListDeepLink: DeepLinkExecutable {
    public let name = "notification-list"
    public let children: [DeepLinkExecutable] = []
    public var isDestination: Bool = false
    
    public init() {}
    
    public func execute(with coordinator: Coordinator, queryItems: [URLQueryItem]?) -> Coordinator? {
        guard let coordinator = coordinator as? PokeCoordinator else { return nil }
        
        coordinator.runPokeNotificationListFlow()
        
        return nil
    }
}

 

이렇게 DeepLinkExecutable를 채택한 2개의 구현체를 생성합니다.

PokeDeepLink의 children에는 PokeNotificationListDeepLink의 인스턴스를 넣어 트리에서의 부모 자식 관계를 연결합니다.

 

2. 트리 연결

PokeDeepLink와 PokeNotificationListDeepLink는 연결했으나 아직 HomeDeepLink와 PokeDeepLink는 연결이 되어 있지 않습니다.

 

public struct HomeDeepLink: DeepLinkExecutable {
    public let name = "home"
    public let children: [DeepLinkExecutable] = [NotificationDeepLink(), SoptampDeepLink(), MyPageDeepLink(), AttendanceDeepLink(), PokeDeepLink()]
    // 생략
}

 

이렇게 HomeDeepLink의 children에 추가해주면 연결이 끝납니다!

 

 

자, 이제 모든 작업이 끝났습니다! ㅎㅎ

보시다시피 새로운 딥링크 요구사항이 추가되었을 때 모듈을 확장하는 것이 매우 간단합니다.

 

우리가 한 일은 DeepLinkExecutable 구현체 2개를 추가하고 노드끼리 연결만 해준 것입니다.

 

여기에는 어떠한 switch 문이나 enum이나 기존 로직 수정이 없습니다.

 

저는 이 방식이 확장성 있고 유지보수가 용이하다고 생각합니다.

 

실제로 실행해보면 

 

원하는 뷰로 잘 라우팅 되는 것을 확인할 수 있습니다!

 

 

내용이 길어져서 다음 글로 이어집니다!

댓글