OCaml의 대수적 효과를 사용해 함수형 게임 애니메이션을 구현하는 방법을 살펴보고, 메뉴, 그래프 뷰어, 튕기는 공 예제를 통해 애니메이션과 로직을 우아하게 분리하는 패턴을 소개합니다.
2021년 1월 1일#ocaml#effects#animations#game
최근에 NUS에서 KC Sivaramakrishnan과 이야기할 기쁨을 누렸습니다. 그는 OCaml multicore 프로젝트의 핵심 개발자 중 한 명인데, 자신의 multicore 작업과 OCaml의 잠재적인 새로운 언어 기능인 대수적 효과에 대해 강연하러 왔었습니다. 큰 그림에서 보면, 대수적 효과는 일종의 모나드 대안으로 볼 수 있습니다.1, 2 하지만 핵심적인 장점은 기존의 직접 스타일 코드와 훨씬 더 잘 어울린다 는 점이며, 따라서 더 유연하고 인체공학적인 도구가 될 수 있다는 것입니다. KC를 만나며 가장 놀라웠던 점 중 하나는 OCaml에서 대수적 효과 구현이 이미 상당히 진척되었다는 사실을 깨달은 것이었습니다. 실제로 대수적 효과는 먼 미래의 공상 같은 것(콜록, modular implicits, 콜록)이 아니라, 지금 당장도 (심지어 꽤 쉽게) 가지고 놀 수 있는 단계에 와 있습니다(물론 OCaml 컴파일러의 실험적 포크를 사용해야 하지만요).
실제로 이것이 바로 지난 몇 주 동안 제가 해오던 일이었습니다. 이 발전이 열어 준 가능성의 세계에 너무나 크게 감동한 나머지, 당시 손에 잡히는 여러 OCaml 프로젝트에 대수적 효과를 넣어 실험하기 시작했습니다. 이런 실험들을 진행하면서 함수형 게임 개발에 대수적 효과를 적용하는 문제를 고민하게 되었고, 그 과정에서 함수형 스타일로 애니메이션을 표현하는 꽤 우아한 패턴을 발견했습니다. 사실 이 패턴은 게임 로직과 함께 애니메이션의 제어 흐름을 관리하는 데서 제가 오래 겪어 오던 문제를 해결해 주었고, 그래서 대수적 효과의 장점을 살펴보기에 꽤 좋은 주제라고 생각해 이 글을 쓰게 되었습니다.



이 글의 나머지 부분에서는 함수형 게임 애니메이션 구현이라는 맥락 속에서 대수적 효과를 부드럽게 소개 해보겠습니다. 대수적 효과를 사용해 게임에서의 상대적으로 복잡한 애니메이션 3가지 사례 연구(위 그림 참조)를 유연하고 우아한 방식으로 구현하는 과정을 살펴볼 것입니다. 이 사례들을 따라가다 보면 대수적 효과의 장단점, 그리고 왜 이것이 분명 기대할 만한 기능인지 더 잘 이해할 수 있을 것입니다.
이 글 전체의 실행 가능한 코드는 다음에서 찾을 수 있습니다:3https://gitlab.com/gopiandcode/ocaml-game-animations-with-algebraic-effects
최종 코드는 함수형 코드와 명령형 코드를 섞어 사용하지만(이것이 관용적인 OCaml 스타일입니다), 우리의 여정은 순수 함수형 게임의 세계에서 시작합니다.

기본적으로 함수형 게임은 게임의 실행을 게임 루프의 주요 단계들을 나타내는 두 개의 별도 함수로 나누어 동작합니다.
여기서의 아이디어는 게임 상태는 시간에 따라 변할 수 있지만, 개별 update와 draw 함수는 순수하고 참조 투명하며, 따라서 함수형 코드가 갖는 일반적인 장점들(모듈성, 조합 가능성, 재사용 가능한 코드, 추론의 용이성 등)을 그대로 누릴 수 있다는 것입니다.
이 아이디어를 실제로 보기 위해, 간단한 게임 메뉴를 구현하고 싶다고 가정해 봅시다.

함수형 접근을 따르면 메뉴의 상태를 다음과 같이 정의할 수 있습니다.
type menu = { selected: int; options: string list; }
이 꽤 단순한 상태를 바탕으로, 다음과 같이 메뉴를 위한 순수 draw 함수를 쉽게 작성할 수 있습니다.
let draw_item ~pos ~is_active txt = ... (* 개별 메뉴 항목 그리기 *)
let draw {selected;options} =
List.iteri (fun ind option ->
draw_item ~pos:ind ~is_active:(ind = selected) option
) options
마찬가지로 메뉴가 사용자 입력에 반응하게 만들고 싶다면, 다음과 같이 함수형 update 연산을 작성할 수 있습니다.
let update menu key =
let len = length menu.options in
let next pos = (pos + 1) mod len in
let prev pos = (pos - 1 + len) mod len in
match key with
| Up -> {menu with selected = next menu.selected}
| Down -> {menu with selected = prev menu.selected}
이것들을 모두 연결하면 프로그램의 메인 루프는 다음과 같이 생길 수 있습니다.
let main () : unit =
let rec loop menu =
draw menu;
let input = get_input () in
let menu = update menu input in
loop menu in
loop {selected=0; options=["A"; "B"; "C"]}
짜잔! 이제 사용자 입력에 동적으로 반응하고 갱신되는 완전한 함수형 메뉴를 갖게 되었습니다. 그리고 그 모든 것을 사악한 비순수 코드로 손을 더럽히지 않고 해냈습니다.

