iOS 멀티 프레임워크 환경에서 리소스 효율적으로 관리하기

iOS 멀티 프레임워크 환경에서 리소스 효율적으로 관리하기

요약: 데일은 대규모 멀티 모듈 프로젝트에서 앱 용량 관리 중요성을 강조합니다. 프로젝트 복잡성이 증가하면서 리소스를 관리하는 게 중요해졌고, 이에 따라 리소스 모듈을 도입하였습니다. 특히, 앱 용량이 갑자기 증가한 사례를 해결하는 과정에서 리소스를 효율적으로 사용하면서도 중복을 줄이는 방안을 모색했습니다. 이 과정에서 PayResourceSupports 프로토콜을 도입하여 리소스 사용을 표준화하고, 리소스 모듈로 자동화 및 최적화를 추진하여 앱 퍼포먼스와 유지보수성을 개선했습니다. 이 경험은 기술 문제를 직접 해결하고, 필요한 기준을 설정하는 데 큰 도움이 되었습니다.

시작하며

안녕하세요. 카카오페이 iOS 개발팀 데일입니다. 현재 카카오페이를 포함한 많은 회사에서 빌드 속도, 생산성 등 다양한 이유로 프로젝트를 멀티 모듈로 구성해서 앱을 개발하고 있는데요. 많은 모듈이 있는 큰 프로젝트를 운영하다 보면 여러 가지 문제를 마주합니다. 최근 카카오페이에서도 이러한 멀티 모듈 환경에서 늘어나는 앱 용량을 어떻게 관리할지 고민했습니다. 앱 용량에 영향을 끼치는 여러 요소가 있지만, 그중 리소스(이미지, 애니메이션, 컬러 등)는 앱 용량에 직접적인 영향을 주는데요. 많은 개발자들이 한 프로젝트에서 작업하는 만큼 그에 따라 리소스도 많이 추가하다보니 앱 용량이 점점 커졌습니다. 관리가 필요하다는 걸 더욱 느꼈습니다.

결과적으로, 리소스 모듈을 만들어 리소스를 관리하도록 개선했는데요. 나아가 이를 자동화하여 편하게 리소스를 사용하게 되었습니다. 그 과정에 있었던 이야기를 공유드리고자 합니다.

에피소드(갑자기 앱 용량이 10MB가 늘었다)

여느 때와 같이 개발을 하는 중, 카카오페이의 특정 모듈 용량이 10MB가 늘어났다는 제보를 받았습니다.

Episode
Episode

모듈 용량이 증가하면 앱 용량이 증가하는 데 직접적인 영향을 끼치는데요. 결국 다운로드 시간이 늘어나면서 사용자에게 부정적인 영향을 끼칠 수 있습니다.

또한 카카오페이 핵심 기능(결제, 송금 등)은 공통 모듈 형태로 관리하고 있는데, 이 공통 모듈은 카카오페이뿐만 아니라, 카카오톡에 들어가는 페이 서비스, 카카오페이 사장님플러스까지 총 3개의 앱에 포함됩니다. 즉, 공통 모듈 용량이 증가하게 되면 여러 앱에 영향을 끼치게 되는데요. 때문에 카카오페이에서는 앱 용량을 굉장히 중요한 요소로 관리하고 있습니다. 위 사례에서 모듈 용량이 증가한 건 PNG에 적합한 이미지 리소스를 SVG로 추가하면서 이미지 사이즈가 비효율적으로 증가했기 때문입니다. PNG로 다시 치환하여 간단하게 해결했습니다.

하지만 “이런 문제를 개발자가 개발하면서 발견할 수 없을까?”, “앱 내의 다양한 리소스(이미지, 애니메이션, 색상 등)를 사용하면서 발생하는 비효율을 줄일 수 없을까?” 고민하게 되었고, 이를 해소하기 위해 작업했던 내용들을 공유드리고자 합니다.

리소스 한 곳에서 관리하기

앞서 설명드렸듯 카카오페이 프로젝트는 많은 모듈로 구성되어 있습니다. 각 팀에서 모듈을 개발하고 있고, 앱이 커짐에 따라 모듈 개수는 계속해서 많아지고 있습니다. 분리된 공간에서도 각 모듈로 여러 기능을 빠르게 만들고 있지만, 이러한 환경에서 리소스를 효율적으로 관리하기란 쉽지 않았습니다. 위 사례와 같이 큰 리소스를 갑작스럽게 추가했을 때 개발자가 변화를 매번 인지하기란 쉽지 않습니다. 결국 본의아니게 동일하거나 비슷한 이미지를 여러 벌 추가하고 있었습니다.

큰 변화를 결정하기

이러한 문제를 해결하기 위해 리소스를 관리하는 리소스 모듈을 만들어, 프로젝트 내의 리소스를 한 곳에 모아 가시성을 높여 중복과 용량을 쉽게 파악하고자 했습니다. 하지만 리소스 모듈을 통합하는 작업은 프로젝트 전반적으로 큰 변화가 있는 작업이라 고민이었는데요. iOS 길드데이에서 동료들에게 이러한 고민을 공유하고, 피드백을 받았습니다. 덕분에 문제를 해결하기 위한 방안을 빠르게 결정하고 작업할 수 있었습니다.

