개념은 정말 단순하다.
클라이언트가 '어떤 시간'에 적을 쏴서 적중했다고 패킷을 통해 주장하면, 서버는 적중당했다고 추정되는 캐릭터의 위치를 '어떤 시간'으로 되돌려서 실제로 맞았는지 확인한다.
왜 이런 개념이 등장했는가?
클라이언트간 비동기 때문이다.
클라이언트는 다른 캐릭터에게 데미지를 줄 때 서버에 요청한다.
만약 서버가 검증 없이 데미지를 적용한다면, 클라이언트를 해킹해 0.1초만에 데미지 패킷을 수십 개 보낼 수도 있다.
이 얼마나 끔찍한 일인가?
결국 서버는 검증을 해야 한다.
어떻게?
라인 트레이스로.
클라이언트가 적중했다고 주장하면, 서버는 클라이언트가 했던 라인 트레이스랑 똑같이 재현해본다.
거기 적 없는데?
빗나갔다.

(위 자신은 클라이언트 예측이 적용된 경우)
내가 적중했다고 아무리 주장해봐야, 그 패킷이 서버에 도달할 쯤이면 캐릭터들은 위 사진보다 더 어긋나있다.
이는 슈터 장르 용어인 '피커스 어드밴티지' 와도 관련이 있으며, 클라이언트 예측이 적용된 게임에서 크게 체감해볼 수 있다.
즉, 서버 되감기(Server-Side Rewind)가 적용되지 않은 게임은, 플레이어가 자신의 핑 상태를 생각해 보이지도 않는 상대 위치에 리드샷을 해야 한다는 거다.
그럼 어떤 결과로 이어지겠는가?
접는다.
유저는 줄어들고, 게임은 망한다.
그렇게 둘 순 없다!
유저가 접기 전에 SSR을 적용해야 한다.
알고리즘 자체는 이해하기 쉬웠지만 실제로 적용해보자니 꽤 어려웠다.
~코드 생략~
/**
* 클라이언트가 서버에 적중 사실 확인을 요청하는 함수
*
* @param HitCharacter 공격에 의해 잠재적으로 적중된 캐릭터
* @param TraceStart 적중 여부를 판단하기 위해 사용될 라인 트레이스의 시작 지점
* @param HitLocation 적중이 발생했을 것으로 예상되는 지점
* @param HitTime 적중이 발생했을 것으로 믿어지는 시점
*/
UFUNCTION(Server, Reliable)
void ServerScoreRequest(
ABlasterCharacter* HitCharacter,
const FVector_NetQuantize& TraceStart,
const FVector_NetQuantize& HitLocation,
float HitTime);
/**
* 서버 상태를 되돌려 특정 시점에서 주어진 공격에 의해 캐릭터가 맞았는지 판단합니다
* 이 함수는 저장된 프레임 패키지를 사용하여 히트박스의 위치를 보간하고 잠재적 충돌을 평가합니다
*
* @param HitCharacter 공격에 의해 잠재적으로 적중된 캐릭터
* @param TraceStart 적중 여부를 판단하기 위해 사용될 라인 트레이스의 시작 지점
* @param HitLocation 적중이 발생했을 것으로 예상되는 지점
* @param HitTime 적중이 발생했을 것으로 믿어지는 시점
* @return 적중 여부와 헤드샷 여부를 나타내는 결과를 반환
*/
FServerSideRewindResult ServerSideRewind(
ABlasterCharacter* HitCharacter,
const FVector_NetQuantize& TraceStart,
const FVector_NetQuantize& HitLocation,
float HitTime);
// 이 컴포넌트를 가진 Character의 HitBox 역할을 하는 BoxComponent들의 위치를 저장하는 함수
void SaveFramePackage(FFramePackage& Package);
// 아래 함수들을 거의 모두 호출하며, HitTime에 HitCharacter가 어디 있었는지 최종 반환하는 함수
FFramePackage GetFrameToCheck(ABlasterCharacter* HitCharacter, float HitTime);
// HitTime을 사이에 두고 있는 2개의 FramePackage의 그 사이, HitTime의 FramePackage를 구하는 함수
FFramePackage InterpBetweenFrames(const FFramePackage& OlderFrame, const FFramePackage& YoungerFrame, float HitTime);
// 현재 프레임의 BoxPosition을 잠시 저장하는 함수
void CacheBoxPositions(ABlasterCharacter* HitCharacter, FFramePackage& OutFramePackage);
// 현재 프레임의 BoxComponent들을 특정 위치로 옮기는 함수 (라인 트레이스를 통해 Hit 판정을 보기 위해 되감기한다)
void MoveBoxes(ABlasterCharacter* HitCharacter, const FFramePackage& Package);
// BoxComponent를 잠시 특정 위치로 옮겼으므로, 라인 트레이스가 끝나면 다시 되돌린다.
void ResetHitBoxes(ABlasterCharacter* HitCharacter, const FFramePackage& Package);
// 캐릭터 Mesh의 CollisionEnabled를 조정하는 함수
void EnableCharacterMeshCollision(ABlasterCharacter* HitCharacter, ECollisionEnabled::Type CollisionEnabled);
// SSR후 적중 결과를 반환하는 함수
FServerSideRewindResult ConfirmHit(
const FFramePackage& Package,
const FVector_NetQuantize& TraceStart,
const FVector_NetQuantize& HitLocation);
// BoxComponent의 현 상태를 FrameHistory에 담는 함수, Tick에서 호출
void SaveFramePackage();
이런 느낌의 구성이다.
게시글이 너무 길어지므로 구현 내용은 생략하겠다.

낙사로 사망한 게 아니다.
점프력이 엄청 높은 상태고, 디버깅 편의를 위해 데미지를 100으로 높여놨다.
핑이 800인 상태의 클라이언트가 움직이는 적을 맞혔고, 서버는 적중당한 것으로 추정되는 캐릭터의 히트박스를 적중 시점의 위치로 되돌려 실제 적중 여부를 검증했다.
결과는 성공적으로 적중했다고 판명됐고, 게임 플레이에 반영되었다.
이걸로 멀티 플레이어, 특히 PVP 장르에서 정말 중요한 SSR의 개념을 알게 되었다.