함수형 게임 개발, 멋지지 않나요?
이전 절에서 보여드린 내용은 새로운 것이 아닙니다. 웹에는 이런 스타일의 게임 엔진을 설명하는 블로그 글과 영상이 셀 수 없이 많고, 꽤나 아름다운 그림을 보여 줍니다.
하지만 안타깝게도 함수형 게임 개발의 땅에서는 모든 것이 겉보기와 같지만은 않습니다. 이 방법론으로 조금이라도 비사소한 게임을 개발하기 시작하면 금세 여러 모서리 사례를 만나게 되는데, 그중에서도 특히 악랄한 예가 바로 애니메이션 처리입니다.
다시 게임 메뉴 예제로 돌아가서, 이제 메뉴에 간단한 삶의 질 개선 기능을 추가하고 싶다고 해봅시다. 복잡한 것은 아니고, 메뉴 상태 사이에 페이드 인 전환만 넣고 싶습니다.
즉, 사용자가 버튼을 누르면 메뉴가 원래 상태에서 새로운 상태로 점진적으로 전환되는 동안 작은 시간 지연이 있어야 합니다.

좋아요, 복잡한 변형은 아니니 우리 프레임워크 안에서 쉽게 구현할 수 있겠죠?

안타깝게도, 아닙니다.
지금까지의 방법론은 update 함수가 순수하고 무상태적 이어야 하며, 게임의 매 프레임마다 호출된다는 가정 위에 전적으로 서 있었습니다.
따라서 사용자가 아래 키를 눌렀을 때 다음 항목이 선택된 새 메뉴를 그냥 반환해 버리면, 변환은 즉시 일어나게 되지 우리가 원하는 점진적인 변화 가 되지 않습니다.
그렇다면 update 함수에서 정확히 무엇을 반환해야 할까요?
함수형 게임 개발 패러다임을 엄격히 따르려 한다면, 이를 달성하는 한 가지 해킹적인 방법은 메뉴 안에서 애니메이션 상태를 명시적으로 추적하는 것입니다.
예를 들어, menu 정의를 다음과 같이 바꿀 수 있습니다.
type time = int
type state = Static of int | MovingBetween of int * int * time
type menu = { selected: state; options: string list; }
여기서 state 타입은 메뉴와 그 애니메이션의 가능한 두 상태를 추적합니다.
time(ms 단위)을 갖고 있거나.이 변경을 바탕으로 update 함수를 다음과 같이 작성할 수 있습니다(이제 프레임 간 시간을 추적하는 추가적인 time 매개변수를 받도록 수정되었습니다).
let update menu key delta =
let len = length menu.options in
let next pos = (pos + 1) mod len in
let prev pos = (pos - 1 + len) mod len in
match menu.state with
| MovingBetween (old_ind,new_ind, remaining) ->
let remaining = remaining - delta in
if remaining < 0 (* 애니메이션이 완료되었는가? *)
then {menu with selected = Static new_ind} (* 예: 정적 상태로 복귀 *)
else {menu with selected = MovingBetween (old_ind, new_ind, remaining)}
| Static ind ->
match key with (* 애니메이션이 완료되었을 때만 입력 처리 *)
| Up -> {menu with selected = MovingBetween (ind, next menu.selected, 100)}
| Down -> {menu with selected = MovingBetween (ind, prev menu.selected, 100)}
이렇게 하면 꽤 그럴듯하게 동작하긴 합니다. 하지만 이제 애니메이션 코드와 프로그램 로직이 뒤섞이기 시작하여 이해하기가 더 어려워지고, 버그가 스며들 가능성도 높아집니다. 게다가 이 방식은 단순한 예제에서는 우연히 잘 동작하더라도 확장 가능하지 않습니다. 애니메이션을 더 추가하려면 함수 전체를 다시 써야 할 것입니다.
분명 순수 함수형 게임 개발 접근은 애니메이션 관리에 심각한 어려움이 있습니다. 하지만 이것이 함수형 접근만의 문제라고 말하는 것은 공정하지 않습니다.
애니메이션 흐름과 로직의 균형을 맞추는 도전은 이 영역에 본질적으로 내재해 있습니다.
이 글의 나머지 부분에서는 방향을 조금 바꾸어, 약간 더 명령형인 게임 구조를 다룰 때 대수적 효과를 사용해 애니메이션을 구현하는 방법을 살펴보겠습니다. 다만 이것은 주로 구현을 단순화하기 위함이며, 아래의 핵심 아이디어는 순수 구현으로도 쉽게 옮길 수 있습니다.
이전 예제에서 한 걸음 물러나 보면, 근본적인 문제는 두 개의 분리된 제어 흐름을 섞으려 했다는 것이었습니다. 하나는 핵심 로직을 위한 흐름이고, 다른 하나는 애니메이션을 위한 흐름입니다. 다시 말해, 우리에게 필요한 것은 일종의 비지역적 제어 흐름입니다.
……그리고 마침 대수적 효과가 바로 이런 기능을 제공합니다.
이론에 대한 더 자세한 논의는 잠시 접어 두고, 최종 사용자 관점에서의 큰 그림에 집중해 보겠습니다. 대수적 효과는 사실상 “재개 가능한” 예외라고 볼 수 있습니다.4
효과를 정의할 때 사용자는 호출자가 제공하는 입력의 타입과, 효과가 완료되었을 때 반환해야 할 출력의 타입을 지정합니다.
effect A : int -> float
효과를 수행하려면 내장 원시 연산인 perform를 사용할 수 있습니다.
perform (A 1)
이 시점에서 현재 프로그램의 실행은 멈추고(예외와 매우 비슷합니다), 제어는 가장 가까운 효과 핸들러를 만날 때까지 스택 위로 이동합니다.
try
...
perform (A 1)
...
with
| effect (A v) k ->
(* 제어가 여기로 이동함 *)
효과 핸들러 위치에서 사용자는 효과에 전달된 매개변수와, continue 원시 연산을 사용해 프로그램을 재개할 수 있는 continuation k를 받습니다.
continue k 1.0
여기서 continuation을 재개하기 위해 전달하는 매개변수는 효과 선언에서 지정한 타입과 일치해야 합니다.
모든 것을 종합하면, 효과를 사용하는 OCaml 프로그램의 제어 흐름은 다음과 같이 보입니다.
let () =
try
let x = perform (A 1) in (* -1-+ *)
x +. 1.0 (* <----------|----+ *)
with (* | | *)
| effect (A v) k -> (* <----------+ | *)
continue k 1.0 (* --2-------------+ *)
처음에는 다소 인위적인 기능처럼 보일 수 있지만, 실제로는 애니메이션 구현에 아주 잘 들어맞습니다.
이제 대수적 효과라는 힘을 손에 넣었으니, OCaml에서 비지역적 제어 흐름을 유연하게 표현할 수 있고, 애니메이션 인터페이스를 구축하는 방향으로 나아갈 수 있습니다.
코드를 쓰기 전에 먼저, 우리가 인터페이스에서 실제로 지원해야 하는 기능이 무엇인지 분명히 하는 것이 중요합니다.
제 경험상 애니메이션을 사용하는 코드는 주로 세 가지 종류의 기능에 의존합니다.
따라서 복잡한 애니메이션을 쉽게 만들 수 있도록, 애니메이션 인터페이스에는 애니메이션을 더 복잡한 것으로 조합할 수 있는 기본 연산들이 있어야 합니다. 예를 들면 다음과 같습니다.
* **병렬 조합**

