사전 게임 상태 직렬화
개념

게임 상태 직렬화

gabury1고친 사람 github-actions[bot]

게임 상태 직렬화는 지금 돌아가는 게임 세계를 바이트 한 줄로 받아 적는 일입니다. 받아 적은 바이트로 나중에 같은 세계를 다시 세웁니다. 세이브 파일과 멀티플레이 게임이 이 일에 기댑니다.

쉽고 빠른 이해

무슨 일을 하나 — 게임 세계의 지금 모습을 바이트로 적어 둡니다. 캐릭터 위치와 체력, 몇 번째 순간인지까지 적으면 나중에 그 순간부터 게임을 다시 이어 갈 수 있습니다. 쓰는 곳은 넷입니다 — 세이브 파일, 지난 순간으로 되감기, 다른 컴퓨터로 보내기, 두 컴퓨터의 세계가 같은지 검사하기.

왜 이렇게 하나 — 게임 세계는 메모리 안에만 있습니다. 바이트로 적어 두지 않으면 게임을 끌 때 사라집니다. 다른 컴퓨터로 보낼 수도 없습니다.

어떻게 도나

  1. 다음 순간을 계산하는 데 필요한 값만 고릅니다. 화면에 그린 그림처럼 보여 주기만 하는 값은 적지 않습니다
  2. 서로를 가리키는 메모리 주소를 번호로 바꿉니다
  3. 정해 둔 순서대로 바이트에 적습니다

대가 — 상태에 값을 하나 더할 때마다 적고 읽는 코드도 손봐야 합니다. 빠뜨리면 다시 세운 세계가 원래와 조금 달라집니다.

상세

보드게임 사진 한 장으로 뜻을 먼저 잡습니다. 그다음 무엇을 적는지, 어디에 쓰는지, 적을 때 무엇이 걸리는지를 차례로 봅니다.

보드게임 판을 찍은 사진

보드게임을 하다가 밤이 늦었습니다. 판을 그대로 둘 수 없어서 휴대폰으로 판을 찍습니다. 말은 상자에 담습니다. 다음 날 사진을 보며 말을 하나씩 같은 칸에 놓으면 어제 멈춘 판이 돌아옵니다.

게임 상태 직렬화는 이 사진을 찍는 일입니다. 사진을 보고 판을 다시 놓는 일은 역직렬화라고 부릅니다.

상태에 드는 값

직렬화는 메모리에 흩어진 데이터를 한 줄로 늘어선 바이트로 바꾸는 일입니다. 바이트로 바꿔야 파일에 적거나 네트워크로 보낼 수 있습니다. 게임 상태 직렬화는 그 대상이 게임 세계 전체인 경우입니다.

게임은 시간을 잘게 잘라 한 걸음씩 세계를 계산합니다. 이 한 걸음을 틱이라고 부릅니다. 틱마다 게임은 지금 상태와 플레이어 입력을 받아 다음 상태를 만듭니다.

그래서 상태는 다음 틱을 계산하는 데 필요한 값 전부입니다. 이 값만 다시 세우면 게임이 멈춘 틱부터 똑같이 이어집니다. 반대로 상태에서 다시 계산해 낼 수 있는 값은 적지 않아도 됩니다.

적는 것 빼는 것
캐릭터·몬스터·아이템의 위치 · 속도 · 체력 화면에 그린 그림
지금이 몇 번째 틱인지 재생 중인 효과음
남은 쿨타임 · 진행 중인 타이머 위치에서 다시 계산되는 그림자 · 조명
난수 생성기가 어디까지 뽑았는지 메뉴 화면에서 마우스가 있는 칸

표의 왼쪽은 게임 규칙이 쓰는 값입니다. 오른쪽은 보여 주기만 하는 값입니다. 오른쪽 값이 조금 달라져도 다음 틱의 계산 결과는 바뀌지 않습니다.

난수 생성기는 눈에 안 띄어서 자주 빠집니다. 게임은 의사 난수 생성기로 공격이 빗나갈지 같은 운을 정합니다. 이 장치는 정해진 수열을 차례로 내놓습니다. 지금 수열의 어디쯤인지를 같이 적어야 다시 세운 세계에서도 다음 운이 같게 나옵니다.

