SwiftUI phaseAnimator vs keyframeAnimator 완벽 가이드: 다단계 애니메이션 마스터하기

SwiftUI의 phaseAnimator와 keyframeAnimator를 실전 예제로 비교합니다. iOS 17+ 다단계 시퀀스, 키프레임 트랙, trigger 함정 해결법까지 한 번에 정리했습니다.

SwiftUI phaseAnimator vs keyframeAnimator 2026

솔직히 말해서, iOS 17이 나왔을 때 가장 반가웠던 건 새 그림자 API도 아니고 Observation 매크로도 아니었습니다. phaseAnimatorkeyframeAnimator 두 친구였죠. iOS 26과 Xcode 26이 표준이 된 2026년 현재, 이 두 API는 이제 “있으면 좋은 것”이 아니라 SwiftUI로 정교한 인터랙션을 만들 때 거의 무조건 만나게 되는 도구가 됐습니다.

그런데 현장에서 코드 리뷰를 하다 보면 같은 질문을 정말 자주 받습니다. “언제 phaseAnimator를 쓰고, 언제 keyframeAnimator를 써야 하나요?” 이름도 비슷하고 시그니처도 닮아서 헷갈리는 게 이해가 됩니다. 이 글에선 두 API의 동작 방식과 차이, 실전 패턴, 그리고 (제가 직접 데여 본) 함정들까지 한 번에 정리해 보겠습니다.

1. 왜 phaseAnimator와 keyframeAnimator가 필요한가

전통적인 withAnimation { ... } 블록은 현재 상태에서 다음 상태로 가는 단일 전환만 표현할 수 있습니다. 한두 단계 정도면 괜찮은데, “바운스 → 회전 → 페이드 아웃” 같은 다단계 시퀀스를 만들려면 이야기가 달라지죠.

예전엔 DispatchQueue.main.asyncAfter를 동원하거나 withAnimation을 중첩해서 어찌어찌 흉내 냈는데, 그러다 보면 코드는 누더기가 되고 타이밍은 자꾸 어긋났습니다. (한 번은 토스트 알림 하나에 비동기 호출이 다섯 개 들어간 코드를 본 적도 있습니다…)

iOS 17의 두 신규 API는 이 문제를 다른 각도에서 풉니다.

  • phaseAnimator: 미리 정의한 “단계(phase)”를 순차적으로 거치며, SwiftUI가 단계 간 전환을 자동으로 애니메이션합니다.
  • keyframeAnimator: 시간축 위의 키프레임을 정의하고, 여러 속성(스케일, 회전, 오프셋 등)을 독립적인 트랙에서 정밀하게 보간합니다.

2. 빠른 비교: 어떤 상황에 어떤 API를 쓸까

구분phaseAnimatorkeyframeAnimator
애니메이션 모델이산적인 상태 간 전환시간축 기반 값 보간
반복 재생기본값(연속 모드)으로 가능트리거 변경 시 1회 재생
트리거선택(연속/트리거 두 가지 변형)필수
다중 속성 제어단계별 통합 상태속성마다 독립 트랙
학습 곡선낮음중간
대표 활용버튼 강조, 알림 펄스, 로딩 표시온보딩 모션, 캐릭터 시퀀스, 로고 인트로

한 줄로 요약하자면 이렇습니다. “상태를 순서대로 보여주고 싶으면 phaseAnimator, 시간을 정밀하게 디자인하고 싶으면 keyframeAnimator”. 저는 보통 이 한 문장으로 의사 결정을 시작합니다.

3. phaseAnimator 기본 사용법

phaseAnimatorSequence 프로토콜을 따르는 컬렉션을 받아서 각 요소를 “단계”로 사용합니다. 가장 간단한 형태는 Bool 두 값을 토글하는 거예요.

import SwiftUI

