카카오페이 계정 토큰 시스템 개편기. 그런데 Swift Concurrency를 사용한...

카카오페이 계정 토큰 시스템 개편기. 그런데 Swift Concurrency를 사용한...

시작하며

안녕하세요. 카카오페이 iOS 개발팀 주니어 개발자 제이미입니다. 저는 카카오페이에서 사용자 계정과 관련된 일들을 하고 있는데요. 최근에 카카오페이 서비스 이용 시 필요한 토큰 시스템 개편 업무를 진행하며 경험한 이야기를 소개해드리려고 합니다. 특히 작년에 애플이 공개한 Swift Concurrency를 적극 활용해 개발할 수 있는 기회가 있었는데요. Swift Concurrency를 서비스에 적용한 이야기도 함께 담았습니다.

카카오페이 서비스를 사용하기 위해서는

카카오페이는 카카오톡에 들어가는 페이 서비스와 카카오페이 앱, 그리고 카카오페이 비즈니스 앱 총 3개의 서비스 앱을 제공하고 있어요.

카카오톡 앱 내 페이 서비스, 카카오페이 앱, 카카오페이 비즈니스 앱
카카오톡 앱 내 페이 서비스, 카카오페이 앱, 카카오페이 비즈니스 앱

그중 이번에 소개드릴 내용은 카카오톡에 들어가는 페이 서비스의 토큰 시스템을 개편한 이야기인데요. 카카오톡을 서비스하는 카카오와 카카오페이를 서비스하는 카카오페이는 법인이 다르기 때문에 서로 다른 토큰을 사용해야 합니다. 카카오페이 서비스에서는 카카오톡 친구 목록 등 카카오톡의 여러 기능이 필요한데요. 때문에 카카오페이 서비스를 이용하기 위해서는 카카오 약관 동의를 마친 정상 토큰으로 카카오페이 토큰을 발급받아야 하는 상황입니다.

그래서 토큰이 뭐죠

인가 코드(Authorization Code)

사용자로부터 허가받은 scope를 바탕으로 생성되는 코드로, 액세스 토큰 발급에 사용합니다.

액세스 토큰(Access Token)

액세스 토큰은 클라이언트가 서버 요청 시 사용하는 문자열입니다. 클라이언트에서는 액세스 토큰을 읽거나 해석할 수 없습니다. 액세스 토큰은 특정 형식일 필요가 없으며 단지 요청을 보내는 클라이언트의 신원을 증명하는 데 사용됩니다. 또한 유효기간이 리프레시 토큰보다 짧으며 필요시 갱신해야 합니다.

리프레시 토큰(Refresh Token)

리프레시 토큰은 클라이언트가 사용자의 상호 작용 없이 새 액세스 토큰을 가져오는 데 사용할 수 있는 문자열입니다. 리프레시 토큰은 인증 서버가 토큰이 만료될 때 사용자의 개입 없이 짧은 시간 동안 유효한 액세스 토큰을 발급받기 위해 존재합니다. 또한 리프레시 토큰으로 새로운 액세스 토큰을 발급받더라도 기존 허가 범위와 같은 액세스 권한만을 얻을 수 있습니다.

카카오페이 서비스를 정상적으로 사용하기 위해서는 인가 코드에서 받아온 카카오 액세스 토큰, 카카오 리프레시 토큰, 카카오페이 액세스 토큰, 카카오페이 리프레시 토큰 총 4개의 토큰을 저장하고 관리해야 하는 상황입니다. 카카오페이 서비스에서 API 요청 시 사용하는 토큰은 카카오페이 액세스 토큰인데요. 지금부터는 편의상 카카오페이 액세스 토큰을 ‘토큰’이라고 하겠습니다.

왜 토큰 관련 개편을 했냐면…

내부적으로 여러 가지 이유가 있지만 몇 가지를 소개드리면 다음과 같습니다.

토큰의 동시 요청 문제 해결

토큰을 동시에 요청하는 경우 요청에 대한 처리가 누락되어 토큰을 담지 않고 API 요청을 보내거나 이미 만료된 토큰을 보내어 정상적이지 않은 동작을 하는 문제가 있었습니다.