받아 적은 바이트가 쓰이는 곳

같은 바이트를 어디로 보내느냐에 따라 쓰임이 갈립니다. 쓰임마다 무엇을 중요하게 보는지도 다릅니다. 표에서 처음 만나는 롤백 넷코드와 디싱크 검사, 해시 함수는 아래 두 절에서 하나씩 풉니다.

쓰임 바이트가 가는 곳 중요하게 보는 것
세이브 파일 디스크 게임이 업데이트된 뒤에도 읽히나
롤백 넷코드 메모리 틱마다 빨리 적고 빨리 읽나
상태 동기화 · 중도 참가 네트워크 바이트가 작나
디싱크 검사 해시 함수 같은 상태가 늘 같은 바이트를 내나

세이브 파일은 가장 익숙한 쓰임입니다. 플레이어가 저장을 누르면 상태를 파일로 적습니다. 불러오기를 누르면 파일을 읽어 세계를 다시 세웁니다.

상태 동기화는 서버가 계산한 상태를 플레이어 컴퓨터에 보내는 방식입니다. 자주 보내므로 바이트가 작을수록 회선을 덜 씁니다.

중도 참가는 이미 진행 중인 경기에 새 플레이어가 들어오는 일입니다. 새로 들어온 컴퓨터는 지금까지의 세계를 모릅니다. 그래서 상태를 한 번 받아서 시작합니다.

되감기에 쓰는 상태

롤백 넷코드는 네트워크 게임에서 상대 입력을 기다리지 않는 방식입니다. 상대 입력이 늦으면 짐작해서 먼저 계산합니다. 짐작이 틀렸으면 지난 틱으로 되감아 다시 계산합니다.

되감으려면 지난 틱의 상태가 손에 있어야 합니다. 그래서 틱마다 상태를 직렬화해 메모리에 보관합니다. 이렇게 어느 순간의 상태를 떠 둔 것을 스냅샷이라고 부릅니다.

flowchart TD
    A["10틱 상태를 스냅샷으로 보관"] --> B["상대 입력을 짐작해 11틱 · 12틱 계산"]
    B --> C["11틱 진짜 입력이 도착 · 짐작과 다름"]
    C --> D["10틱 스냅샷을 역직렬화"]
    D --> E["진짜 입력으로 11틱 · 12틱 다시 계산"]

그림의 맨 위 상자가 없으면 아래 상자들이 돌아가지 못합니다. 되감을 곳이 없기 때문입니다.

어긋남을 찾는 데 쓰는 상태

결정론적 시뮬레이션은 같은 상태와 같은 입력에서 늘 같은 결과를 내는 계산입니다. 이 성질이 있으면 여러 컴퓨터가 입력만 주고받아도 같은 세계를 각자 계산할 수 있습니다.

상태는 보내지 않고 입력만 주고받아 각자 계산하는 이 방식을 락스텝이라고 부릅니다. 모든 컴퓨터가 같은 틱의 입력을 다 받은 뒤에야 다음 틱으로 넘어갑니다.

계산이 어딘가에서 갈라지면 컴퓨터마다 세계가 달라집니다. 이 일을 디싱크(desync)라고 합니다. 갈라진 뒤에도 프로그램은 오류 없이 돌기 때문에 저절로는 안 보입니다.

그래서 틱마다 상태를 직렬화해 해시 함수에 넣습니다. 해시 함수는 큰 데이터를 짧은 값으로 줄입니다. 데이터가 조금만 달라도 다른 값을 냅니다. 이 짧은 값만 서로 견주면 상태 전부를 보내지 않고도 세계가 같은지 압니다.

입력만 주고받는 방식도 상태 직렬화를 아주 버리지는 못합니다. 디싱크 검사와 세이브 파일과 중도 참가에는 여전히 상태가 필요합니다.

메모리 주소를 번호로 바꾸기

게임 속 물건들은 서로를 가리킵니다. 미사일은 쫓아갈 적을 가리킵니다. 아이템은 그것을 든 캐릭터를 가리킵니다.

