웹 메타버스 클라이언트 개발 회고 #Project 2404

프로젝트 개요

웹 브라우저에서 실행되는 3D 메타버스 클라이언트를 Unity WebGL로 개발했다.

클라이언트에서는 플레이어 이동과 다른 사용자 동기화, 여러 공간으로 구성된 월드, NPC와 콘텐츠, 웹 기반 UI 등을 처리해야 했다. PC뿐만 아니라 모바일 브라우저에서도 동작해야 했기 때문에 일반적인 Standalone Unity 프로젝트와 같은 방식으로만 개발하기는 어려웠다.

특히 WebGL은 WASM 환경에서 실행되고 메인 스레드 제약이 있기 때문에 씬 로딩이나 대량 오브젝트 생성처럼 한 번에 많은 작업이 몰리는 부분을 계속 확인해야 했다.

브라우저와 Unity 사이의 기능 차이도 있었다. IFrame이나 HTML Input처럼 Unity 내부에서 처리하기보다는 브라우저 기능을 사용하는 것이 적합한 부분이 있었고, 이런 기능은 JavaScript Bridge를 통해 연결했다.

개발하면서 주로 확인했던 것은 프레임 시간, 메모리, 네트워크 패킷, 씬 로딩 시간이었다. 어느 하나만 줄인다고 해결되는 문제가 아니었다.


WebGL 클라이언트 구조

Unity WebGL Canvas를 기본으로 두고 브라우저에서 필요한 기능은 DOM 영역과 JavaScript로 분리했다.

Unity에서는 월드와 캐릭터, 애니메이션, 게임 로직, 네트워크 상태 처리 등을 담당하고 브라우저에서는 HTML 요소와 IFrame, 키보드 입력, viewport, 포커스 같은 기능을 담당하는 식이다.

이 구조를 잡으면서 가장 신경 쓴 부분은 Unity 코드가 브라우저 구현에 직접 의존하지 않도록 하는 것이었다.

C#에서 브라우저의 DOM을 직접 다루기 시작하면 WebGL 전용 코드가 여러 시스템에 퍼지게 된다. 그래서 브라우저 기능은 별도의 Bridge를 두고 필요한 기능만 호출하도록 구성했다.

WebGL 빌드에서만 실제 JavaScript 함수를 호출하고 Unity Editor에서는 Mock 처리를 할 수 있도록 구성했다.

덕분에 브라우저에서만 확인할 수 있는 기능과 게임 로직을 어느 정도 분리해서 개발할 수 있었다.


플레이어 이동 동기화

멀티플레이어 환경에서는 플레이어의 이동 상태를 다른 클라이언트에 전달해야 했다.

여기서는 이동 데이터를 매 프레임 전송하는 방식이 아니라 일정 주기의 전송과 상태 변화 여부를 같이 사용하는 방식으로 구성했다.

프로젝트에서는 약 0.2초를 기준으로 이동 데이터를 전송하도록 했다.

플레이어가 계속 이동하고 있는 경우에는 cooldown 주기에 맞춰 데이터를 보내고, 이동이 끝난 경우에는 마지막 상태를 전달한 뒤 불필요한 패킷을 계속 발생시키지 않도록 했다.

이렇게 하면 이동하지 않는 상태에서 같은 위치 데이터를 반복해서 보낼 필요가 없다.

전송 데이터도 필요한 값만 담는 DTO 형태로 만들었다.

플레이어 객체 전체의 상태를 전달하는 것이 아니라 위치, 회전, 이동 상태 등 네트워크 동기화에 필요한 값만 전달했다.

WebSocket을 통해 전달되는 데이터는 JSON 기반으로 처리했기 때문에 DTO를 작게 유지하는 것이 직렬화와 네트워크 양쪽에서 유리했다.


수신된 이동 데이터 처리

다른 사용자의 이동 데이터를 받은 뒤에는 해당 위치를 바로 Transform에 적용하지 않았다.

네트워크 패킷은 항상 일정한 간격으로 도착한다고 보장하기 어렵기 때문이다.

그래서 수신한 이동 데이터를 Queue에 넣고 순서대로 처리했다.

현재 이동 상태와 다음 이동 데이터를 분리하고, 두 지점 사이를 Lerp와 Slerp를 사용해서 보간했다.

구조는 다음과 같이 구성했다.

Packet Receive -> DTO 변환 -> Movement Queue 삽입 -> 현재 목표 위치까지 보간 -> 다음 데이터 처리

