Wednesday, August 27, 2025

플러터는 게임 엔진의 시선으로 세상을 봅니다

혹시 앱을 만들다가 "이거, 어딘가 게임 만드는 것과 비슷한데?"라고 느껴본 적 있으신가요? 특히 Unity나 Unreal 같은 게임 엔진을 다뤄본 개발자라면 Flutter를 처음 접했을 때 묘한 기시감을 느꼈을지도 모릅니다. 위젯을 조립해 UI를 만드는 과정은 마치 게임 오브젝트를 씬(Scene)에 배치하는 것과 흡사하고, 상태(State)를 변경해 화면을 갱신하는 모습은 게임 루프 속에서 변수를 조작해 캐릭터의 움직임을 만들어내는 원리와 닮아있습니다. 이것은 우연이 아닙니다. Flutter는 태생부터 앱을 '만드는' 방식이 아니라, '렌더링하는' 방식에 있어 게임 엔진의 철학을 깊숙이 공유하고 있기 때문입니다.

이 글에서는 Flutter의 아키텍처를 게임 엔진, 특히 Unity의 씬 그래프(Scene Graph)와 게임 루프(Game Loop) 개념을 통해 심층적으로 해부하고자 합니다. 왜 Unity 개발자가 다른 모바일 앱 개발자보다 Flutter에 더 빠르게 적응하는지, 그 근본적인 이유를 기술적인 관점에서 파헤쳐 봅니다. Widget, Element, RenderObject로 이어지는 Flutter의 3-트리(Three-Tree) 구조가 어떻게 게임 엔진의 렌더링 파이프라인과 맞닿아 있는지, 그리고 최신 렌더링 엔진 '임펠러(Impeller)'가 어떻게 Metal, Vulkan과 같은 로우레벨 그래픽 API를 직접 제어하며 '버벅임(Jank)' 없는 60/120fps 애니메이션에 집착하는지를 따라가다 보면, 여러분은 Flutter가 단순한 UI 툴킷이 아닌, UI를 위한 고성능 실시간 렌더링 엔진이라는 사실을 깨닫게 될 것입니다.

1. 위젯과 게임 오브젝트: 화면을 구성하는 레고 블록

게임 개발의 가장 기본 단위는 '게임 오브젝트(Game Object)'입니다. Unity를 예로 들어보겠습니다. 텅 빈 씬에 생성된 게임 오브젝트는 그 자체로는 아무것도 아닙니다. 이름과 트랜스폼(Transform, 위치/회전/크기 정보)만 가진 텅 빈 껍데기일 뿐이죠. 여기에 '컴포넌트(Component)'를 붙여야 비로소 의미를 갖습니다. 3D 모델을 보여주려면 Mesh RendererMesh Filter 컴포넌트를, 물리적인 움직임을 원하면 Rigidbody를, 플레이어의 입력을 받으려면 직접 작성한 PlayerController 스크립트 컴포넌트를 붙입니다. 이처럼 게임 오브젝트는 컴포넌트를 담는 그릇이며, 이들의 조합으로 캐릭터, 장애물, 배경 등 게임 세계의 모든 것을 만들어냅니다.

이제 Flutter의 '위젯(Widget)'을 봅시다. Flutter 개발자가 가장 먼저 배우는 것은 "Flutter에서는 모든 것이 위젯이다"라는 말입니다. 이 위젯이라는 개념은 Unity의 게임 오브젝트와 놀라울 정도로 유사합니다. Container 위젯을 한번 볼까요?


Container(
  width: 100,
  height: 100,
  color: Colors.blue,
  child: Text('Hello'),
)

Container는 '파란색 배경을 가진 100x100 크기의 사각형'이라는 시각적 특성과 'Hello라는 텍스트를 자식으로 포함'하는 구조적 특성을 동시에 가집니다. 이를 게임 오브젝트 방식으로 분해해볼 수 있습니다. Container는 하나의 게임 오브젝트입니다. width, height, color는 이 오브젝트의 Transform이나 Mesh Renderer 컴포넌트가 가진 속성(Property)과 같습니다. child 속성은 이 게임 오브젝트가 자식 게임 오브젝트(Text)를 가지고 있음을 의미합니다. Unity에서 빈 게임 오브젝트를 만들고, 그 아래에 텍스트 오브젝트를 자식으로 넣는 것과 완벽하게 동일한 계층 구조입니다.

