• 저장 버튼 또는 특정 이벤트(예: 게임 종료, 스테이지 클리어 등) 시 데이터를 저장하는 방식
  • 저장할 데이터 구조 설계(예: 플레이어 상태, 인벤토리, 진행도, 맵 상태 등)
  • 권장되는 저장 형식(예: JSON, 바이너리, SQL 등)과 각각의 장단점
  • 언리얼 엔진에서 저장 및 로드를 구현하는 구체적인 방법(USaveGame 클래스 활용, 파일 시스템 접근, Steam 클라우드 지원 여부 등)
  • 리슨 서버 환경에서 클라이언트-호스트 간 데이터 동기화 고려 사항

이 내용을 바탕으로 정리해서 알려드리겠습니다. 곧 결과를 공유해드릴게요!

멀티플레이 로그라이크 게임의 세이브/로드 시스템 구현 가이드

저장 이벤트와 저장 시점

멀티플레이 로그라이크 게임에서는 실시간 자동 저장보다는 특정 이벤트에서만 저장을 수행하는 것이 일반적입니다. 예를 들면:

  • 플레이어가 메뉴에서 저장 버튼을 눌렀을 때 (사용자 요청 시).
  • 스테이지 클리어 후 다음 단계로 넘어가기 전에.
  • 게임을 종료할 때 진행 상황을 저장 (진행 중 임의로 저장하지 않고 종료 시 한 번만 저장).

이렇게 이벤트 기반으로 저장함으로써 로그라이크 특유의 긴장감을 유지하고, 불필요한 빈번한 저장으로 인한 성능 저하를 피할 수 있습니다. 특히 리슨 서버 방식의 멀티플레이에서는 호스트가 임의로 잦은 자동 저장을 발생시키면 모든 참가자의 게임이 순간 멈추는 등 불편을 줄 수 있습니다 (Listen Server, saving? - Multiplayer & Networking - Epic Developer Community Forums). 일반적으로 저장 동작은 순간적인 프레임 드롭을 유발할 수 있으므로, 저장 시에는 게임을 일시 정지하거나 짧은 로딩 화면을 표시하는 것이 좋습니다 (Listen Server, saving? - Multiplayer & Networking - Epic Developer Community Forums). 따라서 저장 지점은 자주 발생하지 않도록 설계하고, 플레이어에게도 그 시점을 명확히 인지시키는 것이 바람직합니다.

저장할 데이터 설계 및 확장성 고려

어떤 데이터를 저장할지 설계할 때, 게임의 진행 상황을 복원하는 데 필요한 모든 정보를 포함해야 합니다. 대표적으로:

  • 플레이어 상태: 각 플레이어의 현재 능력치(체력, 경험치, 레벨 등), 위치, 보유 화폐(게임 내 통화) 등을 저장합니다. 멀티플레이의 경우 플레이어별로 이 정보를 구분해서 저장해야 합니다.
  • 인벤토리와 장비: 플레이어가 소지한 아이템 목록, 장비 내구도, 착용 중인 아이템 등을 저장합니다.
  • 진행도 및 게임 상태: 현재 진행 중인 스테이지/층 번호, 난이도, 점수, 랜덤 시드(seed) 값 등 게임 진행을 나타내는 정보를 기록합니다. 로그라이크에서는 맵 생성에 랜덤 요소가 있으므로 시드값이나 맵 인덱스를 저장해 두면 로드 시 동일한 맵을 복원할 수 있습니다.
  • 월드(맵) 상태: 맵에 존재하는 중요 오브젝트들의 상태를 저장합니다. 예를 들어 이미 획득한 아이템이나 열린 보물상자, 처치된 보스처럼 게임 세계의 변화에 해당하는 요소들을 기록해야 합니다. 맵 지형 자체가 고정적이라면 따로 저장할 필요가 없지만, 플레이어의 상호작용으로 변경되는 오브젝트(열린 상자, 사라진 아이템 등)는 저장해두어야 합니다 (Listen Server, saving? - Multiplayer & Networking - Epic Developer Community Forums).

