개발/유니티

25.09.03(유니티)

kimchangmin02 2025. 9. 3. 10:09
  1. JSON 직렬화 (Serialization): C#의 클래스나 리스트 같은 복잡한 데이터 구조를 하나의 긴 텍스트(문자열)로 변환하는 과정입니다.
  2. PlayerPrefs: Unity에서 제공하는 간단한 데이터 저장소입니다. 기기(PC, 스마트폰 등)에 '키(Key)-값(Value)' 형태로 간단한 데이터를 저장할 수 있습니다. (예: "내이름" - "홍길동")

 

 

 

유니티 인스펙터에서 변수 연결은 객체 생성이 아닌 '참조'입니다.

유니티(Unity)에서 스크립트의 public 변수를 인스펙터(Inspector) 창에 연결하는 것은 새로운 객체를 생성하는 행위가 아니라, 이미 씬(Scene)에 존재하거나 프로젝트 파일에 있는 객체 또는 에셋에 대한 참조를 할당하는 것입니다.

스크립트에서 변수를 public으로 선언하면 유니티 에디터의 인스펙터 창에 해당 변수가 노출되어 값을 직접 설정하거나 다른 객체를 드래그 앤 드롭으로 연결할 수 있게 됩니다.[1][2] 이는 개발의 편의성을 높여주는 유니티의 강력한 기능 중 하나입니다.[3]

예를 들어, 스크립트에 public GameObject player; 와 같이 변수를 선언하면 인스펙터 창에 'Player'라는 이름의 필드가 나타납니다. 여기에 씬에 이미 만들어져 있는 'Player' 게임 오브젝트를 끌어다 놓으면, 해당 스크립트는 이제 씬에 있는 'Player' 오브젝트를 가리키게(참조하게) 됩니다.

핵심은 다음과 같습니다:

  • 객체 생성 (Instantiation): 새로운 객체를 메모리에 만드는 과정입니다. 이는 보통 Instantiate() 함수를 사용하거나, 씬에 새로운 오브젝트를 추가할 때 일어납니다.
  • 인스펙터 연결 (Assignment in Inspector): 이미 존재하는 객체에 대한 참조를 변수에 저장하는 과정입니다.[4] 새로운 객체를 만드는 것이 아니라, "이 변수는 저기 있는 저 객체를 가리킨다"라고 알려주는 것과 같습니다.

따라서 인스펙터에서 연결만 해주는 것으로는 객체가 새로 생성되지 않습니다. 스크립트는 단지 인스펙터에 연결된 기존의 객체에 접근하여 그 객체의 컴포넌트나 속성을 제어할 수 있게 될 뿐입니다. 만약 런타임 중에 새로운 객체를 만들어야 한다면, 코드 내에서 Instantiate()와 같은 함수를 명시적으로 호출해야 합니다.

 

 

 

 

1. "씬에 새로운 오브젝트를 추가할 때 (객체 생성이) 일어납니다"의 의미

이 말은 유니티 에디터에서 우리가 무언가를 '만드는' 모든 행위가 곧 '객체 생성(Instantiation)'이라는 의미입니다.

  • Hierarchy(하이어라키) 창에서 우클릭 > Create Empty 또는 3D Object > Cube 등을 선택하는 행위: 이 순간, 비어있거나 혹은 특정 컴포넌트(예: 큐브의 Mesh Filter, Mesh Renderer)를 가진 새로운 게임 오브젝트(GameObject)가 메모리에 생성되고 씬에 배치됩니다. 이것이 바로 '객체 생성'입니다.
  • 프로젝트(Project) 창에 있는 모델 파일이나 프리팹(Prefab)을 Hierarchy 창이나 씬 뷰로 드래그 앤 드롭하는 행위: 이 또한 해당 에셋을 기반으로 한 새로운 게임 오브젝트를 '생성'하여 씬에 배치하는 과정입니다.

따라서 "유니티에서는 게임 오브젝트를 만들면 객체가 생성되나" 라는 질문에 대한 답은 "네, 그렇습니다" 입니다. 씬에 보이는 모든 게임 오브젝트는 이미 메모리에 생성되어 존재하는 '객체 인스턴스'입니다.


2. "객체 생성이 된 것을 참조로 연결했다"는 의미

이 부분도 완벽하게 이해하신 것이 맞습니다. 조금 더 쉬운 비유로 설명해 보겠습니다.

  • 게임 오브젝트 (객체 생성): 여러분이 씬에 만든 '플레이어'라는 실제 캐릭터입니다.
  • 스크립트의 public 변수 (예: public GameObject playerObject;): '플레이어 연락처'를 적을 수 있는 스마트폰의 빈 연락처 칸입니다.
  • 인스펙터에서 연결하는 행위: Hierarchy 뷰에 있는 '플레이어' 캐릭터를 마우스로 끌어서 'Player Object'라는 빈칸에 놓는 것은, 스마트폰 연락처 칸에 그 친구의 실제 전화번호를 적는 것과 같습니다.

이 행위는 새로운 친구를 만드는 것이 아니라, 이미 존재하는 친구의 연락처를 저장하는 행위입니다. 마찬가지로, 인스PECTOR에서의 연결은 새로운 게임 오브젝트를 만드는 것이 아니라, 이미 씬에 생성되어 있는 게임 오브젝트의 메모리 주소(참조)를 변수에 저장하는 것입니다.


3. Prefab과 Instantiate()를 사용하는 이유

이 부분 역시 정확히 짚으셨습니다.

  • 에디터에서의 배치: 게임을 시작하기 전(Edit-time)에는 우리가 직접 씬에 적군을 10마리 배치하거나, 나무를 100그루 심을 수 있습니다. 이것들은 모두 게임이 시작될 때 이미 '생성되어 있는' 객체들입니다.
  • 런타임에서의 한계: 하지만 게임이 실행되는 중(Runtime)에는 우리가 에디터에서처럼 마우스로 드래그해서 적을 추가할 수 없습니다. 예를 들어,
    • 플레이어가 총을 쏠 때마다 총알이 생성되어야 합니다.
    • 일정 시간마다 몬스터가 특정 위치에서 생성(스폰)되어야 합니다.
    • 플레이어가 아이템을 부수면 그 자리에 동전 아이템이 생성되어야 합니다.

이처럼 **"게임이 실행되는 도중에 새로운 객체를 만들어야 할 필요"**가 있을 때 Instantiate() 함수를 사용합니다.

  • 프리팹(Prefab): '객체의 설계도' 또는 '붕어빵 틀'이라고 생각하시면 완벽합니다. 총알, 몬스터, 아이템처럼 반복적으로 생성해야 하는 객체를 미리 설정해서 에셋으로 저장해 둔 것입니다.
  • Instantiate(prefab) 코드: 이 '붕어빵 틀(프리팹)'을 가지고 '새로운 붕어빵(게임 오브젝트)'을 찍어내는(생성하는) 명령어입니다. 이 코드를 호출할 때마다 설계도를 바탕으로 한 새로운 객체가 씬에 실시간으로 만들어집니다.

최종 정리

  • 씬에 오브젝트를 만드는 것 (에디터에서): 객체 생성입니다.
  • 인스펙터에 연결하는 것: 이미 생성된 객체의 **'주소'를 변수에 저장하는 것 (참조)**입니다.
  • Instantiate() 코드 (런타임에서): 게임 실행 중에 코드를 통해 객체를 실시간으로 생성하는 것입니다. (주로 프리팹을 사용합니다)

 

 

 

 

 

1. PlayerPrefs란 무엇인가요? (작은 데이터베이스인가요?)

결론부터 말하면, "아주아주 간단한 메모장"에 가깝습니다. 데이터베이스라고 부르기에는 기능이 매우 단순합니다.