이 방식에서는 네트워크 수신과 실제 화면에서 보이는 이동을 분리할 수 있다.

패킷이 들어올 때마다 Transform을 즉시 변경하는 구조였다면 패킷 도착 간격에 따라 캐릭터가 순간적으로 튀는 현상이 발생할 수 있다.

Queue를 두고 보간 시간을 별도로 관리하면서 이런 현상을 줄였다.

이동 상태에 따라 애니메이션 상태도 같이 변경할 수 있도록 네트워크 이동 데이터와 클라이언트의 표현을 분리했다.


WebSocket과 프레임 부하

개발하면서 의외로 신경 써야 했던 부분은 네트워크와 프레임 처리의 관계였다.

WebSocket 자체가 느린 문제가 아니라 클라이언트에서 특정 작업이 한꺼번에 실행될 때 문제가 발생했다.

특히 씬을 전환하면서 많은 오브젝트를 생성하거나 초기화하는 과정에서 메인 스레드에 작업이 몰렸다.

Profiler로 확인해보면 특정 프레임의 CPU 사용량이 크게 증가했고, 이때 네트워크 처리에도 영향을 줄 수 있었다.

그래서 대량의 오브젝트를 한 프레임에 전부 생성하지 않고 작업을 여러 프레임으로 나눴다.

예를 들어 많은 오브젝트를 생성해야 하는 경우 일정 개수만 처리하고 다음 프레임으로 넘기는 방식이다.

전체 작업량 자체가 줄어드는 것은 아니지만 한 프레임에 발생하는 Spike를 줄일 수 있었다.

실제로 WebGL에서는 이런 차이가 컸다.

추가로 씬 로딩이나 초기화 상태를 네트워크 상태와 분리해서 관리했고, 무거운 작업이 발생하는 구간에서는 연결 timeout도 기존 10초에서 25초 수준으로 조정했다.

다만 timeout을 늘리는 것만으로 해결하려고 하지는 않았다. 실제 원인은 메인 스레드에서 발생하는 긴 작업이었기 때문에 frame slicing을 같이 적용했다.


JavaScript Bridge

WebGL에서는 브라우저 기능을 Unity API만으로 처리하기 어려운 부분이 있었다.

대표적으로 IFrame, HTML Input, 모바일 viewport, 브라우저 포커스 등이었다.

C#에서는 DllImport("__Internal")을 사용해서 JavaScript 함수를 호출하고 .jslib 쪽에서 브라우저 기능을 구현했다.

JavaScript에서는 mergeInto(LibraryManager.library, ...)를 통해 Unity에서 호출할 함수를 등록했다.

브릿지의 역할은 최대한 단순하게 유지했다.

게임 코드에서는 브라우저의 DOM 구조나 실제 HTML 구현을 알지 않고 필요한 동작만 요청한다.

예를 들어 “특정 영역에 웹 콘텐츠를 표시한다”는 요청을 Unity에서 전달하고 실제 IFrame 생성과 위치 조정은 JavaScript에서 처리하는 식이다.

이렇게 분리해두면 브라우저 화면 크기가 바뀌거나 IFrame 처리 방식이 변경돼도 게임 코드까지 수정할 필요가 없었다.


IFrame 기반 웹 콘텐츠

외부 웹 콘텐츠를 Unity 내부에서 직접 렌더링하지 않고 브라우저의 IFrame을 사용하는 구조도 적용했다.

동영상이나 외부 웹 페이지처럼 브라우저가 이미 처리할 수 있는 콘텐츠를 Unity 텍스처로 다시 가져오는 것보다 DOM에서 처리하는 편이 적합했다.

문제는 Unity Canvas와 IFrame의 좌표 체계가 서로 다르다는 점이었다.

Unity에서는 정규화된 좌표를 사용하고 JavaScript에서는 실제 브라우저 픽셀 좌표를 사용하기 때문에 Canvas의 실제 위치와 크기를 기준으로 변환해야 했다.

Canvas의 getBoundingClientRect()를 기준으로 위치와 크기를 계산해서 IFrame에 적용했다.

브라우저 창 크기가 변경되면 다시 계산하도록 구성했다.

또 하나 문제가 됐던 부분은 포커스였다.

IFrame에 포커스가 넘어간 다음 Unity Canvas에서 키보드 입력을 받아야 하는 상황에서 Canvas가 입력을 받지 못하는 경우가 있었다.

이 부분은 JavaScript에서 Canvas와 window의 focus 상태를 다시 잡는 방식으로 처리했다.