동적 확장성을 고려하여 데이터 구조를 설계하는 것도 중요합니다. 게임 업데이트로 새로운 요소(예: 신규 아이템 종류나 플레이어 능력치)가 추가되더라도 기존 세이브 파일을 불러올 수 있어야 합니다. 이를 위해 보통 버전 번호를 세이브 데이터에 포함시켜 버전에 따라 로드 방식을 다르게 처리하거나 변환 과정을 거칩니다 (Save games as JSON (or any text) instead of binary? : r/unrealengine). 또한 Unreal Engine의 저장 시스템(USaveGame)은 UPROPERTY에 SaveGame 플래그가 지정된 변수들을 이름과 함께 직렬화하기 때문에, 변수를 새로 추가하는 정도의 변경은 이전 버전 세이브와도 호환되는 편입니다 (Save games as JSON (or any text) instead of binary? : r/unrealengine). 반대로 변수를 삭제하거나 자료구조를 크게 변경하면 호환성이 깨질 수 있으므로, 그런 경우 세이브 파일의 버전을 확인하여 데이터 변환을 수행하거나 필요시 호환 경고 및 새로운 세이브 파일 생성 등의 대비책을 마련해야 합니다.

멀티플레이 게임에서는 플레이어별 데이터 분리도 고려해야 합니다. 예를 들어, 호스트는 게임의 월드 상태와 진행도를 저장하고 각 클라이언트는 자신의 캐릭터 상태를 별도로 저장하는 식으로 역할을 나눌 수 있습니다 (Listen Server, saving? - Multiplayer & Networking - Epic Developer Community Forums). 이렇게 하면 이후 재접속 시 호스트는 월드 진행도를 복원하고, 각 플레이어는 자신의 진행도(캐릭터 능력치, 아이템 등)를 개별적으로 복원할 수 있습니다. 실제로 마인크래프트 등의 게임도 이와 유사하게, 호스트는 세계를 저장하고 각 플레이어는 자기 인벤토리를 자기 PC에 저장하는 방식으로 동작합니다 (Listen Server, saving? - Multiplayer & Networking - Epic Developer Community Forums).

저장 데이터 형식 선택과 장단점 비교

게임 세이브 데이터를 저장할 때는 여러 파일 형식을 선택할 수 있으며, 각각 장단점이 있습니다:

  • JSON (텍스트 기반):
    사람이 읽을 수 있는 텍스트 포맷으로, 데이터 구조를 계층적으로 표현하기에 적합합니다 (java - What is a good file format for saving game data? - Game Development Stack Exchange). 개발 중 디버깅에 유리하고, 키-값 구조여서 필드가 추가되거나 순서가 바뀌어도 비교적 유연하게 대응할 수 있습니다 (Save games as JSON (or any text) instead of binary? : r/unrealengine).
    단점으로는 파일 용량이 이진(binary) 형식보다 크고 읽기/쓰기 속도가 느린 편입니다. 또한 사용자가 파일을 열어 내용을 수정하기 쉽다는 점에서 치트에 노출될 수 있습니다 (Save games as JSON (or any text) instead of binary? : r/unrealengine). JSON 파일이 잘못 편집되면 파싱 오류로 세이브 데이터를 읽지 못하게 될 수 있으므로, 필요한 경우 암호화나 무결성 검증 등의 대책을 추가로 고려해야 합니다.
  • 바이너리 (이진 데이터):
    Unreal Engine의 기본 SaveGame 시스템이 사용하는 형식으로, 사람이 읽을 수 없는 이진 데이터로 저장합니다. 구조체나 객체 메모리를 직렬화하여 기록하므로 파일 크기가 작고, 텍스트보다 저장/로드 속도가 빠른 것이 장점입니다. 특히 자동 저장이나 빈번한 저장이 필요한 경우 이진 포맷이 퍼포먼스 측면에서 유리합니다 (Save games as JSON (or any text) instead of binary? : r/unrealengine).
    이진 데이터는 사용자가 내용을 임의로 편집하기 어려워 세이브 파일 변조(치트)를 어느 정도 억제하는 효과도 있습니다. 하지만 파일을 사람이 직접 해석하기 힘들고, 포맷이 변경되면 호환성 문제가 발생할 수 있습니다 (Save games as JSON (or any text) instead of binary? : r/unrealengine). 다행히 Unreal의 기본 세이브 시스템은 변경된 각 변수마다 이름과 타입 정보를 함께 저장하여 포맷 변경에 비교적 안정적이도록 설계되어 있습니다 (Save games as JSON (or any text) instead of binary? : r/unrealengine). 그렇더라도 내부 구조 변경 시에는 앞서 언급한 버전 관리 전략을 함께 사용하는 것이 안전합니다.
  • 데이터베이스 (SQL 기반):
    SQLite 같은 로컬 데이터베이스 파일에 저장하거나, 원격 DB 서버(예: MySQL)에 저장하는 방식입니다. 데이터베이스를 사용하면 테이블로 구조화된 방대한 데이터를 효율적으로 관리할 수 있고, SQL 쿼리를 통해 특정 데이터만 빠르게 조회하거나 통계를 낼 수 있다는 장점이 있습니다. 특히 MySQL처럼 서버 DB를 사용하면 Steam 같은 플랫폼에 의존하지 않고도 중앙 서버에 데이터를 보관하여 치트를 방지하거나, 다양한 사용자 통계 데이터를 수집할 수 있습니다 (Save thinks into Steam Cloud? - Blueprint - Epic Developer Community Forums).
    그러나 데이터베이스를 활용하려면 추가적인 인프라와 구현이 필요합니다. 게임 클라이언트가 네트워크를 통해 서버와 통신하는 로직을 작성해야 하고, 직접 DB 서버를 운영해야 한다면 비용과 관리 부담이 큽니다 (How is "Currency" Stored for the Player - Multiplayer & Networking - Epic Developer Community Forums). SQLite와 같은 파일 기반 DB는 별도 서버 없이 동작하지만, 결국 로컬 파일이므로 치트 방지 면에서는 일반 세이브 파일과 큰 차이가 없습니다. 또한 SQL 데이터를 사용하려면 직렬화/역직렬화보다는 SQL문 작성 및 파싱 등 복잡도가 높아집니다. 일반적으로 인디 게임이나 소규모 프로젝트에서는 이런 방식보다는 Steam 클라우드 또는 로컬 세이브 파일 방식(필요시 암호화)을 더 많이 활용합니다 (How is "Currency" Stored for the Player - Multiplayer & Networking - Epic Developer Community Forums).

