저장 파일 불러오기 구조
1. 시스템 요구 사항
건축 시스템에서 불러오기는 단순히 저장된 문자열을 읽어오는 기능이 아니다.
현재 프로젝트의 배치 데이터는 바닥, 벽, 오브젝트, 벽 내부 오브젝트처럼 계층 구조를 가지며, 각 계층은 서로 다른 배치 규칙과 의존 관계를 가진다.
또한 이 데이터는 단순 좌표 정보가 아니라 PlacementManager, GridData, PlacementGridData를 통해 관리되는 런타임 상태와 직접 연결되어 있다.
따라서 불러오기 시스템은 저장 데이터를 그대로 오브젝트로 생성하는 방식이 아니라, 기존 배치 시스템이 사용하는 흐름을 그대로 따라가면서 상태를 복원해야 한다.
불러오기 과정에서는 복원 순서가 중요한 조건이 된다.
벽 내부 구조물은 벽이 존재해야 정상적으로 배치될 수 있기 때문에, 벽보다 먼저 복원되면 잘못된 상태가 만들어진다.
이 때문에 계층별 복원 순서를 보장해야 한다.
또한 불러오기는 항상 초기화와 함께 동작해야 한다.
기존 상태를 유지한 채 데이터를 추가하면 중복 생성이 발생하고, Grid 점유 상태도 틀어지게 된다.
따라서 기존 데이터를 완전히 제거한 뒤, 저장된 데이터를 기반으로 다시 구성해야 한다.
이 시스템은 저장 문자열을 읽어 구조체로 복원하고, 기존 상태를 초기화한 뒤, 계층 순서를 지키면서 기존 배치 시스템을 통해 동일한 상태를 다시 만들어내는 구조를 요구한다.
2. 흐름도
불러오기 요청
↓
LoadData(fileName)
↓
ClearCurrentData()
↓
PlayerPrefs.GetString(fileName)
↓
JsonUtility.FromJson<GridSaveData>(data)
↓
Data 처리(floor→wall→object→inWall 순서대로)
↓
ProcessPlacementData()
↓
CreateData()
↓
PlacementManager.PlaceStructureAt(...)
↓
게임 상태 복원 완료
이 흐름에서 중요한 점은 LoadData가 단순 데이터 읽기 함수가 아니라, 복원 순서를 직접 통제하는 함수라는 것이다.
데이터는 JSON → GridSaveData로 복원되지만, 여기서 바로 오브젝트를 생성하지 않는다.
중간에 ProcessPlacementData와 CreateData를 거쳐 기존 배치 시스템으로 다시 전달된다.
이 구조 덕분에 저장 데이터와 일반 배치가 같은 경로를 사용하게 되고, 시스템 동작이 하나로 유지된다.
3. 구현
3.1. 불러오기 진입 흐름
public void LoadData(string fileName)
{
ClearCurrentData();
if (!PlayerPrefs.HasKey(fileName))
return;
string data = PlayerPrefs.GetString(fileName);
GridSaveData loadedData = JsonUtility.FromJson<GridSaveData>(data);
ProcessPlacementData(loadedData.floorData);
ProcessPlacementData(loadedData.wallData);
ProcessPlacementData(loadedData.objectData);
ProcessPlacementData(loadedData.inWallData);
currentFileName = fileName;
Debug.Log("Data Loaded from : " + fileName);
}LoadData는 불러오기 시스템의 시작점이다.
이 함수가 하는 일은 크게 세 단계로 나뉜다.
현재 상태를 초기화하고, 저장 문자열을 읽어 저장 구조체로 복원한 뒤, 계층별 순서에 맞게 실제 배치를 다시 수행한다.
가장 먼저 ClearCurrentData 함수를 호출한다.
이 호출은 현재 씬과 Grid 상태를 모두 초기화한다.
불러오기는 기존 상태 위에 데이터를 추가하는 방식이 아니라, 저장된 상태를 다시 구성하는 방식이기 때문에 이 초기화가 반드시 필요하다.
이 초기화 과정이 없으면 동일한 구조물이 중복으로 생성되거나, 기존 구조와 로드된 구조가 겹쳐서 잘못된 상태가 만들어질 수 있다.
그 다음 PlayerPrefs.HasKey(fileName)로 해당 이름의 저장 데이터가 존재하는지 확인한다.
HasKey는 Unity의 PlayerPrefs 안에 특정 키가 저장되어 있는지를 검사하는 함수다.
여기서는 예외를 던지지 않고, 저장 데이터가 없으면 바로 반환하는 구조를 사용한다.
이 방식은 없는 저장 슬롯을 선택했다는 상황을 시스템 오류가 아니라 정상적인 입력 분기로 처리하는 방식이다.
저장 슬롯 선택은 UI 흐름에서 자주 발생할 수 있는 입력이기 때문에, 조용히 종료하는 형태가 더 적절하다.
PlayerPrefs.GetString(fileName)는 지정한 키에 저장된 JSON 문자열을 읽어 온다.
이 문자열은 단순 텍스트가 아니라, 이전에 JsonUtility.ToJson으로 만들어진 직렬화 데이터다.
JsonUtility.FromJson<GridSaveData>(data)는 문자열을 다시 GridSaveData 구조체로 역직렬화하여 구조체로 변환한다.
FromJson<T>는 Unity에서 제공하는 JSON 역직렬화 함수이며, [Serializable] 기반 타입에 대해 동작한다.
저장 단계에서 ToJson을 통해 문자열로 바꿨다면, 불러오기 단계에서는 FromJson을 통해 다시 데이터 구조로 되돌린다.
이 과정은 저장 데이터가 단순 문자열이 아니라, 런타임에서 다시 해석 가능한 구조체라는 점을 보여 준다.
그 뒤에 이어지는 네 줄이 이 함수의 핵심이다.
loadedData.floorData, wallData, objectData, inWallData를 순서대로 ProcessPlacementData 함수에 넘긴다.
여기서 중요한 것은 저장 데이터를 한 번에 처리하지 않고, 계층을 나누어 순서대로 처리한다는 점이다.
바닥이 먼저 복원되고, 그 위에 벽과 오브젝트가 올라가며, 마지막으로 벽 내부 오브젝트가 복원된다.
현재 시스템은 이 의존성을 코드 안에 명시적으로 반영하고 있다.
마지막에 currentFileName을 갱신하는 이유는 현재 활성 저장 슬롯 상태를 일치시키기 위해서다.
저장 후 이어서 덮어쓰기를 하거나, UI에서 현재 파일 상태를 추적할 때 이 값이 기준이 된다.
불러오기 시스템에서 가장 중요한 부분은 사실 역직렬화보다 순서다.
ProcessPlacementData(loadedData.wallData)보다 먼저 ProcessPlacementData(loadedData.inWallData)가 실행되면, 벽을 기준으로 설치되어야 하는 구조물이 정상적으로 배치되지 않는다.
다시 말해 현재 불러오기 시스템은 모든 데이터를 동일하게 보는 것이 아니라, 계층 간 선행 조건이 존재한다는 전제 위에서 동작한다.
이 순서를 코드 안에 직접 작성한 방식은 장단점이 있다.
장점은 의존성이 명확하게 드러난다는 점이다.
어떤 데이터를 먼저 복원해야 하는지 코드만 봐도 바로 알 수 있다.
단점은 계층이 더 늘어나거나 복원 순서 규칙이 복잡해질 경우, 이 부분을 수동으로 계속 관리해야 한다는 점이다.
하지만 현재 프로젝트처럼 바닥, 벽, 오브젝트, 벽 내부 오브젝트 정도의 계층 구조에서는 오히려 명시적으로 쓰는 편이 더 직관적이다.
이 설계는 복잡한 추상화보다, 현재 시스템의 제약을 분명하게 드러내는 방식을 선택한 것이다.
3.2. 계층 복원
private void ProcessPlacementData(PlacementGridSaveData placementData)
{
for (int i = 0; i < placementData.cellsData.Count; i++)
{
CellObjectSaveData objectData = placementData.cellObjectData[i];
List<Quaternion> gridCheckRotation = new() { Quaternion.Euler(0, objectData.rotation, 0) };
List<Quaternion> objectRotation = new() { Quaternion.Euler(0, objectData.objectRotation, 0) };
var (selectionData, data)
= CreateData(objectData.structureID,
new(),
new() { objectData.origin },
objectRotation,
gridCheckRotation
);
_placementManager.PlaceStructureAt(selectionData, data);
}
for (int i = 0; i < placementData.edgesData.smallerPoints.Count; i++)
{
EdgeObjectSaveData objectData = placementData.edgesObjectData[i];
List<Quaternion> gridCheckRotation = new() { Quaternion.Euler(0, objectData.rotation, 0) };
List<Quaternion> objectRotation = new() { Quaternion.Euler(0, objectData.objectRotation, 0) };
var (selectionData, data)
= CreateData(objectData.structureID,
new(),
new() { objectData.origin },
objectRotation,
gridCheckRotation
);
_placementManager.PlaceStructureAt(selectionData, data);
}
}ProcessPlacementData는 저장 구조를 실제 복원 흐름으로 바꾸는 핵심 함수다.
이 함수는 PlacementGridSaveData 하나를 받아서, 그 안에 들어 있는 셀 기반 구조물과 Edge 기반 구조물을 각각 순회하며 배치 시스템으로 넘긴다.
셀과 Edge 모두 같은 흐름으로 복원되기 때문에, 두 구조는 동일한 배치 경로를 사용한다.
여기서 중요한 점은 저장 데이터에서 직접 Instantiate를 호출하지 않는다는 것이다.
_placementManager.PlaceStructureAt(selectionData, data)를 통해 기존 배치 시스템을 다시 사용한다.
이 방식 덕분에 저장으로 복원된 구조물도 플레이어가 배치한 구조물과 같은 규칙을 공유하게 된다.
저장과 일반 배치를 별도 경로로 나누지 않았기 때문에, 시스템 일관성이 높다.
첫 번째 루프는 셀 기반 구조물을 처리한다.
placementData.cellsData.Count를 기준으로 반복하고 있지만, 실제 사용하는 데이터는 placementData.cellObjectData[i]이다.
이 구조는 저장 데이터 안에 셀 좌표 목록과 셀 구조물 데이터가 함께 들어 있기 때문이다.
CellObjectSaveData objectData = placementData.cellObjectData[i]로 구조물 하나를 꺼낸 뒤, Quaternion.Euler(0, objectData.rotation, 0)와 Quaternion.Euler(0, objectData.objectRotation, 0)를 각각 만든다.
Quaternion.Euler는 Unity에서 오일러 각을 회전 정보로 바꾸는 함수다.
저장 단계에서는 회전값을 int로 저장했기 때문에, 런타임에서 사용하는 회전 표현으로 다시 변환해야 한다.
여기서 회전 값을 List<Quaternion>으로 감싸는 이유는 SelectionResult 구조가 복수 선택을 전제로 설계되어 있기 때문이다.
단일 구조물 복원도 동일한 인터페이스를 유지하기 위해 리스트 형태로 전달한다.
이후 CreateData(...)를 호출하는데, 여기서 주목할 점은 new() { objectData.origin }만 넘긴다는 점이다.
new() { objectData.origin }은 복원 위치를 지정하는 부분이다.저장 데이터에는 전체 좌표 목록이 들어 있지만, 실제 복원은 origin을 기준으로 다시 계산된다.
이로 인해, 저장 데이터 크기를 줄이고, 좌표 계산을 일관되게 유지할 수 있다.
저장 구조 안에는 positions 같은 정보도 있지만, 복원 시에는 origin을 기준으로 다시 배치 데이터를 만든다.
이건 현재 시스템이 origin, 구조물 ID, 회전 정보를 기반으로 배치 위치를 재구성하는 구조라는 뜻이다.
저장 단계에서 모든 좌표를 그대로 재사용하지 않고, 기준점과 구조물 정의를 통해 다시 계산 가능하다는 점이 코드에 그대로 드러난다.
이렇게 만들어진 selectionData와 data를 _placementManager.PlaceStructureAt(...)로 넘기면, 결국 저장 데이터는 기존 배치 시스템을 통해 다시 씬에 반영된다.
_placementManager.PlaceStructureAt(...) 호출하여 직접 Instantiate를 하지 않고도, 저장 복원과 일반 배치가 동일한 규칙을 공유하게 된다.
Edge 기반 루프도 같은 흐름을 따른다.
차이는 반복 기준이 placementData.edgesData.smallerPoints.Count라는 점이다.
Edge는 smallerPoint와 biggerPoint를 나누어 저장하는 구조를 가지기 때문에, smallerPoints 개수를 기준으로 구조물 수를 간접적으로 다룬다.
하지만 실제 복원 흐름은 셀과 동일하다. 회전을 다시 Quaternion으로 만들고, CreateData()로 배치용 데이터를 준비한 뒤, _placementManager.PlaceStructureAt(...)를 호출한다.
3.3. 복원용 배치 데이터 재구성
private (SelectionResult, ItemData) CreateData(
int id,
List<Vector3> loadedSelectedPositions,
List<Vector3Int> loadedSelectedGridPositions,
List<Quaternion> loadedSelectedPositionsObjectRotation,
List<Quaternion> loadedSelectedPositionGridCheckRotation)
{
ItemData data;
SelectionResult selectionData;
_placementManager.StartPlacingObject(id);
data = _database.GetItemWithID(id);
if (data == null)
throw new System.Exception("No Structure data with id " + id);
selectionData = new()
{
isEdgeStructure = data.objectPlacementType.IsEdgePlacement(),
placementValidity = true,
size = data.size,
selectedGridPositions = loadedSelectedGridPositions,
selectedPositions = loadedSelectedPositions,
selectedPositionsObjectRotation = loadedSelectedPositionsObjectRotation,
selectedPositionGridCheckRotation = loadedSelectedPositionGridCheckRotation,
};
return (selectionData, data);
}CreateData는 저장 데이터를 배치 시스템이 이해할 수 있는 입력 형태로 바꾸는 함수다.
반환형이 (SelectionResult, ItemData) 튜플이라는 점이 먼저 눈에 들어온다.
C# 튜플은 관련 있는 여러 값을 한 번에 반환할 때 쓰기 좋다.
여기서는 배치 결과를 표현하는 SelectionResult와, 실제 구조물 정의인 ItemData를 함께 반환한다.
별도의 임시 클래스를 만들지 않고도 필요한 두 값을 묶어서 전달할 수 있기 때문에, 현재처럼 복원용 변환 함수를 만들 때 적절하다.
함수 내부에서 _placementManager.StartPlacingObject(id)를 먼저 호출하는 이유는 현재 배치 시스템의 상태를 해당 구조물 기준으로 맞추기 위해서다.
그 다음 _database.GetItemWithID(id)로 구조물 ID에 대응하는 ItemData를 찾는다.
여기서 _database가 필요한 이유가 분명해진다.
저장 데이터에는 prefab이나 복잡한 구조물 정의가 직접 들어 있지 않고, ID만 저장되어 있다.
따라서 복원 시에는 이 ID를 다시 실제 구조물 데이터로 변환해야 한다.
if (data == null)에서 저장 데이터에 있는 ID가 데이터베이스에 존재하지 않는다면, 복원이 불가능한 상태이기 때문에 예외를 던진다.
이런 경우를 무시하면 잘못된 구조물이 조용히 누락될 수 있기 때문에, 예외를 던져서 바로 드러내는 편이 낫다.
그 다음 selectionData = new() { ... }로 SelectionResult를 구성한다.
여기서 isEdgeStructure, placementValidity, size, selectedGridPositions, selectedPositions, selectedPositionsObjectRotation, selectedPositionGridCheckRotation을 채운다.
중요한 점은, 이 함수가 저장 데이터를 그대로 넘기는 것이 아니라, 플레이어가 방금 구조물을 선택해서 배치하려는 것처럼 배치 시스템이 요구하는 입력 데이터를 다시 만들어 준다는 것이다.
즉, CreateData 함수는 저장 구조와 런타임 배치 구조 사이를 이어 주는 어댑터 역할을 한다.
이 함수가 있기 때문에 저장 데이터도 일반 배치와 같은 인터페이스를 통해 처리될 수 있다.
3.4. 복원 전 초기화
public void ClearCurrentData()
{
_placementManager.ClearAllPlacedObjects();
currentFileName = null;
Debug.Log("Current game state has been reset.");
}ClearCurrentData는 불러오기 전에 현재 상태를 완전히 비우는 함수다.
_placementManager.ClearAllPlacedObjects()는 현재 씬에 배치된 구조물과 Grid 상태를 함께 초기화한다.
여기서 중요한 건 오브젝트만 지운다가 아니라, 씬 상태와 내부 데이터 상태를 함께 초기화한다는 점이다.
저장 데이터를 불러와서 다시 배치할 때는 기존 상태가 남아 있으면 안 되기 때문에, 이 초기화는 불러오기 흐름의 필수 단계다.
그 다음 currentFileName = null로 현재 활성 저장 슬롯 상태를 비운다.
이 값은 현재 어떤 파일을 기준으로 작업 중인지 나타내는 상태였기 때문에, 새로 로드하는 흐름에서는 먼저 초기화하는 편이 안전하다.
마지막 Debug.Log는 현재 게임 상태가 리셋되었음을 확인하기 위한 출력이다.
불러오기 시스템은 저장보다 디버깅이 더 까다로운 경우가 많기 때문에, 이런 로그는 실제 개발 과정에서 원인 추적에 도움이 된다.
ClearCurrentData 함수는 짧은 함수지만, 복원 전 상태 일관성을 보장하는 핵심 초기화 함수다.
4. 개발 의도
이 불러오기 시스템은 저장 데이터를 직접 오브젝트로 생성하지 않고, 기존 배치 시스템을 그대로 사용해 복원하도록 설계되어 있다.
저장 데이터는 CreateData를 통해 SelectionResult와 ItemData로 변환되고, PlaceStructureAt을 통해 배치된다.
이 구조 덕분에 저장 복원과 일반 배치가 동일한 흐름을 사용하게 된다.
계층별 복원 순서를 코드에 직접 작성한 것도 의도적인 선택이다.
바닥, 벽, 오브젝트, 벽 내부 오브젝트는 서로 의존 관계가 있기 때문에, 순서를 명확하게 고정해 두는 편이 안정적이다.
이 시스템은 데이터를 읽는 기능이 아니라, 저장된 상태를 기존 게임 로직 위에 다시 구성하는 복원 흐름을 중심으로 설계되어 있다.
