본문 바로가기
app/metalens

로직과 UI의 지휘자 : 뷰모델(ViewModel)과 비동기 처리

by 조현성 2025. 9. 3.
async/await와 @Published로 우아하게 상태를 관리하는 법

썸네일

시작하며 : 왜 '지휘자'가 필요한가? 

이전 포스팅까지의 작업으로, 사진 데이터를 주면 분석 리포트를 뱉어내는 강력한 엔진(PhotoMetadataService)을 만들었다. 이제 남은 것은 이 엔진을 UI와 연결하는 일이다. 하지만 여기서 많은 개발자들이 함정에 빠진다. View 코드 안에서 직접 서비스 로직을 호출하고, 상태를 관리하려는 시도를 하는 것이다. 이는 마치 레스토랑 홀 직원이 주방에 직접 들어가 요리하는 것과 같다. 결과는 엉망이 될 수밖에 없다. 

 

그래서 ViewModel이라는 '지휘자' 혹은 '셰프'를 둔다. 

  • View(홀 직원)는 오직 ViewModel(셰프)에게 "손님이 사진을 골랐습니다"라고 주문만 전달한다. 
  • ViewModel(셰프)는 Service(주방)에 요리(분석)를 지시하고, 완성된 요리(OriginalityReport)를 받아 View가 보여주기 좋은 형태로 가공하여 내보낸다. 

이번 포스팅에서는 MetaLens의 지휘자, PhotoInspectorViewModel.swift를 해부한다. SwiftUI의 핵심인 @Published를 이용한 반응형 상태 관리, PhotosPicker와의 연동, 그리고 현대 Swift의 꽃이라 불리는 async/await를 사용하여 복잡한 비동기(Asynchronous) 작업을 얼마나 깔끔하고 우아하게 처리할 수 있는지, 그 모든 것을 알아본다. 

 

ViewModel의 기본 구조와 책임

ViewModel은 ObservableObject 프로토콜을 채택한 class다. ObservableObject는 "나으 ㅣ상태 변화를 누군가 지켜볼 수 있다"고 알려주는 푯말과 같다. 

MetaLens/ViewModels/PhotoInspectorViewModel.swift
import Foundation
import SwiftUI
import PhotosUI
import CoreLocation

// ✅ MainActor는 이 클래스의 모든 코드가 기본적으로 메인(UI) 스레드에서 실행됨을 보장한다.
// UI 업데이트는 반드시 메인 스레드에서 해야 하므로, 이 키워드는 데이터 경쟁(Data Race)을 원천 차단한다.
@MainActor
final class PhotoInspectorViewModel: ObservableObject {
    
    // MARK: - Dependencies
    // ✅ '계약서'인 PhotoMetadataProtocol 타입이다.
    // 실제 구현체(PhotoMetadataService)가 아닌 프로토콜에 의존함으로써, 테스트 용이성과 유연성이 극대화된다.
    private let photoService: PhotoMetadataProtocol
    
    // ✅ 주소 변환을 위한 Apple의 CoreLocation 프레임워크 클래스.
    private let geocoder = CLGeocoder()
    
    // ... (Published Properties) ...

    // MARK: - Initializer
    // ✅ '의존성 주입' 패턴. View가 생성될 때 실제 photoService를 주입받는다.
    init(photoService: PhotoMetadataProtocol) {
        self.photoService = photoService
        Log.info("PhotoInspectorViewModel이 초기화되었습니다.")
    }
    
    // ... (Methods) ...
}

 

심층 분석

@MainActor

Swift 5.5에서 도입된 동시성(Concurrency) 기능의 핵심이다. ViewModel은 UI의 상태를 직접 변경하는 데이터를 가지고 있다. 만약 백그라운드 스레드에서 이미지 분석이 끝났다고 해서, 그 스레드가 직접 UI 데이터를 건드리면 앱은 크래시를 일으킬 확률이 매우 높다. @MainActor는 이 클래스의 모든 코드가 메인 스레드에서 실행되도록 강제함으로써, 이런 종류의 버그를 컴파일 시점에 막아주는 강력한 안전장치다.

