LY Corporation Tech Blog

LY Corporation과 LY Corporation Group(LINE Plus, LINE Taiwan and LINE Vietnam)의 기술과 개발 문화를 알립니다.

물 흐르듯 자연스러운 화면 전환을 향한 여정

들어가며

안녕하세요. Messaging Client Dev 2 팀에서 LINE iOS 앱을 개발하고 있는 박신홍입니다. 작년에 신입으로 입사해서 LINE 앱 대화방에서 사용자 경험을 개선하기 위해 크고 작은 수정 작업을 진행하고 있습니다. 이번 글에서는 그중 가장 재미있게 진행했던 작업 한 가지를 소개하고자 합니다. 대화방에서 사진이나 동영상을 탐색할 때 발생하는 화면 전환을 '물 흐르듯(fluid)' 자연스럽도록 변경하는 작업인데요. 내부에서는 FluidTransition이라고 부르는 이 프로젝트는, 비록 기능적인 요구 사항은 매우 단순했지만 실제 구현 과정은 예상보다 훨씬 복잡했습니다. 덕분에 다양한 시행착오를 겪으면서 많은 것을 배울 수 있었는데요. 이 글을 통해 FluidTransition을 개발하게 된 계기와 그 과정에서 얻은 교훈을 공유하고자 합니다.

물 흐르듯 자연스럽다는 것의 의미

사용자 인터페이스 관점에서 물 흐르듯 자연스럽다는 것은 과연 어떤 의미일까요? '자연스럽다'라는 형용사는 주관적인 단어이므로 동일한 인터페이스를 사람마다 다르게 받아들일 여지가 있을 것 같습니다. 누군가는 부드러운 애니메이션을 자연스럽다고 생각할 수도 있고, 누군가는 빠릿빠릿한 애니메이션을 자연스럽다고 생각할 수도 있습니다.

부드러운 애니메이션에 집착하는 것으로 잘 알려진 Apple은 WWDC18의 Designing Fluid Interfaces 세션을 통해 힌트를 제시했습니다. 사용자가 마음속으로 생각하는 대로 작동해야만 물 흐르듯 자연스러운 인터페이스라고 할 수 있다는 것입니다. 즉, 도구가 마음의 확장판처럼 작동할 때("when a tool feels like an extension of your mind") 사용자는 비로소 자연스럽다고 인식합니다.

어떤 인터페이스가 자연스러운지 아닌지는 사용자가 직접 만져보는 순간 즉각 알아챌 수 있습니다. 하지만 무엇이 자연스럽지 않은지, 왜 자연스럽지 않다고 느끼는 것인지 설명하는 것은 쉽지 않습니다. 일반적으로 화면 전환 애니메이션은 짧은 순간에 일어나며, 전체 앱 사용 경험에서 차지하는 비중이 그렇게 크지 않기 때문일 것입니다. 그렇다면 자연스러운 인터페이스가 왜 그렇게 중요한 것일까요?

오늘날의 터치 인터페이스는 사용자가 화면 속 콘텐츠와 직접 상호작용할 수 있도록 진화하고 있습니다. 키보드나 마우스용 인터페이스와는 달리, 이제 화면 속 콘텐츠가 마치 물리적으로 존재하는 것처럼 사용자의 입력에 즉각 반응하도록 설계되고 있기 때문입니다. 터치 인터페이스는 가장 원시적인 방법으로, 사용자에게 물리적인 세계와 상호작용하고 있다는 환상을 심어주고 있습니다.

하지만 인터페이스가 충분히 자연스럽지 않다면 이런 환상은 한순간에 부서집니다. 예를 들어, 어떤 원시인이 손도끼를 휘둘렀는데 0.5초 뒤에 흠집이 난다면 이 원시인은 엄청난 혼란에 빠질 것입니다. 자기가 생각한 대로 물리적인 세상이 반응하지 않았기 때문이죠. 현재 LINE iOS 앱은 수많은 사람들이 매일 사용하는 커뮤니케이션 도구로 원시 시대의 손도끼만큼 중요한 도구라고 할 수 있는데요. 매일 사용하는 이 도구가 사용자가 생각한 대로 움직이지 않는다면 작은 불편이 누적되면서 앱을 사용하는 재미가 크게 반감될 것입니다. 이것이 바로 LINE 앱에서 물 흐르듯 자연스러운 인터페이스가 중요하다고 생각하는 이유입니다.

