SwiftUI 사진 편집 앱 고급 기능 — Undo/Redo, 텍스트 스티커, SwiftData 프로젝트 자동 저장과 복원
사진 편집 앱을 만들 때 이미지 이동이나 확대·축소 기능만으로는 충분하지 않습니다.
사용자가 실수로 사진을 삭제했을 때 되돌릴 수 있어야 하고, 사진 위에 텍스트를 추가할 수도 있어야 합니다.
또한 편집 도중 앱을 종료하더라도 작업 중이던 프로젝트가 유지되어야 합니다.
이러한 기능을 구현하려면 사진 자체를 반복해서 수정하는 방식보다 원본 이미지와 편집 상태를 분리하는 비파괴 편집 구조가 적합합니다.
이번 글에서는 지난 시간에 만든 SwiftUI 콜라주 편집기를 확장하겠습니다.
1. 이번 글에서 구현할 기능
편집 상태를 이용한 Undo/Redo
사진 이동·확대·회전 기록
텍스트 스티커 추가
텍스트 위치·크기·회전 변경
레이어 순서 관리
SwiftData로 프로젝트 메타데이터 저장
원본 사진을 앱 내부 파일로 보관
자동 저장
앱 재실행 후 프로젝트 복원
프로젝트 삭제 시 관련 파일 정리
데이터 손상과 저장 실패 처리
개발 환경
| 항목 | 내용 |
|---|---|
| 언어 | Swift |
| UI | SwiftUI |
| 최소 지원 버전 | iOS 17 |
| 상태 관리 | Observation |
| 데이터베이스 | SwiftData |
| 파일 관리 | FileManager |
| 이미지 처리 | UIKit / Core Graphics |
| 동시성 | Swift Concurrency |
2. Android와 iOS 구현 방식 비교
| Android | iOS / SwiftUI | 역할 |
|---|---|---|
| ViewModel | @Observable ViewModel | 편집 상태 |
| UndoManager / 자체 명령 기록 | UndoManager / 자체 History | 실행 취소 |
| Room | SwiftData | 프로젝트 메타데이터 |
| 내부 Files 디렉터리 | Application Support | 원본 이미지 |
| Canvas | Core Graphics | 결과물 렌더링 |
| DataStore | UserDefaults / @AppStorage | 편집기 설정 |
| Coroutine | Task / async-await | 자동 저장 |
Android에서 Room과 내부 파일 저장소를 함께 사용하듯이 iOS에서도 SwiftData와 파일 시스템을 조합할 수 있습니다.
특히 고해상도 사진 전체를 데이터베이스에 저장하기보다는 사진 파일은 디스크에, 편집 정보는 데이터베이스에 저장하는 방식을 권장합니다.
3. 편집 프로젝트 구조 설계
이번 예제의 프로젝트 구조는 다음과 같습니다.
PhotoEditor/
├── App/
│ └── PhotoEditorApp.swift
├── Models/
│ ├── EditorDocument.swift
│ └── CollageProject.swift
├── ViewModels/
│ └── EditorViewModel.swift
├── Services/
│ ├── ProjectFileStore.swift
│ └── ProjectRepository.swift
└── Views/
├── EditorView.swift
├── EditorCanvasView.swift
└── ProjectListView.swift이번 글에서는 사진의 원본 데이터와 편집 상태를 분리합니다.
콜라주 프로젝트
│
├── SwiftData
│ ├── 프로젝트 ID
│ ├── 프로젝트 제목
│ ├── 생성일 / 수정일
│ └── 편집 상태 JSON
│
└── Application Support
└── Projects/
└── 프로젝트 UUID/
├── 사진1.jpg
└── 사진2.heicSwiftData에는 프로젝트를 복원하는 데 필요한 상태 정보를 저장하고, 이미지 파일은 앱 전용 저장소에 보관합니다.
4. 비파괴 편집 모델 만들기
먼저 편집 상태를 파일이나 데이터베이스에 저장할 수 있도록 Codable 모델을 정의하겠습니다.
EditorDocument.swift
import Foundation
struct EditorDocument: Codable, Equatable {
var canvasWidth: Double = 1000
var canvasHeight: Double = 1000
var background: String = "#FFFFFF"
var layers: [EditorLayer] = []
}
struct EditorLayer: Codable, Identifiable, Equatable {
enum Kind: String, Codable {
case photo
case text
}
var id: UUID = UUID()
var kind: Kind
var fileName: String?
var text: String?
var x: Double = 500
var y: Double = 500
var width: Double = 400
var height: Double = 400
var scale: Double = 1
var rotation: Double = 0
var fontSize: Double = 48
var textColor: String = "#000000"
}EditorDocument는 하나의 콜라주 프로젝트 전체를 나타냅니다.
EditorLayer는 사진이나 텍스트처럼 캔버스에 배치되는 개별 요소입니다.
여기서 중요한 점은 UIImage를 모델에 직접 저장하지 않았다는 것입니다.
사진 레이어에는 fileName만 저장하고, 실제 이미지는 파일 시스템에서 불러옵니다.
이렇게 하면 편집 상태를 JSON으로 인코딩하거나 SwiftData에 저장하기 쉽습니다.
5. 실행 취소·다시 실행 구조
사진 편집기에서 Undo/Redo를 구현하는 방법은 여러 가지가 있습니다.
대표적으로 다음 두 방식이 있습니다.
| 방식 | 특징 |
|---|---|
| 상태 스냅샷 | 편집 전후 상태 전체를 저장 |
| Command 패턴 | 수행한 편집 명령과 역연산을 저장 |
Command 패턴은 복잡한 편집기에서 효율적일 수 있지만, 구현 난도가 높습니다.
이번 글에서는 이해하기 쉽고 안정적으로 시작할 수 있는 상태 스냅샷 방식을 사용하겠습니다.
원본 이미지를 History에 복사하지 않고, 위치·크기·텍스트 등 가벼운 편집 정보만 저장합니다.
6. EditorViewModel 구현
EditorViewModel.swift
import Foundation
import Observation
@MainActor
@Observable
final class EditorViewModel {
private(set) var document: EditorDocument
private var undoStack: [EditorDocument] = []
private var redoStack: [EditorDocument] = []
private let historyLimit = 50
var selectedLayerID: UUID?
init(document: EditorDocument = EditorDocument()) {
self.document = document
}
var canUndo: Bool {
!undoStack.isEmpty
}
var canRedo: Bool {
!redoStack.isEmpty
}
func edit(
_ change: (inout EditorDocument) -> Void
) {
var updated = document
change(&updated)
guard updated != document else {
return
}
undoStack.append(document)
if undoStack.count > historyLimit {
undoStack.removeFirst()
}
redoStack.removeAll()
document = updated
}
func undo() {
guard let previous = undoStack.popLast() else {
return
}
redoStack.append(document)
document = previous
}
func redo() {
guard let next = redoStack.popLast() else {
return
}
undoStack.append(document)
document = next
}
func replaceDocument(_ newDocument: EditorDocument) {
document = newDocument
undoStack.removeAll()
redoStack.removeAll()
selectedLayerID = nil
}
}이 구조의 핵심은 편집을 수행하기 전에 현재 상태를 Undo Stack에 저장하는 것입니다.
새로운 편집을 수행하면 Redo Stack은 비웁니다.
예를 들어 다음과 같은 순서로 작업했다고 가정하겠습니다.
사진 추가
↓
사진 이동
↓
사진 확대
↓
사진 회전실행 취소를 누르면 이전 상태로 돌아갑니다.
사진 회전 취소
↓
사진 확대 취소
↓
사진 이동 취소다시 실행을 누르면 취소했던 작업을 다시 적용합니다.
7. Undo/Redo 버튼 구현
SwiftUI에서는 다음과 같이 버튼을 만들 수 있습니다.
HStack(spacing: 20) {
Button {
viewModel.undo()
} label: {
Label(
"실행 취소",
systemImage: "arrow.uturn.backward"
)
}
.disabled(!viewModel.canUndo)
Button {
viewModel.redo()
} label: {
Label(
"다시 실행",
systemImage: "arrow.uturn.forward"
)
}
.disabled(!viewModel.canRedo)
}여기서는 EditorViewModel을 화면의 @State로 보유한다고 가정합니다.
@State private var viewModel = EditorViewModel()SwiftUI의 Observation을 사용하면 ViewModel의 상태 변경이 관련 UI에 반영됩니다.
8. 드래그 제스처와 Undo 기록
이전 글에서는 DragGesture로 사진을 이동했습니다.
Undo 기능을 추가할 때 주의할 점은 손가락이 움직일 때마다 History를 기록하지 않는 것입니다.
예를 들어 1초 동안 수십 번 위치가 갱신될 수 있는데, 이를 모두 기록하면 Undo 버튼을 여러 번 눌러야 이전 위치로 돌아갑니다.
따라서 제스처 중에는 임시 위치를 표시하고, 제스처가 종료되었을 때 한 번만 편집을 확정하는 것이 좋습니다.
DragGesture()
.onEnded { value in
let dx = Double(value.translation.width)
let dy = Double(value.translation.height)
viewModel.edit { document in
guard let index = document.layers.firstIndex(
where: { $0.id == layerID }
) else {
return
}
document.layers[index].x += dx
document.layers[index].y += dy
}
}위 코드는 좌표 계산의 기본 구조를 보여줍니다.
실제 캔버스가 논리 좌표 1000×1000을 사용한다면 화면 포인트 이동량을 논리 좌표로 변환해야 합니다.
예를 들어 화면 캔버스 너비가 350포인트라면 다음과 같이 계산할 수 있습니다.
let ratio = 1000.0 / 350.0
let logicalDX = Double(value.translation.width) * ratio
let logicalDY = Double(value.translation.height) * ratio이렇게 해야 iPhone 화면 크기가 달라져도 편집 결과를 동일하게 유지할 수 있습니다.
9. 텍스트 스티커 추가하기
이번에는 사진 위에 텍스트를 추가하는 기능을 구현하겠습니다.
텍스트 스티커도 사진과 동일한 레이어 구조로 관리합니다.
func addText(
_ text: String,
to viewModel: EditorViewModel
) {
let layer = EditorLayer(
kind: .text,
text: text,
x: 500,
y: 500,
width: 600,
height: 120,
fontSize: 48,
textColor: "#000000"
)
viewModel.edit { document in
document.layers.append(layer)
}
viewModel.selectedLayerID = layer.id
}사진과 텍스트를 동일한 레이어 배열로 관리하면 다음 기능을 공통으로 구현할 수 있습니다.
선택
이동
확대·축소
회전
삭제
레이어 순서 변경
실행 취소·다시 실행
10. 텍스트 스티커 SwiftUI View
TextStickerView.swift
import SwiftUI
struct TextStickerView: View {
let layer: EditorLayer
let isSelected: Bool
let canvasScale: CGFloat
var body: some View {
Text(layer.text ?? "")
.font(
.system(
size: CGFloat(layer.fontSize) * canvasScale,
weight: .bold
)
)
.foregroundStyle(
Color(hex: layer.textColor)
)
.frame(
width: CGFloat(layer.width) * canvasScale,
height: CGFloat(layer.height) * canvasScale
)
.scaleEffect(layer.scale)
.rotationEffect(
.radians(layer.rotation)
)
.overlay {
if isSelected {
Rectangle()
.stroke(.blue, lineWidth: 2)
}
}
.position(
x: CGFloat(layer.x) * canvasScale,
y: CGFloat(layer.y) * canvasScale
)
}
}여기서는 HEX 색상을 SwiftUI Color로 변환하는 보조 함수를 사용합니다.
11. HEX 색상 변환
import SwiftUI
extension Color {
init(hex: String) {
let cleaned = hex.replacingOccurrences(
of: "#",
with: ""
)
guard cleaned.count == 6,
let value = UInt64(
cleaned,
radix: 16
) else {
self = .black
return
}
let red = Double((value >> 16) & 0xFF) / 255
let green = Double((value >> 8) & 0xFF) / 255
let blue = Double(value & 0xFF) / 255
self = Color(
.sRGB,
red: red,
green: green,
blue: blue,
opacity: 1
)
}
}이제 #FF0000, #FFFFFF, #000000과 같은 문자열을 Color로 변환할 수 있습니다.
12. 텍스트 입력 화면 구현
import SwiftUI
struct AddTextSheet: View {
@Environment(\.dismiss)
private var dismiss
@State private var text = ""
let onAdd: (String) -> Void
var body: some View {
NavigationStack {
Form {
TextField(
"텍스트를 입력하세요",
text: $text,
axis: .vertical
)
.lineLimit(2...5)
}
.navigationTitle("텍스트 추가")
.toolbar {
ToolbarItem(
placement: .cancellationAction
) {
Button("취소") {
dismiss()
}
}
ToolbarItem(
placement: .confirmationAction
) {
Button("추가") {
let value = text.trimmingCharacters(
in: .whitespacesAndNewlines
)
onAdd(value)
dismiss()
}
.disabled(
text.trimmingCharacters(
in: .whitespacesAndNewlines
).isEmpty
)
}
}
}
}
}편집 화면에서 다음과 같이 연결할 수 있습니다.
@State private var showingTextSheet = falseButton {
showingTextSheet = true
} label: {
Label(
"텍스트 추가",
systemImage: "textformat"
)
}
.sheet(isPresented: $showingTextSheet) {
AddTextSheet { text in
addText(text, to: viewModel)
}
}13. 레이어 순서 변경
콜라주에서는 사진과 텍스트의 앞뒤 순서가 중요합니다.
이번 예제에서는 배열의 뒤쪽에 있는 레이어를 화면에서 더 앞에 표시하도록 설계합니다.
선택한 레이어를 맨 앞으로 가져오는 코드는 다음과 같습니다.
func bringToFront(
id: UUID,
viewModel: EditorViewModel
) {
viewModel.edit { document in
guard let index = document.layers.firstIndex(
where: { $0.id == id }
) else {
return
}
let layer = document.layers.remove(
at: index
)
document.layers.append(layer)
}
}이 작업 역시 edit()를 통해 처리하기 때문에 Undo/Redo 대상이 됩니다.
14. SwiftData 프로젝트 모델 만들기
이제 편집 프로젝트를 저장하겠습니다.
CollageProject.swift
import Foundation
import SwiftData
@Model
final class CollageProject {
@Attribute(.unique)
var id: UUID
var title: String
var createdAt: Date
var updatedAt: Date
var documentData: Data
init(
id: UUID = UUID(),
title: String,
documentData: Data,
createdAt: Date = .now,
updatedAt: Date = .now
) {
self.id = id
self.title = title
self.documentData = documentData
self.createdAt = createdAt
self.updatedAt = updatedAt
}
}documentData에는 EditorDocument를 JSON으로 인코딩한 데이터를 저장하겠습니다.
SwiftData에 사진 원본 전체를 저장하지 않기 때문에 데이터베이스 크기가 지나치게 커지는 문제를 줄일 수 있습니다.
15. 앱에 ModelContainer 연결
PhotoEditorApp.swift
import SwiftUI
import SwiftData
@main
struct PhotoEditorApp: App {
var body: some Scene {
WindowGroup {
ProjectListView()
}
.modelContainer(
for: CollageProject.self
)
}
}이제 하위 SwiftUI 화면에서 다음과 같이 ModelContext를 사용할 수 있습니다.
@Environment(\.modelContext)
private var modelContext16. 편집 상태 JSON 인코딩
func encodeDocument(
_ document: EditorDocument
) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [
.sortedKeys
]
return try encoder.encode(document)
}복원할 때는 다음과 같이 디코딩합니다.
func decodeDocument(
from data: Data
) throws -> EditorDocument {
try JSONDecoder().decode(
EditorDocument.self,
from: data
)
}EditorDocument가 Codable을 채택했기 때문에 별도의 수동 JSON 변환 코드가 필요하지 않습니다.
17. 프로젝트 저장 Repository
ProjectRepository.swift
import Foundation
import SwiftData
@MainActor
final class ProjectRepository {
private let context: ModelContext
init(context: ModelContext) {
self.context = context
}
func create(
title: String,
document: EditorDocument
) throws -> CollageProject {
let data = try JSONEncoder().encode(
document
)
let project = CollageProject(
title: title,
documentData: data
)
context.insert(project)
try context.save()
return project
}
func update(
project: CollageProject,
document: EditorDocument
) throws {
let data = try JSONEncoder().encode(
document
)
project.documentData = data
project.updatedAt = .now
try context.save()
}
func load(
project: CollageProject
) throws -> EditorDocument {
try JSONDecoder().decode(
EditorDocument.self,
from: project.documentData
)
}
func delete(
project: CollageProject
) throws {
context.delete(project)
try context.save()
}
}이 구조에서는 프로젝트의 생성·수정·조회·삭제를 Repository로 분리했습니다.
18. 원본 이미지 파일 저장
원본 사진은 앱 내부의 Application Support 디렉터리에 저장하겠습니다.
ProjectFileStore.swift
import Foundation
struct ProjectFileStore {
private var baseURL: URL {
FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
)[0]
.appendingPathComponent(
"CollageProjects",
isDirectory: true
)
}
func projectDirectory(
id: UUID
) -> URL {
baseURL.appendingPathComponent(
id.uuidString,
isDirectory: true
)
}
func savePhoto(
data: Data,
projectID: UUID,
fileExtension: String
) throws -> String {
let directory = projectDirectory(
id: projectID
)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let fileName =
UUID().uuidString + "." + fileExtension
let destination = directory.appendingPathComponent(
fileName
)
try data.write(
to: destination,
options: .atomic
)
return fileName
}
func photoURL(
projectID: UUID,
fileName: String
) -> URL {
projectDirectory(
id: projectID
)
.appendingPathComponent(fileName)
}
}파일명에는 UUID를 사용해 중복을 방지합니다.
실제 서비스에서는 허용할 파일 형식과 확장자를 검증해야 합니다.
또한 외부 입력으로 받은 파일명을 그대로 경로에 결합하지 않도록 주의해야 합니다.
19. PhotosPicker에서 원본 파일 저장
지난 글에서는 사진을 UIImage로 변환했습니다.
하지만 원본 파일과 메타데이터를 유지해야 한다면 가능한 한 파일 데이터 자체를 보관하는 것이 좋습니다.
guard let data = try await item.loadTransferable(
type: Data.self
) else {
return
}
let fileName = try fileStore.savePhoto(
data: data,
projectID: projectID,
fileExtension: "jpg"
)주의: PhotosPicker에서 반환하는 데이터가 항상 원본 JPEG 파일이라는 의미는 아닙니다.
HEIC 등 다른 형식이 반환될 수 있고, 시스템의 표현 형식 선택에 따라 변환된 데이터가 제공될 수도 있습니다.
따라서 실제 앱에서는 콘텐츠 형식을 확인한 뒤 확장자를 결정해야 합니다.
원본 보존이 중요한 앱에서는 Transferable 파일 표현과 PhotosPicker의 인코딩 정책을 함께 검토하는 것이 좋습니다.
20. 프로젝트 목록 화면
저장된 프로젝트는 SwiftData의 @Query로 조회할 수 있습니다.
ProjectListView.swift
import SwiftUI
import SwiftData
struct ProjectListView: View {
@Environment(\.modelContext)
private var modelContext
@Query(
sort: \CollageProject.updatedAt,
order: .reverse
)
private var projects: [CollageProject]
@State private var errorMessage: String?
var body: some View {
NavigationStack {
List {
ForEach(projects) { project in
NavigationLink {
EditorView(project: project)
} label: {
VStack(
alignment: .leading,
spacing: 6
) {
Text(project.title)
.font(.headline)
Text(
project.updatedAt,
format: .dateTime
.year()
.month()
.day()
.hour()
.minute()
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.onDelete(perform: deleteProjects)
}
.navigationTitle("내 콜라주")
.alert(
"오류",
isPresented: Binding(
get: { errorMessage != nil },
set: {
if !$0 {
errorMessage = nil
}
}
)
) {
Button("확인", role: .cancel) {}
} message: {
Text(errorMessage ?? "")
}
}
}
private func deleteProjects(
at offsets: IndexSet
) {
let repository = ProjectRepository(
context: modelContext
)
do {
for index in offsets {
try repository.delete(
project: projects[index]
)
}
} catch {
errorMessage = "프로젝트 삭제에 실패했습니다."
}
}
}이 예제는 SwiftData 레코드 삭제만 처리합니다.
원본 사진 파일까지 정리하려면 별도의 파일 삭제 절차가 필요합니다.
21. 프로젝트 파일 삭제 시 주의사항
SwiftData 레코드와 파일 시스템은 하나의 트랜잭션으로 묶이지 않습니다.
따라서 다음과 같은 문제가 발생할 수 있습니다.
SwiftData 삭제 성공
│
▼
파일 삭제 실패
│
▼
사용하지 않는 이미지 파일 잔존반대로 파일부터 삭제한 뒤 데이터베이스 삭제가 실패하면 프로젝트가 원본 사진을 찾지 못할 수 있습니다.
실제 앱에서는 삭제 대기 상태를 기록하거나, 데이터베이스 삭제 성공 후 파일 정리를 재시도하는 구조를 고려해야 합니다.
또한 앱 실행 시 참조되지 않는 파일을 점검하는 정리 작업을 구현할 수 있습니다.
22. 프로젝트 자동 저장
사진 편집 앱에서는 사용자가 저장 버튼을 누르지 않아도 작업 내용을 보존하는 것이 중요합니다.
하지만 드래그 제스처가 발생할 때마다 SwiftData에 저장하면 불필요한 디스크 작업이 증가할 수 있습니다.
따라서 Debounce 방식의 자동 저장을 사용하는 것이 좋습니다.
예를 들어 마지막 편집 이후 1초 동안 추가 변경이 없으면 저장하도록 구성할 수 있습니다.
23. Task를 이용한 Debounce 자동 저장
EditorView에서 다음과 같은 상태를 사용할 수 있습니다.
@State private var saveTask: Task<Void, Never>?자동 저장 예약 함수는 다음과 같습니다.
@MainActor
private func scheduleAutoSave() {
saveTask?.cancel()
saveTask = Task {
do {
try await Task.sleep(
for: .seconds(1)
)
try Task.checkCancellation()
let repository = ProjectRepository(
context: modelContext
)
try repository.update(
project: project,
document: viewModel.document
)
} catch is CancellationError {
// 새로운 편집이 발생해 이전 저장 예약 취소
} catch {
saveError = error.localizedDescription
}
}
}이 함수는 마지막 편집 이후 일정 시간이 지난 다음 저장하도록 예약합니다.
다만 화면이 사라지거나 앱이 백그라운드로 전환되는 상황에서는 예약된 작업이 완료되지 않을 수 있습니다.
따라서 중요한 편집 결과는 장면 생명주기와 연결해 추가 저장을 시도해야 합니다.
24. scenePhase로 앱 상태 감지
SwiftUI에서는 scenePhase를 이용해 앱 장면 상태를 확인할 수 있습니다.
@Environment(\.scenePhase)
private var scenePhase다음과 같이 사용할 수 있습니다.
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .inactive ||
newPhase == .background {
saveTask?.cancel()
do {
try ProjectRepository(
context: modelContext
).update(
project: project,
document: viewModel.document
)
} catch {
saveError = error.localizedDescription
}
}
}이 방식은 앱 상태 변경 시 저장을 시도하는 예제입니다.
하지만 iOS가 앱을 강제로 종료하거나 프로세스가 예기치 않게 종료되면 모든 저장 코드가 실행된다고 보장할 수 없습니다.
따라서 자동 저장을 기본으로 유지하면서 앱 상태 변경 시 추가 저장을 수행하는 것이 좋습니다.
25. 저장된 프로젝트 복원
SwiftData에서 프로젝트를 조회한 뒤 JSON 데이터를 디코딩하면 편집 상태를 복원할 수 있습니다.
let repository = ProjectRepository(
context: modelContext
)
do {
let document = try repository.load(
project: project
)
viewModel.replaceDocument(document)
} catch {
saveError = "프로젝트를 불러오지 못했습니다."
}사진 레이어에는 파일명이 저장되어 있으므로 각 레이어의 파일을 다시 읽어 화면에 표시해야 합니다.
예를 들어 다음과 같습니다.
let fileURL = fileStore.photoURL(
projectID: project.id,
fileName: fileName
)
let image = UIImage(
contentsOfFile: fileURL.path
)실제 앱에서는 파일이 없거나 손상된 경우 기본 이미지를 표시하고 사용자에게 복구 가능한 상태를 안내해야 합니다.
26. EditorView 기본 연결
다음은 프로젝트 로딩과 자동 저장을 연결하는 최소 구조입니다.
import SwiftUI
import SwiftData
struct EditorView: View {
let project: CollageProject
@Environment(\.modelContext)
private var modelContext
@Environment(\.scenePhase)
private var scenePhase
@State private var viewModel = EditorViewModel()
@State private var saveTask: Task<Void, Never>?
@State private var saveError: String?
var body: some View {
VStack {
Text("콜라주 편집기")
.font(.title2)
HStack {
Button("실행 취소") {
viewModel.undo()
}
.disabled(!viewModel.canUndo)
Button("다시 실행") {
viewModel.redo()
}
.disabled(!viewModel.canRedo)
}
Text(
"레이어 \(viewModel.document.layers.count)개"
)
}
.navigationTitle(project.title)
.task {
loadProject()
}
.onChange(
of: viewModel.document
) { _, _ in
scheduleAutoSave()
}
.onChange(
of: scenePhase
) { _, phase in
if phase == .background {
saveNow()
}
}
.onDisappear {
saveTask?.cancel()
saveNow()
}
}
private func loadProject() {
do {
let repository = ProjectRepository(
context: modelContext
)
let document = try repository.load(
project: project
)
viewModel.replaceDocument(document)
} catch {
saveError = error.localizedDescription
}
}
private func scheduleAutoSave() {
saveTask?.cancel()
saveTask = Task {
do {
try await Task.sleep(
for: .seconds(1)
)
try Task.checkCancellation()
saveNow()
} catch {
// 예약된 저장 취소
}
}
}
private func saveNow() {
do {
try ProjectRepository(
context: modelContext
).update(
project: project,
document: viewModel.document
)
} catch {
saveError = error.localizedDescription
}
}
}위 코드는 자동 저장과 프로젝트 복원 흐름을 설명하는 기본 골격입니다.
실제 콜라주 캔버스는 지난 글의 CollagePhotoView를 새로운 EditorLayer 모델에 맞게 연결해야 합니다.
또한 프로젝트를 처음 로딩할 때 발생하는 상태 변경을 자동 저장으로 오인하지 않도록 로딩 완료 플래그를 추가하는 것이 좋습니다.
27. 자동 저장 시 데이터 손실 방지
자동 저장을 구현할 때는 다음 상황을 반드시 고려해야 합니다.
앱 실행 직후 아직 프로젝트를 불러오지 않은 상태
편집 중 연속적인 제스처 발생
사진 파일 저장이 완료되기 전 메타데이터 저장
디스크 공간 부족
JSON 인코딩 실패
SwiftData 저장 실패
앱 백그라운드 전환
강제 종료
프로젝트 삭제와 자동 저장의 충돌
특히 사진 파일이 저장되기 전에 해당 파일명을 프로젝트에 기록하면, 나중에 복원할 때 파일을 찾지 못할 수 있습니다.
따라서 파일 저장 성공 → 편집 모델 갱신 → 프로젝트 메타데이터 저장 순서를 유지하는 것이 좋습니다.
28. Undo History를 영구 저장해야 할까?
이번 예제에서는 Undo Stack과 Redo Stack을 메모리에 보관합니다.
따라서 앱을 종료한 뒤 다시 실행하면 편집 결과는 복원되지만 Undo 기록은 초기화됩니다.
일반적인 앱에서는 이 정도로 충분할 수 있습니다.
하지만 전문 편집기처럼 앱을 재실행한 뒤에도 실행 취소 기록을 유지하려면 History 자체를 직렬화해서 저장해야 합니다.
이 경우 데이터 크기와 버전 관리가 복잡해질 수 있습니다.
초기 버전에서는 편집 결과만 저장하고 Undo History는 메모리에서 관리하는 방식을 권장합니다.
29. 성능 최적화
사진 편집 앱에서는 다음과 같은 최적화가 중요합니다.
첫째, 원본 사진을 History에 저장하지 않습니다.
Undo/Redo에는 위치, 크기, 회전 등 편집 정보만 저장합니다.
둘째, 제스처 중에는 데이터베이스를 저장하지 않습니다.
제스처가 끝났을 때 상태를 확정하고 자동 저장을 예약합니다.
셋째, 원본 사진과 미리보기를 분리합니다.
편집 화면에서는 작은 이미지를 사용하고, 최종 내보내기에서만 고해상도 원본을 사용합니다.
넷째, SwiftData 저장과 이미지 렌더링을 구분합니다.
편집 메타데이터 저장은 비교적 가볍지만 고해상도 이미지 렌더링은 CPU와 메모리 사용량이 클 수 있습니다.
다섯째, 사용하지 않는 파일을 정리합니다.
삭제한 프로젝트의 이미지 파일이 계속 남아 있으면 앱 저장 공간이 불필요하게 증가할 수 있습니다.
30. 테스트 체크리스트
사진 추가 후 Undo
사진 삭제 후 Undo
사진 이동 후 Undo
사진 회전 후 Undo
Undo 후 Redo
새로운 편집 시 Redo 초기화
Undo History 최대 개수 제한
텍스트 스티커 추가
텍스트 수정
텍스트 삭제
레이어 순서 변경
프로젝트 SwiftData 저장
원본 이미지 파일 저장
앱 재실행 후 복원
자동 저장 Debounce
앱 백그라운드 전환 시 저장
저장 실패 처리
원본 파일 누락 처리
프로젝트 삭제
프로젝트 파일 정리
대용량 사진 테스트
실제 iPhone 메모리 사용량 확인
31. 마무리
사진 편집 앱을 실제 서비스 수준으로 발전시키려면 이미지 편집 기능뿐 아니라 상태 관리와 데이터 저장 구조가 중요합니다.
이번 글에서 살펴본 핵심 내용을 정리하면 다음과 같습니다.
Undo/Redo는 편집 상태를 기록하는 방식으로 구현할 수 있습니다.
원본 이미지를 복사하지 않고 편집 정보만 저장하면 메모리를 절약할 수 있습니다.
제스처가 끝났을 때 하나의 편집 작업으로 기록하는 것이 좋습니다.
사진과 텍스트를 동일한 레이어 모델로 관리할 수 있습니다.
SwiftData에는 프로젝트 메타데이터를 저장할 수 있습니다.
원본 이미지는 Application Support에 파일로 보관할 수 있습니다.
Codable을 이용해 편집 상태를 JSON으로 저장하고 복원할 수 있습니다.
Task를 이용해 Debounce 자동 저장을 구현할 수 있습니다.
앱 종료와 백그라운드 전환을 고려한 저장 정책이 필요합니다.
데이터베이스와 파일 시스템의 정합성을 함께 관리해야 합니다.
Android에서 Room과 ViewModel을 사용해 본 개발자라면 SwiftData와 Observation을 이용해 비슷한 구조를 만들 수 있습니다.
다만 SwiftUI에서는 상태 변경과 View 갱신이 밀접하게 연결되어 있으므로, 제스처의 임시 상태와 저장할 편집 상태를 명확히 구분하는 것이 중요합니다.
사진 편집 앱의 완성도를 결정하는 것은 편집 기능의 개수뿐 아니라 사용자가 작업한 내용을 얼마나 안전하게 보존하고 복원할 수 있는가입니다.
참고 자료
SwiftUI Observation
https://developer.apple.com/documentation/observationSwiftData
https://developer.apple.com/documentation/swiftdataModelContext
https://developer.apple.com/documentation/swiftdata/modelcontextUndoManager
https://developer.apple.com/documentation/foundation/undomanagerFileManager
https://developer.apple.com/documentation/foundation/filemanagerCodable
https://developer.apple.com/documentation/swift/codableSwiftUI Gestures
https://developer.apple.com/documentation/swiftui/gesturesUIGraphicsImageRenderer
https://developer.apple.com/documentation/uikit/uigraphicsimagerenderer
태그: iOS, Swift, SwiftUI, 사진 편집 앱, Undo, Redo, 텍스트 스티커, SwiftData, MVVM, Codable, FileManager, 자동 저장, 프로젝트 복원, 비파괴 편집, iPhone 앱 개발