길드데이란? 한 달에 한번 카카오페이 iOS 개발자들이 모두 모여서 iOS를 주제로 토론하고, 경험기를 공유하는 날입니다.

그래서 결과는?

리소스 모듈을 만들어 프로젝트의 리소스들을 한 곳에서 관리한 결과, 총 146개의 중복 리소스를 제거할 수 있었고, 물론 그에 따른 앱 용량도 줄였습니다.

리소스 사용 코드 통일하기

카카오페이 iOS 프로젝트에서 리소스가 여러 모듈에 존재했던 만큼, 리소스를 사용하는 방식도 모두 달랐습니다.

// 이미지 예시
let image = UIImage(named: "ic_core_message")
let image = UIImage.ic_core_message

// 컬러 예시
let color = UIColor(named: "grey990", in: .bundle)
let color = UIColor.pui.grey990
let color: UIColor = PayUI.grey990

// 다국어 예시
let string = NSLocalizedString("str_button_confirm")
let string = "str_button_confirm".localized()

사용하는 방식이 모두 다르면 사용율을 파악하기 어렵거나 코드 가독성이 저하되는 등 유지 보수 비용이 증가하게 되는데요. 리소스 모듈 작업으로 리소스 사용 방식도 모두 통일하였습니다.

선언 방식 정하기

일반적으로 UIColor, UIImage 클래스에 Swift의 extension을 통해 변수를 선언해서 사용합니다.

extension UIImage {
    static var ic_core_message: UIImage { UIImage(named: "ic_core_message")! }
}

extension UIColor {
    static var red100: UIColor { UIColor(named: "red100")! }
}

imageView.image = UIImage.ic_core_message
button.tintColor = UIColor.red100

해당 방식은 간단하고 사용하기 편한데요, 카카오페이의 환경에는 적합하지 않았습니다. 앞서 설명드린 데로 만들어진 리소스 모듈을 카카오페이 앱 한 곳이 아니라, 카카오페이/카카오톡/카카오페이 사장님플러스 앱에서 사용하기 때문에, red100 과 같은 범용적인 네이밍은 디자인 시스템이나 다른 코드에 영향을 끼칠 수 있습니다.

프로토콜(protocol)로 영역 나누기

그래서 리소스 모듈의 고유한 영역에 변수를 선언하기 위해 PayResourceSupports란 프로토콜을 추가했습니다.

public final class PayResource<Base> {
    public let base: Base

    public init(_ base: Base) {
        self.base = base
    }
}

public protocol PayResourceSupports {
    associatedtype CompatibleType

    static var pay: PayResource<CompatibleType>.Type { get set }
}

public extension PayResourceSupports {
    static var pay: PayResource<Self>.Type {
        get {
            PayResource<Self>.self
        }
        set {}
    }
}

PayResourceSupports 프로토콜은 pay라는 static 변수를 통해서 PayResource라는 클래스 타입을 반환하며, PayResource 클래스는 PayResourceSupports를 상속받은 클래스의 타입을 알고 있는 형태입니다.

해당 프로토콜을 활용하면 이런 것들이 가능해지게 됩니다.

  • PayResourceSupports를 채택한 리소스 클래스에서 .pay라는 변수로 PayResource에 바로 접근이 가능해집니다.
  • PayResource에는 각 Base 타입마다 사용하는 리소스 변수들이 공통 선언되어 있어, 사용하는 쪽에서 필요한 리소스를 편하게 사용할 수 있습니다.
extension UIImage: PayResourceSupports {}
extension UIColor: PayResourceSupports {}

extension PayResource where Base == UIImage {
    static var ic_core_message: UIImage { UIImage(named: "ic_core_message")! }
}

extension PayResource where Base == UIColor {
    static var red100: UIColor { UIColor(named: "red100")! }
}

// 결과
imageView.image = .pay.ic_core_message
button.tintColor = .pay.red100

프로토콜을 리소스 클래스(UIColor, UIImage)에 적용하고 PayResource의 Base 타입을 비교하면, 미리 선언해 두었던 변수에 접근이 가능합니다. 이로써 리소스 클래스에 동일한 이름의 변수가 있더라도, 페이의 변수들은 .pay를 통해 접근할 수 있기 때문에 다른 모듈에 영향을 주지 않으면서 사용할 수 있게 했습니다.

주의할 점: Bundle 지정

리소스 모듈을 만들 때는 번들을 main이 아닌 리소스 모듈 번들로 명확하게 지정하도록 주의해야 합니다. 일반적인 UIImage, UIColor 등을 만들어 사용하는 방식은 main 번들에서 리소스를 찾게 되는데요. 리소스 모듈의 이미지나 컬러 등의 리소스 파일은 모듈 번들 내에 존재하기 때문에 main 번들에서 찾게 되면 nil을 반환하게 됩니다.

