
FishNet 공식 문서를 참고하여 정리한 글 입니다.
https://fish-networking.gitbook.io/docs/guides/features/prediction
FishNet을 사용하여 멀티플레이 게임 제작 시, 공식 문서를 제외하고는 정보량이 너무나도 부족하여 구현에서 어려움을 겪었던 부분을 중심으로 구현 방법에 대해 정리해보고자 한다.
멀티플레이 게임을 제작하기 위해서는 User Input과 Server에서의 처리 방식, User에게 반환하는 프로세스를 우선 인지해야 한다.
전제:
Server 권한 (authoritative)
목표: 치트 방지/일관성은 서버가 보장, 클라는보이는 것을 맞춤.
기준 단위 확정: Tick (서버 시뮬레이션 스텝)
- 서버의 게임 상태 업데이트 주기이다.
플레이어(Client)의 User Input
- 키/마우스/패드 등으로 이동, 점프, 공격등의 User Input Data를 생성
Client → Server: 입력 전송
- Client는 틱 N의 입력을 서버로 전송
- 지연(Latency) / 패킷 손실(packet loss) 대비 구현 (같은 입력 재전송, 최근 몇 틱 입력 묶어서 전송)
Server: 입력 검증 + authoritative 시뮬레이션
- 서버 단에서 유효성 검증, 물리/이동/게임 로직 연산 후
- 틱 N 처리 후의 authoritative 결과 상태를 생성
- 이 결과는 "서버가 확정한 진실"이다.
Server → 각 Client: 상태/이벤트 배포
Server는 4에서 생성된 결과를 두 종류로 Client에 뿌린다.
a) 소유자 (Owner) Client
= User Input 넣은 클라
- 네가 보낸 입력을 서버가 처리한 결과(정답 상태)
b) 다른 관전자 (Observers) Client
= 서버에 존재하는 그 외 클라
- "해당 입력을 넣은 플레이어는 현재 (x)위치, (v)속도다" 같은 상태 복제 (State Replication)
- "점프/총 발사/휘두르기"와 같은 이벤트 복제 (Event Replication)
각 Client: 표시/보간/예측 처리
a) 소유자 (Owner) Client
- 1) 서버 결과를 기다리면 조작감 bad (부드럽긴 하지만, 입력 후 1,2초 뒤에 반응하는 게임들이 이 방식으로 구현된 것)
- 2) Client 예측 (Client-side Prediction) 사용
b) 관전자 (Observers) Client
- 보통 Server 상태를 조금 늦게 받으니 보간(interpolation)으로 부드럽게 보여줌.
자 그럼, 일단 Client의 입력에 대한 Server에서의 처리 및 다시 각 Client에 뿌려주는 프로세스까지 위에서 살펴봤다. '문제 없는 것 같은데?' 라고 생각한다면 큰 오산이다.
6-a) 소유자(Owner) Client에서 아래와 같은 문제가 있다.
> 서버 결과를 기다리면 입력에 대한 반응이 너무 느림
FPS게임 같은 즉각적인 반응을 요구하는 게임에서 유저의 입력이 위와 같이 처리된다면, 적을 발견하고 즉시 반응하더라도 한참 뒤에 총알이 발사될 것이고, 당연히 유저는 괴리감을 느낄 수 밖에 없다.
그래서 등장하는 게 Prediction(예측) + Reconciliation(보정) (= “일단 클라인 내가 먼저 움직이고, 나중에 서버가 틀렸으면 고친다”)이고, FishNet의 Prediction 시스템은 이 흐름을 프레임이 아니라 네트워크 틱 기반으로 표준화해서 제공한다.
FishNet을 통해 Owner 클라의 입력에 대해 Prediction + Reconciliation "내가 먼저 움직이고, 나중에 틀렸으면 서버가 고친다"는 개념을 네트워크 틱 기반으로 구현가능하다고 했다.
Prediction 구현은 Replicate method와 Reconcile method로 만든다.

