SwiftUI 사진 콜라주 앱 개발 완벽 가이드 — 드래그 앤 드롭, 확대·축소, 회전과 고화질 이미지 저장

사진 콜라주 앱은 여러 장의 사진을 하나의 캔버스에 배치하고 편집한 뒤 새로운 이미지로 저장하는 앱입니다.

단순히 사진 두 장을 합치는 기능이라면 UIGraphicsImageRenderer만으로도 구현할 수 있습니다.

하지만 실제 편집기에서는 사용자가 사진을 직접 이동하고, 확대·축소하고, 회전하고, 여러 사진의 앞뒤 순서를 변경할 수 있어야 합니다.

이러한 기능을 구현하려면 화면에 표시되는 이미지의 위치와 크기뿐 아니라 최종 저장할 이미지의 좌표까지 관리해야 합니다.

이번 글에서는 SwiftUI와 UIKit을 함께 사용해 기본적인 사진 콜라주 편집기를 만들어 보겠습니다.

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

  • PhotosPicker로 여러 사진 선택

  • 자유 배치형 콜라주 캔버스

  • 사진 드래그 이동

  • 핀치 제스처 확대·축소

  • 두 손가락 회전

  • 선택한 사진 앞으로 가져오기

  • 사진 삭제

  • 캔버스 배경색 변경

  • 편집 상태 관리

  • 고화질 이미지 렌더링

  • 사진 앱에 결과물 저장

  • 메모리 사용량 최적화

개발 환경

항목내용
언어Swift
UISwiftUI
최소 지원iOS 17
사진 선택PhotosUI
이미지 처리UIKit / Core Graphics
상태 관리Observation
사진 저장Photos

2. Android와 iOS 콜라주 개발 비교

Android에서 사진 편집기를 구현해 본 개발자라면 다음 개념이 익숙할 것입니다.

AndroidSwiftUI / iOS역할
CanvasGraphicsContext / CGContext이미지 그리기
MatrixCGAffineTransform좌표 변환
GestureDetectorDragGesture 등터치 제스처
ScaleGestureDetectorMagnificationGesture확대·축소
BitmapUIImage / CGImage이미지 데이터
Photo PickerPhotosPicker사진 선택
MediaStorePhotos결과물 저장
ViewModel@Observable편집 상태 관리

SwiftUI는 선언형 UI이기 때문에 화면 요소의 위치, 크기, 회전값을 상태로 관리하고 해당 상태에 따라 화면을 다시 구성하는 방식이 자연스럽습니다.


3. 콜라주 편집기의 전체 구조

이번 예제의 프로젝트 구조는 다음과 같습니다.

PhotoCollage/
 ├── PhotoCollageApp.swift
 │
 ├── Models/
 │    └── CollageItem.swift
 │
 ├── ViewModels/
 │    └── CollageViewModel.swift
 │
 ├── Views/
 │    ├── CollageEditorView.swift
 │    └── CollagePhotoView.swift
 │
 └── Services/
      ├── CollageRenderer.swift
      └── PhotoLibrarySaver.swift

각 파일의 역할을 구분하겠습니다.

  • CollageItem: 사진의 위치, 크기, 회전 등 편집 정보

  • CollageViewModel: 사진 추가, 삭제, 순서 변경, 선택 상태 관리

  • CollageEditorView: 전체 편집 화면

  • CollagePhotoView: 개별 사진 제스처 처리

  • CollageRenderer: 최종 콜라주 이미지 생성

  • PhotoLibrarySaver: 사진 앱 저장

이번 예제에서는 이해하기 쉽도록 선택한 사진을 메모리에 보관합니다. 실제 출시용 앱에서는 원본 파일을 디스크에 저장하고 축소된 미리보기만 메모리에 유지하는 방식으로 확장하는 것이 좋습니다.


4. 콜라주 좌표계 설계

사진 편집기에서 가장 중요한 것은 화면 좌표와 출력 이미지 좌표를 구분하는 것입니다.

예를 들어 편집 화면의 캔버스가 350×350포인트라고 가정하겠습니다.

