본문 바로가기

개발 번역

[번역] iOS 앱의 첫 실행 감지 - 잘못된 방법과 올바른 방법


iOS 앱의 첫 실행을 감지하고, 이를 테스트 가능한 코드로 작성해봅시다.


원문 : Detecting the first launch of the iOS application - the wrong and the right way

이 글은 Swift 매니아, Oleg Dreyman님이 Medium에 올린 글을 동의를 얻고 배포합니다.

 

많은 앱에서 "첫 실행"은 매우 중요합니다. 미리 정해진 데이터 들을 채워 빠르게 화면에 데이터들을 보여준다던가,
다른 다양한 많은 작업들을 할 수 있습니다.

이 기능에 대한 로직은 간단합니다. 이전에 앱이 실행된적이 있는지를 확인하는 플래그를 저장하고,
매번 앱이 실행될 때, 이 플래그를 확인해 첫 실행 여부를 확인하면 됩니다.

많이 설명 할 필요없이 일단 코드부터 봅시다.
잘못된 것이긴 하지만, 지금은 별로 중요하지 않습니다.

final class FirstLaunch {
    
    let userDefaults: UserDefaults = .standard
    
    let wasLaunchedBefore: Bool
    var isFirstLaunch: Bool {
        return !wasLaunchedBefore
    }
    
    init() {
        let key = "com.any-suggestion.FirstLaunch.WasLaunchedBefore"
        let wasLaunchedBefore = userDefaults.bool(forKey: key)
        self.wasLaunchedBefore = wasLaunchedBefore
        if !wasLaunchedBefore {
            userDefaults.set(true, forKey: key)
        }
    }
    
}

 

그리고 이를 실행하는 코드입니다.

let firstLaunch = FirstLaunch()
if firstLaunch.isFirstLaunch {
    /// do things
}}

 

잘 동작합니다!, 로직에 아무 문제가 없고, 실제 앱의 첫 실행도 감지합니다.
그러나, 이 코드에 큰 문제가 있습니다. 

어떻게 테스트 할 것인가?

단순히 로직을 테스트하는 것뿐만 아니라 (지금도 작은 클래스긴 하지만 테스트도 어렵습니다.)
이 로직를 통해 실행되는 코드를 테스트하는 것을 의미합니다.

'첫 실행시에만 동작'하는 모든 기능은 테스트해야하지만, 어떻게 해야할까요?
변경사항을 적용 할 때마다 앱을 삭제할 수는 없습니다.

그래서 FirstLaunch를 수정,변경 가능하도록 만들 필요가 있습니다.
그리고 실무에 적합하도록 해야합니다.

그러기 위해서 FirstLaunch가 실제로 무엇을 하는지 알고, 가능한 범위를 제한해야합니다.

 

 

자, FirstLaunch은 어떤 것을 하나요.

 

  1. wasLaunched 플래그가 존재하는지 확인하고 메모리에 저장합니다.
  2. 만약 플래그가 false (앱이 첫 실행되었거나 false로 설정되었거나 플래그가 존재하지 않음을 의미) 라면,
    true로 만듭니다.

이게 FiristLaunch의 전부입니다.

이렇게 생각해봅시다.
왜 FirstLaunch가 UserDefaults에 대해서 신경써야 하는지,
이 wasLaunched플래그를 조회하고, 유지하는게 얼마나 중요한건지

이것들은 간단한 세부사항들입니다. 또 FirstLaunch의 로직은 비어있어야합니다.
UserDefaults에 대한 의존성을 없앱시다!

final class FirstLaunch {
    
    let wasLaunchedBefore: Bool
    var isFirstLaunch: Bool {
        return !wasLaunchedBefore
    }
    
    init(getWasLaunchedBefore: () -> Bool,
         setWasLaunchedBefore: (Bool) -> ()) {
        let wasLaunchedBefore = getWasLaunchedBefore()
        self.wasLaunchedBefore = wasLaunchedBefore
        if !wasLaunchedBefore {
            setWasLaunchedBefore(true)
        }
    }
    
    convenience init(userDefaults: UserDefaults, key: String) {
        self.init(getWasLaunchedBefore: { userDefaults.bool(forKey: key) },
                  setWasLaunchedBefore: { userDefaults.set($0, forKey: key) })
    }
    
}

 

두개의 함수(조회, 설정)만 필요했던 UserDefaults를 사용하는 대신에 init으로 두 함수를 주입합니다.
그리고 UserDefaults를 기반한 FirstLaunch를 쉽게 만들 수 있도록 convenience 생성자를 만들었습니다.

let firstLaunch = FirstLaunch(userDefaults: .standard, key: "com.any-suggestion.FirstLaunch.WasLaunchedBefore")
if firstLaunch.isFirstLaunch {
    // do things
}

 

그리고 이제 FirstLaunch의 동작을 변형시키는건 쉽습니다.


let alwaysFirstLaunch = FirstLaunch(getWasLaunchedBefore: { return false }, setWasLaunchedBefore: { _ in })
if alwaysFirstLaunch.isFirstLaunch {
    // will always execute
}

 

아니면 멋지게 이렇게 할 수 있습니다. ( 우리가 해야하는 )

extension FirstLaunch {
    
    static func alwaysFirst() -> FirstLaunch {
        return FirstLaunch(getWasLaunchedBefore: { return false }, setWasLaunchedBefore: { _ in })
    }
    
}
let alwaysFirstLaunch = FirstLaunch.alwaysFirst()
if alwaysFirstLaunch.isFirstLaunch {
    // will always execute
}

 