private let photoService: PhotoMetadataProtocol

ViewModel은 실제 서비스가 어떻게 생겼는지 모른다. 오직 analyze 라는 함수가 있따는 '계약서'(Protocol)만 알고 있을 뿐이다. 이것이 바로 의존성 역전 원칙(Dependency Inversion Principle)이며, 좋은 아키텍처의 핵심이다. 

 

UI를 살아 움직이게 하는 마법 : @Published와 상태 관리

ViewModel의 가장 중요한 책임은 '상태(State)'를 관리하는 것이다. "지금 로딩 중인가?", "분석이 끝났는가?", "에러가 발생했는가?" 와 같은 모든 상태를 ViewModel이 들고 있고, View는 그 상태에 따라 자신을 그리기만 하면 된다. 이 둘을 연결해주는 것이 바로 @Published 프로퍼티 래퍼다. 

// MetaLens/ViewModels/PhotoInspectorViewModel.swift

// MARK: - Published Properties
// ✅ @Published: 이 프로퍼티의 값이 변경될 때마다, 이 ViewModel을 구독(Observing)하는
// SwiftUI View에게 "화면을 다시 그려야 해!" 라고 자동으로 알려주는 방송(Publisher) 역할을 한다.

/// 분석이 완료된 최종 리포트. View는 이 값이 채워지면 결과 화면을 그린다.
@Published private(set) var report: OriginalityReport?

/// 현재 사진을 분석 중인지 여부. View는 이 값이 true이면 로딩 인디케이터를 보여준다.
@Published private(set) var isLoading: Bool = false

/// 분석 과정에서 에러가 발생했을 때, 사용자에게 보여줄 에러 메시지. View는 이 값이 채워지면 알림창을 띄운다.
@Published var errorMessage: String?

/// 변환된 주소 정보. View는 이 값이 채워지면 위치 정보 UI를 업데이트한다.
@Published private(set) var placemark: String?

 

심층 분석

@Published

ObservableObject와 한 쌍으로 작동한다. report에 새로운 분석 결과가 할당되는 순간(self.report = analysisResult), @Published는 "값이 바뀌었다!"는 신호를 보낸다. PhotoInspectorView는 @StateObject를 통해 이 ViewModel을 구독하고 있으므로, 신호를 받는 즉시 body를 다시 계싼하여 화면을 갱신한다. 이것이 SwiftUI의 데이터 바인딩(Data Binding)과 반응형 프로그래밍(Reactive Programmming)의 핵심 원리다. 

private(set)

"값을 읽는 것(get)은 누구나 할 수 있지만, 값을 쓰는 것(set)은 이 파일 내부(private)에서만 가능하다"는 접근 제어 키워드다. View가 멋대로 viewModel.isLoading = false 처럼 상태를 조작하는 것을 막고, 오직 ViewModel 내부의 로직을 통해서만 상태가 변경되도록 강제하여 코드의 안정성을 높인다.

 

모든 것의 시작점 : PhotosPicker 연동

사용자가 사진을 선택하는 순간, 모든 분석 프로세스가 시작된다. ViewModel은 이 시작점을 어떻게 알 수 있을까? 바로 PhotosPicker와 바인딩된 프로퍼티에 didSet 옵저버를 사용하는 것이다. 

// MetaLens/ViewModels/PhotoInspectorViewModel.swift

/// PhotosPicker를 통해 사용자가 선택한 사진 항목입니다.
@Published var selectedPhoto: PhotosPickerItem? {
    // ✅ didSet: selectedPhoto 프로퍼티에 새로운 값이 할당된 '직후'에 이 블록이 실행된다.
    didSet {
        // 새로운 사진이 선택되었을 때만(nil이 아닐 때만) 분석을 시작한다.
        if selectedPhoto != nil {
            analyzeSelectedPhoto()
        }
    }
}

 