최종 저장 이미지는 2000×2000픽셀로 만들 수 있습니다.

편집 화면
350 × 350 pt
      │
      │ 좌표 비율 변환
      ▼
최종 결과물
2000 × 2000 px

화면에서 사진의 중심이 (175, 175)라면 결과물에서는 (1000, 1000)이 됩니다.

좌표 변환 비율은 다음과 같습니다.

[
\text{출력 배율}=\frac{2000}{350}\approx5.714
]

이 비율을 위치와 크기에 적용하면 화면과 유사한 결과물을 고해상도로 렌더링할 수 있습니다.

이번 예제에서는 캔버스의 논리적 크기를 1000×1000으로 정의하고, 실제 화면에서는 해당 캔버스를 축소해 표시하겠습니다.

이렇게 하면 기기 화면 크기가 달라도 편집 좌표를 일관되게 관리하기 쉽습니다.


5. 사진 편집 모델 만들기

CollageItem.swift

import Foundation
import UIKit

struct CollageItem: Identifiable {

    let id: UUID
    let image: UIImage

    var center: CGPoint
    var size: CGSize
    var scale: CGFloat
    var rotation: AngleValue

    init(
        id: UUID = UUID(),
        image: UIImage,
        center: CGPoint,
        size: CGSize,
        scale: CGFloat = 1,
        rotation: AngleValue = .init()
    ) {
        self.id = id
        self.image = image
        self.center = center
        self.size = size
        self.scale = scale
        self.rotation = rotation
    }
}

struct AngleValue {
    var radians: Double = 0
}

여기서 center는 이미지의 중심 좌표입니다.

size는 기본 표시 크기이고, scale은 확대·축소 비율입니다.

rotation은 회전 각도를 라디안 단위로 저장합니다.

사진을 편집할 때 실제 UIImage를 매번 수정하지 않고 이러한 변환값만 변경하는 것이 핵심입니다.


6. 편집 상태를 관리하는 ViewModel

CollageViewModel.swift

import Foundation
import UIKit
import Observation

@MainActor
@Observable
final class CollageViewModel {

    var items: [CollageItem] = []

    var selectedItemID: UUID?

    var backgroundColor: UIColor = .white

    let canvasSize = CGSize(
        width: 1000,
        height: 1000
    )

    func addImage(_ image: UIImage) {

        let index = items.count

        let item = CollageItem(
            image: image,
            center: CGPoint(
                x: 500 + CGFloat(index % 3) * 30,
                y: 500 + CGFloat(index % 3) * 30
            ),
            size: CGSize(
                width: 420,
                height: 420
            )
        )

        items.append(item)

        selectedItemID = item.id
    }

    func select(_ id: UUID) {
        selectedItemID = id
    }

    func update(
        id: UUID,
        transform: (inout CollageItem) -> Void
    ) {

        guard let index = items.firstIndex(
            where: { $0.id == id }
        ) else {
            return
        }

        transform(&items[index])
    }

    func removeSelected() {

        guard let selectedItemID else {
            return
        }

        items.removeAll {
            $0.id == selectedItemID
        }

        self.selectedItemID = nil
    }

    func bringSelectedToFront() {

        guard let selectedItemID,
              let index = items.firstIndex(
                where: { $0.id == selectedItemID }
              ) else {
            return
        }

        let item = items.remove(at: index)

        items.append(item)
    }
}

배열의 마지막 항목이 화면에서 가장 앞에 표시되도록 구성하겠습니다.

따라서 선택한 항목을 배열 끝으로 이동하면 앞으로 가져오는 기능을 구현할 수 있습니다.


7. 개별 사진 View 만들기

사진의 위치와 크기를 SwiftUI에 적용해 보겠습니다.

CollagePhotoView.swift

import SwiftUI

struct CollagePhotoView: View {

    let item: CollageItem
    let isSelected: Bool
    let canvasDisplaySize: CGFloat