이러한 계층 구조가 모여 만들어지는 것이 Unity에서는 '씬 그래프(Scene Graph)', Flutter에서는 '위젯 트리(Widget Tree)'입니다. 씬 그래프는 게임 월드의 모든 오브젝트가 어떻게 부모-자식 관계로 얽혀있는지를 보여주는 지도입니다. 부모 오브젝트가 움직이면 자식 오브젝트도 따라 움직이는 것처럼, 위젯 트리에서도 부모 위젯의 특성이 자식 위젯에게 영향을 미칩니다. Center 위젯 안에 Text 위젯을 넣으면 텍스트가 화면 중앙에 배치되는 것이 바로 이런 원리입니다.

결론적으로, Unity 개발자가 씬 뷰(Scene View)에서 게임 오브젝트를 드래그 앤 드롭하고 인스펙터(Inspector) 창에서 컴포넌트 속성을 조절하며 씬을 구성하는 행위는, Flutter 개발자가 코드 에디터에서 위젯을 중첩하고 속성을 부여하며 UI를 선언적으로(declaratively) 구성하는 행위와 본질적으로 같습니다. 사용하는 도구와 언어(C# vs Dart)가 다를 뿐, '객체를 조합하여 계층 구조를 만들고, 속성을 부여해 원하는 장면을 구성한다'는 핵심적인 사고방식, 즉 '문법'을 공유하는 것입니다.

2. 상태(State)와 게임 루프: 살아 움직이는 화면의 심장

정적인 화면을 만드는 것을 넘어, 사용자와 상호작용하며 동적으로 변화하는 앱을 만들려면 '상태'라는 개념이 필수적입니다. Flutter에서 상태는 StatefulWidget과 그 짝인 State 객체를 통해 관리됩니다. 사용자가 버튼을 누르면 숫자가 1씩 증가하는 간단한 카운터 앱을 떠올려봅시다.


class _CounterPageState extends State<CounterPage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }
  // ... build method uses _counter to display the number
}

여기서 _counter 변수가 바로 '상태'입니다. 이 변수의 값이 앱의 현재 모습을 결정합니다. 중요한 것은 _incrementCounter 함수 안의 setState() 호출입니다. 개발자는 단지 "_counter 값을 1 증가시키고 싶어"라는 의도를 setState()에 전달할 뿐입니다. 그러면 Flutter 프레임워크가 알아서 "아, 상태가 바뀌었구나. 이 상태를 사용하는 위젯 부분을 다시 그려야겠다"고 판단하고 해당 위젯의 build() 메소드를 다시 호출하여 화면을 갱신합니다. 이것이 Flutter의 반응형(Reactive) 프로그래밍 모델입니다.

이제 이 과정을 게임 엔진의 '게임 루프(Game Loop)'와 비교해봅시다. 게임 루프는 게임이 실행되는 동안 무한히 반복되는 핵심적인 순환 과정입니다. 보통 다음과 같은 단계로 이루어집니다.

  1. 입력 처리 (Input): 플레이어의 키보드, 마우스, 터치 입력을 감지합니다.
  2. 게임 로직 업데이트 (Update): 입력과 시간에 따라 게임 내 변수(캐릭터의 위치, 체력, 점수 등)를 변경합니다.
  3. 렌더링 (Render): 변경된 변수(상태)를 바탕으로 현재 게임 씬을 화면에 그립니다.

Unity의 Update() 함수가 바로 이 '게임 로직 업데이트' 단계에 해당합니다. 개발자는 Update() 함수 안에 "매 프레임마다 캐릭터의 x좌표를 1씩 이동시켜라"와 같은 코드를 작성합니다. 여기서 캐릭터의 좌표가 바로 게임의 '상태'입니다.

Flutter의 setState()는 이 게임 루프를 이벤트 기반으로 압축한 것과 같습니다. 게임처럼 매 프레임(1/60초 또는 1/120초)마다 모든 것을 검사하고 업데이트하는 대신, Flutter는 '상태 변경'이라는 특정 이벤트가 발생했을 때만 업데이트와 렌더링 과정을 촉발시킵니다. setState()가 호출되면, Flutter는 다음 렌더링 프레임(Vsync 신호)에 맞춰 업데이트(build() 메소드 호출)와 렌더링을 수행합니다. 즉, '필요할 때만 돌아가는 효율적인 게임 루프'를 가지고 있는 셈입니다.