심층 분석

selectedPhoto: PhotosPickerItem?

PhotosPicker는 선택된 사진 정보를 PhotosPickerItem 타입으로 반환한다. 이 타입의 프로퍼티를 ViewModel에 선언하고, View의 PhotosPicker(selection: $viewModel.selectedPhoto, ...) 처럼 $기호를 사용하여 바인딩한다. 

didSet

View에서 사용자가 사진을 선택하면, selectedPhoto의 값이 nil에서 실제 PhotosPickerItem으로 변경된다. 바로 그 순간, didSet 블록이 호출되고, analyzeSelectedPhoto() 함수를 실행시키는 '방아쇠' 역할을 한다.

 

지옥의 콜백을 구원한 영웅 : async/await 와 비동기 처리

이제 이 포스팅의 하이라이트, analyzeSelectedPhoto 함수다. 이 함수는 최소 두 가지의 시간이 걸리는 작업을 처리해야 한다. 

  1. PhotosPickerItem 에서 원본 Data를 로드하는 작업 (잠재적으로 큼) 
  2. 로드된 Data를 PhotoMetadataService에 보내는 분석하는 작업 (CPU 집약적)
  3. (GPS가 있다면) 좌표를 주소로 변환하는 작업 (네트워크 통신)

과거의 방식(콜백 함수, Completion Handler)이었다면, 코드는 여러 단계의 클로저가 중첩된 "콜백 지옥(Callback Hell)"이 되었을 것이다. 하지만 Swift 5.5 부터 도입된 async/await는 이 모든 것을 동기적인 코드처럼 깔끔하게 만들어준다. 

// MetaLens/ViewModels/PhotoInspectorViewModel.swift

private func analyzeSelectedPhoto() {
    Log.info("선택된 사진 분석을 요청합니다.")
    
    // 1. 상태 초기화
    self.report = nil
    self.errorMessage = nil
    self.placemark = nil
    self.isLoading = true
    
    // 2. 비동기 작업 시작
    Task {
        do {
            // 3. 사진 데이터 로드 (첫 번째 비동기 호출)
            guard let data = try await selectedPhoto?.loadTransferable(type: Data.self) else {
                throw AppError.dataLoadingFailed
            }
            
            // 4. 메타데이터 분석 (두 번째 비동기 호출)
            let analysisResult = try await photoService.analyze(photoData: data)
            self.report = analysisResult // 메인 스레드에서 안전하게 UI 상태 업데이트
            
            // 5. 리버스 지오코딩 (세 번째 비동기 호출)
            if let location = analysisResult.summary.location {
                await reverseGeocode(location: location)
            }
            
        } catch {
            // 6. 에러 처리
            self.errorMessage = error.localizedDescription // 메인 스레드에서 안전하게 UI 상태 업데이트
        }
        
        // 7. 로딩 종료
        self.isLoading = false
        Log.info("사진 분석 프로세스가 종료되었습니다.")
    }
}

 

심층 분석

Task { ... }

didSet과 같은 동기적인(Synchronous) 코드 블록에서 비동기(Asynchronous) 작업을 시작하기 위한 진입점이다. 이 Task 블록 안의 코드는 즉시 백그라운드에서 실행되기 시작한다.

await : '기다림'을 의미하는 키워드
  • try await selectedPhoto?.loadTransferable(...) : loadTransferable은 비동기 함수다. await를 붙이면, Swift는 데이터 로드가 끝날 때까지 이 줄에서 함수의 실행을 '일시정지'시킨다. 중요한 것은, 앱 전체가 멈추는 것이 아니라 이 함수의 실행만 잠시 멈추는 것이다. 그동안 사용자는 UI를 계쏙해서 스크롤하는 등 다른 작업을 할 수 있다. 로드가 끝나면, Swift는 정확히 다음 줄(let analysisResult = ...)부터 코드 실행을 재개한다.
  • try await photoService.analyze(...) : 마찬가지로, analyze 함수가 끝날 때까지 기다린다.