    var body: some View {

        let ratio = canvasDisplaySize / 1000

        Image(uiImage: item.image)
            .resizable()
            .scaledToFill()
            .frame(
                width: item.size.width * ratio,
                height: item.size.height * ratio
            )
            .clipped()
            .scaleEffect(item.scale)
            .rotationEffect(
                .radians(item.rotation.radians)
            )
            .overlay {

                if isSelected {

                    Rectangle()
                        .stroke(
                            .blue,
                            lineWidth: 2
                        )
                }
            }
            .position(
                x: item.center.x * ratio,
                y: item.center.y * ratio
            )
    }
}

position()은 캔버스 내에서 이미지 중심의 위치를 지정합니다.

scaleEffect()는 확대·축소를 적용하고, rotationEffect()는 회전을 적용합니다.

다만 선택 테두리는 실제 편집 UI에서 변환된 사진과 일치하도록 배치해야 합니다. 이후 제스처를 포함한 버전에서는 선택 표시를 이미지의 변환과 함께 처리하겠습니다.


8. DragGesture로 사진 이동하기

사진을 손가락으로 끌어 이동하려면 DragGesture를 사용할 수 있습니다.

중요한 점은 드래그 도중 매번 이전 이동값을 누적하면 위치가 과도하게 변경될 수 있다는 것입니다.

따라서 제스처가 시작되었을 때의 위치를 기준으로 현재 이동량을 계산해야 합니다.

@GestureState private var dragTranslation = CGSize.zero

다음과 같이 제스처를 구성할 수 있습니다.

DragGesture()
    .updating($dragTranslation) {
        value, state, _ in

        state = value.translation
    }

이때 translation은 화면 포인트 단위입니다.

편집 모델은 1000×1000 논리 좌표를 사용하므로 화면 배율을 역으로 적용해야 합니다.

예를 들어 화면에 표시된 캔버스 크기가 350포인트라면 다음과 같습니다.

let logicalX = translation.width * 1000 / 350
let logicalY = translation.height * 1000 / 350

9. MagnificationGesture로 확대·축소하기

iOS 17 이상에서는 MagnifyGesture를 사용할 수 있습니다.

MagnifyGesture()
    .onChanged { value in

        let magnification = value.magnification

        print(magnification)
    }

확대·축소 값은 일반적으로 1을 기준으로 해석할 수 있습니다.

  • 1.0: 원래 크기

  • 1.5: 1.5배

  • 2.0: 2배

  • 0.5: 절반 크기

실제 편집기에서는 확대 비율에 제한을 두는 것이 좋습니다.

let limitedScale = min(
    max(newScale, 0.2),
    5.0
)

이렇게 하면 사진이 지나치게 작아지거나 커지는 것을 방지할 수 있습니다.


10. RotationGesture로 사진 회전하기

두 손가락 회전 제스처에는 RotateGesture를 사용할 수 있습니다.

RotateGesture()
    .onChanged { value in

        print(value.rotation.radians)
    }

회전값은 제스처가 시작된 시점의 회전 각도에 더하는 방식으로 관리할 수 있습니다.

예를 들어 기존 회전 각도가 30도이고 제스처에서 20도를 추가했다면 최종 회전은 50도가 됩니다.


11. 이동·확대·회전 제스처 결합

이번에는 실제로 사용할 수 있는 사진 편집 View를 구현하겠습니다.

CollagePhotoView.swift를 다음 코드로 교체합니다.

import SwiftUI

struct CollagePhotoView: View {

    let item: CollageItem
    let isSelected: Bool
    let canvasDisplaySize: CGFloat

    let onSelect: () -> Void
    let onUpdate: ((inout CollageItem) -> Void) -> Void

    @GestureState private var dragOffset = CGSize.zero
    @GestureState private var magnification: CGFloat = 1
    @GestureState private var rotation: Angle = .zero

    private var ratio: CGFloat {
        canvasDisplaySize / 1000
    }