한글 IME 처리

한글 입력은 별도의 처리가 필요했다.

영문 키 입력과 달리 한글은 조합 과정이 있기 때문에 일반적인 키 이벤트만 받아서 처리하면 조합 중인 문자열을 제대로 처리하기 어려웠다.

그래서 브라우저의 HTML Input을 사용했다.

화면에는 보이지 않는 Input을 하나 두고 실제 키보드 입력을 해당 요소가 받도록 했다.

CompositionEvent를 이용해서 한글 조합 상태를 확인하고 최종 문자열을 Unity로 전달했다.

흐름은 다음과 같다.

Keyboard -> HTML Input -> CompositionEvent -> JavaScript -> C# Bridge -> TextMeshPro

이 구조를 사용하면서 Unity의 일반적인 키 입력 처리와 브라우저의 IME 처리를 분리했다.

모바일에서는 HTML Input이 시스템 가상 키보드와 연결된다는 점도 이 구조를 사용하는 이유 중 하나였다.


Addressables 기반 월드 구성

월드가 여러 공간으로 나뉘면서 모든 리소스를 초기 진입 시점에 가져오는 방식은 부담이 커졌다.

웹에서는 초기 다운로드 용량이 그대로 사용자의 첫 진입 시간에 영향을 준다.

그래서 기본 진입에 필요한 리소스와 실제 월드 리소스를 분리했다.

초기에는 WebGL 실행에 필요한 최소 리소스와 Entry Scene을 로드하고 다른 공간은 Addressables를 통해 필요한 시점에 가져왔다.

월드는 야외 공간, 전시 공간, 컨퍼런스 공간, 이벤트 공간 등으로 나눠 각각 독립적으로 관리했다.

사용자가 특정 공간으로 이동하면 해당 Addressable Scene을 로드하고 이전 공간의 리소스를 정리하는 방식이다.

이 구조를 적용하면서 초기 로딩 부담을 줄일 수 있었고, 월드가 커지더라도 전체 리소스를 한 번에 유지하지 않아도 됐다.


씬 전환과 메모리 관리

Addressables를 적용했다고 해서 씬 전환 때 메모리가 바로 정리되는 것은 아니었다.

실제로 여러 번 공간을 이동하는 테스트를 하면서 이전 공간에서 사용했던 텍스처나 오디오 등이 남아 있는 상황을 확인했다.

그래서 Addressable Handle을 직접 관리하고 씬의 로드와 언로드 시점을 명확하게 구분했다.

사용하지 않는 리소스를 정리하기 위해 Resources.UnloadUnusedAssets()도 사용했고 필요한 상황에서는 GC.Collect()까지 적용했다.

다만 GC를 무조건 호출하는 방식으로 사용하지는 않았다.

GC 자체도 메인 스레드 작업이기 때문에 잘못된 위치에서 실행하면 메모리는 줄어들지만 프레임이 튈 수 있다.

그래서 씬 전환 과정에서 언제 정리 작업을 수행할지 같이 조정했다.


모바일 Safari 메모리 문제

PC에서는 여러 번 씬을 이동해도 크게 문제가 나타나지 않았지만 모바일 Safari에서 반복 테스트를 하면서 메모리 문제가 명확하게 나타났다.

특히 씬 이동을 반복할수록 브라우저의 메모리 사용량이 증가했고 일정 시점 이후 페이지가 종료되는 현상이 있었다.

이때 단순히 현재 씬만 unload하는 것으로는 부족했다.

텍스처와 오디오처럼 메모리를 크게 사용하는 리소스가 실제로 해제되고 있는지 확인했고 Addressables Handle과 리소스 참조를 같이 확인했다.

텍스처 압축도 모바일 기준으로 다시 조정했다.

ASTC 4×4와 6×6 등을 사용해서 상황에 따라 텍스처 메모리를 줄였고, 필요 이상으로 큰 텍스처를 사용하는 리소스도 다시 정리했다.

모바일에서 중요한 것은 한 번의 씬 로딩이 정상적으로 끝나는 것만이 아니었다.

월드 A -> 월드 B -> 월드 C -> 월드 A처럼 반복해서 이동했을 때 메모리가 계속 누적되지 않는지를 확인해야 했다.

이 부분을 테스트하면서 씬 관리가 단순한 로딩 기능이 아니라 클라이언트 안정성과 직접 연결된다는 것을 확인했다.


WebGL 렌더링 최적화