언리얼 엔진에서의 세이브 및 로드 구현 방법

USaveGame 클래스를 활용한 저장

Unreal Engine에는 세이브용으로 설계된 USaveGame 클래스를 활용하는 것이 권장됩니다. 먼저 USaveGame을 상속한 커스텀 세이브게임 클래스를 만들고, 그 안에 저장하고자 하는 변수들을 정의합니다 (Unreal Engine C++ Save System (SaveGame) - Tom Looman). (블루프린트에서는 Add New -> Blueprint Class 생성 시 부모 클래스로 SaveGame을 선택하여 만들 수 있습니다.) 게임 내에서 저장 이벤트가 발생하면, 현재 게임 상태 값을 이 SaveGame 오브젝트의 변수들에 복사합니다. 예를 들어 플레이어의 HP나 경험치, 인벤토리 목록 등의 값을 SaveGame 객체의 대응 변수에 설정합니다. 여러 플레이어의 데이터를 저장해야 한다면 SaveGame 객체 내에 플레이어별 구조체 배열 등을 마련하여 복수의 캐릭터 정보를 넣을 수 있습니다. 월드상의 오브젝트 상태를 저장할 때는 해당 오브젝트가 열렸는지/획득되었는지 여부 등을 SaveGame에 기록합니다.

이렇게 준비된 SaveGame 오브젝트를 UGameplayStatics::SaveGameToSlot 함수 (블루프린트 노드 Save Game to Slot)로 디스크에 저장합니다. 이 함수 호출 시 지정한 슬롯 이름과 사용자 인덱스(일반적으로 0)에 대응하는 파일이 생성되며, 기본적으로 게임 프로젝트의 Saved/SaveGames 폴더 아래에 .sav 확장자의 세이브 파일이 저장됩니다 (Unreal Engine C++ Save System (SaveGame) - Tom Looman). 슬롯 이름을 다르게 주거나 사용자 인덱스를 활용하면 여러 개의 세이브 파일을 관리할 수도 있습니다 (예: 슬롯1, 슬롯2 여러 개의 저장 기록).

로드할 때는 UGameplayStatics::LoadGameFromSlot (블루프린트 노드 Load Game from Slot)을 사용하여 파일에서 SaveGame 객체를 불러옵니다. 그런 다음 그 안에 저장된 값을 게임의 현재 객체들에 적용해줍니다. 플레이어의 위치, 능력치, 인벤토리 등는 SaveGame에서 읽은 값으로 설정하고, 필요하면 월드의 오브젝트를 생성하거나 상태를 갱신합니다. 예를 들어 저장된 데이터에 해당 아이템이 이미 획득된 것으로 표시돼 있다면, 로드 시 그 아이템을 맵에 나타나지 않게 하거나 이미 없어진 상태로 설정합니다. 이 과정은 게임 시작 시 또는 플레이어가 이어하기를 선택했을 때 수행합니다. 멀티플레이의 경우 호스트가 세이브 데이터를 불러와 서버의 게임 상태를 복원한 뒤, 해당 상태를 클라이언트들에게 동기화해야 합니다. 서버에서 플레이어 상태나 월드 정보를 로드하여 설정하면, 그 값들이 Unreal의 Replication 시스템을 통해 자동으로 클라이언트들에 전파되므로 결과적으로 모두 동일한 상태로 갱신됩니다 (Unreal Engine C++ Save System (SaveGame) - Tom Looman). (예를 들어 서버에서 플레이어의 체력을 로드하여 설정하면 그 값이 복제되어 각 클라이언트의 플레이어 화면에도 동일한 체력이 반영됩니다.)

