소규모 인메모리 Mac 도형 편집기라면 값 상태 스냅샷부터 시작하세요. 페이로드가 작아지는 이점이 추가 로직을 감수할 만하다면, 자주 발생하고 범위가 좁은 편집에는 역명령을 사용하세요. 두 접근 방식 모두 안정적인 소유자 하나와 각 사용자 동작의 명확한 커밋 경계가 필요합니다.

이 가이드는 한 가지 작업을 다룹니다. 자체 Swift 편집기에서 합성 도형을 이동, 삭제, 재배열할 때 예측 가능한 실행 취소와 다시 실행을 구현하는 것입니다. 아래의 의사 결정 프레임워크는 Apple API를 바탕으로 한 엔지니어링 권고 사항이며, Apple은 스냅샷과 명령 중 하나를 보편적으로 선택하라고 규정하지 않습니다.

각 실행 취소 작업은 무엇을 기억해야 하나요?

스냅샷은 정렬된 도형 배열과 같은 이전 편집 가능 상태를 기록합니다. 실행 취소는 해당 배열을 복원합니다. 역명령은 특정 편집을 되돌리는 데 필요한 작업을 기록합니다. 예를 들어 도형 하나의 중심을 이전 좌표로 되돌리는 작업입니다.

두 표현 방식 모두 Foundation의 UndoManager를 통해 작동합니다. UndoManager는 객체나 객체의 소유자에 대한 작업을 기록하며, 모델에 임의로 대입되는 값을 자동으로 관찰하지는 않습니다. 적절한 복원 작업을 등록하는 것은 변이 메서드의 책임입니다. UndoManager

기록 표현 방식을 선택하기 전에 편집 가능 상태를 정의하세요. 이 편집기의 경우 도형 ID, 좌표, 배열 순서가 여기에 포함됩니다. 호버 표시와 드래그 미리보기는 상태에서 제외하세요. 선택 상태를 기록에 포함할지도 명시적으로 결정해야 합니다. 삭제 후 선택 상태를 복원하면 상황을 이해하기 쉬워질 수 있지만, 실행 취소 시 현재 선택 상태도 바뀝니다.

편집기 전체에 하나의 표현 방식을 강제하지 말고, 각 작업에 이 의사 결정 표를 사용하세요.

작업 또는 조건 권장 표현 방식 보존할 정보
작은 캔버스에서 여러 속성이 함께 변경됨 스냅샷 이전 편집 가능 값 전체
도형 하나 이동 역방향 설정자 안정적인 도형 ID와 이전 중심 좌표
도형 하나 삭제 우선 스냅샷, 필요하면 역명령 역명령의 경우 삭제된 도형 전체와 원래 위치
여러 도형 재배열 역명령이 간단하지 않으면 스냅샷 이전 순서와 영향을 받은 속성
측정한 기록 메모리 비용이 지나침 대상 지정 역명령 정확한 복원에 필요한 모든 정보

유용한 질문은 다음과 같습니다. 역명령이 복원해야 할 모든 항목을 열거할 수 있나요? 도형을 삭제하면서 선택 상태와 순서도 바뀐다면, 삭제된 ID만 기억하는 것으로는 충분하지 않습니다. 여러 관련 값에 영향을 미치는 작업은 스냅샷이 감사하기 쉬운 경우가 많습니다.

스냅샷은 언제 실제로 독립적인가요?

Swift 구조체와 배열은 값 의미론을 따릅니다. 따라서 값 유형만으로 구성한 도형 배열은 이후 변경과 독립된 이전 상태를 나타낼 수 있습니다. 그러나 클래스 인스턴스의 배열을 복사하면 해당 인스턴스에 대한 참조가 유지되므로, 인스턴스를 변경하면 스냅샷이라고 생각한 내용도 바뀔 수 있습니다. 참조를 구조체로 감싸도 이 문제는 해결되지 않습니다. Swift의 값 유형과 참조 유형

소규모 편집기에서는 기록에 일반 값 데이터를 사용하세요. ID, 좌표, 크기, 기타 편집 가능한 속성이 이에 해당합니다. 뷰와 변경 가능한 렌더링 객체는 스냅샷에서 제외하세요. 실시간 모델이 클래스를 사용한다면 복원에 필요한 모든 속성을 담은 별도의 값 표현을 만드세요.