만료된 토큰에 접근

토큰 동시 요청 문제를 포함해 알 수 없는 다양한 이유로 토큰 없이 API 요청을 보내는 경우도 종종 있었습니다.

토큰 접근 코드의 파편화와 불필요한 방어 코드

특정 서비스 레벨 코드당 매핑되는 토큰 접근 코드가 하나의 모듈로 묶여 있다 보니, 토큰 접근 코드가 여러 서비스 레벨로 파편화되었습니다. 또한, 불필요한 방어 코드들이 많아져 코드의 흐름을 예상하기 힘든 문제가 있었습니다.

토큰 관련 에러 처리 다양화

과거에는 토큰 발급과 관련된 계정 관련 에러 얼럿만 노출하고 있었는데요. 그렇다 보니 카카오페이로 진입할 때 네트워크 에러 등이 발생해도 에러 메시지를 출력하지 않는 문제가 있었습니다.

이번 개편 작업에서 다양한 케이스의 에러들을 모두 처리해 얼럿을 표시할 수 있도록 했습니다.

아래에서 설명할 내용처럼 기존 코드의 completionHandler나 google의 promises에서는 내부에서 발생한 에러를 처리하지 않아도 컴파일 및 실행이 가능했었는데요. 그렇다 보니 로직의 홀이 발생할 수 있었습니다. 그래서 이번 개편 작업에서는 Swift Concurrecy를 활용해 에러 throw catch 처리를 컴파일러 레벨에서 도움받아 에러 처리 누락이 발생하지 않도록 했습니다.

문제 해결을 위해 우리가 한 일

아래에서 자세히 설명하지만 문제를 해결하기 위해 우리가 한 일은 다음과 같습니다.

토큰 관련 모듈을 만들어 캡슐화

외부에서 토큰 관련 내용에 직접 접근할 수 없도록 하여 코드가 파편화되고 예상치 못한 사이드이펙트가 발생하는 문제를 막았습니다. 대신 토큰 관련 요청을 할 수 있는 프로토콜에 인터페이스를 별도 모듈에 만들어 요청 옵션만 넘겨주고 토큰을 받아오도록 처리했습니다.

비동기 처리의 결과를 누락 없이 끝까지 전달할 수 있는 환경 보장

기존에는 Swift에서 지원하는 Result를 사용하여 결괏값과 에러를 보다 명확하게 전달하는 방식을 사용했습니다. 하지만 Result의 경우 에러 처리를 강제하지 않기 때문에 옵셔널을 사용하면서 누락될 수 있고 결괏값이 Result로 감싸 져 있기 때문에 꺼내서 사용해야 한다는 불편함이 있었습니다. 다행히 Swift Concurrency가 나오면서 이 문제들이 자연스럽게 해결되었습니다. 그리고 카카오톡의 iOS 최소 지원 버전이 13.1이었기 때문에 Swift Concurrency 기능 대부분을 바로 적용할 수 있었습니다.

Swift Concurrency의 등장

작년 릴리즈 된 Swift 5.5에는 Swift Concurrency를 포함해 다양한 기능들이 나왔는데요. 그중 Swift Concurrency의 async/await 모델이 위에서 설명한 문제들의 많은 부분을 해결할 수 있는 방법이었습니다. 최근 Swift 프로그래밍에서는 다양한 비동기 작업들을 포함하고 있는데요. 기존 방식에서는 completion handler를 주로 이용했는데 nested closure를 통해 결과를 받아 처리하니 사용하기에 어렵고 읽기도 힘들다는 단점이 있었습니다. (그래서 저희는 외부 라이브러리인 google의 promises를 주로 사용했었습니다.) 그에 비해 async/await 모델의 경우 코드의 Control flows가 위에서 아래로 읽을 수 있는 효과를 얻을 수 있고 동기 코드처럼 작성하기 때문에 return과 try/catch 오류 처리를 사용할 수 있는 장점이 있습니다. 그리고 아래와 같은 문제점들도 자연스럽게 해결할 수 있었습니다.

completion 지옥