참고: SaveGame 시스템을 사용하지 않고 직접 파일을 다루는 것도 가능합니다. C++의 FFileHelper::SaveStringToFile 등을 이용하면 원하는 경로에 텍스트 데이터를 쓸 수 있고, FArchive를 사용하면 바이너리 데이터도 기록할 수 있습니다. 예를 들어 JSON 형식으로 저장하려면 게임 데이터를 FJsonObject로 구성하고 FFileHelper::SaveStringToFile로 JSON 문자열을 파일에 저장하면 됩니다. 다만 이러한 방법은 파일 경로 관리, 직렬화/파싱 로직을 수동 구현해야 하므로 SaveGame보다 복잡도가 높습니다. 특별한 이유가 있다면 몰라도, 일반적인 게임 세이브에는 SaveGame 클래스를 활용하는 것이 개발 속도와 안정성 면에서 유리합니다.

Steam 클라우드 저장 연동

PC 플랫폼(스팀)에서는 Steam Cloud를 통해 플레이어의 세이브 데이터를 클라우드에 백업/동기화할 수 있습니다. Unreal Engine 자체가 이를 자동 처리해주지는 않지만, Steam의 Auto-Cloud 기능을 설정하면 구현이 간편합니다. Steamworks 설정에서 지정한 경로(예: 세이브 파일 경로)에 있는 파일들을 Steam 클라우드와 동기화하도록 설정할 수 있으며 (Save thinks into Steam Cloud? - Blueprint - Epic Developer Community Forums), 이렇게 해두면 게임이 종료될 때 해당 세이브 파일을 Steam 클라이언트가 자동으로 업로드해주고 게임 시작 시 다운로드해줍니다. Unreal의 SaveGame으로 생성된 세이브 파일은 기본적으로 로컬에 저장되므로, 개발자는 특별한 작업 없이 로컬 세이브/로드를 그대로 하면 되고 Steam이 백그라운드에서 알아서 클라우드 동기화를 해줍니다 (Save thinks into Steam Cloud? - Blueprint - Epic Developer Community Forums). (반드시 Steamworks 대시보드에서 Auto-Cloud 대상 경로 설정을 해두어야 하며, 이때 기본 세이브 경로인 AppData/Local/<GameName>/Saved/SaveGames/ 등을 등록해야 합니다.)

Steam 클라우드를 프로그래밍적으로 제어하려면 Steamworks SDK의 Remote Storage API를 직접 사용해야 합니다 (Save thinks into Steam Cloud? - Blueprint - Epic Developer Community Forums). 예를 들어 Valve가 제공하는 ISteamRemoteStorage 인터페이스를 C++로 호출하여 파일을 업로드/다운로드하거나, Steam Cloud에 여러 개의 파일 목록을 관리할 수 있습니다. 하지만 이 API는 언리얼의 블루프린트에서 바로 접근할 수는 없고, 개발자가 C++ 코드로 래핑하여 호출한 뒤 블루프린트로 연동해야 합니다 (Steam Cloud saving system : r/unrealengine). 대부분의 경우 Auto-Cloud로 충분하며, Steam 클라우드는 사용자의 로컬에 저장된 세이브를 기준으로 동작합니다. 게임 중 네트워크가 끊겨 있더라도 세이브는 일단 로컬에 되고, 나중에 Steam이 연결 가능해지면 해당 파일을 업로드합니다 (Steam Cloud saving system : r/unrealengine). 따라서 오프라인 상태에서도 세이브/로드는 로컬 진행되고, 추후 자동으로 클라우드에 반영됩니다. (Steam Cloud를 사용하지 않는 플랫폼이거나 사용자라면 당연히 세이브는 로컬에만 남으므로, 항상 로컬 세이브 파일을 안전하게 관리하는 로직이 기본이 되어야 합니다.)

리슨 서버 환경에서의 데이터 동기화 고려 사항