자, 우리가 어떤걸 했나요?
FirstLaunch에서 UserDefaults에 대한 실질적인 책임을 분리했고, 때문에 외부에서 작업하기 쉬워졌습니다.

이런 접근 방식으로, 플래그를 위한 기본 저장공간을 바꿀 수 있고,
코드를 망가뜨리지 않고, First와 앱을 쉽게 테스트 할 수 있습니다.

 

물론, 앱의 첫 실행에 대한 내용이 아닙니다.
여러분들의 클래스 설계나 그것들의 책임에 관한 내용입니다.

가능한 기능들을 분리하고, 항상 테스트하는 방법과 그것에 의존하는 코드를 생각해야합니다.
그 이념에 관한 자세한 내용은 여기를 참조해 주세요.

 

부록 1. 환경에 따른 로직 전환

FirstLaunch가 배치되는 중심 위치 (AppDelegate, ApplicationController 등)가 있다면,
초기화 시점에 이런식으로 추가 가능합니다.

#if DEBUG
    self.firstLaunch = FirstLaunch.alwaysFirst()
#else
    self.firstLaunch = FirstLaunch(userDefaults: .standard, key: "your-key")
#endif

 

 이 방법으로, 디버그 모드에서는 첫번째 실행 처리되지만, 릴리즈시에는 실제 동작을 사용합니다.
그래서 한 줄의 코드를 변경하지 않고도 로직을 전환 할 수 있습니다.
그리고 이 코드는 실무에 적합한 코드입니다!

 

부록 2. 이 문제에 대한 다른 접근법

맞습니다. 이 문제는 여러방법으로 해결될 수 있습니다 (이것이 스위프트의 아름다움입니다..!)
저는 보일러플레이트한(반복되지만 자주쓰이는 패턴) 것들을 찾아내고,
학습 목적으로 그것들을 나열했습니다.

 

1. Subclass UserDefaults:

class FirstLaunch {
    
    let wasLaunchedBefore: Bool
    var isFirstLaunch: Bool {
        return !wasLaunchedBefore
    }
    
    init(userDefaults: UserDefaults, key: String) {
        let wasLaunchedBefore = userDefaults.bool(forKey: key)
        self.wasLaunchedBefore = wasLaunchedBefore
        if !wasLaunchedBefore {
            userDefaults.set(true, forKey: key)
        }
    }
    
}
class AlwaysFalseUserDefaults : UserDefaults {
    
    override func bool(forKey defaultName: String) -> Bool {
        return false
    }
    
    override func setValue(_ value: Any?, forKey key: String) {
        // do nothing
    }
    
}
let alwaysFalseUserDefaults = AlwaysFalseUserDefaults()
let alwaysFirstLaunch = FirstLaunch(userDefaults: alwaysFalseUserDefaults, key: "")

 

 

2. Subclass FirstLaunch:

class AlwaysFirstLaunch : FirstLaunch {
    
    override var isFirstLaunch: Bool {
        return true
    }
    
}
let alwaysFirstLaunch = AlwaysFirstLaunch(userDefaults: UserDefaults(), key: "")

wasLaunchedBefore가 아니라 isFirstLaunched에 대한 getter만 재정의 할 수 있기 때문에,
실제로 이건 굉장히 좋지 않은 방법입니다.
그리고 이건 여전히 UserDefaults에 값을 직접씁니다.

 

 3. Protocols!

protocol FirstLaunchDataSource {
    
    func getWasLaunchedBefore() -> Bool
    func setWasLaunchedBefore(_ wasLaunchedBefore: Bool)
    
}
class FirstLaunch {
    
    let wasLaunchedBefore: Bool
    var isFirstLaunch: Bool {
        return !wasLaunchedBefore
    }
    
    init(source: FirstLaunchDataSource) {
        let wasLaunchedBefore = source.getWasLaunchedBefore()
        self.wasLaunchedBefore = wasLaunchedBefore
        if !wasLaunchedBefore {
            source.setWasLaunchedBefore(true)
        }
    }
    
}
struct AlwaysFirstLaunchDataSource : FirstLaunchDataSource {
    
    func getWasLaunchedBefore() -> Bool {
        return false
    }
    
    func setWasLaunchedBefore(_ wasLaunchedBefore: Bool) {
        // do nothing
    }
    
}
let alwaysFirstLaunchSource = AlwaysFirstLaunchDataSource()
let alwaysFirstLaunch = FirstLaunch(source: alwaysFirstLaunchSource)

 

많은 사람들이 가장 "스위프트스러운" 방법으로 선호하지만,
저는 그것을 강하게 의심합니다. 


여기서 프로토콜을 사용하는것은 많은 이점을 주지 않습니다.
예를 들어, UserDefaults를 FirstLaunchDataSource에 입력할 수 없습니다
(Key값은 어디서 얻을 건가요?)

그래서 우린 다음과 같이 만들어야합니다.

struct UserDefaultsFirstLaunchDataSource : FirstLaunchDataSource {
    
    let defaults: UserDefaults
    let key: String
    
    func getWasLaunchedBefore() -> Bool {
        return defaults.bool(forKey: key)
    }
    
    func setWasLaunchedBefore(_ wasLaunchedBefore: Bool) {
        defaults.set(wasLaunchedBefore, forKey: key)
    }
    
}
let source = UserDefaultsFirstLaunchDataSource(defaults: .standard, key: "your-key")
let firstLaunch = FirstLaunch(source: source)

괴상하지 않나요?
저에겐 두개의 클로저를 전달하는 것이 훨씬 쉽습니다.