LINE 대화방에서의 문제점

사실 화면 전환 애니메이션은 LINE 앱 대화방에서 오래전에 이미 구현돼 수년간 큰 문제 없이 잘 작동하고 있었습니다. 하지만 앞에서 언급한 '물 흐르듯 자연스러운가'라는 기준에 비춰 봤을 때에는, 아직 충분히 자연스럽지 않다는 느낌을 받을 때가 있었습니다. 아래 영상을 보면 보다 쉽게 이해하실 수 있을 것 같습니다. 여러 이미지 메시지를 빠르게 여닫는 상황을 보여드릴 텐데요. 좌측이 지금의 대화방 모습이고, 우측이 FluidTransition이 적용된 빌드입니다.

비교하기 쉽게 개선 전후를 나란히 배치해 두었는데요. 두 영상에서 다른 점이 보이시나요? 예리하신 분이라면 좌측 영상에서는 이미지 뷰어가 열린 후 첫 번째 터치에 반응할 때까지 약간의 딜레이가 있다는 사실을 눈치채셨을 것입니다. 또한 원하는 동작을 수행하기까지 대체로 두 번 이상의 제스처가 필요하다는 점도 확인할 수 있습니다. 우측 영상과 달리 중간에 마음이 바뀌었을 때 바로 동작을 취소하는 것도 어렵습니다. 이렇듯 사용자가 의도한 대로 화면이 반응하지 않는다면 물리적인 도구를 사용할 때와는 다르게 이질감이 느껴질 수 있습니다.

그렇다면 사용자가 의도한 대로 화면이 반응한다는 것은 무슨 의미일까요? 이를 이해하기 위해서는 '재지향성(redirection)'과 '중단(interruption)'에 대해 이야기할 필요가 있습니다.

재지향성과 중단

재지향성과 중단은 서로 밀접하게 관련돼 있는 용어입니다. 재지향성은 사용자가 원래의 목표나 방향을 변경할 수 있다는 것을 의미합니다. 예를 들어, 사용자가 어떤 이미지를 확대하려다가 생각이 바뀌어서 다른 이미지로 넘어가려는 경우가 있습니다. 이때 인터페이스는 사용자의 변경된 의도를 즉시 반영해야 합니다. 한편, 중단은 사용자가 어떤 작업을 시작한 후에 그 작업을 취소하려는 상황을 가리킵니다. 이런 상황에서도 마찬가지로 인터페이스는 사용자의 의도를 즉각적으로 반영해 사용자가 원하는 시점에 작업을 중단할 수 있어야 합니다.

인터페이스에서 방향 전환이나 취소가 불가능하다면 어떨까요? 어떤 동작을 할 때마다 생각, 결정, 제스처, 릴리스라는 단계를 '순차적으로' 겪어야 할 것입니다. 제스처를 하기 전에 모든 생각과 결정이 완료돼야 한다는 것입니다. 하지만 이는 우리가 현실 세계에서 행동하는 방식과는 많이 다릅니다. 예를 들어 쇼핑몰에서 옷을 고르는 상황을 생각해 볼 수 있습니다. 처음에는 한 제품에 관심이 가서 그쪽으로 가지만, 갑자기 다른 제품이 눈에 띄어 방향을 바꿔 그 제품을 확인할 수도 있습니다.

이처럼 생각과 행동은 동시에 일어나며 서로 유동적으로 영향을 미치면서 최종 동작으로 이어지므로, 인터페이스를 디자인할 때에도 이와 같은 행동 패턴을 반영할 필요가 있습니다. 사전에 모든 것을 계획하고 실행에 옮기는 것보다 훨씬 빠르고 유연한 동작이 가능해지기 때문입니다.

재사용성과 유지 보수성

앞서 언급한 문제를 해결하기 위해 먼저 기존에 LINE 앱에서 사용하고 있던 화면 전환 코드를 살펴보았는데, 또 다른 문제가 있었습니다. 화면에 따라 화면 전환을 관리하는 객체가 별도로 구현돼 있었고, 기능 자체는 거의 동일하지만 구현상 디테일이 조금씩 달랐습니다. 그렇다 보니 자세히 봤을 때 애니메이션이 묘하게 다른 경우가 많았고, 대응해야 하는 버그나 극단적인 사용 사례(edge case) 등도 달랐습니다. 무엇보다 1,000줄이 넘는 복잡한 UI 애니메이션 관련 코드가 여러 모듈에 걸쳐 중복으로 산재해 있다 보니 장기적으로 유지 보수하기 매우 까다롭다고 느껴졌습니다.