    var body: some View {

        Image(uiImage: item.image)
            .resizable()
            .scaledToFill()
            .frame(
                width: item.size.width * ratio,
                height: item.size.height * ratio
            )
            .clipped()
            .overlay {
                Rectangle()
                    .stroke(
                        isSelected ? .blue : .clear,
                        lineWidth: 2
                    )
            }
            .scaleEffect(
                item.scale * magnification
            )
            .rotationEffect(
                .radians(
                    item.rotation.radians
                    + rotation.radians
                )
            )
            .position(
                x: item.center.x * ratio
                    + dragOffset.width,
                y: item.center.y * ratio
                    + dragOffset.height
            )
            .onTapGesture {
                onSelect()
            }
            .simultaneousGesture(dragGesture)
            .simultaneousGesture(magnifyGesture)
            .simultaneousGesture(rotateGesture)
    }

    private var dragGesture: some Gesture {

        DragGesture()
            .updating($dragOffset) {
                value, state, _ in

                state = value.translation
            }
            .onEnded { value in

                let dx = value.translation.width / ratio
                let dy = value.translation.height / ratio

                onUpdate { item in
                    item.center.x += dx
                    item.center.y += dy
                }
            }
    }

    private var magnifyGesture: some Gesture {

        MagnifyGesture()
            .updating($magnification) {
                value, state, _ in

                state = value.magnification
            }
            .onEnded { value in

                onUpdate { item in

                    item.scale = min(
                        max(
                            item.scale * value.magnification,
                            0.2
                        ),
                        5.0
                    )
                }
            }
    }

    private var rotateGesture: some Gesture {

        RotateGesture()
            .updating($rotation) {
                value, state, _ in

                state = value.rotation
            }
            .onEnded { value in

                onUpdate { item in

                    item.rotation.radians +=
                        value.rotation.radians
                }
            }
    }
}

이 구현은 제스처가 진행되는 동안 @GestureState로 임시 변환을 표시하고, 제스처가 끝나면 ViewModel에 최종 값을 반영합니다.

이렇게 하면 제스처 중의 이동값과 저장된 이동값이 중복 적용되는 문제를 줄일 수 있습니다.

여러 손가락 제스처를 동시에 사용할 때의 인식 우선순위와 동작은 실제 iPhone에서 검증해야 합니다.


12. PhotosPicker로 여러 사진 추가하기

이제 사진 선택 기능을 연결하겠습니다.

CollageEditorView.swift

import SwiftUI
import PhotosUI

struct CollageEditorView: View {

    @State private var viewModel = CollageViewModel()

    @State private var selectedItems: [PhotosPickerItem] = []

    @State private var isImporting = false
    @State private var errorMessage: String?

    var body: some View {

        VStack(spacing: 16) {

            collageCanvas

            editingToolbar
        }
        .padding()
        .navigationTitle("사진 콜라주")
        .onChange(
            of: selectedItems
        ) { _, newItems in

            Task {
                await importPhotos(newItems)
            }
        }
        .alert(
            "오류",
            isPresented: Binding(
                get: { errorMessage != nil },
                set: {
                    if !$0 {
                        errorMessage = nil
                    }
                }
            )
        ) {
            Button("확인", role: .cancel) {}
        } message: {
            Text(errorMessage ?? "")
        }
    }

    private var collageCanvas: some View {

        GeometryReader { geometry in

            let side = min(
                geometry.size.width,
                geometry.size.height
            )

            ZStack {

                Color(uiColor: viewModel.backgroundColor)

                ForEach(viewModel.items) { item in

                    CollagePhotoView(
                        item: item,
                        isSelected:
                            viewModel.selectedItemID == item.id,
                        canvasDisplaySize: side,
                        onSelect: {
                            viewModel.select(item.id)
                        },
                        onUpdate: { transform in

                            viewModel.update(
                                id: item.id,
                                transform: transform
                            )
                        }
                    )
                }
            }
            .frame(
                width: side,
                height: side
            )
            .clipped()
            .frame(
                maxWidth: .infinity,
                maxHeight: .infinity
            )
        }
        .aspectRatio(1, contentMode: .fit)
    }

