좋은 앱은 잘 만든 AppError 와 Logger 에서 시작된다.
시작하며 : 왜 '기반 공사'가 중요한가?
이전 포스팅에서 MetaLens 라는 앱의 청사진(기획과 아키텍처)을 그렸다.
2025.09.02 - [app/metalens] - 메타렌즈 프로젝트 소개
2025.09.02 - [app/metalens] - 바이브코딩을 위한 MetaLens 코딩 파트너 설정기
이제 본격적으로 코드로 집을 지을 차례다. 이제 몇번 프로젝트를 진행하다 보니 앱의 안정성과 확장성을 책임지기 위해서는 기초적인 설정들이 중요하다는 것을 깨달았다. 이번 포스팅에서는 기능 개발이라는 '인테리어'에 들어가기 전, 반드시 먼저 해야 할 '기반 공사'에 대해 다룬다. 구체적으로는 아래 3가지다.
- 글로벌 출시를 위한 완벽한 다국어 지원 (Localizable.xcstrings)
- 예측 가능한 모든 오류에 대응하는 우아한 에러 처리 시스템 (AppError.swift)
- 미래의 버그를 절반으로 줄여줄 강력한 로깅 시스템 (Logger.swift)
이 작업들은 눈에 보이는 기능을 만드는 것보다 훨씬 중요할 수 있다. 좋은 집이 단단한 기초 위에서 시작되듯, 좋은 앱은 안정적인 기반 위에서 시작되기 때문이다. (다루는 파일은 아래 4개, Localizable.xcstrings → AppError.swift → Logger.swift → MetaLensApp.swift 순이다.)
프로젝트 생성 및 초기 설정
모든 것은 Xcode에서 새 프로젝트를 생성하는 것부터 시작한다. 가장 먼저 해야 할 일은 배포 타겟(Minimum Deployments)을 iOS 18.5로 설정하고, 앱의 핵심 기능인 사진 라이브러리 접근 권한을 요청하는 코드를 Info.plist에 추가 하는 것이다. (곧 iPhone 17과 함께 iOS26 이 출시 할 것이라서 상대적으로 높게 잡았다.)
Info.plist는 앱의 설정 정보를 담는 파일인데, 여기에 Privacy - Photo Library Usage Description 이라는 키를 추가하고, 값으로는 사용자에게 보여줄 메시지를 직접 적는 대신, 현지화 파일의 키(privacy.photoLibrary.usageDescription)를 넣어준다.
이렇게 하면, 사용자의 아이폰 언어 설정에 따라 한국어, 영어, 일본어, 중국어 등으로 된 권한 요청 메시지가 자동으로 보이게 된다. 바로 이것이 '글로벌 앱'을 위한 첫걸음이다.
모든 언어를 담는 그릇 : Localizable.xcstrings
과거에는 언어별로 .strings 파일을 따로 관리해야 했지만, Xcode 14 부터 Strings Catalog(.xcstrings)파일을 생성하고, MetaLens에서 지원할 언어(English, Korean, Japanese, Chinese)를 프로젝트 설정에 추가하면, 아래와 같은 JSON 형태의 단일 파일에서 모든 언어를 관리 할 수 있다.
// MetaLens/Resources/Localizable.xcstrings
{
"sourceLanguage" : "en",
"strings" : {
// ... (중략) ...
"view.button.selectPhoto" : {
"extractionState" : "manual",
"localizations" : {
"en" : { "stringUnit" : { "state" : "translated", "value" : "Select a Photo" } },
"ja" : { "stringUnit" : { "state" : "translated", "value" : "写真を選択" } },
"ko" : { "stringUnit" : { "state" : "translated", "value" : "사진 선택하기" } },
"zh-Hans" : { "stringUnit" : { "state" : "translated", "value" : "选择照片" } }
}
},
// ... (중략) ...
},
"version" : "1.0"
}
- "sourceLanguage" : "en"
- 이 파일의 기준 언어가 '영어'임을 Xcode에 알려준다. 번역이 누락된 언어가 있을 경우, 이 기준 언어의 텍스트가 대신 보이게 된다.
- "view.button.selectPhoto"
- 이것이 바로 '키(Key)'다. 코드에서는 Text("View.button.selectPhoto") 처럼 키를 사용하면, 앱이 실행될 때 실제 값("사진 선택하기")으로 자동 변환된다. 이렇게 키 기반으로 관리하면, 나중에 텍스트가 변경되어도 코드 수정 없이 이 파일만 바꾸면 된다.
지금 당장 모든 번역을 채우지 않더라도, 처음부터 이 구조를 잡아두는 것이 중요하다. 모든 UI 텍스트는 이 파일을 통해 관리한다는 원칙을 세우고 시작해야 한다.
모든 실패를 예상하고 대비하라 : AppError.swift
앱은 항상 성공적으로만 동작하지 않는다. 사진을 선택하다 취소할 수도 있고, 이미지 파일이 손상되었을 수도 있다. 이런 '실패' 상황을 미리 정의하고 관리하는 것이 바로 에러 핸들링의 핵심이다. MetaLens에서는 이를 위해 AppError 라는 커스텀 에러 타입을 정의 했다.
import Foundation
// MARK: - AppError Enum
// 우리 앱에서 발생할 수 있는 모든 커스텀 에러를 정의하는 열거형입니다.
// LocalizedError 프로토콜을 채택하여 사용자에게 보여줄 에러 메시지를 현지화할 수 있습니다.
enum AppError: Error, LocalizedError {
// MARK: - Error Cases
/// 사진 선택이 실패했거나 사용자가 취소했을 때 발생하는 에러
case photoSelectionFailed
/// 선택된 항목이 유효한 이미지가 아닐 때 발생하는 에러
case invalidImage
/// 이미지로부터 메타데이터를 추출하는 데 실패했을 때 발생하는 에러
case metadataExtractionFailed
/// 이미지 데이터를 로드하는 데 실패했을 때 발생하는 에러
case dataLoadingFailed
/// 파일 시스템 관련 작업(예: 저장, 읽기)에 실패했을 때 발생하는 에러
case fileSystemError(description: String)
// MARK: - Localized Description
/// 사용자에게 보여줄 현지화된 에러 설명을 반환합니다.
/// 이 값은 Localizable.xcstrings 파일에 정의된 키를 참조합니다.
var errorDescription: String? {
switch self {
case .photoSelectionFailed:
// "사진 선택 실패"에 대한 현지화 키를 반환합니다.
return String(localized: "error.photoSelectionFailed.description")
case .invalidImage:
// "유효하지 않은 이미지"에 대한 현지화 키를 반환합니다.
return String(localized: "error.invalidImage.description")
case .metadataExtractionFailed:
// "메타데이터 추출 실패"에 대한 현지화 키를 반환합니다.
return String(localized: "error.metadataExtractionFailed.description")
case .dataLoadingFailed:
// "데이터 로딩 실패"에 대한 현지화 키를 반환합니다.
return String(localized: "error.dataLoadingFailed.description")
case .fileSystemError(let description):
// 파일 시스템 에러의 경우, 현지화된 형식에 구체적인 설명을 추가하여 반환합니다.
// String(format:)을 사용하여 동적인 값을 포함시킵니다.
return String(format: String(localized: "error.fileSystemError.descriptionFormat"), description)
}
}
}
심층 분석
enum AppError: Error, LocalizedError
- enum 타입 : 앱에서 발생할 수 있는 오류의 종류는 정해져 있다. photoSelectionFailed, invalidImage 등. 이렇게 정해진 케이스 중 하나를 표현하는데 열거형(enum)이 가장 이상적인 타입이다.
- Error 프로토콜 : Swift에서 '오류'로 취급되기 위해 반드시 채택해야 하는 프로토콜이다. 이를 통해 do-catch 구문에서 이 오류를 잡아낼 수 있다.
- LocalizedError 프로토콜 : 이 프로토콜을 채택하면 error.localizedDescription 이라는 프로퍼티를 사용할 수 있게 된다. 이 프로퍼티를 통해 사용자에게 보여줄, 현재 기기 언어에 맞는 에러 메시지를 제공할 수 있다.
case photoSelectionFailed, case invalidImage, ...
- 각 케이스는 앱이 겪을 수 있는 구체적인 실패 시나리오를 나타낸다. 예를 들어, PhotoPicker에서 사진을 가져오는데 실패하면 PhotoSelectionFailed 에러를 발생(throw) 시키고, ImageIO가 이미지 데이터를 해석하지 못하면 invalidImage 에러를 발생시키는 식이다.
- case fileSystemError(description: String) : 이 케이스는 연관 값(Associated Value)을 가진다. 파일 저장 실패처럼, 좀 더 구체적인 실패 원인을 String 타입으로 함께 전달해야 할 때 사용한다.
var errorDescription: String?
- LocalizedError 프로토콜이 요구하는 계산 프로퍼티다. 이 변수 안에 각 에러 케이스별로 사용자에게 보여줄 메시지를 정의한다.
- retrun String(localized: "error.photoSelectionFailed.description") : 여기가 핵심이다. 에러 메시지를 "사진 선택 실패" 처럼 하드코딩하는 것이 아니라, Localizable.xcstrings에 정의된 키를 반환한다. String(localized:) 초기화 구문은 이 키를 가지고 자동으로 현재 언어에 맞는 문자열을 찾아준다. AppEror 와 Localizable.xcstrings가 완벽하게 연동되는 순간이다.
모든 동작을 기록하는 블랙박스 : Logger.swift
"버그가 발생했는데, 원인을 모르겠어요." 라는 말은 가장 끔찍한 말이다. Logger는 이런 상황을 막기 위한 '블랙박스'다. 앱의 모든 주요 동작을 콘솔에 기록하여, 문제가 발생했을 때 그 원인을 역추적할 수 있게 도와준다.
import Foundation
import os // Logger.swift 파일에서만 os를 import 합니다.
// MARK: - Log Struct
// 앱 전역에서 사용할 수 있는 커스텀 로거입니다.
// 다른 파일에서 import os 없이 Log.debug("메시지") 형태로 바로 사용할 수 있습니다.
struct Log {
// MARK: - Private Logger Instance
/// Apple의 통합 로깅 시스템(Unified Logging System)을 사용하는 기본 로거 인스턴스입니다.
/// subsystem: 앱을 식별하는 고유한 문자열입니다. 보통 앱의 번들 ID를 사용합니다.
/// category: 로그 메시지의 종류를 구분하는 문자열입니다.
private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? "net.johjo.MetaLens", category: "MetaLensApp")
// MARK: - Logging Methods
/// 디버깅 목적으로 사용되는 로그 메시지를 기록합니다.
/// 개발 중에 변수 값을 확인하거나 코드 실행 흐름을 추적할 때 유용합니다.
/// - Parameter message: 기록할 로그 메시지입니다.
static func debug(_ message: String) {
logger.debug("🐛 \(message)")
}
/// 정보성 로그 메시지를 기록합니다.
/// 앱의 일반적인 동작 상태를 나타낼 때 사용됩니다.
/// - Parameter message: 기록할 로그 메시지입니다.
static func info(_ message: String) {
logger.info("ℹ️ \(message)")
}
/// 앱의 흐름에 영향을 줄 수 있는 오류나 경고 메시지를 기록합니다.
/// 예외 처리 블록이나 잠재적인 문제 상황에서 사용됩니다.
/// - Parameter message: 기록할 로그 메시지입니다.
static func error(_ message: String) {
logger.error("‼️ \(message)")
}
}
심층 분석
struct Log
- struct 타입 : Logger는 어떤 상태를 저장할 필요 없이, 단순히 로그를 찍는 '기능'만 제공한다. 이처럼 기능의 모음에는 class 보다 가벼운 struct 가 더 적합하다.
private static let logger = Logger(...)
- private : 이 logger 인스턴스는 Log 구조체 내부에서만 접근 가능하다. 외부에서는 Log.debug() 처럼 여기서 정의한 함수만 사용하도록 강제하여 일관성을 유지한다.
- static : Log 구조체의 인스턴스를 만들지 않고도 Log.logger 처럼 바로 접근할 수 있게 해준다. 덕분에 앱 어디서든 Log.debug("메시지") 형태로 간편하게 로거를 사용할 수 있다.
- Logger(subsystem: ..., category: ...) : Apple이 제공하는 통합 로깅 시스템 os.Logger 의 인스턴스를 생성한다.
- subsystem : Bundle.main.bundleIdentifier ?? "net.johjo.MetaLens"
- 콘솔 앱에서 로그를 필터링할 때 사용할 고유한 식별자다. 우리 앱의 번들ID를 사용하면, 다른 시스템 로그와 섞이지 않고 MetaLens의 로그만 모아서 볼 수 있다. ?? 는 번들 ID를 가져오지 못할 경우를 대비한 기본값이다.
- category : "MetaLensApp"
- 같은 subsystem 내에서도 로그의 종류를 구분하기 위한 이름표다. 나중에 네트워킹 로그를 추가한다면 category: "Network" 처럼 구분할 수 있다.
- subsystem : Bundle.main.bundleIdentifier ?? "net.johjo.MetaLens"
static func debug(_ message: String), info(...), error(...)
- static func : logger 변수와 마찬가지로, 인스턴스 생성 없이 Log.info("분석 시작") 처럼 바로 호출할 수 있다.
- 로그를 디버그, 정보, 에러 3단계로 구분하여 관리하기 위함이다.
- debug : 개발 중에만 필요한 상세 정보 (예: 변수 값)
- info : 앱의 정상적인 동작 흐름 (예: "사진 분석 시작")
- error : catch 블록 등에서 잡힌 실제 오류
- logger.debug("🐛 \(message)"): Logger의 실제 함수를 호출하며, 앞에 붙은 이모지(🐛, ℹ️, ‼️)는 Xcode 콘솔에서 각기 다른 로그 레벨을 시각적으로 빠르게 구분할 수 있게 해주는 아주 유용한 트릭이다.
앱의 시작점 : MetaLensApp.swift
마지막으로, 이 모든 것을 담아 앱을 실행시키는 시작점이다.
import SwiftUI
@main
struct MetaLensApp: App {
var body: some Scene {
WindowGroup {
// ✅ 변경점: 앱이 시작될 때 실제 서비스를 생성하여 View에 전달합니다.
// 이 시점은 @MainActor 컨텍스트이므로 오류가 발생하지 않습니다.
PhotoInspectorView(photoService: PhotoMetadataService())
}
}
}
- @main : 이 속성은 MetaLensApp 구조체가 앱의 진입점(Entry Point)임을 나타낸다. main 함수를 자동으로 생성해준다.
- WindowGroup : iOS 앱의 화면을 담는 컨테이너다.
- PhotoInspectorView(photoService: PhotoMetadataService()) : 여기가 의존성 주입(Dependency Injection)이 시작되는 최상위 지점이다. 앱이 시작될 때, 실제 로직이 담긴 PhotoMetadataService의 인스턴스를 생성하여, UI를 책임질 PhotoInspectorView에게 "이 엔진을 가지고 일해!" 라고 주입해주는 것이다.
정리하며
이 포스팅에서는 MetaLens 앱의 기능 개발에 앞서, 프로젝트의 안정성과 확장성을 책임질 3개의 핵심 기반(현지화, 에러처리, 로깅)을 구축했다. 눈에 보이는 화려한 기능은 아니지만, 이 작업들이 있었기에 앞으로 마주할 수많은 버그와 변화에 훨씬 더 빠르고 우아하게 대처할 수 있게 되었다.
'app > metalens' 카테고리의 다른 글
로직과 UI의 지휘자 : 뷰모델(ViewModel)과 비동기 처리 (0) | 2025.09.03 |
---|---|
앱의 심장을 만들다 : 메타데이터 분석 서비스(Service) 구현 (0) | 2025.09.03 |
데이터의 뼈대를 세우다: 모델(Model)과 프로토콜(Protocol) 정의 (0) | 2025.09.02 |
바이브코딩을 위한 MetaLens 코딩 파트너 설정기 (0) | 2025.09.02 |
메타렌즈 프로젝트 소개 (2) | 2025.09.02 |