스윙 iOS에서 더 개선된 애니메이션 시스템 만들기

더스윙에서 애니메이션 관련 이슈를 어떻게 해결했는지 소개하려고 합니다.

들어가며

안녕하세요. 더스윙에서 iOS 팀장 및 스윙 앱의 iOS 개발을 담당하고 있는 박영수입니다.

앱을 개발하다 보면 다양한 애니메이션 요구사항을 마주하게 되는데요, 애니메이션을 사용하다 보면 의도치 않은 동작을 겪는 경우가 많습니다. 이 글에서는 더스윙에서 애니메이션 관련 이슈를 어떻게 해결했는지 소개하려고 합니다.

iOS 개발에서 애니메이션은 필수적인 요소입니다. 최근 앱들은 요소들을 상황에 맞춰 이동시키거나 숨기거나 변형시켜야 할 때가 많습니다. 이럴 때 일반적으로 UIView.animate 함수나 UIViewPropertyAnimator를 사용하게 되는데요, 예상치 못한 문제가 발생하기 마련입니다.

기본적으로 다음과 같은 뷰들이 배치되어 있다고 가정해보겠습니다.

    menuButton.frame = CGRect(x: 50, y: 250, width: 50, height: 50)
    menuButton.setTitle("☰", for: .normal)
    menuButton.setTitleColor(.black, for: .normal)
    menuButton.backgroundColor = .lightGray
    menuButton.addTarget(self, action: #selector(menuTapped2), for: .touchUpInside)
    view.addSubview(menuButton)
    redBox.backgroundColor = .red
    redBox.frame = CGRect(x: 50, y: 100, width: 100, height: 100)
    view.addSubview(redBox)
    testLabel.frame = CGRect(x: 50, y: 250, width: 300, height: 300)
    testLabel.text = "Label"
    testLabel.textColor = .black
    view.addSubview(testLabel)
    profileImageView.backgroundColor = .blue
    profileImageView.layer.cornerRadius = 20
    view.addSubview(profileImageView)

여기서 메뉴 버튼을 누르면 화면 배치를 바꾸고, 뷰의 색상과 텍스트 컬러도 변경하려고 합니다.

  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    profileImageView.frame = CGRect(
      x: isOpen ? view.bounds.width - 60 : 60,
      y: 50,
      width: 40,
      height: 40
    )
  }
  @objc func menuTapped() {
    isOpen = !isOpen
    UIView.animate(withDuration: 2.0) {
      self.redBox.frame.size.width = (self.isOpen) ? 300 : 100
      self.redBox.backgroundColor = (self.isOpen) ? .yellow : .red
      self.testLabel.textColor = (self.isOpen) ? .blue : .black
      self.view.layoutIfNeeded()
    }
  }

예시로 든 코드는 극단적이긴 합니다. 일반적으로 프레임을 다시 잡을 때 viewDidLayoutSubviews를 직접 활용하지는 않습니다. 하지만 현재 화면의 bounds 등을 참조해야 할 때나 AutoLayout을 사용한다면 자주 활용되는 방식입니다. 위의 코드를 실행하면 다음과 같은 결과가 나옵니다.

0:00
/0:12

의도치 않게 파란색 뷰가 애니메이션되며, UILabeltextColor는 점진적으로 바뀌지 않고 순간적으로 변경됩니다.

위의 예시에서는 UIView.animate 함수를 사용했지만, UIViewPropertyAnimator를 사용해도 동일한 결과가 나타납니다.

무엇이 문제인가?

CATransaction과 암묵적 애니메이션

이 문제에 대해서 이해하기 위해서는 먼저 UIView.animate 함수의 구동방식에 대해 이해하여야 됩니다.

UIView.animate는 사실 Core Animation의 CATransaction이라는 시스템 위에서 작동합니다.

UIView.animate(withDuration: 0.3) {
    viewA.alpha = 0.5
}

이러한 코드는 실제로 UIKit 내부에서는

CATransaction.begin()
CATransaction.setAnimationDuration(0.3)
viewA.alpha = 0.5
CATransaction.commit()

