저장 시스템 확장 구조

목차

1. 요구 사항

2. 흐름도

3. 구현

        3.1. FileSaveManger 클래스의 저장 실행 제어 역할

       3.2. 버튼 이벤트 연결

       3.3. 현재 파일 기준 저장 흐름

       3.4. 새 파일 저장 진입

       3.5. 저장 이름 처리

       3.6. 새 파일명 저장

       3.7. 덮어쓰기

       3.8. 저장 완료 피드백

       3.9. 단축키 입력 처리

4. 개발 의도

1. 시스템 요구 사항

저장 시스템은 단순히 데이터를 기록하는 기능으로 끝나지 않는다.

현재 프로젝트는 PlayerPrefs 기반으로 저장을 수행하고 있고, 저장 데이터 자체는 SaveManager에서 생성 및 직렬화를 담당한다.

하지만 실제 플레이어 입장에서의 저장 흐름은 그보다 훨씬 복잡하다.

현재 어떤 파일을 기준으로 저장 중인지에 따라 동작이 달라지고, 새 파일 저장과 덮어쓰기 저장이 서로 다른 UI 흐름을 가진다.

또한 입력 UI, 확인 UI, 알림 UI까지 포함되기 때문에 저장 과정은 단일 함수 호출로 처리하기 어렵다.

이 흐름을 모두 SaveManager 안에서 처리하면, 저장 데이터 처리 로직과 UI 제어 로직이 섞이게 된다.

그렇게 되면 저장 코드의 책임 범위가 불필요하게 커지고, 유지보수 난이도도 함께 올라간다.

그래서 저장 시스템은 두 계층으로 나뉜다.

SaveManager는 데이터 생성과 실제 저장 실행을 담당하고, FileSaveManager는 플레이어 입력을 받아 저장 흐름을 제어하는 역할을 맡는다.

이 구조는 저장 기능을 데이터 처리와 플레이어 흐름 제어로 분리하는 것을 목표로 한다.

저장 방식 자체는 SaveManager 내부에 남겨 두고, UI와의 연결은 FileSaveManager에서 담당하도록 나눈 구조다.

저장 시스템은 플레이어의 입력을 기반으로 동작한다.

기본적으로 UI 버튼을 통해 저장 흐름이 실행되지만, 키 입력을 통한 저장 요청도 동일하게 처리할 수 있어야 한다.

플레이어는 Ctrl + S와 같은 단축키를 통해 저장을 실행할 수 있으며, 이때도 UI 버튼과 동일한 저장 흐름으로 연결되도록 입력 레이어를 분리하였다.

2. 흐름도

2.1. 저장

플레이어 저장 요청

FileSaveManager.SaveGame()

현재 파일명 확인

파일명 없음 → 입력 UI 표시
또는
파일명 존재 → SaveManager.SaveData(currentFileName)

새 파일명 입력

HandleNewFileNameInput()

저장 데이터 존재 여부 확인

없음 → SaveNewFile()
있음 → 덮어쓰기 UI 표시

OverwriteSave() 또는 CancelOverwrite()

SaveManager.SaveData(newFileName)

알림 UI 출력

이 흐름에서 핵심은 저장 실행과 저장 흐름 제어가 분리되어 있다는 점이다.

SaveManager는 저장 데이터를 다루고, FileSaveManager는 언제, 어떤 방식으로 저장할 것인지를 결정한다.

2.2. 단축키 기반 흐름

매 프레임 입력 검사

ShortcutsManager.Update() 실행

LeftControl + S 입력 감지

_fileSaveManager.SaveGame() 호출

기존 저장 시스템 실행

단축키 기반 흐름에선 UI 없이 키 입력만 감지해 저장 시스템 진입점을 호출한다.

이때 실제 저장 로직은 UI 버튼과 동일한 SaveGame 함수를 통해 실행되기 때문에, 입력 방식이 달라도 동일한 저장 흐름을 유지할 수 있다.

3. 구현

3.1. FileSaveManager 클래스의 저장 실행 제어 역할
public class FileSaveManager : MonoBehaviour
{
    [SerializeField] private Button _saveButton, _newSaveButton;
    [SerializeField] private GameObject _fileNameInputUI;
    [SerializeField] private TMP_InputField _fileInputField;
    [SerializeField] private Button _fileSaveButton;
    [SerializeField] private GameObject _noticePanel;
    [SerializeField] private GameObject _overwriteUI;
    [SerializeField] private Button _confirmButton, _cancelButton;
    [SerializeField] private SaveManager _saveManager;