처음에는 영향 범위를 최소화하기 위해 기존 코드를 조금만 수정하는 방향을 고려했습니다. 하지만 이 문제를 더 근본적으로 해결하고자, 결국 관련 코드를 처음부터 다시 작성하기로 결정했습니다.

디자인 목표

FluidTransition 구현을 시작하기에 앞서 몇 가지 핵심 목표를 설정했습니다.

  1. 유연성: 범용적으로 적용할 수 있으면서도 충분히 유연해서 다양한 상황에 맞춰 커스터마이징할 수 있는 인터페이스를 구현하고자 했습니다.
  2. 중단 가능성: 방향 전환 및 중단 가능한 제스처를 기본적으로 제공하고자 했습니다.
  3. 추상화: 특정 모듈의 도메인 모델에 종속되지 않는 추상화된 인터페이스를 구현하고자 했습니다(예를 들어, 기존 대화방의 화면 전환 코드에서는 CoreDataNSManagedObject에 대한 의존성이 있었습니다).
  4. 가독성: UI 애니메이션 관련 코드는 난해한 경우가 많으므로, 충분히 문서화된 인터페이스를 통해 이를 극복하고자 했습니다.
  5. 편의성: 다른 팀의 개발자도 친숙하고 편리하게 인터페이스를 사용할 수 있도록 설계 단계부터 API 사용자들의 입장에서 고민했습니다.

위 목표를 모두 충족하는 화면 전환 인터페이스를 구현할 수 있다면 대화방뿐 아니라 다른 서비스 영역에서도 점진적으로 도입해 앱 전반의 사용자 경험을 향상시킬 수 있을 것이라고 생각했습니다.

화면 전환의 기본적인 원리

UIKit에서 기본적으로 제공하는 화면 전환 애니메이션은 크게 두 가지가 있습니다. present(_:animated:completion:) 메서드를 통해 실행되는 모달(Modal) 애니메이션과, pushViewController(_:animated:) 메서드를 통해 실행되는 내비게이션 스택(NavigationStack) 애니메이션인데요. 한 줄의 코드만으로 쉽게 적용할 수 있다 보니 iOS 앱에서 가장 흔히 볼 수 있는 애니메이션입니다.

애니메이션을 커스터마이즈하려면 조금 복잡해지는데요. UIKit에서 제공하는 몇 가지 프로토콜을 함께 사용해야 합니다. 모달 애니메이션을 기준으로 설명드리면, 먼저 대상 뷰 컨트롤러의 transitioningDelegate 프로퍼티에 UIViewControllerTransitioningDelegate 프로토콜의 구현체를 제공해야 하는데요. 이는 단순히 UIViewControllerAnimatedTransitioning 또는 UIViewControllerInteractiveTransitioning 프로토콜의 구현체를 제공하는 객체입니다. 전자는 고정된 길이의 애니메이션을 시작할 때 사용하고, 후자는 사용자의 제스처에 따라 변화하는 애니메이션을 구현할 때 사용합니다. 두 프로토콜 모두 UIViewControllerContextTransitioning 프로토콜의 구현체를 사용하는데, 이는 현재 진행 중인 화면 전환 세션에 관련된 다양한 정보를 담고 있는 컨텍스트 객체입니다.

다소 추상적인 설명이라 와닿지 않을 수 있을 수 있는데요. 코드를 통해 UIViewControllerAnimatedTransitioning의 전통적인 구현 패턴을 표현하자면 아래와 같습니다. animate(withDuration:animations:) 메서드를 사용해 animateDuration이라는 고정된 시간 동안 애니메이션을 진행하는 단순한 코드입니다.

class MyCustomTransition: UIViewControllerAnimatedTransitioning {
        func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
        animationDuration
    }
 
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        switch transitionType {
        case .present:
            // 화면 전환에 필요한 뷰들의 초기 상태를 설정합니다.
            UIView.animate(withDuration: animationDuration) {
                // 화면 전환이 자연스럽게 이어지도록 애니메이션을 시작합니다.
            }
        case .dismiss:
            // ...
        }
    }
}