PlayerPrefs는 Unity가 기본으로 제공하는 간단한 데이터 저장 기능입니다. 사용자의 기기(PC, 스마트폰 등)에 데이터를 파일 형태로 저장합니다.

핵심 특징:

  • 저장 방식: '키(Key)-값(Value)' 쌍으로 저장합니다. 마치 사물함에 이름표(Key)를 붙이고 물건(Value)을 넣어두는 것과 같아요.
    • PlayerPrefs.SetInt("BestScore", 100); -> "BestScore"라는 이름표에 100이라는 숫자를 저장.
    • PlayerPrefs.SetString("UserName", "Gildong"); -> "UserName"이라는 이름표에 "Gildong"이라는 글자를 저장.
  • 저장 위치: 사용자의 기기 내부에 저장됩니다. (PC의 레지스트리나 특정 폴더, 스마트폰의 앱 데이터 공간 등)
  • 용도: 주로 간단한 데이터를 저장할 때 사용합니다.
    • 게임 설정 (소리 크기, 그래픽 품질 등)
    • 최고 점수
    • 플레이어 이름
    • 게임의 간단한 진행 상황 (예: "튜토리얼을 완료했는가?")
  • 단점:
    • 보안에 취약: 저장된 파일은 비교적 쉽게 찾아서 내용을 수정할 수 있습니다. 중요한 데이터(결제 정보 등)를 저장하면 절대 안 됩니다.
    • 성능: 많은 양의 데이터를 저장하고 불러오기에는 속도가 느립니다.
    • 복잡한 데이터 저장 불가: 코드에 나온 List Dictionary 같은 복잡한 구조는 직접 저장할 수 없습니다. 그래서 JSON이라는 텍스트 형태로 '번역'해서 저장하는 것입니다.

"작은 데이터베이스인가?" 라는 질문에 대한 답:
네, 데이터를 저장한다는 기능 면에서는 데이터베이스와 목적이 같지만, 체계적인 관리, 검색, 보안, 대용량 처리 등 진짜 데이터베이스가 가진 기능은 전혀 없습니다. **'영구적으로 기록되는 메모장'**이라고 생각하시는 게 가장 정확합니다.


2. 데이터베이스(DB)란 무엇인가요?

데이터베이스(Database, DB)는 "체계적으로 정리된 데이터의 거대한 집합소" 입니다.

메모장(PlayerPrefs)과 도서관(DB)의 차이를 생각하면 쉽습니다.

  • 메모장 (PlayerPrefs): 생각나는 대로 아무렇게나 적어둘 수 있지만, 나중에 수백 장의 메모 중에서 원하는 정보를 찾으려면 일일이 다 뒤져봐야 합니다.
  • 도서관 (DB): 모든 책이 장르, 저자, 제목별로 분류되어 있고, 검색 시스템(사서 또는 컴퓨터)을 통해 "A 작가가 쓴 2010년 이후 소설 찾아주세요" 같은 복잡한 요청도 순식간에 처리할 수 있습니다.

데이터베이스의 핵심 기능:

  1. 구조화된 저장 (Structured Storage): 엑셀 시트처럼 '표(Table)' 형태로 데이터를 체계적으로 저장합니다. 각 줄(Row)은 하나의 데이터 단위를, 각 칸(Column)은 데이터의 속성을 나타냅니다.
  2. 효율적인 검색 및 관리 (Query): SQL과 같은 '질의어(Query Language)'를 사용해서 방대한 데이터 속에서 원하는 조건의 데이터만 빠르고 쉽게 찾아내거나, 수정하고, 삭제할 수 있습니다.
  3. 무결성 및 보안 (Integrity & Security): 데이터가 중복되거나 잘못된 형식으로 저장되는 것을 막고, 사용자별로 접근 권한을 제어하여 데이터를 안전하게 보호합니다.
  4. 동시 접근 제어 (Concurrency): 여러 명의 사용자나 여러 프로그램이 동시에 접속해도 데이터가 꼬이지 않도록 관리합니다.

3. 서버와 클라이언트 같은 복잡한 개념이 필요한가요?

이것도 아주 좋은 질문입니다. 정답은 "무엇을 사용하느냐에 따라 다르다" 입니다.

PlayerPrefs 사용 시:

전혀 필요 없습니다.
PlayerPrefs는 게임(클라이언트)이 실행되는 바로 그 기기 안에 모든 것을 저장하고 불러옵니다. 다른 컴퓨터(서버)와 통신할 일이 전혀 없습니다. 내 컴퓨터에 있는 메모장에 내가 글을 쓰는 것과 같습니다.

데이터베이스 사용 시:

두 가지 경우가 있습니다.

  1. 서버/클라이언트 개념이 필요 없는 경우 (로컬 DB):
    • SQLite 같은 데이터베이스는 '파일' 형태로 존재합니다. 이 DB 파일을 내 게임 프로젝트에 포함시키면, 서버 없이도 내 기기 안에서 체계적인 데이터베이스 기능을 사용할 수 있습니다.
    • PlayerPrefs의 상위 호환 버전이라고 볼 수 있습니다. PlayerPrefs보다 훨씬 더 많은 데이터를 더 빠르고 체계적으로 저장할 수 있지만, 여전히 데이터는 해당 기기에만 저장됩니다.
    • 예시: 수백 개의 아이템 정보, 몬스터 도감, 복잡한 퀘스트 진행 상황 등을 저장하는 싱글 플레이 게임.
  2. 서버/클라이언트 개념이 반드시 필요한 경우 (서버 DB):
    • MySQL, PostgreSQL, MSSQL 같은 데이터베이스는 보통 별도의 컴퓨터, 즉 **'서버'**에서 24시간 실행됩니다.
    • 클라이언트(우리의 게임, 웹사이트, 앱 등)는 인터넷을 통해 이 서버에 접속해서 "로그인 정보 확인해줘", "랭킹 데이터 보여줘" 와 같은 요청을 보내고, 서버는 DB에서 데이터를 찾아 응답을 보내줍니다.
    • 예시: 온라인 게임의 계정 정보, 유저 랭킹, 채팅 내용, 여러 사용자가 공유하는 모든 데이터.

최종 요약

구분 PlayerPrefs 데이터베이스 (DB)
비유 개인 메모장, 포스트잇 체계적인 도서관, 대형 창고
용도 간단한 설정, 최고 점수 등 소량의 데이터 대량의 구조화된 데이터 (유저 정보, 아이템, 랭킹 등)
저장 위치 내 기기 (로컬) 내 기기 (SQLite) 또는 원격 서버 (MySQL 등)
구조 단순한 'Key-Value' 쌍 체계적인 '표(Table)' 구조
보안 매우 취약함 강력한 보안 및 권한 관리 가능
서버 필요 여부 필요 없음 로컬 DB(SQLite)는 필요 없음 / 서버 DB(MySQL)는 필수

현재 작성하신 습관 앱 코드는 PlayerPrefs를 사용하므로 서버나 클라이언트 같은 복잡한 개념은 전혀 신경 쓰실 필요가 없습니다! 모든 데이터는 앱을 사용하는 유저의 스마트폰 안에만 안전하게(하지만 보안은 약하게) 저장됩니다.

 

 

 

 