* **순차 조합**

* **지연으로 애니메이션 분리하기**


따라서 애니메이션 인터페이스는 다음 둘 다 지원하는 것이 중요합니다.
* **애니메이션이 외부 코드와 통신하기:**

* 그리고 **외부 코드가 애니메이션과 통신하기:**

이 절의 나머지에서는 이 기능들을 실제로 어떻게 구현할지 살펴보겠습니다.
우리의 계획은 두 단계입니다.
전이를 위한 데이터 타입은 다음과 같습니다.
type t = MkState : {state:'a; update: 'a -> int -> 'a option} -> t
여기서의 아이디어는 전이가 다음으로 구성된다는 것입니다.
update 함수여기서는 OCaml의 존재 타입을 사용하여 서로 다른 전이들이 서로 다른 내부 상태를 사용할 수 있게 합니다.
이 데이터 타입의 update 함수는 예상한 그대로입니다.
let update (MkState {state;update}) time : t option =
match update state time with
| Some state -> Some (MkState {state; update})
| None -> None
그럼, 이것을 어떻게 사용할까요?
좋은 입문 예시는 기본적인 지연을 구현하는 것입니다.
module Delay = struct
let of_ delay =
let update time delta =
let time = time + delta in
if time < delay
then Some time
else None in
MkState {state=0; update}
end
여기서 전이의 상태는 경과 시간을 나타내는 정수이고, 각 update는 그 경과 시간을 증가시키며, 지정한 지속 시간이 되면 종료합니다.
조금 더 흥미로운 예시는 보간 전이입니다. 이것은 어떤 변수를 초기값에서 최종값으로 점진적으로 바꿉니다.
module Interpolate = struct
let between ?(delay=100) set ~start ~stop =
let distance = stop - start in
let update time delta =
let time = time + delta in
let proportion = Float.(of_int time / of_int delay) in
if Float.(proportion > 1.0)
then (set stop; None)
else (set (start +. distance * proportion); Some time) in
MkState {state=0; update}
end
여기서도 다시 전이의 상태는 경과 시간을 나타내는 정수입니다. 하지만 이번에는 시간을 추적하는 것에 그치지 않고, 매 update마다 setter 함수를 사용해 어떤 임의의 값에 새 값을 설정합니다.
또한 전이의 update를 함수형으로 만든 덕분에, 이것들은 매우 자연스럽게 조합될 수 있습니다.
module Combine = struct
let in_parallel (MkState { state=s_a; update=u_a }) (MkState { state=s_b; update=u_b }) =
let bind x f = match x with None -> None | Some v -> f x in
let update (sa, sb) time =
let sa = bind sa (fun v -> u_a v time) in
let sb = bind sb (fun v -> u_b v time) in
match sa, sb with
| None, None ->
(* 병렬 애니메이션은
두 구성 애니메이션이 모두 완료될 때 완료된다 *)
None
| _ ->
(* 그렇지 않으면 애니메이션 계속 *)
Some (o_sa, o_sb) in
State.MkState {state=(Some s_a,Some s_b); update}
val in_sequence: Transition.t -> Transition.t -> Transition.t
end
여기서 두 전이를 병렬로 실행하려면, 두 구성 애니메이션의 상태를 모두 추적하고 둘 다 완료되었을 때만 종료하는 새 전이를 만듭니다. 간결함을 위해 순차 조합의 정의는 생략했지만, 대체로 병렬 조합과 같은 구조를 따릅니다.
전반적으로 이런 애니메이션들을 결합하면 복잡한 전이를 구성하기 위한 매우 유연한 인터페이스를 얻게 됩니다. 하지만 여정은 아직 끝나지 않았습니다. 현재의 전이 데이터 타입은 애니메이션의 일종의 재현된 인코딩을 나타내기 때문에, 이것만으로는 사용하기 어렵고 쉽게 오용될 수도 있습니다.
예를 들어 다음과 같이 애니메이션을 구성한다고 해봅시다.
let animate menu =
let update_brightness =
Interpolate.between ~start:0 ~stop:10
(fun v -> menu.brightness <- v) in
let update_size =
Interpolate.between ~start:20 ~stop:30
(fun v -> menu.size <- v) in
menu.selected <- next menu.selected;
Combine.in_parallel
update_brightness update_size
문제는 이 애니메이션을 구성할 때,5 menu의 selected 필드 값을 설정하고 있다는 점입니다. 하지만 이것은 애니메이션 도중에 수행되는 것이 아니라, 애니메이션을 구성하는 시점에 수행됩니다(그리고 그것이 반드시 의도한 바는 아닐 수 있습니다).
실제로 이 애니메이션 인터페이스를 완성하려면, 한 단계 더 높은 조합 수준을 도입해야 합니다.
마침내 여기서부터가 이 방정식의 대수적 효과 부분입니다.
우선 애니메이션 전용의 새 효과를 하나 만들겠습니다.
effect Animation : Transition.t * (unit -> unit) option -> unit
이 효과는 두 개의 매개변수를 받습니다. 1. 실행할 전이, 2. 애니메이션이 취소될 때 호출할 선택적 함수입니다. 단순화를 위해 효과가 unit 값을 반환하게 했지만, 애니메이션 실행과 외부 세계 사이의 더 많은 피드백을 허용하도록 반환 타입을 바꾸는 것도 충분히 상상할 수 있습니다.
이전과 마찬가지로, 효과를 수행 하는 과정을 감싸는 보조 함수도 만들겠습니다.
let run ?on_cancellation (x: Transition.t) : unit =
perform (Animation (x, on_cancellation))
이제 이 효과와 애니메이션 실행을 짝지어, 애니메이션의 실행을 포착하는 데이터 타입으로 만들 수 있습니다.
type s = {
current_state: Transition.t;
kont: (unit, s) continuation option;
on_cancellation: (unit -> unit) option
}
여기서 애니메이션은 세 가지 구성 요소로 표현됩니다.
추가 continuation이 없는 단순한 애니메이션을 만들기 위한 보조 함수는 다음과 같습니다.
let return (s: Transition.t) : s = {
current_state=s;
kont=None;
on_cancellation=None
}
타입 안정성을 위해(그리고 사용자가 처리되지 않은 효과를 실수로 발생시키지 않도록 하기 위해), 원시 애니메이션과 완전한 애니메이션을 구분하는 새 타입을 만들겠습니다.
type t = private s
이제 완전한 애니메이션을 만들려면 다음 보조 함수를 사용할 수 있습니다.
let build (f: unit -> s) : t =
try f () with
| effect (Animation (state, on_cancellation)) kont ->
{current_state=state; kont=Some kont; on_cancellation}
이 보조 함수는 애니메이션을 나타내는 함수를 받아, 원시 애니메이션이 최종 전이를 반환하며 완료되거나, 애니메이션 효과가 수행되는 순간 실행을 일시 중지할 때까지 실행합니다.
마지막으로 애니메이션이 구성된 뒤에는, 별도의 update 함수를 사용해 각 반복마다 그것을 갱신할 수 있습니다.
let update time (t: t) =
match Transition.update t.current_state time with
| Some state -> Some {t with current_state = state}
| None -> match t.kont with
| None -> None
| Some kont ->
Some (continue kont ())
이 update 함수는 현재 전이를 갱신하고, 그것이 완료되면 존재하는 경우 애니메이션의 나머지 부분을 재개하려 시도합니다.
이어지는 사례 연구들에서 보게 되겠지만, 이 인터페이스는 복잡한 애니메이션을 꽤 쉽게 구성하면서도 직관적이고 직접적인 방식으로 코드를 쓸 수 있게 해 줍니다.
이제 이 멋진 애니메이션 인터페이스를 손에 넣었으니, 실제로 돌려 보면서 인터랙티브한 요소들을 만들어 봅시다.
먼저 우리의 계속된 예제이자(사실 이 글을 쓰게 된 주된 계기이기도 한) 애니메이션 메뉴부터 시작하겠습니다.
참고: 나머지 예제들에서는 애니메이션을 추적하고 갱신하는 일종의 애니메이션 관리자 존재를 가정합니다. 이는 애니메이션 id를 애니메이션에 매핑하는 기본 해시 테이블 정도로 구현할 수 있으며 상당히 표준적인 코드라서 글에는 포함하지 않았습니다.
우선 원래 메뉴 타입을 다음과 같이 바꿔 봅시다.
type t = {
options: string list;
mutable selected: int;
mutable state: state;
}
여기서의 주요 변화는 draw 상태를 별도로 캡슐화하는 새 필드6를 도입했고, 명령형 스타일로 전환한 것을 반영해 일부 필드를 가변으로 만들었다는 점입니다.
이전과 달리, 새 state 타입은 이제 다음과 같이 정의되어 애니메이션 진행 상황 추적과 관련된 세부 사항을 제거합니다.
type state =
| Static (* 정적인 메뉴 그리기 *)
| Moving of { (* 전이 중인 요소들 그리기 *)
old_ind: int;
old_brightness: int ref;
current_brightness: int ref
}
여기서 이 state 매개변수는 메뉴를 실제로 그리는 데 필요한 정보만 추적합니다. 메뉴를 그릴 때는 두 가지 주요 상태가 있습니다.
실제 draw 코드는 꽤 단순하고 그래픽 API에 크게 의존하므로 여기서는 생략하겠습니다.
이 시스템의 진짜 마법은 이제 update 함수에 있으며, 간결하게 다음처럼 정의됩니다.
let update t status _time =
match Graphics.key_pressed (), t.state with
| false, _
| _, Moving _ ->
() (* 키가 눌리지 않았거나 이동 중이면 입력 무시 *)
| true, Static ->
(* 키가 눌렸고 정적 상태인 경우: *)
let key = Graphics.read_key () in
let len = List.length t.options in
match key with
(* 위로 이동 *)
| 'p' -> add_animation (change_index_anim t t.selected ((t.selected + 1) mod len))
(* 아래로 이동 *)
| 'n' -> add_animation (change_index_anim t t.selected ((t.selected - 1 + len) mod len))
| _ -> ()
이제 메뉴의 update 함수가 애니메이션 로직과 분리되어 있다는 점에 주목해 보세요. 핵심 로직의 주요 변화는 사용자가 위아래를 눌렀을 때 상태를 즉시 갱신하는 대신, change_index_anim 함수를 사용해 그렇게 하는 새 애니메이션을 생성한다는 것입니다.
let change_index_anim t start stop = Animation.build @@ fun () ->
let open Animation in
let old_brightness = ref 100 in
let current_brightness = ref 0 in
(* 메뉴 상태를 그리기 상태로 갱신 *)
t.selected <- stop;
(* 이전 인덱스와 새 인덱스의 밝기를 점진적으로 바꾸는 전이 실행 *)
t.state <- Moving {old_ind=start; old_brightness; current_brightness};
run Transition.(
Combine.in_parallel
(Interpolate.between ~start:100 ~stop:0 ~delay:300
(fun n -> old_brightness := n))
(Interpolate.between ~start:0 ~stop:100 ~delay:300
(fun n -> current_brightness := n)));
(* 전이가 완료되면 메뉴의 draw 상태를 정적으로 갱신 *)
t.state <- Static;
return Transition.identity
여기서 이 함수는 이전과 새로 선택된 메뉴 항목의 색상을 점진적으로 애니메이션하는 애니메이션을 구성합니다. 애니메이션 로직 자체는 메뉴 로직과 완전히 분리되어 있고, 애니메이션 로직과 통신하기 위해 메뉴의 공유 상태 필드7만 사용한다는 점에 주목하세요. 예를 들어 애니메이션이 완료되면 상태 변수를 Static으로 설정하여 핵심 로직에 알려 줍니다.
이것들을 조합하면 큰 수고 없이 부드럽게 움직이는 애니메이션 메뉴를 얻을 수 있습니다.

참고: 여기서 제시한 메뉴 버전은 약간 단순화된 것입니다. 코드베이스의 실제 버전은 타입 안전한 메뉴 옵션을 허용하는 보다 일반적인 구현을 사용합니다.
메뉴는 제어 흐름이 꽤 단순한 예였기 때문에, 회의적인 독자는 이 애니메이션 인터페이스가 더 복잡한 응용을 지원할 만큼 확장될지 걱정할 수도 있습니다. 그런 우려를 덜기 위해, 다음 예제에서는 이 애니메이션 프레임워크를 사용해 약간 더 복잡한 애니메이션 그래프 뷰어를 구현해 보겠습니다. 그리고 이 사례 연구를 통해 애니메이션을 어떻게 이용해 어떤 종류의 로직까지 수행할 수 있는지 살펴보겠습니다.
이 응용의 아이디어는 사용자가 인터페이스를 통해 그래프를 시각적으로 편집하고, 정점과 간선을 추가하거나 이동할 수 있게 하는 것입니다.
먼저 응용의 상태를 다음과 같이 설정하겠습니다.
type t = {
graph: G.t;
mutable state: state;
}
여기서 G.t는 유서 깊은 OCamlGraph 라이브러리가 제공하는 명령형 그래프 모듈의 한 인스턴스일 뿐이며, 그래프의 개별 정점은 다음 타입으로 표현됩니다.
module Cell = struct
type state = Static | Growing
type t = {
data: string;
mutable x: int; mutable y: int;
mutable width: int; mutable height: int;
mutable state:state;
}
end
여기서 셀 자체도 애니메이션되므로, 셀이 정적인지 자라는 중인지 추적하기 위한 추가 매개변수가 있습니다.
그렇다면 메인 응용의 상태는 다음과 같이 정의됩니다.
type state = View | Move of Cell.t | EdgeStart | EdgeFirst of Cell.t | Insert | Debounce
우리는 프로그램의 핵심 로직을 포착하는 다섯 가지 주요 상태를 식별했습니다.
또한 추가로 Debounce라는 상태도 포함합니다. 이것은 직접적인 논리 상태에 대응하지는 않지만, 애니메이션을 약간 억지로 활용해 입력 디바운싱을 구현하기 위한 삶의 질 기능입니다.
다시 한 번 말하지만 그래프를 그리는 것은 꽤 간단하며, 간선과 정점을 순회하면서 화면에 그리기만 하면 됩니다. 이 구현의 흥미로운 부분은 update 함수에 있습니다.
let update viewer s _time =
(* 마우스를 포함하는 셀이 있으면 찾기 *)
let screen_mouse_pos = Coordinate_system.from_display (s.mouse_x,s.mouse_y) in
let cell_contains = find_cell_at_point screen_mouse_pos in
match viewer.state with
| View -> ... (* view 모드 처리 *)
| Debounce -> () (* debounce 모드에서는 입력 무시 *)
| EdgeStart -> ... (* edge 모드 (start) 처리 *)
| EdgeFirst cell -> ... (* edge 모드 (first) 처리 *)
| Insert -> ... (* insert 모드 처리 *)
| Move cell -> (* move 모드 처리 *)
이 더 복잡한 응용에서는 update 함수 전체를 이 글에서 다루기에는 너무 길기 때문에(전체적으로 약 30줄 정도입니다), 대신 구현에서 가장 흥미로운 몇 가지 경우에 초점을 맞추겠습니다.
View 모드는 사용자가 기본적으로 머무는 모드로, 전체 그래프를 보는 데 사용됩니다. 따라서 그 자체로 특별한 기능은 없고, update 코드는 주로 다른 상태로 전환하는 일을 담당합니다.
| View ->
begin match cell_contains, Graphics.button_down () with
| Some cell, true -> set_to_state viewer (Move (cell))
| _ -> match Graphics.key_pressed (), Graphics.read_key () with
| true, 'e' -> set_to_state viewer EdgeStart
| true, 'i' -> set_to_state viewer Insert
| _ -> ()
end
여기서 update 함수는 매우 직관적입니다. 사용자가 특정 노드를 클릭했다면 move 상태로 전환하고, 그렇지 않고 다른 모드에 해당하는 키를 눌렀다면 해당 상태로 전환합니다.
모든 경우에서, 실제 전환을 구현하기 위해 set_to_state라는 보조 함수를 사용합니다.
예상하셨겠지만, 이 함수는 실제로 애니메이션에 위임하는 방식으로 동작합니다.
let debounce_to viewer ~stop ~delay = Animation.build @@ fun () ->
let open Animation in
viewer.state <- Debounce;
run Transition.(Delay.of_ ~delay);
viewer.state <- stop;
return Transition.identity
let set_to_state viewer =
add_animation (debounce_to viewer ~stop:st ~delay:100)
여기서 응용의 상태를 바꾸기 위해, 먼저 상태를 특수한 debounce 상태로 설정하고, 일정한 지연 뒤에야 최종적으로 원하는 상태로 바꾸는 새 애니메이션을 생성합니다.
이 전이 시퀀스의 아이디어는 사용자가 실수로 여러 이벤트를 연속 호출하지 않도록 하는 것입니다. 전환이 발생할 때마다 응용은 짧은 디바운싱 기간 동안 모든 입력을 무시합니다.
어떤 의미에서는 이것을 애니메이션의 남용이라고 볼 수도 있겠지만, 응용의 핵심 로직과 직접 관련된 것은 아니므로 오히려 인터페이스의 이상적인 활용처럼 보입니다.
또 다른 흥미로운 경우는 새 노드를 삽입하는 데 사용되는 insert 모드입니다.
| Insert ->
begin match cell_contains, Graphics.button_down () with
| None, true ->
let cell = create_cell screen_mouse_pos (Random.run (List.random_choose strings)) in
G.add_vertex graph cell;
set_to_state Insert
| _ -> match Graphics.key_pressed (), Graphics.read_key () with
| true, 'e' -> set_to_state viewer EdgeStart
| true, 'i' -> set_to_state viewer View
| _ -> ()
end
다시 한 번, 애니메이션 덕분에 이 경우의 로직은 상당히 직관적이고 따라가기 쉽습니다. 응용은 먼저 사용자가 다른 셀에 점유되지 않은 영역을 클릭했는지 확인하고, 그렇다면 그 위치에 새 셀을 생성합니다. 그렇지 않으면 단순히 다른 상태에 해당하는 버튼이 눌렸는지 확인합니다.
흥미로운 부분은 새 그래프 정점과 그 애니메이션 생성을 처리하는 create_cell 연산입니다.
let create_cell_animation cell = Animation.build @@ fun () ->
let open Animation in
cell.state <- Growing;
run Transition.(Combine.in_parallel
(Interpolate.between ~start:0 ~stop:200 ~delay:200
(fun n -> cell.width <- n))
(Interpolate.between ~start:0 ~stop:100 ~delay:200
(fun n -> cell.height <- n)));
cell.state <- Cell.Static;
return Transition.identity
let create_cell (x,y) txt =
let cell = Cell.{
data=txt; x;y;
width=0; height=0;
state=Static;
} in
add_animation (create_cell_animation cell);
cell
여기서는 정점이 생성될 때, 셀이 아무것도 없는 상태에서 최종 크기로 점차 커지도록 하는 새 애니메이션도 함께 생성하여 더 유연한 인터페이스를 만듭니다.
모든 구성 요소를 결합하면, 기본 응용의 복잡한 로직을 관리하면서도 애니메이션의 장점을 계속 활용할 수 있습니다.

이제 다시 초점을 바꾸어, 마지막 사례 연구에서는 복잡한 응용 로직 대신 더 복잡한 애니메이션 흐름에 집중해 보겠습니다. 이 간단한 프로그램에서는 튕기는 공들의 작은 놀이터를 만들 것입니다. 각 공은 사용자가 마우스로 조작(끌기, 멈추기 등)할 수 있습니다.
먼저 프로그램에서 개별 공의 상태를 나타내는 간단한 데이터 구조를 만들겠습니다.
module Circle = struct
type state = Stationary | Moving | Squashed
type t = {
mutable state: state;
mutable x: int; mutable y: int;
mutable w: int; mutable h: int;
}
end
여기서 공 자체도 개별적으로 애니메이션되므로, 공의 상태를 추적하기 위한 추가 state를 둡니다. 즉, 정지 상태이거나, 이동 중이거나, 찌그러진 상태입니다.
type state = None | Hover of Circle.t * Animation.t | Clicked of Circle.t
type t = {
mutable circles: Circle.t list;
mutable state: state;
mutable selected: bool;
}
이번에는 전체 프로그램의 상태가 세 가지 서로 다른 경우로 나뉩니다.
늘 그렇듯이 draw 함수는 여기서 생략하고, 곧바로 update 연산에 집중하겠습니다.
let update game s _time =
let circle_at_mouse = find_circle_at_mouse game.circles s.mouse_x s.mouse_y in
let mouse_down = Graphics.button_down () in
match game.state, circle_at_mouse with
(* 경우 1: 원 위에 호버 중 *)
| None, Some circle ->
let anim = remove_animation circle in
game.state <- Hover (circle, anim)
(* 경우 2: 원 클릭 *)
| Hover _, Some circle when mouse_down && not game.selected ->
game.state <- Clicked circle;
game.selected <- true;
(* 경우 3: 호버 중이던 원에서 벗어남 *)
| Hover (circle, anim), None ->
add_animation circle anim;
game.state <- None
(* 경우 4: 클릭한 원에서 손을 뗌 *)
| Clicked circle, _ when not mouse_down ->
add_animation circle (circle_bounce_anim circle);
game.selected <- false;
game.state <- None;
(* 경우 5: 원을 끌기 *)
| Clicked circle, _ ->
circle.x <- s.mouse_x;
circle.y <- s.mouse_y;
| _ -> ()
여기서 코드는 매우 직관적이며, 응용이 놓일 수 있는 5가지 논리 상태를 단순한 경우 분석으로 구현합니다.
circle_bounce_anim 사용)을 시작하고 상태를 None으로 바꿉니다.이 예제의 멋진 점은 애니메이션 인터페이스가 응용 로직을 애니메이션과 얼마나 잘 분리하는지 잘 보여 준다는 것입니다. 사실 우리는 원할 때 언제든 애니메이션을 일시 중지하거나 재개할 수도 있습니다.
이 사례 연구의 또 다른 흥미로운 부분은 실제 애니메이션 함수 circle_bounce_anim입니다. 이것은 우리가 앞서 보았던 애니메이션들과 달리 유한한 애니메이션이 아니라 무한히 반복됩니다. 이 인터페이스의 아름다운 점은 추가 코드를 전혀 쓰지 않고도 이런 동작을 달성할 수 있다는 것입니다.
그럼 어떻게 할까요?
대수적 효과는 애니메이션을 직접 스타일 코드 안에 직접 내장할 수 있게 해 주므로, 무한 애니메이션은 단순히 무한 루프입니다.
let circle_bounce_anim circle =
let rec loop () =
let open Animation in
(* 원을 아래로 이동 *)
circle.state <- Moving;
run Transition.(Interpolate.between ~start:circle.y ~stop:(circle.y - 200) ~delay:300
(fun y -> circle.y <- y));
(* 원 찌그러뜨리기 *)
circle.state <- Squashed;
(* 먼저 바깥쪽으로 찌그러짐 *)
run Transition.(Combine.in_parallel
(Interpolate.between ~start:circle.w ~stop:(circle.w + 30) ~delay:100
(fun y -> circle.w <- y))
(Interpolate.between ~start:circle.h ~stop:(circle.h - 30) ~delay:50
(fun y -> circle.h <- y)));
(* 그런 다음 원래 모양으로 복귀 *)
run Transition.(Combine.in_parallel
(Interpolate.between ~start:circle.w ~stop:(circle.w - 30) ~delay:120
(fun y -> circle.w <- y))
(Interpolate.between ~start:circle.h ~stop:(circle.h + 30) ~delay:80
(fun y -> circle.h <- y)));
(* 원을 위로 이동 *)
circle.state <- Moving;
run Transition.(Interpolate.between ~start:circle.y ~stop:(circle.y + 200) ~delay:300
(fun y -> circle.y <- y));
(* 궤적의 꼭대기에서 잠시 멈춤 *)
circle.state <- Stationary;
run @@ Transition.(Delay.of_ ~delay:50);
(* 애니메이션 다시 실행 *)
loop () in
Animation.build loop
여기서 공 튕기기 애니메이션은 공 애니메이션의 각 단계를 순서대로 직접 반복하는, 아주 직관적인 루프로 작성됩니다. 먼저 아래로 떨어지고, 그다음 찌그러지고, 다시 위로 튀어 오릅니다. 애니메이션이 끝나면 그저 다시 반복합니다. 다시 한 번 말하지만, 애니메이션 코드는 실제 응용 로직과 완전히 분리되어 있습니다.
이것들을 모두 합치면, 사례 연구 탐색은 이 튕기는 공들의 마지막 동적 애니메이션으로 마무리됩니다.