Unity 개발자는 '상태(변수)가 바뀌면, 다음 프레임에 화면이 그에 맞게 갱신된다'는 개념에 이미 익숙합니다. 캐릭터의 체력(health) 변수가 줄어들면 화면 상단의 체력 바(UI)가 자동으로 줄어드는 것을 당연하게 여깁니다. Flutter의 setState()와 위젯의 재빌드 과정은 이와 동일한 멘탈 모델을 공유합니다. _counter라는 상태가 바뀌면 Text 위젯이 그에 맞게 다시 그려지는 것은 당연한 결과입니다. 이처럼 Flutter는 앱 개발에 게임 개발의 핵심적인 '상태-주도 렌더링(State-driven rendering)' 패러다임을 그대로 가져왔습니다.

3. 3개의 트리: Flutter 렌더링 파이프라인의 비밀

지금까지의 비유는 Flutter 아키텍처의 표면적인 모습입니다. 더 깊이 들어가면 Flutter가 얼마나 정교하게 게임 엔진의 렌더링 원리를 차용했는지 알 수 있습니다. Flutter의 심장에는 위젯 트리(Widget Tree), 엘리먼트 트리(Element Tree), 렌더오브젝트 트리(RenderObject Tree)라는 3개의 서로 다른 트리가 유기적으로 작동하고 있습니다.

3.1. 위젯 트리 (Widget Tree): 불변의 설계도

개발자가 코드로 작성하는 것이 바로 위젯 트리입니다. 이것은 UI의 '설계도' 또는 '청사진'에 해당합니다. 앞서 설명했듯, 위젯은 불변(immutable)입니다. 즉, 한번 생성되면 그 속성을 바꿀 수 없습니다. setState()가 호출될 때 기존 위젯의 색상을 바꾸는 것이 아니라, 새로운 색상 값을 가진 새로운 위젯 인스턴스를 만들어 기존 것을 '교체'하는 것입니다. 이는 매 프레임마다 새로운 씬 정보를 생성하는 게임 엔진의 방식과 유사합니다.

게임 엔진 비유: Unity 에디터의 하이어라키(Hierarchy) 창에 배치된 오브젝트들의 구성 정보 그 자체입니다. 아직 'Play' 버튼을 누르기 전의 정적인 설계도와 같습니다.

3.2. 엘리먼트 트리 (Element Tree): 영리한 매니저

위젯이 단명하는 설계도라면, 엘리먼트는 이 설계도를 현실 세계에 연결하고 생명주기를 관리하는 '매니저' 또는 '중재자'입니다. 화면에 표시되는 모든 위젯은 각각에 해당하는 엘리먼트를 엘리먼트 트리에 가지고 있습니다. 이 엘리먼트 트리는 위젯 트리처럼 매번 새로 만들어지지 않고, 대부분 재사용됩니다.

setState()가 호출되어 새로운 위젯 트리가 생성되면, Flutter는 이 새로운 위젯 트리를 기존 엘리먼트 트리와 비교합니다. 이때 위젯의 타입과 키(Key)가 동일하다면, 엘리먼트는 "아, 설계도(위젯)의 세부 사항(속성)만 조금 바뀌었구나. 나는 그대로 있으면서 내 정보만 업데이트하면 되겠다"고 판단합니다. 그리고 새로운 위젯의 정보를 받아 자신의 참조를 업데이트합니다. 덕분에 StatefulWidgetState 객체는 위젯이 교체되어도 소멸되지 않고 엘리먼트에 의해 유지될 수 있는 것입니다.

이 '비교 후 업데이트' 과정(이를 'Reconciliation' 또는 '재조정'이라 부릅니다)이 Flutter 성능의 핵심입니다. 매번 모든 것을 파괴하고 새로 그리는 것이 아니라, 변경된 부분만 지능적으로 찾아내어 최소한의 작업으로 화면을 갱신하는 것입니다.

게임 엔진 비유: 게임이 실행(Runtime)될 때, 씬 그래프의 각 게임 오브젝트를 관리하는 엔진 내부의 관리자 객체입니다. 이 관리자는 각 오브젝트의 현재 상태(위치, 활성화 여부 등)를 계속 추적하며, 변경이 필요할 때만 렌더링 파이프라인에 업데이트를 요청합니다. 게임 엔진의 '더티 플래그(dirty flag)' 시스템과 매우 유사합니다.

