본문 바로가기
iOS/개발

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

by 바등쪼 2024. 1. 6.

이전 글인

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

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

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

 

 

이전 글에서 딥링크 라우팅 설계와 구현에 대해 살펴보았습니다.

 

오늘은 앞서 구현한 로직의 활용 사례와 웹 링크를 주제로 글을 적어보겠습니다!

 

추가로 이번 라우팅 로직을 설계를 하며 고민했던 부분들과 코드레벨에서 이렇게 구현한 이유에 대해서도 소개하겠습니다!

 

 

 

먼저 웹링크부터 시작하겠습니다!

웹링크

웹링크는 저희 SOPT Makers 팀에서 딥링크과 구별하여 웹 URL 을 부르는 이름입니다.

이 웹링크가 등장한 이유는 SOPT 앱 자체가 플랫폼으로서 SOPT 공식 홈페이지, Playground 등 다양한 웹 페이지로 이동할 수 있도록 라우터의 역할을 제공하기 있기 때문입니다.

 

즉, 앞서 언급한 웹 사이트들이 웹 뷰의 형태로 앱에서 접근할 수 있어야 한다는 의미입니다.

 

이것은 푸시 알림으로도 이어집니다. 알림 TF가 생기고 푸시 알림 피쳐를 Makers 조직 차원에서 팀을 꾸려 구현을 한 것도 결국 웹 서비스를 만들고 있는 팀에서도 이 푸시 알림에 대한 니즈가 컸기 때문입니다.

 

웹에서 스터디 모집 글이 올라오거나 댓글이 달렸을 때 앱으로 푸시 알림을 전송하고 유저가 이 푸시 알림을 터치하면 웹 뷰로 바로 이동할 수 있는 시나리오가 필요했던 것이죠!

 

이러한 요구사항에 맞추어 클라이언트에서는 딥링크 뿐 아니라 웹 URL이 왔을 때의 라우팅을 처리해야 하는 상황을 맞이했습니다.

 

사실, 웹 링크의 라우팅은 딥링크보다 훨씬 간단합니다.

 

URL이 푸시 알림의 페이로드로 넘어오면 우리는 WKWebView에 이 URL을 reqeust하여 라우팅하면 됩니다.

단! http 또는 https 인 scheme의 URL이어야 정상적으로 웹 뷰가 열립니다.

 

{
    "Simulator Target Bundle" : "bundle id",
    "aps" : {
        "alert" : {
            "title" : "웹링크 테스트",
            "body" : "웹 뷰로 바로가기!",
        },
    },
    "category": "NOTICE",
    "webLink": "https://www.sopt.org",
    "id": "1234"
}

 

웹링크가 페이로드에 담겨져 올 때는 위와 같은 형태로 오는 것으로 백엔드 개발자분들과 함께 규칙을 정했습니다.

deepLink 필드 대신 webLink가 오는 것을 확인할 수 있습니다.

 

NotificationHandler에 로직 추가

   // 프로퍼티
   public let webLink = CurrentValueSubject<String?, Never>(nil)
   private let webLinkParser = WebLinkParser()
   
   // 메서드
   @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)
        }
        
        if payload.hasWebLink {
            self.handleWebLink(with: payload.webLink)
        }
    }
    
    private func handleWebLink(with webLink: String?) {
        guard let webLink else { return }
        do {
            let url = try webLinkParser.parse(with: webLink)
            self.webLink.send(url)
        } catch {
            self.handleLinkError(error: error)
        }
    }

 

  • didRecieve 함수에서 hasWebLink인 경우를 분기하여 handleWebLink(with:) 메서드를 호출합니다.
  • handleWebLink(with webLink:) 에서는 webLinkParser를 이용해 파싱하고 여기서 발생한 에러를 핸들링합니다.

 

WebLinkParser 구조체