“스냅샷”이라는 단어만으로 메모리 비용을 단정하지 마세요. 대표적인 합성 캔버스를 사용해 보존되는 기록과 편집 지연 시간을 측정하세요. 예를 들어 페이로드를 계산하면, 각각 도형 레코드 1,000개를 보존하는 항목 100개는 레코드 항목 100,000개에 해당합니다. 반면 도형 하나에 대한 역명령 항목 100개는 이전 중심 좌표 100개와 ID만 보존합니다. 이 개수는 측정된 바이트 수가 아니며 Swift의 저장 공간 할당에 대한 주장이 아닙니다.

기록 깊이를 의도적으로 설정하세요. Apple 문서에 따르면 levelsOfUndo은 최상위 실행 취소 그룹의 수를 제한하며, 기본값인 0은 그룹 수에 제한이 없다는 뜻입니다. 그룹 제한은 유용하지만 바이트 예산을 제한하지는 않습니다. 배치 스냅샷과 단일 좌표 편집은 보존하는 데이터의 양이 크게 다를 수 있습니다. levelsOfUndo

스냅샷과 역명령은 다시 실행을 어떻게 보장하나요?

복원 메서드는 모델을 변경하기 전에 반대 방향의 복원 작업을 등록해야 합니다. 실행 취소가 해당 메서드를 호출하면, 그 메서드의 등록이 다시 실행을 준비합니다. 스냅샷의 경우 이전 상태를 복원하기 전에 현재 설치된 상태를 캡처하세요. 위치 명령의 경우 저장된 중심 좌표를 설정하기 전에 현재 중심 좌표를 캡처하세요.

Apple의 보관된 실행 취소 아키텍처 가이드는 이 상호 등록 패턴을 명시적으로 설명합니다. 실행 취소 중 호출되는 설정자가 다시 실행에 필요한 값을 등록합니다. 이 예시는 Objective-C를 사용하지만, 아래 전체 Swift 예시는 현재 클로저 API를 사용합니다. 실행 취소 작업 등록하기

isUndoing이 참이라는 이유로 등록을 억제하지 마세요. 이 설계에서는 그렇게 하면 다시 실행을 만드는 등록이 사라집니다. 또한 위치를 복원할 때 상대 이동을 새로 실행하지 마세요. 이전 중심 좌표를 정확히 저장하면 의도한 복원이 명확해집니다.

모델 수준의 완전한 예시

이 독립 실행형 macOS 프로그램은 스냅샷 기반 삭제와 역방향 위치 설정자를 결합합니다. 합성 인메모리 값과 Foundation만 사용하며, 영속성이나 UI 의존성은 없습니다. EditorUndo.swift로 저장한 뒤 현재 Swift 6 툴체인과 macOS SDK를 사용해 컴파일하세요.

swiftc -parse-as-library EditorUndo.swift -o EditorUndo
./EditorUndo

이 프로그램은 실행 중인 앱의 이벤트 루프에 어설션이 의존하지 않도록 전용 관리자를 소유하고 이벤트 그룹화를 비활성화합니다. 각 초기 편집에는 명시적인 그룹이 지정됩니다. 복원 메서드는 실행 취소나 다시 실행 중에 추가 그룹을 열지 않습니다. 자동 그룹화가 비활성화된 경우 실행 취소 전에 그룹을 명시적으로 닫아야 합니다. groupsByEvent

import Foundation

struct Center: Equatable {
    var x: Double
    var y: Double
}

struct Item: Equatable {
    let id: Int
    var center: Center
}

@MainActor
final class Editor {
    private(set) var items: [Item]
    let history: UndoManager

    init(items: [Item], history: UndoManager) {
        precondition(Set(items.map(\.id)).count == items.count)
        self.items = items
        self.history = history
    }

    func replaceItems(_ next: [Item], name: String) {
        precondition(Set(next.map(\.id)).count == next.count)
        guard next != items else { return }
        let previous = items
        history.registerUndo(withTarget: self) { target in
            target.replaceItems(previous, name: name)
        }
        items = next
        history.setActionName(name)
    }

    func setCenter(id: Int, to next: Center) {
        guard let index = items.firstIndex(where: { $0.id == id }) else {
            preconditionFailure("History refers to a missing item")
        }
        let previous = items[index].center
        guard previous != next else { return }
        history.registerUndo(withTarget: self) { target in
            target.setCenter(id: id, to: previous)
        }
        items[index].center = next
        history.setActionName("Move Shape")
    }
}