의 형태로 동작하게 됩니다.

여기서 핵심은 CATransaction이 활성화 되면 그 시점의 모든 CALayer 속성 변경이 자동으로 애니메이션 된다는 점입니다.

문제1. CATransaction의 생명주기

CATransactionanimate 블록이 끝나도 즉시 종료되지 않습니다. 현재 RunLoop 사이클이 완전히 끝날 때까지 유지되며, 그 사이 시점에서 발생하는 모든 UI 변경에도 애니메이션이 적용됩니다.

문제는 RunLoop 사이클이 언제 끝나는지 개발자가 정확히 제어할 수 없다는 점입니다. animate 블록 실행이 끝난 후에도 수십 밀리초 동안 CATransaction이 활성 상태로 남아있을 수 있으며, 이 짧은 시간 동안 발생하는 Delegate 콜백, KVO 알림, Timer 이벤트 등의 UI 업데이트가 모두 의도치 않게 애니메이션됩니다. 결과적으로 "animate 블록 밖에 있으니까 애니메이션 안 되겠지"라는 개발자의 직관과 실제 동작이 달라지게 됩니다.

문제2. 레이아웃 업데이트의 연쇄효과

더 골치 아픈 점은 뷰의 속성 변화가 viewDidLayoutSubviews()layoutSubviews() 같은 레이아웃 메서드를 예상치 못하게 호출할 수 있다는 점입니다.

addSubview(), removeFromSuperview(), isHidden 변경, sizeToFit() 호출 등 다양한 작업들이 레이아웃을 무효화(invalidate)시키고, 이는 곧 레이아웃 메서드의 호출로 이어집니다. 문제는 이러한 레이아웃 메서드들이 UIView.animate의 CATransaction 컨텍스트 안에서 실행되면, 그 내부의 모든 frame 설정이나 제약조건 변경도 함께 애니메이션된다는 것입니다. 특히 viewDidLayoutSubviews()는 뷰 컨트롤러 전체의 레이아웃을 담당하기 때문에, 하나의 작은 애니메이션이 화면 전체로 전염될 수 있습니다. 이는 애니메이션의 영향 범위를 사전에 파악하기 거의 불가능하게 만들며, 디버깅을 매우 어렵게 합니다.

문제3. 예측 불가능한 타이밍

실제 앱을 개발하다 보면 애니메이션이 시작되는 타이밍을 정확히 예측하기 힘듭니다. 사용자 인터랙션, 네트워크 응답, 타이머 이벤트, 시스템 알림 등 다양한 비동기 이벤트들이 언제든 발생할 수 있기 때문입니다.

특히 여러 컴포넌트가 독립적으로 동작하는 복잡한 앱에서는, 어떤 컴포넌트의 애니메이션과 다른 컴포넌트의 UI 업데이트가 우연히 겹치는 경우가 빈번합니다. 예를 들어 사용자가 버튼을 탭해 애니메이션을 시작하는 순간, 백그라운드에서 완료된 네트워크 요청이 UI를 업데이트하거나, 실시간 데이터 스트림이 화면을 갱신할 수 있습니다. 이런 타이밍 충돌은 재현하기도 어렵고, 발견했을 때는 이미 프로덕션 환경에서 사용자들이 이상한 애니메이션을 경험하고 있을 수 있습니다. 더욱이 이러한 문제는 개발 단계에서는 잘 드러나지 않다가 실제 사용 환경의 복잡한 상황에서만 발생하는 경우가 많아 디버깅이 매우 까다롭습니다.

문제4. 애니메이션 가능한 속성의 제한

UIView.animate의 또 다른 근본적인 한계는 CALayer의 animatable properties만 애니메이션할 수 있다는 점입니다.

기본적으로 UIView는 내부적으로 CALayer를 래핑하고 있으며, frame, bounds, alpha, backgroundColor 같은 속성들은 CALayer의 속성으로 매핑되어 애니메이션이 가능합니다. 하지만 UILabeltexttextColor, UIImageViewimage 같은 속성들은 CALayer에 대응되는 속성이 없기 때문에 UIView.animate 블록 안에 넣어도 애니메이션되지 않고 즉시 변경됩니다.

