영화에서 잘 알려진 것처럼, 애니메이션(Animation)은 어떤 사물의 움직임을 표현하는 기술이다. 컴퓨터 애니메이션은 매 장면마다 연속된 이미지를 화면에 출력함으로써 개체가 마치 어떤 동작을 수행하는 것처럼 표현한다. 애니메이션 기술은 특히 만화나 게임 등 여러 콘텐츠에서 부드러운 동작 표현을 위해 사용되어 왔다.

이러한 애니메이션은 UI에서도 예외가 아니다. UI 시스템은 애니메이션을 통해 콘텐츠 뿐만 아니라, UI 객체의 상태 전이를 시각적으로 부각시킬 수 있다. 이는 사용자의 시선을 집중시킴으로써 사용자의 이해를 높이고 UI 앱의 사용성을 개선하는데 도움을 준다. 무엇보다도 애니메이션은 UI 앱의 콘텐츠를 보다 생동감있게 표현하도록 도와줌으로써 사용자의 이목을 집중시킬 수 있다는 점에서 유용하다.

한편, UI 시스템에서는 애니메이션과 더불어 이펙트(Effect)의 정의를 동일 범주에서 해석할 수 있다. UI 앱은 이펙트로서 애니메이션은 물론, 사용자 제스처에 따른 모션 그래픽 효과도 표현할 수 있다. 다만, 애니메이션은 어떤 동적 표현을 범주로 제한한다면, 이펙트는 정적 표현도 포함한다. 예로, 이미지 후처리(Post processing)는 이펙트의 범주로서 해석 가능하다.

이번 장에서는 애니메이션과 이펙트를 구체적으로 살펴본다. 최신 UI에서 애니메이션과 이펙트는 고급 UI를 위한 필수 요소로 작용하기 때문에 이들의 적용 사례는 물론, 구현 메커니즘을 심도있게 학습해 보도록 하자.


1. 학습 목표

