실시간 3D 가상 공간 클라이언트 개발 회고록
이번 프로젝트에서는 Unity로 실시간 3D 가상 공간 클라이언트를 개발했다.
여러 사용자가 하나의 공간에 들어와 서로의 아바타를 보고 이동하거나 채팅, 이모트 같은 상호작용을 할 수 있는 형태였다. 그래서 일반적인 싱글 플레이 클라이언트처럼 로컬 캐릭터만 잘 움직이면 되는 프로젝트는 아니었다.
개발하면서 신경 쓴 부분은 크게 네 가지였다.
사용자 상태를 어떻게 관리할지, 네트워크로 받은 데이터를 어떻게 게임 화면에 반영할지, 많은 UI와 오브젝트를 어떻게 관리할지, 그리고 리소스가 늘어났을 때 성능을 어떻게 유지할지였다.
처음부터 모든 구조를 완성해놓고 개발한 것은 아니다. 기능을 하나씩 붙이다 보니 기존 방식으로는 관리하기 어려운 부분이 생겼고, 그때마다 구조를 바꿔갔다.
클라이언트 구조

초기에는 기능 단위로 필요한 코드를 붙여가면서 개발했다.
그런데 원격 사용자가 들어오고 나가는 기능이 추가되고, 여기에 아바타, 채팅, 이름표, 근접 UI 등이 붙으면서 서로 직접 참조하는 코드가 점점 많아졌다.
예를 들어 네트워크에서 사용자가 들어왔다는 이벤트를 받은 뒤 네트워크 코드에서 직접 아바타를 만들고, 다시 UI까지 찾아가서 갱신하는 식으로 만들면 처음에는 편하지만 나중에 수정하기가 힘들다.
그래서 프로젝트가 진행되면서 데이터와 화면을 분리했다.
Network / Infrastructure -> Model / State -> Controller -> Presentation
네트워크에서는 데이터를 받아 Model의 상태를 변경하고, Controller가 그 상태를 보고 실제 GameObject를 변경하는 구조로 가져갔다.
엄청 복잡한 아키텍처를 만든 것은 아니고, 서로 다른 시스템이 직접 물고 늘어지는 상황을 피하는 것이 목적이었다.
사용자 상태 관리
이 프로젝트에서 꽤 많이 손댄 부분이다.
방 안에 사용자가 추가되거나 나갈 때 단순히 GameObject 하나만 생성하고 삭제하면 끝나는 게 아니었다.
사용자 데이터 자체를 가지고 있어야 했고, 그 데이터를 아바타나 UI에서도 같이 사용해야 했다.
처음에는 이벤트를 직접 연결하는 방식으로 처리했는데 기능이 늘어날수록 어디에서 이벤트를 받고 있는지 찾는 일이 번거로워졌다.
특히 씬이 바뀌거나 객체가 삭제될 때 이벤트 해제를 놓치면 이미 없어져야 할 객체가 Observer 목록에 남아있는 문제도 생길 수 있었다.
그래서 ModelBase와 DictionaryModelBase<K, T>를 만들어 데이터 변경을 Observer에게 전달하도록 했다.
public class DictionaryModelBase<K, T> : Dictionary<K, T>
where T : ModelBase
{
private List<IDictionaryModelObserver<K, T>> _observerList;
public new void Add(K key, T data)
{
base.Add(key, data);
foreach (var observer in _observerList)
observer.OnDictionaryAdded(key, data);
}
public void Remove(K key)
{
foreach (var observer in _observerList)
observer.OnDictionaryRemoved(key);
base[key].InformDataRemoved();
base.Remove(key);
}
}
이제 사용자가 추가되면 해당 데이터를 보고 있는 쪽에서 알아서 처리할 수 있었다.
이 방식이 마음에 들었던 이유는 새로운 UI를 추가할 때 기존 네트워크 코드를 다시 건드릴 필요가 적었다는 점이다.
Observer 수명 관리
Observer 구조를 만들고 나니 또 하나 문제가 생겼다.
Observer를 등록하는 것 자체는 쉬운데, 언제 해제할지가 애매했다.
Unity에서는 씬이 바뀌면 GameObject가 사라질 수 있는데 Model이 해당 객체를 계속 가지고 있으면 문제가 된다.
그래서 MonoObserverBehaviour 쪽에서 OnDestroy() 때 Observer를 제거하도록 했고, 씬 전환에서도 등록된 Observer를 정리할 수 있도록 처리했다.
이 부분은 처음부터 거창하게 설계한 것은 아니다.
실제로 씬을 이동하면서 이전 화면의 객체가 계속 이벤트를 받는 상황을 겪고 나서 수명 관리를 구조적으로 처리하게 됐다.
실시간 네트워크 처리
실시간 공간이다 보니 모든 데이터를 같은 방식으로 보내면 안 됐다.
예를 들어 방 입장이나 퇴장, 채팅 같은 데이터는 한 번 빠지면 문제가 된다.
반면 아바타 위치는 조금 다르다.
현재 위치를 계속 보내고 있는데 예전 위치 하나가 늦게 들어왔다고 해서 그 위치를 다시 적용하면 캐릭터가 순간적으로 뒤로 움직일 수 있다.
그래서 데이터를 두 종류로 나눴다.
Reliable -> 입장 / 퇴장 / 채팅 / 이모트 / 프로필
Volatile -> Position / Rotation / Animator State
broadcast는 반드시 전달되어야 하는 이벤트에 사용했고, broadcast-volatile은 계속 갱신되는 아바타 상태에 사용했다.
public void Broadcast(BroadcastReqDTO data)
{
if (SocketManager != null)
SocketManager.Socket.Emit(
"broadcast",
FastJson.Serialize(data));
}
public void BroadcastVolatile(BroadcastReqDTO data)
{
if (SocketManager != null)
SocketManager.Socket.Emit(
"broadcast-volatile",
FastJson.Serialize(data));
}
이렇게 나누고 나니 위치 데이터 때문에 다른 이벤트가 같이 밀리는 상황을 피하기가 쉬워졌다.
물론 이것만으로 네트워크 문제가 모두 해결되는 것은 아니었다. Ping이 높아지거나 패킷이 많이 빠지는 상황에서는 결국 캐릭터가 순간적으로 튀는 문제가 남았다.
이 부분은 나중에 개선해야 할 영역으로 남겼다.
Animator 동기화
아바타를 동기화할 때 위치와 회전만 보내서는 부족했다.
다른 사용자가 걷고 있는데 내 화면에서는 그냥 미끄러지듯 움직이면 어색하다. 그래서 Animator Parameter도 같이 동기화했다.
처음에는 필요한 Parameter를 코드에 직접 지정할 수도 있었지만, 캐릭터가 바뀔 때마다 코드를 수정해야 하는 문제가 있었다.
그래서 Animator에 등록되어 있는 Parameter를 런타임에서 읽도록 만들었다.
foreach (var param in _avatarAnimator.parameters)
{
switch (param.type)
{
case AnimatorControllerParameterType.Int:
// Int Parameter 처리
break;
case AnimatorControllerParameterType.Float:
// Float Parameter 처리
break;
case AnimatorControllerParameterType.Bool:
// Bool Parameter 처리
break;
}
}
이렇게 해두면 Animator Controller에 Parameter가 추가되어도 동기화 코드를 크게 수정할 필요가 없었다.
대신 이름까지 같이 보내야 하기 때문에 패킷 크기가 커지는 문제가 있었다.
당시에는 개발 편의성을 우선했지만, 접속자가 더 많아진다면 이 부분은 바꿔야 한다고 생각했다.
소켓 연결과 재접속
소켓 연결이 끊어졌을 때도 생각보다 처리할 게 많았다.
사용자가 직접 방을 나간 것인지, 아니면 네트워크가 끊긴 것인지 구분해야 했다.
둘을 똑같이 처리하면 사용자가 정상적으로 나갔는데도 재접속 팝업이 뜨는 문제가 생긴다.
그래서 _selfDisconnect라는 상태를 두었다.
직접 연결을 끊은 경우에는 재접속 처리를 하지 않고, 예상하지 못한 연결 종료일 때만 재접속 및 타이틀 복귀 처리를 하도록 했다.
이런 부분은 실제로 네트워크 기능을 붙여보면서 필요하다고 느낀 부분이었다.
캐릭터 이동
캐릭터 이동은 카메라 방향을 기준으로 입력을 변환했다.
3인칭 카메라에서 플레이어가 W를 눌렀을 때 월드의 +Z 방향으로 움직이게 만들면 카메라를 돌렸을 때 조작감이 이상해진다.
그래서 현재 카메라의 Y축 회전을 구한 다음 입력 방향을 회전시켰다.
Vector3 cameraDirection =
CameraMoveHandler.Instance.GetDirection();
_direction =
ProxyInputSystem.Instance.GetDirectionAxis();
float targetAngle =
Mathf.Atan2(cameraDirection.x, cameraDirection.z)
* Mathf.Rad2Deg;
_direction =
Quaternion.AngleAxis(
targetAngle,
Vector3.up) * _direction;
결국 화면에서 보이는 방향을 기준으로 캐릭터가 움직이게 만들었다.
모바일에서는 조이스틱 입력을 사용했기 때문에 키보드 입력과 크게 다르지 않게 같은 방향 변환 로직을 사용할 수 있었다.
Ground 체크와 충돌
캐릭터 Ground 체크는 처음에 단일 Raycast로 처리했다.
그런데 계단이나 경사면을 이동할 때 Grounded가 순간적으로 풀리는 경우가 있었다.
Ray가 정확하게 바닥을 맞추지 못하는 프레임이 생겼기 때문이다.
그래서 캐릭터 중앙뿐만 아니라 앞과 뒤에도 Ray를 하나씩 추가했다.
float dist = _isGrounded ? 0.2f : 0.1f;
Vector3 root =
_transform.position + new Vector3(0f, 0.05f, 0f);
_isGrounded =
Physics.Raycast(root, Vector3.down, dist) ||
Physics.Raycast(
root + _direction * 0.1f,
Vector3.down,
dist) ||
Physics.Raycast(
root + _direction * -0.07f,
Vector3.down,
dist);
정교한 지형 분석을 한 것은 아니지만 실제 플레이에서 발생하던 Ground 체크 문제를 줄이는 데는 충분했다.
벽 충돌도 비슷하게 캐릭터 앞쪽을 미리 검사했다.
특히 Rigidbody 관성 때문에 벽에 붙은 상태에서 캐릭터가 조금씩 파고드는 상황이 있어서 이동 방향 앞쪽에서 장애물을 먼저 확인하도록 했다.
Orbit Camera
카메라는 두 개의 Pivot을 사용하는 방식으로 만들었다.
LookAtPivot -> FollowPivot -> Camera
수평과 수직 회전은 LookAtPivot에서 처리하고 카메라와의 거리는 FollowPivot에서 관리했다.
이렇게 나누니 회전과 거리 조절을 따로 처리하기가 편했다.
실내처럼 공간이 좁은 곳에서는 카메라가 벽을 뚫고 나가는 문제도 있었다.
그래서 캐릭터와 카메라 사이에 Raycast를 쏘고 중간에 벽이 있으면 카메라 거리를 충돌 지점까지 줄였다.
복귀할 때는 바로 원래 거리로 이동시키지 않고 보간했다.
이 부분은 실제 플레이에서 체감이 컸다. 카메라가 벽에 걸렸다가 갑자기 튀는 느낌이 줄어들었다.
3D 공간의 UI 처리
말풍선이나 상호작용 UI도 처음에는 World Space Canvas를 사용했다.
그런데 사용자 수가 많아지면서 Canvas가 계속 늘어났고 관리하기도 점점 번거로워졌다.
그래서 부유 UI를 하나의 Screen Space Overlay Canvas에서 관리하는 방식으로 변경했다.
3D 위치를 화면 좌표로 바꾸고 다시 Canvas 로컬 좌표로 변환했다.
World Position
-> WorldToScreenPoint
-> Screen Position
-> ScreenPointToLocalPointInRectangle
-> UI Local Position
이 방식으로 캐릭터 머리 위에 표시되는 말풍선이나 근접 UI를 하나의 Canvas에서 관리할 수 있었다.
카메라 뒤쪽 UI 문제
여기서 별도의 문제가 하나 생겼다.
카메라 뒤에 있는 오브젝트도 WorldToScreenPoint()를 호출하면 화면 좌표가 나오기 때문에 UI가 화면 반대편에 나타나는 경우가 있었다.
그래서 먼저 타겟이 카메라 뒤에 있는지를 확인했다.
카메라의 전방 방향과 카메라에서 타겟으로 향하는 방향을 내적해서 판정했다.
Vector3 camToTarget =
_camera.transform.position - targetWorldPos;
float dotValue =
Vector3.Dot(
CameraMoveHandler.Instance.GetDirection(),
camToTarget);
if (dotValue > 0)
{
_view.gameObject.SetActive(false);
return;
}
뒤쪽에 있는 경우 UI를 끄고, 앞쪽에 있을 때만 화면 좌표를 계산했다.
단순한 처리지만 이걸 넣고 나서 화면 반대편에 UI가 나타나는 문제는 없어졌다.
UI Object Pool
말풍선이나 피드 카드처럼 생성과 삭제가 반복되는 객체가 꽤 많았다.
처음에는 필요할 때 Instantiate()하고 사용이 끝나면 Destroy()하는 방식으로 처리했다.
기능적으로는 문제가 없었지만 플레이 중 계속 객체를 만들고 삭제하면서 GC Allocation이 발생했다.
그래서 Queue 기반 Object Pool을 만들었다.
GetObject -> Pool에서 꺼냄 -> 없으면 Instantiate
ReturnObject -> 비활성화 -> Pool에 반환
객체가 활성화될 때 OnGenerated(), 반환될 때 OnReturned()를 호출하도록 만들어 초기화와 정리 작업도 한 곳에서 관리했다.
이후 반복적으로 생성되는 UI는 대부분 Pool을 통해 처리했다.
프로파일러에서 확인했을 때 해당 구간의 런타임 GC Allocation을 0 Byte 수준으로 만들 수 있었다.
Addressables
리소스 관리도 개발하면서 문제가 생긴 부분이다.
맵이나 아바타 코스튬을 전부 앱에 포함시키면 리소스가 늘어날수록 초기 설치 용량도 같이 커진다.
실제로 초기 구성에서는 전체 리소스를 포함하면서 패키지 용량이 500MB 이상으로 올라갔다.
그래서 Addressables를 이용해 필요한 리소스를 별도로 내려받도록 변경했다.
다운로드 전에 GetDownloadSizeAsync()를 사용해 필요한 용량을 먼저 확인하고, 실제 다운로드에서는 GetDownloadStatus()를 사용해서 진행률을 표시했다.
Addressable Label 확인
-> Download Size 확인
-> 사용자에게 용량 표시
-> Dependency Download
-> Progress 표시
-> Release
모든 리소스를 처음부터 받지 않아도 되도록 구성하면서 초기 다운로드 패키지를 100MB 이하로 줄일 수 있었다.
최적화하면서 확인한 것
이번 프로젝트에서 최적화는 처음부터 계획한 것보다는 실제 문제가 보일 때마다 진행했다.
처음에는 World Space Canvas를 사용했는데 UI가 늘어나면서 Draw Call이 많이 증가했다.
그래서 Screen Space Canvas 하나에서 UI를 처리하는 방식으로 바꿨다.
객체 생성도 마찬가지였다. 말풍선이나 피드처럼 자주 만들어지는 객체는 Pooling으로 변경했다.
결과는 대략 다음과 같았다.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| UI | 개별 World Space Canvas | 단일 Screen Space Canvas |
| UI Draw Call | 80+ 수준 | 10 이하 |
| 반복 객체 | Instantiate / Destroy | Object Pool |
| 런타임 GC | 지속적인 Allocation | 주요 반복 구간 0 Byte |
| 초기 패키지 | 500MB+ | 100MB 이하 |
여기서 중요한 건 숫자 자체보다 실제 프로파일러를 보고 병목을 찾은 뒤 구조를 바꿨다는 점이다.
처음부터 최적화된 구조를 만들었다기보다 문제가 확인된 부분을 하나씩 수정해갔다.
아쉬웠던 부분
가장 아쉬운 부분은 네트워크 동기화다.
당시에는 broadcast-volatile로 최신 위치를 계속 전달하고 받은 값을 적용하는 방식으로 충분하다고 판단했다.
일반적인 환경에서는 크게 문제가 없었지만 Ping이 높아지거나 패킷 손실이 발생하면 원격 캐릭터가 순간적으로 튀는 문제가 있었다.
지금 다시 만든다면 위치만 보내는 것에서 끝내지 않고 속도나 가속도까지 같이 전달하고, 클라이언트에서 다음 위치를 어느 정도 예측하도록 만들 것 같다.
Dead Reckoning이나 보간 버퍼를 사용하는 방법도 적용할 수 있다.
직렬화 방식도 마찬가지다.
당시에는 JSON이 디버깅하기 편했고 개발 속도도 빨랐기 때문에 FastJson을 사용했다.
하지만 실시간 Transform이나 Animator 데이터를 계속 JSON 문자열로 만들어 보내는 것은 효율적인 방식은 아니다.
접속자가 많아지는 상황까지 생각한다면 FlatBuffers나 MemoryPack 같은 바이너리 직렬화를 사용하는 편이 더 적합했을 것이다.
마무리
이 프로젝트에서 가장 크게 경험한 부분은 여러 시스템이 서로 연결되기 시작하면 처음 생각했던 것보다 클라이언트 구조가 빠르게 복잡해진다는 점이었다.
처음에는 캐릭터 이동과 네트워크 동기화 정도만 해결하면 될 것 같았지만 실제로 개발해보니 사용자 상태 관리, Observer 수명, 소켓 연결 상태, UI 투영, Object Pool, Addressables까지 같이 고려해야 했다.
특히 한 가지 기능을 수정했을 때 다른 시스템에 영향을 주지 않도록 만드는 것이 생각보다 중요했다.
그래서 개발 후반으로 갈수록 기능 자체보다 데이터가 어떻게 흘러가는지 먼저 확인하고 구현하는 습관이 생겼다.
이번 프로젝트에서는 실시간 네트워크 통신부터 캐릭터 제어, 3D 수학을 이용한 카메라와 UI 처리, 메모리 관리, 리소스 스트리밍까지 클라이언트의 여러 영역을 직접 다뤄볼 수 있었다.
완성된 구조가 처음부터 있었던 것은 아니다.
문제가 생기면 원인을 찾고, 현재 구조로 계속 가져갈 수 있는지 판단한 뒤 필요한 부분을 바꾸는 식으로 개발했다.
그 과정을 거치면서 단순히 기능을 구현하는 것보다 프로젝트가 커졌을 때도 계속 수정할 수 있는 구조를 만드는 것이 클라이언트 개발에서 중요하다는 것을 배웠다.