더 큰 문제는 UIKit을 벗어나는 순간입니다. MKMapView의 카메라 이동이나 커스텀 그래픽 요소들을 애니메이션하려면 완전히 다른 방식을 사용해야 합니다. 이는 애니메이션 구현 방식이 뷰의 종류에 따라 파편화되고, 일관성 없는 코드베이스로 이어지게 됩니다. 결과적으로 개발자는 "어떤 속성이 애니메이션 가능한지"를 항상 기억해야 하고, 그렇지 않으면 예상과 다른 동작으로 인해 혼란을 겪게 됩니다.

문제5. 애니메이션 취소의 어려움

UIViewPropertyAnimator는 애니메이션을 취소할 수 있지만 UIView.animate는 애니메이션 객체를 반환하지 않기 때문에, 시작한 애니메이션을 추적하거나 제어할 방법이 없습니다.

진행 중인 애니메이션을 취소하려면 layer.removeAllAnimations()를 호출해야 하는데, 이는 해당 뷰의 모든 애니메이션을 한꺼번에 제거합니다. 특정 애니메이션만 선택적으로 취소할 수 없을 뿐만 아니라, 애니메이션이 목표값에 도달하지 못하고 중간 위치에서 멈춰버리는 문제도 발생합니다.

실제로 사용자가 빠르게 반복해서 버튼을 탭하거나, 애니메이션 진행 중에 화면을 벗어나거나, 데이터 변경으로 인해 진행 중인 애니메이션을 즉시 중단해야 하는 상황은 매우 흔합니다. 하지만 UIView.animate는 이런 동적인 제어를 거의 불가능하게 만들어, 결과적으로 부자연스러운 UX로 이어지게 됩니다.

스윙 iOS의 새로운 애니메이션 시스템

Core Animation에는 CADisplayLink라는 객체가 있습니다. (CADisplayLink | Apple Developer Documentation )

이 객체는 초기화할 때 특정 객체의 selector를 타겟으로 설정하여, 화면이 갱신될 때마다 해당 selector에 자신의 정보를 전달합니다. 이 정보에는 마지막으로 화면이 갱신된 시간의 timestamp 등 화면 갱신 관련 데이터들이 포함되어 있습니다.

스윙에서는 이 CADisplayLink의 정보를 중앙에서 관리하고, 다른 객체들이 해당 정보를 관찰(Observing)할 수 있는 중앙 관리자를 만들었습니다.