    private string newFileName;

    ...
}

이 클래스는 저장 데이터를 직접 다루지 않는다.

실제 데이터 저장은 SaveManager가 담당하고, FileSaveManager는 플레이어 입력을 받아 저장 흐름을 어떻게 진행할지 결정하는 역할을 맡는다.

여기서 [SerializeField]를 사용한 이유는 UI 오브젝트와 버튼 참조를 Inspector에서 직접 연결하기 위해서다.

Unity에서 UI 버튼과 입력 필드를 코드로 찾는 방법도 있지만, 그런 방식은 씬 구조가 바뀌었을 때 취약해지고 런타임 탐색 비용도 늘어나지만, [SerializeField]를 쓰면 필요한 참조를 에디터에서 명시적으로 연결할 수 있다.

코드를 보면 버튼과 UI 오브젝트들이 함께 선언되어 있다.

_saveButton, _newSaveButton, _fileSaveButton, _confirmButton, _cancelButton은 각각 저장 흐름의 서로 다른 단계와 연결된다.

이 버튼들은 이후 Start 함수에서 각각 SaveGame, ShowSaveInputField, HandleNewFileNameInput, OverwriteSave, CancelOverwrite 함수로 연결된다.

버튼은 단순 입력 장치이고, 실제 동작 흐름은 이 클래스 내부 함수로 이어진다.

UI 오브젝트들도 단순 참조가 아니라 상태 전환에 직접 관여한다.

_fileNameInputUI는 새 파일명 입력 단계에서 SetActive(true)로 열리고, 저장이 끝나면 false로 닫힌다.

_overwriteUI는 동일 이름이 존재할 때만 활성화되며, 확인 또는 취소에 따라 닫힌다.

_noticePanel은 저장 완료 직후 잠깐 켜졌다가 코루틴에 의해 자동으로 꺼진다.

이처럼 각 UI 오브젝트는 특정 함수에서만 상태가 바뀌도록 분리되어 있어, 어떤 시점에 어떤 UI가 켜지고 꺼지는지 추적할 수 있다.

newFileName은 '입력 UI → 중복 검사 → 덮어쓰기 확인' 까지 이어지는 흐름에서 동일한 파일명을 유지하기 위한 상태값이다.

입력 처리와 저장 실행이 같은 함수에서 끝나지 않기 때문에, 중간 상태를 저장할 필드가 필요하다.

3.2. 버튼 이벤트 연결
private void Start()
{
    _saveButton.onClick.AddListener(SaveGame);
    _newSaveButton.onClick.AddListener(ShowSaveInputField);
    _fileSaveButton.onClick.AddListener(HandleNewFileNameInput);
    _confirmButton.onClick.AddListener(OverwriteSave);
    _cancelButton.onClick.AddListener(CancelOverwrite);
}

Start 함수는 Unity에서 오브젝트가 활성화된 뒤 한 번 호출되며, 여기서 모든 버튼과 함수가 연결된다.

각 줄은 단순 등록이 아니라 UI 입력과 실행 흐름을 직접 연결한다.

_saveButton 버튼은 저장 버튼이 눌렸을 때, SaveGame 함수를 호출하여 현재 파일을 저장한다.

_newSaveButton 버튼은 ShowSaveInputField 함수를 호출하여, 새 저장 버튼이 입력 UI를 여는 흐름으로 이어지게 만든다.

_fileSaveButton 버튼은 HandleNewFileNameInput 함수를 호출하여, 입력 완료 버튼이 실제 파일명 검증과 저장 분기로 연결되도록 한다.

_confirmButton 버튼은 OverwriteSave 함수를 호출하여, 덮어쓰기 확인 버튼이 실제 저장 실행으로 이어지게 만든다.

_cancelButton 버튼은 CancelOverwrite 함수를 호출하여, 덮어쓰기 취소 시 UI만 닫도록 연결된다.

Button.onClick은 UnityEvent 기반 이벤트 시스템이며, AddListener는 해당 이벤트에 콜백을 등록하는 함수다.

버튼이 눌렸을 때 실행할 함수를 등록하는 구조이며, 코드와 UI 이벤트를 연결하는 가장 일반적인 방법이다.