    private var editingToolbar: some View {

        VStack(spacing: 12) {

            PhotosPicker(
                selection: $selectedItems,
                maxSelectionCount: 9,
                matching: .images
            ) {

                Label(
                    "사진 추가",
                    systemImage: "plus"
                )
            }
            .disabled(isImporting)

            HStack {

                Button("앞으로") {
                    viewModel.bringSelectedToFront()
                }

                Spacer()

                Button(
                    "삭제",
                    role: .destructive
                ) {
                    viewModel.removeSelected()
                }
            }
            .disabled(viewModel.selectedItemID == nil)

            if isImporting {
                ProgressView("사진 불러오는 중...")
            }
        }
    }

    @MainActor
    private func importPhotos(
        _ items: [PhotosPickerItem]
    ) async {

        guard !items.isEmpty else {
            return
        }

        isImporting = true

        defer {
            isImporting = false
        }

        for item in items {

            do {

                guard let data =
                    try await item.loadTransferable(
                        type: Data.self
                    ),
                    let image = UIImage(data: data)
                else {
                    continue
                }

                viewModel.addImage(image)

            } catch {

                errorMessage = "사진을 불러오지 못했습니다."
            }
        }

        selectedItems = []
    }
}

이제 사진을 여러 장 선택하면 캔버스에 추가할 수 있습니다.

추가한 사진은 드래그, 확대·축소, 회전 제스처로 편집할 수 있습니다.

주의: 이 코드는 편집기의 기본 구조를 보여주는 예제입니다. 실제 출시용 앱에서는 사진 선택 작업이 겹치지 않도록 요청을 관리하고, 가져오기 실패 항목을 사용자에게 구체적으로 안내해야 합니다.


13. 캔버스 배경색 변경

콜라주 앱에서는 배경색을 변경하는 기능도 유용합니다.

예를 들어 흰색, 검은색, 회색 배경을 제공할 수 있습니다.

ViewModel에 이미 다음 속성이 있습니다.

var backgroundColor: UIColor = .white

SwiftUI에서 배경색 선택 버튼을 추가할 수 있습니다.

HStack {

    Button("흰색") {
        viewModel.backgroundColor = .white
    }

    Button("검정") {
        viewModel.backgroundColor = .black
    }

    Button("회색") {
        viewModel.backgroundColor = .lightGray
    }
}

다만 UIKit의 UIColor를 SwiftUI Observation 모델에 저장할 때는 상태 변경 추적과 값 비교 정책을 고려해야 합니다.

단순한 색상 선택 기능이라면 별도의 색상 열거형을 사용하는 것도 좋은 방법입니다.


14. 고화질 이미지 렌더링의 핵심

화면에 표시된 콜라주를 그대로 스크린샷으로 저장하면 출력 해상도가 화면 크기에 제한될 수 있습니다.

예를 들어 편집 화면이 350×350포인트이고 화면 배율이 3배라면 단순 화면 캡처 결과는 대략 1050×1050픽셀 수준일 수 있습니다.

하지만 사진 콜라주 앱에서는 2000×2000픽셀이나 그 이상의 결과물이 필요할 수 있습니다.

따라서 최종 이미지는 편집 화면을 캡처하는 대신 편집 모델의 위치·크기·회전값을 사용해 별도로 렌더링하는 것이 좋습니다.


15. CollageRenderer 구현

CollageRenderer.swift

import UIKit

struct CollageRenderer {