@main
struct Demo {
    @MainActor
    static func main() {
        let history = UndoManager()
        history.groupsByEvent = false
        history.levelsOfUndo = 100

        let original = [
            Item(id: 1, center: Center(x: 20, y: 30)),
            Item(id: 2, center: Center(x: 80, y: 30))
        ]
        let editor = Editor(items: original, history: history)

        func edit(_ operation: () -> Void) {
            history.beginUndoGrouping()
            defer { history.endUndoGrouping() }
            operation()
        }

        edit {
            editor.setCenter(id: 1, to: Center(x: 50, y: 40))
        }
        let moved = editor.items

        edit {
            editor.replaceItems(
                editor.items.filter { $0.id != 2 },
                name: "Delete Shape"
            )
        }
        let deleted = editor.items

        history.undo()
        precondition(editor.items == moved)
        history.undo()
        precondition(editor.items == original)
        history.redo()
        precondition(editor.items == moved)
        history.redo()
        precondition(editor.items == deleted)
        print("Undo and redo checks passed")
    }
}

예상 출력은 Undo and redo checks passed입니다. 연구 환경에 Swift 컴파일러가 없어 여기서는 예시를 살펴보기만 했고 실행하지는 않았습니다. 이 예시는 기록의 핵심을 보여줍니다. SwiftUI 인터페이스에서 뷰를 갱신하려면 관찰 가능한 모델이나 어댑터도 필요합니다.

클로저는 편집기를 직접 캡처하는 대신 target 매개변수를 사용합니다. Apple 문서에 따르면 등록 대상은 참조 유형이어야 하고, 관리자는 대상에 대한 비소유 참조를 유지합니다. 또한 클로저 매개변수를 사용하면 캡처된 대상을 통한 순환 참조를 방지할 수 있습니다. registerUndo(withTarget:handler:)

전제 조건은 실행 취소 작업을 조용히 누락하는 대신 잘못된 모델 가정을 드러냅니다. 프로덕션 명령 경계에서는 등록 전에 잘못된 편집을 거부하고 적절한 애플리케이션 수준의 오류를 표시해야 합니다. 기록을 재생하는 중에 ID를 찾을 수 없다면, 예상된 기록 순서 바깥에서 모델이 변경되었음을 뜻합니다.

드래그를 실행 취소 가능한 단일 동작으로 만들려면 어떻게 해야 하나요?

드래그는 여러 업데이트를 만들지만, 사용자는 완료된 이동 한 번을 한 번에 실행 취소할 수 있기를 기대합니다. Apple의 자동 그룹화는 런 루프의 실행 패스를 따르므로, 전체 제스처의 정의로 취급할 수 없습니다. 명시적 그룹으로 여러 등록을 묶을 수 있지만 경계는 여전히 신중하게 관리해야 합니다. groupsByEvent, beginUndoGrouping()

이 편집기에서는 미리보기와 커밋 순서를 사용하세요.

  1. 드래그가 시작될 때 대상 ID와 커밋된 시작 중심 좌표를 제스처의 기준값으로 보관합니다.
  2. 임시 이동값에는 @GestureState와 updating(_:body:)을 사용합니다. 커밋된 항목을 변경하지 않고 기준 좌표에 이동값을 더한 위치에 도형을 렌더링합니다.
  3. onEnded(_:)이 성공적으로 완료되면 최종 제스처 값으로 최종 중심 좌표를 계산하고 모델의 실행 취소 가능 설정자를 한 번 호출합니다.
  4. 결과 중심 좌표가 커밋된 중심 좌표와 같다면 등록하지 않습니다.
  5. 취소되면 미리보기를 버리고 커밋된 모델을 유지합니다.

이 순서는 SwiftUI 문서의 구분을 따릅니다. 제스처가 끝나거나 취소되면 제스처 상태가 초기화되고, onEnded(_:)은 성공적으로 완료된 경우에만 실행됩니다. 따라서 취소 후에도 반드시 실행되어야 하는 정리 작업은 onEnded(_:)에만 의존할 수 없습니다. 제스처로 상호작용성 추가하기

미리보기가 진행되는 동안 편집기의 커밋 상태를 안정적으로 유지하세요. 간단한 정책은 드래그 중 다른 캔버스 변경과 캔버스 실행 취소를 비활성화하고, 완료되거나 취소된 뒤 다시 활성화하는 것입니다. 그렇지 않다면 드래그 커밋을 수락하기 전에 중간에 발생한 편집이 저장된 기준값을 어떻게 무효화하는지 정의하세요.