이벤트 해제가 필요할 정도로 동적 생성되는 UI에서는 관리가 복잡해질 수 있지만, 현재 구조처럼 씬에 고정된 저장 UI에서는 Start 함수에서 한 번 등록하는 방식이 가장 단순하기 때문에 사용하였다.

3.3. 현재 파일 기준 저장 흐름
public void SaveGame()
{
    string currentFileName = _saveManager.GetCurrentFileName();

    if (string.IsNullOrEmpty(currentFileName))
    {
        ShowSaveInputField();
    }
    else
    {
        _saveManager.SaveData(currentFileName);
        StartCoroutine(Notice());
    }
}

SaveGame 함수는 플레이어가 저장 버튼을 눌렀을 때 실행되는 함수다.

먼저 _saveManager.GetCurrentFileName()을 호출해 현재 활성 저장 파일 이름을 가져온다.

이 값이 존재하는지 여부에 따라 저장 흐름이 갈린다.

이미 저장된 파일이 있으면 같은 파일에 덮어쓰기 저장을 수행하고, 아직 저장된 파일이 없으면 새 파일명 입력 UI를 띄운다.

이 구조 덕분에 플레이어 입장에서는 같은 버튼을 눌러도 현재 상태에 따라 다른 저장 흐름이 자연스럽게 이어진다.

string.IsNullOrEmpty(currentFileName)는 C#에서 문자열이 null이거나 빈 문자열인지 동시에 검사하는 함수다.

이 함수는 저장 파일명이 아직 정해지지 않은 초기 상태를 확인하는 데 적합하다.

currentFileName == null || currentFileName == ""처럼 직접 비교할 수도 있지만, IsNullOrEmpty를 사용하면 의도가 더 명확하고 코드도 짧다.

null과 빈 문자열을 함께 처리할 수 있다는 장점이 있지만, 공백만 들어간 문자열까지 막지는 않는다는 단점이 있다.

하지만 현재 구조에서는 새 파일명 입력은 별도 함수에서 Trim()과 함께 처리하므로 충분하다.

현재 파일명이 있으면 _saveManager.SaveData(currentFileName)를 바로 호출한다.

여기서 중요한 건 FileSaveManager가 저장 데이터에 직접 접근하지 않는다는 점이다.

파일명 판단과 UI 흐름 제어는 여기서 하고, 실제 저장 실행은 SaveManager로 넘긴다.

저장이 끝난 뒤에는 StartCoroutine(Notice())를 호출해 알림 UI를 잠시 보여준다.

StartCoroutine은 Unity에서 시간 지연을 포함한 비동기 흐름을 다룰 때 사용하는 방식이다.

현재 구조에서는 저장 완료 알림을 일정 시간 보여 준 뒤 자동으로 닫는 흐름을 만들기 위해 사용되었다.

3.4. 새 파일 저장 진입
private void ShowSaveInputField()
{
    _fileNameInputUI.SetActive(true);
    _fileInputField.text = "";
}

이 함수는 새 파일 저장 흐름을 시작할 때 호출된다.

먼저, SetActive(true)를 통해 파일명 입력 UI를 화면에 표시한다.

Unity의 GameObject.SetActive는 오브젝트를 활성화하거나 비활성화하는 함수이며, 현재 구조에서는 UI 패널 표시 제어에 사용된다.

이후, _fileInputField.text = ""를 통해 입력창 내용을 비운다.

이 줄이 필요한 이유는 이전 입력값이 남아 있으면 새 파일 저장 흐름에서 잘못된 기본값이 보일 수 있기 때문이다.

플레이가 새 저장을 시도할 때마다 항상 빈 입력창에서 시작하게 만들어 입력 흐름을 안정적으로 유지한다.

3.5. 저장 이름 처리
private void HandleNewFileNameInput()
{
    newFileName = _fileInputField.text.Trim();

    if (string.IsNullOrEmpty(newFileName))
        return;

    if (_saveManager.IsDataAvailable(newFileName))
        _overwriteUI.SetActive(true);
    else
        SaveNewFile();
}

이 함수는 플레이어가 파일명을 입력한 뒤 저장 버튼을 눌렀을 때 실행된다.

_fileInputField.text.Trim()은 입력된 문자열의 앞뒤 공백을 제거한 값을 가져온다.

