ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Ensuring 60fps Animations in SwiftUI (GPU Rendering Optimization)
    SwiftUI 2025. 8. 23. 10:27

    안녕하세요. 그린입니다 🍏
    이번 포스팅은 SwiftUI 애니메이션의 성능을 극한까지 끌어올려 일관된 60fps를 보장하는 실전 최적화 방법들에 대해 알아보겠습니다 🚀


    SwiftUI Animation Performance Deep Dive

    이런 경험 있으신가요?

    • "부드럽게 움직이다가 갑자기 끊어져요 😱"
    • "복잡한 리스트에서 스크롤할 때 버벅거림"
    • "여러 애니메이션이 동시에 실행될 때 프레임 드롭 발생"
    • "디바이스가 뜨거워지면서 애니메이션이 느려짐"

    이런 문제들은 단순히 사용자 경험을 해치는 것을 넘어서서 앱의 품질 인식에 직접적인 영향을 미칩니다.

    특히 iOS 17부터 ProMotion 디스플레이가 더 널리 퍼지면서, 사용자들은 120fps까지도 기대하게 되었거든요.

     


    Why 60fps Matters More Than Ever?

    📱 사용자 기대치 상승 최신 연구에 따르면 이렇습니다.

    • 60fps 미달 애니메이션 앱의 체감 품질은 50% 하락
    • 사용자의 80%가 부드러운 애니메이션을 앱 품질의 핵심 지표로 인식
    • ProMotion 디스플레이 사용자들의 120fps 기대치 급증

    ⚡ iOS의 렌더링 최적화 진화

    • Metal Performance Shaders 활용 확대
    • Core Animation의 GPU 가속 개선
    • SwiftUI 자체 렌더링 엔진 최적화 (iOS 16+)
    • ProMotion과 Dynamic Island의 고주사율 활용

    프레임 드롭의 원인 분석

    🔍 실제 성능 측정하는 법

    기존의 Debug Navigator나 간단한 성능 도구로는 정확한 애니메이션 성능을 측정하기 어려워요.

    실전에서 사용할 수 있는 정확한 측정 방법들을 알아봅시다.

     

    1️⃣ Instruments의 Core Animation 템플릿 활용

    // 성능 측정을 위한 테스트용 SwiftUI 뷰
    struct PerformanceTestView: View {
        @State private var isAnimating = false
        @State private var items: [UUID] = (0..<100).map { _ in UUID() }
        
        var body: some View {
            ScrollView {
                LazyVStack(spacing: 2) {
                    ForEach(items, id: \.self) { item in
                        AnimatedCard(isAnimating: isAnimating)
                    }
                }
            }
            .onAppear {
                // 애니메이션 성능 테스트 시작
                withAnimation(.easeInOut(duration: 2.0).repeatForever()) {
                    isAnimating.toggle()
                }
            }
        }
    }
    
    struct AnimatedCard: View {
        let isAnimating: Bool
        
        var body: some View {
            RoundedRectangle(cornerRadius: 10)
                .fill(Color.blue)
                .frame(height: 60)
                .scaleEffect(isAnimating ? 1.05 : 1.0)
                .opacity(isAnimating ? 0.8 : 1.0)
                .padding(.horizontal)
        }
    }

     

    2️⃣ 실시간 FPS 모니터링

    import SwiftUI
    
    struct FPSMonitor: View {
        @StateObject private var fpsCounter = FPSCounter()
        
        var body: some View {
            VStack {
                Text("FPS: \(fpsCounter.fps, specifier: "%.1f")")
                    .font(.headline)
                    .foregroundColor(fpsColor)
                    .padding()
                    .background(Color.black.opacity(0.7))
                    .cornerRadius(8)
            }
        }
        
        private var fpsColor: Color {
            switch fpsCounter.fps {
            case 55...Double.infinity: return .green
            case 45..<55: return .yellow
            default: return .red
            }
        }
    }
    
    class FPSCounter: ObservableObject {
        @Published var fps: Double = 0
        
        private var displayLink: CADisplayLink?
        private var lastTimestamp: CFTimeInterval = 0
        private var frameCount: Int = 0
        
        init() {
            startMonitoring()
        }
        
        private func startMonitoring() {
            displayLink = CADisplayLink(target: self, selector: #selector(displayLinkTick))
            displayLink?.add(to: .main, forMode: .common)
        }
        
        @objc private func displayLinkTick(displayLink: CADisplayLink) {
            frameCount += 1
            
            let currentTime = displayLink.timestamp
            let deltaTime = currentTime - lastTimestamp
            
            if deltaTime >= 1.0 { // 1초마다 FPS 계산
                DispatchQueue.main.async {
                    self.fps = Double(self.frameCount) / deltaTime
                    self.frameCount = 0
                    self.lastTimestamp = currentTime
                }
            }
            
            if lastTimestamp == 0 {
                lastTimestamp = currentTime
            }
        }
        
        deinit {
            displayLink?.invalidate()
        }
    }
    

     

    3️⃣ GPU 사용률 모니터링

    import Metal
    
    class GPUMonitor: ObservableObject {
        @Published var gpuUtilization: Float = 0
        private var device: MTLDevice?
        private var timer: Timer?
        
        init() {
            device = MTLCreateSystemDefaultDevice()
            startMonitoring()
        }
        
        private func startMonitoring() {
            timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in
                self.updateGPUUtilization()
            }
        }
        
        private func updateGPUUtilization() {
            // Metal Performance Shaders를 통한 GPU 사용률 추정
            guard let device = device else { return }
            
            // 간접적인 GPU 사용률 측정 (실제 구현에서는 더 정교한 방법 필요)
            let commandQueue = device.makeCommandQueue()
            let commandBuffer = commandQueue?.makeCommandBuffer()
            
            let startTime = CFAbsoluteTimeGetCurrent()
            commandBuffer?.commit()
            commandBuffer?.waitUntilCompleted()
            let endTime = CFAbsoluteTimeGetCurrent()
            
            // 간단한 휴리스틱으로 GPU 부하 추정
            let executionTime = endTime - startTime
            DispatchQueue.main.async {
                self.gpuUtilization = min(Float(executionTime * 1000), 100.0)
            }
        }
        
        deinit {
            timer?.invalidate()
        }
    }
    

     

    🔍 성능 병목 지점 식별

    실제로 애니메이션 성능 문제가 발생하는 주요 지점들을 파악해봅시다.

    // 🚫 성능 문제를 일으키는 코드들
    struct ComplexItem: Identifiable, Hashable {
        let id = UUID()
        let value: Int
        let data: [Double]
        
        init(value: Int) {
            self.value = value
            self.data = (0..<100).map { _ in Double.random(in: 0...1) }
        }
    }
    
    func generateComplexItems() -> [ComplexItem] {
        return (0..<100).map { ComplexItem(value: $0) }
    }
    
    struct ComplexCalculationView: View {
        let item: ComplexItem
        let offset: CGFloat
        
        var body: some View {
            Rectangle()
                .fill(Color.blue)
                .frame(height: 80)
                .overlay(
                    // 매 프레임마다 복잡한 수학 연산 수행
                    Text("\(performHeavyCalculation(item: item, offset: offset), specifier: "%.2f")")
                        .foregroundColor(.white)
                        .font(.headline)
                )
        }
        
        // 🚫 매우 비효율적인 계산 (매 프레임마다 실행됨)
        private func performHeavyCalculation(item: ComplexItem, offset: CGFloat) -> Double {
            var result = 0.0
            for i in 0..<item.data.count {
                result += item.data[i] * sin(Double(i) + Double(offset) * 0.01)
            }
            return result / Double(item.data.count)
        }
    }
    
    struct ComplexShape: Shape {
        let animationProgress: CGFloat
        
        func path(in rect: CGRect) -> Path {
            var path = Path()
            let points = 50
            
            // 🚫 매 프레임마다 복잡한 경로 계산
            for i in 0..<points {
                let angle = Double(i) / Double(points) * 2 * .pi
                let radius = rect.width / 4 * (1 + animationProgress * sin(angle * 3))
                let x = rect.midX + cos(angle + Double(animationProgress) * .pi) * radius
                let y = rect.midY + sin(angle + Double(animationProgress) * .pi) * radius
                
                if i == 0 {
                    path.move(to: CGPoint(x: x, y: y))
                } else {
                    path.addLine(to: CGPoint(x: x, y: y))
                }
            }
            path.closeSubpath()
            return path
        }
    }
    
    struct ProblematicAnimationView: View {
        @State private var items: [ComplexItem] = generateComplexItems()
        @State private var animationOffset: CGFloat = 0
        
        var body: some View {
            ScrollView {
                LazyVStack {
                    ForEach(items) { item in
                        // 문제 1: 매 프레임마다 복잡한 계산 수행
                        ComplexCalculationView(item: item, offset: animationOffset)
                            .background(
                                // 문제 2: 매 프레임마다 새로운 Gradient 생성
                                LinearGradient(
                                    colors: generateDynamicColors(for: item),
                                    startPoint: .top,
                                    endPoint: .bottom
                                )
                            )
                            .overlay(
                                // 문제 3: 복잡한 Path 애니메이션
                                ComplexShape(animationProgress: animationOffset / 100)
                                    .stroke(Color.white, lineWidth: 2)
                            )
                            .shadow(
                                // 문제 4: 동적 Shadow (GPU 킬러!)
                                color: .black.opacity(Double(animationOffset / 200)),
                                radius: animationOffset / 10,
                                x: animationOffset / 20,
                                y: animationOffset / 20
                            )
                    }
                }
            }
            .onAppear {
                withAnimation(.linear(duration: 3.0).repeatForever(autoreverses: true)) {
                    animationOffset = 200
                }
            }
        }
        
        // CPU 집약적인 계산
        private func generateDynamicColors(for item: ComplexItem) -> [Color] {
            return (0..<10).map { index in
                Color(
                    red: Double(item.hashValue + index) / Double(Int.max),
                    green: sin(Double(index) * 0.1),
                    blue: cos(Double(index) * 0.2)
                )
            }
        }
    }
    

     


    최적화 방법들

    1️⃣ 렌더링 레이어 분리

    SwiftUI의 렌더링 최적화 핵심은 변경되는 부분과 변경되지 않는 부분을 분리하는 것입니다.

    // ✅ 최적화된 애니메이션 구조
    struct OptimizedAnimationView: View {
        @State private var animationOffset: CGFloat = 0
        
        var body: some View {
            ZStack {
                // 정적 배경 (한 번만 렌더링)
                StaticBackgroundView()
                
                // 애니메이션되는 레이어만 분리
                AnimatedElementsLayer(offset: animationOffset)
            }
            .onAppear {
                withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: true)) {
                    animationOffset = 100
                }
            }
        }
    }
    
    struct StaticBackgroundView: View {
        var body: some View {
            // 정적 요소들은 별도 뷰로 분리
            Rectangle()
                .fill(LinearGradient(
                    colors: [.blue.opacity(0.3), .purple.opacity(0.3)],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                ))
                .ignoresSafeArea()
        }
    }
    
    struct AnimatedElementsLayer: View {
        let offset: CGFloat
        
        var body: some View {
            Circle()
                .fill(Color.white)
                .frame(width: 50, height: 50)
                .offset(x: offset, y: 0)
                // GPU 가속 활용을 위한 3D Transform 사용
                .rotation3DEffect(
                    .degrees(offset * 2),
                    axis: (x: 0, y: 1, z: 0)
                )
        }
    }

     

    2️⃣ 메모리 풀과 객체 재사용

    // 객체 재사용으로 메모리 할당 최소화
    class AnimationObjectPool: ObservableObject {
        private var availableViews: [ReusableAnimationView] = []
        private var activeViews: [ReusableAnimationView] = []
        
        func getReusableView() -> ReusableAnimationView {
            if let reusableView = availableViews.popLast() {
                activeViews.append(reusableView)
                return reusableView
            } else {
                let newView = ReusableAnimationView()
                activeViews.append(newView)
                return newView
            }
        }
        
        func returnView(_ view: ReusableAnimationView) {
            if let index = activeViews.firstIndex(of: view) {
                activeViews.remove(at: index)
                view.reset()
                availableViews.append(view)
            }
        }
    }
    
    class ReusableAnimationView: ObservableObject, Equatable {
        @Published var position: CGPoint = .zero
        @Published var scale: CGFloat = 1.0
        @Published var rotation: Double = 0
        
        func reset() {
            position = .zero
            scale = 1.0
            rotation = 0
        }
        
        static func == (lhs: ReusableAnimationView, rhs: ReusableAnimationView) -> Bool {
            return ObjectIdentifier(lhs) == ObjectIdentifier(rhs)
        }
    }
    
    struct PooledAnimationView: View {
        @StateObject private var pool = AnimationObjectPool()
        @State private var animatedViews: [ReusableAnimationView] = []
        
        var body: some View {
            ZStack {
                ForEach(animatedViews, id: \.self) { animatedView in
                    Circle()
                        .fill(Color.red)
                        .frame(width: 30, height: 30)
                        .position(animatedView.position)
                        .scaleEffect(animatedView.scale)
                        .rotationEffect(.degrees(animatedView.rotation))
                }
            }
            .onTapGesture {
                addNewAnimation()
            }
        }
        
        private func addNewAnimation() {
            let newView = pool.getReusableView()
            animatedViews.append(newView)
            
            // 애니메이션 시작
            withAnimation(.easeOut(duration: 2.0)) {
                newView.position = CGPoint(x: 200, y: 200)
                newView.scale = 2.0
                newView.rotation = 360
            }
            
            // 2초 후 객체 반환
            DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
                if let index = animatedViews.firstIndex(of: newView) {
                    animatedViews.remove(at: index)
                    pool.returnView(newView)
                }
            }
        }
    }

     

    3️⃣ GPU 가속 활용 극대화

    GPU 가속을 최대한 활용하는 핵심은 GPU에서 처리할 수 있는 변환들을 우선적으로 사용하는 것입니다.

    struct GPUAcceleratedAnimationView: View {
        @State private var animationProgress: Double = 0
        @State private var items: [AnimationItem] = (0..<50).map { AnimationItem(id: $0) }
        
        var body: some View {
            TimelineView(.animation) { timeline in
                ZStack {
                    ForEach(items) { item in
                        OptimizedAnimatedItem(
                            item: item,
                            progress: animationProgress,
                            time: timeline.date.timeIntervalSince1970
                        )
                    }
                }
            }
            .onAppear {
                withAnimation(.linear(duration: 5.0).repeatForever(autoreverses: false)) {
                    animationProgress = 1.0
                }
            }
        }
    }
    
    struct OptimizedAnimatedItem: View {
        let item: AnimationItem
        let progress: Double
        let time: TimeInterval
        
        // GPU에서 처리되는 변환들만 사용
        private var transform: ProjectionTransform {
            let angle = progress * 2 * .pi + Double(item.id) * 0.1
            let scale = 0.8 + 0.4 * sin(time + Double(item.id))
            
            // 3D 변환 매트릭스 직접 계산 (GPU 최적화)
            let cosAngle = cos(angle)
            let sinAngle = sin(angle)
            
            return ProjectionTransform(CGAffineTransform(
                a: cosAngle * scale,
                b: sinAngle * scale,
                c: -sinAngle * scale,
                d: cosAngle * scale,
                tx: 0,
                ty: 0
            ))
        }
        
        var body: some View {
            RoundedRectangle(cornerRadius: 8)
                .fill(
                    LinearGradient(
                        colors: [.blue, .purple],
                        startPoint: .topLeading,
                        endPoint: .bottomTrailing
                    )
                )
                .frame(width: 40, height: 40)
                .transformEffect(transform)
                // GPU 가속 3D 변환 추가
                .rotation3DEffect(
                    .radians(progress * 2 * .pi),
                    axis: (x: 1, y: 1, z: 0)
                )
                .position(
                    x: 200 + 100 * cos(progress * 2 * .pi + Double(item.id) * 0.2),
                    y: 200 + 100 * sin(progress * 2 * .pi + Double(item.id) * 0.2)
                )
        }
    }
    
    struct AnimationItem: Identifiable, Hashable {
        let id: Int
    }
    

     

    4️⃣ 메모리 사용량 최적화

    // 메모리 효율적인 애니메이션 관리
    class MemoryEfficientAnimationManager: ObservableObject {
        @Published var visibleItems: [AnimationItem] = []
        private var allItems: [AnimationItem] = []
        private var visibilityBounds: CGRect = .zero
        
        init() {
            // 미리 계산된 애니메이션 데이터
            allItems = (0..<1000).map { AnimationItem(id: $0) }
            updateVisibleItems()
        }
        
        func updateVisibilityBounds(_ bounds: CGRect) {
            visibilityBounds = bounds
            updateVisibleItems()
        }
        
        private func updateVisibleItems() {
            // 화면에 보이는 아이템들만 메모리에 유지
            visibleItems = allItems.filter { item in
                let itemFrame = calculateItemFrame(for: item)
                return visibilityBounds.intersects(itemFrame)
            }
        }
        
        private func calculateItemFrame(for item: AnimationItem) -> CGRect {
            // 아이템의 예상 위치 계산 (실제 렌더링 없이)
            let x = CGFloat(item.id % 10) * 60
            let y = CGFloat(item.id / 10) * 60
            return CGRect(x: x, y: y, width: 50, height: 50)
        }
    }
    
    struct MemoryOptimizedAnimationView: View {
        @StateObject private var manager = MemoryEfficientAnimationManager()
        @State private var animationOffset: CGFloat = 0
        
        var body: some View {
            GeometryReader { geometry in
                ScrollView {
                    LazyVStack(spacing: 10) {
                        ForEach(manager.visibleItems) { item in
                            OptimizedListItem(item: item, offset: animationOffset)
                                .onAppear {
                                    // 아이템이 나타날 때만 애니메이션 활성화
                                    scheduleItemAnimation(for: item)
                                }
                        }
                    }
                    .background(
                        // 스크롤 영역 감지용 투명 뷰
                        Color.clear
                            .onReceive(NotificationCenter.default.publisher(for: UIScrollView.didScrollNotification)) { _ in
                                manager.updateVisibilityBounds(geometry.frame(in: .global))
                            }
                    )
                }
            }
            .onAppear {
                withAnimation(.linear(duration: 3.0).repeatForever(autoreverses: true)) {
                    animationOffset = 50
                }
            }
        }
        
        private func scheduleItemAnimation(for item: AnimationItem) {
            // 아이템별 개별 애니메이션 스케줄링
            let delay = Double(item.id % 10) * 0.1
            
            DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
                withAnimation(.easeInOut(duration: 1.0)) {
                    // 개별 아이템 애니메이션 로직
                }
            }
        }
    }
    
    struct OptimizedListItem: View {
        let item: AnimationItem
        let offset: CGFloat
        
        var body: some View {
            RoundedRectangle(cornerRadius: 12)
                .fill(Color.blue.opacity(0.8))
                .frame(height: 50)
                .offset(x: offset, y: 0)
                .padding(.horizontal)
                // 뷰의 투명도가 0에 가까우면 렌더링 스킵
                .opacity(offset == 0 ? 1.0 : max(0.1, 1.0 - abs(offset) / 100))
        }
    }

     

    5️⃣ Observable과 Observation 프레임워크 활용

    import Observation
    import SwiftUI
    
    // iOS 17+ Observation 프레임워크로 성능 최적화
    @Observable
    class ModernAnimationState {
        var animationProgress: Double = 0
        var animatedItems: [ModernAnimationItem] = []
        var isAnimating = false
        
        // 필요한 부분만 업데이트되도록 세분화된 상태 관리
        var backgroundOpacity: Double = 1.0
        var foregroundScale: Double = 1.0
        var rotationAngle: Double = 0
        
        func startAnimation() {
            isAnimating = true
            
            // 각 속성별 개별 애니메이션으로 렌더링 최적화
            withAnimation(.easeInOut(duration: 2.0)) {
                backgroundOpacity = 0.5
            }
            
            withAnimation(.spring(duration: 1.5, bounce: 0.3)) {
                foregroundScale = 1.2
            }
            
            withAnimation(.linear(duration: 3.0).repeatForever(autoreverses: false)) {
                rotationAngle = 360
            }
        }
    }
    
    @Observable
    class ModernAnimationItem {
        let id: UUID = UUID()
        var position: CGPoint = .zero
        var velocity: CGPoint = .zero
        var isVisible = true
        
        func updatePhysics(deltaTime: TimeInterval) {
            // 물리 시뮬레이션 로직
            position.x += velocity.x * deltaTime
            position.y += velocity.y * deltaTime
            
            // 경계 체크
            if position.x < 0 || position.x > 400 {
                velocity.x *= -0.8
            }
            if position.y < 0 || position.y > 600 {
                velocity.y *= -0.8
            }
        }
    }
    
    struct ModernOptimizedAnimationView: View {
        @State private var animationState = ModernAnimationState()
        
        var body: some View {
            TimelineView(.animation) { timeline in
                ZStack {
                    // 배경 레이어 (독립적으로 애니메이션)
                    BackgroundLayer(opacity: animationState.backgroundOpacity)
                    
                    // 전경 레이어 (독립적으로 애니메이션)
                    ForegroundLayer(
                        scale: animationState.foregroundScale,
                        rotation: animationState.rotationAngle
                    )
                    
                    // 동적 아이템들
                    ForEach(animationState.animatedItems, id: \.id) { item in
                        if item.isVisible {
                            ModernAnimatedItemView(item: item)
                        }
                    }
                }
            }
            .onAppear {
                animationState.startAnimation()
            }
        }
    }
    
    struct BackgroundLayer: View {
        let opacity: Double
        
        var body: some View {
            Rectangle()
                .fill(LinearGradient(
                    colors: [.blue.opacity(0.3), .purple.opacity(0.3)],
                    startPoint: .topLeading,
                    endPoint: .bottomTrailing
                ))
                .opacity(opacity)
                .ignoresSafeArea()
        }
    }
    
    struct ForegroundLayer: View {
        let scale: Double
        let rotation: Double
        
        var body: some View {
            Circle()
                .fill(Color.white.opacity(0.8))
                .frame(width: 100, height: 100)
                .scaleEffect(scale)
                .rotationEffect(.degrees(rotation))
        }
    }
    
    struct ModernAnimatedItemView: View {
        let item: ModernAnimationItem
        
        var body: some View {
            Circle()
                .fill(Color.red)
                .frame(width: 20, height: 20)
                .position(item.position)
                // GPU 최적화된 변환 사용
                .drawingGroup() // Core Animation 레이어로 렌더링
        }
    }

     

    6️⃣ Metal과의 연동

    극한의 성능이 필요한 경우 Metal과 연동하여 GPU를 직접 활용할 수 있습니다.

    import Metal
    import MetalKit
    import SwiftUI
    
    // Metal 렌더링과 SwiftUI 통합
    struct MetalAnimationView: UIViewRepresentable {
        func makeUIView(context: Context) -> MTKView {
            let metalView = MTKView()
            metalView.device = MTLCreateSystemDefaultDevice()
            metalView.delegate = context.coordinator
            metalView.preferredFramesPerSecond = 120 // ProMotion 활용
            return metalView
        }
        
        func updateUIView(_ uiView: MTKView, context: Context) {
            // SwiftUI 상태 변경 시 Metal 렌더러 업데이트
        }
        
        func makeCoordinator() -> MetalRenderer {
            return MetalRenderer()
        }
    }
    
    class MetalRenderer: NSObject, MTKViewDelegate {
        private var device: MTLDevice!
        private var commandQueue: MTLCommandQueue!
        private var pipelineState: MTLRenderPipelineState!
        
        override init() {
            super.init()
            setupMetal()
        }
        
        private func setupMetal() {
            device = MTLCreateSystemDefaultDevice()
            commandQueue = device.makeCommandQueue()
            
            // 셰이더 파이프라인 설정
            let library = device.makeDefaultLibrary()
            let vertexFunction = library?.makeFunction(name: "vertex_main")
            let fragmentFunction = library?.makeFunction(name: "fragment_main")
            
            let pipelineDescriptor = MTLRenderPipelineDescriptor()
            pipelineDescriptor.vertexFunction = vertexFunction
            pipelineDescriptor.fragmentFunction = fragmentFunction
            pipelineDescriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
            
            do {
                pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor)
            } catch {
                print("파이프라인 생성 실패: \(error)")
            }
        }
        
        func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
            // 화면 크기 변경 처리
        }
        
        func draw(in view: MTKView) {
            guard let drawable = view.currentDrawable,
                  let passDescriptor = view.currentRenderPassDescriptor,
                  let commandBuffer = commandQueue.makeCommandBuffer(),
                  let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: passDescriptor) else {
                return
            }
            
            renderEncoder.setRenderPipelineState(pipelineState)
            
            // GPU에서 직접 애니메이션 계산 및 렌더링
            // (실제 구현에서는 버텍스 버퍼와 셰이더 코드가 필요)
            
            renderEncoder.endEncoding()
            commandBuffer.present(drawable)
            commandBuffer.commit()
        }
    }
    
    // SwiftUI에서 Metal 뷰 사용
    struct MetalIntegratedView: View {
        var body: some View {
            VStack {
                Text("Metal 가속 애니메이션")
                    .font(.title)
                    .padding()
                
                MetalAnimationView()
                    .frame(height: 300)
                    .background(Color.black)
                    .cornerRadius(12)
            }
            .padding()
        }
    }

     


    성능 측정 및 프로파일링

    실제로 최적화 전후의 성능 차이를 측정해보겠습니다.

    import SwiftUI
    
    struct PerformanceComparison: View {
        @StateObject private var fpsMonitor = FPSCounter()
        @StateObject private var gpuMonitor = GPUMonitor()
        @State private var isOptimized = false
        @State private var animationOffset: CGFloat = 0
        
        var body: some View {
            VStack(spacing: 20) {
                // 성능 지표 표시
                HStack {
                    PerformanceIndicator(
                        title: "FPS",
                        value: "\(fpsMonitor.fps, specifier: "%.1f")",
                        color: fpsColor
                    )
                    
                    PerformanceIndicator(
                        title: "GPU",
                        value: "\(gpuMonitor.gpuUtilization, specifier: "%.1f")%",
                        color: gpuColor
                    )
                }
                .padding()
                
                // 최적화 토글
                Toggle("최적화 모드", isOn: $isOptimized)
                    .padding(.horizontal)
                
                // 테스트 애니메이션 뷰
                if isOptimized {
                    OptimizedTestView(offset: animationOffset)
                } else {
                    UnoptimizedTestView(offset: animationOffset)
                }
                
                Spacer()
            }
            .onAppear {
                startPerformanceTest()
            }
        }
        
        private var fpsColor: Color {
            switch fpsMonitor.fps {
            case 55...Double.infinity: return .green
            case 45..<55: return .yellow
            default: return .red
            }
        }
        
        private var gpuColor: Color {
            switch gpuMonitor.gpuUtilization {
            case 0..<30: return .green
            case 30..<70: return .yellow
            default: return .red
            }
        }
        
        private func startPerformanceTest() {
            withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: true)) {
                animationOffset = 100
            }
        }
    }
    
    struct PerformanceIndicator: View {
        let title: String
        let value: String
        let color: Color
        
        var body: some View {
            VStack {
                Text(title)
                    .font(.caption)
                    .foregroundColor(.secondary)
                Text(value)
                    .font(.title2.bold())
                    .foregroundColor(color)
            }
            .padding()
            .background(Color.gray.opacity(0.1))
            .cornerRadius(8)
        }
    }
    
    struct UnoptimizedTestView: View {
        let offset: CGFloat
        
        var body: some View {
            ScrollView {
                LazyVStack {
                    ForEach(0..<20, id: \.self) { index in
                        // 🚫 비최적화 버전
                        RoundedRectangle(cornerRadius: 10)
                            .fill(
                                // 매 프레임마다 새로운 그라디언트 생성
                                LinearGradient(
                                    colors: generateRandomColors(),
                                    startPoint: .leading,
                                    endPoint: .trailing
                                )
                            )
                            .frame(height: 60)
                            .overlay(
                                // 복잡한 경로 애니메이션
                                ComplexAnimatedShape(progress: offset / 100)
                                    .stroke(Color.white, lineWidth: 2)
                            )
                            .shadow(
                                color: .black.opacity(0.3),
                                radius: 5 + offset / 20,
                                x: offset / 50,
                                y: offset / 50
                            )
                            .scaleEffect(1.0 + offset / 1000)
                            .rotationEffect(.degrees(Double(offset) * 0.5))
                            .padding(.horizontal)
                    }
                }
            }
        }
        
        private func generateRandomColors() -> [Color] {
            return (0..<5).map { _ in
                Color(
                    red: .random(in: 0...1),
                    green: .random(in: 0...1),
                    blue: .random(in: 0...1)
                )
            }
        }
    }
    
    struct OptimizedTestView: View {
        let offset: CGFloat
        
        // ✅ 정적 그라디언트 미리 계산
        private static let staticGradient = LinearGradient(
            colors: [.blue, .purple, .pink],
            startPoint: .leading,
            endPoint: .trailing
        )
        
        var body: some View {
            ScrollView {
                LazyVStack {
                    ForEach(0..<20, id: \.self) { index in
                        // ✅ 최적화 버전
                        RoundedRectangle(cornerRadius: 10)
                            .fill(Self.staticGradient)
                            .frame(height: 60)
                            // GPU 가속 변환만 사용
                            .rotation3DEffect(
                                .degrees(Double(offset) * 0.5),
                                axis: (x: 0, y: 1, z: 0)
                            )
                            .scaleEffect(1.0 + offset / 1000)
                            .padding(.horizontal)
                            // 렌더링 최적화
                            .drawingGroup()
                    }
                }
            }
        }
    }
    
    struct ComplexAnimatedShape: Shape {
        let progress: CGFloat
        
        func path(in rect: CGRect) -> Path {
            var path = Path()
            let steps = 20
            
            for i in 0..<steps {
                let angle = Double(i) / Double(steps) * 2 * .pi
                let radius = rect.width / 4 * (1 + progress * sin(angle * 2))
                let x = rect.midX + cos(angle) * radius
                let y = rect.midY + sin(angle) * radius
                
                if i == 0 {
                    path.move(to: CGPoint(x: x, y: y))
                } else {
                    path.addLine(to: CGPoint(x: x, y: y))
                }
            }
            path.closeSubpath()
            return path
        }
    }

     

     

    실제 테스트 결과를 바탕으로 최적화 효과를 분석해보겠습니다.

    // 성능 벤치마크 도구
    class AnimationBenchmark: ObservableObject {
        @Published var results: BenchmarkResults = BenchmarkResults()
        
        func runBenchmark() async {
            await withTaskGroup(of: Void.self) { group in
                group.addTask {
                    await self.benchmarkUnoptimized()
                }
                
                group.addTask {
                    await self.benchmarkOptimized()
                }
            }
        }
        
        @MainActor
        private func benchmarkUnoptimized() async {
            let startTime = CFAbsoluteTimeGetCurrent()
            var frameCount = 0
            let duration: Double = 5.0 // 5초간 테스트
            
            while CFAbsoluteTimeGetCurrent() - startTime < duration {
                // 비최적화 애니메이션 시뮬레이션
                performHeavyCalculations()
                frameCount += 1
                
                try? await Task.sleep(nanoseconds: 16_666_666) // ~60fps
            }
            
            let actualDuration = CFAbsoluteTimeGetCurrent() - startTime
            results.unoptimizedFPS = Double(frameCount) / actualDuration
        }
        
        @MainActor
        private func benchmarkOptimized() async {
            let startTime = CFAbsoluteTimeGetCurrent()
            var frameCount = 0
            let duration: Double = 5.0
            
            while CFAbsoluteTimeGetCurrent() - startTime < duration {
                // 최적화된 애니메이션 시뮬레이션
                performOptimizedCalculations()
                frameCount += 1
                
                try? await Task.sleep(nanoseconds: 16_666_666) // ~60fps
            }
            
            let actualDuration = CFAbsoluteTimeGetCurrent() - startTime
            results.optimizedFPS = Double(frameCount) / actualDuration
        }
        
        private func performHeavyCalculations() {
            // 비최적화 계산 시뮬레이션
            var result = 0.0
            for i in 0..<1000 {
                result += sin(Double(i)) * cos(Double(i))
            }
        }
        
        private func performOptimizedCalculations() {
            // 최적화된 계산 시뮬레이션 (사전 계산된 값 사용)
            let _ = cachedCalculationResults[0]
        }
        
        private let cachedCalculationResults: [Double] = {
            return (0..<1000).map { i in
                sin(Double(i)) * cos(Double(i))
            }
        }()
    }
    
    struct BenchmarkResults {
        var unoptimizedFPS: Double = 0
        var optimizedFPS: Double = 0
        
        var improvement: Double {
            guard unoptimizedFPS > 0 else { return 0 }
            return (optimizedFPS - unoptimizedFPS) / unoptimizedFPS * 100
        }
    }
    
    struct BenchmarkView: View {
        @StateObject private var benchmark = AnimationBenchmark()
        @State private var isRunning = false
        
        var body: some View {
            VStack(spacing: 20) {
                Text("애니메이션 성능 벤치마크")
                    .font(.title.bold())
                
                if !isRunning {
                    Button("벤치마크 시작") {
                        runBenchmark()
                    }
                    .buttonStyle(.borderedProminent)
                } else {
                    ProgressView("테스트 진행 중...")
                }
                
                if benchmark.results.optimizedFPS > 0 {
                    VStack(alignment: .leading, spacing: 10) {
                        HStack {
                            Text("비최적화:")
                            Spacer()
                            Text("\(benchmark.results.unoptimizedFPS, specifier: "%.1f") fps")
                                .foregroundColor(.red)
                        }
                        
                        HStack {
                            Text("최적화:")
                            Spacer()
                            Text("\(benchmark.results.optimizedFPS, specifier: "%.1f") fps")
                                .foregroundColor(.green)
                        }
                        
                        Divider()
                        
                        HStack {
                            Text("성능 개선:")
                            Spacer()
                            Text("+\(benchmark.results.improvement, specifier: "%.1f")%")
                                .foregroundColor(.blue)
                                .font(.headline.bold())
                        }
                    }
                    .padding()
                    .background(Color.gray.opacity(0.1))
                    .cornerRadius(12)
                }
                
                Spacer()
            }
            .padding()
        }
        
        private func runBenchmark() {
            isRunning = true
            Task {
                await benchmark.runBenchmark()
                await MainActor.run {
                    isRunning = false
                }
            }
        }
    }

     


    적용 체크리스트

    ✅ 애니메이션 최적화 체크리스트

    // 실무에서 바로 적용할 수 있는 최적화 체크리스트
    struct AnimationOptimizationChecklist {
        
        // 1. ✅ 렌더링 레이어 분리
        static func checkLayerSeparation() -> Bool {
            // 정적 콘텐츠와 동적 콘텐츠가 분리되어 있는가?
            // 변경되지 않는 배경 요소들이 별도 뷰로 구성되어 있는가?
            return true
        }
        
        // 2. ✅ GPU 가속 활용
        static func checkGPUAcceleration() -> Bool {
            // 3D Transform 사용 여부
            // drawingGroup() 적용 여부
            // Metal 연동 고려 여부
            return true
        }
        
        // 3. ✅ 메모리 최적화
        static func checkMemoryOptimization() -> Bool {
            // 불필요한 뷰 생성 방지
            // 객체 재사용 패턴 적용
            // 메모리 누수 방지
            return true
        }
        
        // 4. ✅ 계산 최적화
        static func checkCalculationOptimization() -> Bool {
            // 사전 계산된 값 사용
            // 캐싱 전략 적용
            // 불필요한 재계산 방지
            return true
        }
    }
    
    // 성능 모니터링 헬퍼
    struct PerformanceHelper {
        static func measureExecutionTime<T>(
            operation: () throws -> T
        ) rethrows -> (result: T, time: TimeInterval) {
            let startTime = CFAbsoluteTimeGetCurrent()
            let result = try operation()
            let endTime = CFAbsoluteTimeGetCurrent()
            return (result, endTime - startTime)
        }
        
        static func logPerformanceWarning(fps: Double, threshold: Double = 55.0) {
            if fps < threshold {
                print("⚠️ 성능 경고: FPS \(fps) < \(threshold)")
            }
        }
    }
    

     

    ⚠️ 흔한 실수들과 해결책

    // 🚫 실수 1: 과도한 상태 변경
    struct BadAnimationView: View {
        @State private var animationValues: [Double] = Array(repeating: 0, count: 100)
        
        var body: some View {
            VStack {
                ForEach(0..<animationValues.count, id: \.self) { index in
                    Rectangle()
                        .frame(height: animationValues[index])
                        .onReceive(Timer.publish(every: 0.016, on: .main, in: .common).autoconnect()) { _ in
                            // 🚫 매 프레임마다 100개의 상태 변경!
                            animationValues[index] = Double.random(in: 10...100)
                        }
                }
            }
        }
    }
    
    // ✅ 해결책: 상태 변경 최소화
    struct GoodAnimationView: View {
        @State private var globalAnimationTime: Double = 0
        
        var body: some View {
            TimelineView(.animation) { timeline in
                VStack {
                    ForEach(0..<100, id: \.self) { index in
                        Rectangle()
                            .frame(height: calculateHeight(for: index, time: globalAnimationTime))
                    }
                }
            }
            .onAppear {
                withAnimation(.linear(duration: 2.0).repeatForever(autoreverses: false)) {
                    globalAnimationTime = 1.0
                }
            }
        }
        
        private func calculateHeight(for index: Int, time: Double) -> Double {
            // 단일 시간 값으로 모든 높이 계산
            return 50 + 30 * sin(time * 2 * .pi + Double(index) * 0.1)
        }
    }
    

     

    ⚠️ iOS 버전별 최적화 팁

    // iOS 버전별 최적화 전략
    struct VersionSpecificOptimizations {
        
        @available(iOS 17.0, *)
        static func iOS17Optimizations() -> some View {
            // iOS 17+ 최신 기능 활용
            return AnyView(
                Rectangle()
                    .phaseAnimator([false, true]) { content, phase in
                        content
                            .scaleEffect(phase ? 1.2 : 1.0)
                    } animation: { phase in
                        .bouncy(duration: 1.0)
                    }
            )
        }
        
        @available(iOS 16.0, *)
        static func iOS16Optimizations() -> some View {
            // iOS 16+ Layout 프로토콜 활용
            return AnyView(
                Rectangle()
                    .overlay(alignment: .center) {
                        Text("Optimized")
                    }
            )
        }
        
        static func legacyOptimizations() -> some View {
            // iOS 15 이하 호환성
            return AnyView(
                Rectangle()
                    .overlay(
                        Text("Legacy")
                            .position(x: 100, y: 50)
                    )
            )
        }
    }
    

     


    Conclusion

    SwiftUI 애니메이션 성능 최적화는 사용자 경험의 핵심이자 앱의 품질을 결정하는 중요한 요소입니다.

     

    핵심 포인트를 정리해볼까요?

     

    🎯 60fps 보장을 위한 핵심 전략

    • 렌더링 레이어 분리로 불필요한 업데이트 방지
    • GPU 가속 활용으로 렌더링 성능 극대화
    • 메모리 최적화로 원활한 애니메이션 유지
    • 실시간 성능 모니터링으로 문제 조기 발견

    🎯 실무 적용 가능한 최적화 기법

    • TimelineView와 Observable로 현대적인 애니메이션 구현
    • Metal 연동으로 극한 성능이 필요한 경우 대응
    • 버전별 최적화 전략으로 폭넓은 기기 지원
    • 체계적인 성능 측정과 벤치마킹

    🎯 iOS 최신 기능과의 시너지

    • ProMotion 디스플레이의 120fps 활용
    • iOS 17+ PhaseAnimator로 더 부드러운 애니메이션
    • Observation 프레임워크로 효율적인 상태 관리

    가장 중요한 것은 사용자가 체감할 수 있는 부드러움입니다.

    기술적인 최적화도 중요하지만, 실제 사용자가 앱을 사용할 때 "와, 정말 부드럽다!"라고 느낄 수 있어야 진정한 성공이에요.

     

    특히 최근 들어 사용자들의 애니메이션 품질에 대한 기대치가 매우 높아졌습니다.

    네이티브 iOS 앱 수준의 부드러운 애니메이션을 SwiftUI로도 충분히 구현할 수 있으니, 이번 기회에 여러분의 앱도 한 단계 업그레이드해보세요! 🚀✨

     


    References

     

    Animation | Apple Developer Documentation

    The way a view changes over time to create a smooth visual transition from one state to another.

    developer.apple.com

     

    About Core Animation

    About Core Animation Core Animation is a graphics rendering and animation infrastructure available on both iOS and OS X that you use to animate the views and other visual elements of your app. With Core Animation, most of the work required to draw each fr

    developer.apple.com

     

    Metal Performance Shaders | Apple Developer Documentation

    Optimize graphics and compute performance with kernels that are fine-tuned for the unique characteristics of each Metal GPU family.

    developer.apple.com

    'SwiftUI' 카테고리의 다른 글

    SwiftUI Preview - Thunk  (1) 2025.11.29
    SwiftUI's diffing  (5) 2025.09.27
    Bring Swift Charts to the third dimension (feat. WWDC 2025)  (6) 2025.07.12
    What's new in SwiftUI (feat. WWDC 2025)  (3) 2025.06.11
    Marquee  (0) 2025.03.18
Designed by Tistory.