3.3. 렌더오브젝트 트리 (RenderObject Tree): 실제 화가

엘리먼트가 관리자라면, 렌더오브젝트는 실제 '그리기'를 담당하는 '화가'입니다. 렌더오브젝트는 화면에 무언가를 그리는데 필요한 모든 구체적인 정보를 가지고 있습니다: 크기, 위치, 그리고 어떻게 그려야 하는지(페인팅 정보). 엘리먼트 트리의 각 엘리먼트는 대부분 자신과 연결된 렌더오브젝트를 가집니다 (레이아웃에만 관여하는 일부 위젯 제외).

Flutter의 렌더링 과정은 크게 두 단계로 나뉩니다.

  1. 레이아웃(Layout): 부모 렌더오브젝트가 자식 렌더오브젝트에게 "너는 이만큼의 공간을 쓸 수 있어" (제약 조건 전달)라고 알려주면, 자식은 "알겠어, 그럼 나는 이만한 크기가 될게" (크기 결정)라고 응답하며 자신의 크기를 결정하고 부모에게 알립니다. 이 과정이 트리 전체에 걸쳐 재귀적으로 일어납니다.
  2. 페인팅(Painting): 레이아웃이 끝나 각 렌더오브젝트의 크기와 위치가 확정되면, 각 렌더오브젝트는 자신의 위치에 자신을 그립니다.

이 과정은 게임 엔진이 3D 모델의 정점(vertex) 위치를 계산하고, 텍스처를 입혀 화면에 최종적으로 그려내는(rasterize) 과정과 개념적으로 동일합니다. 렌더오브젝트 트리는 GPU가 이해할 수 있는 저수준의 그리기 명령으로 변환되기 직전의 마지막 단계입니다.

게임 엔진 비유: 씬 그래프의 모든 최종 렌더링 데이터를 담고 있는 렌더 큐(Render Queue) 또는 커맨드 버퍼(Command Buffer)와 같습니다. GPU에 "이 좌표에, 이 크기로, 이 셰이더와 텍스처를 사용해서 삼각형들을 그려라"고 명령을 내리기 직전의 모든 준비가 끝난 상태입니다.

4. 임펠러(Impeller): 120fps를 향한 게임 엔진의 야망

Flutter가 왜 이렇게 복잡한 3개의 트리 구조를 가질까요? 바로 '성능', 특히 '버벅임(Jank) 없는 부드러운 애니메이션' 때문입니다. 그리고 이 집착의 정점에 새로운 렌더링 엔진, 임펠러(Impeller)가 있습니다.

전통적인 앱 프레임워크는 보통 운영체제(OS)가 제공하는 네이티브 UI 컴포넌트를 사용합니다. 이는 안정적이지만, OS의 제약에 얽매이고 플랫폼 간 일관성을 유지하기 어렵습니다. 반면 Flutter는 게임 엔진처럼 OS의 UI 컴포넌트를 전혀 사용하지 않습니다. 대신, 빈 화면(Canvas) 위에 모든 위젯을 직접 그립니다. 이는 마치 Unity가 iOS나 안드로이드의 버튼을 쓰는 대신, 자체 엔진으로 모든 UI와 3D 모델을 직접 그리는 것과 같습니다. 이 방식은 완전한 제어권과 최고의 성능 잠재력을 제공합니다.

기존 Flutter의 렌더링 엔진은 Skia였습니다. Skia는 구글이 개발한 강력한 2D 그래픽 라이브러리로, 크롬 브라우저와 안드로이드 OS에서도 사용됩니다. 하지만 Skia에는 한 가지 고질적인 문제가 있었습니다. 바로 '셰이더 컴파일레이션 버벅임(Shader Compilation Jank)'입니다. 새로운 형태의 애니메이션이나 그래픽 효과가 화면에 처음 나타나는 순간, GPU는 이 효과를 어떻게 그려야 할지 정의하는 프로그램인 '셰이더(Shader)'를 실시간으로 컴파일(번역)해야 했습니다. 이 컴파일 과정이 몇 밀리초(ms) 이상 걸리면, 한 프레임을 그리는 데 주어진 시간(60fps 기준 약 16.67ms)을 초과하게 되어 화면이 순간적으로 멈추는 '버벅임'이 발생했습니다.