편집기의 다른 부분에서 커밋된 좌표를 계속 업데이트해야 한다면 미리보기 전용 렌더링이 맞지 않을 수 있습니다. 이 경우 명시적 제스처 트랜잭션을 구현하세요. 시작 상태를 저장하고, 별도의 기록 항목을 만들지 않고 중간 업데이트를 적용한 뒤, 성공 시 복원 작업 하나를 등록합니다. 취소되면 시작 상태로 복원해야 합니다. 임의의 콜백이나 비동기 작업이 끝날 때까지 공유 실행 취소 그룹을 열어 둔 채로 두지 마세요.

모델과 실행 취소 관리자는 누가 소유해야 하나요?

독립적으로 편집 가능한 각 캔버스에는 안정적인 편집기 소유자 하나를 두세요. 해당 소유자가 커밋된 항목을 관리하고 기록 작업의 대상이 됩니다. 모델 변경과 실행 취소 상호작용은 모두 메인 액터에서 처리하세요. 현재 Apple 문서에서는 UndoManager와 해당 클로저 핸들러가 메인 액터에 격리되어 있다고 명시합니다. UndoManager

SwiftUI에서는 뷰의 관리자를 @Environment(\.undoManager)를 통해 읽으세요. 뷰가 평가될 때마다 새 관리자를 만들지 말고, 안정적인 편집기나 명령 레이어를 통해 해당 관리자를 전달하세요. 환경 값은 선택 사항입니다. Apple 문서에 따르면 지원하지 않는 컨텍스트에서는 nil을 반환하며, 이 경우 실행 취소 등록을 건너뛸 수 있습니다. 편집은 계속할 수 있지만, 이 경로에서는 실행 취소를 사용할 수 있다고 표시해서는 안 됩니다. EnvironmentValues.undoManager

AppKit 캔버스에서는 적절한 리스폰더를 통해 관리자를 가져오세요. 기본 NSResponder.undoManager 구현은 다음 리스폰더에 요청을 전달합니다. 따라서 메뉴의 기본 동작이 해당 관리자에 연결되길 기대한다면, 개인적으로 만든 관리자를 캔버스의 리스폰더 컨텍스트에 명시적으로 통합해야 합니다. 모델에 관리자를 저장하는 것만으로는 연결되지 않습니다. NSResponder.undoManager

독립 실행형 예시의 그룹화 설정은 해당 전용 관리자에 적용됩니다. 그룹화 정책에 참여하는 모든 항목을 소유하지 않는 한, 그 설정을 환경에서 제공된 관리자에 그대로 적용하지 마세요. UI에서는 컨텍스트의 기존 이벤트 정책을 따라 완료된 캔버스 편집을 등록하고, 동기 하위 작업 여러 개가 하나의 동작을 구성하면 함께 묶으세요.

편집기 대상을 해제하기 전에 통제된 수명 주기 경계에서 대상을 가리키는 대기 중인 등록을 제거하세요. Apple은 해당 대상에 지정된 실행 취소 및 다시 실행 작업을 제거하기 위해 removeAllActions(withTarget:)을 제공합니다. 전체 관리자를 비우면 공유 관리자일 경우 다른 참여자에게도 영향을 줍니다. removeAllActions(withTarget:)

최종 설계를 결정하기 전에 무엇을 확인해야 하나요?

표현 방식의 경계를 넘나드는 짧은 순서를 실행해 보세요. 도형 하나를 이동하고, 다른 도형을 삭제한 뒤, 둘 다 실행 취소하고 다시 실행합니다. 각 단계 후 값, ID, 순서를 확인하세요. 취소된 드래그, 시작 위치에서 끝나는 드래그, 독립된 편집기 창 두 개도 추가하세요. 각 사례는 복원 데이터, 제스처 완료, 무작업 처리, 소유권이라는 서로 다른 경계를 확인합니다.

캔버스 전체 교체는 명시적 작업으로 다루세요. 복원에 충분한 상태를 보존해 실행 취소 가능하게 만들거나, 오래된 기록을 의도적으로 지운 뒤 새 기준 상태를 설정하세요. 편집 가능한 필드를 빠뜨린 스냅샷과 대상을 찾을 수 없는 역명령은 모두 불완전한 구현입니다.

값 기반 소규모 캔버스는 스냅샷부터 시작하고, 안정적인 소유자를 하나 두며, 각 제스처를 한 번만 커밋하세요. 복원 데이터가 명확하고 측정 결과 추가 로직이 타당한 경우 역명령을 도입하세요. 이렇게 하면 사용자 동작 하나의 의미를 바꾸지 않고 기록 표현 방식을 변경할 수 있습니다.

출처