    func render(
        items: [CollageItem],
        backgroundColor: UIColor,
        outputSize: CGSize = CGSize(
            width: 2000,
            height: 2000
        )
    ) -> UIImage {

        let format = UIGraphicsImageRendererFormat()

        format.scale = 1
        format.opaque = true

        let renderer = UIGraphicsImageRenderer(
            size: outputSize,
            format: format
        )

        return renderer.image { context in

            let cgContext = context.cgContext

            backgroundColor.setFill()

            cgContext.fill(
                CGRect(
                    origin: .zero,
                    size: outputSize
                )
            )

            let ratioX = outputSize.width / 1000
            let ratioY = outputSize.height / 1000

            for item in items {

                cgContext.saveGState()

                let center = CGPoint(
                    x: item.center.x * ratioX,
                    y: item.center.y * ratioY
                )

                let width =
                    item.size.width * ratioX * item.scale

                let height =
                    item.size.height * ratioY * item.scale

                cgContext.translateBy(
                    x: center.x,
                    y: center.y
                )

                cgContext.rotate(
                    by: item.rotation.radians
                )

                let rect = CGRect(
                    x: -width / 2,
                    y: -height / 2,
                    width: width,
                    height: height
                )

                item.image.draw(in: rect)

                cgContext.restoreGState()
            }
        }
    }
}

이 코드는 편집 모델의 위치, 확대 비율, 회전값을 이용해 최종 이미지를 렌더링합니다.

format.scale = 1을 지정했기 때문에 출력 이미지의 픽셀 크기를 outputSize와 일치시키기 쉽습니다.

다만 화면의 scaledToFill().clipped()와 위 렌더러의 draw(in:)는 이미지 비율 처리 방식이 다릅니다. 화면과 최종 결과물을 정확히 일치시키려면 렌더러에서도 Aspect Fill 크롭을 적용해야 합니다.


16. Aspect Fill을 적용한 이미지 렌더링

사진의 비율을 유지하면서 지정된 영역을 가득 채우려면 다음과 같이 계산할 수 있습니다.

func drawAspectFill(
    image: UIImage,
    in rect: CGRect
) {

    let imageSize = image.size

    let scale = max(
        rect.width / imageSize.width,
        rect.height / imageSize.height
    )

    let drawSize = CGSize(
        width: imageSize.width * scale,
        height: imageSize.height * scale
    )

    let drawRect = CGRect(
        x: rect.midX - drawSize.width / 2,
        y: rect.midY - drawSize.height / 2,
        width: drawSize.width,
        height: drawSize.height
    )

    let context = UIGraphicsGetCurrentContext()

    context?.saveGState()
    context?.clip(to: rect)

    image.draw(in: drawRect)

    context?.restoreGState()
}

이 함수를 CollageRenderer 내부에서 item.image.draw(in: rect) 대신 호출하면 이미지 비율을 유지하면서 지정된 영역을 채울 수 있습니다.

이때 drawAspectFill() 함수는 CollageRenderer에서 호출 가능한 범위에 정의해야 합니다.


17. 최종 이미지 저장하기

고화질 콜라주를 생성한 뒤 사진 앱에 저장하겠습니다.

PhotoLibrarySaver.swift

import UIKit
import Photos

enum PhotoSaveError: LocalizedError {

    case permissionDenied

    var errorDescription: String? {
        "사진 앱에 저장할 권한이 없습니다."
    }
}

struct PhotoLibrarySaver {

    func save(_ image: UIImage) async throws {

        let status = await PHPhotoLibrary.requestAuthorization(
            for: .addOnly
        )

        guard status == .authorized else {
            throw PhotoSaveError.permissionDenied
        }

        try await PHPhotoLibrary.shared().performChanges {

            PHAssetChangeRequest.creationRequestForAsset(
                from: image
            )
        }
    }
}

Info.plist에는 다음 권한 설명이 필요합니다.

<key>NSPhotoLibraryAddUsageDescription</key>
<string>완성한 콜라주 이미지를 사진 앱에 저장하기 위해 사용합니다.</string>

사진 저장은 사용자가 저장 버튼을 누른 시점에 실행하는 것이 좋습니다.


18. 저장 버튼 연결

CollageEditorView에 다음 상태를 추가합니다.

@State private var isExporting = false
@State private var saveMessage: String?

그리고 편집 도구 영역에 저장 버튼을 추가합니다.

Button {

    Task {
        await exportCollage()
    }

} label: {

    Label(
        "콜라주 저장",
        systemImage: "square.and.arrow.down"
    )
}
.disabled(
    viewModel.items.isEmpty || isExporting
)