extension PayResource where Base == UIImage {
    static var ic_core_message: UIImage { UIImage(named: "ic_core_message")! } // nil
}

번들(Bundle)이란? 실행 가능한 코드와 그 코드로 인해 사용되는 리소스(이미지, 소리 등)를 갖고 있는 디렉터리

리소스 모듈 번들에서 이미지를 찾도록 하기 위해서는 번들을 가져와 이미지 생성 시점에 지정해줘야 하는데요. 번들을 가져올 때는 프레임워크 타입(Static, Dynamic)에 따라 방식을 다르게 해주어야 합니다.

번들 가져오기

// Dynamic framework일 때 Bundle을 가져오는 방식
private class _Class {}

extension Bundle {
    // _Class 클래스가 위치한 framework의 번들을 가져옴
    static let resourceBundle = Bundle(for: _Class.self)
}

// Static framework일 때 Bundle을 가져오는 방식
private class _Class {}

extension Bundle {
    static let resourceBundle: Bundle = {
        // 기본 번들, framework안에서 리소스 모듈 이름으로 번들을 가져옴
        let bundleName = "PayShared_PayResource"

        let candidates = [
            Bundle.main.resourceURL,
            Bundle(for: _Class.self).resourceURL,
            Bundle.main.bundleURL,
        ]

        for candidate in candidates {
            let bundle = candidate
                .flatMap { $0.appendingPathComponent(bundleName + ".bundle") }
                .flatMap { Bundle(url: $0) }
            if let bundle {
                return bundle
            }
        }
        return Bundle(for: _Class.self)
    }()
}

보시다시피 Static framework에서 번들을 불러올 때 번들 이름으로 직접 찾는데요, Static framework 번들은 이를 사용하는 framework 폴더 안에 존재하기 때문에 경로를 붙여 번들을 찾아야 합니다.

번들 이름은 모듈 명이나 배포 환경에 따라 다를 수 있는데요. 카카오페이 리소스 모듈은 SPM (Swift Package Manager)을 통해 PayShared라는 이름으로 배포하고 있습니다. 그렇기 때문에 SPM명_리소스모듈명PayShared_PayResource라는 이름으로 번들을 가져오고 있습니다.

Bundle
Bundle

위 방법으로 가져온 번들을 이미지 생성 시점에 지정해 주면 리소스 모듈 번들에서 이미지를 찾게 되면서 이미지를 잘 찾아올 수 있습니다.

extension PayResource where Base == UIImage {
    // UIImage:0x600000694630 named(PayResource: ic_core_message) {20, 20} renderingMode=alwaysTemplate>
    static var ic_core_message: UIImage { UIImage(named: "ic_core_message", in: .resourceBundle, compatibleWith: nil)! }
}

자동화하기

모든 리소스가 한 곳에 위치하고, 이를 사용하는 코드 모두 통일했기 때문에 이를 관리하기가 쉬워졌습니다. 관리하기 쉬워짐은 곧 자동화하기도 쉬워짐을 의미하기도 하는데요. iOS 프로젝트는 Asset Catalog로 모든 리소스를 관리합니다. 즉, Asset Catalog에서 관리하는 리소스 형태를 알면 쉽게 리소스 정보를 알 수 있습니다.

Asset Catalog: .xcasset 확장자의 폴더

Image Set: 이미지명.imageset 폴더

Color Set: 컬러명.colorset 폴더

스크립트를 통해 리소스 명, 타입, 용량 정보를 가져와 활용하여 다음과 같은 스크립트를 만들 수 있었습니다.

기존에 개발자가 이미지를 추가하려면 이미지 추가 -> UIImage 변수 선언 -> Image 변수 선언 -> 빌드 확인 순으로 확인해야 했다면, 자동화 이후에는 이미지 추가 -> 빌드(pre-post 시점 스크립트) 과정으로 단순화했습니다! 추가로 스크립트에서 리소스를 점검하여, 용량이 큰 리소스는 없는지 매번 확인할 수 있게 됐습니다!

마무리하며

결과적으로 iOS 프로젝트 리소스를 통일된 형태의 코드로 만들고, 사용할 수 있다는 점에서 R.swift와 유사한 라이브러리를 만들게 되었는데요. 좋은 라이브러리를 찾아 도입하는 것도 물론 좋지만, 카카오페이 프로젝트에 적합한 결과물을 만들 수 있었기에 더 의미있었던 것 같습니다. 이 경험을 통해서 앞으로도 개발을 하면서 우리가 필요한 기술은 무엇이고, 기준은 무엇인지 생각할 수 있는 좋은 계기가 생긴 것 같습니다! 짧은 글 읽어주셔서 감사합니다 :)

dale.hyuk
dale.hyuk

카카오페이에서 iOS 개발하고 있는 데일입니다. 반갑습니다 :)