1단계: PlayerPrefs + JSON (현재 코드 방식)

  • 대상: 아주 간단한 캐주얼 게임, 하이퍼캐주얼 게임, 프로토타입(시제품).
  • 장점: 구현이 가장 쉽고 빠르다. Unity 기본 기능이라 별도의 라이브러리가 필요 없다.
  • 단점:
    • 성능 문제: 데이터가 커질수록 전체 JSON을 읽고 쓰는 과정이 매우 느려집니다. 예를 들어, 아이템 100개 중 1개의 수량만 바뀌어도 100개 전체 데이터를 다시 저장해야 합니다.
    • 보안 문제: 데이터가 암호화되지 않은 텍스트(JSON)로 저장되므로, 루팅/탈옥된 폰에서는 파일을 열어 최고 점수, 재화 등을 마음대로 조작할 수 있습니다.
    • 안정성 문제: 저장 중 앱이 강제 종료되면 JSON 파일이 깨져서 모든 데이터가 날아갈 수 있습니다.

2단계: 파일 직접 저장 + 암호화 (조금 더 발전된 로컬 저장)

JSON으로 변환하는 것까지는 비슷하지만, PlayerPrefs 대신 C#의 파일 입출력 기능을 사용해 직접 파일로 저장합니다.

  • 저장 방식:
    1. 데이터 객체를 JSON (또는 Binary) 형식으로 변환합니다.
    2. 변환된 데이터를 **암호화 알고리즘(예: AES)**을 사용해 암호화합니다.
    3. 암호화된 데이터를 Application.persistentDataPath 경로에 파일(.dat, .sav 등)로 직접 저장합니다.
  • 장점:
    • 보안 강화: 데이터가 암호화되어 있어 사용자가 파일을 열어도 내용을 알아볼 수 없고 수정하기 어렵습니다.
    • 안정성 향상: '임시 파일에 저장 후 원본과 교체'하는 방식으로 구현하면, 저장 중 앱이 꺼져도 데이터가 날아가는 것을 방지할 수 있습니다.
  • 단점:
    • 여전히 전체 데이터를 읽고 쓰는 방식이라 데이터가 커지면 성능 저하가 발생합니다.
    • 암호화, 파일 관리 코드를 직접 구현해야 해서 복잡도가 올라갑니다.

3단계: 로컬 데이터베이스(SQLite) 사용 (전문적인 로컬 저장)

여기서부터 '진짜 데이터베이스' 개념이 들어갑니다. 대부분의 싱글 플레이 모바일 게임은 이 방식을 사용합니다.

  • SQLite란? 별도의 서버 없이 파일 하나로 동작하는 경량 데이터베이스입니다. 모바일 앱(안드로이드, iOS)에 내장되어 있을 정도로 널리 쓰입니다.
  • 저장 방식:
    1. 게임 안에 SQLite DB 파일(.db)을 만듭니다.
    2. 플레이어 정보 테이블, 아이템 테이블, 스테이지 클리어 정보 테이블 등 데이터를 '표' 형태로 구조화하여 저장합니다.
    3. 데이터가 변경되면 전체를 다시 쓰는 것이 아니라, 변경이 필요한 '줄'이나 '칸'만 찾아서 수정합니다.
  • 장점:
    • 압도적인 성능: 데이터가 많아져도 필요한 부분만 수정하므로 매우 빠릅니다. "레벨 50짜리 검 아이템의 공격력을 105로 변경해줘" 같은 특정 데이터 조작에 특화되어 있습니다.
    • 체계적인 관리: 데이터가 정규화된 테이블로 관리되어 구조가 명확하고 확장하기 쉽습니다.
    • 강력한 기능: 복잡한 조건으로 데이터를 검색(쿼리)하는 것이 가능합니다.
  • 단점:
    • SQL(데이터베이스 언어)에 대한 학습이 필요합니다.
    • Unity에서 사용하려면 별도의 라이브러리(플러그인)를 설정해야 합니다.

4단계: 서버 데이터베이스 (온라인 게임의 표준)

유저 랭킹, 멀티플레이, 데이터 동기화 등 다른 유저와의 상호작용이 필요하거나, 기기를 바꿔도 내 데이터를 유지하고 싶을 때 반드시 필요한 방식입니다.

  • 저장 방식:
    1. 게임(클라이언트)은 데이터를 직접 저장하지 않습니다.
    2. 모든 데이터 변경 요청(예: "몬스터를 잡아서 경험치 10을 얻었어요")을 인터넷을 통해 서버로 보냅니다.
    3. 서버는 요청을 검증하고, 서버에 있는 **중앙 데이터베이스(MySQL, MongoDB 등)**에 데이터를 저장합니다.
    4. 클라이언트는 서버로부터 처리 결과와 최신 데이터 상태를 다시 전달받아 화면에 표시합니다.
  • 장점:
    • 데이터 영속성: 핸드폰을 바꾸거나 앱을 지웠다 깔아도, 계정으로 로그인하면 내 데이터가 그대로 유지됩니다.
    • 최고 수준의 보안: 모든 데이터 검증이 서버에서 이루어지므로 클라이언트에서의 조작(치팅, 해킹)을 원천적으로 차단할 수 있습니다.
    • 멀티플레이 구현: 모든 유저의 데이터가 중앙 서버에 있으므로 랭킹, 길드, PVP 등 온라인 콘텐츠 구현이 가능합니다.
  • 단점:
    • 서버 개발 및 유지보수 비용이 발생합니다.
    • 클라이언트-서버 통신 등 개발 난이도가 급격하게 상승합니다.
    • 인터넷 연결이 필수입니다.

결론 및 추천

현재 만드시는 습관 앱의 경우, 데이터가 복잡해지거나 성능 문제가 발생한다면 다음 단계를 고려해볼 수 있습니다.

  • 만약 싱글 플레이 앱으로 남을 것이라면: **3단계. 로컬 데이터베이스(SQLite)**가 가장 이상적인 해결책입니다. 데이터가 많아져도 빠르고 안정적으로 관리할 수 있습니다.
  • 만약 여러 기기에서 데이터를 동기화하거나 친구와 습관을 공유하는 기능을 넣고 싶다면: 4단계. 서버 데이터베이스로 가야 합니다.

 

 

 

 

 

 

 

핵심은 [System.Serializable] 속성(Attribute)입니다.

JsonUtility가 클래스나 구조체를 JSON 텍스트로 변환(직렬화)하거나, JSON 텍스트를 다시 클래스 객체로 복원(역직렬화)하려면 두 가지 필수 조건이 있습니다.

  1. 클래스(또는 구조체) 선언부 바로 위에 [System.Serializable] 이라고 명시되어 있어야 합니다.
  2. JSON으로 변환하려는 필드(변수)는 반드시 public 이어야 합니다. (private 필드는 무시됩니다.)

 [System.Serializable]이 필요한가?

이 속성은 C# 컴파일러와 Unity 엔진에게 "이 클래스는 데이터를 어딘가로 옮기거나 저장할 수 있도록 특별한 형태로 변환될 수 있는 녀석입니다." 라고 알려주는 일종의 '꼬리표' 또는 '허가증'입니다.

비유를 들어 설명해 드릴게요.

  • 일반 클래스: 그냥 평범한 건물입니다. 내부 구조가 어떻게 되어있는지 외부에서는 알기 어렵습니다.
  • [System.Serializable]이 붙은 클래스: **'조립식 주택'**과 같습니다.
    • 이 꼬리표가 붙어 있으면, Unity(JsonUtility)는 "아, 이 건물은 분해해서 각 부품(필드)을 트럭(메모리 스트림, JSON 텍스트 등)에 실을 수 있구나!"라고 인식합니다.
    • 직렬화(Serialization): JsonUtility.ToJson은 이 조립식 주택을 설계도에 따라 벽(public 필드) 하나하나, 창문(public 필드) 하나하나 분해해서 트럭에 싣는 과정과 같습니다.
    • 역직렬화(Deserialization): JsonUtility.FromJson은 트럭에 실린 부품들을 설계도에 맞춰 다시 원래의 집 형태로 조립하는 과정입니다.