public protocol DisplayLinkObservable: AnyObject {
  var enabled: Bool { get }
  func updated(time: Double)
}
public class DisplayLinkManager: NSObject {
  public static let shared: DisplayLinkManager = DisplayLinkManager()
  private lazy var displayLink: CADisplayLink = CADisplayLink(target: self, selector: #selector(DisplayLinkManager.updateDisplay))
  private var observers: [DisplayLinkObservable] = []
  public func addObserver(_ observer: DisplayLinkObservable) {
    observers.append(observer)
  }
  public func setup() {
    displayLink.add(to: .main, forMode: .common)
  }
  @objc func updateDisplay(_ displayLink: CADisplayLink) {
    for observer in observers where observer.enabled == true {
      observer.updated(time: displayLink.timestamp)
    }
    observers = observers.filter { $0.enabled }
  }
}

DisplayLinkObservable을 상속하는 객체는 DisplayLinkManager에 Observer로 추가되며, enabled가 true일 때 update 함수를 통해 화면 갱신 시마다 현재 갱신 시간을 받을 수 있습니다.

SWAnimation

DisplayLinkObservable을 상속받아 스윙에서 사용할 수 있는 Animation 객체를 생성했습니다.

이 객체는 애니메이션이 시작하기 전까지의 delay, 애니메이션의 duration, 실제로 동작할 block closure, 그리고 완료 후에 실행될 completion closure를 가집니다.

public class SWAnimation: DisplayLinkObservable {
  private var startTime: Double? = nil
  private var delay: Double
  private var duration: Double
  private var finishTime: Double? {
    guard let startTime else { return nil }
    return startTime + delay + duration
  }
  private var block: DoubleClosure
  private var completion: BoolClosure
  private var _enabled: Bool = true
  @discardableResult
  public static func animate(
    delay: Double = 0.0,
    duration: Double,
    block: @escaping DoubleClosure,
    completion: @escaping BoolClosure = DefaultBoolClosure
  ) -> SWAnimation {
    let animation = SWAnimation(delay: delay, duration: duration, block: block, completion: completion)
    DisplayLinkManager.shared.addObserver(animation)
    return animation
  }
  private init(delay: Double = 0.0, duration: Double, block: @escaping DoubleClosure, completion: @escaping BoolClosure = DefaultBoolClosure) {
    self.delay = delay
    self.duration = duration
    self.block = block
    self.completion = completion
  }
  public func cancel() {
    _enabled = false
    completion(false)
  }
  public var enabled: Bool { _enabled }
  public func updated(time: Double) {
    if startTime == nil {
      startTime = time
    }
    if let finishTime, finishTime < time {
      _enabled = false
      block(1.0)
      completion(true)
    } else if let startTime {
      let progress: Double = if startTime + delay > time {
        0.0
      } else {
        min((time - (startTime + delay)) / duration, 1.0)
      }
      block(progress)
    }
  }
}

추가적으로 static 함수를 제공하여 객체를 직접 생성하지 않고도 static 함수만으로 애니메이션을 생성할 수 있게 했으며, 생성된 객체는 자동으로 DisplayLinkManager에 추가됩니다. 객체는 DisplayLinkManager의 observer로 강한 참조되고 있기 때문에, 생성된 객체를 별도로 저장하지 않아도 유실될 염려가 없습니다.

update 함수에서는 객체의 생성 시간과 delay, duration을 계산하여 progress closure에 현재 진행도를 전달하도록 구현했습니다.

Curve

시간은 선형적이기 때문에 SWAnimation의 progress는 선형적일 수밖에 없습니다. 하지만 애니메이션에서 가장 선호되는 방식은 easeInOut이므로, 추가로 easeIn, easeOut을 지원하고, 바운스 효과를 위해 easeInBack, easeOutBack, easeInOutBack을 만들어 progress를 조정하도록 했습니다.

또한 개발자들이 progress를 통해 일일이 값을 계산하는 것은 번거로우므로, 값을 자동으로 계산해주는 함수를 제공합니다.

public enum Curve: String {
  case linear
  case easeIn
  case easeOut
  case easeInOut
  case easeInBack
  case easeOutBack
  case easeInOutBack
  public func curve(_ t: Double) -> Double {
    switch self {
    case .linear: return Curve.linear(t)
    case .easeIn: return Curve.easyIn(t)
    case .easeOut: return Curve.easyOut(t)
    case .easeInOut: return Curve.easyInOut(t)
    case .easeInBack: return Curve.easyInBack(t)
    case .easeOutBack: return Curve.easeOutBack(t)
    case .easeInOutBack: return Curve.easeInOutBack(t)
    }
  }
  // 아래 함수들은 단순한 계산식들이므로 코드를 생략합니다.
  private static func linear(_ t: Double) -> Double
  private static func easyIn(_ t: Double) -> Double
  private static func easyOut(_ t: Double) -> Double
  private static func easyInOut(_ t: Double) -> Double
  private static func easyInBack(_ t: Double) -> Double
  private static func easeOutBack(_ t: Double) -> Double
  private static func easeInOutBack(_ t: Double) -> Double
  public func calculate(start: Double, end: Double, progress: Double) -> Double
  public func calculate(start: CGFloat, end: CGFloat, progress: Double) -> CGFloat
  public func calculate(start: UIColor, end: UIColor, progress: Double) -> UIColor
}

이제 개발자들은 Curve를 통해 시작값, 종료값, 그리고 현재 progress를 전달하면 현재 진행도에 맞는 값을 받을 수 있습니다.

적용

예시의 애니메이션 코드를 SWAnimation을 활용하면 다음과 같이 변경됩니다.

