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