서버-클라이언트 저장 역할 분담: 리슨 서버(호스트) 기반 게임에서는 호스트가 주요 게임 상태를 저장하고, 각 클라이언트는 자기 캐릭터 정보만 저장하는 방식으로 분담하는 것이 효율적입니다 (Listen Server, saving? - Multiplayer & Networking - Epic Developer Community Forums). 호스트는 월드 진행 상황과 적 상태 등 전체 게임 세계의 상태를 책임지고, 클라이언트는 자신의 개인 진행 상황(예: 캐릭터 능력치, 인벤토리)을 자신의 PC에 저장하는 식입니다. 클라이언트는 자신의 캐릭터 정보는 모두 알고 있지만, 다른 플레이어나 월드 전체의 상태 정보까지 완전히 가지고 있지는 않을 수 있으므로 (Listen Server, saving? - Multiplayer & Networking - Epic Developer Community Forums) 월드/세션 진행도 저장은 호스트가 수행해야 정확합니다.

데이터 저장 동기화: 저장 직전에 호스트와 클라이언트 간 게임 상태가 일치하도록 동기화하는 것이 중요합니다. 예를 들어 클라이언트가 어떤 아이템을 주웠다면 즉시 서버에 반영되어 호스트가 그 정보를 갖고 있어야 이후 세이브에 빠짐없이 기록됩니다. 일반적으로 저장 트리거는 호스트에서 발생시키고, 필요하면 호스트가 각 클라이언트에 RPC를 보내 **자신의 데이터(프로필)**를 로컬에 저장하도록 유도할 수 있습니다. 로드 시에는 호스트가 저장했던 월드 데이터를 복원하고, 플레이어들의 위치나 상태를 세이브 시점으로 되돌린 후 클라이언트들을 접속시킵니다. 이때 각 플레이어의 캐릭터 상태는 호스트가 저장해둔 스냅샷 데이터를 적용할 수도 있고, 각 클라이언트가 자신의 로컬 세이브 데이터를 불러와 서버에 동기화할 수도 있습니다. 어느 방식을 택하든 최종적으로 서버의 플레이어 상태를 갱신하고 나면 그 값이 Replication을 통해 클라이언트들과 동기화되어 모두 동일한 게임 상태를 갖게 됩니다.

세이브 파일 접근 범위: 세이브 파일은 각 플레이어의 로컬 머신에 저장되므로, 호스트의 월드 세이브 파일은 해당 호스트가 다시 게임을 열 때만 직접 활용될 수 있습니다. 다른 클라이언트는 호스트의 세이브 파일에 접근할 수 없기 때문에, 만약 세션을 이어서 플레이하려면 원래 호스트가 다시 호스트를 맡아야 합니다 (호스트가 저장한 세계를 불러오려면 동일한 PC에서 호스팅해야 함). 따라서 멀티플레이 세션 진행도는 호스트에게 귀속된다고 볼 수 있습니다. 대신 각 클라이언트의 캐릭터 진척도는 해당 클라이언트의 로컬에 저장되어 있으므로, 예를 들어 다른 사용자의 세션에 참여하더라도 자신의 프로필 진행(영구 언락, 캐릭터 레벨 등)은 유지되도록 설계하는 것이 일반적입니다.

권한(Authority) 처리: 구현 시에는 서버/클라이언트 권한을 명확히 구분해야 데이터 불일치가 없습니다. 세이브/로드 동작은 서버에서 실행되도록 하고, 클라이언트가 이를 요청하면 서버에 호출을 위임하는 방식이 안전합니다. 예를 들어 클라이언트의 저장 버튼 입력 -> 서버의 GameMode에서 세이브 수행 순서로 처리합니다. 또한 객체의 상태를 로드하여 적용할 때 서버에서 변수 값을 변경하면 자동으로 클라이언트들에게 복제되지만, 클라이언트가 자신의 화면에서만 값을 바꿔봐야 권한이 없으면 서버에 반영되지 않습니다. 따라서 세이브 파일로부터 게임 상태를 복원하는 코드는 주로 GameMode 또는 서버 권한이 있는 컴포넌트에서 실행하고, 클라이언트 측에서는 필요 시 자신의 SaveGame을 로드하여 서버에 전달하는 정도로 역할을 제한합니다. 이렇게 하면 데이터 동기화와 권한 문제가 최소화되어 신뢰성을 높일 수 있습니다. (Listen Server, saving? - Multiplayer & Networking - Epic Developer Community Forums)

+ Recent posts