UIViewControllerInteractiveTransitioning도 전체적인 원리는 비슷합니다. 다만 사용자의 제스처에 반응해야 하기 때문에 UIGestureRecognizer를 사용해 제스처 도중에 진행 상태를 계산하고, 그에 따라 뷰의 속성을 업데이트해야 한다는 차이점이 있습니다. 제스처가 완료되면 마찬가지로 animate(withDuration:animations:) 메서드를 사용해 애니메이션을 이어 나가야 합니다.

지금까지 커스텀 화면 전환의 일반적인 구현 패턴을 살펴봤는데요. 한 가지 문제점은 애니메이션 시점에 UIView.animate(withDuration:animations:) 메서드를 사용한다는 점입니다. 이 메서드는 뷰를 애니메이션할 때 사용하는 가장 쉽고 보편적인 방법이지만, 구현 목표 중 하나인 '중단 가능성'을 충족하기에는 적합하지 않습니다.

중단 가능한 화면 전환

중단 가능한 화면 전환을 구현하기 위해서는 기존 방법보다 더 정교하게 애니메이션을 컨트롤할 수 있는 방법이 필요합니다. UIViewPropertyAnimator는 이런 요구 사항을 충족하는 훌륭한 도구인데요. 이 클래스는 애니메이션의 진행 상태를 프로그래밍 방식으로 조절할 수 있게 해주며, 애니메이션을 중단하거나 이어서 재생할 수 있게 해줍니다. 심지어는 애니메이션을 역방향으로 바꿔서 실행할 수도 있습니다.

마침 UIViewControllerAnimatedTransitioning 프로토콜도 UIViewPropertyAnimator의 강력한 기능을 화면 전환에 연계할 수 있도록 하는 인터페이스를 제공합니다. 이를 사용해서 앞에서 설명한 '전통적인 구현 패턴'을 업데이트하면 아래와 같습니다.

 class MyCustomTransition: UIViewControllerAnimatedTransitioning {
    func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
        // noop - interruptibleAnimator will handle everything for the transition
    }

    func interruptibleAnimator(using transitionContext: UIViewControllerContextTransitioning)
        -> UIViewImplicitlyAnimating {
        transition!.transitionAnimator
    }
}

extension MyCustomTransition: UIViewControllerInteractiveTransitioning {
    func startInteractiveTransition(_ transitionContext: UIViewControllerContextTransitioning) {
        transition = createAndStartTransition(transitionContext, operation)
    }

    var wantsInteractiveStart: Bool {
        isInitiallyInteractive
    }
}

여기서는 기존과 달리 animateTransition(using:)에서 아무 작업도 할 필요가 없습니다. interruptibleAnimator(using:) 메서드를 구현하는 순간 모든 화면 전환 애니메이션은 UIViewControllerInteractiveTransitioning 쪽으로 위임되기 때문입니다. wantsInteractiveStartfalse를 반환하면 즉시 애니메이션이 시작되며, true를 반환하면 사전에 설정된 UIGestureRecognizer가 먼저 반응을 시작합니다. 사용자가 손가락을 화면에서 떼면 애니메이션이 이어서 재생됩니다.

이로써 준비 작업은 모두 끝났습니다. 이제 createAndStartTransition 메서드만 잘 구현하면 자연스럽게 방향 전환 및 중단이 가능한 애니메이션을 만들 수 있습니다.

FluidTransition 톺아보기

createAndStartTransition이 생성하는 FluidTransition 객체는 화면 전환 애니메이션과 관련된 모든 UI 로직을 관할하는 클래스입니다. 화면 전환 애니메이션이 진행되는 동안 특정 수명 주기에 도달할 때마다 적절한 델리게이트(delegate) 메서드를 호출해 관련 뷰 컨트롤러가 이벤트에 반응할 수 있도록 돕고, 필요한 정보를 얻어올 수 있도록 했습니다.

그렇게 하기 위해서는 델리게이트 프로토콜을 잘 설계하는 것이 중요한데요. 이 프로토콜을 채택해서 구현해야 하는 것은 제가 아닌 각 서비스의 몫이기 때문입니다. 앞서 언급한 디자인 목표와도 직결되는 부분이기도 하므로 최대한 유연하면서도 친절하게 인터페이스를 설계하고자 고민했습니다.