만약 [System.Serializable] 꼬리표가 없다면, JsonUtility는 이 클래스를 어떻게 분해하고 조립해야 할지 그 '방법' 자체를 모르기 때문에 그냥 빈 JSON ({})을 반환하거나 작업을 무시해버립니다.

 

 

 

 

 

1. [SerializeField] vs [System.Serializable]

사용자께서 기억하고 계신 것은 [SerializeField] 입니다. 두 속성은 이름이 비슷하지만 역할이 완전히 다릅니다.

  • [SerializeField]
    • 역할: Unity 인스펙터(Inspector) 창에 보이게 하는 기능입니다.
    • 대상: private 또는 protected 필드(변수)
    • 설명: 원래 private 변수는 외부(특히 인스펙터 창)에서 보거나 수정할 수 없습니다. 하지만 스크립트의 캡슐화(정보 은닉)는 지키고 싶고, 개발 중 테스트를 위해 인스펙터 창에서 값을 편하게 바꾸고 싶을 때 사용합니다.
    • 예시:
    • C#
       
      public class Player : MonoBehaviour
      {
          [SerializeField]
          private float speed = 5.0f; // private이지만 인스펙터 창에 노출됨
      }
    • JsonUtility와의 관계: [SerializeField]가 붙은 private 필드도 JsonUtility가 직렬화 대상에 포함시킵니다. 즉, JSON으로 저장됩니다.
  • [System.Serializable]
    • 역할: 클래스나 구조체 자체를 직렬화가 가능한 형태로 만들어주는 기능입니다.
    • 대상: class 또는 struct 선언부
    • 설명: 이 속성은 특정 변수 하나가 아니라, 클래스 전체에 대한 '허가증'입니다. "이 클래스는 통째로 데이터 덩어리로 변환될 수 있습니다"라고 알려주는 역할을 합니다. 이 허가증이 없으면, 해당 클래스 타입의 변수는 public이든 [SerializeField]가 붙었든 JsonUtility가 아예 처리하지 못합니다.
    • 예시:
    • C#
       
      [System.Serializable] // 이 클래스는 직렬화 가능!
      public class MyCustomData 
      {
          public int value1;
          public string value2;
      }
      
      public class GameManager : MonoBehaviour
      {
          public MyCustomData gameData; // MyCustomData에 [System.Serializable]이 없다면 이 필드는 JSON으로 저장되지 않음
      }

결론: 두 개는 관련이 있지만 목적이 다릅니다.

  • [System.Serializable]은 클래스를 직렬화할 수 있도록 자격을 부여하는 것.
  • public 또는 [SerializeField]는 클래스 내의 어떤 필드들을 직렬화 대상으로 삼을지 결정하는 것.

 

 

 

 

 

"왜 사전은 안 되는데 리스트는 되나요?"

아주 좋은 질문입니다. 이것은 Unity의 JsonUtility가 내부적으로 어떻게 동작하는지에 대한 핵심적인 문제입니다.

간단한 답변: Unity의 JsonUtility가 그렇게 설계되었기 때문입니다. JsonUtility는 빠르고 간단한 사용에 초점을 맞춘 도구라서, 복잡한 자료 구조보다는 기본적인 것들만 지원하도록 만들어졌습니다.

조금 더 자세한 기술적 설명:

1. JSON의 구조와 리스트(배열)의 유사성

JSON(JavaScript Object Notation)은 데이터를 표현하는 매우 단순한 규칙을 가지고 있습니다. 크게 두 가지 구조로 이루어져 있습니다.

  • 객체 (Object): { "key": "value", "another_key": 123 }
    • 중괄호 {}로 묶고, 'Key-Value' 쌍으로 데이터를 표현합니다. C#의 클래스와 매우 유사합니다.
  • 배열 (Array): [ "apple", "banana", "cherry" ]
    • 대괄호 []로 묶고, 순서가 있는 값들의 목록을 표현합니다. C#의 배열이나 리스트와 완벽하게 일치합니다.

JsonUtility는 C#의 클래스를 JSON 객체로, C#의 리스트/배열을 JSON 배열로 1:1로 변환하는 간단한 규칙을 따릅니다.

2. 사전(Dictionary)의 복잡성

반면에 C#의 Dictionary<TKey, TValue>는 조금 더 복잡합니다.

  • 키(Key)의 타입: Dictionary의 키는 string 뿐만 아니라 int, enum, 심지어 직접 만든 클래스 객체 등 다양한 타입이 될 수 있습니다. 하지만 JSON 객체의 키는 반드시 문자열(string)이어야 한다는 규칙이 있습니다. JsonUtility는 이러한 다양한 키 타입을 어떻게 문자열로 변환해야 할지 일반적인 규칙을 정하기 어렵습니다.
  • 직렬화의 모호성: Dictionary를 JSON으로 어떻게 표현할지에 대한 표준적인 방법이 여러 가지가 있을 수 있습니다.
    • 방법 A (배열 안에 객체 넣기): [ { "key": "운동하기", "value": [...] }, { "key": "독서", "value": [...] } ]
    • 방법 B (객체로 표현하기): { "운동하기": [...], "독서": [...] }

이런 모호함과 복잡성 때문에, Unity 개발팀은 JsonUtility를 만들 때 "Dictionary는 지원하지 말자. 대신 사용자가 직접 방법 A처럼 리스트 형태로 변환해서 사용하도록 하자"라고 결정한 것입니다. 현재 우리가 분석한 코드가 바로 이 **'방법 A'**를 직접 구현한 예시입니다.

3. "리스트는 나중에 문자열로 변환하나?"

네, 맞습니다! JsonUtility.ToJson() 메소드가 호출되는 순간, 다음과 같은 일이 순차적으로 일어납니다.

  1. JsonUtility SaveDataContainer 객체를 분석합니다.
  2. 첫 번째 필드인 habitList를 봅니다. "오케이, List<string>이군. 이건 JSON 배열로 바꾸면 되겠다."
    • ["운동하기", "독서"] 와 같은 텍스트를 생성합니다.
  3. 두 번째 필드인 habitRecords를 봅니다. "오케이, List<HabitRecord>이군. 이것도 JSON 배열로 바꿔야지."
  4. habitRecords 리스트의 첫 번째 요소(HabitRecord 객체)를 분석합니다.
    • "이 객체는 habitName dateTicks 필드가 있네. 이건 JSON 객체로 바꿔야겠다."
    • { "habitName": "운동하기", "dateTicks": [639..., 639...] } 와 같은 텍스트를 생성합니다.
  5. 리스트의 두 번째, 세 번째... 요소도 똑같이 처리합니다.
  6. 이 모든 조각들을 합쳐서 최종적인 하나의 거대한 문자열을 만들어냅니다.
JSON
 
{
  "habitList": ["운동하기", "독서"],
  "habitRecords": [
    {
      "habitName": "운동하기",
      "dateTicks": [639162048000000000, 639170688000000000]
    },
    {
      "habitName": "독서",
      "dateTicks": [639165504000000000]
    }
  ]
}

이처럼 List는 JSON의 Array 구조와 직접적으로 매칭되기 때문에 JsonUtility가 쉽게 처리할 수 있는 것입니다.

 

 

 

 

 

1. SaveData()가 호출될 때마다 매번 다시 다 지우고 새로 쓰는 건가요?

네, 맞습니다.

SaveData() '덮어쓰기(Overwrite)' 방식으로 동작합니다. 이전 저장 내용에 무언가를 추가하거나 수정하는 것이 아니라, 현재 메모리에 있는 모든 데이터를 통째로 다시 저장합니다.

