delay-based와 rollback 넷코드
실시간 멀티플레이어가 네트워크 지연을 숨기는 전략 입력을 늦추는 delay-based와 예측 후 되감는 rollback을 원리부터 비교
delay-based와 rollback은 다른 기술이 아니라, 같은 결정론 토대 위에서 원격 입력을 '기다리느냐 예측하느냐'로 갈리는 두 전략입니다.
왜 넷코드가 필요한가
실시간 멀티플레이어의 핵심 난제는 여러 기계가 같은 게임 세계를 보게 만드는 상태 동기화입니다. 어려운 이유는 네트워크가 불안정하기 때문입니다. 보낸 정보는 지연되거나, 순서가 뒤바뀌거나, 사라질 수 있습니다.
세 결함을 구분해 두면 이후 설명이 쉽습니다. 지연(latency)은 패킷이 한 기계에서 다른 기계까지 가는 시간이고, 손실은 UDP에서 패킷이 사라지는 것, 지터(jitter)는 지연의 분산입니다.
Glenn Fiedler는 "인터넷은 초당 60번 보낸 패킷이 1/60초 간격으로 곱게 도착한다고 보장하지 않는다"고 설명합니다(Deterministic Lockstep). 평균 핑이 같아도 도착 간격이 출렁이면 화면이 끊겨 보입니다.
입력만 보내는 lockstep과 결정론
초기 P2P 게임은 게임 상태 전체가 아니라 각 플레이어의 입력만 교환했습니다. 이것이 락스텝(lockstep)입니다. 1993년 Doom은 1 tic(약 28.57ms)마다 입력을 모든 피어에 보내고, 모든 입력이 도착해야 그 tic을 진행했습니다.
입력만 주고받는데도 모든 기계가 같은 화면을 보려면 전제가 하나 필요합니다. 같은 시작 상태에 같은 입력을 먹였을 때 모두가 비트 단위로 같은 결과를 내야 합니다.
이것이 결정론(determinism)입니다. 이 원칙이 깨지면 한 기계의 미세한 차이가 시간이 지나며 완전한 desync(동기화 깨짐)로 번집니다.
결정론이 보장되면 네트워크로는 입력만 보내면 되고, 대역폭이 오브젝트 수가 아니라 입력 크기에 비례합니다. 유닛이 수천 개인 실시간 전략 게임(real-time strategy, RTS)이 지금도 이 모델을 쓰는 이유입니다.
여기까지가 두 방식의 공통 토대입니다. 게임은 고정 프레임레이트로 돌고(격투게임 60fps, 1프레임 ≈ 16.67ms), 시뮬레이션은 결정론적이며, 네트워크로는 입력만 보냅니다. 갈림길은 단 하나, 원격 입력이 도착할 때까지 무엇을 하느냐입니다.
위 그림은 두 전략의 결정 지점을 한눈에 보여줍니다. delay-based는 기다리고, rollback은 예측한 뒤 틀린 경우에만 되감습니다.
delay-based 넷코드
delay-based는 결정론적 락스텝에 입력 지연 버퍼를 얹은 형태입니다. 핵심은 자신의 입력조차 의도적으로 몇 프레임 늦춰 실행하는 것입니다. 내 입력을 즉시 반영하지 않고 D프레임 뒤에 실행하기로 약속하면, 그 D프레임 동안 입력 패킷이 상대에게 도착할 시간이 생깁니다.
결과적으로 양쪽 시뮬레이션은 매 프레임 두 입력이 모두 갖춰진 상태에서만 한 프레임씩 전진합니다. 추측도 되감기도 없으니 양쪽 상태는 항상 동일합니다. 지연 프레임 수는 편도 지연을 프레임 시간으로 나눠 정합니다.
핑이 90ms이면 편도는 평균 45ms이고, 45ms ÷ 16.67ms ≈ 약 3프레임의 입력 지연이 필요합니다(왕복이 대칭이라는 가정의 계산 예시). 지터를 견디려 1~2프레임을 더 얹기도 합니다.
장점은 분명합니다. 상태 저장이 필요 없어 구현이 단순하고, 전제는 결정론 하나뿐이며, 시각적 깨짐이 없습니다. Age of Empires는 유닛 상태 대신 명령만 전달해 28.8k 모뎀에서 1500유닛을 동기화했습니다(Bettner & Terrano, GDC 2001).
단점은 모두 "모든 입력이 도착할 때까지 진행할 수 없다"는 동기 제약에서 나옵니다. 핑이 높을수록 입력 랙이 그대로 체감되고, 패킷이 늦거나 유실되면 화면 전체가 멈추며(stutter), 전체 속도가 가장 느린 피어에 묶입니다. 콤보처럼 프레임 단위 타이밍이 생명인 격투게임에서 이 입력 랙은 치명적입니다.
rollback 넷코드
rollback은 아직 도착하지 않은 상대 입력을 예측해 시뮬레이션을 진행합니다. 예제에서는 로컬 입력 지연을 0으로 두고 상대의 직전 입력을 반복합니다. 격투게임은 입력이 매 프레임 바뀌지 않으므로 이 단순 가정의 적중률이 높습니다. 네 부분이 맞물려 돌아갑니다.
- 입력 예측(prediction): 상대 입력이 오기 전 직전 입력을 가정해 진행합니다. 직전 입력이 중립일 때만 무행동을 예측합니다.
- 상태 스냅샷(state snapshot): 매 프레임의 게임 상태 전체를 기록해 둡니다. GGPO(Good Game Peace Out)의
save_game_state콜백이 상태 구조체를 통째로 복사하는 부분입니다. 이것이 흔히 말하는 '롤백 레코드'의 실체입니다. - 입력 링버퍼(ring buffer): 과거 입력과 스냅샷을 롤백 윈도우 크기로 고정한 원형 버퍼에 보관합니다. 롤백 가능한 범위와 아직 확정되지 않은 입력을 모두 담을 수 있도록 용량을 정합니다.
- 재시뮬레이션(re-simulation): 실제 입력이 예측과 다르면, 일치하던 가장 최근 프레임의 스냅샷으로 되감아 실제 입력으로 현재 프레임까지 다시 돌립니다. 예측이 맞았으면 아무 일도 하지 않습니다.
구현
상태가 정수 위치 하나뿐인 결정론적 시뮬레이션으로 예측·롤백·재시뮬을 확인합니다. 상대 입력은 delay 프레임 늦게 도착하고, 그동안은 직전 확정 입력으로 예측합니다. 예측이 빗나가면 스냅샷으로 되감아 다시 돌립니다.
NEUTRAL = 0
def step(pos, p1, p2):
return pos + p1 + p2
def run(frames, p1, p2, delay):
if frames < 0 or delay < 0 or len(p1) != frames or len(p2) != frames:
raise ValueError("invalid frame count, input length, or delay")
snap = [0] * frames
used_p2 = [None] * frames
pos = 0
confirmed = NEUTRAL
rollbacks = 0
# Finish receiving inputs even after simulation stops advancing.
for f in range(frames + delay):
arrived = f - delay
simulated = min(f, frames)
if 0 <= arrived < frames:
confirmed = p2[arrived]
if arrived < simulated and used_p2[arrived] != confirmed:
rollbacks += 1
pos = snap[arrived]
for g in range(arrived, simulated):
snap[g] = pos
used_p2[g] = confirmed
pos = step(pos, p1[g], confirmed)
if f < frames:
snap[f] = pos
used_p2[f] = confirmed
pos = step(pos, p1[f], confirmed)
return pos, rollbacks
p1 = [1, 1, 1, 1, 1, 0, 0]
p2 = [0, 0, 1, 1, 0, 0, 0]
assert run(7, p1, p2, 0) == (7, 0)
assert run(3, [0, 0, 0], [0, 0, 1], 2)[0] == 1
print(run(7, p1, p2, 2))frames는 실행할 프레임 수이고, p1과 p2는 각 프레임의 입력입니다. delay는 상대 입력의 도착 지연이며, 예제는 손실과 순서 역전을 가정하지 않습니다.
마지막 프레임 이후에도 남은 입력을 받아야 최종 상태가 확정됩니다. 위 코드를 Python으로 실행하면 (최종 위치, 롤백 횟수)가 다음과 같이 출력됩니다.
(7, 2)상대 입력이 2프레임 늦게 도착하므로 예측이 두 번 빗나가고, 그때마다 해당 프레임으로 되감아 2프레임씩 다시 돌립니다. 모든 입력이 확정된 뒤 최종 상태(7)는 지연이 전혀 없었을 때의 정답(7)과 비트 단위로 일치합니다. 결정론이 보장되면 되감아 다시 돌려도 같은 결과로 수렴한다는 것이 rollback의 정확성 근거입니다.
| 기록 | 의미 | 예제의 보관 범위 |
|---|---|---|
snap[f] | 프레임 f 실행 직전 상태 | 전체 실행 프레임 |
used_p2[f] | 실제 적용한 상대 입력 | 전체 실행 프레임 |
confirmed | 가장 최근 도착한 상대 입력 | 값 하나 |
예제는 구조를 보이기 위해 고정 길이 배열을 사용합니다. 실시간 구현에서는 확정되어 다시 쓰지 않을 기록을 버리고 제한된 버퍼를 재사용합니다.
비용과 체감
모든 재시뮬레이션은 화면 한 프레임 예산(60Hz = 16.66ms) 안에 끝나야 합니다. NetherRealm의 발표 제목 '8 Frames in 16ms'가 이 뜻입니다.
7프레임을 되감아 7개의 경량 시뮬레이션과 1개의 렌더를 한 프레임 안에 처리합니다(Stallone, GDC 2018). 예산을 넘기면 다음 프레임에 더 많이 되감아야 하는 죽음의 나선(spiral of death)에 빠집니다.
입력 지연을 낮게 설정하면 오프라인에 가까운 반응성을 얻을 수 있습니다. 다만 GGPO API는 ggpo_set_frame_delay로 입력 지연을 설정할 수 있습니다. 롤백이 항상 0지연이라는 뜻은 아닙니다.
예측이 틀리면 그 입력에 의존한 상태 전체가 보정될 수 있습니다. 저장한 롤백 범위를 넘어 입력이 늦으면 진행을 기다려야 하며, 보정 정도는 게임 상태와 네트워크 조건에 달려 있습니다.
GPU 파티클이나 사운드는 재실행할 때 중복 효과를 낼 수 있습니다. 게임 결과에 영향을 주지 않는 표현은 시뮬레이션과 분리하고 재생 여부를 관리합니다.
두 방식 비교
| 축 | delay-based | rollback |
|---|---|---|
| 원격 입력 대기 | 도착까지 대기(내 입력도 D프레임 지연) | 예측해 즉시 진행, 틀리면 재시뮬 |
| 로컬 반응성 | 핑에 비례해 나빠짐 | 설정한 입력 지연 적용 가능 |
| 두 클라이언트 상태 | 매 프레임 항상 동일 | 예측 중에는 다를 수 있음 |
| 시각적 아티팩트 | 없음 | 예측 빗나가면 pop(대개 미미) |
| 패킷 지연·유실 시 | 화면 전체가 멈춤 | 상태 보정, 롤백 범위 초과 시 대기 가능 |
| 필요 자료구조 | 입력 버퍼만 | 상태 스냅샷 + 입력 링버퍼 |
| CPU 비용 | 낮음 | 높음(재시뮬, 나선 위험) |
| 잘 맞는 곳 | RTS, 저지연 LAN | 격투게임, 핑 변동 큰 인터넷 |
트레이드오프는 한 줄로 요약됩니다. delay-based는 일관성을 위해 반응성을 희생하고, rollback은 반응성을 위해 순간적 일관성을 희생합니다.
어디에 쓰이는가
넷코드는 격투게임만의 것이 아닙니다. 진실을 누가 소유하는가로 크게 세 계열로 나뉩니다.
P2P 결정론 계열(격투의 rollback, RTS의 lockstep) 은 중앙 서버 없이 결정론 자체를 동기화 수단으로 씁니다. 격투게임은 2020년 코로나19로 대회가 온라인으로 옮겨가며 rollback이 사실상 표준이 됐습니다. Guilty Gear Strive(2021), Street Fighter 6(2023), Tekken 8(2024)이 모두 출시 시점 rollback으로 나왔습니다. RTS는 유닛이 수천 개라 상태 스냅샷이 비현실적이므로 lockstep을 유지합니다.
권위 서버 계열(FPS, MOBA) 은 다릅니다. 1인칭 슈팅 게임(first-person shooter, FPS)은 서버가 유일한 진실을 쥐고 클라이언트를 신뢰하지 않으며, 기계 간 결정론이 필요 없습니다. 대신 클라이언트측 예측, 서버 조정(reconciliation), 지연 보상(lag compensation), 엔티티 보간을 쌓습니다(Valve Source 문서). 이 예측은 rollback과 닮았지만, FPS는 자기만 예측하고 서버가 확정하면 그쪽으로 보정될 뿐이라는 점이 다릅니다.
선택을 가르는 변수는 플레이어 수, 동기화 단위 규모, 트위치 반응 요구, 부정행위 모델입니다. 격투는 2인에 작은 상태라 rollback, RTS는 수천 유닛이라 lockstep, FPS는 상금이 걸린 경쟁과 안티치트 때문에 권위 서버를 고릅니다.
직접 구현할 때의 벽
GGPO의 세 전제(완전 결정론, 직렬화 가능한 상태, 렌더 없이 1프레임 save/load/advance)는 각각 별개의 난제로 갈라집니다.
결정론이 가장 깊은 벽입니다. 부동소수점 연산은 컴파일러와 CPU에 따라 미세하게 달라 desync를 냅니다.
그래서 다수의 결정론 게임이 부동소수점을 버리고 고정소수점으로 시뮬레이션을 다시 씁니다. 난수 생성기(random number generator, RNG)의 내부 상태도 저장 상태에 포함해야 같은 프레임을 몇 번 되감아도 같은 난수열이 재생됩니다.
상태 직렬화는 자료구조 설계 문제입니다. 이상적 형태는 전체 게임 상태를 하나의 큰 구조체에 연속 배치해 복사 한 번으로 저장·복원하는 것입니다.
동적 할당이 있으면 포인터를 인덱스로 바꿔야 합니다. 매 틱 전체 스냅샷이 비싸면 변경분만 저장하는 증분 롤백으로 비용을 줄입니다.
이 전제들이 코드 전반에 스며들기 때문에 나중에 끼워넣기가 매우 어렵습니다. NetherRealm은 Mortal Kombat X에 rollback을 후행 이식하며 약 10개월에 8 man-year를 썼다고 보고했습니다(직렬화에만 2 man-year).
반대로 처음부터 GGPO를 전제로 설계한 Skullgirls의 Mike Zaimont는 약 2주 만에 통합했다고 소개했습니다(Stallone, GDC 2018). 같은 기능이 설계 시점에 따라 2주와 8 man-year로 갈립니다.
정리
delay-based와 rollback은 결정론적 시뮬레이션에서 원격 입력을 기다리거나 예측하는 두 방식입니다. rollback은 예측이 틀렸을 때 저장한 상태와 입력으로 다시 실행하며, 마지막 입력까지 확인해야 최종 상태가 일치합니다.
입력 지연 설정과 롤백 범위에 따라 반응성과 재실행 비용이 달라집니다. 플레이어 수와 상태 규모, 결정론 보장, 부정행위 대응 요구를 함께 검토해야 구현 방식을 정할 수 있습니다.