거대한 View를 잘게 쪼개는 것의 미학
시작하며 : 왜 View를 분리해야 하는가?
이전 포스팅까지의 여정으로 앱의 두뇌(ViewModel)와 심장(Service)을 모두 구현 했다.
로직과 UI의 지휘자 : 뷰모델(ViewModel)과 비동기 처리
async/await와 @Published로 우아하게 상태를 관리하는 법시작하며 : 왜 '지휘자'가 필요한가? 이전 포스팅까지의 작업으로, 사진 데이터를 주면 분석 리포트를 뱉어내는 강력한 엔진(PhotoMetadataService)
johjo.net
앱의 심장을 만들다 : 메타데이터 분석 서비스(Service) 구현
ImageIO 프레임워크를 파헤쳐 사진의 비밀을 캐내다시작하며 : '실제 일'을 하는 녀석의 등장지난 포스팅에서 앱의 뼈대(Model)와 소통 규칙(Protocol)을 정의했다. 하지만 아직 앱은 아무일도 하지 못
johjo.net
이제 남은 것은 이 모든 것을 사용자에게 보여줄 '얼굴', 즉 View를 만드는 일이다. SwiftUI를 사용하면 PhotoInspectorView.swift 라는 단 하나의 파일 안에 모든 UI 코드를 작성하는 것도 기술적으로는 가능하다. 하지만 이는 재앙으로 가는 지름길이다.
하나의 파일에 수백, 수천 줄의 UI 코드가 뒤섞인 '거대한 뷰(Massive View)'는 다음과 같은 모든 문제를 야기한다.
- 가독성 저하 : 코드의 구조를 파악하기 어려워진다.
- 유지보수 난이도 급증 : 작은 UI 수정 하나가 예쌍치 못한 다른 곳에 영향을 미칠 수 있다.
- 재사용 불가능 : 점수 원형 그래프를 다른 화면에서 또 쓰고 싶다면, 코드를 그대로 복사-붙여넣기 해야 한다.
- SwiftUI 프리뷰 성능 저하 : 파일이 거대해질수록 프리뷰는 점점 느려지고, 생산성은 바닥을 친다.
그래서 '관심사 분리(Separation of Concerns)' 원칙에 따라, 거대한 PhotoInspectorView를 의미 있는 기능 단위의 작은 '부품'으로 잘게 쪼개기로 했다. 그리고 이 부품들을 Components 폴더에 보관하여, 언제든 재사용하고 독립적으로 테스트할 수 있도록 만들 것이다. 이것이 바로 현대 UI 개발의 핵심, 컴포넌트 기반 개발(Component-Based Development) 이다.
이번 포스팅에서는 MetaLens의 얼굴을 구성하는 4개의 핵심 컴포넌트(EmptyStateView, ScoreCircleView, SummaryCardView, FlagsListView, 그리고 ScoringGuideView)를 어떤 철학으로, 어떻게 구현했는지 낱낱이 파헤쳐 본다.
첫 인상을 결정한다 : EmptyStateView.swift
모든 앱은 사용자가 처음 마주하는 '비어 있는 상태(Empty State)'를 가진다. 이 화면은 단순히 비어있는 공간이 아니라, 사용자에게 앱의 정체성을 알리고 첫 행동을 유도하는 가장 중요한 '가이드'다.
MetaLens/Components/EmptyStateView.swift
import SwiftUI
/// 앱의 초기 상태, 즉 아직 분석할 사진이 선택되지 않았을 때 보여주는 뷰입니다.
struct EmptyStateView: View {
var body: some View {
VStack(spacing: 16) {
Spacer(minLength: 50)
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 80))
.foregroundColor(.secondary)
Text("view.emptyState.title")
.font(.title2)
.fontWeight(.bold)
Text("view.emptyState.subtitle")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Spacer()
}
.padding(.top, 50)
}
}
#Preview("Empty State Preview") {
EmptyStateView()
.padding()
}
심층 분석
struct EmptyStateView: View
SwiftUI의 모든 뷰는 View 프로토콜을 따르는 구조체(struct)다. 값 타입인 구조체는 상태를 직접 소유하지 않고, 외부에서 주입된 데이터에 따라 화면을 그리는 역할만 하므로 가볍고 예측 가능하다.
VStack(spacing: 16)
UI 요소들을 수직으로 쌓는 컨테이너다. spacing 파라미터로 내부 요소들 간의 간격을 16포인트로 지정했다.
Spacer(minLength: 50)
VStack 내에서 남는 공간을 최대한 밀어내는 역할을 한다. minLength를 지정하여 최소한의 공간을 확보하고, 전체 콘텐츠가 화면 중앙보다 살짝 위쪽에 위치하도록 유도하여 시각적 안정감을 준다.
Image(systemName: "photo.on.rectangle.angled")
Apple이 제공하는 내장 아이콘 시스템인 SF Symbols를 사용한다. systemName으로 아이콘의 이름을 지정하면 된다. SF Symbols는 텍스트처럼 폰트 크기나 굵기를 조절할 수 있고, 앱의 전반적인 디자인과 통일성을 유지하는 데 매우 유용하다.
- .font(.system(size: 80)) : 아이콘의 크기를 폰트처럼 80포인트로 지정한다.
- .foregroundColor(.secondary) : 아이콘의 색상을 2차 색상(연한 회색)으로 지정하여, 너무 튀지 않으면서도 은은하게 주목을 끈다.
Text("view.emptyState.title")
2025.09.02 - [app/metalens] - 집을 짓기 전 땅 다지기: 프로젝트 설정과 필수 유틸리티 에서 구축한 Localizable.xcstrings의 키를 사용한다. 이 컴포넌트는 자신이 어떤 언어로 보여질지 전혀 신경 쓰지 않는다.
- .font(.title2), .fontWeight(.bold) : 텍스트의 스타일과 굵기를 지정하여 정보의 위계를 만든다. 제목은 크고 굵게, 부제는 작고 얇게.
#Preview("...")
이 컴포넌트 하나만 독립적으로 미리 볼 수 있게 해주는 마법 같은 기능이다. EmptyStateView를 개발하는 동안, 다른 복잡한 뷰 없이 오직 이 화면에만 집중하여 빠르게 UI를 완성할 수 있었다.
핵심 가치를 시각화하다 : ScoreCircleView.swift
MetaLens의 핵심 가치는 '원본성 점수'다. ScoreCircleView는 이 추상적인 숫자를 사용자가 즉각적으로 인지할 수 있는, 매력적인 시각적 요소로 변환하는 책임을 진다.
MetaLens/Components/ScoreCircleView.swift
import SwiftUI
/// 원본성 점수를 시각적으로 표현하는 원형 뷰입니다.
struct ScoreCircleView: View {
let report: OriginalityReport
var body: some View {
VStack {
Text("view.results.scoreTitle")
.font(.headline)
.foregroundColor(.secondary)
ZStack {
// 점수에 따라 색상이 변하는 원형 배경
Circle()
.stroke(
report.scoreBadgeColor.opacity(0.3),
style: StrokeStyle(lineWidth: 20)
)
Circle()
.trim(from: 0, to: CGFloat(report.score) / 100.0)
.stroke(
report.scoreBadgeColor,
style: StrokeStyle(lineWidth: 20, lineCap: .round)
)
.rotationEffect(.degrees(-90))
.shadow(color: report.scoreBadgeColor.opacity(0.5), radius: 10, x: 0, y: 5)
// 점수 텍스트
HStack(alignment: .firstTextBaseline, spacing: 0) {
Text("\(report.score)")
.font(.system(size: 60, weight: .bold))
Text("/100")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.secondary)
}
}
.frame(height: 200)
.padding()
// 점수가 나타날 때 애니메이션 효과
.animation(.easeInOut(duration: 1.0), value: report.id)
}
}
}
심층 분석
let report: OriginalityReport
이 컴포넌트는 외부로부터 OriginalityReport 데이터를 주입받는다. 이 컴포넌트는 점수가 어떻게 계산되었는지 전혀 알지 못한다. 오직 주어진 report 데이터를 화면에 그릴 뿐이다.
Zstack
UI 요소들을 겹쳐서 쌓는 컨테이너다. 연한 회색 원(배경), 점수에 따른 색상 원(전경), 그리고 점수 텍스트를 순서대로 겹치기 위해 ZStack을 사용했다.
원형 그래프의 비밀
- Circle().stroke(...) : Circle()은 원 모양을 정의하고, .stroke 수정자(Modifier)는 그 원의 '테두리'만 그리도록 한다. lineWidth로 테두리의 두께를 20포인트로 지정했다. 첫 번째 원은 opacity(0.3)을 주어 반투명한 배경 트랙 역할을 한다.
- Circle().trim(from: 0, to: CGFloat(report.score) / 100.0) : 이것이 핵심이다. .trim 수정자는 도형의 일부만 그리도록 한다. from: 0, to: 0.5 는 원의 절반만 그리라는 의미다. to 값에 CGFloat(report.score) / 100.0 을 전달하여, 점수가 95점이면 원의 95%만, 45점이면 45%만 그리도록 했다.
- .rotationEffect(.degrees(-90)) : 기본적으로 .trim은 3시 방향에서 시작한다. 시계처럼 12시 방향에서 시작하도록, 원 전체를 -90도 회전시켰다.
- style: StrokeStyle(lineCap: .round) : 테두리의 끝 모양을 둥글게(round) 처리 하여 부드러운 느낌을 준다.
Model과 View의 완벽한 협업
report.scoreBadgeColor를 사용한다고 해서, 이 뷰는 점수가 85점 이상일 때 녹색이라는 사실을 모른다. 그 모든 로직은 OriginalityReport 모델의 scoreBadgeColor 라는 계산 프로퍼티(Computed Property) 안에 캡슐화되어 있다. 이 뷰는 그저 report.scoreBadgeColor 가 주는 색상을 가져다 쓸 뿐이다. 이로써 View는 '디자인 로직'으로부터 완벽하게 분리된다.
.animation(.easeInOut(duration: 1.0), value: report.id)
value로 지정된 report.id가 변경될 때마다 애니메이션을 적용한다. 새로운 사진이 분석되어 report 객체가 통째로 바뀌면, 원형 그래프가 1초에 걸쳐 부드럽게 채워지는 애니메이션이 실행되어 사용자 경험을 극대화한다.
복잡한 정보를 우아하게 : SummaryCardView.swift 와 FlagsListView.swift
분석 결과에는 수많은 텍스트 정보가 포함된다. 이 정보들을 그냥 나열하면 사용자는 길을 잃는다. 이 두 컴포넌트는 '카드'라는 시각적 그룹핑과 명확한 타이포그래피를 ㅌ오해 복잡한 정보를 우아하게 정리하는 역할을 한다.
MetaLens/Components/SummaryCardView.swift
import SwiftUI
import CoreLocation
/// 사진의 메타데이터 요약 정보를 보여주는 카드 형태의 뷰입니다.
struct SummaryCardView: View {
let summary: PhotoSummary
let placemark: String?
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("view.results.summaryTitle") // ...
summaryRow(symbol: "camera.fill", label: "view.summary.cameraLabel", value: "\(summary.make ?? "") \(summary.model ?? "")")
// ... (다른 summaryRow들) ...
locationRow(location: summary.location, placemark: placemark)
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 20))
}
/// 메타데이터 요약 정보의 각 행을 구성하는 헬퍼 뷰입니다.
@ViewBuilder
private func summaryRow(symbol: String, label: String, value: String?) -> some View {
// 값이 유효할 때만 행을 보여줍니다.
if let value, !value.trimmingCharacters(in: .whitespaces).isEmpty {
// ... (HStack으로 아이콘, 라벨, 값 표시) ...
}
}
// ... (locationRow) ...
}
MetaLens/Components/FlagsListView.swift
import SwiftUI
/// 분석된 상세 플래그 목록을 보여주는 뷰입니다.
struct FlagsListView: View {
let flags: [OriginalityFlag]
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("view.results.flagsTitle") // ...
ForEach(flags) { flag in
HStack(alignment: .top) {
Image(systemName: flag.symbolName)
.foregroundColor(flag.color)
// ...
Text(flag.detail.localizedDescription)
// ...
}
// ...
}
}
.padding()
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 20))
}
}
심층 분석
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 20))
iOS의 '유리' 효과를 내는 .thinMaterial 배경을 둥근 사각형(RoundedRectangle) 모양으로 적용한다. 이를 통해 카드 UI가 뒷배경과 자연스럽게 어우러지면서도 시각적으로 명확하게 구분되는, 현대적인 디자인을 쉽게 구현할 수 있다.
private func summaryRow(...) -> some View
SummaryCardView 내부에서 반복적으로 사용되는 '아이콘-라벨-값' 형태의 행을 별도의 private 함수로 분리했다. 이는 코드 중복을 줄이고, body 프로퍼티의 가독성을 높이는 매우 중요한 리팩토링 기법이다. @ViewBuilder 덕분에 이 함수는 일반 SwiftUI 뷰처럼 사용할 수 있다.
if let value, !value.trimmingCharacters(in: .whitespaces).isEmpty
summaryRow의 핵심 로직이다. 메타데이터 값이 nil이거나, 공백만 있는 빈 문자열일 경우에는 아예 해당 행을 그리지 않는다. 이를 통해 "렌즈: 정보 없음"과 같은 불필요한 정보가 UI를 지저분하게 만드는 것을 막는다.
ForEach(flags) { flag in ... }
FlagsListView는 flags 배열을 순회하며 각 flag에 대한 UI를 그린다.
Image(systemName: flag.symbolName).foregroundColor(flag.color)
ScoreCircleView와 마찬가지로, 이 뷰는 isEdited 플래그가 왜 scissors 아이콘에 red 색상인지 전혀 알지 못한다. 모든 시각적 규칙은 OriginalityFlag 모델이 symbolName과 color라는 계산 프로퍼티를 통해 제공한다. View는 그저 모델이 시키는 대로 그릴 뿐이다.
Text(flag.detail.localizedDescription)
현지화 로직의 정점이다. 예전 포스팅에서 설계했듯, FlagDetail enum은 구조화된 정보만 가지고 있고, localizedDescription이 Localizable.xcstrings의 키를 조합하여 최종 텍스트를 만들어낸다. FlagsListView는 이 복잡한 과정을 전혀 알 필요 없이, 그저 flag.detail.localizedDescription를 호출하기만 하면 된다.
신뢰를 더하는 한 스푼 : ScoringGuideView.swift
사용자에게 점수만 툭 던져주는 것은 불친절하다. "왜 이 점수가 나왔는가?"라는 사용자의 궁금증을 해소해줄 때, 비로소 우리 앱은 '신뢰'를 얻게 된다. ScoringGuideView는 바로 그 신뢰를 구축하는 역할을 하는, 작지만 매우 중요한 컴포넌트다.
MetaLens/Components/ScoringGuideView.swift
import SwiftUI
/// 원본성 점수 산정 기준을 사용자에게 설명하는 뷰입니다.
/// Sheet 형태로 표시되며, 복잡한 정보를 섹션별로 명확하게 전달하는 데 중점을 둡니다.
struct ScoringGuideView: View {
// 이 뷰를 닫기 위해 상위 뷰에서 제공하는 환경 변수입니다.
@Environment(\.dismiss) private var dismiss
var body: some View {
// 네비게이션 스택을 사용하여 상단에 타이틀과 닫기 버튼을 표시합니다.
NavigationStack {
// `List`를 사용하여 정보를 그룹화하고 깔끔한 UI를 제공합니다.
List {
// 기본 점수 섹션
Section {
Text("view.scoringGuide.baseScore.description")
} header: {
Text("view.scoringGuide.baseScore.title")
}
// 감점 요인 섹션
Section {
ruleRow(score: "view.scoringGuide.deductions.photoshop.rule", description: "view.scoringGuide.deductions.photoshop.description")
// ... (다른 감점 요인들) ...
} header: {
Text("view.scoringGuide.deductions.title")
}
// 점수 미반영 요인 섹션
Section {
Text("view.scoringGuide.neutralFactors.gps.description")
Text("view.scoringGuide.neutralFactors.noSoftware.description")
} header: {
Text("view.scoringGuide.neutralFactors.title")
}
}
.navigationTitle("view.scoringGuide.title")
.navigationBarTitleDisplayMode(.inline)
// 상단 오른쪽에 '닫기' 버튼을 추가합니다.
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("view.button.close") {
dismiss() // 버튼을 탭하면 dismiss 액션을 호출하여 시트를 닫습니다.
}
}
}
}
}
/// 점수와 설명을 한 행에 표시하는 헬퍼 뷰입니다.
private func ruleRow(score: LocalizedStringKey, description: LocalizedStringKey) -> some View {
// ... (HStack으로 점수와 설명 표시) ...
}
}
심층 분석
@Environment(\.dismiss) private var dismiss
SwiftUI의 환경 변수(Environment Values) 를 사용하는 방법이다. .sheet이나 .navigationLink로 띄워진 뷰는 시스템으로부터 '닫기'(dismiss) 액션을 환경 변수로 주입받는다. 우리는 이 dismiss를 호출하기만 하면, 뷰가 어떻게 띄워졌는지 신경 쓸 필요 없이 안전하게 뷰를 닫을 수 있다.
List
VStack과 비슷하지만, 테이블 뷰처럼 섹션을 나누고 기본 스타일링을 제공하여 정보성 콘텐츠를 표시하는 데 최적화된 컨테이너다.
Section { ... } header: { ... }
List 안에서 콘텐츠를 논리적인 그룹으로 묶고, 각 그룹에 회색 제목(Header)을 붙여준다. 이를 통해 사용자는 복잡한 채점 기준을 한눈에 구조적으로 파악할 수 있다.
.toolbar { ... }
네비게이션 바에 버튼과 같은 UI 요소를 추가하는 표준적인 방법이다. placement: .confirmationAction은 버튼을 오른쪽 상단(보통 '완료'나 '확인' 위치)에 배치하도록 지정한다.
정리하며
이번 포스팅에서는 거대한 PhotoInspectorView를 5개의 작고 재사용 가능한 컴포넌트로 분리하는 대대적인 리팩토링을 진행했다.
- 컴포넌트 기반 개발의 '왜'와 '어떻게'를 이해했다.
- 각 컴포넌트가 단 하나의 책임만 갖도록 설계하여 코드의 명확성을 높였다.
- Model이 UI 로직(색상, 아이콘, 현지화 텍스트)을 일부 책임지게 함으로써, View 코드를 '데이터를 그리는' 순수한 역할에만 집중하도록 만들었다.
- SwiftUI 프리뷰를 활용하여 각 컴포넌트를 독립적으로 개발하고 테스트하는 것의 효율성을 확인했다.
이제 잘 만들어진 '레고 블록'들을 모두 손에 쥐고 있다. 다음 포스팅에서는, 이 블록들을 조립하여 MetaLens의 최종 얼굴인 PhotoInspectorView를 완성하고, ScoringGuideView를 sheet로 띄우는 등 사용자 경험을 한 단계 끌어올리는 방법을 알아볼 것이다.
'app > metalens' 카테고리의 다른 글
MetaLens 개인정보 처리방침 (Privacy Policy) (0) | 2025.09.04 |
---|---|
최종 조립 및 완성 : 메인 뷰(View)와 사용자 경험(UX) 향상 (0) | 2025.09.04 |
로직과 UI의 지휘자 : 뷰모델(ViewModel)과 비동기 처리 (0) | 2025.09.03 |
앱의 심장을 만들다 : 메타데이터 분석 서비스(Service) 구현 (0) | 2025.09.03 |
데이터의 뼈대를 세우다: 모델(Model)과 프로토콜(Protocol) 정의 (0) | 2025.09.02 |