안녕하세요! 피피아노입니다 🎵
서론
애플리케이션에서 진행률을 시각적으로 표현할 때, SwiftUI에서는 기본으로 제공하는 기본 컴포넌트인 ProgressView를 사용해서 해당 UI를 구현하게 됩니다. 하지만 이러한 기본 애니메이션 방식으로는 시간 기반으로 자연스럽게 진행되는 부드러운 애니메이션을 구현하기 어렵습니다.
이번 포스팅에서는 SwiftUI의 애니메이션 한계로 겪었던 진행률 애니메이션이 끊겨 보이는 문제를 분석하고, UIkit의 CADisplayLink를 활용하여 해결한 경험에 대해서 정리를 해보려고 합니다.
문제 상황
처음에 구현하고자 했던 기능은 진행률이 0%에서 100%까지 7초 동안 자연스럽게 증가하게 하는 것이었습니다.
기본적으로 SwiftUI에서는 다음과 같은 방식으로 애니메이션을 줄 수 있습니다.
ProgressView(value: isAnimating ? 1.0 : 0.0)
.animation(.easeInOut(duration: 7.0), value: isAnimating)
하지만 해당 코드에서 문제가 발생했습니다.
동작은 정상적이지만 시각적으로 뚝뚝 끊겨 보이고, 텍스트로 함께 표시한 % 수치도 불연속적으로 점프했습니다.
기존 시도들
처음에는 다음과 같은 방식들을 시도해보았습니다.
- withAnimation + State 조작
- Timer를 사용하여 일정 간격마다 progress += 0.05 식의 접근(처음에는 0.5로 시도하였지만 끊김이 발생하여 간격을 더 세밀하게 조절하기 위해 0.05로 변경)
- 상태 플래그(isAnimating) 기반의 트리거 방식
하지만 이러한 방식들은 다음과 같은 이유로 제가 원하는 형태를 구현하기에는 부족했습니다.
- Timer는 FPS와 맞지 않아 여전히 계단식 진행
- SwiftUI의 animation()은 값 변화를 기준으로만 동작하기 때문에 실제 시간 흐름에 비례하는 애니메이션을 보장하지 않음
원인 분석
문제의 핵심은 SwiftUI의 애니메이션이 선언형이고 상태 기반이라는 점입니다.
SwiftUI는 progress 1.0처럼 상태를 바꾸면 그 차이를 알아차리고 시각적인 애니메이션을 수행합니다. 하지만 애니메이션은 실제 시간에 따라 정확하게 변화하지 않고, 내부적으로는 단순한 렌더링 효과일 뿐 프레임 단위로 변화 값을 직접 제어할 수 없었습니다.
해결 방법
SwiftUI가 부족한 이 부분을 해결하기 위해 UIKit의 CADisplayLink를 도입했습니다. 문제를 분석하던 중 Apple 공식 문서를 참고하게 되었습니다.
https://developer.apple.com/documentation/quartzcore/cadisplaylink
CADisplayLink | Apple Developer Documentation
A timer object that allows your app to synchronize its drawing to the refresh rate of the display.
developer.apple.com
해당 문서에서 CADisplayLink는 디스플레이 주기에 맞춰 콜백을 실행해주는 고정 주기 타이머라는 점을 확인할 수 있었습니다.
SwiftUI에서는 값이 바뀔 때 애니메이션을 붙이는 식으로만 처리되기 때문에, 프레임 단위로 값을 조정하거나 시간 흐름을 기준으로 값을 제어하는 기능이 부족합니다. 이에 따라 CADisplayLink의 구조와 사용 예제를 기반으로 다음과 같은 전략을 세웠습니다.
- startTime 기준으로 경과 시간 측정
- 총 지속 시간 대비 비율(t) 계산
- easeOut(t)으로 커브 적용
- progress = easeOut(t)로 매 프레임 업데이트
- 100%로 도달시 displayLink.invalidate()로 종료
private func startProgress() {
let displayLink = CADisplayLink(target: DisplayLinkProxy { link in
let elapsed = Date().timeIntervalSince(startTime)
let t = min(elapsed / totalDuration, 1.0)
progress = easeOut(t)
if t >= 1.0 { link.invalidate() }
}, selector: #selector(DisplayLinkProxy.update(_:)))
displayLink.add(to: .main, forMode: .default)
}
이 방식으로 구현한 결과, ProgressView는 제가 의도한대로 7초 동안 자연스럽게 진행되었습니다.
CADisPlayLink의 내부 동작 원리 및 메모리 관리
우선 CADisplayLink는 iOS의 CoreAnimation 시스템과 긴밀히 연동되어 작동합니다. 디스플레이가 리프레시 될 때마다, 즉 1초에 60프레임 기준으로 콜백을 호출해주는 고정 주기 타이머입니다. 이 콜백은 RunLoop에 등록되어 있으며, 일반적으로 .main RunLoop에서 동작하게 됩니다. 이렇게 하면 애니메이션 업데이트가 디스플레이의 실제 렌더링 타이밍과 정확히 동기화되어 매우 부드러운 UI 갱신이 가능합니다.
메모리 관리 주의사항
공식 문서에 따르면, CADisplayLink는 명시적으로 invalidate()를 해주지 않으면 계속해서 RunLoop에 남아 있어 리소스를 소모한다고 합니다.
- Retain Cycle 방지: CADisplayLink의 target이 SwiftUI View 자신일 경우, 강한 참조로 인해 뷰가 해제되지 않는 문제가 생깁니다. 이를 방지하기 위해 Proxy 클래스를 사용해 간접적으로 클로저를 전달합니다.
- invalidate() 호출 필수: 애니메이션 종료 시 displayLink.invalidate()를 호출하지 않으면 계속 실행되어 메모리 누수 및 성능 저하로 이어질 수 있습니다.
- .onDisappear에서의 정리: 뷰가 사라질 때 displayLink를 무조건 해제하는 것도 안전한 전략입니다. 이는 뷰 라이프사이클에 따른 예측 불가능한 상태를 방지할 수 있습니다.
이처럼 CADisplayLink는 UIkit 기반이지만, 해당 문제점만 잘 처리해준다면 SwiftUI에서도 충분히 활용할 수 있습니다.
결론
애플 개발자 문서를 통해 CADisplayLink의 동작 방식과 사용법을 명확히 이해할 수 있었고, 실제 적용 과정에서 안정성과 정확성을 확보하는 데 큰 도움이 되었습니다.
이번 경험을 통해 단순히 기능을 구현하는 것을 넘어서, 기존 도구의 한계를 인식하고 대안을 찾아 적용하는 과정 자체가 하나의 문제 해결 방법이고 개발자로서의 성장에 중요하다고 느꼈습니다.
SwiftUI는 선언형이라는 강점을 갖고 있지만, 아직까지는 UIKit의 제어 가능성을 필요로 하는 순간이 존재합니다. 그럴 때 이를 잘 조합해 쓰는 유연함이 실제 앱의 품질을 개선할 수 있다는 점도 직접 느낄 수 있었습니다.
감사합니다.
잘못된 내용이 있거나 더 좋은 내용 피드백은 언제나 환영합니다!
궁금하신 부분은 댓글로 질문 부탁드립니다!
'Apple > SwiftUI' 카테고리의 다른 글
[SwiftUI] 앱에 Face ID 잠금 기능 적용하기 (8) | 2025.07.08 |
---|---|
[SwiftUI] SwiftUI 상태 동기화 트러블슈팅 (1) | 2025.06.25 |
[SwiftUI] NavigationStack 사용 시 화면 전환 안 되는 문제와 title 깨짐 문제 트러블슈팅 (0) | 2025.06.03 |
[SwiftUI] SwiftUI로 카메라 기능 구현하기 (2) | 2025.03.01 |
[SwiftUI] ProgressView 생성하기 (15) | 2025.01.15 |