SwiftUI 사진 편집 앱 고급 기능 — Undo/Redo, 텍스트 스티커, SwiftData 프로젝트 자동 저장과 복원

사진 편집 앱을 만들 때 이미지 이동이나 확대·축소 기능만으로는 충분하지 않습니다.

사용자가 실수로 사진을 삭제했을 때 되돌릴 수 있어야 하고, 사진 위에 텍스트를 추가할 수도 있어야 합니다.

또한 편집 도중 앱을 종료하더라도 작업 중이던 프로젝트가 유지되어야 합니다.

이러한 기능을 구현하려면 사진 자체를 반복해서 수정하는 방식보다 원본 이미지와 편집 상태를 분리하는 비파괴 편집 구조가 적합합니다.

이번 글에서는 지난 시간에 만든 SwiftUI 콜라주 편집기를 확장하겠습니다.

1. 이번 글에서 구현할 기능

  • 편집 상태를 이용한 Undo/Redo

  • 사진 이동·확대·회전 기록

  • 텍스트 스티커 추가

  • 텍스트 위치·크기·회전 변경

  • 레이어 순서 관리

  • SwiftData로 프로젝트 메타데이터 저장

  • 원본 사진을 앱 내부 파일로 보관

  • 자동 저장

  • 앱 재실행 후 프로젝트 복원

  • 프로젝트 삭제 시 관련 파일 정리

  • 데이터 손상과 저장 실패 처리

개발 환경

항목내용
언어Swift
UISwiftUI
최소 지원 버전iOS 17
상태 관리Observation
데이터베이스SwiftData
파일 관리FileManager
이미지 처리UIKit / Core Graphics
동시성Swift Concurrency

2. Android와 iOS 구현 방식 비교

AndroidiOS / SwiftUI역할
ViewModel@Observable ViewModel편집 상태
UndoManager / 자체 명령 기록UndoManager / 자체 History실행 취소
RoomSwiftData프로젝트 메타데이터
내부 Files 디렉터리Application Support원본 이미지
CanvasCore Graphics결과물 렌더링
DataStoreUserDefaults / @AppStorage편집기 설정
CoroutineTask / 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.heic

SwiftData에는 프로젝트를 복원하는 데 필요한 상태 정보를 저장하고, 이미지 파일은 앱 전용 저장소에 보관합니다.


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 = false
Button {
    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 modelContext

16. 편집 상태 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. 마무리

사진 편집 앱을 실제 서비스 수준으로 발전시키려면 이미지 편집 기능뿐 아니라 상태 관리와 데이터 저장 구조가 중요합니다.

이번 글에서 살펴본 핵심 내용을 정리하면 다음과 같습니다.

  1. Undo/Redo는 편집 상태를 기록하는 방식으로 구현할 수 있습니다.

  2. 원본 이미지를 복사하지 않고 편집 정보만 저장하면 메모리를 절약할 수 있습니다.

  3. 제스처가 끝났을 때 하나의 편집 작업으로 기록하는 것이 좋습니다.

  4. 사진과 텍스트를 동일한 레이어 모델로 관리할 수 있습니다.

  5. SwiftData에는 프로젝트 메타데이터를 저장할 수 있습니다.

  6. 원본 이미지는 Application Support에 파일로 보관할 수 있습니다.

  7. Codable을 이용해 편집 상태를 JSON으로 저장하고 복원할 수 있습니다.

  8. Task를 이용해 Debounce 자동 저장을 구현할 수 있습니다.

  9. 앱 종료와 백그라운드 전환을 고려한 저장 정책이 필요합니다.

  10. 데이터베이스와 파일 시스템의 정합성을 함께 관리해야 합니다.

Android에서 Room과 ViewModel을 사용해 본 개발자라면 SwiftData와 Observation을 이용해 비슷한 구조를 만들 수 있습니다.

다만 SwiftUI에서는 상태 변경과 View 갱신이 밀접하게 연결되어 있으므로, 제스처의 임시 상태와 저장할 편집 상태를 명확히 구분하는 것이 중요합니다.

사진 편집 앱의 완성도를 결정하는 것은 편집 기능의 개수뿐 아니라 사용자가 작업한 내용을 얼마나 안전하게 보존하고 복원할 수 있는가입니다.


참고 자료

태그: iOS, Swift, SwiftUI, 사진 편집 앱, Undo, Redo, 텍스트 스티커, SwiftData, MVVM, Codable, FileManager, 자동 저장, 프로젝트 복원, 비파괴 편집, iPhone 앱 개발