저장 함수는 다음과 같습니다.

@MainActor
private func exportCollage() async {

    isExporting = true

    defer {
        isExporting = false
    }

    let renderer = CollageRenderer()

    let image = renderer.render(
        items: viewModel.items,
        backgroundColor: viewModel.backgroundColor,
        outputSize: CGSize(
            width: 2000,
            height: 2000
        )
    )

    do {

        try await PhotoLibrarySaver().save(image)

        saveMessage = "콜라주 이미지를 저장했습니다."

    } catch {

        saveMessage = error.localizedDescription
    }
}

saveMessage를 화면의 .alert에 연결하면 저장 성공 또는 실패 결과를 표시할 수 있습니다.

성능 주의: 이 예제의 렌더링은 메인 액터에서 실행됩니다. 2000×2000 이상의 이미지나 여러 장의 고해상도 사진을 처리할 때는 UI 멈춤을 방지하도록 렌더링 작업을 분리해야 합니다.


19. 콜라주 이미지의 메모리 사용량

출력 이미지가 2000×2000픽셀이라고 가정하겠습니다.

RGBA 4바이트 픽셀 버퍼 기준으로 대략 다음과 같은 메모리가 필요할 수 있습니다.

[
2000 \times 2000 \times 4 = 16,000,000\text{ bytes}
]

약 15.3MiB입니다.

하지만 실제 편집 과정에서는 원본 이미지, 미리보기, 렌더링 버퍼, 저장 인코딩 버퍼가 동시에 존재할 수 있습니다.

따라서 최종 이미지 한 장의 메모리만 계산해서는 안 됩니다.

예를 들어 4000×3000 사진 9장을 모두 디코딩하면 상당한 메모리가 필요할 수 있습니다.

실제 서비스에서는 다음 구조를 권장합니다.

원본 사진 파일
    │
    ├── 디스크 저장
    │
    └── 축소 미리보기
           │
           ▼
       SwiftUI 편집
           │
           ▼
     최종 출력 시에만
       원본 읽기
           │
           ▼
       고화질 렌더링

20. 원본 사진을 유지해야 하는 이유

사진을 여러 번 회전하거나 크기를 조절할 때 매번 UIImage를 새로 생성하면 화질과 메모리 측면에서 불리할 수 있습니다.

특히 JPEG로 반복 저장하면 손실 압축으로 인해 화질이 저하될 수 있습니다.

따라서 사진 편집 앱에서는 원본 이미지와 편집 명령을 분리하는 것이 좋습니다.

예를 들어 다음과 같은 데이터를 저장할 수 있습니다.

{
  "photoId": "photo-001",
  "centerX": 500,
  "centerY": 500,
  "width": 420,
  "height": 420,
  "scale": 1.5,
  "rotation": 0.3
}

이 정보만 있으면 원본 사진을 다시 불러와 동일한 편집 결과를 재구성할 수 있습니다.

이러한 방식을 비파괴 편집이라고 부릅니다.


21. 편집 프로젝트 저장하기

실제 콜라주 앱에서는 사용자가 편집 중이던 작업을 나중에 다시 열 수 있어야 합니다.

이 경우 사진 파일과 편집 상태를 분리해 저장합니다.

CollageProjects/
 └── project-001/
      ├── project.json
      ├── photo-001.heic
      ├── photo-002.jpg
      └── thumbnail.jpg

project.json에는 다음 정보를 저장할 수 있습니다.

  • 캔버스 크기

  • 배경색

  • 사진 파일명

  • 사진 위치

  • 사진 크기

  • 확대 비율

  • 회전 각도

  • 레이어 순서

이렇게 하면 앱을 종료했다가 다시 실행해도 이전 작업을 복원할 수 있습니다.


22. 드래그 앤 드롭과 제스처 이동의 차이

SwiftUI에서 사진을 손가락으로 움직이는 기능은 DragGesture로 구현할 수 있습니다.

하지만 다른 앱이나 화면에서 파일을 끌어오는 시스템 드래그 앤 드롭은 별도의 API를 사용합니다.

