프로젝트 개요
웹 브라우저에서 접속할 수 있는 3D 메타버스 서비스를 개발하면서 실시간 서버를 담당했다.
일반적인 웹 서비스라면 로그인, 데이터 조회, 저장 같은 요청을 API 서버에서 처리하면 크게 문제가 없다. 그런데 메타버스에서는 상황이 조금 달랐다. 사용자가 이동할 때마다 위치와 회전값이 계속 바뀌고, 같은 공간에 들어와 있는 다른 사용자들에게 이 값이 전달되어야 한다.
처음에는 이것도 결국 WebSocket으로 데이터를 주고받으면 되는 문제라고 생각했다. 실제로 초기 구현은 그렇게 시작했다. 그런데 접속자가 늘어나면서 서버가 처리해야 하는 메시지 수가 예상보다 빠르게 증가했다.
그래서 개발하면서 서버를 크게 두 부분으로 나눴다.
Main API Server는 로그인이나 데이터 조회처럼 요청 단위로 끝나는 작업을 담당하고, World Real-time Server는 실제 월드에 들어온 사용자의 연결과 실시간 상태 동기화를 담당하도록 분리했다.
구성은 대략 다음과 같다.
Client -> Main API Server -> Redis/MySQL/MongoDB
Client -> World Server -> Redis
API 서버는 Express와 Node.js cluster를 사용했고, 실시간 서버는 Socket.IO와 worker_threads를 사용했다. Redis는 서버 사이에서 공유해야 하는 상태와 접속 정보를 관리하는 용도로 사용했고, MySQL은 반드시 보존되어야 하는 데이터를 저장했다. 대량으로 발생하는 로그나 구조가 일정하지 않은 데이터는 MongoDB에 저장했다.
처음부터 이 구조를 완성된 형태로 만든 것은 아니다. 개발하면서 서버 한 곳에서 처리하려고 했던 기능을 하나씩 분리했다.