struct PulsingHeart: View {
    var body: some View {
        Image(systemName: "heart.fill")
            .font(.system(size: 80))
            .foregroundStyle(.pink)
            .phaseAnimator([false, true]) { content, isPulsing in
                content
                    .scaleEffect(isPulsing ? 1.2 : 1.0)
                    .opacity(isPulsing ? 1.0 : 0.7)
            } animation: { _ in
                .easeInOut(duration: 0.8)
            }
    }
}

이게 끝입니다. 단계 배열 [false, true]를 무한히 순환하면서 하트 아이콘이 부드럽게 박동합니다. 별도의 타이머나 @State가 전혀 필요 없죠. 처음 봤을 때 좀 충격이었습니다.

3.1 enum으로 단계 정의하기

실전에서는 Bool보다 의미 있는 이름을 가진 CaseIterable enum이 훨씬 낫습니다. 가독성도 가독성이지만, 나중에 단계 하나를 끼워 넣을 때 차이가 큽니다.

enum NotificationPhase: CaseIterable {
    case idle, expand, settle

    var scale: CGFloat {
        switch self {
        case .idle: 1.0
        case .expand: 1.4
        case .settle: 1.05
        }
    }

    var rotation: Angle {
        switch self {
        case .idle: .zero
        case .expand: .degrees(-15)
        case .settle: .degrees(0)
        }
    }
}

struct BellIcon: View {
    var body: some View {
        Image(systemName: "bell.fill")
            .font(.system(size: 60))
            .phaseAnimator(NotificationPhase.allCases) { content, phase in
                content
                    .scaleEffect(phase.scale)
                    .rotationEffect(phase.rotation)
            } animation: { phase in
                switch phase {
                case .idle: .easeIn(duration: 0.4)
                case .expand: .spring(response: 0.3, dampingFraction: 0.4)
                case .settle: .easeOut(duration: 0.5)
                }
            }
    }
}

여기서 핵심은 각 단계마다 다른 애니메이션 곡선을 줄 수 있다는 점입니다. 위 예시에선 expand 단계만 스프링을 써서 “튕겨 나오는” 느낌을 살렸어요. 이게 phaseAnimator가 단순한 토글 이상으로 강력해지는 이유입니다.

3.2 trigger로 한 번만 재생하기

좋아요 버튼을 탭했을 때처럼 이벤트가 발생한 순간에만 시퀀스를 재생하고 싶다면? trigger 파라미터를 씁니다.

struct LikeButton: View {
    @State private var likeCount = 0

    var body: some View {
        Button {
            likeCount += 1
        } label: {
            Image(systemName: "hand.thumbsup.fill")
                .font(.system(size: 40))
                .foregroundStyle(.blue)
                .phaseAnimator(
                    [1.0, 1.6, 0.9, 1.0],
                    trigger: likeCount
                ) { content, scale in
                    content.scaleEffect(scale)
                } animation: { _ in
                    .spring(response: 0.35, dampingFraction: 0.5)
                }
        }
    }
}

trigger 값(여기서는 likeCount)이 변할 때마다 단계 시퀀스가 처음부터 끝까지 한 번 재생됩니다. 무한 반복이 아니라 “한 번 강하게 강조”가 필요할 때 정말 잘 맞습니다.

4. keyframeAnimator 기본 사용법

keyframeAnimator는 사고방식 자체가 좀 다릅니다. 단계가 아니라 시간축 위의 키프레임을 정의하고, SwiftUI가 매 프레임마다 보간된 값을 콘텐츠에 적용해 줍니다. After Effects를 만져 본 분이라면 굉장히 익숙한 모델일 거예요.

struct AnimationValues {
    var scale: CGFloat = 1.0
    var verticalOffset: CGFloat = 0
    var rotation: Angle = .zero
    var opacity: Double = 1.0
}

struct CelebrationLogo: View {
    @State private var celebrate = 0