  override func viewDidLayoutSubviews() {
    super.viewDidLayoutSubviews()
    profileImageView.frame = CGRect(
      x: isOpen ? view.bounds.width - 60 : 60,
      y: 50,
      width: 40,
      height: 40
    )
  }
  @objc func menuTapped2() {
    isOpen = !isOpen
    SWAnimation.animate(duration: 2.0) { (progress) in
      self.redBox.frame.size.width = (self.isOpen) ? Curve.easeInOut.calculate(start: 100, end: 300, progress: progress) : Curve.easeInOut.calculate(start: 300, end: 100, progress: progress)
      self.redBox.backgroundColor = (self.isOpen) ? Curve.easeInOut.calculate(start: .red, end: .yellow, progress: progress) : Curve.easeInOut.calculate(start: .yellow, end: .red, progress: progress)
      self.testLabel.textColor = (self.isOpen) ? Curve.easeInOut.calculate(start: .black, end: .blue, progress: progress) : Curve.easeInOut.calculate(start: .blue, end: .black, progress: progress)
      self.view.layoutIfNeeded()
    }
  }

예시 코드보다 조금 더 길어지긴 했지만, 크게 복잡해지지는 않았습니다. 이 코드를 적용하면 애니메이션은 다음과 같이 동작합니다.

0:00
/0:18

파란색 뷰는 더 이상 애니메이션되지 않으며, 텍스트 컬러는 점진적으로 색이 바뀌는 것을 확인할 수 있습니다. 또한 SWAnimation은 객체를 반환하기 때문에, 자체 cancel 함수를 통해 언제든지 애니메이션을 취소할 수 있습니다.

이 시스템을 더 활용할 수 있지 않을까?

앞서 설명했듯이 CADisplayLink는 화면 갱신 시마다 호출됩니다. 즉, 1초당 해당 함수가 호출되는 횟수를 알 수 있다면 현재 앱의 FPS를 측정할 수 있습니다. 이를 통해 메인 스레드에 심한 지연을 일으키는 화면과 로직을 찾아낼 수 있으며, 그에 대한 조치를 취할 수 있습니다.

스윙에서는 이를 위해 DisplayLinkObservable을 상속하는 FPSWatcher를 만들어, 개발 중인 앱의 FPS를 측정하여 성능을 관리하고 있습니다.

0:00
/0:20

개선후기

스윙 iOS 팀의 개발 철학은 "복잡한 것은 안으로 숨기고, 밖에서는 빠르게 개발하자"입니다. 이번 애니메이션 시스템 개발도 이 철학을 그대로 반영했습니다.

CADisplayLink, CATransaction, 레이아웃 생명주기 같은 복잡한 개념들은 DisplayLinkManagerSWAnimation 내부로 완전히 숨겼습니다. 이제 팀원들은 "이 애니메이션이 다른 뷰에 영향을 주지 않을까?", "레이아웃 업데이트 타이밍이 겹치면 어쩌지?" 같은 고민 없이, 단순히 애니메이션하고 싶은 속성과 진행도만 생각하면 됩니다.

좋은 도구는 개발자가 본질에 집중할 수 있게 해줍니다. UIView.animate의 예측 불가능한 동작에 시간을 낭비하는 대신, 이제 우리는 "어떤 애니메이션이 사용자 경험을 더 좋게 만들까?"에 집중할 수 있게 되었습니다.

복잡함을 내부로 숨기고 간단한 인터페이스를 제공하는 것, 그것이 좋은 시스템의 핵심이라고 믿습니다. 이 글이 비슷한 문제를 겪고 있는 다른 팀들에게 도움이 되길 바랍니다.


🚖 함께 할 동료를 찾고 있어요 🚖


SWING은 더 나은 도시를 만들기 위해 노력하는 팀입니다. 데이터, 기술, 사용자 중심의 혁신을 통해 이동 경험을 바꾸고자 하는 분들을 기다리고 있습니다.  

여정에 함께하고 싶다면, 지금 바로 아래 링크를 통해 지원해주세요!

👉 SWING 채용 공고 확인하기 👈