struct WebLinkParser: NotificationLinkParser {
    func parse(with link: String) throws -> String {
        guard let components = URLComponents(string: link) else {
            SentrySDK.capture(message: "푸시 알림 WebLink Parse 에러: \(link)")
            throw NotificationLinkError.invalidLink
        }
        
        guard link.starts(with: "https") || link.starts(with: "http") else {
            throw NotificationLinkError.invalidScheme
        }
        
        let queryItems = components.queryItems
        
        if isExpiredLink(queryItems: queryItems) {
            throw NotificationLinkError.expiredLink
        }
        
        return link
    }
}

 

  • WebLinkParser는 간단한 구조체입니다.
  • url 문자열을 받아서 URLComponents로 변환하고 스킴을 검사합니다.
  • 쿼리 아이템을 따로 분리한 후 다른 문제가 발생하지 않았다면 다시 url 문자열을 리턴합니다.

 

ApplicationCoordinator에 로직 추가

self.notificationHandler.webLink
    .compactMap { $0 }
    .receive(on: DispatchQueue.main)
    .filter { _ in
        self.childCoordinators.contains(where: { $0 is MainCoordinator })
    }.sink { [weak self] url in
        self?.handleWebLink(webLink: url)
        self?.notificationHandler.clearNotificationRecord()
    }.store(in: cancelBag)
    

private func handleWebLink(webLink: String) {
    self.router.dismissModule(animated: false)
    self.router.pushSOPTWebView(url: webLink)
}

 

  • 앞서 파싱을 거친 URL을 ApplicationCoordinator가 구독합니다.
  • 값을 수신하면 pushSOPTWebView라는 웹 뷰 유틸 함수를 사용해 웹 뷰를 띄웁니다.
    public func pushSOPTWebView(url: String) {
        guard let url = URL(string: url) else { return }
        
        let webView = SOPTWebView(startWith: url)
        self.rootController?.pushViewController(webView, animated: true)
    }

 

SOPTWebView는 현재 Makers에서 사용하고 있는 임시 웹 뷰로 WKWebView를 감싼 래퍼 객체입니다.

 

이제 웹링크를 담고 있는 푸시 알림을 전송하면

 

웹뷰가 잘 열리는 것을 확인할 수 있습니다!

 

 

 

딥링크 라우팅 로직의 활용

이제 설계한대로 구현은 전부 끝났고 이렇게 구현한 라우팅 로직을 추가로 활용한 사례에 대해 소개하겠습니다.

바로 바로가기 버튼입니다.

 

푸시 알림 보관함(알림 리스트 뷰)

 

현재 SOPT에서는 사용자가 지금까지 받은 푸시알림 내역을 보관함의 형태로 제공하고 있습니다.

이제 저 리스트 중에서 하나를 터치하면

 

알림 상세 뷰

이렇게 상세 뷰로 넘어가게 되고 아래에 보면 "바로가기" 버튼이 위치합니다.

이것은 푸시 알림 페이로드에 담겨져 있던 딥링크 또는 웹링크와 연결됩니다.

바로가기 버튼을 터치하면 푸시 알림을 터치했던 것처럼 딥링크/웹링크가 의미하는 뷰로 말 그대로 바로가기 하는 것입니다.

 

이 때의 라우팅 로직은 별도로 구현한 것이 아니라 기존에 구현했던 푸시 알림 라우팅 모듈을 활용했습니다.

 

한 번의 터치로 상황에 맞는 여러 뷰로 라우팅되는 것은 사용성 측면에서도 큰 의미가 있습니다.

바로가기 이동 예시

 

    public func receive(deepLink: String) {
        self.parseDeepLink(with: deepLink)
    }
    
    public func receive(webLink: String) {
        self.handleWebLink(with: webLink)
    }

 

이를 위해 NotificationHandler에 이렇게 recieve 함수를 추가하고 APNs에서 직접 푸시 알림을 전송하지 않고 사용자가 런타임에 링크와 관련된 버튼을 터치했을 때에도 라우팅 로직이 트리거될 수 있게 했습니다.

 

 

 

이번 딥/웹링크 라우팅 로직을 구현하며 고민했던 사항들

1. 접근 제한자

현재 SOPT 앱 프로젝트에는 모듈화가 적용되어 있습니다.