렌더링 쪽에서는 Draw Call과 텍스처 사용량부터 확인했다.

특히 오브젝트가 많이 배치된 공간에서는 작은 오브젝트 하나하나가 별도의 렌더링 비용을 만들 수 있었다.

정적인 오브젝트는 Mesh Baker와 Static Batching을 사용해서 최대한 묶었다.

같은 텍스처를 사용하는 리소스는 Texture Atlas를 적용해서 Material 변경도 줄였다.

프로젝트에서는 WebGL 환경에서 60 FPS를 유지하는 것을 목표로 Draw Call을 대략 200~400 수준에서 관리했고, 공간에 따라 최적화를 진행하면서 Draw Call을 80% 이상 줄인 경우도 있었다.

모든 공간에서 동일한 수치를 맞추려고 하기보다는 실제로 부하가 큰 공간을 먼저 찾아서 수정했다.


LOD와 그림자 설정

LOD도 적극적으로 사용했다.

플레이어와 가까운 오브젝트는 높은 품질의 Mesh를 사용하지만 거리가 멀어지면 단순한 Mesh로 교체했다.

웹에서는 화면에 작게 보이는 오브젝트에 높은 폴리곤 비용을 계속 사용하는 것이 효율적이지 않았다.

그림자는 더 민감했다.

Shadow Distance를 낮추고 Cascade 수도 제한했다.

프로젝트 환경에서는 필요한 영역을 기준으로 Shadow Distance를 약 25~35m 정도로 조정하고 Cascade도 제한해서 사용했다.

품질을 최대한 유지하는 방향보다는 실제 플레이 화면에서 차이가 적은 부분부터 비용을 줄였다.


WebGL Quality Tier

PC와 모바일에 동일한 그래픽 설정을 적용하지 않았다.

URP Asset을 기반으로 Quality Tier를 분리하고 텍스처 Mipmap Limit, 그림자 거리, Cascade 등의 설정을 환경에 따라 변경했다.

모바일에서는 높은 품질의 그림자를 유지하는 것보다 메모리와 GPU 부하를 줄이는 것이 우선이었다.

텍스처 역시 모든 기기에서 동일한 해상도를 사용할 필요가 없었다.

이런 설정을 하나의 Quality 설정으로 묶어두면서 기기 성능에 따라 렌더링 비용을 조절할 수 있도록 했다.


모바일 조작

모바일에서는 화면을 두 영역으로 나눠 조작했다.

왼쪽에는 Virtual Joystick을 두고 이동을 처리했고 오른쪽 영역에서는 Touch Look을 통해 카메라를 조작했다.

여기서 단순히 화면의 왼쪽과 오른쪽만 나누면 UI를 터치할 때 카메라가 같이 움직이는 문제가 생겼다.

그래서 EventSystem.IsPointerOverGameObject()를 사용해서 UI를 누르고 있는 터치가 게임 입력으로 전달되지 않도록 했다.

멀티터치도 고려했다.

조이스틱을 잡고 있는 손가락과 카메라를 조작하는 손가락이 서로 다른 입력으로 처리되어야 했기 때문에 터치 ID를 기준으로 입력을 구분했다.

PC에서 마우스 하나만 생각하고 만든 입력 구조를 그대로 가져오지 않고 모바일 입력을 별도로 처리했다.


모바일 브라우저 viewport

모바일 브라우저에서는 100vh를 기준으로 화면을 잡는 방식에도 문제가 있었다.

주소창이 나타나거나 사라지면서 실제 viewport 높이가 달라질 수 있기 때문이다.

그래서 JavaScript에서 window.resize와 실제 viewport 정보를 확인하고 Unity Canvas의 크기를 다시 계산하도록 했다.

특히 window.innerHeightvisualViewport를 확인해서 실제 사용 가능한 영역에 맞게 UI와 Canvas 크기를 조정했다.

이 부분 역시 Unity 내부의 해상도 처리만으로 해결하려고 하지 않고 브라우저에서 실제 값을 받아오는 방식으로 처리했다.


성능을 확인하는 기준

초기에는 FPS를 가장 먼저 봤다.

하지만 개발을 진행하면서 FPS 하나만 보고 판단하는 것은 부족했다.

그래서 이후에는 CPU Frame Time, GPU Frame Time, Draw Call, Texture Memory, WASM Memory, Network Packet Rate, Scene Load Time을 같이 확인했다.