비유하자면 이렇습니다.

  • 잘못된 생각 (수정/추가 방식): 어제 찍은 단체 사진에 오늘 새로 온 사람 한 명을 합성해서 붙이는 것. (X)
  • 실제 동작 (덮어쓰기 방식): 어제 찍은 단체 사진은 그냥 버리고, 오늘 새로 온 사람을 포함해서 모든 인원이 다시 모여 단체 사진을 새로 찍는 것. (O)

PlayerPrefs.SetString("MyHabitData", json); 이 코드가 바로 그 역할을 합니다. "MyHabitData"라는 이름표가 붙은 사물함에 기존에 무엇이 들어있었든 상관없이, 그냥 비워버리고 새로운 json 문자열을 집어넣습니다.

이 방식은 구현이 매우 간단하다는 장점이 있지만, 데이터가 많아지면 매번 전체를 다시 써야 하므로 비효율적일 수 있습니다. (이것이 나중에 SQLite 같은 데이터베이스를 배우는 이유입니다.)


2. 이미 추가된 녀석은 또 추가할 필요 없는데, 그 부분은 어떻게 처리했나요?

이것이 바로 '덮어쓰기' 방식의 핵심입니다. "중복을 신경 쓸 필요가 없다"는 것이죠.

AddHabit 함수의 흐름을 따라가 보겠습니다.

  1. 초기 상태 (메모리):
    • habitList = ["운동하기", "독서"]
  2. AddHabit("물 마시기") 호출:
    • habitList.Add("물 마시기"); 코드가 실행됩니다.
    • 현재 메모리 상태: habitList = ["운동하기", "독서", "물 마시기"]
  3. SaveData() 호출:
    • SaveData 함수는 현재 메모리에 있는 habitList를 봅니다.
    • ["운동하기", "독서", "물 마시기"] 라는 완성된 목록 전체를 가져옵니다.
    • 이 전체 목록을 JSON으로 변환합니다.
    • 변환된 JSON 텍스트를 PlayerPrefs 통째로 덮어씁니다.

SaveData() 함수는 "어? '물 마시기'가 새로 추가됐네? 이것만 추가해야지" 라고 생각하지 않습니다. 그냥 "현재 상태가 어떠하든, 지금 보이는 이 모습 그대로를 저장한다" 라는 단순한 원칙만 따릅니다.

마찬가지로 DeleteHabit도 마찬가지입니다.

  1. 초기 상태 (메모리):
    • habitList = ["운동하기", "독서", "물 마시기"]
  2. DeleteHabit("독서") 호출:
    • habitList.Remove("독서"); 코드가 실행됩니다.
    • 현재 메모리 상태: habitList = ["운동하기", "물 마시기"]
  3. SaveData() 호출:
    • SaveData 함수는 현재 메모리에 있는 habitList를 봅니다.
    • ["운동하기", "물 마시기"] 라는 최신 목록 전체를 가져옵니다.
    • 이 목록을 PlayerPrefs 통째로 덮어씁니다.

결론:

 DataManager의 설계 철학은 다음과 같습니다.

  1. 모든 데이터 변경(Add, Delete, AddRecord)은 일단 메모리에서만 일어난다.
  2. 데이터 변경이 끝난 직후, 메모리의 현재 상태를 '스냅샷' 찍듯이 SaveData()를 호출하여 파일에 덮어쓴다.
  3. 따라서 SaveData() 함수 자체는 '무엇이 바뀌었는지' 전혀 알 필요 없이, 그냥 현재 상태를 그대로 저장하는 역할만 충실히 수행하면 된다.

 

 

 

 

 

 

1. JsonUtility.FromJson은 어떻게 동작하는가? (새로운 객체를 만드는 건가?)

네, 맞습니다. 정확히는 "새로운 객체를 만들고, JSON 데이터로 그 객체의 속성을 채워 넣는다" 입니다.

FromJson은 두 가지 정보가 필요합니다.

  1. 데이터가 담긴 json 문자열: "어떤 내용으로 채워야 하는가?"
  2. 클래스 타입 정보: "어떤 모양의 그릇(객체)을 만들어야 하는가?"

LoadData 함수의 이 코드를 봅시다.

C#
 
SaveDataContainer dataContainer = JsonUtility.FromJson<SaveDataContainer>(json);

이 한 줄의 코드가 실행될 때 내부적으로 일어나는 일은 다음과 같습니다.

  1. "어떤 모양의 그릇이 필요하지? 아, SaveDataContainer 모양이구나."
    • JsonUtility는 제네릭 타입 <SaveDataContainer>를 보고, SaveDataContainer 클래스의 설계도를 찾아봅니다.
    • 설계도에는 public List<string> habitList; public List<HabitRecord> habitRecords;라는 두 개의 칸이 있다는 것을 확인합니다.
  2. "일단 SaveDataContainer 모양의 비어있는 새 그릇을 만들자."
    • new SaveDataContainer()를 호출하는 것과 유사하게, 메모리에 새로운 SaveDataContainer 객체를 생성합니다.
    • 현재 이 새 객체의 habitList habitRecords는 모두 null(비어있음) 상태입니다.
  3. "이제 json 문자열을 읽으면서 내용을 채워 넣어야겠다."
    • json 문자열을 한 줄씩 파싱(분석)합니다.
    • JSON
       
      {
        "habitList": ["운동하기", "독서"],
        "habitRecords": [ ... ]
      }
    • "JSON에 habitList라는 키가 있네? 내 그릇에도 habitList라는 칸이 있지!"
      • JSON의 habitList 값인 ["운동하기", "독서"] (JSON 배열)를 읽습니다.
      • 이것을 C#의 List<string> 형태로 변환합니다.
      • 새로 만든 SaveDataContainer 객체의 habitList 필드에 이 List<string>을 할당(대입)합니다.
    • "JSON에 habitRecords라는 키도 있네? 내 그릇에도 habitRecords 칸이 있지!"
      • JSON의 habitRecords 값인 [ { "habitName": ... }, { ... } ] (JSON 배열)를 읽습니다.
      • 이것을 C#의 List<HabitRecord> 형태로 변환하는 복잡한 과정을 재귀적으로 수행합니다. (배열 안의 각 JSON 객체를 HabitRecord 객체로 하나씩 만듭니다.)
      • 변환이 끝난 List<HabitRecord> SaveDataContainer 객체의 habitRecords 필드에 할당합니다.
  4. "다 채웠다! 이 완성된 그릇(dataContainer 변수)을 돌려주자."
    • 모든 내용이 채워진 SaveDataContainer 객체가 dataContainer 변수에 최종적으로 담기게 됩니다.

2. 사전도 객체인가? 사전을 만들고 있는 건가?

아주 중요한 질문입니다.

  • 사전(Dictionary)도 객체인가?
    • 네, C#에서는 Dictionary를 포함한 거의 모든 것이 '객체(Object)'입니다. 클래스, 구조체, 리스트, 사전 모두 객체의 한 종류입니다.
  • JsonUtility.FromJson이 사전을 만들고 있는 건가?
    • 아닙니다. 이것이 핵심입니다.
    • JsonUtility.FromJson<SaveDataContainer>(json); 이 코드는 SaveDataContainer 객체를 만들고 있을 뿐, Dictionary를 직접 만들지는 않습니다. SaveDataContainer 클래스의 설계도에는 Dictionary가 아니라 List<HabitRecord>가 들어있기 때문입니다.

LoadData 함수의 전체 흐름을 다시 보면 명확해집니다.

C#
 