이는 고사양 게임에서 새로운 지역에 진입하거나 새로운 스킬을 처음 사용할 때 프레임이 순간적으로 떨어지는 현상과 정확히 같습니다. 게임 개발자들은 이 문제를 해결하기 위해 '셰이더 예열(Shader pre-warming)'이나 '사전 컴파일(Ahead-of-Time compilation)' 같은 기법을 오랫동안 사용해왔습니다.

임펠러는 바로 이 게임 엔진의 해법을 Flutter에 그대로 가져온 것입니다. 임펠러의 핵심 철학은 '런타임에 셰이더를 컴파일하지 않는다'는 것입니다. 대신, 앱을 빌드하는 시점에 Flutter 엔진이 필요로 하는 모든 종류의 셰이더를 미리 컴파일해서 앱 패키지에 포함시킵니다. 런타임에는 이미 준비된 셰이더들을 조합하여 사용하기만 하면 되므로, 셰이더 컴파일로 인한 버벅임이 원천적으로 사라집니다.

또한, 임펠러는 Skia보다 훨씬 더 저수준의 그래픽 API인 Metal(Apple)과 Vulkan(Android 등)을 직접 활용하도록 설계되었습니다. 이는 엔진이 GPU 하드웨어에 더 가깝게, 더 직접적으로 명령을 내릴 수 있다는 의미입니다. 중간에 여러 추상화 계층을 거치지 않으므로 오버헤드가 적고 성능을 극한까지 끌어올릴 수 있습니다. 현대의 AAA급 게임 엔진들이 DirectX 12, Metal, Vulkan을 사용하는 이유와 정확히 일치합니다.

결국 Flutter가 임펠러를 통해 추구하는 목표는 명확합니다. 앱의 UI를 마치 고사양 게임처럼, 어떤 상황에서도 프레임 드랍 없이 부드럽게 렌더링하겠다는 것입니다. 사용자의 스크롤, 화면 전환, 복잡한 애니메이션이 120Hz 디스플레이에서 물 흐르듯 120fps로 표현되는 경험을 제공하는 것. 이것은 더 이상 단순한 '앱 개발'의 영역이 아니라, '실시간 인터랙티브 그래픽스'의 영역이며, 이는 게임 엔진의 본질과 같습니다.

결론: 앱 개발과 게임 개발의 경계에서

Flutter의 아키텍처를 게임 엔진의 렌즈로 들여다보면, 두 세계가 얼마나 많은 철학과 기술을 공유하는지 명확히 보입니다.

  • 위젯 트리는 게임의 씬 그래프처럼 화면의 구조를 정의합니다.
  • 상태(State)와 setState()게임 루프 안에서 변수를 업데이트하여 동적인 변화를 만들어내는 원리를 압축적으로 구현한 것입니다.
  • 위젯-엘리먼트-렌더오브젝트로 이어지는 렌더링 파이프라인은 설계, 관리, 실행을 분리하여 효율성을 극대화하는 게임 엔진의 정교한 렌더링 아키텍처를 빼닮았습니다.
  • 최신 렌더러 임펠러는 셰이더 사전 컴파일과 저수준 그래픽 API 직접 제어라는, 최신 게임 엔진의 성능 최적화 기법을 그대로 채택했습니다.

Unity 개발자가 Flutter를 빨리 배우는 이유는 단순히 같은 객체지향 언어(C#과 Dart는 문법적으로 유사합니다)를 사용하기 때문만이 아닙니다. 그들은 이미 '장면을 객체의 계층 구조로 구성하고, 상태 변화에 따라 매 프레임 화면을 다시 그린다'는 핵심적인 멘탈 모델에 익숙하기 때문입니다. Flutter는 그들에게 새로운 앱 프레임워크가 아니라, UI 렌더링에 특화된 또 하나의 친숙한 '게임 엔진'처럼 느껴질 수 있습니다.

Flutter의 여정은 우리에게 중요한 시사점을 던져줍니다. 앱과 게임의 경계는 점점 더 흐려지고 있으며, 사용자들은 이제 앱에서도 게임과 같은 부드럽고 즉각적인 인터랙션을 기대합니다. Flutter는 이러한 시대적 요구에 '게임 엔진의 문법'으로 가장 확실하게 응답하고 있는 프레임워크일 것입니다.


0 개의 댓글:

Post a Comment