대표적으로 draggable과 dropDestination을 사용할 수 있습니다.

따라서 두 기능을 구분해야 합니다.

기능SwiftUI API
캔버스 안에서 사진 이동DragGesture
사진 확대·축소MagnifyGesture
사진 회전RotateGesture
앱 간 데이터 드래그draggable
외부 드롭 데이터 수신dropDestination

이번 글에서는 콜라주 캔버스 안에서 사진을 자유롭게 이동하는 기능을 구현했습니다.


23. 실제 사진 콜라주 앱에서 추가할 기능

기본 편집기를 완성했다면 다음 기능을 확장할 수 있습니다.

  1. 정사각형·가로형·세로형 캔버스 선택

  2. 사진 간격 및 테두리 설정

  3. 여러 가지 콜라주 템플릿

  4. 사진 모서리 둥글게 처리

  5. 사진 그림자

  6. 텍스트 스티커

  7. 배경 패턴

  8. 실행 취소 및 다시 실행

  9. 프로젝트 자동 저장

  10. 고해상도 PNG 및 JPEG 내보내기

특히 실행 취소 기능은 사진 편집 앱의 사용성을 크게 개선합니다.

단순한 상태 복사 방식부터 편집 명령을 기록하는 Command 패턴까지 다양한 구현 방법이 있습니다.


24. 테스트 체크리스트

  • PhotosPicker 여러 장 선택

  • 선택한 사진 캔버스에 추가

  • 사진 선택 표시

  • 사진 드래그 이동

  • 핀치 확대·축소

  • 두 손가락 회전

  • 제스처 연속 사용

  • 사진 앞으로 가져오기

  • 선택한 사진 삭제

  • 배경색 변경

  • 화면 크기 변경 시 좌표 유지

  • 고화질 렌더링

  • 화면과 출력 결과의 비율 일치

  • 사진 앱 저장 권한

  • 저장 실패 처리

  • 여러 장의 고해상도 사진 테스트

  • 메모리 사용량 측정

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

  • 실제 iPhone 터치 테스트


25. 마무리

SwiftUI를 이용하면 사진을 선택하고 캔버스에서 자유롭게 이동·확대·회전하는 콜라주 편집기를 구현할 수 있습니다.

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

  1. PhotosPicker로 여러 장의 사진을 가져올 수 있습니다.

  2. 사진의 위치와 변환값은 별도 모델로 관리하는 것이 좋습니다.

  3. DragGesture로 사진 이동을 구현할 수 있습니다.

  4. MagnifyGesture로 확대·축소를 처리할 수 있습니다.

  5. RotateGesture로 사진 회전을 구현할 수 있습니다.

  6. 여러 제스처를 조합해 편집기를 만들 수 있습니다.

  7. 레이어 순서를 배열로 관리할 수 있습니다.

  8. UIGraphicsImageRenderer로 고해상도 결과물을 생성할 수 있습니다.

  9. Photos 프레임워크로 결과물을 사진 앱에 저장할 수 있습니다.

  10. 실제 서비스에서는 비파괴 편집과 메모리 최적화가 중요합니다.

Android에서 Canvas와 Matrix를 사용해 본 개발자라면 SwiftUI에서도 비슷한 편집 기능을 구현할 수 있습니다.

다만 SwiftUI는 선언형 UI이기 때문에 화면 요소를 직접 조작하기보다 사진의 위치, 크기, 회전값을 상태로 관리하고 그 상태를 화면에 반영하는 방식으로 설계하는 것이 좋습니다.

사진 콜라주 앱의 핵심은 단순한 이미지 합성이 아니라 화면에서 편집한 결과를 원본 화질을 최대한 유지하면서 정확하게 출력하는 것입니다.


참고 자료

태그: iOS, Swift, SwiftUI, 사진 콜라주, PhotosPicker, DragGesture, MagnifyGesture, RotateGesture, 이미지 편집, Core Graphics, UIGraphicsImageRenderer, 사진 저장, MVVM, iPhone 앱 개발