이 포스팅은 DeepCheck의 시작부터 완성까지의 여정을 기록하는 시리즈의 첫 번째 포스팅이다. 이 프로젝트는 어느 날 갑자기 떠오른 아이디어가 아니다. 오래된 경험에서 비롯된 문제의식과 다가올 미래를 예측하여 오랫동안 준비해온 결과물이다.
모든 것은 아주 단순한, 하지만 개발자로서 떨쳐낼 수 없는 질문 하나에서 시작 되었다.
왜 아이폰에는 쓸만한 하드웨어 점검 앱이 없을까?
오래된 경험, 새로운 기회를 보다
개발자 커리어를 블루버드소프트 라는 곳에서 Windows CE 기반의 임베디드 장비들을 다루며 시작했다. 지금은 생소하게 들릴 수 있는 이름이다. 주로 PDA나 산업용 단말기 같은 기기들을 다루었다. 당시 주요 업무 중 하나는, 막 출고된 기기들의 하드웨어가 정상적으로 작동하는지 검증하는 테스트 소프트웨어를 만드는 것이었다.
까만 화면에 로그만 찍히던 열악한 환경에서 버튼의 클릭 신호, 시리얼 포트의 데이터 송수신, 무선 통신 모듈의 전파 강도까지, 하드웨어의 속살을 직접 들여다보는 코드를 짜는 일은 꽤나 흥미로웠다. 그 경험을 통해 하드웨어와 소프트웨어의 경계에서 발생하는 문제들을 해결하는 노하우를 쌓을 수 있었다.
시간이 흘러 아이폰이 세상을 지배하게 되었지만, 항상 풀리지 않는 숙제 같은 것이 보였다. 완벽해 보이는 이 기기에도 분명 물리적인 한계와 결함이 존재할 텐데, 사용자가 직접 기기의 건강 상태를 속속들이 진단해 볼 만한 마땅한 앱이 없다는 사실이었다. 마치 고급 자동차를 샀는데, 엔진오일 레벨이나 타이어 공기압을 직접 확인할 방법이 없는 것과 같았다.
문제는 명확했고, 점점 더 커지고 있었다
- 새 iPhone 구매자의 불안감 : 수백만 원을 호가하는 새 iPhone을 구매했지만, '뽑기 운'이라는 말처럼 초기 불량에 대한 불안감을 사용자가 온전히 떠안아야 했다. 교환 기간인 14일 안에 눈에 보이지 않는 결함까지 찾아내기란 거의 불가능에 가깝다.
- 중고폰 시장의 불신 : 중고폰 거래는 그야말로 '정보 비대칭'의 끝판왕이다. 판매자는 기기 상태를 객관적으로 증명하고 싶어 하고, 구매자는 사진만으로는 알 수 없는 숨겨진 결함(배터리 효율 조작, 미세한 터치 불량 등)을 찾아내고 싶어 한다. 신뢰할 수 있는 중재자가 없는 시장은 혼탁해질 수밖에 없다.
- 수리 후의 찝찝함 : 비싼 돈을 주고 액정이나 배터리를 교체받았는데, 정말 정품 부품으로 제대로 수리된 것인지 사용자는 알 길이 없다. 서비스센터의 말을 믿는 것 외에는 방법이 없다.
예견된 미래, 그리고 시작된 준비
이러한 문제의식을 마음 한편에 품고 있던 중, 시장의 거대한 변곡점이 다가오고 있음을 직감했다. 바로 iPhone 17과 iOS26의 출시였다. 애플의 공식 발표를 예상하고 오래전부터 이 앱을 준비해왔다. 새로운 하드웨어와 OS가 등장하면, 필연적으로 새로운 기준의 '정상'과 '불량'이 정의된다. 사용자들은 그 어느 때보다 자신의 기기 상태에 대해 궁금해할 것이고, 중고 시장에는 이전 세대 기기들이 쏟아져 나오며 혼란이 가중될 것이 분명했다.
바로 지금이다. iPhone 17 출시일에 맞춰, 사용자들이 가장 필요로 하는 완벽한 진단 툴을 세상에 내놓자.
2025년 9월 10일 새벽 2시, 전 세계가 Apple의 키노트를 지켜보며 새로운 iPhone에 열광하고 있을 때, 나는 이미 완성 단계에 접어든 DeepCheck 프로젝트의 코드를 마지막으로 다듬고 있었다. 오래된 경험과 미래에 대한 예측이 만나는 순간이었다.
아키텍처 선택: 왜 SwiftUI와 MVVM이었나?
프로젝트를 시작할 때 가장 먼저 결정해야 하는 것은 '어떤 뼈대 위에 집을 지을 것인가' 이다. 즉, 아키텍처 선택의 시간이다. 이번 프로젝트의 기술 스택은 고민의 여지없이 SwiftUI와 MVVM 아키텍처로 결정했다.
- SwiftUI : 더 이상 UIKit을 고집할 이유가 없었다. 선언형(Declarative) UI 프레임워크인 SwiftuI는 "무엇을(What)" 그릴지에만 집중하면 "어떻게(How)" 그릴지는 프레임워크가 알아서 처리해 준다. if문 하나로 뷰를 조건부 렌더링하고, @State 프로퍼티 래퍼 하나로 데이터와 UI의 상태를 동기화하는 경험은 UIKit의 Delegate 패턴, Storyboard의 복잡함과는 차원이 다른 개발 경험을 선사한다. 최신 iOS 환경에 최적화된 앱을 빠르게 만드는 데 이보다 더 좋은 선택은 없다.
- MVVM (Model-View-ViewModel) : SwiftUI와 가장 궁합이 잘맞는 아키텍처이다. UIKit 시절의 거대한 View Controller (Massive View Controller) 문제를 해결하기 위해 등장한 MVVM은 각자의 역할이 명확하다.
- Model : 순수한 데이터 그 자체이다. struct CheckItem 처럼 앱의 데이터와 비즈니스 로직을 담는다. 다른 어떤 계층에도 의존하지 않는 독립적인 존재이다.
- View : 사용자에게 보여지는 UI이다. 오직 화면을 그리고, 사용자로부터 입력을 받는 역할만 수행한다. ViewModel이 "이 데이터를 여기에 그려줘"라고 말하면, View는 그저 충실히 그려낼 뿐이다.
- ViewModel : View와 Model을 연결하는 다리이자, View를 위한 모든 로직을 처리하는 '지휘자'이다. Model로부터 원본 데이터를 받아와 View에 표시할 형태로 가공(String 포매팅, Date 변환 등)하고, View의 버튼 탭과 같은 이벤트를 받아 Model의 데이터를 변경하거나 비즈니스 로직(Service)을 호출한다.
이 구조는 관심사의 분리(Separation of Concerns)라는 중요한 원칙을 지켜준다. View는 데이터가 어떻게 생겼는지 알 필요 없고, ViewModel은 UI가 어떻게 생겼는지 알 필요 없다. 덕분에 각자의 역할에만 집중할 수 있어 코드의 재사용성과 테스트 용이성이 극대화 된다.
프로젝트의 첫 단추 : 폴더 구조와 진입점
좋은 아키텍처는 잘 정리된 폴더 구조에서 시작된다. DeepCheck 프로젝트의 폴더 구조는 MVVM 패턴의 역할을 그대로 반영했다.
DeepCheck/
├── App/ // ✅ 앱의 생명주기 및 진입점
│ └── DeepCheckApp.swift
├── Views/ // ✅ 사용자에게 보여질 UI (View)
├── ViewModels/ // ✅ UI를 위한 로직 (ViewModel)
├── Models/ // ✅ 데이터 구조 (Model)
├── Services/ // ✅ 핵심 비즈니스 로직 (점검, 권한 등)
├── Protocols/ // ✅ 의존성 분리를 위한 추상화
├── Checks/ // ✅ 개별 점검 항목 로직
├── Components/ // ✅ 재사용 가능한 뷰 컴포넌트
├── Utils/ // ✅ 에러, 로거 등 공통 유틸리티
└── Resources/ // ✅ Assets, Localizable 파일 등 리소스
각 폴더의 역할이 명확히 분리되어 있어, 특정 기능에 대한 코드를 찾거나 새로운 기능을 추가할 때 길을 잃을 염려가 없다. 이제 코드의 첫 줄부터 살펴본다. 모든 앱의 시작점, DeepCheckApp.swift 파일이다.
// DeepCheck/App/DeepCheckApp.swift
import SwiftUI
@main
struct DeepCheckApp: App {
// MARK: - Properties
/// 앱의 전반적인 상태, 특히 권한 처리 상태를 관리하는 ViewModel입니다.
@StateObject private var permissionsViewModel = PermissionsViewModel()
// MARK: - Body
var body: some Scene {
WindowGroup {
// permissionsViewModel의 didCompletePermissions 값에 따라 보여줄 뷰를 결정합니다.
if permissionsViewModel.didCompletePermissions {
// 권한 처리 절차가 완료되었으면, 메인 화면인 WelcomeView를 보여줍니다.
WelcomeView()
} else {
// 아직 권한 처리 절차가 완료되지 않았으면, PermissionsView를 보여줍니다.
// 생성해둔 viewModel 인스턴스를 PermissionsView에 전달합니다.
PermissionsView(viewModel: permissionsViewModel)
}
}
}
}
@StateObject private var permissionsViewModel = PermissionsViewModel()
여기서 @StateObject는 아주 중요한 역할을 한다. 이 프로퍼티 래퍼는 DeepCheckApp이 permissionsViewModel의 소유자(Owner)임을 선언한다. 즉, 앱의 생명주기 동안 permissionsViewModel은 단 한 번만 생성되어 메모리에 유지된다. 이 덕분에 앱의 어느 곳에서든 권한 상태라는 단일 진실 공급원(Single Source of Truth)에 접근할 수 있게 된다.
if permissionsViewModel.didCompletePermissions
이 부분이 바로 SwiftUI의 마법이 시작되는 지점이다. PermissionsViewModel 내부의 didCompletePermissions는 @Published로 선언된 프로퍼티다. 사용자가 권한을 모두 허용하면 ViewModel은 이 값을 true로 변경한다. 그러면 SwiftUI는 이 변경을 자동으로 감지하여 View의 구조를 다시 그린다. 즉, PermissionsView는 화면에서 사라지고 WelcomeView가 마법처럼 나타나게 되는 것이다. 복잡한 화면 전환 코드를 짤 필요가 없다. 그저 상태(didCompletePermissions)만 바꾸면, UI는 선언된 대로 알아서 반응한다.
이처럼 앱의 가장 최상단에서 상태를 관리하고, 그 상태에 따라 보여줄 뷰를 결정하는 방식은 매우 안정적이고 효율적인 사용자 경험을 만들어준다. 사용자는 처음 앱을 켰을 때만 권한을 요청받고, 그 다음부터는 곧바로 메인화면으로 진입하게 되기 때문이다.
지금부터는 DeepCheck의 첫 기능인 '권한 처리' 로직을 통해 Model, ViewModel, View가 실제로 어떻게 각자의 역할을 수행하고 유기적으로 협력하는지 A to Z로 파헤쳐 본다.
MVVM 실전 : 권한 처리 기능 심층 분석
DeepCheck는 마이크, 위치, 블루투스 등 다양한 하드웨어에 접근해야 한다. 사용자에게 불쾌감을 주지 않으면서, 점검에 필요한 모든 권한을 어떻게 체계적으로 요청하고 관리할 수 있을까?
1단계 : 데이터의 본질을 정의하다 - Model (PermissionModel.swift)
모든 것은 데이터에서 시작한다. '권한'이라는 추상적인 개념을 코드로 표현하기 위해 PermissionType과 AuthorizationStatus라는 두 개의 enum을 정의했다. 이 파일의 코드는 군더더기 없이 간결하며, 오직 데이터의 '타입'과 '상태'만을 정의하는 Model의 역할에 완벽하게 충실한다.
// DeepCheck/Models/PermissionModels.swift
import Foundation
// ✅ 점검에 필요한 권한의 종류를 정의하는 열거형(enum)입니다.
enum PermissionType {
case locationWhenInUse // 위치 정보 (사용 중에만)
case motion // 동작 및 피트니스
case microphone // 마이크
case camera // 카메라
case bluetooth // 블루투스
}
// ✅ 각 권한의 허용 상태를 정의하는 열거형입니다.
enum AuthorizationStatus {
case notDetermined // 아직 사용자가 선택하지 않은 상태
case authorized // 허용된 상태
case denied // 거부된 상태
}
PermissionType
어떤 종류의 권한들이 있는지(locataionWhenInUse, motion 등) 명확하게 정의한다. 각 케이스는 그 자체로 하나의 타입을 의미한다.
AuthorizationStatus
권한의 3가지 상태(notDetermined, authorized, denied)를 정의한다. Apple의 공식 프레임워크(AVFoundation, CoreLocation 등)에서 사용하는 권한 상태와 거의 동일한 구조로, 명확하고 직관적이다.
이처럼 Model은 순사하게 '데이터가 어떻게 생겼는가'만을 정의한다. 권한을 '어떻게' 요청하는지에 대한 코드는 전혀 없다.
2단계: 모든 것을 지휘하다 - ViewModel (PermissionsViewModel.swift)
ViewModel은 권한 처리의 모든 로직을 담당하는 지휘자이다. View로부터 "권한 요청해줘!"라는 명령을 받으면, PermissionsManager라는 전문가(Service)에게 실제 작업을 위임하고, 그 결과를 가공하여 View가 표시 할 수 있는 데이터(@Published 프로퍼티)로 변환한다.
// DeepCheck/ViewModels/PermissionsViewModel.swift
import Foundation
// ✅ @MainActor: 이 클래스의 모든 프로퍼티와 메서드는 메인 스레드에서만 접근해야 함을 명시합니다.
// SwiftUI의 View와 데이터를 주고받는 ViewModel에서는 UI 업데이트의 안정성을 위해 필수적입니다.
@MainActor
// ✅ final: 이 클래스는 더 이상 상속될 수 없음을 나타냅니다. 컴파일러가 클래스의 메서드 호출을 최적화(Static Dispatch)할 수 있어 성능상 이점이 있습니다.
final class PermissionsViewModel: ObservableObject {
// ✅ @Published: 이 프로퍼티들의 값이 변경될 때마다, 이 ViewModel을 구독하는 모든 View에게 "UI 업데이트 해!"라고 자동으로 알려주는 마법 같은 프로퍼티 래퍼입니다.
@Published var didCompletePermissions: Bool = false
// [PermissionType: AuthorizationStatus] 딕셔너리를 사용하여 각 권한의 상태를 효율적으로 관리합니다.
@Published var permissionStatuses: [PermissionType: AuthorizationStatus] = [:]
@Published var shouldShowSettingsAlert: Bool = false
private let permissionsManager: PermissionsManagerProtocol
/// ✨ MVP에 필요한 권한 목록을 명확하게 정의합니다.
/// 주석 처리를 통해 특정 권한을 쉽게 활성화/비활성화할 수 있는 유연한 구조입니다.
let requiredPermissions: [PermissionType] = [
.locationWhenInUse,
.motion,
.microphone,
.bluetooth
// .camera, // MVP에서는 사용하지 않으므로 비활성화
]
// 의존성 주입(Dependency Injection)을 통해 PermissionsManager를 외부에서 받습니다.
// 이는 테스트 용이성을 극대화합니다. 실제 객체 대신 Mock 객체를 주입하여 테스트할 수 있습니다.
init(permissionsManager: PermissionsManagerProtocol = PermissionsManager()) {
self.permissionsManager = permissionsManager
updateAllStatuses() // 생성 시점에 모든 권한의 현재 상태를 즉시 확인합니다.
checkIfPermissionsScreenIsNeeded() // 권한 화면이 필요한지 여부를 확인하여, 이미 권한이 있다면 바로 메인 화면으로 넘어갑니다.
}
/// View로부터 권한 요청 명령을 받았을 때 실행되는 메인 로직입니다.
func grantPermissions() async {
// requiredPermissions 배열을 순회하며 아직 결정되지 않은(.notDetermined) 권한만 요청합니다.
for permission in requiredPermissions {
if permissionsManager.authorizationStatus(for: permission) == .notDetermined {
// PermissionsManager에게 실제 권한 요청을 위임하고, 결과를 비동기적으로 기다립니다.
let status = await permissionsManager.requestPermission(for: permission)
// 결과를 딕셔너리에 업데이트합니다.
permissionStatuses[permission] = status
}
}
updateAllStatuses() // 모든 요청이 끝난 후, 다시 한번 최신 상태로 업데이트합니다.
// 권한 중 하나라도 '거부'된 상태가 있는지 확인합니다.
let isAnyPermissionDenied = requiredPermissions.contains {
permissionStatuses[$0] == .denied
}
if isAnyPermissionDenied {
// 거부된 권한이 있다면, 사용자에게 설정 앱으로 유도하는 알림을 띄우도록 상태를 변경합니다.
shouldShowSettingsAlert = true
} else {
// 모든 필수 권한이 허용되었다면, 메인 화면으로 넘어갈 수 있도록 상태를 변경합니다.
let needsRequest = requiredPermissions.contains {
permissionStatuses[$0] == .notDetermined
}
if !needsRequest {
didCompletePermissions = true
}
}
}
/// 모든 필수 권한의 현재 상태를 가져와 permissionStatuses 딕셔너리를 업데이트합니다.
private func updateAllStatuses() {
for permission in requiredPermissions {
permissionStatuses[permission] = permissionsManager.authorizationStatus(for: permission)
}
}
/// 앱 시작 시, 이미 모든 권한이 허용된 상태인지 확인합니다.
private func checkIfPermissionsScreenIsNeeded() {
let isAnyPermissionNotGranted = requiredPermissions.contains {
permissionStatuses[$0] != .authorized
}
// 모든 권한이 이미 허용 상태라면, didCompletePermissions를 true로 변경하여 권한 화면을 건너뜁니다.
if !isAnyPermissionNotGranted {
didCompletePermissions = true
}
}
}
이 ViewModel은 정말 많은 일을 하고 있으며, MVVM의 역할과 장점을 명확하게 보여준다.
@MainActor
이 클래스의 모든 프로퍼티 접근과 메소드 호출은 기본적으로 메인 스레드에서 실행된다. SwiftUI의 View는 메인 스레드에서만 업데이트되어야 하므로, 이 어노테이션 하나로 스레드 관련 버그를 우너천 차단하는 매우 중요한 안전장치이다.
@Published 프로퍼티 3종 세트
- didCompletePermissions : 권한 화면을 보여줄지, 메인 화면으로 넘어갈지를 결정하는 핵심 스위치이다.
- permissionStatuses : 각 PermissionType별 현재 AuthorizationStatus를 담는 딕셔너리이다. View는 이 데이터를 사용하여 각 권한의 현재 상태를 UI에 표시한다.
- shouldShowSettingsAlert : 사용자가 권한을 '거부'했을 때, 설정 앱으로 유도하는 알림창을 띄우기 위한 스위치이다. 이런 디테일이 사용자 경험의 질을 결정한다.
requiredPermissions
MVP 범위에 맞춰 필요한 권한만 정의해 둔 배열이다. [.camera]를 주석 처리한 것에서 볼 수 있듯이, 요구사항 변경에 매우 유연하게 대처할 수 있는 좋은 설계이다.
init()의 스마트한 로직
객체가 생성되자마자 현재 권한 상태를 모두 확인하고 updateAllStatuses, 만약 이미 모든 권한이 허용된 상태라면 checkIfPermissionsScreenIsNeeded에서 didCompletePermissions를 true로 바꿔, 권한 화면 자체를 건너뛰게 만든다. 재방문 사용자에게 쾌적한 경험을 선사한다.
grantPermissions()의 섬세한 로직
단순히 모든 권한을 요청하는 것이 아니라, notDetermined(미결정) 상태인 권한만 요청하여 불필요한 시스템 팝업을 띄우지 않는다. 또한, 요청 후 denied(거부) 상태인 권한이 있다면 알림창을 띄우고, 모두 authorized(허용) 상태가 되었을 때만 화면 전환시키는 등 모든 예외 상황을 섬세하게 처리한다.
3단계: 그저 보여줄 뿐 - View(PermissionsView.siwft)
View는 이제 이 똑똑한 ViewModel을 사용하는 PermissionView의 실제 코드를 분석해 본다. 이 뷰는 ViewModel이 제공하는 '상태'를 바탕으로 UI를 그리고, 사용자 액션을 다시 ViewModel에 전달하는 역할에만 완벽하게 집중한다.
// DeepCheck/Views/PermissionsView.swift
import SwiftUI
struct PermissionsView: View {
// ✅ @ObservedObject: 이 View는 viewModel을 소유하지 않고, 상위 뷰(DeepCheckApp)로부터 전달받아 '관찰'만 합니다.
@ObservedObject var viewModel: PermissionsViewModel
// ✅ @Environment: SwiftUI가 제공하는 시스템 환경 값에 접근합니다. 여기서는 URL을 열기 위한 기능을 가져옵니다.
@Environment(\.openURL) private var openURL
var body: some View {
VStack(spacing: 20) {
// ... (상단 UI: 아이콘, 제목, 설명 등)
// ✅ viewModel의 requiredPermissions 배열을 순회하며 각 권한에 대한 UI를 그립니다.
VStack(alignment: .leading, spacing: 15) {
ForEach(viewModel.requiredPermissions, id: \.self) { permission in
// 현재 순회 중인 permission의 상태를 viewModel에서 가져옵니다.
let status = viewModel.permissionStatuses[permission] ?? .denied
// ✅ switch 문을 사용하여 각 권한 타입에 맞는 UI(PermissionRow)를 체계적으로 구성합니다.
switch permission {
case .locationWhenInUse:
PermissionRow(iconName: "location.circle.fill", title: "permissions.location.title", /*...*/)
// ... (다른 권한 케이스들)
case .camera:
EmptyView() // MVP에서 사용하지 않는 카메라는 빈 뷰를 반환하여 쉽게 UI에서 제외합니다.
}
}
}
.padding()
// ... (UI 스타일링)
// ✅ 메인 액션 버튼. 버튼이 눌리면 viewModel의 비동기 함수인 grantPermissions를 호출합니다.
Button {
Task {
await viewModel.grantPermissions()
}
} label: { /* ... */ }
// ✅ viewModel의 shouldShowSettingsAlert 상태값과 alert UI를 바인딩합니다.
// 이 값이 true가 되면 알림창이 자동으로 나타납니다.
.alert("permissions.alert.title", isPresented: $viewModel.shouldShowSettingsAlert) {
// '설정으로 이동' 버튼은 @Environment로 가져온 openURL을 사용해 앱 설정 화면을 엽니다.
Button("permissions.alert.button.settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
openURL(url)
}
}
Button("permissions.alert.button.cancel", role: .cancel) {}
} message: { /* ... */ }
}
}
}
// ✅ 재사용을 위해 권한 목록의 한 줄을 별도의 View로 분리했습니다. (단일 책임 원칙)
struct PermissionRow: View {
// ... (PermissionRow 구현)
// 이 뷰는 전달받은 status 값에 따라 체크마크 아이콘을 보여주거나, 회색으로 처리하는 등
// 자체적인 UI 로직만을 담당합니다.
}
@ObservedObject
DeepCheckApp.swift에서 만들어진 permissionsViewModel을 전달받아 '구독'한다. 이제 ViewModel의 @Published 프로퍼티가 변경되면 이 PermissionsView는 자동으로 다시 그려진다.
@Environment(\.openURL)
SwiftUI의 강력한 기능 중 하나로, 시스템 환경(시간, 색상모드, URL열기 등)에 쉽게 접근할 수 있게 해준다. 이 덕분에 View는 '설정 홤녀을 어떻게 여는지'에 대한 구체적인 방법을 알 필요 없이, 그저 openURL(url)을 호출하기만 하면 된다.
State-Driven Alert
.alert 수정자는 isPresented 파라미터에 $viewModel.shouldShowSettingsAlert를 바인딩함으로써, ViewModel이 shouldShowSettingsAlert = true 로 상태를 바꾸기만 하면 알아서 알림창을 띄워준다. "알림창을 띄워라"가 아니라 "알림창이 필요한 상태는 이것이다"라고 선언하는 방식이다.
이제 모든 조각이 맞춰졌다. Model은 데이터의 구조를, ViewModel은 상태와 모든 비즈니스 로직을, View는 오직 상태를 기반으로 한 UI렌더링과 사용자 입력 전달만을 담당한다. 이것이 바로 이 프로젝트에서 구현한 MVVM 아키텍처의 핵심이다.
'app > deepcheck' 카테고리의 다른 글
DeepCheck 개인정보 처리방침 (Privacy Policy) (0) | 2025.09.11 |
---|