먼저 화면 전환은 개념적으로 출발지(source)와 목적지(destination)로 분류할 수 있으므로, 이를 따라 각 프로토콜의 이름은 FluidTransitioningSourceFluidTransitioningDestination으로 지었습니다. 예를 들어 대화방에서 이미지 뷰어로 전환해야 한다면 대화방은 출발지, 이미지 뷰어는 도착지가 될 것입니다. 이 출발/도착 개념은 절대적인 것이어서, 이미지 뷰어에서 대화방으로 전환할 때에도 도착지에서 출발지로 되돌아간다고(dismiss) 이해하면 편할 것 같습니다.

두 프로토콜 모두 FluidTransitioningController라는 상위 프로토콜을 채택하고 있는데요. 이곳에는 여러 수명 주기 메서드를 포함해서 공통적으로 수행해야 하는 메서드들이 선언돼 있습니다. 복잡한 애니메이션이나 인터랙션, 상태 관리 등은 FluidTransition 객체에서 대신 처리해 주기 때문에 API 사용자 입장에서는 이러한 수명 주기 메서드를 통해 화면 전환의 각 단계마다 관련된 뷰들이 어떠한 모습이어야 하는지 정의하기만 하면 됩니다. 전체적인 플로를 간소화해서 그림으로 나타내면 아래와 같습니다.

위 그림에서 FluidTransitionDispatcher라는 클래스가 처음 등장하는데요. 이 클래스는 API 사용자가 손쉽게 화면 전환을 시작할 수 있도록 도와주는 연결 통로입니다. 아래와 같이 매우 친숙한 인터페이스를 가지고 있으며, 제네릭 제약(generic constraint)을 갖고 있기 때문에 FluidTransition을 처음 접하는 개발자도 컴파일러 경고를 따라 프로토콜 요구 사항을 충족해 나갈 수 있도록 설계했습니다.

/// Presents a destination view controller on top of a source view controller using fluid transitions.
///
/// When `overCurrentContext` is set to `true`, the source view remains in the view hierarchy.
public func present<S, D>(
    _ destination: D,
    on source: S,
    animated: Bool = true,
    overCurrentContext: Bool = false,
    completion: (() -> Void)? = nil
) where S: UIViewController & FluidTransitioningSource, D: FluidTransitioningDestination,
    S.TransitioningItem == D.TransitioningItem {
    // ...
}

예컨대 위 정의에 따르면, FluidTransition을 사용해 화면 전환을 하고 싶다면 각각 FluidTransitionSource, FluidTransitioningDestination 프로토콜을 따르는 객체를 명시해야 하네요. 이 사실을 알게 된 API 사용자가 다음으로 구현해야 할 프로토콜은 아래와 같은 모습입니다. 

@MainActor
public protocol FluidTransitioningController {
    associatedtype TransitioningItem

    /// Captures a snapshot of the view associated with the specified item.
    func fluidTransition(_ transition: FluidTransition, takeSnapshotFor item: TransitioningItem) -> UIView?

    /// Prepares for the transition to another controller with a specified item.
    func fluidTransition(
        _ transition: FluidTransition,
        prepareTransitionTo controller: any FluidTransitioningController,
        with item: TransitioningItem
    )

    // ...
}

지면 관계상 모든 메서드를 나열할 수는 없지만, 각 메서드마다 DocC 스타일의 가이드를 제공해 개발자가 프로토콜을 쉽게 이해하고 적용해 나갈 수 있도록 돕고 있습니다. 이렇게 프로토콜을 조금씩 구현하면서 컴파일러 경고를 해결해 나가다 보면, 자연스럽게 기본적인 FluidTransition 구현이 완료되는 것입니다.

화면 전환의 핵심, FluidTransitionable 뷰 구현하기

대부분의 화면 전환은 사용자가 메시지와 같은 어떤 콘텐츠를 선택했을 때 해당 콘텐츠가 자연스럽게 확대 또는 축소되는 애니메이션을 포함하고 있습니다. FluidTransitionable이라는 프로토콜을 채택한 구조체가 이런 애니메이션의 핵심인데요. 화면 전환이 시작되는 지점과 끝나는 지점 각각의 레이아웃을 설정할 수 있는 인터페이스를 제공하며, 두 레이아웃 사이에서 자연스럽게 애니메이션할 수 있는 기능이 있습니다.

public protocol FluidTransitionable {
    var foreground: SnapshotView { get }
    var background: SnapshotView? { get }
    func configure(with operation: TransitionOperation, animatingTo position: UIViewAnimatingPosition?)
    // ...
}

public final class SnapshotView: UIView {
    public let sourceSnapshot: UIView
    public let destinationSnapshot: UIView
    // ...
}

