본문 바로가기
app/metalens

앱의 심장을 만들다 : 메타데이터 분석 서비스(Service) 구현

by 조현성 2025. 9. 3.
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를 완벽하게 해부했다. 

  1. ImageIO 프레임워크를 사용하여 저수준의 이미지 데이터에 접근하고, 복잡하게 중첩된 메타데이터를 추출하는 방법을 배웠다. 
  2. 추출된 데이터를 우리가 설계한 Model에 맞춰 가공하고, DateFormatter, ByteCountFormatter 같은 유용한 도구들을 활용하는 법을 익혔다. 
  3. 단순한 데이터를 '점수'와 '판단'이라는 의미 있는 정보로 변환하는 비즈니스 로직을 직접 구현했다. 
  4. 이 모든 과정을 헬퍼 함수로 분리하여, 복잡한 로직을 명확하고 관리하기 쉬운 단위로 나누는 것의 중요성을 확인했다.

이제 강력한 분석 엔진이 생겼다. 다음 포스팅에서는, 이 엔진을 UI와 연결하는 '지휘자', ViewModel을 구현하고, async/await 를 통해 복잡한 비동기 작업을 얼마나 우아하게 처리할 수 있는지 알아볼 것이다.