    var body: some View {
        VStack {
            Image(systemName: "star.fill")
                .font(.system(size: 100))
                .foregroundStyle(.yellow)
                .keyframeAnimator(
                    initialValue: AnimationValues(),
                    trigger: celebrate
                ) { content, value in
                    content
                        .scaleEffect(value.scale)
                        .offset(y: value.verticalOffset)
                        .rotationEffect(value.rotation)
                        .opacity(value.opacity)
                } keyframes: { _ in
                    KeyframeTrack(\.scale) {
                        SpringKeyframe(1.4, duration: 0.3, spring: .bouncy)
                        CubicKeyframe(1.0, duration: 0.4)
                    }
                    KeyframeTrack(\.verticalOffset) {
                        CubicKeyframe(-60, duration: 0.35)
                        SpringKeyframe(0, duration: 0.55, spring: .bouncy)
                    }
                    KeyframeTrack(\.rotation) {
                        LinearKeyframe(.degrees(-20), duration: 0.2)
                        LinearKeyframe(.degrees(20), duration: 0.2)
                        CubicKeyframe(.zero, duration: 0.3)
                    }
                    KeyframeTrack(\.opacity) {
                        LinearKeyframe(1.0, duration: 0.55)
                        LinearKeyframe(0.6, duration: 0.15)
                        LinearKeyframe(1.0, duration: 0.1)
                    }
                }

            Button("축하하기") { celebrate += 1 }
                .buttonStyle(.borderedProminent)
        }
    }
}

코드를 잘 보면 네 개의 트랙(scale, verticalOffset, rotation, opacity)이 각각 독립적인 타임라인을 가집니다. 이게 keyframeAnimator의 핵심 사상이에요. 회전이 끝나기도 전에 스케일이 커지거나 줄어들 수 있고, 투명도는 또 다른 리듬으로 변할 수 있습니다.

4.1 키프레임 종류 선택하기

SwiftUI는 네 가지 기본 키프레임을 제공합니다.

  • LinearKeyframe: 선형 보간. 일정한 속도가 필요할 때.
  • CubicKeyframe: 부드러운 곡선 보간. 자연스러운 가감속이 기본값.
  • SpringKeyframe: 스프링 물리 모델. 튕기는 느낌을 정밀하게 제어.
  • MoveKeyframe: 보간 없이 즉시 점프. 시퀀스 중간에 “순간 이동”이 필요할 때.

개인적으로는 CubicKeyframe을 기본값처럼 쓰고, 강조하고 싶은 한두 군데에만 SpringKeyframe을 섞는 편이 가장 무난했습니다.

5. 실전 패턴 1: 다단계 토스트 알림

토스트 알림처럼 나타남 → 잠시 정지 → 사라짐 시퀀스가 필요한 경우, phaseAnimator의 trigger 패턴이 깔끔합니다.

enum ToastPhase: CaseIterable {
    case hidden, visible, fadingOut

    var offset: CGFloat {
        switch self {
        case .hidden: -80
        case .visible, .fadingOut: 0
        }
    }
    var opacity: Double {
        switch self {
        case .hidden, .fadingOut: 0
        case .visible: 1
        }
    }
}

struct ToastView: View {
    @State private var showCount = 0

    var body: some View {
        VStack {
            Text("저장되었습니다")
                .padding()
                .background(.thinMaterial, in: .capsule)
                .phaseAnimator(
                    ToastPhase.allCases,
                    trigger: showCount
                ) { content, phase in
                    content
                        .offset(y: phase.offset)
                        .opacity(phase.opacity)
                } animation: { phase in
                    switch phase {
                    case .hidden: .linear(duration: 0)
                    case .visible: .spring(response: 0.4, dampingFraction: 0.7)
                    case .fadingOut: .easeIn(duration: 0.4).delay(1.5)
                    }
                }

            Button("토스트 표시") { showCount += 1 }
        }
    }
}

주목할 점은 fadingOut 단계의 delay(1.5)입니다. phaseAnimator는 단계마다 다른 애니메이션을 줄 수 있으니까, “1.5초간 머물렀다가 사라진다”를 외부 타이머 없이 한 줄로 표현할 수 있어요. 이전 같았으면 Task.sleep이 들어갔을 자리죠.

6. 실전 패턴 2: 정교한 로고 인트로