public void LoadData()
{
    // 1. DataManager가 사용할 최종 목표인 '사전'을 비어있는 상태로 만듭니다.
    this.habitRecords = new Dictionary<string, List<DateTime>>();

    if (PlayerPrefs.HasKey("MyHabyData"))
    {
        // ... (json 문자열 읽기) ...

        // 2. [FromJson]은 '사전'이 아닌 'SaveDataContainer' 객체를 만듭니다.
        // 이 객체 안에는 'List<HabitRecord>'가 들어 있습니다.
        SaveDataContainer dataContainer = JsonUtility.FromJson<SaveDataContainer>(json);

        // 3. '사전'을 직접 만드는 부분은 바로 여기, 'foreach' 루프입니다.
        // 우리가 직접 코드로 '역번역'을 수행하는 과정입니다.
        if (dataContainer.habitRecords != null)
        {
            foreach (var record in dataContainer.habitRecords) // 'List'를 순회
            {
                // ... (long -> DateTime 역번역) ...
                
                // ★★★ 바로 이 라인이 사전에 데이터를 채워 넣는(만드는) 코드입니다! ★★★
                // this.habitRecords 라는 사전에
                // Key는 record.habitName으로,
                // Value는 loadedDates(List<DateTime>)로 하여 데이터를 추가합니다.
                this.habitRecords[record.habitName] = loadedDates;
            }
        }
    }
    // ...
}

결론:

JsonUtility.FromJson은 **저장용 중간 단계 객체(SaveDataContainer)**를 만드는 역할만 합니다.

실제로 우리가 최종적으로 사용하고 싶은 **사전(Dictionary)**을 만드는 작업은, FromJson으로 복원된 List<HabitRecord> foreach 문으로 하나씩 돌면서, 우리가 직접 코드로 데이터를 재조립(역번역)하는 과정을 통해 이루어집니다.

SaveData  Dictionary -> List로 **'번역'**했던 것처럼,
LoadData 때는 List -> Dictionary로 **'역번역'**하는 것입니다. 그리고 이 역번역은 FromJson이 해주는 것이 아니라 우리가 직접 코딩한 것입니다

 

 

 

 

 

 

 

 

1. public이어야 하는 이유: JsonUtility는 '외부인'이다

결론부터 말하면, JsonUtility는 우리가 작성한 클래스의 '외부'에 있는 별개의 도구이기 때문입니다.

C#의 접근 제한자 규칙을 다시 생각해 보겠습니다.

  • public: 누구나 접근 가능. 클래스 내부, 외부, 상속받은 클래스 등 어디서든 접근할 수 있습니다.
  • private: 오직 해당 클래스 내부에서만 접근 가능. 외부에서는 그 변수가 존재하는지조차 알 수 없습니다.

JsonUtility.ToJson(myObject)를 호출할 때, JsonUtility myObject라는 객체를 '밖에서' 들여다보며 분석합니다. 마치 의사가 환자를 진찰하는 것과 같습니다.

  • public 필드: 환자가 "여기가 아파요"라고 스스로 말해주는 정보. 의사(JsonUtility)는 이 정보를 쉽게 얻어서 진단서(JSON)에 기록할 수 있습니다.
  • private 필드: 환자의 마음속 생각이나 숨겨진 병력. 환자가 직접 말해주지 않으면 의사(JsonUtility)는 알 방법이 없습니다. 따라서 진단서에 기록할 수 없습니다.

JsonUtility는 C#의 문법 규칙을 철저히 따르는 '착한 도구'입니다. 그래서 private으로 선언된 정보에는 "아, 이건 외부인인 내가 건드리면 안 되는 정보구나"라고 판단하고 그냥 무시해버리는 것입니다.

2. "같은 폴더면 접근할 수 있다"는 개념의 오해

아마도 다른 프로그래밍 언어의 개념(예: Java의 package-private)이나 C#의 internal 접근 제한자와 혼동하신 것 같습니다.

  • Java의 package-private (default): 같은 패키지(폴더라고 생각할 수 있음) 안에서는 public처럼 접근 가능.
  • C#의 internal: 같은 '어셈블리'(보통 하나의 프로젝트) 안에서는 public처럼 접근 가능.
  • C#의 private: 가장 강력한 제한. 같은 폴더, 같은 프로젝트 여부와 상관없이 오직 그 클래스 { } 블록 안에서만 접근할 수 있습니다. 예외는 없습니다.

using (말씀하신 'import')은 다른 네임스페이스에 있는 클래스나 타입을 "이 파일에서 그 이름을 사용하겠습니다"라고 선언하는 것이지, private 멤버에 접근할 수 있게 해주는 마법의 열쇠가 아닙니다.

3. [SerializeField]가 예외적인 이유

앞서 설명드렸던 [SerializeField]가 바로 이 private 규칙에 대한 유일한 예외를 만들어주는 특별한 지시어입니다.

C#
 
[SerializeField]
private float speed;

이 코드는 컴파일러와 Unity 엔진에게 이렇게 말하는 것과 같습니다.

"이 speed 변수는 원래 private 규칙에 따라 외부에서 절대 못 건드리는 게 맞아. 하지만 특별히 Unity 엔진(인스펙터 창, 그리고 직렬화 시스템인 JsonUtility 포함)에게만큼은 예외적으로 접근을 허용해 줘!"

그래서 [SerializeField]를 붙이면 private임에도 불구하고 JsonUtility가 그 값을 읽어서 JSON으로 만들 수 있는 것입니다.


최종 정리

접근 방식 JsonUtility가 접근할 수 있는가? 이유
public float health; O (가능) public은 원래부터 외부 접근이 허용됨.
private float mana; X (불가능) private은 클래스 외부의 접근을 엄격히 금지함. JsonUtility는 외부인.
[SerializeField] private float stamina; O (가능) [SerializeField]가 "Unity 엔진에게는 특별히 허용해!"라는 예외 규칙을 만들어 줌.

따라서 JsonUtility를 사용할 때는, 저장하고 싶은 데이터를 public으로 만들거나, 캡슐화를 지키고 싶다면 private으로 선언하고 [SerializeField]를 붙여주는 것이 규칙입니다.

 

 

 

 

 

 

 

1. 이름이 같은 습관을 추가하면 어떻게 되나요?

현재 코드 구조에서는 두 가지 데이터 저장소가 따로 움직이기 때문에 문제가 발생할 수 있습니다.

  • habitList (List<string>):
    • List는 중복을 허용합니다. habitList.Add("운동하기")를 두 번 호출하면 리스트에는 "운동하기"가 두 개 들어갑니다. 서로 다른 습관으로 고려되지 않고 그냥 목록에 이름만 두 개 들어갑니다. UI에서는 아마 두 줄로 보일 겁니다.
  • habitRecords (Dictionary<string, List<DateTime>>):
    • Dictionary Key는 절대 중복될 수 없습니다.
    • 만약 habitRecords["운동하기"] = new List<DateTime>(); 코드가 이미 실행된 상태에서 또 실행되어도, 새로운 리스트로 '덮어써질' 뿐, "운동하기"라는 Key가 두 개 생기지는 않습니다.

결론적으로 현재 코드의 잠재적 문제입니다.
만약 UI에서 이름 중복을 막는 처리가 없다면, 사용자가 "운동하기"를 두 번 추가할 수 있습니다.

  • habitList에는 ["운동하기", "운동하기"]가 들어갑니다.
  • 하지만 habitRecords에는 "운동하기"라는 Key가 하나뿐이므로, 두 습관이 하나의 기록 리스트를 공유하게 됩니다. 한쪽에서 기록을 추가하면 다른 쪽에도 반영되는 것처럼 보이게 되어 사용자에게 큰 혼란을 G줄 수 있습니다.

해결책:
AddHabit 함수에서 이미 같은 이름의 습관이 habitList에 있는지 먼저 검사해야 합니다.

C#
 
public void AddHabit(string newHabit)
{
    // 이미 존재하는지 검사!
    if (habitList.Contains(newHabit))
    {
        Debug.LogWarning("이미 존재하는 습관입니다: " + newHabit);
        return; // 함수를 여기서 종료
    }

    // 존재하지 않을 때만 추가하고 저장
    habitList.Add(newHabit);
    SaveData();
}

