좋은 UX는 어떻게 사용자의 궁금증을 먼저 해결해주는가?
시작하며 : 단순한 '조립'을 넘어 '경험'을 설계하다
지난 포스팅 2025.09.03 - [app/metalens] - 생명을 불어넣는 작업 : 재사용 가능한 UI 컴포넌트(Components) 제작 에서 앱의 얼굴을 구성할 모든 UI 부품(Components)을 완성했다. EmptyStateView부터 ScoreCircleView, SummaryCardView 까지, 각자 하나의 책임만을 다하는 잘 만들어진 '레고 블록'들이다. 이제 남은 작업은 이 블록들을 PhotoInspectorView 라는 큰 판위에 조립하여 하나의 완성된 화면을 만드는 것이다.
하지만 이번 포스팅은 단순히 블록을 조립하는 방법을 설명하는 데 그치지 않는다. 한 걸음 더 나아가, '사용자 경험(UX)'이라는 관점에서 이 조립 과정을 바라볼 것이다. 어떻게 하면 사용자가 우리 앱을 더 신뢰하게 만들 수 있을까? 어떻게 하면 복잡한 정보를 더 쉽게 전달할 수 있을까?
이 글에서는 MetaLens의 최종 완성본, PhotoInspectorView.swift 를 해부한다. 각 컴포넌트가 어떻게 유기적으로 결합되는지, 그리고 ScoringGuideView를 Sheet 형태로 제공하여 사용자의 궁금증을 미리 해결해주는 것이 왜 좋은 UX 설계인지, 그 모든 과정을 심층적으로 분석한다. 이로써 MetaLens의 MVP 개발기의 대장정이 막을 내린다.
지휘자(ViewModel)와 무대(View)의 만남
PhotoInspectorView의 가장 중요한 역할은
2025.09.03 - [app/metalens] - 로직과 UI의 지휘자 : 뷰모델(ViewModel)과 비동기 처리 에서 만든 지휘자, PhotoInspectorViewModel과 연결되는 것이다. View는 ViewModel의 상태 변화를 '구독'하고, 그 상태에 따라 자신을 다시 그린다.
MetaLens/Views/PhotoInspectorView.swift
import SwiftUI
import PhotosUI
import CoreLocation
struct PhotoInspectorView: View {
// ✅ @StateObject는 이 View의 생명주기 동안 ViewModel 인스턴스를 단 한 번만 생성하고 유지한다.
@StateObject private var viewModel: PhotoInspectorViewModel
// ✅ Sheet의 표시 여부를 제어하기 위한 상태 변수. @State는 View 자신의 단순한 상태를 저장한다.
@State private var isShowingScoringGuide = false
// ✅ 의존성 주입. View는 생성될 때 ViewModel을 만들기 위한 '재료(photoService)'를 받는다.
init(photoService: PhotoMetadataProtocol) {
_viewModel = StateObject(wrappedValue: PhotoInspectorViewModel(photoService: photoService))
}
// ... (body) ...
}
심층 분석
@StateObject private var viewModel: PhotoInspectorViewModel
@StateObject는 SwiftUI에서 View가 ObservableObject를 구독하는 가장 표준적인 방법이다. 이 프로퍼티 래퍼는 View가 재생성되더라도 viewModel 인스턴스를 파괴하지 않고 메모리에 유지시켜, 앱의 상태가 날아가는 것을 막아준다. private으로 선언하여 이 View 외부에서 viewModel에 직접 접근하는 것을 막는다.
@State private var isShowingScoringGuide = false
@State는 View 내부의 간단한 상태(예: 버튼 클릭 여부, 토글 상태 등)를 저장히기 위해 사용된다. isShowingScoringGuide가 true가 되면, SwiftUI는 이 상태 변화를 감지하고 이 값을 사용하는 UI(sheet 수정자)를 다시 계산한다.
init(photoService: PhotoMetadataProtocol)
이 View는 ViewModel을 직접 받지 않는다. 대신 ViewModel을 생성하는 데 필요한 '재료'인 photoService를 받는다. 이 init 안에서 받은 재료를 사용하여 StateObject를 안전하게 생성한다. 이 구조는 의존성 주입의 흐름을 App → View → ViewModel 으로 명확하게 만들어, Swift의 엄격한 동시성 규칙을 준수하는 가장 현대적인 방식이다.
상태에 따라 얼굴을 바꾸다 : body의 조건부 렌더링
PhotoInspectorView의 body는 자신이 직접 무언가를 그리기보다, ViewModel의 상태를 보고 어떤 컴포넌트를 무대에 올릴지 결정하는 '무대 감독'의 역할을 한다.
var body: some View {
NavigationStack {
ZStack {
// ... (배경색) ...
ScrollView {
VStack(spacing: 24) {
// ✅ ViewModel의 report 상태에 따라 뷰를 분기한다.
if let report = viewModel.report {
// ✅ 분석 결과가 있으면, resultsView 헬퍼 함수를 호출한다.
resultsView(for: report)
} else {
// ✅ 분석 결과가 없으면(초기 상태), EmptyStateView 컴포넌트를 보여준다.
EmptyStateView()
}
}
.padding()
.animation(.easeInOut, value: viewModel.report?.id)
}
// ✅ isLoading 상태에 따라 로딩 화면을 겹쳐서 보여준다.
if viewModel.isLoading {
// ... (로딩 UI) ...
}
}
// ... (네비게이션 설정, 버튼, alert, sheet) ...
}
}
심층 분석
if let report = viewModel.report
이것이 바로 SwiftUI의 조건부 렌더링(Conditional Rendering)이다. viewModel.report가 nil이면, else 블록의 EmptyStateView()가 화면에 그려지고, 분석이 끝나 report에 값이 할당되면, if 블록의 resultsView(for: report)가 화면에 그려진다. SwiftUI는 이 상태 변화를 감지하고 두 뷰 사이의 전환을 자동으로 처리한다.
.animation(.easeInOut, value: viewModel.report?.id)
이 한 줄의 코드가 놀라운 사용자 경험을 만든다. value로 지정된 viewModel.report?.id 값이 바뀔 때마다(즉, 새로운 분석 리포트가 생성될 때마다), SwiftUI는 VStack의 레이아웃 변경사항에 대해 easeInOut 애니메이션을 자동으로 적용한다. 덕분에 EmptyStateView가 사라지고 결과 뷰가 나타나는 과정이 부드럽게 전환된다.
레고 블록 조립하기 : resultsView와 컴포넌트의 조합
resultsView는 PhotoInspectorView 내부에 선언된 private 헬퍼 함수다. 이 함수의 유일한 책임은 만들어 두었던 UI 컴포넌트들을 순서대로 조립하는 것이다.
/// 사진 분석 결과를 보여주는 뷰입니다.
@ViewBuilder
private func resultsView(for report: OriginalityReport) -> some View {
// 1. ScoreCircleView를 올린다.
ScoreCircleView(report: report)
.overlay(alignment: .topTrailing) {
// 2. 그 위에 정보 버튼을 겹친다.
Button {
isShowingScoringGuide = true
} label: {
Image(systemName: "info.circle")
.font(.title2)
.foregroundColor(.secondary)
}
.padding(.trailing, 20)
}
// 3. SummaryCardView를 올린다.
SummaryCardView(summary: report.summary, placemark: viewModel.placemark)
// 4. FlagsListView를 올린다.
FlagsListView(flags: report.flags)
}
심층 분석
@ViewBuilder
여러 개의 뷰(ScoreCircleView, SummaryCardView 등)를 반환하는 함수를 만들 때 사용하는 키워드다. @ViewBuilder가 없다면, 이 뷰들을 VStack이나 Group으로 감싸서 단일 뷰로 만들어 반환해야 한다.
.overlay(alignment: .topTrailing) { ... }
ScoreCircleView 위에 다른 뷰를 겹쳐서 올리는 수정자다. alignment: .topTrailing 은 겹쳐질 뷰(Button)를 ScoreCircleView의 오른쪽 상단에 배치하라는 의미다.
Button { isShowingScoringGuide = true } label: { ... }
정보 아이콘 버튼이다. label에는 버튼의 모양을, action 클로저 { } 안에는 버튼을 눌렀을 때 실행될 코드를 넣는다. 여기서는 단순히 @State 변수인 isShowingScoringGuide의 값을 true로 바꾸는 역할만 한다.
사용자의 궁금증을 먼저 해결해주다 : .sheet 수정자
사용자는 점수를 보자마자 "이 점수, 뭘 기준으로 나온 거지?" 라고 궁금해할 것이다. 좋은 UX는 이 질문이 나오기 전에 답을 미리 준비해두는 것이다. .sheet 수정자는 바로 그 답을 제공하는 가장 우아한 방법이다.
.sheet(isPresented: $isShowingScoringGuide) {
ScoringGuideView()
}
심층 분석
.sheet(isPresented: $isShowingScoringGuide) { ... }
이 수정자는 isShowingScoringGuide라는 상태를 감시한다.
- isPresented: $... : $ 기호는 '바인딩(Binding)'을 의미한다. isShowingScoringGuide의 값을 읽을 뿐만 아니라, 사용자가 시트를 아래로 쓸어내려 닫을 때 이 값을 다시 false로 변경할 수 있는 권한을 sheet에게 넘겨준다.
- 이 값이 false일 때는 아무 일도 하지 않다가, 정보 버튼을 눌러 true가 되는 순간, 클로저 { } 안에 있는 ScoringGuideView()를 화면 아래서 위로 올라오는 '시트(Sheet)' 형태로 띄운다.
- 왜 이것이 좋은 UX인가? 사용자의 현재 작업 흐름(사진 분석 결과 확인)을 완전히 끊지 않으면서도, 부가적인 정보(채점 기준)를 자연스럽게 제공할 수 있기 때문이다. 사용자는 궁금증을 해결한 뒤, 시트를 닫고 원래의 화면으로 바로 돌아올 수 있다.
최종완성 : 그리고 앞으로의 길
이것으로 MetaLens MVP의 모든 코드 구현이 끝났다. 8개의 포스팅에 걸쳐, 하나의 질문에서 시작된 아이디어를 구체적인 기획으로 만들고, 견고한 아키텍처를 설계했으며, 그 위에 데이터 모델, 서비스, 뷰모델, 그리고 재사용 가능한 UI 컴포넌트들을 차례로 쌓아 올렸다. 그리고 마침내, 이 모든 것을 조립하여 사용자에게 가치를 전달하는 하나의 완성된 제품을 만들었다.
MetaLens.xcodeproject 프로젝트 파일부터 최하위 컴포넌트인 EmptyStateView.swift 까지, 작성한 모든 코드는 각자의 위치에서 명확한 책임을 다하며 유기적으로 작동한다.
이제 남은 것은 App Store에 이 앱을 선보이는 일, 그리고 사용자들의 피드백을 바탕으로 PRD에 적어두었던 다음 목표들 "PDF 리포트 내보내기", "메타데이터 제거"와 같은 전문가용 유료 기능을 향해 나아가는 것이다.
'app > metalens' 카테고리의 다른 글
MetaLens 개인정보 처리방침 (Privacy Policy) (0) | 2025.09.04 |
---|---|
생명을 불어넣는 작업 : 재사용 가능한 UI 컴포넌트(Components) 제작 (0) | 2025.09.03 |
로직과 UI의 지휘자 : 뷰모델(ViewModel)과 비동기 처리 (0) | 2025.09.03 |
앱의 심장을 만들다 : 메타데이터 분석 서비스(Service) 구현 (0) | 2025.09.03 |
데이터의 뼈대를 세우다: 모델(Model)과 프로토콜(Protocol) 정의 (0) | 2025.09.02 |