이번 장을 통해 다음 사항을 학습해 보자.

  • 프레임 기반 애니메이션 재생 원리를 이해한다.
  • FPS를 이애하고 메인루프에서 이를 결정하는 원리를 살펴본다.
  • UI 엔진에서 커스텀이 가능한 사용자 애니메이션을 제공하는 방식을 살펴본다.
  • 프로퍼티 기반 애니메이션 재생 방법을 살펴본다.
  • 애니메이션 시간을 조작하고 애니메이션 가속 제어 방식을 이해한다.
  • 애니메이션 기반 이펙트 적용 사례를 살펴본다.
  • 사용자 상호작용을 위한 제스처 이벤트 제공 방법을 살펴본다.
  • 태스크 스케줄링 기반의 멀티 태스킹 제공 방법을 살펴본다.


  • 2. 애니메이션

    2.1 프레임 애니메이션

    프레임(Frame) 애니메이션 동작 이해를 위한 간단한 예시로서, 그림 6.1과 같이 연속된 장면을 시간 순서대로 앱 화면에 출력한다고 가정해 보자.


    그림 1: 연속된 장면 재생 (Calvin and Hobbes)

    프레임 애니메이션은 애니메이션 영화를 떠올리면 쉽게 이해할 수 있다. 애니메이션의 기본 원리는 기록한 장면을 순차대로 출력하는데 있으며 프레임 애니메이션을 구현하기 위해서는 다음 두 가지 조건을 고려해야 한다.

  • (연속된) 장면 준비
  • 준비된 장면을 시간 순서대로 화면에 출력

  • 위 두 조건을 코드로 옮겨보자.

    /* * 10장의 장면으로 구성된 애니메이션을 구현한다. * 이미디어트 렌더링 방식을 이용한다. */ frameAnimation(): //장면 1 준비 UIImage img1; img1.open(“frame0.jpg”); img1.geometry(x, y, w, h); //이미지를 화면에 다시 그리기 위해 무효 영역을 설정한다. invalidateArea(x, y, w, h); //화면 갱신을 요청한다. drawScreen(); //장면 2 준비 UIImage img2; img2.open(“frame1.jpg”); img2.geometry(x, y, w, h); //이미지를 화면에 다시 그리기 위해 무효 영역을 설정한다. invalidateArea(x, y, w, h); //화면 갱신을 요청한다. drawScreen(); //동일하게 장면 10까지 반복 작업을 수행한다.

    코드 1: 프레임 애니메이션 구현

    코드 1에서는 순차적으로 장면 이미지를 준비하여 화면에 출력 요청을 한다. 물론 프로세서 동작 환경에 따라 각 장면을 출력하는데 걸리는 소요시간이 다르기 때문에 어떤 고정된 시간 동안 애니메이션이 출력되어야 한다면, FPS(Frames Per Second)를 고려해야한다. 일반적으로 초당 30 ~ 60 장면을 출력하면 부드러운 애니메이션을 표현할 수 있는데 이와 관련된 자세한 사항은 다음 절에서 살펴본다.
    드로잉을 직접 요청받지 않는 리테인드 모드의 그래픽스 시스템에서는 UI 엔진이 매 프레임마다 UI 앱에게 장면을 준비하도록 요청한다. 이는 UI 엔진이 메인루프를 통해 프레임 변화를 감지하고 새로운 장면을 출력해야 하는 시점을 직접 통제하기 때문에 가능하다. 기본적으로 메인루프는 사이클을 매 반복할 때마다 화면을 출력하므로 화면 출력 전에 애니메이션 동작 이벤트를 발생시킬 수 있다.

    /* * 10장의 장면으로 구성된 애니메이션을 구현한다. * 리테인드 렌더링 방식을 이용한다. * 애니메이션 콜백을 등록하여 콜백 함수에서 장면을 구축하도록 한다. */ frameAnimation(): /* 애니메이션 객체를 하나 생성한다. UIAnimation은 UIEngine으로부터 이벤트 신호를 받아, 매 프레임마다 updatedCb()을 호출한다.*/ UIAnimation animation; animation.addEventCb(UIAnimation.Updated, updatedCb); //애니메이션을 가동한다. animation.play(); /* * 애니메이션 콜백 함수 */ updatedCb(): //현재 장면 준비 UIImage img; String path = “frame” + frame + “.jpg”; //path = frame0.jpg img.open(path); img.geometry(x, y, w, h); img.show(); ++frame; //다음 장면 번호: frame1

    코드 2: 메인루프와 통합된 애니메이션 구현


    그림 2: 메인루프 애니메이션 수행 단계

    사용자 애니메이션 수행 함수를 메인루프와 연결해주는 UIAnimation은 생성된 개체 수만큼 UIEngine으로부터 참조되어 수행될 수 있다. UIEngine은 생성된 UIAnimation 인스턴스를 리스트로 관리하고 있다가 메인루프의 이벤트 처리 단계에서 일괄적으로 처리 가능하다. 이 때 UIAnimation 인스턴스는 자신에게 등록된 사용자 콜백 함수를 호출하여 사용자가 애니메이션을 구현할 수 있도록 도와주는 역할을 수행한다.

    /* * UIAnimation은 UIEngine의 애니메이션 목록에 자신의 인스턴스를 추가한다. */ UIAnimation.constructor(): UIEngine.addAnimation(self);

    코드 3: UIEngine과 UIAnimation의 연동

    3장에서 소개한 GIF(Graphics Interchange Format)는 프레임 애니메이션의 대표 예로서 적절하다. GIF는 하나의 애니메이션을 구성하는 연속된 장면 이미지를 기록한다. 앱으로부터 GIF 출력 요청을 받은 UI 엔진은 애니메이션 처리 루틴을 통해 GIF로부터 출력해야 할 현재 프레임 번호를 결정하고 이를 렌더링 엔진에게 전달하며 렌더링 엔진은 이미지 디코딩 과정을 거쳐 GIF 데이터로부터 해당 프레임의 비트맵 이미지를 생성한다. 생성된 이미지는 지정된 렌더링 절차를 거쳐 화면에 출력된다. UI 엔진에 의해 수행되는 애니메이션 처리 루틴은 메인루프를 통해 반복 수행되면서 프레임 번호를 계속 갱신하므로 애니메이션이 동작하는 결과를 최종적으로 보여줄 수 있다.

    //UIAnimatedImage는 GIF처럼 애니메이션을 갖춘 이미지 출력하는 기능을 담당한다. UIAnimatedImage img; img.open(“sample.gif”); //GIF 파일을 불러온다. img.geometry(x, y, w, h); img.play(); //애니메이션 재생

    코드 4: GIF 애니메이션 구현

    /* * 이미지 애니메이션을 재생한다. */ UIAnimatedImage.play(): self.animation; self.animation.addEventCb(UIAnimation.Updated, /* 람다를 이용하여 애니메이션 콜백 함수를 구현한다. lambda()의 인자로 전달되는 obj는 UIAnimation 자신을, target은 호출한 UIAnimatedImage 객체를 가리킨다. */ lambda(UIAnimation obj, UIAnimatedImage target): //이미지 프레임 번호를 증가한다. target.frame(target.frameNum() + 1); /* 애니메이션 프레임이 끝에 도달하면 애니메이션을 종료한다. 이는 콜백 함수의 반환값으로 결정할 수 있다. true: 진행, false: 종료 */ if (target.frameNum() < target.frameCount()) return true; else return false; self ); self.animation.play();

    코드 5: GIF 애니메이션 재생부

    코드 5는 UIAnimatedImage 컨트롤을 이용하여 프레임 애니메이션을 재생하는 코드의 핵심 로직을 보여준다. UIAnimatedImage는 애니메이션을 재생하기 위해 UIAnimation 인스턴스를 생성하며 애니메이션 콜백 함수 내에서 프레임 번호를 증가하는 작업을 수행한다. 만약 프레임 번호가 마지막 프레임에 도달한다면 애니메이션을 중단시킨다. UIAnimatedImage.frame()을 통해 UIAnimatedImage가 출력해야할 애니메이션 프레임 번호를 전달받는다. UIAnimatedImage는 렌더링 엔진을 통해 GIF로부터 해당 프레임의 이미지를 디코딩하여 화면에 출력할 수 있다고 전제한다.

    이때, 우리가 출력해야할 애니메이션을 특정 시간 동안에 재생해야 한다면 어떻게 해야할까? 연속된 프레임의 이미지를 출력하는 방법을 이해했다면 이제 여러분은 위와 같은 의문이 생겼을지도 모른다. 실제로 애니메이션 재생 시간은 애니메이션마다 모두 다르다.


    2.2 FPS 제어

    FPS(Frames Per Second)는 프레임율(Frame Rate)라고도 하며 초당 화면 출력 수를 의미한다. 기본적으로 FPS가 높을수록 애니메이션을 더 부드럽게 표현할 수 있는데 게임과 영화처럼 애니메이션이 중요한 콘텐츠에서는 대게 60fps를 지향한다. 개인마다 차이는 존재하지만, 60fps 미만에서는 시청자는 애니메이션 동작이 부드럽지 않다는 느낌을 받을 수 있으며 30fps 미만이라면 그 차이를 쉽게 인지할 수 있다.

    그렇다면 왜 60fps일까? 60fps는 시험적으로 결정한 수치로 볼 수 있는데 실제 60fps 이상에서는 사람의 시각으로는 그 차이를 인지하기 어렵다. 그렇기 때문에 60fps 이상으로 화면 출력을 하면, 더 많은 화면 출력을 위해 프로세싱 부하와 전력 소모만 증가할 뿐이다. 이런 이유로 최신의 모니터나 휴대용 전자 장비의 화면 출력 장치의 화면 주사율이 최대 60Hz로 설계된다. 반대로, 소프트웨어가 초당 60장 이상의 장면 이미지를 생성하더라도 화면 출력 기기가 이를 뒷바쳐주지 않으면 소용이 없다. 60fps는 현재 상용 제품의 표준 수치로서 통용되며(일부 3D 모니터의 경우 좌,우 시야 장면 출력을 위해 120Hz까지 확장한다.) 최신 소프트웨어 플랫폼은 60fps을 출력할 수 있는 동작 환경을 가능한 보장해야 한다.

    FPS의 개념을 이해했다면 실제 동작 예시를 들어보자. 3초짜리 애니메이션을 60fps로 출력한다면 총 몇 장의 장면 이미지를 생성해야할까? 60 x 3 = 180이며, 이는 UIEngine.run()에 의해 수행되는 메인루프의 반복 횟수가 총 180번 임을 의미하며 초당 60번을 반복해야 한다. 달리 말하면, 메인루프는 while() 구문을 통해 초당 60번의 화면 갱신(렌더링)을 수행해야만 한다.


    그림 3: 60fps 고정 메인루프 수행 시퀀스

    60fps를 보장하기 위해서는 애니메이션의 화면 출력은 대략 0.0167초 내에 완성되어야 한다. 만약 메인루프를 한 번 수행하는데 걸리는 평균 시간이 이보다 적다면 60fps를 초과할 수도 있다. 이 경우 60fps를 초과하지 않도록 메인루프의 동작을 지연시켜야 한다. 하지만 반대로, 만약 메인루프를 한 번 수행하는데 0.0167초를 초과한다면 60fps를 보장할 수 없다. 이 경우 우리가 고려할 수 있는 방법은 플랫폼 또는 앱을 최적화하거나 더 좋은 성능의 하드웨어 프로세서로 교체하는 것 뿐이다.

    시스템이 수직 동기화(vsync)를 요구한다면, 시간 엄수는 훨씬 더 엄격하다. 특히 멀티 윈도우 환경이라면, 디스플레이 장치는 여러 클라이언트(UI 앱)이 공유하는 자원에 해당하기 때문에 한 클라이언트만을 위해 화면 갱신(refresh) 작업을 수행할 수가 없게 된다. 이러한 구조에서는 윈도우 서버가 일정 시간 간격(초당 60회)으로 각 클라이언트(UI 앱)의 준비된 화면을 합성한 후 최종 이미지를 커널을 통해 출력 요청한다. 하드웨어 특성상 화면 갱신(refresh) 주기는 일정하므로 윈도우 서버의 작업 수행은 마치 시간을 엄수하며 출발하는 기차와도 같다. 따라서, 윈도우 서버의 화면 출력 주기에 맞춰 클라언트가 화면 출력 작업을 완수하지 못한다면 윈도우 서버의 다음 화면 출력 시점까지 클라이언트의 출력 결과물을 화면으로 내보낼 수 없다. 이 경우, 클라이언트는 해당 프레임의 출력 결과물을 다음 프레임으로 지연시켜야 한다.

    한편, 클라이언트가 화면 출력물을 출력 버퍼에 기록하는 동안 윈도우 서버가 해당 버퍼에 접근하는 것을 방지하기 위해 이중(Double) 또는 삼중(Triple)의 다수의 출력 버퍼를 이용할 수 있다. 만약 클라이언트가 화면 출력 작업이 지연될 경우, 윈도우 서버는 클라이언트의 주 버퍼(Primary Buffer)에 저장된 이전 화면을 그대로 활용하게 된다.


    그림 4: 더블 버퍼 출력 시스템

     스크린 티어링(Screen Tearing)


    스크린 티어링(화면 찢김 현상)은 수직 동기화를 하지 않는 경우 발생하며 한 화면에 여러 프레임의 이미지가 동시에 기록되는 현상이다. 이는 출력 버퍼에 다음 장면을 기록하는 중, 디스플레이 장치로 출력 버퍼의 데이터를 내보내어 발생하며 마치 화면이 찢어지는 것처럼 보이게 된다.


    이를 방지하기 위해 다중 버퍼를 이용하거나, 모니터 주사가 완전히 끝날 때까지 비디오 카드에서 메모리를 기록하는 작업을 대기하고 동기화 작업을 수행할 수 있다.


    2.3 시간 측정

    60fps를 목표로 메인루프를 가동해 보자. 우리가 목표로 하는 0.0167초를 계산하기 위해서 먼저 시간을 측정하는 방법을 확보한다.
    각 운영체제에서 제공하는 타임스탬프(Timestamp) 기능을 활용하면 경과 시간을 손쉽게 계산할 수 있다.

  • 리눅스(유닉스 계열): clock_gettime()
  • 윈도우즈: timeGetTime()
  • : mach_absolute_time()

  • /* * Linux clock_gettime()을 이용한 현재 시간 구하기 (C 언어) * UIEngine.time()이 time() 함수를 호출한다고 가정한다. */ #include double time() { struct timespec timestamp; double seconds; //실시간 시간정보를 timestamp에 기록한다. clock_gettime(CLOCK_REALTIME, ×tamp); //timestamp에 기록된 시간을 초 단위로 환산한다. //Nanoseconds -> Microseconds -> Milliseconds -> Seconds seconds = (timestamp.tv_sec + timestamp.tv_nsec) / 1000000000L; return seconds; }

    코드 6: 타임스탬프 구현 예

    시간을 측정하는 방법을 확보했다면, 메인루프에서는 이를 이용하여 이전 사이클의 시간과 현재 사이클의 시간을 기록하고 이 두 시간의 차, 즉 경과시간(Elapsed Time)를 통해 메인루프가 수행해야할 시간을 확인한다. 만약 시간 소모가 우리가 예상한 시간 0.0167초보다 작다면, 남은 시간만큼 대기 상태(idle) 또는 수면 상태(sleep)로 메인루프를 지연시킴으로써 한 프레임을 수행하는데 걸리는 시간을 조율할 수 있다.


    그림 5: FPS 조율을 위한 프레임 경과시간 계산

    프레임 시간을 구하는 데 있어서 실제로는 다양한 변수가 존재하므로 단순히 두 시간의 차만으로는 정확한 FPS 측정이 어려울 수도 있다. 특히 매 프레임 메인루프 수행 시간의 변동폭이 크다면, 단순히 이전 프레임간의 경과 시간 계산 방식보다는 누적된 프레임의 평균 시간을 통해 메인루프 수행 시간을 조율하는 것도 하나의 방법이다.

     CPU Scaling Governor

    FPS를 보다 정확하게 측정하기 위해서 CPU 주파수(Frequency)를 조율하는 방법을 알아두자. 두 앱 간 성능 비교 또는 최적화를 통해 앱 성능이 얼마나 향상되었는지 측정하기 위해서는 앱 프로세스의 동작 환경이 가급적 동일해야 한다. 특히, 프로세서 파워는 프로세스 성능에 가장 큰 영향을 주기 때문에 성능 측정 시 프로세서 파워에 변화가 없어야 한다. 리눅스에서는 CPU Frequency를 조절함으로써 CPU 파워를 실시간으로 제어한다. 이러한 제어 정책을 CPU Scaling Governor라고 하며 특히 소모 전력에 민감한 임베디드 시스템에서는 CPU Frequency를 조절함으로써 전력 소모를 줄이는 동작을 수행한다. 문제는 이 정책으로 인해 성능 측정 중 CPU 파워가 변할 수 있으며 이는 성능 측정 결과에도 적지 않은 영향을 미칠 수가 있다.
    리눅스에서는 CPU Frequency 제어 방식 정책을 동작 유형별로 정의하고 있다. 다음 명령어를 통해 현 시스템에서 설정 가능한 정책을 열람한다.

    $cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_available_governors

    performance, powersave, userspace, schedutil
    이 중 가능한 가장 높은 성능을 내도록 performance 정책을 이용한다. $echo performance > /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor
    다른 방법으로는 CPU Frequency를 직접 고정한다.
    현재 설정된 CPU Frequency의 최소, 최대 범위를 확인하기 위해서 다음 명령어를 이용한다.

    $cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_min_freq

    1200000

    $cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq

    3300000
    현 시스템에서 지정 가능한 CPU Frequency 목록을 확인한다. $cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_available_frequencies 1200000 2400000 3300000
    가장 높은 CPU 파워로 지정하기 위해 scaling_min_freq와 scaling_max_freq를 최대 수치(3300000)로 지정한다.


    $echo 3300000 > /sys/devices/system/cpu/cpu*/cpufreq/scaling_min_freq

    $echo 3300000 > /sys/devices/system/cpu/cpu*/cpufreq/scaling_max_freq
    위 설정의 cpu*의 *은 cpu 번호로 대처한다. 예로, 8코어는 cpu0, cpu1, cpu2, cpu3 … cpu7. 멀티 코어를 활용한다면, 모든 CPU에 대해서 작업을 동일하게 수행해야 한다.
    CPU Scaling Governor에 대한 보다 자세한 사항은 다음 문서를 참조하자.

    https://www.kernel.org/doc/html/v4.14/admin-guide/pm/cpufreq.html



    2.4 애니메이션 재생 시간 제어

    FPS를 제어하고 시간 측정 방법을 이해했다면, 코드 5의 GIF 애니메이션 구현부로 돌아가 보자. 코드 6.5는 프레임(Frame) 애니메이션을 구현하는데 특정 시간동안 애니메이션을 제어하는 방법이 필요하다. 만약, 애니메이션 재생 시간이 3초라고 가정한다면 우리는 UIAnimation의 lambda()를 3초동안 총 180번 호출받아야 한다.

    우선 UIAnimation의 내부 구현은 무시한 채, UIAnimation()이 동작 시간을 추가 인자로 받고, 콜백 함수에서 애니메이션 재생 흐름(progress)을 추가 인자로 전달해 보자.

    /* * 이미지 애니메이션을 재생한다. */ UIAnimatedImage.play(): /* 람다를 이용하여 애니메이션 콜백 함수를 구현한다. UIAnimation()에 전달한 duration()은 애니메이션 동작 시간(3초)을 가리키고, lambda()의 인자로 애니메이션 진행 과정(progress)를 추가로 전달한다. target은 UIAnimatedImage을 가리킨다. progress는 0 ~ 1 사이의 정규값으로 전달된다. 0은 시작 시간, 1은 종료 시간 즉, 3초가 경과된 시점에 전달되는 값이다. */ self.animation = UIAnimation(self.duration()); self.animation.addEventCb(UIAnimation.Updated, lambda(obj, progress, target): //현재 출력할 프레임 번호를 지정한다. target.frame(target.frameCount() * progress); self ); //play()에서는 애니메이션 시작 시간을 기록한다. self.animation.play();

    코드 7: GIF 애니메이션 재생 시간 지정

    progress는 0에서 1 사이의 정규값으로 전달받는다. 0은 애니메이션의 시작, 1은 끝으로 이해하고 이 구간에 GIF 프레임 번호를 매핑한다.

    핵심은, UIAnimation에서 콜백 함수 lambda()를 호출하는 부분이다. UIEngine은 메인루프를 거쳐 등록된 UIAnimation을 갱신(update)한다(그림 1). 이 때, UIEngine은 앞서 살펴본 시간을 구하는 time() 함수를 이용하여 현재 시간을 UIAnimation에게 전달한다. 현재 시간을 전달받은 UIAnimation은 경과 시간을 계산하고 이를 정규값으로 변환하여 진행률을 결정한다. 이 값은 콜백 함수의 progress 인자로 전달된다.

    /* * UIAnimation의 경과시간을 갱신한다. * current: 현재 타임스탬프 */ UIAnimation.update(current): /* 현재 출력할 프레임 번호를 지정한다. begin은 play()시 기록한 시간이며 duration은 UIAnimation 생성시 전달받은 재생시간(3초)이다. */ progress = (current - self.begin) / self.duration; /* 콜백 함수를 호출하고 필요시 Animation을 종료한다. */ if (self.cbFunc(progress, self.callerObj) == false) self.stop(); self.unref();

    코드 8: UIAnimation progress 계산


    2.5 프로퍼티 애니메이션

    프로퍼티 애니메이션은 오브젝트의 기본 속성(Property)을 통해 애니메이션 효과를 준다. 일부 프레임워크에서는 트윈(Tween) 애니메이션이라고도 한다. 프로퍼티 애니메이션 역시 앞서 구축한 UIAnimation 기능을 그대로 활용할 수 있다. 핵심은, 앞서 살펴본 프레임 애니메이션에서 매 프레임마다 장면 번호를 갱신한 것과 동일하게, 변화를 주고자 하는 오브젝트의 속성을 결정하고 이 속성을 애니메이션 콜백 함수에서 새로 갱신하는 것이다. 이러한 속성은 대표적으로, 오브젝트의 위치, 크기, 색상, 투명도(Opacity) 등이 있으나 사실상, 프레임워크에서 허용하는 변경가능한 속성은 모두 대상이 될 수 있다.


    그림 6: 프로퍼티 애니메이션

    예시로, 객체의 이동 애니메이션을 구현하기 위해 위치 속성을 변경해 보도록 하자. 이 경우, 우리는 객체의 시작과 끝 위치 값을 입력으로 받고 이 두 위치 값 사이에서 애니메이션 progress를 토대로 현재 애니메이션 위치를 계산한다.

    /* 현재 애니메이션 위치를 계산한다. toPos와 fromPos는 property 데이터에 저장된 변수이다. curPos는 x, y 값을 갖는 데이터형 변수이다. */ curPos = (prop.toPos - prop.fromPos) * progress; prop.obj.position(curPos); ...

    코드 9: 이동 애니메이션 콜백 함수 구현부

    색상의 경우도 다르지 않다.

    /* 현재 애니메이션 색상을 계산한다. toColor와 fromColor는 미리 저장된 변수이다. */ curColor.r = (prop.toColor.r - prop.fromColor.r) * progress; curColor.g = (prop.toColor.g - prop.fromColor.g) * progress; curColor.b = (prop.toColor.b - prop.fromColor.b) * progress; prop.obj.color(curColor); ...

    코드 10: 색상 애니메이션 콜백 함수 구현부

    코드 9와 10에서 등장하는 property 데이터(prop)의 출처는 우선 무시해도 좋다. 이는 애니메이션에 적용할 프로퍼티 정보와 대상 오브젝트를 하나로 구조화한 데이터이며 외부로부터 UIAnimation에 전달된다.

    오브젝트의 일부 속성은 상호 베타적이기 때문에 독릭접 또는 서로 동시에 적용이 가능하다. 프로퍼티 애니메이션의 오브젝트 속성은 이처럼 필요시 하나의 애니메이션에 혼합되어 구동 가능하다.

    //위치 curPos = (prop.toPos - prop.fromPos) * progress; prop.obj.position(curPos); //색상 curColor.r = (prop.toColor.r - prop.fromColor.r) * progress; curColor.g = (prop.toColor.g - prop.fromColor.g) * progress; curColor.b = (prop.toColor.b - prop.fromColor.b) * progress; prop.obj.color(curColor); //크기 curSize = (prop.toSize - prop.fromSize) * progress; prop.obj.size(curSize); //투명도 curOpacity = (prop.toOpacity - prop.fromOpacity) * progress; prop.obj.opacity(curOpacity);

    코드 11: 통합 프로퍼티 애니메이션 콜백 함수 구현부

    프로퍼티 애니메이션을 구현하는 UI 앱은 변경하고자 하는 속성 값을 지정해야 한다. 이러한 속성 값은 코드 11의 toPos와 toColor, toSize, toOpacity로서 활용된다. 프레임워크는 사용자가 애니메이션의 프로퍼티 값을 쉽게 전달할 수 있도록 편의 인터페이스를 제공할 필요가 있다.

    UIPropertyTransition은 이를 위해 설계한 새로운 인터페이스이다. UIPropertyTransition은 UIAnimation을 hasa 관계로 구축, 매 프레임 애니메이션이 수행될 수 있도록 도와준다. 무엇보다도 앱 개발자가 애니메이션을 더욱 직관적으로 구현할 수 있도록 편의 인터페이스를 제공한다.

    /* 프로퍼티 애니메이션을 구동하기 위한 인스턴스, UIPropertyTransition은 UITransition을 확장한다. */ UIPropertyTransition transition; //애니메이션에 적용할 속성을 지정한다. transition.prop.toPos(100, 200); transition.prop.toScale(1.5); transition.prop.toOpacity(0.5); transition.prop.toColor(“red”); //애니메이션 추가 정보(시간, 반복 횟수, 되감기 등)를 기입한다. transition.duration(2.0); transition.repeat(3); transition.rewind(true); //애니메이션 대상 객체 transition.target(obj); //애니메이션 재생 transition.play();

    코드 12: 프로퍼티 애니메이션를 위한 UIPropertyTransition 인터페이스

    애니메이션의 공통 기능의 인터페이스를 프로퍼티로부터 분리한다면 추후 다양한 목적의 애니메이션 인터페이스로 확장이 용이하다. 이를 위한 설계 방향은 다음 그림과 같다. 

    그림 7: UITransition 클래스 다이어그램


    2.6 애니메이션 가속 제어

    기본적으로 UI 프레임워크에서는 앱 개발자가 애니메이션 가속에 변화를 주어 심미적 효과를 개선할 수 있도록 이징(Easing)을 정의한다. UI 프레임워크에서는 이징 개념을 인터폴레이터(Interpolator)로 구현할 수 있으며 인터폴레이터는 애니메이션 재생 시 가속을 조정하기 위한 방안으로 코드 6.8의 progress의 값을 조정하는 역할을 수행한다. 실제로 progress는 지정된 시간동안 객체의 변화 속도를 결정하는 변수에 해당하는데 코드 6.11를 보면 애니메이션을 수행 중인 객체의 속성을 결정하기 위해 progress를 변수로 사용하는 것을 확인할 수 있다. 따라서, 인터폴레이터를 통해 매 프레임 progress 증가분 즉, progress 값을 조정할 수 있다면 객체의 애니메이션 변이도 달라질 것이다. 가령, 네 프레임에 걸쳐 애니메이션을 종료한다고 가정해 보자. progress는 [0.25, 0.5, 0.75, 1]처럼 선형 증가에 가까운 값으로서 전달될 수 있다. 이 때 인터폴레이터가 개입한다면, 이 값을 [0.125, 0.2, 0.5, 1]로 바꿀 수 있으며 이는 곧 객체의 변화 속도에 영향을 미친다. progress의 범위를 잘 조절한다면, 반동(Bounce)과 같은 효과를 주는 것 역시 가능하다.

    인터폴레이터는 원래의 progress(그림 6.7의 progress’) 값을 입력으로 받아 특정한 수식 또는 룰에 의해 이 progress의 값을 변경한 값을 계산한다. 이 후 이 값은 UIAnimator의 콜백 함수의 progress 인자로 곧장 전달된다.


    그림 8: UIInterpolator의 동작 관계

    일반적으로 UI 프레임워크가 결정하는 기본 애니메이션은 등속이지만 인터폴레이터가 제공할 수 있는 애니메이션은 다음과 같은 속도 곡선의 형태를 보인다.


    그림 9: Easing Animation 기본 곡선

    인터폴레이터의 구현부는 단순하다. 핵심은 우리가 출력하고자 하는 곡선 그래프를 선정한 후, 등속에 해당하는 선형(Linear)의 progress 값을 Input 값으로 이용하는 것이다. 선정한 곡선 그래프에서 Input 값에 해당하는 Output 값이 우리가 최종적으로 UIAnimator의 콜백 함수에 전달할 progress의 값이 된다. 곡선 그래프를 표현하는 방법은 벡터 그래픽스 4.6절 그래프를 참조하도록 하자.

    간단한 예시로서, 다음 코드는 Ease Out를 구현한다.

    /* * UIEaseOutInterpolator는 UIInerpolator 인터페이스를 구현한다. * 멤버 함수 map()은 UIAnimator에 의해 호출되며, progress(0 - 1) 사이의 값을 * 사인 곡선(0 - 90도)에 매핑한 결과값을 반환한다. UIAnimator로 반환된 값은 사용자 * 콜백 함수의 progress 인수로서 활용된다. */ UIEaseOutInterpolator implements UIInterpolator: func(progress): return sin(progress * 90);

    코드 13: Ease Out Interpolator 구현부

    코드 13에서 눈여겨 볼 사항은 UIInterpolator 인터페이스를 구현하는 점에 있다. 인터폴레이터 기능을 인터페이스로 제공한다면, 추후에 사용자가 원하는 동작의 인터폴레이터도 추가가 가능하다.

    UICustomInterpolator implements UIInterpolator: func(progress): //사용자가 원하는 그래프 곡선에 맞춰 progress의 값을 변환시킨다. return xxx(progress);

    코드 14: Custom Interpolator 구현

    프레임워크에서 미리 준비된 인터폴레이터는 또는 사용자가 추가한 인터폴레이터는 UIAnimator에 전달되어 사용할 수 있다.

    UICustomInterpolator myInterpolator; //사용자 커스텀 인터폴레이터 //인터폴레이터를 UIAnimation에 적용한다. UIAnimation animation = UIAnimation(...); animation.interp(myInterpolator); animation.play(); //혹은, UIPropertyTransition에 적용할 수도 있다. UIPropertyTransition transition; transition.interp(myInterpolator); ...

    코드 15: Interpolator 적용 예


    3. 이펙트

    애니메이션을 구현할 수 있는 기반 구조(Infrastructure)를 구축했으므로, 이펙트를 구현해 보도록 하자. 이미지 프로세싱 6.3절 필터에서는 이미지 효과를 구현하는 대표적인 방안으로서 포스트 프로세싱을 구현하기 적합한 메커니즘이다. 만약 애니메이션과 이미지 필터를 결합한다면 동적으로 가공할 수 있는, 움직이는 이미지 결과물을 만들어 낼 수 있다.

    이미지 프로세싱 그림 42는 이미지 필터로 가공한 이미지 결과물이다. 실제로 산업에서 사용되는 다양한 이미지 에디터에서는 본 효과 외에 다른 수많은 이미지 필터를 제공하고 있으며 사용자는 사용자 요구에 맞는 이미지 필터를 결합하여 원하는 결과물을 만들어낼 수 있다.


    3.1 블러 효과

    본 절에서는 대표적으로 블러(Blur) 애니메이션을 구현해 본다. 블러 필터에 애니메이션을 결합한다면 시간 흐름에 따라 점차적으로 흐려지는 효과를 표현할 수 있다.


    그림 10: 블러 애니메이션

    블러 애니메이션을 구현하기에 앞서, 블러 이론을 조금 더 살펴보자. 블러는 이미지를 흐리게 하는 효과를 제공하는데 마치 카메라의 초점을 흐리게 하는 것과 유사하다. 이는 배경 이미지를 흐리게 함으로써 보다 중요 콘텐츠를 강조하거나 애니메이션에서 장면 전이 효과로도 사용된다. UI 앱에서는 팝업(Popup) 상자처럼 일시적인 콘텐츠에 사용자의 초점을 유도하기 위해 팝업의 배경에 블러 효과를 적용하기도 한다.


    그림 11: Modal Popup (iOS)

    블러를 구현하는 방식은 Flat, Quadric, Cubic 등 다양하지만 그 중에서도 가우시안(Gaussian) 블러가 가장 좋은 품질을 제공한다. 가우시안 블러를 구현하기 위해서는 가우시안 분포를 이해해야 하는데, 이는 과학분야에서 분포를 구할 때 쓰이는 가장 보편적인 방법이며 정규분포 또는 확률분포에서 잡음을 제거하기 위해 사용된다. 특히 이차원 영상에서 가우시안 분포는 계산하고자 하는 픽셀과 이 픽셀로부터 인접한 이웃 픽셀간 평균값으로 결정하게 된다. 이 때, 대상 픽셀로부터 각 이웃 픽셀 위치까지 거리로 가중치를 결정하며 이러한 가중치는 거리가 멀수록 점차 감소하게 된다. 이웃 픽셀의 가중치는 미리 계산하여 입력해 둘 수 있다. 정리하자면, 가우시안 블러를 적용하기 위해서는 원본 영상의 각 픽셀을 순회하며 각 픽셀마다 이웃 픽셀의 가중치 커널(또는 마스크)을 적용하여 평균 값으로 대처하는 과정을 반복한다. 더 많은 이웃 픽셀을 개입할 수록 영상 이미지의 흐림 정도는 증가하며 커널의 계산량도 많아진다.


    그림 12: 2차 가우시안 분포 도식화 (Wikipedia)


    그림 13: 2차 가우시안 정규분포식

    2차 가우시안 정규분포식에서 x와 y는 구하고자 하는 픽셀 위치로부터의 인접한 픽셀의 거리를 가리킨다. 이 식을 이용하면 인접 픽셀의 가중치 값을 계산할 수 있다. 다음 배열은 실제 3 x 3 크기의 가우시안 커널을 가리킨다. 이 커널은 인접한 좌,우,상,하 각각 한 픽셀씩 총 9개의 픽셀의 가중치 값을 담고 있다.


    커널을 구축했다면, 커널을 이용하여 픽셀의 최종 색상을 계산하는 작업은 4장 이미지 프로세싱 6.1절 알파 블렌딩과 개념적으로 다르지 않다. 블렌딩에서 두 픽셀을 합성하는 것과 비슷하게, 인접 픽셀 모두를 동일한 수식으로 합성하면 된다. 이 때 각 픽셀의 합성 비율은 알파값이 아닌 커널의 가중치 값으로 결정한다. 

    마지막으로, 개념을 정리하여 블러 함수를 코드로 작성하면 다음과 같다.

    UICustomInterpolator implements UIInterpolator: func(progress): //사용자가 원하는 그래프 곡선에 맞춰 progress의 값을 변환시킨다. return xxx(progress);

    코드 14: Custom Interpolator 구현

    프레임워크에서 미리 준비된 인터폴레이터는 또는 사용자가 추가한 인터폴레이터는 UIAnimator에 전달되어 사용할 수 있다.

    /* * 입력 픽셀을 받고 블러 효과를 적용 후 픽셀을 반환한다. * UIBlurFilter는 UIFilter 인터페이스를 구현한다. * * in: 입력 픽셀 정보 * coord: 입력 픽셀의 위치 정보 */ UIBlurFilter implements UIFilter: func(Pixel in, Point coord, ...): Pixel out; //출력할 픽셀 Pixel buffer[,] = self.buffer; //입력 버퍼(텍스처) 메모리 Kernel kernel[,] = self.kernel; //가중치 정보를 보유한 커널 데이터 /* 가중치를 적용하여 인접한 픽셀들과 합성. 대표로 블러 강도가 1인 경우를 보여준다. */ if (self.blurSize == 1) //인접 픽셀 정보. 버퍼 경계를 벗어나지 않도록 처리하는 코드는 생략했다. Pixel lt = buffer[coord.y - 1][coord.x - 1]; //left top Pixel t = buffer[coord.y - 1][coord.x]; //top Pixel rt = buffer[coord.y - 1][coord.x + 1]; //right top Pixel l = buffer[coord.y][coord.x - 1]; //left Pixel r = buffer[coord.y][coord.x + 1]; //right Pixel lb = buffer[coord.y + 1][coord.x - 1]; //left bottom Pixel b = buffer[coord.y + 1][coord.x]; //bottom Pixel rb = buffer[coord.y + 1][coord.x + 1]; //bottom right //픽셀 합성, 각 가중치를 곱하여 평균값을 내는 것에 주목하자. out = (lt * kernel[0][0] + t * kernel[0][1] + rt * kernel[0][2] + l * kernel[1][0] + in * kernel[1][1] + r * kernel[1][2] + lb * kernel[2][0] + b * kernel[2][1] + rb * kernel[2][2]) / 9 return out;

    코드 16: 블러 필터 구현부

    코드 16은 이해를 돕기 위해 코드를 최대한 풀어서 작성했다. 커널은 필터 함수를 수행하기 전에 미리 구축하여 UIBlurFilter 멤버 변수로 보유한다. 이 예제에서는 블러 크기가 1, 즉 거리가 1인 인접 픽셀에 대해서 블러 필터를 수행한다. 실제로는 blurSize의 크기에 따라 개입할 인접 픽셀의 개수도 많아져야 하므로 유동적으로 구동할 수 있도록 코드를 작성해야 한다.

    13번째 줄의 커널은 사용자의 요청에 따라 앞서 살펴본 가우시안 정규분포식이 결정한다.

    UIImage img; ... UIBlurFilter filter; filter.size(1); //블러 필터의 크기를 1로 지정한다. img.addFilter(filter);

    코드 17: 블러 필터 호출부

    UIBlurFilter.size(Var size): //커널의 크기를 결정한다. 가운데 픽셀을 위해 kSize + 1을 하는 것에 주의하자. kSize = 2 * size; self.kernel = Kernel[kSize + 1, kSize + 1]; /* 가우시안 정규분포식을 이용하여 가중치 값을 계산한다. 어쩌면, 사이즈별 가중치 테이블을 미리 구축하는 것도 가능하다. */ sum = 0; sigma = 1.0; //표준편차를 1로 설정 s = 2.0 * sigma * sigma; for (y = -kSize; y <= kSize; y++) for (x = -kSize; x <= kSize; x++) r = sqrt(x * x + y * y); weight = exp(-(r *r) / s) / (PI * s); self.kernel[y + 2][x + 2] = weight; sum += weight; //마지막으로 정규화 수행 for (y = 0; y < kSize + 1; y++) for (x = 0; x < kSize + 1; x++) self.kernel[y][x] /= sum;

    코드 18: 커널 계산 로직

    코드 18은 가우시안 정규분포식을 코드로 옮겨놓은 것에서 크게 벗어나지 않는다. 사용자가 블러필터의 크기를 지정하면 그 크기를 인자로 커널을 구축한다. 사실, 가우시안 커널을 준비하는 과정에서 각 가중치 원소를 모두 계산할 필요가 없다. 커널이 담고 있는 가중치 값은 상하 대칭이라는 점에서 계산 중복을 줄이는 것이 가능하다. 사소하게 들리지만, 커널의 크기가 커진다면 계산양도 많아질 수 있다는 점에서 실제 구현에서는 최적화에 염두하도록 하자.

    이미지 후처리와 같은 대다수의 필터 효과는 블러처럼 입력 이미지가 미리 주어져 있어야 한다. 실제로 코드 6.16의 11번째 줄을 보면, 우리는 입력 버퍼로부터 픽셀 정보를 가져오는 작업을 수행하는 것을 볼 수 있다. 이미지 객체에 필터를 적용하는 경우라면, 이미 비트맵으로 구성된 이미지 정보가 구축되어 있기 때문에 자연스럽게 이를 입력 버퍼로 활용할 수 있다.

    한편, 블러 필터의 대상이 단순 비트맵 이미지가 아닌 UI 컨트롤과 같은 복합 원소들로 구성된 경우라면 필터를 적용할 원본 이미지를 미리 생성할 필요가 있다. 실제로 많은 필터 효과는 계산 과정에서 원본 이미지를 필요로하기 때문에 래스터라이징(rasterizing) 하는 과정에서 즉각적으로 필터를 적용하기 어려울 수 있다. 따라서, 필터를 적용할 객체를 미리 임시 버퍼에 그린 후, 이 버퍼를 입력 이미지로하여 필터를 적용하는 과정을 거쳐야 한다.


    그림 14: 필터를 위한 2단계 렌더링 수행

    그림 14는 2단계 렌더링 패스를 도식화 한다. 그림에서는 마치 렌더링 엔진이 두 개가 존재하는 것처럼 해석될 수도 있지만, 실제로는 한 장면을 위해 하나의 렌더링 엔진이 렌더링 절차를 두 번 반복해서 수행한다. 렌더링은 2 ~ 5장에서 살펴보았던 내용을 통해 이해할 수 있다.

    최적화 팁으로, 객체의 원본 이미지를 저장한 임시 버퍼는 렌더링 엔진 내에서 적절한 캐시 메커니즘을 이용해 관리할 수 있으며 재사용을 통해 버퍼 할당 작업 및 렌더링의 횟수를 최소화할 수 있다. 필터 적용 시 필터 효과의 특성을 잘 이해하여 렌더링 단계를 최소화하는 방안을 강구해야 한다.


    3.2 블러 애니메이션

    블러 효과에 애니메이션을 적용해 보도록 하자. 사실상, 일정시간 동안 블러 크기를 점차적으로 높이는 것만으로도 블러 애니메이션을 만들 수 있다. 우리는 이미 앞에서 애니메이션은 물론, 블러 필터의 동작까지 모두 이해하였으므로 블러에 애니메이션 효과를 추가하는 작업은 매우 간단하다.

    UIView content; ... //블러 필터 추가 UIBlurFilter filter; content.addFilter(filter); //1초간 애니메이션 가동 UIAnimation animation = UIAnimation(1.0); animation.addEventCb(UIAnimation.Updated, lambda(UIAnimation obj, Var progress, UIBlurFilter target): //1초간 블러 크기는 0에서 10으로 증가한다. target.size(10 * progress); filter ); animation.play();

    코드 19: 블러 애니메이션 가동


    4. 유저 인터렉션 효과

    이번 절에서는 애니메이션 기능을 바탕으로 유저 인터렉션(User-Interaction) 효과를 지원하는 방법에 대해서 좀 더 살펴본다. 유저 인터렉션 효과는 사용자 입력에 반응하는 정적 변화 또는 애니메이션이 가미된 동작 효과를 정의한다. 사용자가 앱을 사용하는 동안 지루하지 않도록 재미 요소를 추가하는 것 이상의 효과를 제공하는데, 주로 화면 공간 내 UI 배치를 최소화하고 주요 콘텐츠에 사용자 시선을 집중시키며 문맥을 이해하는 데 도움을 준다. 이러한 효과는 앱 사용성을 한층 더 높여 앱의 완성도를 높이는데 기여를 한다. 예로, 포커스(Focus)를 가지는 객체, 화면 전이 그리고 사용자 터치 반응 효과 등이 해당하며, 이는 사용자가 앱을 사용하는 동안 앱이 제공하는 기능을 잘 간파할 수 있도록 시각적 보조장치 역할을 수행한다.

    개념적으로 UI에 인터렉션 효과를 제공하기 위해서는 입력, 조건 처리, 기능 수행, 상태 변경 네 단계가 필요하다.

    입력은 사용자 입력 내지 시스템 신호를 가리킨다. 사용자 입력은 마우스, 키보드, 터치, 음성 입력 등이 존재한다. 시스템 신호는 어떤 특정 조건이 만족될 때 시스템에 의해 자체적으로 발생한 신호를 가리키는데, 예로 시스템에 의해 전달된 메시지 신호는 알림 앱이 사용자에게 그 정보를 알리는 효과를 구동시킨다.

    사용자 또는 시스템으로 부터 신호가 발생하면 앱은 현재 앱의 상태 조건을 확인하고 그에 맞는 기능을 구동하여 상태를 전이시킨다. 이러한 동작 시퀀스는 앱 구현 로직에 의존한다.


    그림 15: 유저 인터렉션 처리 단계


    4.1 입력 신호 전달

    첫 번째 단계인 입력 신호 처리 단계를 살펴보자. 입력 신호는 UI 프레임워크가 갖추어야할 기본 기능 중 하나로서, 대표적으로 키, 마우스 그리고 터치 신호로 구분할 수 있다. 모바일처럼 터치 스크린이 주 입력 장치인 기기에서는 터치 신호를 추가로 받지만 이는 근본적으로 마우스 신호와 큰 차이가 없다.

    일반적으로 사용자 입력 신호는 입력 장치부터 커널을 통해 윈도우 서버로 전달된다. 윈도우 서버 역할을 수행하는 윈도우 관리자는 내부 구현을 통해 입력 신호를 필터링하는 과정을 수행한다. 이 과정에서 우선순위 기반으로 신호를 받아야할 대상(윈도우 클라이언트)이 누구인지 판단하고 이벤트를 전달할 목적지를 결정한다. 대상을 결정하면 약속한 프로토콜을 통해 신호를 전달한다. 대게 현재 활성화된 앱(클라이언트)이 그 대상이 되며, 신호는 IPC를 통해 패킷 형태로 전달한다. 특히, 데스크탑 또는 멀티 윈도우 환경에서는 여러 UI 앱이 동시에 동작할 수 있으므로 윈도우 관리자는 관리 정책을 기반으로 입력 신호를 반드시 포커스를 가진 하나의 UI 앱으로만 전달해야 한다.


    그림 16: 윈도우 시스템을 통한 입력 이벤트 전달 과정

    입력 신호는 여러 정보를 기록한다. 가령, 어떤 장치에서 발생한 신호인지 구별하기 위한 ID, 신호가 발생한 시간(Timestamp), 입력이 발생한 화면 위치(Position), 키가 눌렸는지(Press/Release) 이동했는지(Move) 여부 등의 정보가 이에 해당한다. 윈도우 서버와 클라이언트간 신호 정보가 비동기적으로 전송되는 경우 클라이언트는 전달받은 데이터를 입력 신호 큐(event queue)에 축적할 수 있다. 만약 UI 엔진이 입력 신호 송신을 전담하기 위한 이벤트 리스너 스레드를 별도록 운용한다면 UI 앱의 메인루프는 입력 신호 송신 작업으로 동작이 지연되지 않고 독립적으로 운행될 수 있는 장점을 가질 수 있다. UI 앱의 메인루프는 이벤트 대기 중 처리해야할 입력 신호 데이터가 있는 사실을 확인하면 이벤트 처리과정으로 진입한다. 이벤트 처리 시점에서는 그간 축적된 입력 신호를 처리하는 단계를 수행한다. 이 후 입력 신호는 캔버스 및 위젯 엔진, 필요에 따라 포커스 관리자 등을 거쳐 최종적으로 대상 UI 객체까지 전달되어야 한다.


    그림 17: UI 컨트롤로 이벤트가 전달되는 과정

    캔버스 엔진은 내부적으로 관리하는 UIObject 리스트로부터 이벤트를 받을 대상 객체를 찾을 수 있다. (코드 20) 이 후, 객체 리스트를 순회하며 각 오브젝트의 경계 범위(Bounding Box)와 이벤트 발생 위치를 토대로 이벤트 대상을 판단한다. 이벤트 발생 위치에 여러 객체가 중첩 배치될 경우, 레이어상 최상단의 객체가 이벤트 우선순위를 가지며 캔버스 엔진은 각 객체의 이벤트 전파(Propagation) 옵션값을 확인하여 이벤트를 중첩된 하단 레이어의 다른 객체로 전달할지 말지를 결정할 수 있다. (그림 17)


    그림 18: obj A 클릭 이벤트 후 obj B의 클릭 이벤트 수행

    /* * 이벤트 핸들링을 수행한다. * EventInfo에는 서버로부터 전달받은 입력 이벤트 세부 사항 정보가 기록되어 있다. */ UICanvas.procEvent(EventInfo info, ...): //전파할 이벤트 대상 객체를 탐색한다. foreach(self.objs, obj) if (obj.procEvent(info, ...) == false) break; UIObject.procEvent(EventInfo info, ...): /* 객체가 활성화 상태인 경우에만 이벤트를 수행한다. 삭제 중 객체인지, 비활성화 되었는지, 가시 상태인지 등이 이에 해당한다. */ if (obj.activate == false) return true; // 이벤트 발생 위치가 오브젝트 영역 내에 위치해야 한다. if (intersects(obj.geometry(), info.position) == false) return true; // 해당 이벤트 타입에 해당하는 콜백 함수 리스트를 얻어온다. List eventCbs = obj.eventTable[info.type]; //등록된 콜백 함수를 순차적으로 호출한다. foreach(eventsCbs, func) func(...); /* 자기 자신의 콜백 함수를 수행한 후, 자식 객체로 이벤트를 전달한다. 디자인에 따라 이 수행 순서는 바뀔 수도 있다. */ foreach (obj.children, child) if (child.procEvent(info, ...) == false) break; //propagation이 true일 경우, 다음 오브젝트로 입력 이벤트를 전파한다. return obj.propagation;

    코드 20: 이벤트 수행 메인 로직

    //오브젝트의 propagation은 다음 인터페이스로 설정 가능하다. obj.propagateEvent(true);

    코드 21: 이벤트 프로퍼게이션 설정 예시

    코드 20 19번째 줄을 보면, UIObject는 이벤트 타입과 해당 이벤트 타입에 등록된 콜백 함수 리스트를 테이블 형태로 구축하고 있는 점을 확인할 수 있다. 이벤트는 Press, Unpress, Cursor In, Cursor Out, Move 등의 원초적인 형태와 Tab, Double Tab, Long Presss, Flick 등 가공된 형태 모두 가능하다. 이벤트를 받은 오브젝트는 해당 이벤트를 등록한 소스에게 이벤트 정보를 전달한다. 호출된 이벤트 콜백 함수는 UI 컨트롤에 직접 등록된 앱 콜백 함수에 해당하거나 해당 오브젝트를 확장하는 클래스의 이벤트 매핑 과정을 거쳐 앱 콜백함수로 연결될 수 있다.

    이벤트 대상 객체는 기본적으로 앱 또는 엔진 자신에 의해 이벤트 리스너(이벤트 리스너를 활용하는 예시는 UI 앱 개발의 기본의 3절 UI 컨트롤 이벤트 핸들링을 참고) 를 등록한 객체로 한정한다. 이벤트 발생 로직을 수행할 콜백 함수가 존재할 경우에만 입력 신호가 의미 있으므로 이벤트 리스너가 등록되지 않는 경우에는 이벤트 전달 대상에서 제외할 수도 있다. 이는 코드 6.20의 UICanvas.procEvent() 구현 과정에서 적절한 조건 판단을 통해 수행 가능하다.

    UICanvas에 전달된 이벤트를 한 번 더 가공하기 위해 제스처 레이어로 전달하는 것도 가능한데, 이 경우 제스처 레이어는 각종 제스처 타입을 정의하며 입력 신호로부터 제스처 발생 여부를 판단하여 UI 컨트롤로 가공된 제스처 형태의 신호를 전달한다.


    4.2 제스처

    앞 절에서 우리는 입력 신호 발생으로부터 UI 엔진을 거쳐 오브젝트에 등록된 이벤트를 호출하는 과정을 살펴보았다. 실제로 유저 인터렉션 효과를 구현하려면, 전 과정을 토대로 위젯 내지 앱 개발자가 오브젝트에 등록한 콜백 함수를 통해 기능 로직을 추가해야 한다. 콜백 함수를 등록하고 콜백을 호출하는 메커니즘은 앞 서 이해했으므로, 사용자가 등록할 수 있는 제스처 타입에 대해서 좀 더 자세히 알아보도록 하자.

    제스처(Gesture)는 UI 기능을 발동(Trigger)하는 입력 방법으로서 클릭 외에도 다양한 입력 메서드를 제공한다. 그 중 가장 일반적인 입력인 클릭(Click) 또는 탭(Tab)은 누름(Press)와 해지(Unpress) 사용자 입력을 연달아 받을 시 발생한다. 이 때 두 입력 간 좌표는 변화가 없거나 일정 범위 내에 있어야 하고 시간 간격 또한 일정 범위(0.2초) 내에 있어야 한다. 만약 두 입력 사이의 좌표 위치에 변화가 존재한다면 클릭이 아닌 플릭(Flick) 이벤트로 해석할 수 있고 시간 간격이 클릭의 정의를 벗어난다면 해당 이벤트는 클릭이 아닌 롱프레스(Long press) 이벤트로 해석할 수 있다. 앱 개발자는 클릭, 롱프레스, 플릭과 같은 사용자 입력 방식을 자체적으로 정의하지 않고 UI 시스템이 정의한 룰을 따르는 것이 유리하며 이는 앱 간 동작 일관성 측면에서 매우 중요하다.

    제스처는 UI 시스템의 설계 및 정의에 따라 종류 및 동작 정의가 다를 수 있으나 UX에서 통용되는 사용자 입력 형태를 정의하는 것이 바람직하다. UI 시스템은 제스처를 통해 앱 개발자가 해당 입력 이벤트를 추가 구현없이 바로 이벤트 타입으로서 이용할 수 있는 편의를 제공한다.


    그림 19: 제스처 종류 예시 (EFL)

    기본적으로 제스처 이벤트는 윈도우 서버로부터 전달받은 원본의 입력 이벤트를 해석, 가공하고 이를 구조화한 인터페이스를 통해 앱에게 제공하는 것을 목표로 한다. 그리고 설계 관점에서 이를 전문으로 담당하는 제스처 모듈을(UIGesture)를 별도로 정의할 수 있다. UI 프레임워크는 제스처를 구현하고 UI 엔진에 결합할 수 있으며, 이를 통해 앱은 UI 컨트롤을 이용하여 제스처 이벤트를 등록하고 앱 시나리오에 맞는 기능 동작을 구현할 수 있다.


    그림 20: 클라이언트 제스처 모듈 설계

    특수 목적으로 제스처 입력을 처리해야 한다면 윈도우 서버 자체에서 해당 기능을 정의하여 제공하는 것도 고려 가능하다. 예를 들면, 모바일 앱의 퀵패널(QuickPanel)를 활성화 하는 기능이 이에 해당한다. 사용자는 화면을 수직으로 드래그(Drag)함으로써 퀵패널을 활성화한다. 이러한 기능 앱은 그 자체가 윈도우 클라이언트에 해당할 수도 있으나 윈도우 서버와 긴밀하게 동작하기 위해 윈도우 매니저의 모듈(그림 6.20)로서 구현하는 것도 검토 가능하다.


    그림 21: 윈도우 서버 제스처 모듈 설계


    그림 22: 모바일 퀵패널 (삼성 갤럭시)

    제스처가 정의하는 각 기능을 커스터마이징할 수 있으면 보다 구현이 보다 유연하다. 특히 UI 시스템마다 제스처 동작 정의가 다르다면 멀티 플랫폼을 지원하는 앱에서는 그 동작 정의를 변경해서 일원화해야만 한다. 따라서, UI 시스템에서는 기존 제스처를 수정할 수 있는 인터페이스를 고려해야 한다. 새로운 제스처 타입을 추가하는 방안은 제스처를 구현하는 클래스를 확장함으로써 가능하지만 기존 제스처를 변경해야 한다면 각 제스처의 동작 계산을 결정하는 주요 팩터를 변경할 수 있는 파라미터를 노출시켜야 한다. 물론 이러한 커스터마이징으로 인해 각 앱마다 제스처 동작 정의가 다르다면 사용자는 사용 방법에 있어서 혼란스러울 수도 있다. 따라서 앱 관점에서 무분별한 변경은 주의해야 한다.

    다음 목록은 제스처를 커스터마이징할 수 있는 팩터의 예시이다. 기본적으로 인터페이스를 통해 각 파라미터를 노출할 수 있다.

  • tabTimeout: 탭 발생 시, Press - Unpress 사이의 최대 시간 간격
  • doubleTabTimeout: 두 탭 사이의 최대 시간 간격 지정
  • tabFingerSize: 더블 탭 경우, 두 탭 간의 거리를 허용하기 위한 오차 범위
  • zoomDistanceTolerance: 줌 경우, 두 탭 간의 최소 거리 지정
  • zoomFingerFactor: 줌 경우, 두 손가락 사이의 거리 변화에 따른 줌 비율
  • lineLength: 드래그 경우, 직선 여부를 판단하기 위한 두 탭 간의 최소 거리
  • lineAngularTolerance: 드래그 경우, 직선 여부를 판단하기 위한 각도 변화 허용 범위
  • rotateAngularTolerance: 회전 경우, 회전을 인식하기 위해 발생해야 할 최소한의 각도
  • flickTimeLimit: 플릭 경우, Press - Unpress 사이의 최대 시간 간격

  • //다음 GestureLayer 설정은 앱에서 사용하는 제스처 기능에 전역적으로 영향을 미친다. //tabTimeOut을 0.5초로 지정 UIGesture.tabTimeout(0.5); //zoom을 인식하기 위한 두 탭간의 최소 거리를 100픽셀로 지정 UIGesture.zoomDistanceTolerance(100); //줌 제스처 발생 시, 해당 줌 팩터만큼 뷰를 확대 또는 축소시킨다. UIView view; view.addGestureCb(UIGesture.GestureZoom, lambda(UIView obj, UIGestureInfoZoom event): obj.scale(event.zoom); ...

    코드 22: 제스처를 통한 줌 인터렉션 구현


    코드 22의 11번째 줄을 보면 줌 팩터 값을 사용자에게 제공하기 위해 UIGestureZoom 데이터를 콜백 함수 인자로 전달하는 것을 확인할 수 있다. 여기서 UIGestureInfoZoom은 UIGestureInfo의 확장 타입으로서 zoom 정보를 추가로 담고 있는 구조체 정보로 볼 수 있는데 실제 인터페이스 설계 방안은 각 디자인 방침에 따르면 된다.

    시스템 Configuration 데이터의 일부로서 제스처 팩터를 외부 파일로 기술할 수 있는 형식으로 제공한다면 보다 유연한 UI 프레임워크 시스템을 구축할 수 있다. 이를 통해 플랫폼을 이용하는 제품마다 그 특성을 다르게 가져갈 수 있는 장점을 제공할 수 있다.


    5. 스레딩

    비주얼 효과를 위해 복잡한 알고리즘 내지 수학적 연산을 수행하는 작업은 UI 앱 성능에 부담을 준다. 예로 이미지 필터의 경우 이미지 픽셀에 대응하는 수학적 연산을 수행하며 이는 이미지 해상도에 비례하는 데이터 처리량을 요구한다. 파티클(Particle) 효과로서 화면에 무수히 많은 객체를 출력하고, 물리적 연산을 기반으로 강체(Rigid Body) 충돌 처리 효과 등을 UI 객체에 적용하는 작업 역시 마찬가지이다.
    2.2절에서 언급했듯이 앱이 60fps의 성능을 보장하기 위해서는 메인루프가 한 사이클마다 처리해야하는 작업은 0.0167 초 안에 끝나야 한다. 이 시간 내에 앱의 비지니스 로직부터 렌더링 엔진의 드로잉까지의 작업을 완수해야 한다. 실제로 그림 6.2의 사용자 애니메이션 수행 함수가 계산으로 인해 지연된다면 메인루프는 0.0167초 내로 한 프레임 작업을 완수할 수 없을 것이다. 만약 애니메이션 구현부가 많은 연산을 요구한다면 작업을 병렬로 처리하는 방법을 고민해 볼 수 있다. 계산량을 많이 요구하는 이펙트는 최적화의 우선 순위 대상이다.

    이번 절에서 우리는 병렬 처리의 가장 기본이 되는 메커니즘으로서 스레드를 언급하지 않을 수 없다. 스레드는 매우 원시적인 방법이지만, 모든 OS에서 지원하기 때문에 호환성은 뛰어나 대부분의 소프트웨어 개발에서 응용할 수 있는 방법 중 하나로 채택한다. 비록 문제가 발생한 경우, 예측불가한 동작 순서는 비결정적인(Non-deterministic) 동작 결과를 초래하고 디버깅을 극도로 어렵게 하는 단점 등이 있지만 섬세한 동작 설계를 바탕으로 스레드 동기화(Synchronization)같은 도구를 잘 활용한다면 나름 안정적인 스레드 작업을 펼칠 수도 있다.

    사실 이펙트 최적화와 별개로 UI 앱에서 사용자가 스레드를 사용해야 하는 이유는 다양하다. 네트워크를 통하여 데이터를 주고 받기 위해 신호 대기 상태에 있거나, 단시간 내에 처리할 수 없을 정도의 많은 양의 데이터를 처리해야 하는 작업 등이 이에 해당한다. 여기서 핵심은 UI 엔진의 메인루프가 원활이 구동될 수 있어야 한다는 점에 있다. 앱에서 구현한 어떤 함수가 매우 많은 시간을 소모한다면 UI 엔진의 메인루프는 그만큼 동작이 지연될 수 밖에 없으며 화면 갱신 시간 역시 느려질 것이다. 하지만, 앱 개발 입장에서 UI 엔진은 블랙박스로서 동작하기 때문에 외부에서 이를 고려하여 앱을 구현하는 것은 쉽지 않다. 따라서, 반대로 UI 프레임워크가 앱 개발자가 쉽고 안정적으로 스레드 작업을 구현할 수 있도록 UI 엔진과 상호작용하는 스레드는 물론 그에 준하는 편의 인터페이스를 제공하는 것을 고려해야 한다.


    5.1 스레드 방안

    메인루프를 구동하는 스레드를 주 스레드(Main Thread)라고 한다면, 주 스레드와 별개로 어떤 작업을 병렬 처리하기 위해 작업 스레드(Worker Thread)를 여러 개 추가할 수 있다. 개념적으로 작업 스레드에서 수행한 결과물이 UI 내지 화면에 출력되는 콘텐츠와 의존성을 갖지 않다면 작업 스레드와 주 스레드간의 동기화(Synchronization) 작업은 요구되지 않는다. 이 경우 앱 개발자는 일반 스레드 개념대로 작업 스레드를 다룰 수 있다. 하지만, 작업 결과물이 화면에 반영되어야 하는 어떤 정보라면 작업 스레드와 주 스레드 사이에 공유 자원을 안전하게 접근할 수 있는 동기화 작업을 수행해야 한다.

    이를 위해 작업 스레드는 주 스레드와 동기화를 실행할 수 있는 임계 영역(Critical Section)을 지정해야 하며 이 구간만큼은 메인루프가 작업 스레드와의 동기화를 위해 지정된 사이클 작업을 중단할 수 있어야 한다.


    그림 23: 메인루프와 작업 스레드간 동기화


    그림 23에서 임계 영역을 지정하기 위해 begin_critical_section()과 end_critical_section()를 호출한 것에 주목하자. 메인루프를 운용하는 UIEngine은 두 호출 사이에서 만큼은 메인루프를 대기 상태로 만들어야 작업 스레드에서 UI 객체 등의 공유 자원에 안전하게 접근하는 것을 보장해 줄 수 있다.


    5.2 태스크

    이러한 개념을 바탕으로, 스레드 간 약속된 내부 동작을 수행하기 위해 UIEngine은 고수준의 병렬 작업 기능을 제공하기 위해 UITask를 제공할 수 있다. UITask는 사용자가 병렬로 수행하고자 하는 작업을 보다 수월하게 구축하기 위해 작업을 추상화한 편의 인터페이스를 제공한다. UITask의 핵심 메서드를 살펴보면 다음과 같다.

    /* 사용자는 병렬 작업을 수행하기 위해 UITask로부터 UserTask를 구현할 수 있다. UITask는 UIEngine과 동기화를 수행하는 직렬 또는 병렬 작업을 수행한다. */ abstract UITask: /* 실제 수행할 동기/비동기 작업을 구현한다. 비동기의 경우 task()는 작업 스레드에서 수행하고 동기의 경우 메인스레드에서 수행한다. */ task(): /* task()가 끝났을 때 호출되며 task()가 수행한 결과물을 메인스레드와 공유 자원인 (예: UI 객체) 데이터에 반영하는 등이 작업을 end()에서 구현한다. 또는 작업 리소스를 정리하는 작업을 수행할 수도 있다. UITask는 task()를 호출한 후, 해당 메서드가 종료될 시 메인루프와 동기화 작업을 거쳐 메인 스레드로부터 end() 메서드를 호출한다. 따라서, end() 내에서 수행하는 작업은 작업 스레드가 아닌 메인 스레드에 해당하므로 동기화 작업을 따로 수행할 필요가 없다. */ end(): /* task()를 실행한다. 필요하다면 메서드 인자 내지 컴파일러 내장 옵션을 통해 동기/비동기 수행을 결정할 수 있을 것이다. */ public run():

    코드 23: UITask 핵심 메서드 설명

    UITask는 기본적으로 작업 스레드에서 동작하지만 설정에 따라 메인스레드에서 수행될 수 있는 선택사항을 사용자에게 제공하는 유연성을 갖출 수도 있다. 그렇지 않다면, UITask와 UIAsyncTask처럼 보다 직관적으로 기능을 분리하는 것도 좋다. 그 외로 UITask 클래스에는 작업 취소 등 부차적인 기능이 더 필요하겠지만 여기서 내용을 전부 다루지는 않을 예정이다.

    이제 사용자는 UITask를 확장하여 실제 수행할 작업을 구현한다.

    /* 사용자는 동기/비동기 작업을 수행하기 위해 UITask로부터 UserTask를 구현한다. */ UserTask extends UITask: /* 공유 자원으로서 이미지 객체 활용 이미지 객체는 캔버스 엔진에 종속된 공유 데이터이므로 작업 스레드에서 접근 시 메인루프(주 스레드)에 의해 경합이 발생할 수 있다. */ UIImage img; constructor(): //화면에 출력할 멤버 변수 img 객체의 초기 설정을 수행한다. ... /* 수행할 작업을 구현한다. */ override task(): /* 여기서 어떤 Heavy한 작업을 한다고 가정하자. 네트워크를 통해 어떤 대용량의 이미지 데이터를 받아온다. 다운로드가 끝나면 task()를 종료한다. */ ... /* task()를 완료할 시 수행할 작업을 구현한다. */ override end(): /* 내려받은 이미지 데이터를 이미지 객체로 불러온다. */ self.img.open(“/tmp/downloaded.png”);

    코드 24: UITask를 확장한 동기/비동기 작업 구현

    최종적으로 사용자는 메인스레드에서 UserTask를 호출한다.

    /* func()은 메인 스레드에서 동작하는 어떤 함수이다. */ func(): UserTask task; task.run();

    코드 25: UITask를 통한 작업 스레드에서 작업 수행

    task 객체를 생성한 시점은 메인스레드에서 동작 중인 func()에 해당하며 이 시점에 호출된 UserTask 생성자는 아무 문제 없이 이미지 객체를 생성하고 초기 설정을 수행한다. 이 후, task.run()을 호출하면 UITask 내부 동작에 의해 작업 스레드에서 task()를 호출하며 메인루프와 병렬로 작업이 수행된다. 따라서 시스템 환경에 따라 수월한 작업 처리가 가능하다. task()가 종료되면 end()에 의해 사용자가 준비한 이미지가 화면에 출력될 것이다.

    여기까지 우리가 기대하는 동작 시퀀스이다. 이 동작 시퀀스를 시퀀스 다이어그램으로 표현하면 다음과 같다.


    그림 24: 작업 스레드 기반 UITask 동작 시퀀스

    사용자 코드와 메인루프는 동일한 주 스레드 상에서 수행되는 한편, 사용자가 task.run()을 호출하면 task는 내부적으로 작업 스레드에서 실행할 task 인스턴스 정보를 첨부하여 UIEngine에 비동기 이벤트를 보낼 수 있다. 비동기 이벤트로 시스템에서 지원하는 여러 방식을 검토할 수 있으며 대표적으로 리눅스 시스템에서는 메시지나 파이프(Pipe) 내지 파일 디스크립터(File Descriptor) 등을 활용할 수 있다.

    이후 메인루프는 주 동작 사이클에서 이벤트를 처리하는 시점에 스레드 관련 메시지를 해석하여(dispatch) 스레딩 작업을 지시한다. 따라서, 메인루프는 요청받은 작업을 수행할 스레드를 생성한 후 작업을 실행하며 작업 스레드는 스레드가 종료되는 시점 또는 작업이 끝나는 시점에 UIEngine으로 앞서 task를 실행한 방식과 동일하게 비동기 메시지를 보내고 이벤트 처리 단계에서 task.end()가 주 스레드에서 호출될 수 있도록 요청한다.


    그림 25: 주 스레드와 작업 스레드 간 UITask 상호 운용 과정

    그림 25에서는 스레딩을 위한 메시지를 일반 이벤트로 취급하였으며 이는 4.1의 사용자 입력 신호를 전달하는 과정과 동일하다. 실제 UI 엔진을 운용하는 과정에서 필요한 이벤트와 메시지는 다양하고 복잡하므로 보다 정교한 내부 이벤트 운용을 위해서는 각 이벤트 형식에 최적화된 이벤트 처리 단계를 구축하는 것을 고려해 볼 수 있다.


    5.3 크리티컬 세션

    앞서 살펴본 바에 의하면, UITask를 통해 어떤 작업이 완성하고 그 작업 결과물은 end() 메서드를 활용하면 단발성을 띈 작업을 병렬로 수행하는 것은 문제되지 않을 듯 하다. 하지만 비동기 작업을 진행하면서 작업 결과물을 주기적으로 반영해야 한다면 동기화 역시 주기적으로 요청해야 한다. 이를 위해 UIEngine에서 임계 영역을 지정할 수 있는 방법을 추가로 고려해 볼 수 있다.

    override UserTask.task(): /* 여기서 어떤 Heavy한 작업을 거쳐 임의 수의 이미지를 실시간으로 생성한다고 가정하자. 생성된 이미지는 즉시 이미지 객체를 통해 화면에 출력해야 한다. */ repeat (infinite) Pixel bitmap[] = generateImage(...); /* beginCriticalSection()을 호출하면 메인루프가 동작을 중단하여 작업 스레드에서 공유 자원에 안전하게 접근할 수 있도록 도와준다. 동기화 작업이 끝나면 endCriticalSection()를 호출하여 메인루프가 재개하도록 한다. */ UIEngine.beginCriticalSection(); self.img.source(bitmap); UIEngine.endCriticalSection();

    코드 26: 크리티컬 세션을 통한 동기화 작업 수행

    그림 23과 같이 beginCriticalSection()과 endCriticalSection() 사이는 메인루프가 동작을 멈추는 구간으로서 주 스레드가 공유 자원에 접근하지 못하도록 방지하는 역할을 수행한다. 작업 스레드에서 UIEngine.beginCriticalSection()을 호출하면 주 스레드로 스레드 동기화 요청 메시지를 보낸 후 작업 스레드는 그 결과를 받기 위한 모니터링 상태(또는 대기 상태)로 전환한다. 이 후 지정된 메인루프 사이클을 돌던 주 스레드는 이벤트 처리 단계에 도달 시 요청한 메시지를 해석한다. 이 후 주 스레드는 요청한 대로 메인루프의 동작을 일시 중지하고 작업 스레드가 임계 영역 구간을 수행할 수 있도록 동작 재개 요청 메시지를 보낸다. 이 후 주 스레드는 endCriticalSection()이 불릴 때까지 모니터링(대기) 상태에 빠지며 주 스레드로부터 UI 객체에 접근하는 일이 없도록 방지한다. 한편, 모니터링 상태에 있던 작업 스레드는 주 스레드로부터 동작 재개 메시지를 전달 받고 그 즉시 대기 상태에서 깨어나 이 후 작업을 진행한다. 이 후 작업 스레드는 endCriticalSection()을 호출하면서 주 스레드와 동기화 작업을 동일한 방식으로 수행한다.

    그림 26: 주 스레드와 작업 스레드간 임계 영역 수행 시퀀스

    beginCriticalSection()과 endCriticalSection()의 구간은 임계 구역으로서 주 스레드의 동작이 지연되므로 UI 앱의 동작 역시 지연되는 구간에 해당된다. 따라서 보다 매끄럽게 동작하는 앱을 구성하기 위해 임계 영역을 최소화하는 것이 중요하다.


    5.4 태스크 스케줄링

    이론적으로 프로세스는 CPU에서 가용한 물리적 스레드 개수와 동일한 수의 논리적 스레드를 운영하는 것이 이상적이다. 가용한 물리적 스레드보다 더 많은 논리적 스레드를 생성한다면 스레드 전환 작업에 많은 시간을 소모하게 되어 오히려 성능 하락 요소로 작용할 수 있다. 따라서, 하나의 프로세스에서 최적의 스레드 운용을 위한 관리가 필요하다. 사용자가 다수의 UITask를 요구할 시, UIEngine은 작업 스레드를 몇 개까지 운영할 지 계산해야 한다. 만약 작업마다 개별 스레드를 생성한다면 스레드 과부하로 이어질 수 있다.

    멀티코어 시스템이 도래한 후, 현대의 시스템에서 스레드풀(Thread Pool)은 보편화 됐다. 프로그래밍 언어 차원에서 스레드풀을 기반으로 태스크 스케줄링 기능을 자체적으로 지원하기도 하지만 보다 원시적인 환경에서는 태스크 스케줄링을 자체적으로 구현해야한다.

    일반적으로 스레드풀은 각 스레드마다 할당할 수 있다. 태스크 스케줄링 저변에는 일정한 수의 스레드를 미리 생성하고 그에 해당하는 스레드풀을 할당한다. 요청받은 작업은 태스크 큐에 축적하고 태스크 스케줄링 정책에 따라 각 스레드가 축적된 작업을 취득하여 자신의 스레드풀에 담을 수 있다. 각 스레드는 자신의 스레드풀에 담긴 작업을 선입선출(First-In First-Out) 방식으로 처리가능하다. 스레드가 처리할 작업이 스레드풀에 더 이상 존재하지 않다면 스레드는 추가 작업 요청이 올 때까지 대기 상태(Wait)로 존재한다.


    그림 27: 스레드풀 기반 태스크 스케줄링


    태스크 스케줄링 시 스레드 간 작업을 균등히 분배하는 것이 중요하다. 문제는 각 작업이 소요할 시간을 정확히 예측하는 것은 불가하므로 각 스레드가 처리해야할 작업의 양을 작업의 개수로 판단하는 것도 하나의 방법이다. 이 경우, 작업을 균등하게 분배하기 위해 스레드간 작업을 주고받는 동작이 필요하다. 어느 한 스레드가 다른 스레드에 비해 작업을 상대적으로 빨리 처리했다면 다른 스레드로부터 작업을 가져옴(Steal)으로써 작업을 균등하게 분배할 수 있다.

    UIEngine은 태스크 스케줄링을 바탕으로 사용자가 요청한 UITask 작업을 스레드풀 기반으로 처리한다면 사용자의 작업을 보다 효율적으로 처리할 수 있다. 현재 시스템의 CPU 코어 개수 내지 물리적 스레드의 개수만큼 스레드를 생성할 수 있다. 사용자 또는 UI 시스템 자체적으로 생성한 비동기 작업이 UIEngine 내부 태스크 스케줄링을 통해 일괄적으로 처리된다면 스레딩 부하에서 자유로워질 수 있으며 보다 효율적인 작업 처리가 가능하다. 그리고 이는 곧 성능 최적화로 이어진다.

    다음 그림은 이러한 태스크 스케줄링 기반 작업 처리 과정의 단적인 예이다.


    그림 28: 태스크 스케줄링 기반 UITask 동작 시퀀스



    6. 정리하기

    애니메이션은 UI 기능을 보다 풍성하게 해주는 핵심 기능으로서 UI 엔진에서 반드시 다뤄야하는 기능 중 하나이다. 이번 장에서 우리는 애니메이션 개념은 물론 사용자가 애니메이션을 적용하는데 필요한 필수 요건에 대해서 살펴보았다.

    애니메이션은 기본적으로 연속된 준비된 장면을 한 장씩 출력함으로써 표현 가능하다. 이를 위해 프레임 애니메이션을 다루는 방법을 논했으며 대표적인 프레임 애니메이션으로서 GIF가 존재함을 알 수 있었다.

    UIEngine의 메인루프는 애니메이션 프레임을 결정하는 핵심부로서 메인루프 사이클을 통해 애니메이션 프레임 번호를 결정한다. 사용자가 매 프레임마다 장면을 갱신하기 위해 메인루프 기반의 애니메이션 인터페이스를 제공하는 방안을 살펴보았다.

    초당 60프레임 애니메이션의 필요성과 각 프레임 시간 계산 및 제어를 통한 60 FPS를 제공하는 방안을 살펴보았으며 이를 토대로 다양한 애니메이션 가속 방식과 이를 커스텀할 수 있은 메커니즘에 대해 살펴보았다. 그뿐만 아니라 프로퍼티에 변화를 준 UI 객체의 애니메이션 제공 방법도 살펴보았다.

    이러한 애니메이션 동작 방식을 기반으로 이펙트를 구현하는 방법을 살펴보았으며 대표적인 이펙트로서 블러 애니메이션을 살펴보았다. 추가로, 사용자 상호작용의 효과를 위해 사용자 입력 이벤트를 처리하는 메커니즘은 물론 그 동작 과정을 살펴보았으며 제스처 인식에 반응하는 이벤트 제공 방안도 살펴보았다.

    마지막으로, 스레드풀을 활용한 태스크 스케줄링 기반의 비동기 작업 처리 방법을 살펴보았다. 이를 이용하면 메인루프와 별개로 여러 작업을 동시에 처리할 수 있는 보다 고성능의 UI 엔진을 제공할 수 있음을 배울 수 있었다.