Trim()은 C# 문자열 함수이며, 플레이어가 실수로 앞뒤에 공백을 넣었을 때 같은 이름인지 다르게 인식하는 문제를 줄여 준다.

이 처리는 작아 보이지만 저장 파일명 비교에서는 꽤 중요하다.

공백 하나 때문에 중복 저장이나 중복 검사 실패가 일어날 수 있기 때문이다.

그 다음 string.IsNullOrEmpty(newFileName)로 입력값이 비어 있는지 검사한다.

비어 있다면 아무 작업도 하지 않고 종료한다.

이 구조는 빈 파일명 저장을 사전에 막는 가장 단순한 검증 방식이다.

이어서 _saveManager.IsDataAvailable(newFileName)을 호출해 이미 같은 이름의 저장 파일이 존재하는지 확인한다.

이 검사 결과에 따라 분기가 갈린다.

이미 존재하면 _overwriteUI.SetActive(true)로 덮어쓰기 확인 UI를 띄우고, 존재하지 않으면 SaveNewFile 함수로 바로 저장한다.

결국 이 함수는 단순 입력 처리 함수가 아니라, 플레이어 입력값을 기반으로 새 저장과 덮어쓰기 흐름을 분기하는 핵심 제어 함수다.

3.6. 새 파일명 저장
private void SaveNewFile()
{
    _saveManager.SaveData(newFileName);
    _fileNameInputUI.SetActive(false);
    StartCoroutine(Notice());
}

SaveNewFile 함수는 새 파일명으로 저장을 수행하는 함수다.

실제 데이터 저장은 _saveManager.SaveData(newFileName)에 맡기고, 그 후 입력 UI를 비활성화한다.

이 함수는 저장 실행 자체보다, 새 파일 저장 흐름이 성공적으로 끝난 뒤 UI 상태를 어떻게 정리할지에 더 가까운 역할을 한다.

알림 코루틴을 호출해 플레이어에게 저장이 완료되었다는 피드백도 제공한다.

3.7. 덮어쓰기
private void OverwriteSave()
{
    _saveManager.SaveData(newFileName);
    _overwriteUI.SetActive(false);
    _fileNameInputUI.SetActive(false);
    StartCoroutine(Notice());
}

private void CancelOverwrite()
{
    _overwriteUI.SetActive(false);
}

OverwriteSave 함수도 SaveNewFile 함수와 비슷하지만, 덮어쓰기 확인 UI까지 함께 닫는 점이 다르다.

저장은 새 파일 저장과 같은 함수인 _saveManager.SaveData(newFileName)를 그대로 사용한다.

저장 데이터 관점에서는 새 파일 저장과 덮어쓰기가 다르지 않고, 차이는 UI 흐름과 플레이어 확인 절차에 있다.

이 구조가 중요한 이유는 저장 로직을 분기하지 않았기 때문이다.

실제 저장은 하나의 경로를 유지하고, 플레이어 경험 차이는 FileSaveManager에서만 처리한다.

이런 분리는 테스트와 유지보수에서 유리하다.

저장 성공 여부는 한 함수만 검증하면 되고, 덮어쓰기 여부는 UI 분기만 보면 된다.

CancelOverwrite 함수는 가장 짧지만 의미가 분명하다.

덮어쓰기 UI를 닫고 현재 흐름을 보류한다.

저장 자체는 실행하지 않는다.

플레이어가 입력한 newFileName은 그대로 남아 있을 수 있으므로, 다시 확인하거나 다른 이름으로 수정하는 흐름으로 자연스럽게 이어질 수 있다.

이런 식으로 기능을 쪼개 두면 각 함수가 하는 일이 명확하고, UI 상태 변화도 추적하기 쉬워진다.

3.8. 저장 완료 피드백
IEnumerator Notice()
{
    _noticePanel.SetActive(true);
    yield return new WaitForSeconds(1.5f);
    _noticePanel.SetActive(false);
}

이 코루틴은 저장 완료 알림 UI를 잠시 보여 주는 역할을 한다.

IEnumerator 반환형은 Unity에서 코루틴을 만들 때 사용하는 전형적인 형태다.

yield return new WaitForSeconds(1.5f)는 1.5초 동안 실행을 멈추고, 그 뒤 다음 줄을 이어서 실행한다.