스플래시 화면 로고가 화면 밖에서 들어와, 잠깐 회전하면서 커지고, 마지막엔 살짝 진동하며 정착해야 한다고 합시다. 이런 다중 속성·다중 타이밍 시퀀스는 정확히 keyframeAnimator의 영역입니다.

struct LogoIntroValues {
    var x: CGFloat = -300
    var scale: CGFloat = 0.4
    var rotation: Angle = .degrees(-90)
}

struct LogoIntro: View {
    @State private var play = 0

    var body: some View {
        Image("AppLogo")
            .resizable()
            .frame(width: 140, height: 140)
            .keyframeAnimator(
                initialValue: LogoIntroValues(),
                trigger: play
            ) { content, value in
                content
                    .offset(x: value.x)
                    .scaleEffect(value.scale)
                    .rotationEffect(value.rotation)
            } keyframes: { _ in
                KeyframeTrack(\.x) {
                    SpringKeyframe(0, duration: 0.7, spring: .smooth)
                }
                KeyframeTrack(\.scale) {
                    CubicKeyframe(1.2, duration: 0.6)
                    SpringKeyframe(1.0, duration: 0.4, spring: .bouncy)
                }
                KeyframeTrack(\.rotation) {
                    CubicKeyframe(.degrees(15), duration: 0.5)
                    SpringKeyframe(.zero, duration: 0.5, spring: .bouncy)
                }
            }
            .onAppear { play += 1 }
    }
}

각 트랙이 동시에 시작되지만 끝나는 시점은 다릅니다. 회전이 0.5초까지 “과회전”하는 동안 스케일은 이미 1.2까지 커지고, 마지막 0.4~0.5초에 두 속성이 동시에 정착하면서 안정감 있는 마무리가 만들어집니다. 이런 “겹쳐서 마무리되는 느낌”이야말로 좋은 모션의 비밀이라고 생각해요.

7. 자주 빠지는 함정과 해결법

7.1 trigger가 같은 값으로 “재할당”되어도 발동하지 않는다

SwiftUI는 Equatable한 trigger 값의 변경을 감시합니다. 즉, 같은 값을 다시 대입해도 “변화 없음”으로 판단해서 애니메이션이 재생되지 않아요. UUID()를 새로 만들거나 Int를 증가시키는 방식이 가장 안전합니다.

// ❌ 같은 true를 다시 set 해도 재생되지 않음
@State var trigger: Bool = false
func playAgain() { trigger = true }

// ✅ 매번 새 값이 되도록 증가시키기
@State var trigger: Int = 0
func playAgain() { trigger += 1 }

저도 이걸로 30분쯤 날린 적이 있어서, 이젠 거의 반사적으로 Int 카운터를 씁니다.

7.2 phaseAnimator의 단계 사이에서 “끊김”이 보인다

각 단계 간 애니메이션 곡선이 너무 짧거나 종류가 들쭉날쭉할 때 끊김이 두드러집니다. 시각적 연속성을 위해선 인접한 단계 사이에 .spring(...)이나 .easeInOut으로 일관성을 주거나, 더 정밀한 통제가 필요하다면 keyframeAnimator로 옮기는 게 낫습니다.

7.3 keyframeAnimator의 트랙 길이가 다르면?

각 트랙은 자신의 keyframe duration 합이 끝나면 마지막 값으로 고정됩니다. 가장 긴 트랙이 끝나는 순간이 전체 애니메이션의 종료 시점이 되죠. 그러니까 “길이를 맞추겠다”고 의미 없는 빈 키프레임을 추가하지 마세요. 짧은 트랙은 먼저 정착하도록 두는 편이 훨씬 우아합니다.

7.4 매 트리거마다 처음 상태로 “순간 이동”한다

keyframeAnimator는 트리거가 변할 때 initialValue로 리셋한 뒤 다시 키프레임을 따라갑니다. “현재 상태에서 자연스럽게 이어가고 싶다”면 keyframeAnimator는 적절한 도구가 아니에요. 이런 경우엔 withAnimation + @State 조합으로 직접 보간하거나, phaseAnimator의 연속 모드를 검토하세요.