프로그램 안에서는 이 참조를 대개 포인터로 둡니다. 포인터는 상대가 놓인 메모리 주소를 담는 값입니다.

메모리 주소는 그 프로그램이 그 순간 돌 때만 뜻이 있습니다. 게임을 껐다 켜면 같은 적이 다른 주소에 놓입니다. 주소를 그대로 적으면 불러온 뒤에 엉뚱한 곳을 가리킵니다.

그래서 물건마다 번호를 붙입니다. 참조는 그 번호로 적습니다. 이 번호를 식별자 또는 ID(Identifier)라고 부릅니다. 불러올 때는 번호로 상대를 찾아 참조를 다시 잇습니다.

Python
units = [
    {"id": 7, "hp": 30},
    {"id": 9, "hp": 50, "target": 7},
]
by_id = {u["id"]: u for u in units}
t = by_id[9]["target"]  # 7
by_id[t]["hp"]          # 30

9번 유닛은 적을 주소가 아니라 번호 7 로 가리킵니다. 불러온 쪽은 번호로 7번을 찾아 체력 30 을 읽습니다. 7번이 메모리 어디에 다시 놓였든 상관없습니다.

같은 상태에서 같은 바이트 내기

디싱크 검사는 바이트가 한 글자만 달라도 다른 해시를 냅니다. 그래서 상태가 같으면 바이트도 같아야 합니다. 상태는 같은데 바이트만 다르면 디싱크가 아닌데도 디싱크로 보입니다.

처리 순서가 첫째 원인입니다. 해시 테이블은 키로 값을 빨리 찾는 자료구조입니다. 언어에 따라서는 그 안을 도는 순서가 실행마다 바뀝니다.

그 순서대로 적으면 같은 세계가 다른 바이트가 됩니다. 그래서 물건을 번호 순으로 정렬해서 적습니다.

여러 바이트로 된 수를 적는 순서가 둘째 원인입니다. 16진수 0x1234 는 두 바이트 0x12 와 0x34 로 나뉩니다. 수의 높은 자리를 담은 0x12 부터 적을지, 낮은 자리를 담은 0x34 부터 적을지를 엔디언이라고 부릅니다. CPU(Central Processing Unit, 중앙 처리 장치)마다 기본 순서가 다를 수 있어서 직렬화할 때 한쪽으로 정해 둡니다.

빈 바이트가 셋째 원인입니다. 컴파일러는 메모리를 빨리 읽으려고 값과 값 사이에 빈 바이트를 끼웁니다. 이것을 패딩이라고 부릅니다.

패딩에는 아무 값이나 남아 있을 수 있습니다. 그래서 메모리를 손대지 않고 베끼면 같은 상태에서도 바이트가 매번 달라질 수 있습니다. 패딩을 0 으로 채우거나 값만 골라 적어 이 차이를 없앱니다.

빨리 적고 작게 적기

롤백 넷코드는 매 틱 상태를 적습니다. 화면을 1초에 예순 번 그리는 게임이면 직렬화도 1초에 예순 번입니다. 적는 데 시간이 걸리면 그만큼 화면이 늦어집니다.

그래서 빠르기가 중요한 쓰임에서는 JSON(JavaScript Object Notation) 같은 글자 형식을 피합니다. 글자 형식은 숫자를 글자로 바꾸고 다시 숫자로 읽는 데 시간이 듭니다.

대신 상태의 값들을 메모리 한 구역에 흩어지지 않게 이어 놓는 방식을 많이 씁니다. 그러면 그 덩어리를 한 번에 베끼는 것으로 직렬화가 끝납니다. 이렇게 베끼려면 앞에서 본 포인터를 번호로 바꿔 둬야 불러온 뒤에도 뜻이 있습니다. 패딩도 0 으로 채워 둬야 같은 상태가 같은 바이트를 냅니다.

네트워크로 보낼 때는 크기가 중요합니다. 틱마다 상태 전부를 보내면 회선이 버티지 못합니다. 그래서 지난번에 보낸 상태와 달라진 값만 보내기도 합니다. 이 방식을 델타 압축이라고 부릅니다.