Replicate 는 '점프/이동/공격' 등의 행위를 담아 실행하는 곳이고 (시뮬레이션), State Forwarding을 켜면 다른 Client에서도 동일 입력 로직을 실행할 수 있다.
Reconcile은 'Replicate 수행 후의 상태 (state)'를 담아 오차를 정정하는 곳이다. (ex. 위치/회전/속도/체력 등)
(A) “발사 버튼을 눌렀다” = 입력(Input)
이건 Replicate 쪽(입력 기반) 에 들어갈 수 있는 대표적인 것. (행위)
즉, Replicate의 데이터에 FirePressed 같은 값이 들어갈 수 있다.
(B) “총알이 실제로 맞았는가 / 데미지가 들어갔는가” = 판정/결과(Result)
이건 거의 항상 서버 단독(authoritative) 으로 처리하는 게 정석이다.
이유: 치트/일관성/동시성
(C) “사운드/이펙트/애니메이션”
1. Owner 로컬에서 즉시 재생(쾌감/반응성) + (필요하면) 서버가 “정식 발사 확정”을 주면 보정
2. 관전자에게는 서버가 ‘발사 이벤트’를 따로 브로드캐스트(ObserversRpc 등) 하거나
state forwarding을 켰으면 관전자도 Replicate로 “발사 시뮬레이션”을 어느 정도
같이 돌릴 수 있음
< 결론 >
Replicate = 틱 N에 발사 버튼이 눌림(입력)을 기반으로, 그 틱에서 할 수 있는 즉시 반응
(예: 반동, 로컬 탄약 감소, 발사 애니 트리거, 로컬 가짜 탄환/트레이서 생성 등)을 ‘시뮬레이션’
Reconcile = 서버가 확정한 결과 상태
(예: 탄약 수, 실제 발사 성공 여부, 발사 시점/총알 시드, 캐릭터 상태 등)를
Owner에게 보내서, Owner의 예측이 틀렸으면 고쳐주는 것
서버 = 진짜 발사 판정/탄약 소비 확정/히트 판정/데미지 적용 (정답 생성)
3-1) Replicate (입력 기반 시뮬레이션)
User Input으로 인해 생성된 “Tick N의 입력 데이터”를 인자로 받는 함수
이 함수 안에서 실제 이동/점프/물리힘 적용 같은 "1 Tick"만큼을 수행한다.
호출/실행 주체는 설정에 따라 달라짐:
1) 기본: Owner + Server
2) Enable State Forwarding: Owner + Server + Other Clients(관전자)
## 기본(Owner + Server)
1) Owner의 Replicate 실행 이유
Owner는 입력을 받자마자 “내 화면에서” `즉시 결과`를 보여줘야한다. (RTT 기다리면 늦어짐)
그래서 Owner는 서버 응답 오기 전에 Replicate로 “내가 움직였다/발사했다”를 먼저 시뮬레이션 한다.
2) Server의 Replicate 실행 이유
목적: 권한 있는 `정답 시뮬레이션(Authoritative simulation)`
서버도 “같은 입력”으로 동일한 로직을 돌려서,
`실제로 가능한지, 충돌/물리 결과가 뭔지, 그리고 최종 상태가 뭔지`를 확정해야 한다.
그리고 이 서버 결과가 `정답(ground truth)` 이다.
> Owner의 Replicate = “빠른 가짜(예측)”
Server의 Replicate = “권한 있는 진짜(정답)”
## State Forwarding ON (Owner + Server + Observers)
3) 관전자(Observers)가 Replicate를 실행하는 이유
“관전자도 입력 기반 시뮬레이션을 같이 돌려서, 서버가 상태를 보내주기 전에도
'더 자연스럽고 일관되게 보이게'하기 위해서”
State forwarding OFF:
관전자는 “입력”을 모르거나 실행하지 않으니,
서버가 보내주는 “결과 상태(위치/발사 이벤트/애니 트리거 등)”를 받아서 보간/스무딩으로만 보여준다.
ON:
관전자도 “틱 단위로 들어오는 입력/상태 전달 흐름”을 통해 Replicate를 실행할 수 있어서,
발사 타이밍, 반동/애니/이펙트, (경우에 따라) 투사체 비주얼 같은 것들을 더 같은 로직으로
재현하기가 쉬워진다.
< 주의 >
관전자가 Replicate를 실행한다고 해서 관전자가 권한을 갖는 게 아니다!
그건 어디까지나 표시/시뮬레이션을 “더 비슷하게” 해보는 것이고, 최종 판정은 서버가 가진다.
3-2) Reconcile (보정 - 서버 정답으로 맞추기)
서버가 만든 authoritative 결과 (정답) 상태를 Owner(및 필요 시 관전자)에게 전달하고,
클라는 서버 상태로 “리셋”한 다음, 저장해둔 입력을 다시 돌려(리플레이) 현재로 따라잡는다.
즉, “조작감은 즉시 반응 + 권한은 서버”를 동시에 만족시키기 위한 장치.
(1) “서버가 만든 정답 상태”?
서버가 authoritative simulation 을 돌리고 난 뒤에 얻은 결과 상태 (정답)
ex1 (이동): 틱 100 처리 후 서버가 확정한 position, rotation, velocity
ex2 (발사): 틱 100에 발사가 성공했는지/실패했는지, 탄약이 실제로 몇 발 줄었는지
즉, “Replicate에서 server가 실행해서 나온 결과” = 서버 정답 상태(그 틱의 ground truth)
(2) “이걸 왜 Owner에게 뿌리나?”
Owner는 기다리기 싫어서 예측을 했기 때문에 틀릴 수 있다.
서버와 완전히 동일한 물리/충돌/지연 조건이 Owner에서 100% 재현되지 않으면,
“나는 맞았다고 봤는데 서버는 빗나감”, “나는 여기까지 갔다고 봤는데 서버는 벽에 걸림”
같은 오차가 생긴다.
그래서 서버는 Owner에게 “정답 상태”를 보내서 오차를 보정시킨다.
(3) “리셋”은 뭘 한다는 뜻?
Owner가 예측으로 만들어낸 상태가 있고 (Owner Replicate)
서버가 보내준 정답 상태가 있으면, (Server Replicate)
Owner는 일단 자신의 상태를 서버가 보내준 값으로 강제로 맞추는 작업 (Server Replicate으로 맞춤)
ex) transform.position = serverPosition
(5) “저장해둔 입력”?
Owner는 예측을 하는 동안 현재 Tick까지 입력을 쌓아둔다
틱 100 입력(앞으로 이동)
틱 101 입력(계속 이동)
틱 102 입력(점프)
…
(서버 응답이 늦게 오기 때문)
(6) “리플레이”, "따라잡기"는 왜?
상황을 예로 들어보자 (RTT가 있어 서버 응답이 늦음)
Owner 입력/예측(localTick):
200: Fire 입력 발생 → (즉시) 예측 발사 반응 렌더/사운드/반동 (Replicate 200)
201: 예측 진행 (Rep 201)
202: 예측 진행 (Rep 202)
203: 예측 진행 (Rep 203)
204: 예측 진행 (Rep 204)
205: 현재 상태 렌더 중 (Rep 205)
Server(권한 처리):
... (지연) ...
200 입력 수신 → authoritative로 틱200 처리 → "틱200 정답 상태" 생성 → 전송
Owner가 정답 수신(대략 localTick 205 즈음):
- 화면을 'Tick 200'으로 되감아 보여주지 않음
- 내부에서만:
1) Tick 200 상태를 서버 정답으로 덮어쓰기(리셋)
2) 저장해둔 입력(201~205)을 빠르게 다시 실행(리플레이)
- 그 결과로 "정답 기반의 Tick205 상태"를 만든 뒤,
- 다음 프레임부터 계속 그 상태를 렌더링 (튀김 최소화)
총 발사 예시로 “리셋 + 201~현재 입력 리플레이”를 아주 구체적으로
가정:
Owner A가 Tick 200에 총 발사 버튼을 누름 (네트워크 Latency - 서버 결과는 5틱 뒤에 도착)
(1) Tick 200: Owner A에서 일어난 일 (Owner Replicate = 예측)
Owner A는 틱 200 입력을 만든다
{
FirePressed = true,
AimDir = ...,
MoveInput = ...
}
그리고 Replicate(Tick 200) 를 로컬에서 즉시 실행한다. (예측)
화면 즉시 반응용
- 총구 섬광(이펙트) 보여줌
- 반동 애니/카메라 흔들림
- “딸깍” 사운드
동시에 이 입력(Tick 200)을 서버로 보낸다.
(2) Tick 200~현재(205): Owner A는 계속 게임을 진행
Owner는 201,202,203,204,205… 입력도 계속 만들고,
각 틱마다 Replicate를 실행해 “끊김 없이” 플레이한다.
이때 Owner는 내부적으로 이런 걸 저장해둔다:
InputHistory[200] = { FirePressed=true, ...}
InputHistory[201] = { ... }
…
InputHistory[205] = { ... } (현재 틱)
(3) 서버가 Tick 200 입력을 처리하고, “정답 상태”를 만든다.
서버는 Tick 200 입력으로 authoritative 로직을 실행 (Server Replicate)
- 정말 발사 가능한가? (쿨다운/탄약/상태)
- 탄약이 실제로 감소했는가?
- 히트스캔이면 레이캐스트로 피격 판정 + 데미지 적용
- 투사체면 서버가 “진짜 총알” 스폰/시뮬
그리고 서버는 “Tick 200 처리 후의 정답 상태”를 만든다.
{
ammo = 29 (정답),
fireAccepted = true/false,
(필요하면) spreadSeed, projectileId, muzzleTick=200 같은 재현 정보,
(이동까지 함께면) position/velocity도 포함
}
(4) Owner A가 서버로부터 Reconcile(Tick 200 정답)을 받는다.
4-A) “Tick 200 상태를 서버 정답으로 리셋”
Owner A가 Tick 200 시점의 상태를 서버 정답으로 강제 교체. (본인 예측 버림)
ex: “내 로컬 (Owner)에선 발사 성공해서 ammo=29라고 생각했는데,
서버는 벽에 막혀 발사 실패라 ammo=30이다” 같은 차이가 있으면,
Tick 200의 ammo/상태를 서버 값으로 강제 교체.
4-B) “Tick 201~205까지 저장 입력(InputHistort[n])을 리플레이해서 현재로 복구”
Owner는 지금 화면상 이미 Tick 205까지 와 있는 상태.
그런데 “Tick 200 상태”를 정답으로 바꿔버리면, 논리적으로 현재 상태(Tick 205)는 깨진다.
(예측 기반으로 200~205까지 진행했기 때문)
그래서 Owner는
(되감기) 틱 200 정답 상태로 맞춤(리셋)
(재실행) 저장해 둔 입력들을 다시 적용
Replicate(틱 201 입력) 실행 → 틱 201 상태 계산
Replicate(틱 202 입력) 실행 → 틱 202 상태 계산
…
Replicate(틱 205 입력) 실행 → 다시 “현재 틱 205 상태”에 도달
이게 “현재로 따라잡는다”의 정확한 뜻!
< 중요한 개념 >
화면을 200으로 보여주고 201~205를 눈에 띄게 다시 보여주는 게 아니라,
내부적으로 순식간에 201~205를 ‘계산만 다시 해서’ 현재 상태를 복구하는 거야. (오차 없애기 위함)
공식 문서 참고
https://fish-networking.gitbook.io/docs/guides/features/prediction/configuring-networkobject
직접적인 코드 작성과 언제 어떤 상황에 대해 Enable State Forwarding을 ON/OFF하는지 이어서 작성하겠다.
https://velog.io/@khkim09/FishNet-Prediction-Reconcile-2