SetActive(true)를 통해 알림 UI를 활성화하고, 1.5초 뒤에 SetActive(false)를 통해 알림 UI를 비활성화한다.

3.9. 단축키 입력 처리
public class ShortcutsManager : MonoBehaviour
{
    [SerializeField]
    private FileSaveManager _fileSaveManager;

    private void Update()
    {
        if(Input.GetKey(KeyCode.LeftControl) && Input.GetKeyDown(KeyCode.S))
        {
            _fileSaveManager.SaveGame();
        }
    }
}

ShortcutsManager는 키 입력을 통해 저장 흐름을 호출하는 입력 레이어다.

단축키가 직접 SaveManager를 호출하지 않고, FileSaveManager.SaveGame()으로 연결된다.

버튼 저장과 단축키 저장이 같은 진입점을 공유하게 만드는 구조다.

이렇게 하면 UI 버튼과 단축키가 서로 다른 입력 방식이더라도, 실제 저장 분기 로직은 한 곳에서 유지할 수 있다.

입력 처리는 Update 함수에서 이루어진다.

Unity에서 키보드 입력처럼 프레임마다 상태를 확인해야 하는 기능은 Update 함수에 두는 것이 일반적이다.

버튼 UI처럼 이벤트가 자동으로 발생하는 구조가 아니라, 현재 프레임에서 어떤 키가 눌렸는지를 계속 검사해야 하기 때문이다.

이 부분은 UI 이벤트와 단축키 입력이 서로 다른 처리 방식을 가져야 하는 이유를 보여 준다.

조건식 Input.GetKey(KeyCode.LeftControl) && Input.GetKeyDown(KeyCode.S)는 왼쪽 Ctrl을 누르고 있는 상태에서 S가 눌린 순간만 감지한다.

GetKey는 키를 누르고 있는 동안 true를 반환하고, GetKeyDown은 해당 프레임에서 눌린 순간 한 번만 true를 반환한다.

이 두 함수를 같이 쓰는 이유는 Ctrl + S 조합을 정확히 처리하기 위해서다.

S도 GetKey로 검사했다면 키를 누르고 있는 동안 저장 함수가 여러 프레임 반복 호출될 수 있다.

현재 구조는 그런 중복 호출을 막고, 단축키가 한 번 입력됐을 때 한 번만 저장 흐름으로 넘어가게 만든다.

_fileSaveManager.SaveGame()을 호출하는 것도 중요한 포인트다.

키 입력 감지 자체는 ShortcutsManager가 하지만, '새 파일 저장인지, 기존 저장인지, 입력 UI가 필요한지' 같은 판단은 여기서 하지 않는다.

이런 판단을 FileSaveManager에 맡겼기 때문에 입력 레이어와 저장 실행 레이어가 분리된다.

단축키 입력이 추가되더라도 저장 정책을 다른 곳에 복제할 필요가 없다.

4. 개발 의도

이 구조의 핵심은 저장 책임을 나누는 것이다.

SaveManager는 저장 데이터 생성과 실제 저장소 기록을 담당하고, FileSaveManager는 플레이어 입력과 UI 흐름을 제어한다.

이렇게 나누면 저장 로직은 UI 상태에 영향을 받지 않고, UI는 저장 데이터 구조를 몰라도 동작할 수 있다.

또한 저장 방식 확장도 고려했다.

현재는 PlayerPrefs를 사용하지만, FileSaveManager는 저장 방식 자체를 직접 다루지 않는다. 저장 실행은 항상 SaveManager를 통해 이루어진다.

나중에 파일 저장이나 클라우드 저장으로 변경하더라도, UI 흐름을 그대로 유지하면서 저장 부분만 교체할 수 있다.

테스트 측면에서도 장점이 있다.

저장 데이터가 제대로 생성되는지는 SaveManager만 보면 되고, 저장 흐름이 제대로 동작하는지는 FileSaveManager만 보면 된다.

입력 처리 역시 동일한 기준으로 분리하였다.

저장 흐름은 FileSaveManager를 중심으로 유지하고, ShortcutsManager는 키 입력을 감지하여 해당 흐름의 진입점만 호출하도록 구성하였다.

이 구조를 통해 버튼 입력과 단축키 입력이 동일한 저장 진입점을 공유하게 되었고, 입력 방식이 추가되더라도 저장 로직을 수정하지 않고 확장할 수 있다.