이 프로토콜은 아래와 같이 한 쌍의 SnapshotView로 구성돼 있고, 각각의 SnapshotView는 또다시 sourceSnapshot, destinationSnapshot으로 구성돼 있습니다. 아래 예시를 기준으로 설명드리면, 메시지 셀의 스냅샷과 이미지 뷰어의 스냅샷을 각각 생성해서 하나의 SnapshotView를 생성할 수 있습니다. 물론 서비스와 화면에 따라 다른 애니메이션 요구 사항이 있을 수 있으므로 꼭 이와 같은 방식을 따를 필요는 없습니다. 필요한 사양에 맞춰 FluidTransitionable 구현체를 작성한 뒤, fluidTransition(_:transitionableFor:transitioningTo:) 메서드에서 리턴하기만 하면 됩니다.

여기서 SnapshotView가 한 쌍으로 구성된 부분이 의아할 수도 있을 것 같은데요. 이 두 개의 뷰는 정확하게 겹쳐 있어서 사용자에게는 하나의 뷰로 보이지만, 사실은 각각 다른 뷰 계층(hierarchy)에 삽입돼 있습니다. 이는 대화방처럼 플로팅 UI가 많은 화면의 경우 특히 유용한데요. animateKeyframes를 사용해 앞에 있는 SnapshotView의 투명도를 애니메이션과 함께 조절하면, 아래 영상처럼 플로팅 UI 밑으로 자연스럽게 깔려서 들어가는 듯한 애니메이션을 간편하게 연출할 수 있기 때문입니다. 작은 디테일이지만 완성도 높은 애니메이션을 제공하고자 한다면 꼭 고려해야 할 부분입니다.

고민과 시행착오

딜레이에 주의하기

UI 로직처럼 메인 스레드에서 실행되는 작업이라면 으레 그렇듯이, 화면 전환 과정에서도 너무 무거운 작업 때문에 버벅임이 발생하지 않도록 각별히 주의를 기울여야 합니다. 특히 FluidTransition의 경우 제스처에 즉각 반응하도록 설계됐기 때문에 사용자는 아주 사소한 딜레이도 어렵지 않게 눈치챌 수 있습니다.

화면 전환 과정에서 버벅임을 유발할 가능성이 있는 가장 대표적인 작업이 뷰 스냅샷을 생성하는 작업인데요. 스냅샷을 생성하는 여러 방법 중 상황에 따라 가장 효율적인 방법을 적절히 선택할 필요가 있습니다. 

아래 코드는 뷰의 스냅샷을 생성하는 세 가지 방법을 나열한 것입니다.

// Method 1 
let image = UIImage.create(size: sourceView.bounds.size) { _ in  // custom extension
    sourceView.drawHierarchy(in: sourceView.bounds, afterScreenUpdates: true)
}
return UIImageView(image: image)

// Method 2
return sourceView.snapshotView(afterScreenUpdates: true)

// Method 3
return sourceView.snapshotView(afterScreenUpdates: false)

첫 번째 방법은 UIGraphicsImageRendererContextUIView의 그래픽 요소를 직접 그리는 방식으로, Apple 문서에 따르면 세 방법 중 가장 비효율적입니다. 이미지에 블러를 적용하는 등 꼭 필요한 경우가 아니라면 사용을 지양해야 할 것입니다.

그렇다면 snapshotView(afterScreenUpdates:)를 사용하는 것이 항상 더 빠를까요? 아쉽게도 항상 그렇지는 않습니다. 특히 afterScreenUpdates 인자를 true로 지정하면 최근 변경 사항을 포함해서 한 번의 레이아웃 사이클을 거친 뒤 스냅샷을 생성하기 때문에, 꽤 오랜 시간(iPhone 14 Pro 기준으로 약 13ms)이 소요되는 것을 확인할 수 있었습니다. 화면 주사율이 120Hz인 기기에서 한 사이클 당 8ms을 넘으면 프레임 드롭이 발생할 수 있기 때문에, 꼭 필요한 경우에만 유의하면서 사용할 필요가 있습니다.

화면 전환 시작 시점에 스냅샷을 찍어야 하는 뷰가 이미 레이아웃이 완료된 상태라는 것이 보장된다면, 세 번째 방법을 사용하는 게 가장 효율적입니다. 테스트 결과 iPhone 14 Pro 기준으로 1ms 남짓 소요됐습니다.

