고성능 실시간 가상 공간 백엔드 개발 회고
이번 프로젝트에서는 여러 사용자가 하나의 가상 공간에 들어와 서로의 상태를 실시간으로 공유하는 서버를 개발했다.
처음부터 지금과 같은 구조로 시작한 것은 아니다. 처음에는 일반적인 Node.js 서버를 기준으로 기능을 붙여 나갔다. 로그인이나 회원 정보, 인벤토리, 상점 같은 기능은 큰 문제가 없었다.
문제는 실시간 월드가 붙으면서 생겼다.
플레이어가 움직일 때마다 위치와 회전값을 서버로 보내고, 서버는 같은 방에 있는 다른 사용자들에게 다시 전달해야 했다. 여기에 채팅이나 아이템 획득, 방 입장 같은 일반적인 이벤트까지 같이 처리하다 보니 서버가 맡는 일이 너무 달라졌다.
그래서 API를 처리하는 서버와 실제 월드를 처리하는 서버를 나눴다.
Client -> API Server -> 로그인 / 인벤토리 / 상점 / 월드 생성
Client -> World Server -> 위치 동기화 / 룸 이벤트 / 브로드캐스트
처음에는 단순히 서버를 나누면 끝이라고 생각했는데, 실제로 개발하다 보니 프로세스 내부에서도 다시 나눌 필요가 있었다.