7.5 접근성 “동작 줄이기” 설정 존중하기

iOS 26에서도 @Environment(\.accessibilityReduceMotion)은 여전히 핵심입니다. 화려한 애니메이션을 무조건 재생하지 말고, 이 값이 true일 땐 단순한 페이드나 즉시 전환으로 대체하는 분기를 두는 것이 좋습니다. (앱스토어 리뷰에서 “멀미가 난다”는 별 1점을 받기 전에요.)

struct AccessibleConfetti: View {
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @State private var play = 0

    var body: some View {
        Image(systemName: "party.popper.fill")
            .font(.system(size: 60))
            .keyframeAnimator(
                initialValue: AnimationValues(),
                trigger: play
            ) { content, value in
                if reduceMotion {
                    content.opacity(value.opacity)
                } else {
                    content
                        .scaleEffect(value.scale)
                        .rotationEffect(value.rotation)
                        .opacity(value.opacity)
                }
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    SpringKeyframe(1.3, duration: 0.3, spring: .bouncy)
                    CubicKeyframe(1.0, duration: 0.4)
                }
                KeyframeTrack(\.rotation) {
                    LinearKeyframe(.degrees(20), duration: 0.35)
                    LinearKeyframe(.zero, duration: 0.35)
                }
                KeyframeTrack(\.opacity) {
                    LinearKeyframe(1.0, duration: 0.6)
                    LinearKeyframe(0.7, duration: 0.1)
                    LinearKeyframe(1.0, duration: 0.1)
                }
            }
            .onTapGesture { play += 1 }
    }
}

8. 성능 관점에서의 선택 기준

두 API 모두 SwiftUI 렌더 파이프라인 위에서 동작하고, 일반적인 UI 강조 애니메이션 수준에서는 성능 차이가 사실상 없습니다. 다만 매 프레임마다 호출되는 콘텐츠 클로저 안에서 무거운 계산을 하거나 큰 이미지를 다시 만드는 코드는 둘 다 무조건 피해야 해요. 보간된 값은 읽기만 하고, 변환은 scaleEffect·rotationEffect·offset 같은 가벼운 modifier에 맡기는 것이 원칙입니다.

또 keyframeAnimator는 트랙별로 시간축을 따라 매 프레임 새 값을 만들어 내기 때문에, 애니메이션 중에 print 디버깅을 끼워 넣으면 성능 저하가 눈에 보일 정도로 발생합니다. 디버깅은 .onChange나 Instruments의 SwiftUI 트랙으로 옮기세요.

9. iOS 26에서의 주의 사항

iOS 26은 SwiftUI 애니메이션 API 자체에 큰 변화를 주지는 않았습니다. 하지만 Liquid Glass 같은 시각 효과 위에 phaseAnimator/keyframeAnimator를 결합할 때 블러 비용이 누적되어 프레임이 떨어질 수 있어요. Glass 효과가 들어간 컨테이너에서 큰 영역을 60fps 이상으로 애니메이션하려면, 영역을 좁히거나 애니메이션 중 임시로 Glass를 비활성화하는 패턴을 한 번쯤 고려해 볼 만합니다.

10. 의사 결정 체크리스트

  1. 애니메이션이 여러 속성을 서로 다른 타이밍으로 움직여야 하는가? → keyframeAnimator
  2. “상태 A → B → C”처럼 단계로 표현하면 자연스러운가? → phaseAnimator
  3. 특정 사용자 액션이 있을 때만 한 번 재생되는가? → 둘 다 가능, 단계가 단순하면 phaseAnimator
  4. 로딩 인디케이터처럼 무한 반복이 필요한가? → phaseAnimator(연속 모드)
  5. 스프링·큐빅·리니어를 한 시퀀스 안에 정밀하게 섞고 싶은가? → keyframeAnimator

FAQ

phaseAnimator와 keyframeAnimator 중 어느 것이 더 빠른가요?

