언리얼 엔진 클라이언트 안에서 서버 패킷이 도착해서 화면에 그려지기까지, 그리고 내 입력이 서버로 나가기까지 어떤 스레드를 거치는지 보여주는 영상이다. 이 클라는 패킷을 별도 스레드로 받지만 캐릭터를 실제로 움직이는 일은 게임 스레드가 프레임마다 한 번씩 하므로, 클라의 FPS와 멈춤이 그대로 지연과 끊김이 된다. 이 지연은 서버 틱 시간이나 커널 RTT에는 잡히지 않는다.
한 프레임 안에서 스레드들이 어떻게 겹쳐 돌아가는지 레인으로 보여준다. 리시브 스레드는 받고 파싱만 하고, 게임 스레드는 입력, 틱, 패킷 적용을 하고, 센드 스레드는 큐에 쌓인 것을 보낸다. 리시브·센드 스레드는 언리얼 기본이 아니라 이 클라가 직접 만든 소켓 모듈의 스레드다. 렌더 스레드는 게임 스레드보다 한 프레임 뒤에서 F1을 그리고, RHI·GPU는 그 다음 프레임에 F1을 처리한다.
화면의 한 칸은 1프레임이고 게임 스레드의 한 틱이다. 리시브 스레드 레인의 점들은 패킷 도착 시점인데, 도착 시점과 관계없이 모두 다음 틱으로 넘어간다.
이유는 액터(UObject)를 게임 스레드에서만 만질 수 있기 때문이다. 다른 스레드가 패킷을 아무리 빨리 받아도 캐릭터 위치를 바꾸는 것은 게임 스레드 하나뿐이고, 받은 패킷은 다음 틱을 기다린다.
서버와 클라가 같은 "틱"을 다르게 재는 방식을 나란히 비교한다. 서버는 20Hz 고정으로 50ms마다 한 틱을 돌고, 일이 끝나면 다음 틱까지 쉰다. 예시에서 틱 처리 시간은 18ms, 예산은 50ms이고, 50ms를 넘기면 서버 렉이다. 그래서 서버는 "한 틱 처리에 몇 ms"로 이야기한다.
클라는 프레임을 이어서 돌린다. 프레임 하나는 게임 스레드 한 바퀴(입력, 틱, 패킷 적용, 렌더에 넘기기)다. 상한(예: 60fps)까지는 끝나는 대로 다음 프레임으로 가고, 무거우면 프레임 시간 자체가 늘어 FPS가 떨어진다. 화면에서는 16.7ms 프레임이 이어지다가 보스 이펙트 구간에서 33ms, 67ms로 늘어난다. 그래서 클라는 "1초에 몇 프레임"으로 이야기한다.
환산식은 1프레임 시간 = 1000 ÷ FPS다. 60fps는 16.7ms, 30fps는 33ms, 15fps는 67ms다. 이 영상에서 "틱"은 클라 게임 스레드의 한 프레임을 뜻하며, FPS가 떨어진다는 것은 클라 틱 하나가 길어진다는 뜻이다.
패킷이 들어오는 순서를 상자로 보여준다.
- 소켓: 커널 수신 버퍼에 데이터가 쌓인다.
- 리시브 스레드:
recv()로 읽고, 패킷을 조립·파싱해서 큐에 넣는다.
- 수신 큐: 틱 사이에 쌓였다가 틱이 올 때 한 번에 빠진다.
- 게임 스레드 Tick: ① 큐에서 꺼낸다(프레임당 한도가 있다). ② 핸들러가 액터 이동, 스폰, UI를 처리한다. ③ 그 결과는 다음 프레임에 그려진다.
패킷은 언제 도착하든 다음 틱까지 기다린다. 리시브 스레드가 아무리 빨라도 적용은 프레임 단위다. 리시브 스레드는 파싱까지만 하고, 액터를 건드리는 일은 게임 스레드 몫이다.
반대 방향은 입력, 게임 스레드 틱(입력 샘플, 예측 이동, 이동 패킷 생성), 송신 큐, 센드 스레드(send()), 소켓 순서다. 게임 스레드는 송신 큐에 넣고 바로 리턴하므로 send()에서 멈추지 않는다.
이동을 10Hz(100ms마다) 보내는 것이 목표여도 패킷은 그 시점 이후 첫 틱에서 만들어진다. 60fps에서는 틱이 16.7ms 간격이라 송신 간격이 100ms로 일정하다. 15fps에서는 틱이 67ms 간격이라 송신 간격이 133ms, 67ms로 번갈아 나온다.
송신 간격이 들쭉날쭉하면 다른 플레이어 화면에서 내 캐릭터가 끊겨 보인다. 입력도 다음 틱까지 기다리므로 15fps면 최대 67ms가 더해진다. 센드 스레드는 게임 스레드가 send()에서 멈추지 않게 해줄 뿐, 이 대기를 없애지는 못한다.
패킷이 도착해서 화면에 보이기까지의 시간을 네 구간으로 쌓는다. 네트워크(편도), 틱 끝까지, 렌더 스레드, GPU·화면이다.
60fps: 30 + 25 + 17 + 17 ≈ 88ms
15fps: 30 + 100 + 67 + 67 ≈ 263ms
"틱 끝까지"는 다음 틱 시작까지 평균 반 프레임을 기다리고, 그 틱 한 프레임이 끝날 때까지를 더한 값(평균 1.5프레임)이다. 렌더와 GPU는 각각 한 프레임씩이다. 같은 서버, 같은 회선인데 FPS만으로 화면에 보이기까지 약 175ms 차이가 난다.
이 지연은 서버 틱 시간이나 커널 RTT에는 잡히지 않는다. 영상은 이 수치를 평균·예시로 표시하고, 몇 프레임 뒤에 그려지는지는 엔진 설정에 따라 다르다고 적는다.
보스전 예시에서 세 레인의 실제 작업량은 게임 스레드 12ms, 렌더 스레드 15ms, RHI·GPU 64ms(보스 이펙트)다. GPU가 64ms를 쓰는 동안 렌더 스레드는 GPU를 기다리고, 게임 스레드는 렌더 스레드를 기다린다. 그래서 1프레임이 67ms(15fps)가 된다.
게임 스레드는 렌더 스레드보다 최대 1프레임만 앞서 갈 수 있다. 게임 스레드 자체 일은 12ms뿐이지만 프레임은 67ms가 된다.
- 틱 횟수: 초당 60에서 15로 준다.
- 꺼내는 양(틱당 200개, 예시): 초당 12,000에서 3,000으로 준다.
- 보스전에 들어오는 양은 초당 8,000(예시)이라 큐가 밀린다.
이펙트가 GPU를 잡고 있어도 게임 스레드가 같이 기다리므로 패킷 처리도 같이 느려진다.
GC나 동기 로딩으로 게임 스레드가 150ms 멈추는 장면이다. 그동안 리시브 스레드는 계속 받고, 수신 큐 숫자가 쌓여 올라간다. 언리얼 GC는 게임 스레드를 멈추지만 UObject를 만지지 않는 리시브 스레드는 계속 돈다.
멈춤이 풀리면 게임 스레드는 틱마다 200개씩(예시) 몰아서 처리한다. 아래 그래프에서 점선은 서버의 실제 위치, 실선은 화면 위치다. 멈춘 동안 화면 위치는 제자리에 있다가, 풀린 뒤에는 밀린 이동을 1.5배 속도로 빨리 감듯 따라간다.
이렇게 몰아서 적용하면 유저에게는 캐릭터가 멈췄다가 튀거나 빨리 움직이는 러버밴딩으로 보인다.
이 지연을 줄이는 방법 여덟 가지를 나열한다.
- 리시브 스레드에서 이동 합치기: 같은 대상의 이동은 최신 하나만 큐에 넣어, 밀려도 최신 상태로 보이게 한다.
- 처리 한도를 시간(ms)으로: 개수 대신 "틱당 2ms까지"로 잡으면 무거운 패킷이 섞여도 프레임이 일정하다.
- 우선순위: 내 캐릭터와 전투 결과를 먼저, 먼 대상의 이동은 나중에 처리한다.
- 스폰은 나눠서, 미리: 액터 풀과 비동기 로딩으로 보스전 스폰 히치를 막는다.
- GC 부담 줄이기: 증분 GC를 쓰고 패킷 처리 중
UObject 생성을 최소화한다.
- 보스전 GPU 부하 낮추기: 이펙트 LOD와 개수 상한으로 게임 스레드 대기를 줄인다.
- 이동은 타임스탬프와 함께: 받는 쪽이 보간 버퍼로 고르게 재생해 끊김을 완화한다.
- 수신 큐 지표 남기기: 큐 길이, 대기 시간, 프레임 시간을 기록한다. 큐가 상한에서 막히면 그 압력이 서버까지 간다.
클라 렉의 상당 부분은 게임 스레드가 패킷을 얼마나 빨리 적용하느냐에서 나온다. 수신 큐 길이, 패킷 대기 시간, 프레임 시간을 같이 찍어두면 서버 렉이나 회선 문제와 구분할 수 있다.
용어 정리
- 게임 스레드
- 언리얼에서 입력 처리, 게임 로직 틱, 패킷 적용을 하는 메인 스레드다. 액터를 바꿀 수 있는 유일한 스레드라 여기가 느려지면 모든 것이 느려진다.
- 렌더 스레드
- 게임 스레드가 넘긴 장면 정보를 받아 그리기 명령을 만드는 스레드다. 게임 스레드보다 한 프레임 뒤에서 돈다.
- RHI · GPU
- RHI(Rendering Hardware Interface)는 그리기 명령을 DirectX, Vulkan 같은 그래픽 API로 넘기는 층이고, GPU가 실제로 그린다. 영상에서는 렌더 스레드보다 또 한 프레임 뒤에 처리된다.
- 리시브 스레드 · 센드 스레드
- 소켓 입출력만 맡도록 게임 스레드와 따로 둔 스레드다. 게임 스레드가
recv()·send()에서 블록되지 않게 해준다. 언리얼 기본 NetDriver는 게임 스레드에서 소켓을 읽고 보내며, 이 영상의 두 스레드는 자체 소켓 모듈 기준이다.
- 수신 큐 · 송신 큐
- 네트워크 스레드와 게임 스레드 사이에 패킷을 넘기는 대기열이다. 수신 큐 길이가 늘어난다는 것은 게임 스레드가 받는 속도를 못 따라간다는 신호다.
- 틱 (Tick)
- 게임 로직을 한 번 갱신하는 단위다. 서버는 고정 간격(예: 20Hz, 50ms)으로 돌고, 이 영상에서 클라의 틱은 게임 스레드의 한 프레임이다.
- 프레임 · FPS
- 화면 한 장을 만드는 한 바퀴와, 1초에 그 바퀴를 몇 번 도는지다. 1프레임 시간은 1000 ÷ FPS ms로, 60fps는 16.7ms, 15fps는 67ms다.
- 액터 (UObject)
- 언리얼에서 캐릭터, 몬스터, 이펙트처럼 월드에 놓이는 객체다.
UObject는 그 기반 클래스이며 게임 스레드에서만 안전하게 다룰 수 있다.
- 핸들러
- 패킷 종류별로 실행되는 처리 함수다. 이동 패킷이면 액터 위치를 바꾸고, 스폰 패킷이면 액터를 만든다.
- 예측 이동
- 서버 응답을 기다리지 않고 클라가 입력을 바로 자기 캐릭터에 반영하는 것이다. 서버 결과가 오면 차이를 보정한다.
- GC (가비지 컬렉션)
- 쓰지 않는 객체 메모리를 회수하는 작업이다. 언리얼 GC는 게임 스레드를 잠시 멈추므로 객체가 많을수록 멈춤이 길다.
- 동기 로딩
- 에셋을 디스크에서 읽는 동안 게임 스레드가 기다리는 로딩 방식이다. 비동기 로딩은 백그라운드에서 읽어 멈춤을 피한다.
- 히치 (Hitch)
- 평소보다 훨씬 긴 프레임 하나가 튀는 현상이다. GC, 동기 로딩, 대량 스폰이 흔한 원인이다.
- 러버밴딩
- 화면 속 캐릭터가 멈췄다가 실제 위치로 끌려가듯 튀거나 빨리 움직이는 현상이다. 밀린 이동을 한꺼번에 적용할 때 생긴다.
- 보간 버퍼
- 받은 위치를 잠깐 모아두고 타임스탬프에 맞춰 사이 값을 채워 재생하는 버퍼다. 패킷 간격이 고르지 않아도 움직임이 매끄럽게 보인다.