do-catch

try 키워드가 붙은 함수는 실패하여 에러를 던질(throw) 수 있다. do-catch 블록은 이 에러를 우아하게 잡아내는 방법이다. do 블록 안의 try await 구문 어디서든 에러가 발생하면, 코드 실행은 즉시 중단되고 catch 블록으로 점프한다. catch 블록에서는 @Published 프로퍼티인 errorMessage에 에러 설명을 담아, UI에 알림창을 띄우도록 한다.

@MainActor의 위력

Task 블록 안의 모든 코드는 백그라운드에서 실행될 수 있지만, @MainActor로 선언된 PhotoInspectorViewModel의 프로퍼티(report, errorMessage, isLoading 등)에 접근하는 코드는 Swift 컴파일러가 알아서 메인 스레드로 다시 돌려서 안전하게 실행해준다. 과거에는 DispatchQueue.main.async { ... } 같은 코드를 직접 써야 했지만, 이제는 그럴 필요가 없다.

 

또 다른 비동기 작업 : 리버스 지오코딩

분석이 끝난 후, GPS 좌표를 주소로 변환하는 것 역시 Apple 서버와 통신해야 하는 대표적인 비동기 작업이다. 이 또한 async/await로 깔끔하게 처리할 수 있다. 

// MetaLens/ViewModels/PhotoInspectorViewModel.swift

private func reverseGeocode(location: CLLocationCoordinate2D) async {
    Log.info("위치 정보 주소 변환을 시작합니다...")
    let clLocation = CLLocation(latitude: location.latitude, longitude: location.longitude)
    
    do {
        // ✅ CLGeocoder의 비동기 함수를 호출하고, 결과가 올 때까지 기다린다.
        let placemarks = try await geocoder.reverseGeocodeLocation(clLocation)
        
        if let place = placemarks.first {
            let address = [place.country, place.administrativeArea, place.locality, /* ... */]
                .compactMap { $0 }
                .joined(separator: ", ")
            
            // ✅ @Published 프로퍼티를 업데이트하면, View는 자동으로 주소 정보를 표시한다.
            self.placemark = address
            Log.info("주소 변환 성공: \(address)")
        }
    } catch {
        Log.error("주소 변환 실패: \(error.localizedDescription)")
        self.placemark = nil // 실패 시 nil로 설정
    }
}

 

analyzeSelectedPhoto 함수와 완벽하게 동일한 구조다. try await로 비동기 함수를 호출하고, do-catch로 에러를 처리하며, 성공 시 @Published 프로퍼티를 업데이트한다. 이처럼 async/await는 어떤 종류의 비동기 작업이든 일관된 방식으로 처리할 수 있게 해주는 강력한 패러다임이다. 

 

정리하며

이번 포스팅에서는 MetaLens의 두뇌이자 지휘자인 ViewModel을 구현했다. 

  1. @Published 와 ObservableObject를 통해, ViewModel의 상태 변화가 어떻게 SwiftUI View에 자동으로 반영되는지, 그 반응형 매커니즘을 이해했다. 
  2. PhotosPicker 와의 연동을 통해, 사용자의 입력이 어떻게 비즈니스 로직을 촉발시키는지 그 시작점을 분석했다. 
  3. async/await 와 Task 를 사용하여, 데이터 로딩, CPU 집약적 분석, 네트워크 통신이라는 여러 종류으 ㅣ복잡한 비동기 작업을, 마치 동기 코드처럼 읽기 쉽고 안전하게 처리하는 방법을 마스터 했다.

이제 앱의 내부 로직은 모두 완성되었다. 남은 것은 이 모든 것을 담아낼 아름다운 그릇, 즉 UI를 만드는 일이다. 다음 포스팅에서는 Components 폴더에 ScoreCircleView 와 같은 UI 부품들을 하나씩 만들어 나가는 과정을 다룰 것이다.