모듈화의 장점에는 접근제한자의 활용도을 높일 수 있다는 점도 해당됩니다.

 

이번 딥링크 라우팅 로직 구현 전에는 Coordinator의 모든 메서드가 private으로 동작하고 있었습니다. 하지만 이번 구현으로 인해 일부 메서드들이 internal 또는 public으로 바뀌었습니다.

 

public으로 전환을 최소화하기 위해 딥링크 구현체의 위치를 Coordinator가 위치한 모듈로 정했습니다. ➡️ internal로 설정 가능

 

2. struct vs class

앞선 글에서 등장했던 여러 객체들을 struct로 구현할지 아니면 class로 구현할지도 고민을 했습니다.

애플에서는 기본적으로 객체를 구현할 때 struct를 권장합니다. (성능에서 struct가 더 뛰어남)

 

하지만 mutable한 특성이 필요하거나 reference type이 필요하면 class가 적합한 선택지입니다.

 

이번 설계에서도 이러한 기준을 적용하여 DeepLinkExecutabable 구현체(딥링크 구현체)는 불변성을 위해 struct로 구현했고 DeepLinkComponentsExecutable 구현체 (DeepLinkComonents)는 동일 객체의 보장 (identity)및 가변성이 필요해서 class로 구현했습니다.

 

DeepLinkComonents는 딥링크 구현체를 큐의 형태로 가지고 있으며 이것을 하나씩 dequeue하여 실행시키는 역할을 수행하는 데 이 행위 자체가 mutable한 특성이 필요하기 때문입니다. 또한 만약 struct로 구현하게 되면 변수에 할당 시 복사가 발생하는데 딥링크 구현체들의 실행을 맡는 객체에 복사가 발생하여 서로 다른 인스턴스에서 라우팅이 실행되면 예상하지 못한 순서로 뷰가 쌓이게 될 수 있기에 참조 타입인 class를 사용했습니다.

 

반면 DeepLinkParser와 WebLinkParser는 파싱의 역할만 수행하고 immutable하기 때문에 구조체를 선택했습니다.

 

 

3. DIP

DIP의 준수를 위해 구현체와 인터페이스를 분리하고 최대한 인터페이스에 의존하도록 염두하며 설계했습니다.

DeepLinkComponents는 DeepLinkComponentsExecutable 프로토콜로 추상화하고, DeepLink 구현체들은 DeepLinkExecutable 프로토콜로 추상화했습니다.

 

또한 각 프로토콜이 여러 책임을 가지는 것을 막기위해 프로토콜 또한 분리했습니다.

예를 들어 DeepLinkTreeNode 프로토콜을 분리하여 트리 노드에 대한 요구사항만 명세하도록 했습니다.

 

 

4. 에러 핸들링

에러 핸들링은 언제나 중요하지만 이번처럼 지속적으로 사용되는 피쳐에는 특히 더 중요하기에 에러 핸들링 코드를 필요한 부분에 넣어 안정성을 확보하고자 했습니다.

링크 파싱 실패, 잘못된 링크가 도착한 경우, 만료된 링크, 스킴의 문제 등 여러 에러 상황이 발생했을 때 NotificationLinkError 가 발생하도록 했고 각 case에 대응되도록 에러를 처리하여 사용자에게 팝업을 보여주는 등 구동 도중의 에러가 사용성을 저하시키지 않도록 노력했습니다.

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

 

추가로 에러 발생 시 Sentry에 에러 로그를 업로드하여 모니터링이 가능하도록 하여 에러 상황 대응을 위한 기반을 마련했습니다.

 

 

 

테스트 코드

이렇게 구현만 하고 끝내면 검증이 되지 않았고 추후에 다른 피쳐를 개발하다 보면 이번에 구현한 로직에서 회귀 문제가 발생할 수도 있습니다.

 

이러한 문제를 해결하기 위해 Unit Test 코드를 작성하여 테스트를 진행했습니다.

라우팅 로직 자체가 UI와 맞닿아 있는 부분이 있기에 해당 부분을 제외하고 Unit Test가 가능한 부분은 전부 테스트 코드를 작성하고자 했으며 이렇게 작성한 코드 자체가 문서로서 역할을 하여 다른 개발자들이 딥링크 라우팅 로직을 이해할 때 도움일 될 것이라고 생각합니다.

 