이 글을 정말로 마무리하기 전에, 아마 여러분 머릿속에 있을 한 가지 질문에 답해야 할 것 같습니다. 왜 함수형 반응형 프로그래밍(FRP)이 아닌가?
함수형 반응형 프로그래밍(FRP)은 함수형 스타일로 애니메이션을 인코딩하는 사실상의 표준 접근 가운데 하나입니다. 애니메이션과 함수형 프로그래밍을 검색하면 거의 언제나 FRP에 대한 결과를 보게 됩니다.
FRP의 일반적인 아이디어는 전체 애니메이션을 일종의 의존성 그래프로 인코딩하는 것입니다. 여기서 그래프의 정점은 시간에 따라 변하는 값들을 나타내고, 그래프의 간선은 이 값들 사이의 데이터 의존성을 나타냅니다. 즉, 변수 A가 변수 B에 의존한다면, B가 갱신될 때 A의 값도 갱신되어야 합니다.
FRP 용어로 시간에 따라 변하는 연속적인 값은 signal이고, 이산적인 값은 event입니다.
type 'a signal
type 'a event
예를 들어 마우스 좌표에 그림을 그리고 싶다면, 마우스 위치를 나타내는 의존성 그래프의 “노드”로부터 그림 자체를 구성하면 됩니다.
val mouse_position: (int * int) signal
let drawing : circle signal =
Signal.map (fun (x,y) -> Circle (x,y)) mouse_position
본질적으로 FRP는 모나드적 접근과 꽤 잘 맞물립니다. 이런 의존성들을 구성하는 과정은 본질적으로 애니메이션의 재현된 표현이 되며(앞서 본 타임라인 구조와 비슷합니다), 그 구성은 모나드 바인딩으로 단순화될 수 있습니다.
그런데 제가 FRP에 대해 갖는 핵심적인 문제는 그 인코딩의 전염성입니다. 애니메이션을 구성할 때, 프로그램 안의 어떤 값이라도 시간에 따라 변하는 값에 의존한다면, FRP는 그것 역시 의존성 그래프 안에 인코딩되어야 한다고 요구합니다. 따라서 일반적인 그림은 결국 전체 응용/게임이 시간에 따라 변하는 하나의 게임 상태 표현으로 귀결된다는 것입니다.
상상할 수 있듯이, 이것은 금방 꽤 성가셔질 수 있습니다. 예를 들어 코드에 애니메이션을 도입하고 싶을 때, 그 애니메이션이 프로그램의 아주 작은 부분에만 필요하더라도 전체 프로그램을 이 패러다임으로 감싸야 합니다. 이런 식의 모나드 래퍼를 코드베이스 전체에 집요하게 전파하는 태도는 Haskell 배경이라면 좀 더 받아들이기 쉬울 수 있겠지만, 일반적으로 관용적인 OCaml의 장점 중 하나는 훨씬 더 실용적이라는 점입니다. 그래서 저는 FRP를 선택하지 않았습니다.
마침내 이 긴 글의 끝에 도달했습니다. 바라건대 이 사례 연구들을 통해 애니메이션을 위한 이 대수적 효과 기반 인터페이스가 얼마나 유연하고 사용하기 쉬운지 어느 정도 설득할 수 있었으면 좋겠습니다.
엄밀히 말하면 이 글에서 사용한 대수적 효과의 기능은 기존의 제어 흐름 구성 요소들로도 대부분 흉내 낼 수 있습니다. 하지만 대수적 효과를 통해 continuation을 명시적으로 관리한다는 사고방식이 이 인터페이스를 떠올리는 데 정말 큰 도움이 되었다고 느꼈습니다.
다른 무엇보다도, 로직 코드와 애니메이션 코드를 섞는 문제는 꽤 오랫동안 저를 괴롭혀 왔고, 이것을 어떻게 관리할 수 있는지에 대해 다룬 글도 많이 보지 못했습니다.8 그래서 이 글이 누군가에게 도움이 될 수 있기를 바랍니다.
전반적으로 저는 대수적 효과를 다루는 과정이 즐거웠고, OCaml에서 문제에 접근하는 새로운 패턴을 매우 다양하게 열어 준다고 느꼈습니다. 그리고 다가오는 한 해 동안 이 분야에서 어떤 새로운 발전이 나올지 무척 기대하고 있습니다.