snapshotView(afterScreenUpdates:)를 사용할 수 없는 경우도 있는데요. 만약 스냅샷을 생성하고자 하는 뷰의 크기가 아주 크다면 소요 시간이 수백 밀리초 단위로 매우 오래 걸리거나, 심지어는 스냅샷 생성에 실패할 가능성도 있습니다. 이런 경우 섬네일 데이터를 사용해서 직접 UIImageView를 생성하는 것이 가장 효율적인 방법일 수 있습니다.

사용자 입력에 항상 반응하기

FluidTransition에서는 화면 전환 도중에도 출발 또는 도착 화면이 사용자의 입력에 항상 반응할 준비가 되어 있어야 한다고 생각했습니다. 예를 들어, 사용자가 화면 전환을 취소한 직후 다른 아이템을 터치했을 때, 별도로 처리해 주지 않는다면 이 터치 입력은 무시됩니다. 이렇게 되면 앞에서 애써 구현한 중단 가능한 화면 전환이 무색해질 수 있기 때문에, 사용자가 언제든지 화면 전환을 취소하고 다른 작업을 바로 시작할 수 있도록 했습니다.

생각보다 방법은 간단했는데요. isUserInteractionEnabled 속성을 적절한 타이밍에 설정해 주면 됩니다. 다만 모달과 내비게이션 중 어느 방식으로 전환하는지에 따라 다르게 설정해야 하는 부분이 있었습니다. 기본적으로 화면 전환 애니메이션은 UIViewControllerContextTransitioning 객체에서 제공하는 containerView 안에서 진행되는데, 화면 전환 방식에 따라 이 containerView의 뷰 계층이 달라지기 때문입니다.

모달 방식에서는 containerView가 출발 화면을 덮으면서 애니메이션이 발생하므로, 사용자가 출발 화면으로 되돌아가기 위해 손가락을 떼는 순간 containerViewisUserInteractionEnabled 속성을 false로 설정해야 합니다. 그렇게 해야 애니메이션 도중의 터치 이벤트가 sourceView에 온전히 전달될 수 있기 때문입니다. 반면 내비게이션 방식에서는 containerView 내부에 출발 화면과 도착 화면이 삽입되므로, 출발 화면과 도착 화면의 isUserInteractionEnabled 속성을 적절한 타이밍에 위 그림 아래 표와 같이 설정해야 합니다.

이렇게 간단한 수정만으로 사용자 경험을 끌어올릴 수 있지만, 이렇게 사용자 입력에 항상 반응하게 되면 예상치 못한 문제가 발생할 수 있습니다. 가령 도착 화면에서 출발 화면으로 되돌아가는 도중 사용자가 다른 아이템을 '따닥' 선택해서 동시에 여러 번의 화면 전환 요청을 발생시킨다면 어떻게 될까요? iOS에서는 이런 상황에서 별도의 동시성 처리를 하지 않고 요청이 오는 대로 화면 전환을 수행하기 때문에, FluidTransitionDispatcher 내부에서 이런 상황을 적절히 통제해 줄 필요가 있습니다.

마치며

지금까지 LINE 앱 대화방에서 화면이 더 자연스럽고 쾌적하게 전환되도록 개선한 경험을 공유드렸습니다. 평소 앱 사용자로서는 별생각 없이 지나쳤던 부분이지만, 직접 구현해 보니 세세한 요소 하나하나가 사용자 경험에 얼마나 큰 영향을 미치는지 실감했습니다. 사용자가 자연스럽게 느끼는 것은, 뒤에서 수많은 고민과 실험을 통해 만들어 낸 결과물이라는 것을 이번 프로젝트를 통해 다시 한번 확인할 수 있었습니다.

각종 그래프와 기술 지표로 표현할 수 있는 성능도 물론 중요하지만, 사용자가 눈과 손가락으로 직관적으로 느끼는 자연스러움도 앱의 전반적인 품질과 성능을 대변하는 하나의 축이라고 생각합니다. LINE 앱처럼 수억의 사용자가 매일 이용하는 앱이라면 더욱 그렇습니다. 이번 프로젝트에서 얻은 교훈과 여러 시행착오를 소중한 가이드라인으로 삼아, 앞으로도 사용자에게 최상의 경험을 제공하기 위한 여정을 계속하려고 합니다. 긴 글 읽어주셔서 감사합니다.