여러 가지 비동기 처리를 순차적으로 수행해야 하는 경우 completionHandler가 중첩되어 들여 쓰기가 많아지는 문제가 생기게 됩니다. 이는 가독성을 크게 떨어뜨릴 수 있습니다.

kakaoAuthorizedCode(scope: "") { kakaoAuthroizedCode in
    kakaoToken(kakaoAuthorizedCode: kakaoAuthroizedCode) { kakaoToken in
        kakaoPayToken(kakaoToken: kakaoToken) { kakaoPayToken in
            // kakaoPayToken 획득 완료!
        }
    }
}

completion 누락

guard로 중간에서 메서드를 리턴하는 경우 completionHandler 호출을 누락하는 실수를 할 수 있습니다.

func operation(on viewController: UIViewController?, completionHandler: () -> Void) {
    guard let viewController = viewController else {
        return
    }
    // ...
    // 다양한 작업들
    completionHandler()
}

if let으로 감싸 놓고 completion 호출 누락

if let을 이용해 옵셔널 바인딩을 하는 경우 해당 변수가 nil인 경우 completionHandler 호출을 누락하는 실수를 할 수 있습니다.

import UIKit

func operation(on viewController: UIViewController?, completionHandler: () -> Void) {
    if let viewController {
        // ...
        // 다양한 작업들
        completionHandler()
    }
}

promises catch 누락

기존에 비동기 처리를 google/promises로 주로 하고 있었는데 여러 장점들도 있었지만, catch문을 작성하지 않아도 내부에서 에러 발생 시 컴파일이 가능해서 로직에 홀이 생길 수 있었습니다.

import Promises

func work() -> Promise<Void> {
    return Promise(SomeError.jamie)
}

func operation() {
    work()
        .then {
            ..
        }
//      .catch {
//
//      }
}

Swift Concurrency를 적용하면서

MainActor의 늪에서 헤맨 이야기

첫 번째는 메인 스레드 문제였습니다. iOS 정책상 UI를 그리는 코드는 메인 스레드에서 실행되어야 하고, 무거운 작업은 메인 스레드에서 실행하면 사용성에 영향을 줄 수 있어 백그라운드 스레드에서 실행하는데요. 기존 비동기 프로그래밍 코드와 google의 promises는 기본적으로 메인 스레드에서 동작하다가 글로벌 스레드가 필요한 경우 DispatchQueue.global.async를 이용해 무거운 비동기 작업을 수행하고 다시 메인 스레드로 돌아와 나머지 작업을 이어 나갔습니다. async/await 적용 시 기본 스레드는 글로벌 스레드이고, 필요시에만 MainActor를 붙여 사용했는데요. 기존과 다른 스레드 처리 방식 적응에 어려움이 있어서 완전히 적응하기 전까지 많은 실수와 크래시가 발생했습니다. 또한 Swift Concurrency는 기본적으로 글로벌 스레드에서 동작하는데, 클래스나 메서드에 @MainActor가 붙은 경우에는 메인 스레드에서 동작했습니다. 처음 사용할 때는 개념들이 익숙하지 않아서 어떤 코드가 어떤 스레드에서 실행되는지 예상하지 못해 크래시를 많이 발생시키기도 했습니다.

func globalAsyncFunc() async {
    print("globalAsyncFunc", Thread.isMainThread) // false
}

class SomeClass {
    func asyncFunc() async {
        print("SomeClass.asyncFunc", Thread.isMainThread) // false
        await MainActor.run {
            print("SomeClass.asyncFunc.MainActor", Thread.isMainThread) // true
        }
    }

    static func staticAsyncFunc() async {
        print("SomeClass.staticAsyncFunc", Thread.isMainThread) // false
    }

    @MainActor
    func mainActorAsyncFunc() async {
        print("SomeClass.mainActorAsyncFunc", Thread.isMainThread) // true
    }
}

class ViewController: UIViewController {
    func asyncFunc() async {
        print("ViewController.asyncFunc", Thread.isMainThread) // true
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        Task {
            await asyncFunc()
            await SomeClass().asyncFunc()
            await SomeClass.staticAsyncFunc()
            await SomeClass().mainActorAsyncFunc()
            await globalAsyncFunc()
        }
    }
}

