ImageIO 프레임워크를 파헤쳐 사진의 비밀을 캐내다
시작하며 : '실제 일'을 하는 녀석의 등장
지난 포스팅에서 앱의 뼈대(Model)와 소통 규칙(Protocol)을 정의했다. 하지만 아직 앱은 아무일도 하지 못하는 빈 껍데기일 뿐이다. 이제 드디어, 이 뼈대에 살을 붙이고 피를 돌게 할 '심장', 즉 서비스(Service) 레이어를 구현할 차례다.
Service는 우리 MVVM 아키텍처에서 '실제 일'을 하는 녀석이다. ViewModel로 부터 "사진 데이터 줄 테니 분석해줘"라는 요청을 받으면, 묵묵히 사진 파일을 파헤쳐 그 안에 숨겨진 비밀(메타데이터)을 캐내고, 우리가 정한 규칙(비즈니스 로직)에 따라 '원본성 점수'를 계산하여 최종 보고서(OriginalityReport)를 만들어내는 역할이다.
이번 포스팅에서는 MetaLens의 핵심 두뇌인 PhotoMetadataService.swift 파일의 모든 코드를 한 줄 한 줄 분석하며, Apple의 강력한 저수준(Low-level) 프레임워크인 ImageIO를 어떻게 다루는지, 그리고 객관적인 데이터를 어떻게 주관적인 '점수'로 변환하는지에 대한 모든 것을 알아본다.
계약서 이행 : PhotoMetadataPRotocol의 구체화
데이터의 뼈대를 세우다: 모델(Model)과 프로토콜(Protocol) 정의
좋은 코드는 '무엇'을 담을지와 '어떻게' 소통할지를 먼저 정의한다. 시작하며 : '데이터'가 아닌 '모델'을 설계하는 이유이전 포스팅에서 프로젝트의 기반 공사를 마쳤다. 이제 실제 사진을 분석
johjo.net
지난 포스팅에서 PhotoMetadataProtocol 이라는 '계약서'를 만들었다. 이 계약서에는 단 하나의 조항만 있었다.
// MetaLens/Protocols/PhotoMetadataProtocol.swift
protocol PhotoMetadataProtocol {
func analyze(photoData: Data) async throws -> OriginalityReport
}
PhotoMetadataService 클래스의 첫 번째 임무는 이 계약서에 서명하고, 명시된 조항을 실제로 구현하는 것이다.
MetaLens/Services/PhotoMetadataService.swift
import Foundation
import ImageIO // 이미지의 메타데이터에 직접 접근하기 위해 ImageIO 프레임워크를 import합니다.
import CoreLocation // GPS 좌표 데이터를 다루기 위해 CoreLocation을 import합니다.
import UIKit // 사진 방향(Orientation) 정보를 가져오기 위해 UIKit을 import합니다.
// MARK: - PhotoMetadataService
// PhotoMetadataProtocol 명세서를 실제로 구현하는 서비스 클래스입니다.
final class PhotoMetadataService: PhotoMetadataProtocol {
// ... 구현 ...
}
final class PhotoMetadataService
- class 타입 : Service는 내부에 특정 로직을 포함하고 상태를 가지지는 않지만, 여러 곳에서 공유될 수 있는 '객체'의 성격이 강하므로 class로 선언했다.
- final 키워드 : 이 클래스는 더 이상 다른 클래스가 상속(inheritance)할 필요가 없다는 것을 컴파일러에게 명확히 알려준다. final을 붙이면 컴파일러는 이 클래스의 메서드가 오버라이드(Override)될 가능성을 배제하고 최적화를 수행하므로, 미세한 성능 향상 효과를 얻을 수 있다. 상속이 필요 없는 모든 클래스에 final을 붙이는 것은 좋은 습관이다.
: PhotoMetadataProtocol
- 프로토콜 채택(Adoption) : 이 클래스가 PhotoMetadataProtocol 이라는 계약서를 준수하겠다고 서명하는 부분이다. 이제 Swift 컴파일러는 이 클래스 안에 계약서의 조항인 analyze 함수가 제대로 구현되어 있는지 검사할 것이다.
분석의 시작점 : analyze 함수
analyze 함수는 서비스의 유일한 공개 창구(Public Interface)이자, 모든 분석 과정이 시작되는 곳이다.
// MetaLens/Services/PhotoMetadataService.swift
/// 주어진 사진 데이터(Data)를 비동기적으로 분석하여 OriginalityReport를 반환합니다.
func analyze(photoData: Data) async throws -> OriginalityReport {
Log.info("사진 분석을 시작합니다.")
// 1. ImageIO를 사용하여 이미지 소스를 생성합니다.
guard let imageSource = CGImageSourceCreateWithData(photoData as CFData, nil) else {
Log.error("CGImageSource 생성에 실패했습니다. 유효하지 않은 이미지일 수 있습니다.")
throw AppError.invalidImage
}
// 2. 이미지의 메타데이터 속성을 추출합니다.
guard let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any] else {
Log.error("이미지 속성(메타데이터) 추출에 실패했습니다.")
throw AppError.metadataExtractionFailed
}
// 3. 추출된 속성을 바탕으로 PhotoSummary 객체를 생성합니다. (Helper 메서드 사용)
let summary = createPhotoSummary(from: properties, data: photoData)
// 4. 생성된 요약 정보와 전체 속성을 바탕으로 원본성 점수와 플래그를 생성합니다. (Helper 메서드 사용)
let (flags, score) = generateFlagsAndScore(from: summary, properties: properties)
// 5. 모든 정보를 취합하여 최종 OriginalityReport를 생성하고 반환합니다.
let report = OriginalityReport(score: score, summary: summary, flags: flags)
Log.info("사진 분석을 완료했습니다. 최종 점수: \(score)점")
return report
}
심층 분석
이 함수는 5단계의 명확한 흐름을 가진다.
1. CGImageSourceCreateWithData (이미지 소스 생성) : ImageIO의 시작이다.
- photoData as CFData : ImageIO는 오래된 C 기반의 프레임워크라 Swift의 Data 타입을 직접 이해하지 못한다. CFData로 형변환(Casting) 하여 전달해야 한다.
- guard let ... else : 이 함수는 실패할 수 있다. 만약 전달된 photoData가 JPEG 나 PNG 같은 유효한 이미지 데이터가 아니라면 nil을 반환한다. guard let 구문은 이처럼 함수 초반에 유효성을 검사하고, 실패 시 빠르게 함수를 종료시키는 (여기서는 throw AppError .invalidImage) 가장 이상적인 방법이다.
2. CGImageSourceCopyPropertiesAtIndex (속성 추출) : 이미지의 모든 메타데이터를 거대한 딕셔너리로 뽑아낸다.
- imageSource, 0, nil : 첫 번째 인자는 이미지 소스, 두 번째 인자 0은 이미지의 인덱스(GIF처럼 여러 이미지로 구성된 파일이 아니라면 항상 0이다), 세 번째 인자는 추가 옵션(보통 nil) 이다.
- as? [String: Any] : 이 함수가 반환하는 타입은 CFDictionary 라는 C 타입이다. as? 를 통해 Swift의 딕셔너리 타입인 [String: Any]로 안전하게 변환을 시도한다. Any 타입인 이유는, 딕셔너리 안에 문자열, 숫자, 또 다른 딕셔너리 등 다양한 타입의 값이 섞여 있기 때문이다.
- 역시 실패할 수 있으므로 guard let 으로 안전하게 처리한다.
3. createPhotoSummary(...) : 핼퍼 함수 호출 1
- 거대하고 지저분한 properties 딕셔너리를 우리가 원하는 깔끔한 PhotoSummary 모델로 가공하기 위해, 책임을 분리한 private 헬퍼 함수를 호출한다.
4. generateFlagsAndScore(...) : 헬퍼 함수 호출2
- 가공된 summary와 원본 properties를 바탕으로, 점수를 계싼하고 분석 플래그를 생성하는 두 번째 헬퍼 함수를 호출한다.
5. OriginalityReport(...) : 최종 보고서 생성
- 위 단계들에서 만들어진 모든 조각(score, summary, flags)들을 조립하여 최종 결과물인 OriginalityReport를 생성하고 반환(return)한다.
혼돈 속에서 질서 찾기 : createPhotoSummary 헬퍼 함수
ImageIO가 반환하는 properties 딕셔너리는 표준화되지 않은 수많은 키와 값이 중첩된, 그야말로 '혼돈'의 상태다. 이 함수는 그 혼돈 속에서 우리가 원하는 '질서'있는 정보(PhotoSummary)를 찾아내는 탐험가 역할을 한다.
// MetaLens/Services/PhotoMetadataService.swift
private func createPhotoSummary(from properties: [String: Any], data: Data) -> PhotoSummary {
// ImageIO는 메타데이터를 여러 딕셔너리(TIFF, EXIF, GPS 등)로 나누어 관리합니다.
let tiffDict = properties[kCGImagePropertyTIFFDictionary as String] as? [String: Any]
let exifDict = properties[kCGImagePropertyExifDictionary as String] as? [String: Any]
let gpsDict = properties[kCGImagePropertyGPSDictionary as String] as? [String: Any]
// 각 딕셔너리에서 원하는 값을 안전하게 추출합니다.
let make = tiffDict?[kCGImagePropertyTIFFMake as String] as? String
let model = tiffDict?[kCGImagePropertyTIFFModel as String] as? String
let software = tiffDict?[kCGImagePropertyTIFFSoftware as String] as? String
let lens = exifDict?[kCGImagePropertyExifLensModel as String] as? String
// 이미지 해상도 추출
let width = properties[kCGImagePropertyPixelWidth as String] as? Int
let height = properties[kCGImagePropertyPixelHeight as String] as? Int
let dimensions = (width != nil && height != nil) ? "\(width!)x\(height!)" : nil
// 파일 크기를 사람이 읽기 좋은 형태로 변환 (e.g., "1.2 MB")
let fileSize = ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)
// 촬영 시간 추출
let creationDateString = exifDict?[kCGImagePropertyExifDateTimeOriginal as String] as? String
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy:MM:dd HH:mm:ss" // EXIF 날짜 형식
let creationDate = dateFormatter.date(from: creationDateString ?? "")
// GPS 좌표 추출 (남/북, 동/서 방향을 고려하여 음수/양수 값으로 변환해야 합니다)
var location: CLLocationCoordinate2D? = nil
if let lat = gpsDict?[kCGImagePropertyGPSLatitude as String] as? Double,
let lon = gpsDict?[kCGImagePropertyGPSLongitude as String] as? Double,
let latRef = gpsDict?[kCGImagePropertyGPSLatitudeRef as String] as? String,
let lonRef = gpsDict?[kCGImagePropertyGPSLongitudeRef as String] as? String {
let latitude = latRef == "N" ? lat : -lat
let longitude = lonRef == "E" ? lon : -lon
location = CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
// 추출한 모든 정보를 PhotoSummary 객체에 담아 반환합니다.
return PhotoSummary(make: make, model: model, lens: lens, software: software, dimensions: dimensions, fileSize: fileSize, location: location, creationDate: creationDate)
}
심층 분석
kCGImageProperty... as String
- kCGImagePropertyTIFFDictionary 같은 것들은 ImageIO가 미리 정의해 둔 '키' 상수다. C 기반이라 타입이 CFString 이므로, Swift의 String 키로 사용하기 위해 as String 으로 형변환을 해준다.
tiffDict?[...], exifDict?[...]
- ? (Optional Chaining)를 사용하는 이유는 tiffDict 자체가 nil 일 수 있기 때문이다. 만약 tiffDict 가 nil 이라면, 그 뒤의 [...] 코드는 실행되지 않고 전체 결과가 nil 이 되어 앱이 크래시되는 것을 막아준다.
ByteCountFormatter.string(...)
- 파일 크기 12500000 를 "12.5MB" 처럼 사람이 읽기 좋은 문자열로 변환해주는 Apple의 편리한 클래스다. 직접 1024로 나누는 계싼을 할 필요가 없다.
DateFormatter
- "2025:09:02 10:30:00" 같은 EXIF 표준 날짜 문자열을 Swift의 Date 객체로 변환하기 위해 사용한다. dateFormat을 EXIF 표준에 맞게 "yyyy:MM:dd HH:mm:ss"로 설정하는 것이 핵심이다.
GPS 좌표 추출 로직
- 여기가 가장 까다로운 부분이다. GPS 데이터는 위도(Latitude)와 경도(Longitude) 숫자 값 외에, 기준점(Ref) 정보를 함께 가진다.
- latRef는 "N"(북위) 또는 "S"(남위) 값을, lonRef는 "E"(동경) 또는 "W"(서경) 값을 가진다.
- let latitude = latRef == "N" ? lat : -lat → latRef가 "N"이면 양수, "S"이면 음수(-)로 변환한다.
- let longitude = longRef == "E" ? lon : -lon → lonRef가 "E"이면 양수, "W"이면 음수(-)로 변환한다.
- 이 변환 과정을 거쳐야만, CLLocationCoordinate2D가 이해할 수 있는 표준 GPS 좌표가 완성된다.
데이터에 의미를 부여하다 : generateFlagsAndScore 헬퍼 함수
이 함수는 MetaLens의 핵심 비즈니스 로직(Business Logic)이다. 앞서 추출한 객관적인 '사실'들을 바탕으로, "이 사진은 편집된 것 같아", "이 사진은 원본일 확률이 높아" 와 같은 주관적인 '판단'을 내리고, 이를 '점수'로 계량화한다.
// MetaLens/Services/PhotoMetadataService.swift
private func generateFlagsAndScore(from summary: PhotoSummary, properties: [String: Any]) -> (flags: [OriginalityFlag], score: Int) {
var score = 100
var flags: Set<OriginalityFlag> = [] // 중복 플래그를 피하기 위해 Set을 사용
// --- 점수 계산 로직 ---
// 1. 소프트웨어 정보 확인 (가장 강력한 편집 증거)
if let software = summary.software, !software.isEmpty {
let lowercasedSoftware = software.lowercased()
if lowercasedSoftware.contains("photoshop") || lowercasedSoftware.contains("lightroom") {
score -= 30
flags.insert(OriginalityFlag(type: .isEdited, detail: .softwareEdited(software: summary.software!)))
} // ... (이하 생략)
} else {
flags.insert(OriginalityFlag(type: .isOriginal, detail: .noSoftwareInfo))
}
// 2. 제조사/모델 정보 확인
if let make = summary.make, let model = summary.model, !make.isEmpty, !model.isEmpty {
flags.insert(OriginalityFlag(type: .isOriginal, detail: .cameraInfo(make: make, model: model)))
} else {
score -= 10
flags.insert(OriginalityFlag(type: .warning, detail: .cameraInfoMissing))
}
// ... (GPS, 촬영 시간 로직 생략) ...
score = max(0, score)
return (Array(flags), score)
}
심층 분석
var flags: Set<OriginalityFlag>
- Set 타입 : Array와 달리, Set은 중복된 값을 허용하지 않는다. 만약 어떤 로직에 의해 같은 플래그가 두 번 추가되더라도, Set은 이를 알아서 한 번만 저장해준다. 중복을 피하기에 가장 적합한 자료구조다.
if let software = summary.software, !software.isEmpty
- 소프트웨어 정보가 nil이 아니고, "" 같은 빈 문자열도 아닐 때만 이 로직을 실행한다.
- lowercased() : "Photoshop"과 "photoshop"을 동일하게 취급하기 위해, 모든 문자열을 소문자로 변환하여 비교하는 것은 아주 중요한 전처리 과정이다.
score -= 30, score -= 20, ...
- 비즈니스 로직 : 바로 이 부분이 우리 앱의 '판단 기준'이다. 우리는 "PC 편집 툴이 모바일 앱보다 더 강력한 편집을 의미하므로, 더 큰 점수를 깎는다(-30점)" 와 같은 주관적인 규칙을 코드에 녹여냈다. 이 점수 로직은 앞으로 앱을 업데이트하며 계속해서 더 정교하게 다듬어 나갈 수 있다.
score = max(0, score)
- 여러 감점 요인이 중첩되어 점수가 음수가 되는 것을 방지하기 위한 안전장치다. 점수는 최소 0점을 보장한다.
return (Array(flags), score)
- 모든 계산이 끝난 후, Set 이었던 flags를 Array로 변환하여 점수와 함께 튜플(Tuple) 형태로 반환한다.
정리하며
이번 포스팅에서는 MetaLens의 심장인 PhotoMetadataService를 완벽하게 해부했다.
- ImageIO 프레임워크를 사용하여 저수준의 이미지 데이터에 접근하고, 복잡하게 중첩된 메타데이터를 추출하는 방법을 배웠다.
- 추출된 데이터를 우리가 설계한 Model에 맞춰 가공하고, DateFormatter, ByteCountFormatter 같은 유용한 도구들을 활용하는 법을 익혔다.
- 단순한 데이터를 '점수'와 '판단'이라는 의미 있는 정보로 변환하는 비즈니스 로직을 직접 구현했다.
- 이 모든 과정을 헬퍼 함수로 분리하여, 복잡한 로직을 명확하고 관리하기 쉬운 단위로 나누는 것의 중요성을 확인했다.
이제 강력한 분석 엔진이 생겼다. 다음 포스팅에서는, 이 엔진을 UI와 연결하는 '지휘자', ViewModel을 구현하고, async/await 를 통해 복잡한 비동기 작업을 얼마나 우아하게 처리할 수 있는지 알아볼 것이다.
'app > metalens' 카테고리의 다른 글
생명을 불어넣는 작업 : 재사용 가능한 UI 컴포넌트(Components) 제작 (0) | 2025.09.03 |
---|---|
로직과 UI의 지휘자 : 뷰모델(ViewModel)과 비동기 처리 (0) | 2025.09.03 |
데이터의 뼈대를 세우다: 모델(Model)과 프로토콜(Protocol) 정의 (0) | 2025.09.02 |
집을 짓기 전 땅 다지기: 프로젝트 설정과 필수 유틸리티 (0) | 2025.09.02 |
바이브코딩을 위한 MetaLens 코딩 파트너 설정기 (0) | 2025.09.02 |