2025. 1. 22. 10:40ㆍUIKit, SwiftUI, H.I.G
앱을 처음 앱스토어에 배포한 이후,
유지보수를 하다 보면 예상치 못한 버그를 수정하거나, 앱의 기능을 개선하거나, 새로운 기능을 추가하는 등의 앱 업데이트도 지속해서 이루어집니다.
앱 업데이트는 보통 아이폰 스스로 진행되거나, 사용자가 App Store에 직접 해당 앱 링크에 접속해서 업데이트를 시켜주는 방식이 있죠.
보통은 전자의 방식으로 [설정 - App Store - 자동 다운로드] 설정에 의해 사용자가 별도로 앱 업데이트를 시키지 않아도,
핸드폰을 사용하지 않는 새벽 시간대에 (알게 모르게) 업데이트된 앱을 다운로드 받아줍니다.
문제는 해당 설정을 OFF 시켜둔 사용자에게 해당되는, 후자의 경우인데요!
단순한 기능이 추가된 경우에는 - 업데이트 없이 하위 버전에 해당하는 앱을 사용해도 크게 문제가 되지는 않겠지만,
만약 앱의 실행과 관련된 중대한 버그를 수정한 경우, 혹은 최신 버전 업데이트로 인해 기존 버전과 호환이 되지 않는 경우에는 - 업데이트를 시켜주지 않는 상황에서 앱 실행 자체가 문제가 될 수 있을 겁니다.
강제 업데이트 Alert는 이런 상황을 사전에 방지하기 위해 사용되는 기능입니다!
⚠️ 강제 업데이트 Alert 사용과 관련해 주의해야 할 점!
: 강제 업데이트 Alert는 말 그대로, 사용자의 앱 사용을 중단시키고 사용자에게 업데이트를 강요하기 위한 수단입니다.
이는 "사용자의 앱 경험을 명백하게 해치는 행위"에 해당하죠. -> 앱 업데이트 심사 시 리젝의 사유가 될 수 있다는 의미입니다.
따라서, 업데이트가 필수적이지 않은 경우에는 선택적 업데이트 Alert를 고려하거나,
Alert 사용 시기와 빈도를 조절하는 등의 사용자 앱 경험을 해치지 않기 위한 노력을 기울이는 것이 중요할 겁니다 !
따라서, 이번 글에서는 <강제 업데이트 Alert를 포함한 선택적 업데이트 Alert를 어떤 식으로 구현했는지.>
그리고 <구현하기까지 어떤 요소 등을 고려해서 기능을 추가했는지.>까지 차근차근 살펴보도록 하겠습니다.
업데이트 규칙 세우기
<토스터 TOASTER> 앱에서는 크게 Semantic Versioning 규칙을 따라 앱 버전을 관리하고 있습니다.
*Semantic Versioning은 <Major Version.Minor Version.Patch Version>의 형식으로 <1.2.0>과 같이 관리하는 가장 흔한 방식입니다. 자세한 내용은 밑줄 링크를 타고 친절하게 설명해 둔 예전 글을 참고해주세욧!
기본적으로는 호환 불가 -> 호환 가능 & 신규 기능 -> 버그 수정의 순으로 Major -> Minor -> Patch 버전 번호를 높이는 틀이 있지만,
저희 팀은 기획 팀에서 애플리케이션 상황에 맞추어 추가적인 규칙을 추가해 어플 버전을 관리하도록 했습니다.
어떤 규칙인지 살펴볼까요?
- Major Version (1.x.x) : 이전 버전에 없던 신규 기능이 추가되었을 때 적용 (ex. 커뮤니티 기능의 추가 등)
- Minor Version (x.1.x) : 특정 기능 내에서 관련된 세부 기능이 추가되었을 때, 혹은 기존 기능이 개선되었을 때 적용 (ex. 링크의 제목 수정 가능 등)
- Patch Version (x.x.1) : 2차 QA를 진행한 이후 발견된 버그를 반영하거나, 유저 피드백에 따른 오류 수정 등을 반영할 때 적용 (ex. 레이아웃 수정, 코치마크 적용 등)
그리고 이 규칙에 맞추어 표출할 Alert도 구분해줬습니다.
여기서 표출되는 업데이트 Alert가 구분된다는 것에 대한 의미는,
Alert에 들어갈 Title과 Description 메시지를 포함한 업데이트의 강제/선택 유무까지 구분해준다는 의미입니다.
우리 앱에서는 Major 업데이트인 경우에만 강제 업데이트 Alert를 표출.
나머지 업데이트에 대해서는 선택적 업데이트 Alert를 표출하거나, Alert 자체를 표출하지 않는 방식으로 로직을 확정하게 되었습니다.
실제 Alert를 표출할 때는 각 case를 구분해주기 위해 아래와 같은 enum형을 만들었습니다.
이제부터는 UpdateAlertType라는 enum형으로 Alert의 Title과 Message 부분에 들어갈 텍스트를 쉽게 관리해줄 수 있겠군요!
enum UpdateAlertType {
case NoticeFeatUpdate // Patch Update case -> 선택 업데이트
case NoticeUpdate // Minor Update case -> 선택 업데이트
case ForceUpdate // Major Update case -> 강제 업데이트
var title: String {
switch self {
case .NoticeFeatUpdate:
return "기능 업데이트 알림"
case .NoticeUpdate:
return "업데이트 알림"
case .ForceUpdate:
return "신규 기능 업데이트 알림"
}
}
var description: String {
switch self {
case .NoticeFeatUpdate:
return "토스터의 기능이 추가되었어요!\n지금 바로 업데이트해보세요"
case .NoticeUpdate:
return "토스터의 사용성이 개선되었어요!\n지금 바로 업데이트해보세요"
case .ForceUpdate:
return "토스터의 새로운 기능을 이용하기 위해서는\n업데이트가 필요해요!\n최신 버전으로 업데이트 하시겠어요?"
}
}
}
이제부터 해야 할 것은?
앱 실행과 동시에, App Store에 올라와 있는 앱의 최신 버전과 실행되고 있는 앱의 현재 버전을 비교합니다. -> 이후, 비교 결과에 따라 적절한 UIAlertController를 표출하면 되는 로직!
- App Store 버전 : iTunes API를 통해 앱에 할당된 ID를 가지고 App Store에 올라와 있는 앱 정보를 통해 가져옵니다.
- 앱의 현재 버전 : Info.plst 파일에 정의된 CFBundleShortVersionString 값을 가져옵니다.
위에서 말하는 앱에 할당된 ID란?
[App Store Connect - 앱 정보 - Apple ID 값]에 자동으로 부여된 고유한 앱 ID 값을 의미합니다.
이 값을 사용해 현재 App Store에 올라와 있는 애플리케이션에 대한 정보를 가져올 수 있어요! (업데이트 이후, 정보가 최신화되기까지는 하루이틀 정도 소요되는 듯해요.)
App Store에서 현재 앱의 최신 버전을 가져오기
비동기로 위에서 확인한 appID 값을 바탕으로 itunes API를 통해 앱 정보를 JSON으로 받아오는 방식입니다.
URL 주소를 입력해 주고, URLSession을 사용해서 네트워크 통신 과정을 거치면, 아래와 같은 JSON 파일에 접근을 할 수 있는데요.
저희는 이 정보 중 results 배열 안에 담긴 "version"이라는 키 값을 사용해서 버전 번호를 가져올 수 있었습니다.
func checkAppStoreVersion() async -> String {
guard let url = URL(string: "https://itunes.apple.com/lookup?id=\(appId)&country=kr") else { return "" }
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let json = try JSONSerialization.jsonObject(with: data, options: .allowFragments) as? [String: Any],
let results = json["results"] as? [[String: Any]],
let appStoreVersion = results[0]["version"] as? String { return appStoreVersion }
} catch {
print("앱스토어 버전을 가져오지 못했습니다😡")
}
return ""
}
애플리케이션의 현재 앱 버전을 가져오기 (Bundle)
CFBundleShortVersionString 키 값을 사용해, 앱의 현재버전을 가져올 수 있습니다.
func checkBundleVersion() -> String {
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? ""
return version
}
이때의 CFBundleShortVersionString은 Info.plst에서의 Bundle version string (short) 값에 해당되고,
이 String 값과 연결된 MARKERING_VERSION 값은,
[General - Identity - Version]으로 접근해서 수정하거나 / [General - Build Settings - Versioning - Marketing Version]으로 접근할 수 있는 것을 확인할 수 있습니다!
*전자의 방법으로 Version을 관리하면, 후자의 Markerting Version 값도 자동으로 수정됩니다.
버전 비교하고, 상황에 맞는 Alert 표출하기
이제 앞에서 가져온 두 가지의 버전을 순서대로 (Major -> Minor -> Patch 순으로) 비교하고,
각 상황에 따라 3개의 Alert 타입 중 적합한 Alert Type case를 선정하는 로직을 추가해 줍니다.
func checkUpdateAlertNeeded() async -> UpdateAlertType? {
let bundleVersion = checkBundleVersion
let appStoreVersion = await checkAppStoreVersion()
let bundleVersionArray = bundleVersion.split(separator: ".").map { $0 }
let appStoreVersionArray = appStoreVersion.split(separator: ".").map { $0 }
if bundleVersionArray[0] < appStoreVersionArray[0] {
return .ForceUpdate
} else if bundleVersionArray[0] == appStoreVersionArray[0]
&& bundleVersionArray[1] < appStoreVersionArray[1] {
return .NoticeFeatUpdate
} else if bundleVersionArray[0] == appStoreVersionArray[0]
&& bundleVersionArray[1] == appStoreVersionArray[1]
&& bundleVersionArray[2] < appStoreVersionArray[2] {
return .NoticeUpdate
} else {
return nil
}
}
이제 정해진 Type에 맞추어서 Alert를 띄어주는 메서드를 정의할 수 있겠군요.
강제 업데이트인 상황에서는 앱스토어와 이어지는 "업데이트" AlertAction 만을 표출해 주고,
그것이 아닌 나머지 두 상황에서는 Alert를 dismiss 시킬 수 있는 "다음에" AlertAction을 함께 표출해 주도록 Alert를 구성한 모습입니다.
func showUpdateAlert(
type: UpdateAlertType,
on viewController: UIViewController
) {
let alertViewController = UIAlertController(
title: type.title,
message: type.description,
preferredStyle: .alert
)
if type != .ForceUpdate {
let laterAction = UIAlertAction(title: "다음에", style: .default)
alertViewController.addAction(laterAction)
}
let updateAction = UIAlertAction(title: "업데이트", style: .default) { _ in
if let url = URL(
string: "itms-apps://itunes.apple.com/app/\(self.appId)"
), UIApplication.shared.canOpenURL(url) {
if #available(iOS 10.0, *) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
} else {
UIApplication.shared.openURL(url)
}
}
}
alertViewController.addAction(updateAction)
viewController.present(alertViewController, animated: true)
}
그리고 진짜 마지막으로 앱 실행 시 업데이트의 필요여부를 체크하기 위해 SceneDelegate에서 해당 함수를 호출해 주면 됩니다.
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let updateAlertManager = UpdateAlertManager()
func checkUpdate(rootViewController: UIViewController) async {
if let updateStatus = await updateAlertManager.checkUpdateAlertNeeded() {
updateAlertManager.showUpdateAlert(
type: updateStatus,
on: rootViewController
)
}
}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
...
guard let windowScene = (scene as? UIWindowScene) else { return }
let appDelegate = UIApplication.shared.delegate as! AppDelegate
let navigationController = ToasterNavigationController()
self.window = UIWindow(windowScene: windowScene)
self.window?.rootViewController = navigationController
self.window?.makeKeyAndVisible()
Task {
await checkUpdate(rootViewController: navigationController)
}
appCoordinator?.start()
}