위 코드는 다양한 context에서 async 메서드를 실행했을 때의 예시입니다. 각 print문 오른쪽 주석은 해당 구문을 실행하는 스레드가 메인 스레드인지 여부를 표시했습니다. 내용이 같더라도 Task의 context에 따라 다른 스레드를 사용하는 것을 볼 수 있습니다.

동시성 관련 이야기

Swift Concurrency에서 async/await와 함께 공개된 actor도 사용했는데요. 저희는 actor를 크게 다양한 요청을 동시에 처리하는 복잡한 비즈니스 로직 문제를 해결하는 곳과 여러 스레드에서 요청을 처리하면서 발생하는 경쟁 조건 문제를 해결하는 곳에서 사용했습니다.

비즈니스 로직 문제 해결

첫 번째로 기존에 존재했던 토큰을 여러 번 동시에 호출했을 때 각각 다른 응답을 받아 문제가 발생할 수 있는 상황을 해결하기 위해 토큰 요청 처리를 actor로 구현하여 마치 큐잉하는 것처럼 동작하도록 했습니다.

  • 토큰 API를 여러 번 호출했을 때 처음 요청의 응답을 받아 사용하도록 구현했습니다.
  • 진입점 요청이 여러 번 들어왔을 때 한 번에 하나씩만 동작하도록 구현했습니다.

경쟁 조건으로 인한 메모리 문제 해결

개발을 하고 사내 테스트를 진행하면서 다양한 크래시 리포트가 올라왔는데 그중 하나가 바로 EXC_BAD_ACCESS였습니다. 이 오류 코드는 특정 메모리 주소에 해당하는 값이 유효하지 않거나 더 이상 존재하지 않는 경우에 액세스하려고 할 때 애플리케이션이 충돌하면서 발생하는 코드입니다. 발생할 수 있는 이유는 여러 가지가 있지만 대표적인 경우가 Race Condition으로 인한 충돌이었습니다. 비동기 프로그래밍 특성상 여러 코드가 동시에 실행되며 문제가 발생하는 경우가 많기 때문에 코드를 한 줄 한 줄 따라가며 디버깅하기가 힘들다는 단점이 있습니다. 그래서 문제를 해결하는 방법도 조금 다르게 접근할 수도 있는데요. 재현 경로를 모르는 경우에는 내부 크래시 모니터링 도구를 이용했습니다. 재현이 가능한 경우에는 Zombie Object를 찾거나 Xcode에서 제공하는 Thread Sanitizer나 다양한 툴을 이용하여 문제를 찾고, 문제가 되는 부분을 actor로 수정하여 코드가 actor-isolated된 환경에서 thread-safe하게 동작하도록 수정하였습니다.

마치며

이번 계정 토큰 리팩터링을 진행하며 다양한 문제를 만났는데요. 문제들을 해결하는 과정에서 명확한 인터페이스, 스레드 경쟁 조건, 그리고 비즈니스 로직을 잘 나타내는 구조를 많이 고민해볼 수 있는 뜻깊은 경험이자 기회였던 것 같습니다.

또한 새로운 기술인 Swift Concurrency를 성공적으로 적용하여 릴리즈 함으로써 여러 Swift Concurrency 관련 사내 스터디를 활성화시키는 긍정적인 효과를 낳기도 했습니다.

직접 개편한 토큰 시스템이 적용된 앱 릴리즈 후, 사내 모니터링 툴을 이용해 크래시 리포트를 받아보고 있는데요. 토큰 없이 API 요청을 보내는 것을 포함하여 여러 가지 지표들에서 기존 문제점들이 많이 개선된 결과가 보여 뿌듯했습니다.

지금까지 긴 글 읽어주셔서 감사합니다. 아직 Swift Concurrency 적용을 망설이고 있다면, 지금 바로 도전해 보시는 건 어떨까요? 😉

jamie.kao
jamie.kao

카카오페이에서 iOS 개발을 하는 제이미입니다. 항상 더 나은 방향으로의 성장을 위해 노력합니다.

태그