2. 바뀐 부분만 업데이트하고 싶다면 어떻게 해야 하나요?

PlayerPrefs JsonUtility를 사용하는 현재 방식으로는 사실상 불가능에 가깝습니다. JSON은 단순한 텍스트 파일이라 "파일의 특정 줄만 찾아가서 이 내용으로 바꿔줘" 같은 기능이 없습니다.

바뀐 부분만 업데이트하려면, 앞서 설명드렸던 데이터베이스(DB), 특히 SQLite 같은 로컬 DB를 사용해야 합니다.

DB를 사용했을 때의 시나리오를 보여드릴게요.

가정: Habits라는 이름의 테이블(엑셀 시트)이 있다고 상상해 보세요.
| ID (고유번호) | Name (이름) |
|---|---|
| 1 | 운동하기 |
| 2 | 독서 |

AddHabit("물 마시기")가 호출되면:

  • DB에 보내는 명령(SQL): INSERT INTO Habits (Name) VALUES ('물 마시기');
  • 결과: 파일 전체를 다시 쓰는 게 아니라, 테이블의 맨 마지막에 새로운 한 줄만 추가됩니다.

기록을 추가(AddRecord)하면:

  • Records라는 다른 테이블에 INSERT 명령으로 한 줄만 추가합니다.

습관 이름을 "운동하기"에서 "헬스하기"로 바꾸면:

  • DB에 보내는 명령: UPDATE Habits SET Name = '헬스하기' WHERE ID = 1;
  • 결과: ID가 1인 줄을 찾아서 Name 칸의 내용만 수정합니다.

이처럼 DB를 사용하면 데이터의 특정 부분만 효율적으로 추가, 수정, 삭제(CRUD)할 수 있습니다.


3. SaveData가 호출될 때마다 JSON 파일을 수정하는 건가요? 좀 안 좋은 것 같은데요.

네, 맞습니다. 매번 파일을 열고, 내용을 전부 지우고, 새로운 내용 전체를 다시 씁니다.

말씀하신 대로 이것은 매우 안 좋은(비효율적인) 방식입니다.

  • 성능 저하: 데이터가 몇십 킬로바이트(KB) 수준일 때는 거의 티가 안 나지만, 메가바이트(MB) 단위로 커지면 저장할 때마다 게임이 눈에 띄게 멈칫거릴 수 있습니다. (렉 발생)
  • 배터리 소모: 모바일 기기에서는 파일 입출력(I/O)이 배터리를 많이 소모하는 작업 중 하나입니다. 잦은 저장은 배터리 수명에 좋지 않습니다.
  • 저장 매체 수명: SSD 같은 플래시 메모리는 쓰기 횟수에 수명이 있습니다. 게임 데이터 정도로 수명이 다하는 일은 거의 없지만, 원리적으로는 좋지 않은 방식입니다.

그러면 왜 이 방식을 쓸까요?

  1. 구현이 압도적으로 쉽다: 초보자가 데이터베이스나 복잡한 파일 시스템을 배우지 않고도 가장 빠르고 직관적으로 저장 기능을 만들 수 있습니다.
  2. 데이터가 적을 때는 문제가 안 된다: 이 습관 앱처럼 저장할 데이터의 총량이 매우 적을 것으로 예상되는 프로젝트에서는 성능 저하가 거의 체감되지 않습니다. "나쁜 방식"이지만 "충분히 쓸만한 방식"인 셈이죠.

개선 방안:
꼭 DB를 쓰지 않더라도, SaveData 호출 빈도를 줄이는 방법으로 개선할 수 있습니다.

  • 자동 저장 타이머: 데이터가 변경될 때마다 저장하는 대신, '변경 플래그'만 켜두고 5분마다 한 번씩 저장하도록 타이머를 설정합니다.
  • 특정 시점에만 저장: 게임을 종료할 때(OnApplicationQuit), 앱이 백그라운드로 전환될 때(OnApplicationPause) 등 중요한 시점에만 SaveData를 호출합니다. 이렇게 하면 불필요한 파일 쓰기를 크게 줄일 수 있습니다.

결론적으로, 현재의 SaveData 방식은 "간단한 프로젝트를 위한 쉽고 빠른 구현"에 초점을 맞춘 트레이드오프(trade-off)의 결과물입니다. 프로젝트의 규모가 커지고 데이터가 복잡해지면 반드시 더 효율적인 방식으로 전환해야 합니다.

 

 

 

 

 

 

 

1. JSON 안에 있는 문자열은 어떤 형식인가요?

네, 사용자께서 예시로 드신 그 형식이 정확히 맞습니다. PlayerPrefs에 "MyHabitData"라는 키로 저장된 값(문자열)을 실제로 열어보면, 정말로 아래와 같은 텍스트가 통째로 들어있습니다.

JSON
 