애플의 XCTest 프레임워크를 이용해 테스트를 진행했습니다.

func test_만료된_딥링크를_파싱한다() {
    // Given
    let link = "home?expiredAt=2023-01-01T00:00:00.000Z"

    // When
    XCTAssertThrowsError(try parser.parse(with: link)) { error in
        // Then
        XCTAssertTrue(error is NotificationLinkError)

        if let linkError = error as? NotificationLinkError {
            XCTAssertTrue(linkError == .expiredLink)
        }
    }
}

 

테스트 메서드의 예시이며 전체 테스트 코드는 Pull Request를 확인해 주세요!

 

테스트 커버리지

테스트 대상 객체들에 대해 평균 Test Coverage 92.5%를 기록했습니다.

아쉽게도 UI와 연관된 메서드가 있어 100%는 기록하지 못했으나 다양한 테스트 케이스에서 검사를 진행하고자 노력했습니다.

 

 

 

 

마무리

생각보다 내용이 길어져서 3번으로 나누어 글을 업로드하게 되었습니다.

 

객체지향의 관점에서 더 좋은 코드를 작성하고 싶다는 목표와 고민이 합쳐져서 여기까지 오게 된 것 같습니다.

 

이번 구현을 통해 딥링크 라우팅 모듈이 SOLID 원칙의 관점에서 최대한 작은 책임과 역할 그리고 딥링크 추가가 발생했을 때 확장에 용이하도록 바뀌었다고 생각합니다.

프로토콜 다형성의 활용과 객체의 추상화의 의존의 개념 또한 적용되었습니다.

 

코디네이터와 딥링크 로직을 최대한 분리하였고, Tree 구조의 딥링크 URL 파싱으로 같은 이름의 path의 딥링크가 들어와도 부모 노드를 따라 차례로 탐색하기 때문에 엉뚱한 뷰로 라우팅 되는 문제를 사전에 방지하였습니다.

 

꽤나 긴 여정이었고 아직 부족한 부분도 많지만 여러차례 QA와 테스트를 통해 서비스를 발전시키고 저 또한 같이 성장할 수 있는 기회였습니다.

 

서버 개발자, 클라이언트 개발자, PM, 디자이너 모두가 함께 고생해서 만든 기능인 만큼 사용자들이 편하게 사용할 수 있었으면 좋겠네요!!

 

 

관련 코드가 더 궁금하시다면

 

https://github.com/sopt-makers/SOPT-iOS/pull/302

 

[Faet] #298 - 푸시 알림 딥링크 라우팅 구현 by lsj8706 · Pull Request #302 · sopt-makers/SOPT-iOS

🌴 PR 요약 푸시 알림 페이로드에 딥링크가 들어오는 경우 해당 딥링크가 의미하는 뷰로 바로 이동하는 로직을 구현했습니다. 🌱 작업한 브랜치 feature/#298 🍀 PR 포인트 딥링크 규격 상세한 규

github.com

 

https://github.com/sopt-makers/SOPT-iOS/pull/347

 

[Test] #346 - 딥링크 라우팅 Unit 테스트 코드 by lsj8706 · Pull Request #347 · sopt-makers/SOPT-iOS

🌴 PR 요약 🌱 작업한 브랜치 feature/#346 🌱 PR Point 이전 PR에서 구현한 딥링크 라우팅 코드들에 대해 테스트 코드를 작성하고 테스트를 진행했습니다. XCTest를 사용해서 테스트를 진행했습니다. U

github.com

 

이 PR들을 참고해주세요!

 

레포지토리 링크는 다음과 같습니다.

https://github.com/sopt-makers/SOPT-iOS

 

GitHub - sopt-makers/SOPT-iOS: SOPT 공식 어플리케이션

SOPT 공식 어플리케이션. Contribute to sopt-makers/SOPT-iOS development by creating an account on GitHub.

github.com

 

댓글