API Server와 World Server 분리
Main API Server는 Express를 사용했다.
로그인, 사용자 정보 조회, 게임 데이터 조회, 월드 입장에 필요한 정보 생성처럼 요청-응답 형태로 처리할 수 있는 기능은 API 서버에서 처리했다.
여기에는 Node.js의 cluster도 적용했다. 하나의 Node.js 프로세스만 사용하는 것보다 여러 프로세스를 띄워 여러 CPU 코어를 사용할 수 있게 하기 위해서였다.
반대로 월드 안에서 발생하는 실시간 데이터는 API 서버에서 처리하지 않았다.
World Server에서는 Socket.IO를 이용해 클라이언트와 지속적인 연결을 유지했다. 사용자가 월드에 들어오면 해당 World Server의 Room에 연결하고, 같은 채널에 있는 사용자들의 상태를 서로 전달했다.
이렇게 분리한 이유는 단순했다.
API 요청이 순간적으로 많이 들어왔다고 해서 월드에 접속해 있는 사용자들의 실시간 통신까지 영향을 받게 만들고 싶지 않았다. 반대로 월드에서 위치 패킷이 많이 발생한다고 API 요청 처리까지 느려지는 것도 피해야 했다.
그래서 API는 비교적 Stateless하게 구성하고, 실시간 연결 상태는 World Server에서 가지고 있도록 했다.
World Server 구조
실시간 서버는 Socket.IO를 사용했다.
처음에는 하나의 프로세스에서 모든 월드를 처리하는 구조도 생각했지만, 월드 수가 늘어날수록 특정 월드에서 발생하는 트래픽이 전체 서버에 영향을 줄 수 있다는 문제가 있었다.
그래서 World Server를 여러 인스턴스로 나눴다.
각 Worker Thread가 독립적인 World Server 인스턴스를 가지고, 포트 단위로 구분된 World Server가 여러 개 실행되도록 구성했다.
Node.js의 worker_threads를 사용한 이유도 여기에 있다. API 서버의 cluster와는 목적이 조금 달랐다.
API 서버에서는 HTTP 요청을 여러 프로세스로 분산하는 것이 중요했고, World Server에서는 실시간 연결과 이벤트 처리를 여러 독립적인 실행 단위로 분리하는 것이 중요했다.
각 Worker는 자기 World의 Socket.IO 연결과 Room을 관리하고, 다른 Worker가 가진 실시간 연결을 직접 건드리지 않는다.
대신 Worker 사이에서 공유해야 하는 정보는 Redis를 사용했다.
이렇게 하면서 한 World Server에 문제가 생기더라도 전체 실시간 서버가 같이 영향을 받는 구조를 피하려고 했다.
물론 Worker Thread를 사용한다고 해서 문제가 모두 해결되는 것은 아니었다. Worker가 종료되면 Redis에 남아 있는 해당 World 정보도 같이 정리해야 했고, 재시작 과정에서 이전 Worker가 남긴 상태가 새 Worker와 충돌하지 않도록 별도의 정리 과정이 필요했다.
그래서 Graceful Shutdown 과정에서 World Registry를 정리하고, 서버 시작 시에도 이전에 남아 있을 수 있는 상태를 확인하도록 구성했다.
Redis를 이용한 World Registry
여러 World Server가 존재하기 때문에 클라이언트가 어느 서버로 들어갈지 결정하는 과정이 필요했다.
이 정보를 각각의 서버 메모리에만 가지고 있으면 다른 서버에서는 현재 접속 상태를 알 수 없다.
그래서 Redis에 World와 Channel의 상태를 등록했다.
예를 들어 World Server가 생성되면 Redis에 해당 World의 서버 정보와 현재 접속자 수 등을 등록하고, 사용자가 들어오거나 나가면 값을 변경했다.
클라이언트가 월드에 입장할 때는 Redis에 등록된 World 목록을 확인하고 접속자가 가장 적은 서버를 선택하는 방식으로 분산시켰다.
단순한 Round Robin 방식도 가능했지만 실제 접속자가 어느 정도 들어와 있는지를 고려하는 것이 더 적절하다고 판단했다.
다만 이 부분을 구현하면서 문제가 하나 있었다.
처음에는 Redis에서 현재 접속자 수를 가져온 다음 서버에서 최대 인원을 비교하고 다시 값을 저장하는 식으로 처리했다.
GET -> 현재 인원 확인 -> 최대 인원 비교 -> SET
그런데 동시에 여러 명이 입장하면 같은 접속자 수를 읽고 둘 다 입장을 허용할 수 있었다.
결국 Redis를 사용하고 있어도 연산 전체가 원자적으로 처리되지 않으면 Race Condition이 발생한다는 것을 확인했다.
이후 접속자 수를 변경하는 부분은 단순한 GET/SET 방식에서 벗어나 원자적인 카운트 처리 구조를 고려했다. 실제로 이런 부분은 Redis를 사용한다는 것보다 Redis에서 어떤 연산을 어떻게 묶어서 처리하는지가 더 중요했다.
JWT World Join Ticket
월드 서버에 바로 사용자 인증 정보를 전달하는 대신 API 서버에서 World Join Ticket을 발급하도록 구성했다.
로그인 과정은 API 서버에서 처리하고, 월드에 들어갈 때는 짧은 시간 동안만 유효한 JWT를 발급한다.
이 토큰에는 사용자 식별 정보와 접속하려는 World 등의 정보를 넣었다.
만료 시간은 약 20초 정도로 짧게 설정했다.
월드 입장 요청을 받은 World Server에서는 Socket.IO Middleware에서 JWT를 먼저 검증했다.
Socket Connection -> JWT 검증 -> Session 생성 -> World/Channel 확인 -> Room Join
인증을 각 이벤트마다 처리하는 것보다 연결 과정에서 한 번 검증하고 이후에는 해당 소켓의 세션 정보를 사용하는 편이 관리하기 쉬웠다.
여기서 JWT의 만료 시간이 짧다고 해서 자동으로 완전한 의미의 일회용 토큰이 되는 것은 아니다. 실제 일회용 처리가 필요한 경우에는 jti 등을 Redis에 기록해서 재사용 여부까지 확인해야 한다.
프로젝트에서는 짧은 유효시간을 가진 World Join Ticket으로 운용했다.
Socket.IO Middleware
Socket.IO에는 Middleware를 붙여 연결 단계에서 공통 처리를 할 수 있도록 했다.
인증 토큰을 확인하고 사용자 세션을 구성한 뒤, 해당 사용자가 어떤 World와 Channel에 들어가야 하는지를 확인했다.
이 과정을 각각의 이벤트 핸들러에서 반복하지 않도록 앞단에서 처리했다.
실시간 서버에서 이런 공통 처리를 분리해 놓으니 이후 이벤트를 추가할 때도 구조가 비교적 단순했다.
예를 들어 이동 이벤트에서는 인증 여부를 다시 확인하는 대신 이미 만들어진 세션을 기준으로 처리할 수 있었다.
Room과 Channel 관리
World 안에서는 다시 Channel을 나눴다.
같은 World에 있다고 해서 모든 사용자의 패킷을 서로에게 보내는 것은 비효율적이기 때문이다.
Socket.IO의 Room 기능을 이용해 같은 Channel에 있는 사용자들을 그룹으로 묶었다.
사용자가 Channel에 들어오면 해당 Room에 Join하고, 나가면 Leave한다.
이 구조가 나중에 패킷 최적화에서도 중요하게 사용됐다.
처음에는 World 단위로 사용자 상태를 전송하려고 했지만 사용자가 늘어나면서 불필요한 전송이 많아졌다. Channel 단위로 나누면서 적어도 서로 다른 Channel의 사용자에게는 위치 정보를 보내지 않게 만들 수 있었다.
Reliable 데이터와 실시간 위치 데이터 분리
모든 데이터를 같은 방식으로 Broadcast하면 안 된다는 것도 개발하면서 알게 된 부분이다.
채팅이나 중요한 상태 변경처럼 반드시 전달되어야 하는 데이터와 플레이어의 위치나 회전처럼 계속 갱신되는 데이터는 성격이 다르다.
예를 들어 플레이어 위치가
x=10 -> x=11 -> x=12 -> x=13
으로 변경됐는데 x=11 패킷이 늦게 도착했다고 해서 반드시 다시 처리할 필요는 없다. 결국 필요한 것은 최신 위치에 가까운 값이다.
그래서 중요한 이벤트는 일반적인 reliable broadcast 방식으로 처리하고, 위치나 회전값처럼 고빈도로 발생하는 데이터는 Socket.IO의 broadcast-volatile을 사용했다.
여기서 volatile을 사용한다고 TCP 자체가 UDP처럼 바뀌는 것은 아니다. Socket.IO 레벨에서 현재 전송할 수 없는 상황의 오래된 이벤트를 굳이 쌓아서 보내지 않도록 하는 방식이다.
실시간 게임에서는 오래된 위치 패킷을 계속 밀어 넣는 것보다 최신 상태를 유지하는 편이 더 중요했다.
위치 패킷 때문에 발생한 O(N²) 문제
개발 중 가장 크게 체감했던 문제는 Broadcast였다.
예를 들어 Channel에 100명이 있고 모든 사용자의 위치를 20Hz로 전송한다고 가정하면 단순한 Fan-out만 계산해도 대략
100 * 99 * 20 = 198,000
개의 전달 작업이 초당 발생한다.
사용자 수가 증가하면 이 숫자는 선형으로 증가하지 않는다.
초기 구현에서는 모든 사용자의 위치 정보를 같은 방식으로 전송했고, 부하가 증가하면서 Node.js Event Loop Delay가 크게 올라갔다. 테스트 환경에서 이벤트 루프 지연이 수백 ms 수준까지 올라가는 상황도 확인했다.
처음에는 서버 CPU를 더 사용하는 방법을 생각했지만 패킷 자체가 너무 많았다.
서버를 추가하는 것만으로 해결하기보다는 먼저 보내는 데이터 양을 줄여야 했다.
그래서 Channel의 최대 인원을 제한하고, 위치와 회전값 같은 고빈도 데이터에는 volatile 전송을 적용했다. 불필요한 필드도 제거했다.
결과적으로 네트워크에서 전달되는 Payload 자체도 줄였고, 서버가 매번 처리해야 하는 Serialization 비용도 낮출 수 있었다.
이 문제를 겪고 나서 실시간 서버에서는 CPU보다 먼저 데이터 흐름을 봐야 한다는 생각을 하게 됐다.
Redis Cache Layer
Redis는 단순히 World Registry 용도로만 사용하지 않았다.
자주 조회되는 데이터는 Redis에 캐싱했고, 용도에 따라 자료구조를 다르게 사용했다.
단순한 값은 String, 사용자나 World의 여러 속성을 묶어 관리해야 하는 경우 Hash, 순위나 정렬된 데이터를 다루는 경우 Sorted Set을 사용했다.
이 작업을 각각의 서비스 코드에서 직접 Redis 명령으로 처리하지 않도록 별도의 Redis Wrapper를 만들었다.
애플리케이션에서는 Redis의 구체적인 명령어보다 get, set, hash, sorted set 등의 의미 단위로 사용할 수 있도록 만들었다.
처음에는 이런 Wrapper가 오히려 코드만 늘리는 것 아닌가 싶었지만 Redis 사용 위치가 많아지면서 공통 옵션과 예외 처리, Key 규칙을 한곳에서 관리할 수 있다는 장점이 있었다.
MySQL과 MongoDB 분리
모든 데이터를 하나의 DB에 넣지는 않았다.
사용자 정보나 게임 진행 상태처럼 정확하게 보존되어야 하는 데이터는 MySQL에서 관리했다.
반대로 실시간 서버에서 계속 발생하는 접속 로그, 이벤트 로그처럼 데이터 발생량이 많고 구조가 계속 변할 수 있는 데이터는 MongoDB에 저장했다.
특히 서버 장애가 발생했을 때 단순히 “서버가 느렸다”는 정보만으로는 원인을 찾기 어려웠다.
그래서 MongoDB에 관련 로그를 남기고, 시간대별 접속량이나 특정 이벤트 발생 여부를 같이 확인할 수 있도록 했다.
DB를 선택할 때도 단순히 어느 DB가 더 빠른지를 보는 것이 아니라 데이터의 성격을 먼저 구분했다.
Ghost User 문제
모바일 환경을 대응하면서 예상하지 못했던 문제가 하나 있었다.
사용자가 모바일에서 브라우저를 백그라운드로 보내거나 Wi-Fi 연결이 순간적으로 끊어지면 서버 입장에서는 연결이 바로 종료되지 않는 경우가 있었다.
실제로 사용자는 이미 게임을 나갔는데 서버에는 아직 접속 중인 사용자로 남아 있는 상황이 발생했다.
이런 상태를 Ghost User로 보고 연결 상태 확인을 강화했다.
Socket.IO의 pingInterval과 pingTimeout을 조정해서 일정 시간 동안 응답이 없는 연결을 정리하도록 했다.
하지만 이것만으로 끝내면 또 다른 문제가 있었다.
타임아웃 처리와 실제 disconnect 이벤트가 거의 동시에 발생하거나, 네트워크가 복구되는 과정에서 여러 정리 로직이 실행될 수 있었다.
그래서 disconnect cleanup은 여러 번 실행되어도 결과가 동일하도록 멱등적으로 처리했다.
Disconnect -> Session 정리 -> Room Leave -> Redis Count 감소 -> Registry 갱신
각 단계에서 이미 삭제된 상태를 다시 삭제해도 문제가 발생하지 않도록 구성했다.
실시간 서버에서는 연결 종료가 항상 한 번만 깔끔하게 발생한다고 생각하면 안 된다는 것을 이 과정에서 확실하게 느꼈다.
Stale World Registry
Worker가 정상적으로 종료되면 Redis에서 World 정보를 지우면 되지만 서버가 갑자기 죽는 경우에는 이야기가 달라진다.
Worker가 죽었는데 Redis에는 World 정보가 그대로 남아 있으면 다른 서버에서는 해당 World가 정상적으로 존재한다고 판단할 수 있다.
그러면 신규 사용자가 죽어 있는 World Server로 접속하려고 할 수 있다.
그래서 World Registry에 단순히 서버 정보만 저장하지 않고 해당 서버가 살아 있다는 것을 확인할 수 있는 상태값을 함께 관리했다.
Heartbeat를 갱신하고 일정 시간 동안 갱신되지 않으면 해당 World를 stale 상태로 판단해서 제거하는 방식으로 보완했다.
서버 시작 과정에서도 이전 실행에서 남아 있는 Registry를 정리하도록 했다.
결국 분산 환경에서는 “현재 서버가 살아 있는가”라는 상태 자체도 데이터로 관리해야 했다.
Worker 장애와 Graceful Shutdown
Worker Thread 구조를 적용하면서 Worker의 생성과 종료 과정도 별도로 관리했다.
Worker가 시작되면 해당 World를 Registry에 등록하고 필요한 Socket.IO 서버를 초기화한다.
종료할 때는 새로운 접속을 받지 않고 기존 연결을 정리한 뒤 Redis의 World Registry를 제거한다.
중간에 프로세스가 강제 종료될 경우 Graceful Shutdown을 거치지 못할 수 있기 때문에 Heartbeat와 TTL을 같이 사용했다.
정상 종료는 즉시 정리하고, 비정상 종료는 TTL을 통해 늦게라도 정리되는 구조다.
이렇게 이중으로 처리해 두는 것이 운영 측면에서는 더 안전했다.
서버 모니터링
서버가 정상적으로 돌아가는지만 확인해서는 실제 문제를 찾기 어려웠다.
특히 Node.js에서는 CPU 사용량이 낮더라도 Event Loop가 특정 작업 때문에 밀릴 수 있다.
그래서 WhaTap을 이용해 서버 상태를 확인하면서 Heap, Event Loop Delay, TPS, 동시 접속자 등의 지표를 같이 봤다.
실시간 서버에서는 단순한 요청 처리량보다 Event Loop Delay가 상당히 중요한 지표였다.
패킷이 몰렸을 때 CPU가 높아지는 것뿐만 아니라 이벤트 처리가 뒤로 밀리는 현상이 나타났기 때문이다.
또한 접속자 수만 보는 것이 아니라 MCU와 DAU 같은 서비스 지표도 같이 확인했다.
장애가 발생하면 MongoDB의 이벤트 로그와 서버 모니터링 지표를 시간대별로 맞춰보면서 어느 시점부터 문제가 발생했는지 확인했다.
구현하면서 남은 문제
현재 구조에서도 해결해야 할 부분은 남아 있다.
가장 큰 문제는 같은 Channel에 사용자가 많아졌을 때의 Broadcast 비용이다.
Channel 인원을 제한하는 것만으로 어느 정도 해결할 수 있지만 사용자가 더 많아지면 결국 같은 문제를 다시 만나게 된다.
그래서 다음 단계에서는 AOI(Area of Interest)를 적용하는 것을 고려하고 있다.
현재는 같은 Channel에 있으면 기본적으로 서로의 상태를 전달하지만 AOI Grid나 Quad-tree를 사용하면 실제로 가까이 있는 사용자에게만 상태를 전달할 수 있다.
예를 들어 100명이 같은 Channel에 있어도 화면에 보이지 않는 먼 위치의 사용자까지 매번 위치를 보낼 필요는 없다.
이렇게 되면 단순한
User -> Channel 전체
구조에서
User -> 관심 영역에 포함된 사용자
구조로 바꿀 수 있다.
또 하나는 JSON 기반 Payload다.
JSON은 개발하기 편하고 디버깅하기도 좋았지만 위치 데이터처럼 초당 여러 번 발생하는 패킷에는 Serialization과 Payload 크기 측면에서 부담이 생긴다.
그래서 향후에는 Protobuf나 FlatBuffers 같은 Binary Protocol을 적용하는 것도 고려하고 있다.
음성이나 영상까지 같은 WebSocket 구조에서 처리하는 것도 한계가 있다.
게임 상태나 Signaling은 WebSocket으로 유지하되 음성/영상 데이터는 WebRTC 기반으로 분리하고, 여러 사용자 간 미디어 전달에는 SFU 구조를 사용하는 방향이 더 적합하다고 판단했다.
개발하면서 바뀐 생각
처음에는 실시간 서버를 만드는 것을 단순히 WebSocket 연결을 많이 유지하는 문제라고 생각했다.
그런데 실제로 구현해보니 연결 자체보다 연결된 이후 발생하는 데이터를 어떻게 줄이고 분산할지가 더 중요했다.
100명이 접속한 서버와 1,000명이 접속한 서버의 차이는 단순히 사용자가 10배가 되는 것이 아니었다. 같은 데이터를 서로에게 보내는 구조라면 Broadcast 비용이 훨씬 빠르게 증가한다.
그래서 개발하면서 서버를 계속 추가하는 것보다 먼저 데이터의 성격을 나누는 작업을 했다.
API 요청과 실시간 이벤트를 분리했고, 중요한 데이터와 위치 데이터의 전송 방식을 나눴다. World와 Channel도 분리했고, Redis에는 여러 서버가 공유해야 하는 상태만 넣었다.
DB 역시 MySQL과 MongoDB를 데이터 성격에 따라 나눴다.
그리고 장애가 발생했을 때 정상적으로 복구할 수 있도록 Worker 종료, Disconnect, Redis Registry 정리까지 같이 처리했다.
이번 개발에서 가장 크게 얻은 것은 특정 기술을 많이 사용했다는 사실보다 실시간 시스템에서는 처리해야 하는 작업과 전달해야 하는 데이터를 줄이는 것이 먼저라는 것이었다.
Node.js cluster나 worker_threads를 사용한다고 해서 자동으로 고성능 서버가 되는 것도 아니고, Redis를 붙였다고 해서 분산 문제가 해결되는 것도 아니었다.
결국 병목이 어디에서 발생하는지 확인하고, 그에 맞게 데이터 흐름과 실행 구조를 바꾸는 과정이 중요했다.
현재 구조도 완성된 형태라고 보기는 어렵다. 동시 접속자가 더 증가하면 AOI와 Binary Protocol이 필요하고, 음성이나 영상까지 추가하면 WebRTC SFU 구조도 필요하다.
다만 API, 실시간 월드, Worker, Redis, DB를 각각 어떤 책임으로 분리할지 직접 구성하면서 웹 기반 메타버스 서버가 일반적인 REST 서버와 어떤 차이가 있는지는 확실히 경험할 수 있었다.