{
  "habitList": ["운동하기", "독서"],
  "habitRecords": [
    {
      "habitName": "운동하기",
      "dateTicks": [638451264000000000, 638452128000000000]
    },
    {
      "habitName": "독서",
      "dateTicks": [638452992000000000]
    }
  ]
}
  • {}: JSON 객체를 의미 (C# 클래스와 유사)
  • []: JSON 배열을 의미 (C# 리스트/배열과 유사)
  • "habitList":, "habitName":: Key와 Value가 콜론 : 으로 구분됨

이 전체가 하나의 거대한 문자열(string)입니다.

2. "근데 이렇게는 못한다면서요?" - JSON 객체 vs C# Dictionary

이것이 핵심입니다. 겉모습이 비슷하다고 해서 속까지 같은 것은 아닙니다.

JSON 객체 { "key": value } 와 **C# Dictionary**는 근본적으로 다릅니다.

구분 JSON 객체 ({} in Text) C# Dictionary<TKey, TValue>
정체 데이터 표현 규칙(Format), 그냥 텍스트 C# 언어의 자료 구조(Data Structure), 메모리 상의 객체
Key 타입 반드시 문자열(string) 이어야 함. "key"처럼 쌍따옴표 필수. string, int, enum, 클래스 등 어떤 타입이든 가능.
Value 타입 문자열, 숫자, 불리언, 배열, 다른 객체 등 제한된 타입만 가능 Key와 마찬가지로 어떤 타입이든 가능.
기능 데이터를 담고 있을 뿐, 자체적인 기능이 없음. .Add(), .Remove(), .ContainsKey() 등 다양한 **메소드(기능)**를 가짐.

JsonUtility가 "Dictionary는 처리 못해요"라고 말하는 것은, C#의 Dictionary 객체가 가진 복잡성(다양한 Key 타입, 내부적인 해시 테이블 구조 등)을 JSON 텍스트로 어떻게 표현해야 할지 모르겠다는 뜻입니다.

3. 사전 형태로는 뭐지? (혼동 해결!)

'사전 형태'라는 말을 두 가지로 나누어 생각해야 합니다.

1. C#의 Dictionary 객체 (우리가 코딩할 때 쓰는 것)

  • 이것은 JsonUtility 처리하지 못하는 대상입니다.
  • 우리가 SaveData에서 '번역'을 통해 피하려고 했던 바로 그 형태입니다.
  • C#
     
    // 이건 C# 코드 속, 메모리에 존재하는 '객체'
    var myDict = new Dictionary<string, List<DateTime>>();
    myDict.Add("운동하기", new List<DateTime>());

2. JSON 객체 (텍스트 파일에 저장되는 결과물)

  • 이것은 JsonUtility 만들어내는 결과물입니다.
  • 겉모습이 사전처럼 { "key": value } 형태를 띠고 있지만, 본질은 그냥 텍스트 덩어리입니다. C# Dictionary 객체가 가진 기능이나 유연성은 전혀 없습니다.
  • JSON
     
    // 이건 파일에 저장된 '텍스트'
    {
      "habitName": "운동하기",
      "dateTicks": [6384..., 6384...]
    }

결론 (가장 중요한 비유):

  • **C# Dictionary**는 살아 움직이는 **'로봇 강아지'**입니다. 다양한 기능(메소드)이 있고, 복잡한 내부 구조를 가지고 있습니다.
  • JSON 객체는 그 로봇 강아지를 찍은 '사진' 또는 **'3D 모델링 데이터(텍스트)'**입니다.
    • 사진은 로봇 강아지의 모습을 완벽하게 담고 있어서, 누가 봐도 로봇 강아지처럼 보입니다.
    • 하지만 사진 자체는 짖거나 꼬리를 흔드는 기능이 전혀 없습니다. 그냥 그림(텍스트)일 뿐입니다.

JsonUtility.ToJson()은 '로봇 강아지'를 직접 '사진'으로 찍지 못합니다. 너무 복잡해서요.

그래서 우리가 SaveData에서 하는 일은,

  1. 로봇 강아지(Dictionary)를 분해해서 **'조립식 블록 장난감'(List<HabitRecord>)**으로 만듭니다.
  2. JsonUtility는 이 간단한 '조립식 블록 장난감'을 보고 **'설계도(JSON 텍스트)'**를 그립니다. 이 설계도에는 로봇 강아지의 모습이 완벽하게 담겨 있습니다.

LoadData에서는

  1. JsonUtility.FromJson()이 **'설계도(JSON 텍스트)'**를 보고 **'조립식 블록 장난감(List<HabitRecord>)'**을 다시 조립합니다.
  2. 우리가 직접 짠 foreach 코드가 그 '조립식 블록 장난감'을 이용해 다시 원래의 살아 움직이는 **'로봇 강아지(Dictionary)'**를 만들어내는 것입니다.

 

 

 

 

 

1. 앱이 꺼질 때 저장 안 되는 위험성 vs. 잦은 저장의 비효율성

말씀하신 대로, 저장을 미루는 방식의 가장 큰 단점은 **"마지막 저장 시점과 앱이 비정상적으로 종료되는 시점 사이의 데이터가 유실될 위험"**이 있다는 것입니다.

이것은 결국 안정성(Safety)과 성능(Performance) 사이의 트레이드오프(Trade-off) 문제입니다.

방식 장점 단점 적합한 경우
변경 시 즉시 저장 (현재 방식) - 데이터 유실 위험 거의 없음<br>- 구현이 가장 간단함 - 성능 저하 (렉 유발)<br>- 배터리 소모 심함<br>- 잦은 파일 I/O 발생 - 데이터가 매우 중요하고 양이 적은 앱 (메모장, 은행 앱 등)<br>- 간단한 프로토타입
특정 시점에만 저장 (종료/백그라운드) - 평상시 성능에 전혀 영향 없음<br>- 배터리 효율 좋음 - 비정상 종료 시 데이터 유실 위험 가장 큼 (예: OS 강제 종료, 폰 배터리 방전) - 자동 저장이 없는 PC 게임<br>- 데이터 유실이 치명적이지 않은 유틸리티 앱
자동 저장 타이머 (주기적 저장) - 성능과 안정성의 균형<br>- 최악의 경우에도 최근 몇 분간의 데이터만 유실됨 - 구현이 조금 더 복잡함<br>- 항상 최신 상태는 아님 - 대부분의 싱글 플레이 게임 (문서 편집기, RPG 게임 등)<br>- 현재 습관 앱에 가장 추천되는 방식

결론: 맞습니다. 저장을 미루면 앱이 갑자기 꺼졌을 때 데이터가 날아갈 위험이 존재합니다. 그래서 얼마나 자주 저장할지는 "최악의 경우 어느 정도의 데이터 손실까지 감당할 수 있는가?"를 기준으로 결정해야 합니다.

  • 습관 앱의 경우: 5분 동안 열심히 기록한 데이터가 날아가면 사용자는 매우 화가 날 겁니다. 따라서 '변경 플래그'와 '주기적 저장'을 결합하는 것이 가장 이상적입니다.
    • 변경 플래그(isDirty): 데이터가 변경되면 isDirty = true;로 설정합니다.
    • 주기적 저장: 1분마다 isDirty 플래그를 확인하고, true일 경우에만 SaveData()를 호출한 뒤 isDirty = false;로 바꿉니다. 이렇게 하면 변경이 없을 때는 불필요한 저장을 하지 않아 효율적입니다.
    • 종료 시 저장: 위 방식에 더해, OnApplicationQuit 시점에도 isDirty를 확인하고 저장하면 안정성이 더욱 높아집니다.

2. 앱이 꺼지는 그 시점에 많은 일을 한 번에 할 수 있는가?

이것도 아주 중요한 문제입니다. 결론부터 말하면, "OS가 허용하는 짧은 시간 안에 끝내야 한다" 입니다.

OnApplicationQuit() (PC/에디터) 또는 OnApplicationPause(bool pauseStatus) (모바일) 같은 이벤트 함수들은 앱의 생명주기에서 매우 중요한 시점에 호출됩니다.

  • OnApplicationQuit (PC, Unity 에디터에서 정지 버튼 누를 때):
    • 이 함수가 호출되면 앱은 종료 절차에 들어갑니다.
    • 여기서 파일 저장 같은 작업을 수행할 수 있습니다.
    • 보통 몇 초 정도의 시간은 주어지지만, 너무 오래 걸리는 작업을 하면 OS가 응답 없다고 판단하고 강제 종료시킬 수 있습니다.
  • OnApplicationPause(true) (모바일에서 홈 버튼 누르거나 다른 앱으로 전환할 때):
    • 이것이 모바일에서는 가장 중요한 저장 시점입니다. 대부분의 사용자는 앱을 '종료'하기보다 '백그라운드로 보낼' 뿐입니다.
    • OS는 앱이 백그라운드로 전환될 때, 메모리가 부족하면 언제든지 예고 없이 앱 프로세스를 죽여버릴 수 있습니다. 따라서 이 시점에 데이터를 저장하는 것이 매우 중요합니다.
    • OS가 허용하는 시간은 매우 짧습니다 (보통 1~5초 내외). 이 시간 안에 저장 작업이 끝나지 않으면, OS는 가차없이 앱을 정지시키고 저장 작업은 중간에 끊겨버립니다.

질문에 대한 답변:

"많은 일을 한 번에 할 수 있는가?" -> 아니오. 빠르고 가벼운 일만 보장됩니다.

현재의 SaveData 함수는 JSON 직렬화와 PlayerPrefs 저장을 사용합니다. 데이터의 양이 수 메가바이트(MB) 이하 수준이라면, 이 작업은 수십~수백 밀리초(ms) 안에 끝납니다. (1초 = 1000ms)

따라서 현재의 SaveData 함수는 OnApplicationQuit이나 OnApplicationPause에서 호출해도 전혀 문제가 없을 만큼 빠릅니다.

하지만 만약 저장해야 할 데이터가 수십 MB가 넘어가거나, 원격 서버에 데이터를 전송하는 등 오래 걸리는 작업을 이 시점에 수행하려고 하면, 데이터가 제대로 저장되기 전에 앱이 종료되어 파일이 깨지거나 데이터가 유실될 위험이 매우 커집니다.

결론: 앱 종료 시점의 저장은 매우 중요하지만, 반드시 '빠르게 끝날 것이 보장된' 작업만 수행해야 합니다. 현재의 JSON 저장 방식은 여기에 해당하므로 안전하게 사용할 수 있습니다.