좋은 코드는 '무엇'을 담을지와 '어떻게' 소통할지를 먼저 정의한다.
시작하며 : '데이터'가 아닌 '모델'을 설계하는 이유
이전 포스팅에서 프로젝트의 기반 공사를 마쳤다. 이제 실제 사진을 분석하고, 그 결과를 보여주는 코드를 작성해야 한다. 이때 가장 먼저 해야 할 일은 무엇일까? 바로 '데이터의 청사진'을 그리는 일이다.
이것을 '모델(Model) 설계' 라고 부르는데, 단순히 String, Int 같은 변수 뭉치가 아니라, 앱이 다루는 정보의 의미와 관게를 구조적으로 정의하는 과정이다. 예를 들어, '사진 분석 결과'는 무엇으로 구성되는가? '원본이 라는 증거'는 어떤 정보를 담아야 하는가? 이 청사진이 명확해야, 이후에 만들어질 모든 로직과 UI가 흔들림 없이 견고하게 세워질 수 있다.
이번 포스팅에서는 MetaLens의 핵심 데이터 모델 3가지(PhotoSummary, OriginalityFlag, OriginalityReport)를 어떤 고민을 통해 설계했는지, 그리고 각 모듈이 서로를 '느슨하게' 결합하도록 만드는 강력한 도구인 프로토콜(Protocol)을 어떻게 활용했는지 심층적으로 분석해본다.
사실(Fact)을 담는 그릇 : PhotoSummary.swift
가장 먼저 정의할 모델은 사진에서 추출한 객관적인 '사실' 정보만을 담는 그릇이다. 여기에는 어떤 판단이나 가공이 들어가서는 안 된다. 오직 "사진 파일 안에 이런 정보가 들어있었다" 라는 순수한 데이터만 담는다.
MetaLens/Models/PhotoSummary.swift
import Foundation
import CoreLocation // 위치 정보를 다루기 위해 CoreLocation 프레임워크를 import 합니다.
// MARK: - PhotoSummary Struct
// 사진으로부터 추출된 주요 메타데이터를 요약하여 담는 구조체입니다.
// 이 정보는 분석 리포트의 일부로 사용됩니다.
struct PhotoSummary: Equatable, Identifiable {
// MARK: - Properties
/// SwiftUI 리스트 등에서 각 항목을 고유하게 식별하기 위한 ID입니다.
let id = UUID()
/// 사진을 촬영한 기기의 제조사입니다. (예: "Apple")
/// 모든 사진에 정보가 있는 것은 아니므로 옵셔널(Optional) 타입입니다.
let make: String?
/// 사진을 촬영한 기기의 모델명입니다. (예: "iPhone 15 Pro")
let model: String?
/// 사용된 렌즈 정보입니다.
let lens: String?
/// 사진을 편집했거나 생성한 소프트웨어 정보입니다. (예: "Adobe Photoshop 23.5")
let software: String?
/// 사진의 해상도 정보입니다. (예: "4032x3024")
let dimensions: String?
/// 파일 크기 정보입니다. (예: "12.5 MB")
let fileSize: String?
/// 사진이 촬영된 위치 정보 (위도, 경도) 입니다.
let location: CLLocationCoordinate2D?
/// 사진이 촬영된 날짜와 시간입니다.
let creationDate: Date?
// MARK: - Equatable Conformance
// 두 PhotoSummary 인스턴스가 같은지 비교하기 위한 구현입니다.
// 주로 테스트나 데이터 비교 시에 사용됩니다.
static func == (lhs: PhotoSummary, rhs: PhotoSummary) -> Bool {
return lhs.id == rhs.id
}
}
심층분석
struct PhotoSummary
- PhotoSummary는 사진의 메타데이터라는 '값'의 묶음이다. 데이터를 전달하고 복사 할 때, 원본이 예기치 않게 변경될 위험이 없는 값 타입(struct)이 참조 타입(class)보다 훨씬 안전하고 적합하다.
Equatable, Identifiable
- Equatable 프로토콜 : 두 PhotoSummary 인스턴스가 같은지(==) 비교할 수 있게 해준다. 주로 유닛 테스트에서 예상 결과와 실제 결과가 같은지 검증할 때 유용하다.
- Identifiable 프로토콜 : SwiftUI의 List나 ForEach에서 각 항목을 효율적으로 구별하고 업데이트하기 위해 필요하다. 이 프로토콜을 채택하려면 id 라는 고유 식별자를 반드시 가져야 한다.
let id = UUID()
- let 키워드, UUID 타입 : id는 한번 생성되면 절대 변해서는 안 되는 고유값이므로 let으로 선언했다. UUID()는 거으 ㅣ중복될 일이 없는 고유한 ID를 생성해주는 가장 표준적인 방법이다.
let make: String?, let lens: String?, ...
- String? (Optional String) 타입 : ? 는 옵셔널(Optional)을 으미ㅣ하며, "값이 있을 수도 있고, 없을 수도 있다(nil)"는 뜻이다. 사진 메타데이터는 표준이 아니기 때문에, 어떤 사진에는 제조사(make) 정보가 없을 수도 있고, 어떤 사진에는 렌즈(lens) 정보가 없을 수도 있다. String이 아닌 String? 으로 선언함으로써, 이런 '값이 없는' 상황을 안전하게 처리할 수 있다. 만약 String으로 선언했다면, 정보가 없는 사진을 분석할 때 앱은 크래시를 일으킬 것이다.
let location: CLLocationCoordinate2D?
- CLLocationCoordinate2D 타입 : 위도(latitude)와 경도(longitude)는 단순한 숫자 쌍이 아니다. Apple 생태계에서는 CoreLocation 프레임워크의 CLLocationCoordinate2D 타입을 사용하여 위치 정보를 다루는 것이 표준이다. 이 타입을 사용하면 나중에 지도에 핀을 찍거나, 주소로 변환하는 등의 위치 관련 기능을 손쉽게 확장할 수 있다.
let creationDate: Date?
- Date 타입 : 날짜와 시간 역시 String으로 다루면 위험하다. "2025-09-02"와 "09/02/2025"는 같은 날이지만 문자열로는 다르기 때문이다. Foundation 프레임워크의 Date 타입을 사용하면, 시간대(Timezone)나 포맷에 상관없이 특정 시점을 일관되게 다룰 수 있다.
판단의 근거를 담는 그릇: OriginalityFlag.swift
두 번째 모델은 '원본성 점수'를 계산하는 데 사용된 각각의 '판단의 근거'를 담는 그릇이다. 예를 들어, "포토샵 사용 흔적이 있다"는 하나의 '플래그(Flag)'가 된다.
이 모델 설계의 핵심은, 단순히 String으로 설명을 저장하는 것이 아니라, 구조화된 enum으로 정보의 '종류'와 '내용'을 분리하여 저장하는 것이다. 이 결정이 나중에 현지화와 유지보수를 얼마나 편하게 만드는지 주목해야 한다.
MetaLens/Models/OriginalityFlag.swift
import Foundation
import SwiftUI
// MARK: - OriginalityFlag Struct (현지화를 위해 수정됨)
struct OriginalityFlag: Identifiable, Hashable {
// MARK: - Properties
let id = UUID()
let type: FlagType
// ✅ 변경점: 문자열 대신, 구조화된 정보인 FlagDetail을 저장합니다.
let detail: FlagDetail
// MARK: - Enums
enum FlagType {
case isOriginal, isEdited, warning, information
}
// ✅ 추가점: Flag의 상세 내용을 현지화 키와 동적 데이터로 관리하는 enum입니다.
enum FlagDetail: Hashable {
case softwareEdited(software: String)
case mobileAppEdited(software: String)
case softwareVersion(version: String)
case noSoftwareInfo
case cameraInfo(make: String, model: String)
case cameraInfoMissing
case hasGps
case noGps
case hasCreationDate
case creationDateMissing
// MARK: - Computed Property for Localization
/// View 레이어에서 이 값을 호출하여 최종 현지화된 문자열을 얻습니다.
var localizedDescription: String {
switch self {
case .softwareEdited(let software):
return String(format: String(localized: "flag.softwareEdited.description"), software)
case .mobileAppEdited(let software):
return String(format: String(localized: "flag.mobileAppEdited.description"), software)
case .softwareVersion(let version):
return String(format: String(localized: "flag.softwareVersion.description"), version)
case .noSoftwareInfo:
return String(localized: "flag.noSoftwareInfo.description")
case .cameraInfo(let make, let model):
return String(format: String(localized: "flag.cameraInfo.description"), make, model)
case .cameraInfoMissing:
return String(localized: "flag.cameraInfoMissing.description")
case .hasGps:
return String(localized: "flag.hasGps.description")
case .noGps:
return String(localized: "flag.noGps.description")
case .hasCreationDate:
return String(localized: "flag.hasCreationDate.description")
case .creationDateMissing:
return String(localized: "flag.creationDateMissing.description")
}
}
}
// MARK: - Computed Properties for UI
var symbolName: String {
switch type {
case .isOriginal: return "checkmark.seal.fill"
case .isEdited: return "scissors"
case .warning: return "exclamationmark.triangle.fill"
case .information: return "info.circle.fill"
}
}
var color: Color {
switch type {
case .isOriginal: return .green
case .isEdited: return .red
case .warning: return .orange
case .information: return .blue
}
}
// MARK: - Hashable Conformance
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
심층 분석
let type: FlagType
- FlagType(Nested Enum) 타입 : 이 플래그가 긍정적인 증거(isOriginal)인지, 부정적인 증거(isEdited)인지, 아니면 그냥 경고(warning)나 정보(information)인지를 구분하는 타입이다. String으로 "edited", "warning" 처럼 관리하는 것(매직 스트링)은 오타에 취약하고 컴파일러의 도움을 받을 수 없다. 이렇게 enum 으로 타입을 명확히 하면, switch 문에서 모든 케이스를 처리하도록 강제할 수 있어 코드가 훨씬 안전해진다.
let detail: FlagDetail
- FlagDetail(Enum with Associated Values) 타입 : 이 모델의 핵심이다. "Adobe Photoshop Lightroom으로 편집됨"이라는 정보를 그냥 String으로 저장하면 두 가지 큰 문제가 생긴다. 1) 다른 언어로 번역하기 어렵다. 2) 나중에 "Adobe"가 포함된 모든 플래그를 찾아 로직을 변경하고 싶을 때, 문자열 파싱이라는 끔찍한 작업을 해야 한다.
- FlagDetail은 이 문제를 완벽하게 해결한다. 예를 들어, case softwareEdited(software: String)는 "소프트웨어로 편집되었다"는 정보의 종류와 실제 소프트웨어 이름(String)이라는 구체적인 데이터를 구조적으로 함께 저장한다.
var localizedDescription: String
- 계산 프로퍼티(Computed Property) : 저장된 FlagDetail 정보를 바탕으로, Localizable.xcstrings의 키를 조합하여 최종적으로 사용자에게 보여줄 현지화된 문자열을 '계산'해내는 함수 같은 변수다.
- case .softwareEdited(let software): return String(format: String(localized: "flag.softwareEdited.description"), software):
- let software : softwareEdited 케이스에 연관 값으로 저장된 소프트웨어 이름을 추출한다.
- String(localized: "...") : Localizable.xcstrings 에서 " Edited with Adobe software: %@" 와 같은 번역문을 가져온다.
- String(format: ..., software) : 가져온 번역문의 %@ 부분에 실제 소프트웨어 이름(software)을 채워 넣어 최종 문자열을 완성한다.
- 결과적으로, Service 레이어는 FlagDetail.softwareEdited(software: "Photoshop") 처럼 순수한 데이터만 생성하고, 실제 사용자에게 보여질 텍스트는 View 레이어가 flog.detail.localizedDescription를 호출하는 시점에 결정된다. 이것이 바로 '관심사의 분리' 원칙이다.
var symbolName: String, var color: Color
- FlagType에 따라 UI에 표시될 아이콘 이름(symbolName)과 색상(color)을 모델이 직접 알려주도록 설계했다. 이렇게 하면 View는 "이 플래그는 isEdited 타입이니까 빨간색으로 칠해야지" 같은 로직을 가질 필요가 없다. 그냥 flag.color를 가져다 쓰기만 하면 된다. 모델이 자신의 표현 방식을 일부 책임짐으로써, View의 코드를 더 단순하게 만든다.
최종 보고서를 담는 그릇 : OriginalityReport.swift
마지막 모델은 PhotoSummary(사실)와 OriginalityFlag(판단의 근거들)을 모두 종합하여, 사용자에게 보여줄 최종 '분석 보고서'를 담는 그릇이다.
MetaLens/Models/OriginalityReport.swift
import Foundation
import SwiftUI // 점수에 따른 색상을 반환하기 위해 SwiftUI를 import합니다.
// MARK: - OriginalityReport Struct
// 사진 분석이 완료된 후의 모든 정보를 담는 최종 결과물 모델입니다.
// 이 리포트가 ViewModel을 통해 View에 전달되어 사용자에게 보여집니다.
struct OriginalityReport: Identifiable {
// MARK: - Properties
/// 리포트 자체를 고유하게 식별하기 위한 ID입니다.
let id: UUID
/// 계산된 최종 원본성 점수입니다. (0-100점)
let score: Int
/// 사진의 기본 메타데이터 요약 정보입니다.
let summary: PhotoSummary
/// 원본성 점수를 계산하는 데 사용된 모든 플래그들의 배열입니다.
let flags: [OriginalityFlag]
/// 분석이 수행된 날짜와 시간입니다.
let analyzedDate: Date
// MARK: - Initializer
// 이니셜라이저를 사용하여 리포트 객체를 쉽게 생성할 수 있습니다.
init(id: UUID = UUID(), score: Int, summary: PhotoSummary, flags: [OriginalityFlag], analyzedDate: Date = Date()) {
self.id = id
self.score = score
self.summary = summary
self.flags = flags
self.analyzedDate = analyzedDate
}
// MARK: - Computed Properties for UI
/// 점수에 따라 UI에 표시될 배지의 색상을 결정합니다.
/// 85점 이상은 Green(안전), 50-84점은 Amber(주의), 50점 미만은 Red(위험)
var scoreBadgeColor: Color {
if score >= 85 {
return .green
} else if score >= 50 {
return .orange
} else {
return .red
}
}
}
심층 분석
let summary: PhotoSummary, let flags: [OriginalityFlag]
- 이 모델은 다른 모델(PhotoSummary, OriginalityFlag)을 자신의 프로퍼티로 포함한다. 이를 컴포지션(Composition) 이라고 하며, "A는 B를 가지고 있다(A has a B)" 관계로 모델을 조립해나가는 방식이다. OriginalityReport는 PhotoSummary를 가지고 있고, 여러 개의 OriginalityFlag를 가지고 있다.
var scoreBadgeColor: Color
- OriginalityFlag의 color 프로퍼티와 같은 맥락이다. score라는 자신의 데이터를 기반으로, UI에 표시될 색상 정보를 직접 계산하여 제공한다. View는 점수가 몇 점일 때 무슨 색을 써야 하는지 고민할 필요 없이, report.scoreBadgeColor를 가져다 쓰기만 하면 된다.
소통의 규칙을 정하다 : PhotoMetadataProtocol.swift
지금까지 데이터의 '청사진'을 모두 그렸다. 이제 이 데이터를 실제로 생성하고 가공할 Service와, 이 데이터를 화면에 보여달라고 요청할 ViewModel을 만들어야 한다.
이때, ViewModel이 Service를 직접 알고 있으면 어떻게 될까? ViewModel은 Service라는 특정 구현체에 강하게 결합(Tightly Coupled)된다. 이렇게 되면 두 가지 큰 문제가 발생한다.
- 테스트의 어려움 : ViewModel을 테스트하려면, 항상 실제 PhotoMetadataService가 필요하다. 즉, 실제 사진 파일이 있어야만 테스트가 가능해진다.
- 확장의 어려움 : 나중에 사진을 서버에서 분석하는 CloudPhotoMetadataService를 추가하고 싶다면, ViewModel의 코드를 직접 수정해야만 한다.
이 문제를 해결하는 것이 바로 프로토콜(Protocol) 이다.
MetaLens/Protocols/PhotoMetadataProtocol.swift
import Foundation
// MARK: - PhotoMetadataProtocol
// 사진 메타데이터 분석 서비스가 반드시 따라야 하는 프로토콜입니다.
// 서비스의 구체적인 구현과 ViewModel을 분리하여 테스트 용이성과 유연성을 확보하는 역할을 합니다.
// 우리 프로젝트에서는 프로토콜임을 명시적으로 나타내기 위해 'Protocol' 접미사를 사용하기로 결정했습니다.
protocol PhotoMetadataProtocol {
// MARK: - Analysis Function
/// 주어진 사진 데이터(Data)를 비동기적으로 분석하여 OriginalityReport를 반환합니다.
/// 이 함수는 시간이 걸릴 수 있는 작업을 처리하므로 'async'로 선언되었습니다.
/// 또한, 분석 과정에서 다양한 오류(예: 유효하지 않은 이미지)가 발생할 수 있으므로 'throws'로 선언되었습니다.
///
/// - Parameter photoData: 분석할 원본 사진의 데이터입니다. (UIImage가 아닌 Data 타입)
/// - Returns: 분석 결과가 담긴 `OriginalityReport` 객체.
/// - Throws: 분석 과정에서 발생한 오류. `AppError`의 케이스 중 하나일 것입니다.
func analyze(photoData: Data) async throws -> OriginalityReport
}
심층 분석
protocol PhotoMetadataProtocol
- protocol 키워드 : 프로토콜은 '클래스'나 '구조체'가 아니다. 이것은 "이 프로토콜을 따르는(채택하는) 타입은, 반드시 이런 모양의 함수나 변수를 가지고 있어야 한다"고 약속하는 '기능의 명세서' 또는 '계약서'다.
func analyze(photoData: Data) async throws -> OriginalityReport
- 이것이 바로 '계약서의 조항'이다. 이 프로토콜을 따르는 모든 타입은, analyze 라는 이름의 함수를 반드시 구현해야 한다.
- photoData: Data (Input) : 이 함수는 반드시 Data 타입의 입력을 받아야 한다.
- -> OriginalityReport (Output) : 성공하면 반드시 OriginalityReport 타입의 결과를 반환해야 한다.
- async : 이 작업은 비동기적으로 동작할 수 있음을 약속한다. 즉, 함수가 즉시 끝나지 않을 수 있다.
- throws : 이 작업은 실패할 수 있으며, 실패 시 Error를 던질(throw) 것임을 약속한다.
결과적으로, ViewModel은 이제 구체적인 PhotoMetadataService 클래스를 몰라도 된다. ViewModel은 오직 PhotoMetadataProtocol 이라는 '계약서'만 바라보고 일한다.
// PhotoInspectorViewModel.swift
class PhotoInspectorViewModel: ObservableObject {
private let photoService: PhotoMetadataProtocol // ✅ 특정 클래스가 아닌, '계약서' 타입을 들고 있다.
init(photoService: PhotoMetadataProtocol) {
self.photoService = photoService
}
// ...
}
ViewModel은 자신에게 전달된 photoService가 PhotoMetadataService인지, 아니면 테스트용 MockPhotoMetadataService인지 전혀 관심이 없다. 그저 PhotoMetadataProtocol 이라는 계약서에 적힌 대로 analyze 함수를 호출할 수 있다는 것만 알면 된다.
정리하며
이번 포스팅에서는 앱의 데이터 청사진인 모델을 설계하고, 모듈 간의 소통 규칙인 프로토콜을 정의했다.
- Model : 단순한 데이터 컨테이너가 아니라, 정보의 의미와 관계를 담는 구조화된 설계도다. Optional, enum, computed property 등을 활용하여 안전하고 표현력 있는 모델을 만들었다.
- Protocol : 클래스 간의 직접적인 의존성을 끊어주는 '계약서'다. 이를 통해 테스트가 쉽고 확장이 유연한, 소위 '좋은 아키텍처'를 만들 수 있다.
이제 데이터의 뼈대와 소통 규칙이 모두 정해졌다. 다음 포스팅에서는 오늘 정의한 PhotoMetadataProtocol 이라는 계약서를 실제로 이행하는 PhotoMetadataService를 구현하며 MetaLens의 핵심 분석 로직을 완성해볼 것이다.
'app > metalens' 카테고리의 다른 글
로직과 UI의 지휘자 : 뷰모델(ViewModel)과 비동기 처리 (0) | 2025.09.03 |
---|---|
앱의 심장을 만들다 : 메타데이터 분석 서비스(Service) 구현 (0) | 2025.09.03 |
집을 짓기 전 땅 다지기: 프로젝트 설정과 필수 유틸리티 (0) | 2025.09.02 |
바이브코딩을 위한 MetaLens 코딩 파트너 설정기 (0) | 2025.09.02 |
메타렌즈 프로젝트 소개 (2) | 2025.09.02 |