씬 전환이 느리다면 Scene Load Time만 보는 것이 아니라 해당 순간 CPU Frame Time이 얼마나 증가하는지 확인했다.

메모리는 현재 사용량만 확인하지 않고 동일한 씬을 반복해서 이동했을 때 계속 증가하는지를 확인했다.

네트워크 역시 패킷 수만 보는 것이 아니라 실제 이동 상태에서 어느 정도 빈도로 데이터가 발생하는지 확인했다.

이렇게 측정 항목을 나눠놓으니 최적화할 때도 감으로 수정하는 일이 줄었다.


개발하면서 정리한 부분

웹 메타버스 클라이언트는 Unity 기능만 잘 사용한다고 끝나는 구조가 아니었다.

WebGL에서는 WASM과 브라우저 환경을 같이 봐야 했고, 네트워크에서는 전송 주기와 수신 데이터 처리 방식을 따로 관리해야 했다.

씬이 많아지면서 Addressables와 리소스 수명 관리가 중요해졌고, 모바일에서는 메모리와 viewport까지 별도로 대응해야 했다.

브라우저에서 제공하는 기능을 굳이 Unity 내부에서 다시 구현하지 않고 JavaScript Bridge를 통해 넘기는 것도 중요한 판단이었다.

개발하면서 특히 많이 수정했던 부분은 “한 번 정상적으로 동작하는 구조”를 “같은 동작을 반복해도 문제가 생기지 않는 구조”로 바꾸는 일이었다.

씬을 한 번 로드하는 것보다 씬을 계속 이동했을 때 메모리가 유지되는지가 중요했고, 캐릭터가 한 번 움직이는 것보다 여러 사용자가 동시에 움직일 때 네트워크 데이터와 보간이 안정적으로 처리되는지가 중요했다.

WebGL 클라이언트에서는 평균적인 성능보다 특정 순간에 발생하는 Spike를 줄이는 것이 중요했다.

대량 오브젝트 생성은 프레임 단위로 나누고, 네트워크 이동 데이터는 Queue를 거쳐 보간하고, 리소스는 Addressables Handle을 기준으로 수명 주기를 관리했다.

각각 다른 문제처럼 보이지만 실제로는 메인 스레드, 메모리, 네트워크, 렌더링이 서로 연결되어 있었다.


마무리

이번 프로젝트를 진행하면서 WebGL을 단순히 Unity의 빌드 플랫폼 중 하나로 생각하면 안 된다는 점을 많이 확인했다.

같은 Unity 코드라도 브라우저에서 실행하면 메모리 관리 방식이나 입력 처리, 외부 콘텐츠 연동 방식에서 다른 문제가 생겼다.

그래서 클라이언트 구조도 Unity 내부만 보는 방식에서 벗어나 브라우저와 Unity의 역할을 나눠서 설계했다.

실시간 이동은 WebSocket과 DTO를 사용하고 0.2초 단위의 전송 주기와 상태 변화를 기준으로 데이터를 관리했다. 수신한 데이터는 Queue에 넣고 Lerp와 Slerp로 보간해서 네트워크 상태와 실제 화면상의 움직임을 분리했다.

월드는 Addressables로 나누고 씬 Handle을 관리하면서 필요하지 않은 리소스를 정리했다. 모바일에서는 ASTC와 LOD, Shadow 설정 등을 조정했고 반복적인 씬 이동에서 메모리가 누적되지 않는지도 계속 확인했다.

브라우저와 관련된 기능은 JavaScript Bridge로 분리했다. IFrame은 DOM에서 처리했고 한글 입력은 HTML Input과 CompositionEvent를 이용했다. 모바일 viewport와 Canvas focus 역시 브라우저 쪽에서 직접 제어했다.

개발하면서 가장 많이 신경 쓴 부분은 특정 기능 하나의 완성도가 아니었다.

각 기능이 다른 시스템에 어떤 부하를 만드는지 확인하는 것에 가까웠다.

씬 로딩 때문에 CPU가 순간적으로 올라가면 네트워크 처리에도 영향을 줄 수 있고, 리소스 해제가 제대로 되지 않으면 모바일에서는 브라우저가 종료될 수도 있다.

그래서 이후에는 기능을 추가할 때도 단순히 동작 여부만 확인하지 않고 Frame Time, Memory, Network, Rendering을 같이 확인하는 방식으로 개발했다.

웹 기반 3D 클라이언트에서는 이런 식으로 시스템 사이의 경계를 같이 보는 것이 중요하다고 판단했다.