API 서버
API 서버는 Node.js와 Express로 구성했다.
하나의 Node.js 프로세스만 사용하는 대신 cluster를 이용해서 여러 Worker 프로세스를 띄웠다.
cluster.schedulingPolicy = cluster.SCHED_RR;
각 Worker가 API 요청을 처리하고 Master가 Worker의 생명주기를 관리하는 구조였다.
이걸 적용한 이유는 CPU를 여러 코어에서 사용하기 위해서였다. 그리고 Worker가 죽었을 때 서버 전체를 다시 시작하지 않고 해당 Worker만 다시 만들 수 있다는 점도 좋았다.
실제로 개발하면서 Worker 하나가 비정상 종료되는 상황도 있었기 때문에 이 구조가 꽤 유용했다.
다만 여기서 한 가지 문제가 생겼다.
각 Worker는 프로세스가 다르기 때문에 메모리도 따로 가지고 있다. 한 Worker에서 저장한 데이터를 다른 Worker가 바로 볼 수 없다.
그래서 세션이나 실시간 상태처럼 여러 프로세스에서 공유해야 하는 데이터는 프로세스 메모리에 넣지 않고 Redis를 사용했다.
결과적으로 API 서버에서는 대략 이런 식으로 역할을 나눴다.
Master
-> Worker #1
-> Worker #2
-> Worker #3
-> ...
그리고 공통 상태는 Redis에서 가져왔다.
월드 서버
실시간 월드는 API 서버와 다른 방식으로 구성했다.
특히 Private World가 문제였다.
사용자가 직접 방을 만들 수 있었기 때문에 방이 만들어졌다가 없어지는 일이 계속 발생했다. 단순하게 하나의 프로세스에서 모든 방을 관리할 수도 있었지만, 특정 방에서 연산량이 많아지면 같은 프로세스의 다른 방까지 영향을 받을 수 있었다.
그래서 worker_threads를 이용했다.
월드 하나를 Worker 하나에 연결하는 방식이었다.
World Host
-> Private World #1
-> Private World #2
-> Private World #3
-> ...
월드가 만들어지면 Worker를 만들고, 월드가 삭제되면 해당 Worker도 종료했다.
방을 논리적으로만 나누는 것보다 관리하기 편했고, 특정 월드에서 작업이 몰렸을 때 다른 월드에 영향을 주는 것도 줄일 수 있었다.
대신 Worker를 동적으로 만들고 없애다 보니 생각하지 못했던 문제가 하나 생겼다.
포트 충돌이었다.
EADDRINUSE가 계속 발생했던 이유
Private World는 일정한 포트 범위를 사용했다.
처음에는 사용 가능한 포트 인덱스를 배열로 관리했다.
const index =
privateServerIndexList.length === 0
? indexCount++
: privateServerIndexList.pop()!;
방이 없어지면 사용했던 인덱스를 다시 배열에 넣었다.
구현 자체는 간단했다.
그런데 간헐적으로 이런 에러가 발생했다.
Error: listen EADDRINUSE: address already in use
처음에는 포트 관리 배열에 문제가 있다고 생각했다.
그런데 로그를 따라가 보니 방 삭제 과정에서 문제가 있었다.
Worker에게 종료 명령을 보내고 바로 포트를 반환하고 있었는데, 실제 서버 소켓은 아직 종료되지 않은 상태였다.
순서가 이런 식이었다.
Worker 종료 요청
-> 포트 반환
-> 새로운 Worker 생성
-> 같은 포트로 listen()
-> 기존 Worker가 아직 포트를 사용 중
-> EADDRINUSE
종료 요청과 실제 종료가 같은 시점이 아니었던 것이다.
그래서 포트 반환 시점을 바꿨다.
Worker 종료 요청
-> 기존 연결 정리
-> HTTPS Server close
-> Worker exit
-> 포트 반환
worker.on('exit')가 발생한 이후에 포트를 다시 사용할 수 있도록 수정했다.
이 문제는 코드만 봤을 때는 별것 아닌 것처럼 보였는데 실제로는 꽤 오래 잡았다. 비동기 서버에서 리소스를 반납할 때는 “종료를 요청한 것”과 “종료가 끝난 것”을 구분해야 한다는 걸 확실하게 알게 된 부분이다.
위치 동기화
실시간 월드에서 가장 신경 쓴 부분은 위치 동기화였다.
처음에는 위치 데이터도 일반적인 Socket.IO 이벤트처럼 전송했다.
그런데 인원이 많아지니까 문제가 보이기 시작했다.
예를 들어 한 명이 초당 20번 위치를 보내고 방에 30명이 있다면 단순 계산으로도 꽤 많은 브로드캐스트가 발생한다.
20 packets
x 30 users
x 29 recipients
= 17,400 broadcasts/sec
실제 테스트에서도 사람이 많아지고 모두 움직이는 상황에서 클라이언트의 위치가 순간적으로 튀는 현상이 발생했다.
처음에는 서버 CPU를 먼저 봤다.
그런데 CPU 사용량만으로 설명되지 않는 지연이 있었다. 패킷이 밀리면서 오래된 위치 데이터가 계속 전달되는 게 문제였다.
위치 데이터는 조금 다르게 생각해야 했다.
예를 들어 실제 위치가
100 -> 101 -> 102 -> 103 -> 104
라고 해도 네트워크가 밀려서 100부터 순서대로 늦게 들어오면 최신 위치를 보여주는 것보다 오히려 움직임이 이상해진다.
이미 지나간 위치는 버려도 된다.
그래서 위치 동기화에는 Socket.IO의 volatile을 적용했다.
socket.to(this._roomType)
.volatile
.emit(event, JSON.stringify(data));
반대로 채팅이나 아이템 획득, 입장/퇴장처럼 없어지면 안 되는 데이터는 일반적인 emit을 사용했다.
결국 패킷을 성격에 따라 나눴다.
채팅 / 아이템 / 입퇴장 -> 일반 전송
위치 / 회전 -> volatile
이렇게 바꾼 뒤 패킷이 밀리는 상황에서 오래된 위치 데이터가 계속 쌓이는 문제가 많이 줄었다.
테스트 환경에서는 평균 전달 지연이 180ms 이상 올라가던 상황에서 15ms 전후까지 내려오는 것을 확인했다.
물론 이 수치는 특정 테스트 환경에서 측정한 결과라 서버 전체 성능을 의미하는 값은 아니다. 그래도 실제 플레이 상황에서 체감될 정도의 차이는 있었다.
소켓 연결 검증
월드 서버는 외부에서 직접 접근할 수 있기 때문에 연결할 때 검증을 여러 단계로 나눴다.
처음에는 토큰만 확인하면 충분하다고 생각했는데, 실제로는 확인해야 할 것이 더 많았다.
현재 구조는 다음과 같다.
WSS Connect
-> Join Token 확인
-> Redis Session 확인
-> 중복 접속 확인
-> Packet Router 등록
Join Token에는 사용자가 접속해야 하는 World Server의 인덱스를 넣었다.
그래서 다른 월드의 포트로 직접 접근하더라도 토큰에 들어 있는 서버 정보와 현재 서버의 정보를 비교해서 거부할 수 있었다.
그 다음에는 Redis의 세션 정보를 확인했다.
그리고 같은 유저가 이미 접속해 있는지도 확인했다.
마지막에는 해당 소켓에서 사용할 패킷 라우터를 등록했다.
라우터 등록도 처음에는 연결 코드에서 하나씩 처리했다.
패킷이 많아지면서 이 방식은 관리하기 불편했다.
그래서 RouteData에 필요한 정보를 넣고 연결 시 순회하면서 등록하도록 바꿨다.
새 패킷을 추가할 때 기존 연결 코드까지 계속 수정하지 않아도 된다는 점에서 이쪽이 훨씬 편했다.
Redis 사용
Redis는 이 프로젝트에서 단순한 캐시 용도로만 사용하지 않았다.
세션이나 유저 아이템, 월드 정보, 랭킹처럼 자주 읽고 쓰는 데이터를 Redis에서 처리했다.
특히 유저별 데이터는 Redis Hash를 많이 사용했다.
HGET
HSET
HEXISTS
HDEL
이 부분은 여러 데이터에서 비슷한 코드가 반복돼서 제네릭 클래스로 묶었다.
export default class UserDictionaryCacheUidBase<T> {
private _key: string;
private _redisClient: IORedis.Redis;
constructor(key: string, redisClient: IORedis.Redis) {
this._key = key;
this._redisClient = redisClient;
}
public async GetData(
userID: string,
uid: string
): Promise<T> {
const data = await this._redisClient.hget(
this._key + userID,
uid.toString()
);
return JSON.parse(data as string) as T;
}
public async SetData(
userID: string,
uid: string,
data: T
): Promise<void> {
await this._redisClient.hset(
this._key + userID,
uid.toString(),
JSON.stringify(data)
);
}
}
이렇게 만들어 놓으니 아이템이나 세션처럼 비슷한 형태의 데이터를 추가할 때 Redis 접근 코드를 다시 작성할 필요가 없었다.
Redis를 쓰면서 생긴 동시성 문제
Redis를 사용하면서 또 다른 문제가 생겼다.
상점 구매 처리였다.
처음에는 구매 요청이 들어오면 Redis에서 잔액을 확인하고 차감한 다음 MySQL에 저장했다.
문제는 요청이 동시에 들어올 수 있다는 것이었다.
예를 들어 잔액이 100이고 70짜리 아이템을 두 번 구매하면 다음과 같은 상황이 가능했다.
요청 A -> 잔액 100 조회
요청 B -> 잔액 100 조회
요청 A -> 70 차감
요청 B -> 70 차감
Redis를 사용한다고 해서 이 과정 전체가 자동으로 원자적으로 처리되는 것은 아니었다.
HGET하고 HSET하는 사이에 다른 요청이 들어올 수 있기 때문이다.
그래서 구매 요청 자체를 변경했다.
여러 아이템을 구매하는 경우 각각 따로 처리하지 않고 구매 목록을 한 번에 받도록 했다.
구매 목록
-> 전체 금액 계산
-> 잔액 확인
-> 한 번에 차감
-> 아이템 지급
-> DB 반영
이렇게 처리하면서 같은 요청에서 필요한 금액을 먼저 계산하고 처리하도록 만들었다.
이 문제를 겪고 나서 Redis를 캐시로 사용하는 것과 Redis를 상태 저장소로 사용하는 것은 생각보다 차이가 크다는 걸 느꼈다. 단순히 DB보다 빠른 저장소를 하나 추가한다고 해서 동시성 문제가 사라지는 것은 아니었다.
랭킹
랭킹은 Redis Sorted Set을 사용했다.
점수 변경은 ZADD, 순위 조회는 ZREVRANGE를 사용했다.
ZADD -> 점수 변경
ZREVRANGE -> 순위 조회
랭킹을 조회할 때마다 MySQL에서 전체 데이터를 정렬하는 것보다 Redis에서 바로 가져오는 편이 적합했다.
실제 영속 데이터는 MySQL에 두고, 자주 조회하는 랭킹 데이터는 Redis에서 처리하는 식으로 역할을 나눴다.
DAU와 동시 접속자 통계
기본적인 서버 통계도 직접 만들었다.
DAU는 Redis Hash에 유저 ID를 넣어서 같은 사용자가 여러 번 접속해도 한 번만 집계되도록 했다.
현재 접속자는 세션 데이터의 개수를 기준으로 계산했고, 그날 기록된 최대 동시 접속자 수도 별도로 저장했다.
날짜가 변경되면 이전 통계를 MySQL에 저장하고 새로운 날짜의 통계를 다시 시작했다.
사용자 접속
-> DAU 등록
-> 현재 세션 수 확인
-> 최대 동시 접속자 갱신
-> 통계 저장
대단한 모니터링 시스템을 만든 것은 아니지만 별도의 시스템을 붙이지 않고 서버에서 필요한 수치를 바로 확인할 수 있다는 점은 편했다.
개발하면서 가장 많이 바뀐 생각
처음에는 서버 성능 문제를 CPU와 프로세스 수의 문제라고 생각했다.
그래서 Cluster를 쓰고 Worker Thread를 쓰면 대부분 해결될 것이라고 생각했다.
실제로는 그렇지 않았다.
패킷이 많아지니까 네트워크 큐에서 문제가 생겼고, 월드를 빠르게 만들고 없애니까 포트 수명 관리에서 문제가 생겼다.
Redis를 사용해서 DB 접근을 줄였더니 이번에는 동시 요청에 대한 데이터 정합성을 신경 써야 했다.
결국 서버에서 중요한 건 특정 기술을 많이 사용하는 게 아니었다.
어떤 작업을 같은 프로세스에서 처리할 것인지, 어떤 데이터는 늦어도 괜찮고 어떤 데이터는 늦으면 안 되는지, 실패했을 때 어디까지 복구해야 하는지를 정하는 일이 더 중요했다.
이번 프로젝트에서 최종적으로 잡은 구조도 이런 판단의 결과였다.
Client
-> API Server
-> 인증 / 계정 / 인벤토리 / 상점 / 월드 관리
Client
-> World Server
-> 실시간 위치 / 룸 이벤트 / 브로드캐스트
API / World Server
-> Redis
-> 세션 / 캐시 / 랭킹 / 실시간 상태
Redis
-> MySQL
-> 영속 데이터 / 통계
아쉬웠던 부분
현재 구조는 한 대의 서버에서 여러 World Worker를 관리하는 방식이다.
개발 규모에서는 충분했지만 월드가 계속 늘어나면 결국 한 서버의 CPU와 메모리가 한계가 된다.
Worker Thread를 계속 만드는 것으로 해결할 수 있는 문제는 아니다.
다음에 같은 시스템을 더 큰 규모로 만든다면 월드 서버를 처음부터 독립적인 실행 단위로 분리하는 방향을 생각할 것 같다.
예를 들면 Kubernetes나 Agones 같은 환경에서 월드 서버를 각각 별도의 인스턴스로 관리하고 필요한 만큼 늘리는 방식이다.
네트워크도 현재는 WebSocket을 사용하고 있지만 위치 동기화가 더 중요해지는 환경이라면 WebTransport나 QUIC 계열을 검토할 수 있다.
데이터 저장 방식도 마찬가지다.
현재는 Redis에 먼저 상태를 반영하고 MySQL에 비동기로 저장하는 부분이 있기 때문에 두 저장소의 상태가 잠시 달라질 수 있다.
규모가 커진다면 Transactional Outbox 같은 방식을 사용해서 Redis와 MySQL 사이의 데이터 변경을 보다 안정적으로 전달하는 구조도 고려할 수 있다.
마무리
이 프로젝트에서 가장 많이 고민했던 것은 결국 “어떻게 더 빠르게 만들 것인가”보다는 “어디까지 분리해야 하는가”였다.
처음부터 Cluster, Worker Thread, Redis 같은 기술을 전부 사용한 것은 아니다.
필요한 문제가 생길 때마다 하나씩 추가했다.
API와 실시간 월드를 분리했고, 월드 안에서도 특정 작업을 별도 Worker로 떼었다.
위치 패킷은 전부 안정적으로 전달하려고 하기보다 오래된 데이터는 버리는 쪽으로 바꿨다.
Redis를 사용하면서 발생한 동시성 문제도 직접 처리하면서 데이터가 변경되는 순서를 다시 확인했다.
이런 과정을 거치면서 서버 구조를 잡을 때 기술 이름부터 정하는 것보다는 먼저 데이터와 작업의 성격을 나누는 게 중요하다는 쪽으로 생각이 바뀌었다.
실시간 서버는 특히 그렇다.
모든 데이터를 빠르게 처리하려고 하는 것보다, 무엇을 빠르게 처리해야 하고 무엇을 버려도 되는지를 정하는 게 더 중요했다.