일반적인 UI 애니메이션 수준에선 의미 있는 성능 차이가 없습니다. 두 API 모두 SwiftUI의 보간 엔진 위에서 돌아가요. 다만 keyframeAnimator는 매 프레임 트랙별로 값을 계산하니까, 콘텐츠 클로저 안에서 무거운 작업(이미지 재생성, 복잡한 레이아웃 계산)을 피하는 게 더 중요합니다.

iOS 16 이하를 지원해야 하는데 비슷한 효과를 낼 수 있나요?

두 API 모두 iOS 17 이상에서만 사용할 수 있습니다. iOS 16 이하에선 @State + withAnimation을 중첩하거나 Timer로 단계 전환을 직접 구현해야 하고, keyframe 같은 다중 트랙 보간은 TimelineView나 외부 라이브러리(예: Lottie)로 대체하는 게 일반적입니다.

같은 phaseAnimator 안에서 단계마다 애니메이션 곡선을 다르게 줄 수 있나요?

네, 됩니다. animation: 클로저는 현재 단계 값을 인자로 받기 때문에, 단계별로 switch를 사용해 .spring, .easeInOut, .linear 등 서로 다른 애니메이션을 반환할 수 있어요. “튕기며 등장 → 부드럽게 정착 → 천천히 페이드 아웃” 같은 복합 시퀀스를 만드는 핵심 패턴입니다.

keyframeAnimator의 트리거가 변경되었는데도 애니메이션이 다시 재생되지 않습니다. 왜 그런가요?

가장 흔한 원인은 trigger 값이 실제로는 변하지 않은 경우입니다. 같은 Bool에 같은 값을 다시 대입하거나, Equatable 비교에서 동일하다고 판단되는 객체를 넘기면 SwiftUI는 “변경 없음”으로 보고 무시해요. Int 카운터를 += 1로 증가시키거나, 새 UUID()를 할당하세요.

두 API를 한 화면에 동시에 사용해도 괜찮은가요?

완전히 괜찮습니다. 예를 들어 배경의 무한 펄스 효과는 phaseAnimator로, 사용자가 버튼을 눌렀을 때 보여 주는 정교한 강조 모션은 keyframeAnimator로 구현하는 식의 조합이 매우 자연스러워요. 다만 동일한 뷰의 동일한 속성에 두 API를 동시에 적용하면 변환이 충돌할 수 있으니, 책임 영역(어떤 속성은 누구의 통제하에 있는지)은 명확히 나눠 주세요.

마무리

phaseAnimator“상태의 시퀀스”를 직관적으로 표현하는 도구이고, keyframeAnimator“시간의 디자인”을 위한 도구입니다. 둘 중 무엇이 더 좋다기보다, 만들고 싶은 인터랙션을 어느 멘탈 모델로 설명할 수 있는지가 선택의 기준이 됩니다.

오늘 만든 애니메이션이 이산적인 상태로 설명된다면 phaseAnimator를, 시간축 위의 곡선으로 설명된다면 keyframeAnimator를 먼저 시도해 보세요. 그리고 두 도구가 각자 잘하는 것에 집중하도록 책임을 나누어 주는 것 — 이게 SwiftUI 애니메이션 코드를 단순하고 강력하게 유지하는 가장 확실한 비결입니다.

저자 소개 Priya Raghavan

Priya spent six years at Instacart building the iOS shopper app, where she led the migration from UIKit to SwiftUI across 80+ screens and cut crash-free sessions from 99.2% to 99.87%. Before that, she was a contractor at a Bay Area design studio shipping App Store apps for two Fortune 500 retail clients. She focuses on practical SwiftUI architecture - what holds up when you have 12 engineers committing to the same codebase, not just toy MVVM examples. Her recent work involves The Composable Architecture, Swift concurrency migration audits, and reducing main-thread hangs on older devices like the iPhone XR that enterprise fleets still ship. Priya runs a small consultancy in Oakland and occasionally speaks at try! Swift NYC. She has been writing Swift since the Objective-C bridging days of 2015.