게임이 바뀐 뒤의 세이브 파일

게임은 출시 뒤에도 업데이트됩니다. 캐릭터에 배고픔 수치를 새로 넣으면 상태의 모양이 바뀝니다. 그 전에 만든 세이브 파일에는 배고픔 값이 없습니다.

그래서 세이브 파일 맨 앞에 버전 번호를 적어 둡니다. 불러오는 쪽은 버전 번호를 먼저 읽습니다. 옛 버전이면 없는 값을 기본값으로 채워 새 모양으로 옮깁니다.

옛 파일을 새 프로그램이 읽을 수 있는 성질을 하위 호환이라고 부릅니다. 하위 호환을 지키려면 데이터의 모양이 바뀔 때마다 옛 모양도 읽히게 관리해야 합니다. 이 관리를 스키마 진화라고 부릅니다.

롤백용 스냅샷은 메모리에만 잠깐 있다가 사라집니다. 그래서 게임이 업데이트되어도 옛 스냅샷을 읽을 일이 거의 없습니다.

믿을 수 없는 바이트

세이브 파일은 플레이어 컴퓨터의 디스크에 있습니다. 플레이어가 파일을 열어 체력이나 돈을 마음대로 바꿀 수 있습니다. 네트워크로 받은 상태도 중간에서 누군가 바꿀 수 있습니다.

그래서 역직렬화하는 쪽은 받은 바이트를 그대로 믿지 않습니다. 길이와 범위를 확인해서 체력이 음수이거나 없는 번호를 가리키면 거부합니다. 확인하지 않으면 게임이 멈추거나 치트의 통로가 됩니다.

권위 서버 구조는 이 문제를 다른 쪽에서 풉니다. 서버 하나가 세계를 계산합니다. 플레이어 컴퓨터가 보낸 상태는 받지 않습니다. 플레이어는 입력만 보내고 상태는 서버에서 받기만 합니다.

대가

상태에 값을 하나 더할 때마다 적는 코드와 읽는 코드를 같이 고쳐야 합니다. 한 군데라도 빠뜨리면 불러온 세계가 원래와 조금 다릅니다. 이 차이는 화면에서 바로 안 보입니다. 몇 틱 뒤에 다른 결과로 드러납니다.

롤백 넷코드처럼 매 틱 적는 쓰임에서는 상태가 클수록 느려집니다. 그래서 상태를 작게, 메모리 한 구역에 모이게 설계하는 것부터 직렬화 비용을 줄이는 일이 됩니다. 이 설계는 게임 코드를 짜는 방식 전체에 영향을 줍니다.

관련 항목

게임 상태 직렬화가 속하는 상위 분류

직렬화 · 게임 개발 · 게임 엔진 · 게임 루프 · 시뮬레이션

게임 상태 직렬화를 쓰는 기법

세이브 파일 · 롤백 넷코드 · 롤백 · 리플레이 · 중도 참가 · 상태 동기화 · 결정론적 시뮬레이션 · 락스텝

게임 상태를 이루는 구성 요소

엔티티 · 엔티티 컴포넌트 시스템 · 틱 · 의사 난수 생성기 · 시드 · 식별자

직렬화한 상태를 견주는 도구

디싱크 · 해시 함수 · 체크섬 · 스냅샷

같은 상태에서 같은 바이트를 내려고 정하는 규칙

엔디언 · 패딩 · 해시테이블 · 부동소수점 · 고정소수점

직렬화한 상태를 적는 데이터 형식

JSON · 프로토콜 버퍼 · FlatBuffers · MessagePack · 바이너리 포맷

세이브 파일의 버전을 맞추는 규약

스키마 진화 · 하위 호환 · 데이터 마이그레이션 · 매직 넘버

직렬화한 상태를 줄이는 기법

델타 압축 · 압축 · 비트 패킹 · 가변 길이 정수

믿을 수 없는 상태에서 나는 문제와 막는 구조

치트 · 역직렬화 취약점 · 입력 검증 · 권위 서버

다른 이름: game state serialization · 상태 직렬화 · 게임 상태 저장