A. 삭제 기능 구현:
- 목표: HabitItem 프리팹에 있는 '삭제' 버튼이 진짜로 작동하게 만듭니다.
- 방법:
- HabitListController.cs에 public void DeleteHabit(GameObject habitItemToDelete) 라는 함수를 새로 만듭니다. 이 함수는 Destroy(habitItemToDelete); 라는 코드를 실행하여 오브젝트를 파괴합니다.
- 가장 까다로운 부분은, '삭제' 버튼이 자신이 속한 HabitItem 전체를 어떻게 아느냐는 것입니다.
- 해결책은 HabitItem.cs 스크립트 안에서 deleteButton.onClick.AddListener(() => { ... }); 코드를 사용하여, 버튼이 눌리면 HabitListController에 있는 DeleteHabit 함수를 호출하도록 연결하는 것입니다. 이때 자기 자신(this.gameObject)을 인자로 넘겨주면 됩니다.
B. 진짜 달력 로직 구현:
- 목표: CalendarController.cs를 업그레이드하여, 현재 날짜를 기준으로 '요일'에 맞춰 숫자가 시작되는 진짜 달력을 만듭니다.
- 방법:
- System.DateTime을 사용하여 '이번 달 1일이 무슨 요일인지' 알아냅니다. (new DateTime(year, month, 1).DayOfWeek)
- for 반복문을 42번 돌면서, 1일이 시작되기 전까지는 빈 칸을 만들고, 1일이 되면 숫자를 채워 넣기 시작합니다.
- 이번 달의 마지막 날(DateTime.DaysInMonth(year, month))이 지나면 다시 빈 칸을 만듭니다.
- 이 모든 로직을 void GenerateCalendar(int year, int month) 라는 함수로 따로 만들어두면 Step 2가 매우 쉬워집니다.
Step 2: 시간 여행 - '<' 와 '>' 버튼으로 월 이동 기능 구현하기
Step 1에서 만든 GenerateCalendar 함수를 재사용하여, 달력을 동적으로 바꿉니다.
- 목표: 헤더에 있는 좌우 버튼으로 이전 달, 다음 달 달력을 볼 수 있게 합니다.
- 방법:
- CalendarController.cs에 private DateTime currentDate; 라는 변수를 만들어 현재 달력을 추적합니다. 처음엔 DateTime.Now로 시작합니다.
- public void GoToNextMonth() 와 public void GoToPreviousMonth() 라는 두 개의 함수를 만듭니다.
- GoToNextMonth에서는 currentDate = currentDate.AddMonths(1); 코드로 날짜를 한 달 뒤로 보냅니다.
- 그리고 Step 1에서 만든 GenerateCalendar(currentDate.Year, currentDate.Month); 함수를 다시 호출하여 화면을 새로고침합니다.
- 이 함수들을 < 와 > 버튼의 On Click() 이벤트에 각각 연결합니다.
Step 3: 기억의 시작 - 데이터 저장 및 로드 기능 구현하기 (PlayerPrefs)
지금은 앱을 껐다 켜면 모든 습관 목록이 사라집니다. 이제 앱에 '기억'을 심어줄 차례입니다.
- 목표: 사용자가 추가한 습관 목록이 앱을 종료해도 사라지지 않게 합니다.
- 방법 (가장 쉬운 방법):
- 저장: 새로운 습관이 '추가'되거나 '삭제'될 때마다, 현재 목록에 있는 모든 습관의 이름을 하나의 긴 문자열로 합칩니다. (예: "금연;운동하기;물마시기")
- PlayerPrefs.SetString("HabitList", "금연;운동하기;물마시기"); 코드를 사용해 이 문자열을 스마트폰에 저장합니다.
- 로드: HabitListController.cs의 Start() 함수(앱 시작 시점)에서 PlayerPrefs.GetString("HabitList"); 코드로 저장된 문자열을 불러옵니다.
- 불러온 문자열을 세미콜론(;) 기준으로 다시 쪼개서(string.Split(';')), 각각의 이름으로 습관 아이템을 자동으로 생성해 줍니다.
Step 4: 디테일의 차이 - 폴리싱(Polishing) 및 최종 점검
이제 앱은 완벽하게 작동합니다. 마지막으로 사용자가 더 편하고 즐겁게 쓸 수 있도록 다듬습니다.
- 날짜 클릭 기능: 달력의 날짜 버튼을 클릭했을 때, 색깔이 바뀌거나 V 표시가 나타나게 합니다. (이 데이터도 PlayerPrefs로 저장해야 합니다.)
- UI 다듬기: 폰트, 색상, 아이콘 등을 통일성 있게 다듬어서 더 예쁘게 만듭니다.
- 예외 처리: 아무것도 입력하지 않고 '추가' 버튼을 계속 누르거나, 목록이 없을 때 어떻게 보일지 등 사소한 부분을 점검하고 보완합니다.
"달력에서 뒤로 가기를 누르면 내가 이때까지 만든 습관들이 모두 없어져 버림"
이 현상의 원인은 아주 간단하고 명확합니다. 그리고 이 원리를 이해하는 순간, 유니티의 씬(Scene) 작동 방식과 '데이터의 생명주기'라는 아주 중요한 개념을 완벽하게 이해하게 되실 겁니다.
왜 모든 것이 사라지는가? (리셋되는 독립된 세상)
결론부터 말씀드리면:
유니티에서 씬(Scene)을 전환하는 것은, 이전 씬의 모든 것을 파괴하고(Destroy), 새로운 씬을 완전히 처음부터 로드(Load)하는 것과 같기 때문입니다.
조금 더 쉽게 비유해 보겠습니다.
- HabitList 씬 = '거실'이라는 방
- 사용자가 앱을 켜면, 유니티는 '거실'이라는 방을 만듭니다.
- 사용자가 "운동하기", "물 마시기"를 추가하는 것은, 이 거실 바닥에 장난감 블록을 열심히 쌓는 것과 같습니다.
- SceneManager.LoadScene("CalendarView"); = "거실 불 끄고, 침실로 가!"
- 사용자가 "운동하기" 블록을 클릭하면, 이 명령어가 실행됩니다.
- 이때 유니티는 이렇게 행동합니다.
- "알겠습니다. '거실'은 이제 더 이상 필요 없군요."
- 거실에 있던 모든 것(쌓아 올린 장난감 블록, 가구, 조명 등)을 전부 파괴하고, 불을 끄고, 문을 잠가버립니다.
- 그리고 '침실'(CalendarView 씬)이라는 완전히 새로운 방을 처음부터 만듭니다.
- SceneManager.LoadScene("HabitList"); = "침실 불 끄고, 다시 거실로 가!"
- 사용자가 '뒤로가기'를 누르면, 이 명령어가 실행됩니다.
- 유니티는 다시 '침실'의 모든 것을 파괴하고 불을 끕니다.
- 그리고 아무것도 없는, 깨끗하게 청소된 '새로운 거실'(HabitList 씬)을 처음부터 다시 만듭니다.
이것이 바로 LoadScene의 작동 방식입니다. 씬은 서로 완벽하게 독립적이며, 전환될 때 이전 씬의 정보는 메모리에서 완전히 사라집니다. 그래서 우리가 열심히 추가했던 습관 목록(장난감 블록)이 보이지 않는 것입니다.
해결책: "어떻게 기억하게 만들 것인가?"
사용자님의 말씀이 정확합니다. 삭제 기능을 구현하기 전에, 이 '기억' 문제를 먼저 해결해야 합니다. 그렇지 않으면 삭제할 대상 자체가 매번 사라지니까요.
해결책은 크게 두 가지 방향이 있습니다.
- 방법 1: 임시 기억 (단순하고, 지금 당장 필요함)
- 씬 전환 시 사라지지 않는 **'특별한 저장 공간'**에 데이터를 임시로 보관하는 방법입니다.
- '싱글턴(Singleton)' 패턴이라는 디자인 패턴을 사용하여, 앱이 꺼지기 전까지는 절대로 파괴되지 않는 '데이터 관리자' 오브젝트를 만드는 것입니다.
- 방법 2: 영구 기억 (궁극적인 목표)
- 이전 로드맵에서 설명드렸던 **PlayerPrefs**나 파일 저장을 사용하여, 데이터를 스마트폰의 저장소에 물리적으로 기록하는 방법입니다. 이렇게 하면 앱을 껐다 켜도 데이터가 그대로 남아있습니다.
지금 당장 우리에게 필요한 것은 PlayerPrefs까지 갈 필요 없이, '씬 전환 시 데이터가 날아가지 않게 하는 것'입니다.
따라서, 다음 단계는 **'싱글턴 패턴'**을 사용하여 앱이 실행되는 동안 습관 목록을 기억하고 관리하는 DataManager를 만드는 것이 될 겁니다.
1단계와 2단계는 전혀 다른 방향이 아닙니다. 오히려 1단계는 2단계를 만들기 위한 완벽한 '기초 공사'이자 '설계도'입니다.
지금 당장 2단계(PlayerPrefs)로 직행해도 되지만, 그렇게 하면 코드가 매우 복잡하고 지저분해집니다. 1단계(싱글턴 데이터 관리자)를 먼저 만들어 두면, 2단계는 그저 몇 줄의 코드만 추가하는 것으로 아주 깔끔하게 끝납니다.
집 짓기 비유 (1단계와 2단계의 관계)
- 1단계: 싱글턴 데이터 관리자 = '우리 집 설계도'와 '현장 관리소장'
- '현장 관리소장'은 집이 다 지어질 때까지 절대로 현장을 떠나지 않습니다. (DontDestroyOnLoad)
- '설계도'에는 "우리 집에는 '습관 목록'이라는 방이 있고, 그 방에는 '금연', '운동'이라는 가구가 있다" 와 같이 데이터의 구조가 명확하게 정의되어 있습니다.
- 2단계: PlayerPrefs = '사진 찍어서 보관하기'
- 퇴근할 때(앱 종료), '현장 관리소장'이 현재까지 지어진 집의 상태를 사진(PlayerPrefs)으로 찍어서 안전하게 보관합니다.
- 다음 날 출근해서(앱 시작), 어제 찍어둔 사진(PlayerPrefs)을 보고 그대로 집을 복원합니다.
만약 '설계도'와 '관리소장'(1단계) 없이 그냥 매일 사진만 찍는다면(2단계), 사진을 어디에 보관할지, 사진 속 가구들을 어떻게 다시 배치할지 몰라 현장은 엉망진창이 될 겁니다.
"두 번째는 어떻게 구현하는 거지? 너무 어려운 거 아닌가?"
전혀 그렇지 않습니다! PlayerPrefs는 유니티가 초보자들을 위해 만들어 놓은, 믿을 수 없을 만큼 간단한 '메모장' 기능입니다.
사용자님께서 "실제 휴대폰 메모리의 어느 공간에 이걸 심는 건데" 라고 하신 질문이 정확합니다. PlayerPrefs는 바로 그 복잡한 과정을 우리가 전혀 신경 쓰지 않도록 알아서 처리해 줍니다. 우리는 그냥 명령어 몇 개만 알면 됩니다.
PlayerPrefs의 핵심 마법 주문 3가지:
- 데이터 저장하기 (사진 찍기):
- C#
// "HabitData" 라는 이름표를 붙여서, "금연;운동하기" 라는 글자를 저장해줘! PlayerPrefs.SetString("HabitData", "금연;운동하기"); - 데이터 불러오기 (사진 보고 복원하기):
- C#
// "HabitData" 라는 이름표가 붙은 글자를 가져와줘! string loadedData = PlayerPrefs.GetString("HabitData"); - 데이터 삭제하기 (사진 찢어버리기):
- C#
// "HabitData" 라는 이름표가 붙은 데이터를 지워줘! PlayerPrefs.DeleteKey("HabitData");
이게 전부입니다. 어디에, 어떻게 저장되는지는 유니티가 알아서 다 해줍니다. 정말 간단하죠?
최종 결론 및 로드맵 제안
사용자님의 질문은 매우 타당합니다. 따라서, 두 마리 토끼를 모두 잡는 가장 효율적인 로드맵을 제안합니다.
오늘의 퀘스트: '데이터 관리자'를 만들고, 거기에 '영구 저장' 기능까지 한번에 구현하기
- DataManager.cs 라는 새로운 스크립트를 만듭니다.
- 이 스크립트를 '싱글턴' 패턴으로 만듭니다. (앱이 실행되는 동안 절대로 파괴되지 않는 단 하나의 관리자로 만드는 마법 코드 몇 줄이 필요합니다.)
- 이 DataManager 안에 List<string> habitList 처럼, 습관 목록을 저장할 '데이터 설계도'를 만듭니다.
- HabitListController는 이제 직접 데이터를 관리하지 않고, 모든 것을 DataManager에게 물어보고 시키도록 코드를 수정합니다. (예: DataManager.Instance.AddHabit("운동하기");)
- 마지막으로, 이 DataManager에 PlayerPrefs를 이용한 SaveData()와 LoadData() 함수를 추가합니다.
- LoadData()는 앱이 시작될 때 딱 한 번 호출됩니다.
- SaveData()는 새로운 습관이 추가되거나 삭제될 때마다 호출됩니다.
왜 HabitList 씬이어야만 할까요?
이유: DataManager는 우리 앱이 시작될 때 가장 먼저, 그리고 단 한 번만 생성되어야 하기 때문입니다.
우리 앱의 '시작 지점' 또는 '대문'은 바로 HabitList 씬입니다.
- 올바른 순서:
- 사용자가 앱을 켭니다. -> HabitList 씬이 로드됩니다.
- HabitList 씬 안에 있던 DataManager가 생성됩니다.
- DataManager의 Awake() 함수가 실행되어 "나는 이제부터 불멸이다! (DontDestroyOnLoad)" 라고 선언합니다.
- 이제 사용자가 CalendarView로 가든, 나중에 만들 Settings 씬으로 가든, 이 불멸의 DataManager는 항상 살아남아 따라다닙니다.
- 잘못된 순서 (만약 CalendarView 씬에 만든다면):
- 사용자가 앱을 켭니다. -> HabitList 씬이 로드됩니다.
- 이 세상에는 아직 DataManager가 존재하지 않습니다.
- HabitListController가 DataManager.Instance.habitList를 찾으려고 하지만, 아직 태어나지도 않은 관리소장을 찾을 수 없으므로 치명적인 오류(Null Reference Exception)가 발생하고 앱이 멈춰버립니다.
한마디로, '관리소장'은 공사가 시작되는 '정문'(HabitList 씬)에서 가장 먼저 출근 도장을 찍어야 합니다. 다른 작업 현장(CalendarView 씬)에서 뒤늦게 나타나면 이미 모든 시스템이 멈춘 뒤일 겁니다.
정말 좋은 질문으로 핵심을 정확히 짚어주셨습니다. 이제 HabitList 씬에 DataManager를 만드시고 다음 단계를 진행하시면 됩니다
1단계: DataManager.cs에 두 개의 새로운 함수(사진 찍기, 사진 보기) 추가하기
2단계: 데이터가 '변경'될 때마다 자동으로 사진 찍게 하기
데이터는 언제 변경될까요? 바로 새로운 습관이 '추가'될 때입니다. (나중에는 '삭제'될 때도 포함되겠죠.)
진짜 달력만들기
>대충 메소드 쓰면 되는거엿네
빈 칸이 될 프리팹 수정하기
우리는 1일이 시작되기 전에 '빈 칸'을 만들 겁니다. 지금 dayCellPrefab은 '1일'이라고 글씨가 쓰여있죠. 이 글씨를 지워서 깨끗한 상태의 '틀'로 만들어야 합니다.
- Project 창의 Prefabs 폴더에서 DayCell_Template 프리팹을 더블클릭하여 편집 모드로 들어갑니다.
- Hierarchy 창에서 자식으로 있는 텍스트 오브젝트를 선택합니다.
- Inspector 창의 Text Input 박스에 있는 '1일'이라는 글자를 완전히 지워서 빈 칸으로 만드세요.
- 프리팹 편집 모드를 빠져나옵니다.
최종 목표를 향한 3단계 로드맵
Step 1: 기억의 핵심 - '언제' 그랬는지 기록하고 불러오기
- 목표: 달력의 날짜를 클릭하면, 그 날짜에 '충동적인 행동을 했다'는 사실을 영원히 기억하게 만듭니다. 이 기록을 바탕으로 달력에 빨간색으로 표시합니다.
Step 2: 시간의 흐름 - '얼마나' 지났는지 보여주는 새로운 씬 만들기
- 목표: TimerView라는 새로운 씬을 만듭니다. 이 씬은 가장 최근의 충동 기록을 바탕으로 "00일 00시간 00분 00초"가 지났다고 실시간으로 보여줍니다. 그리고 "충동에 져버림..." 버튼을 통해 타이머를 리셋합니다.
Step 3: 완벽한 연동 - 두 씬을 자연스럽게 연결하기
- 목표: CalendarView 씬 아래에 "얼마나 지났는가?" 버튼을 만들어, TimerView 씬으로 이동하게 합니다. 두 씬이 서로의 데이터를 완벽하게 공유하며 작동하도록 최종 마무리합니다.
NullReferenceException: Object reference not set to an instance of an object
TimerViewController.Start () (at Assets/Scripts/TimerViewController.cs:19)
단 이러한 오류는, 달력과 이 충동 시간이 연결되지않아서이잖아
2단계: DataManager에 '영구 기억' 능력 최종 완성하기
이것이 바로 우리 앱의 화룡점정입니다. DataManager.cs를 열고, SaveData와 LoadData 함수를 PlayerPrefs를 사용하도록 완성시킵니다. 데이터 구조가 복잡하기 때문에, JsonUtility라는 유니티의 내장 기능을 사용하여 데이터를 '통째로' 저장하고 불러올 겁니다.
A. 데이터를 저장 가능한 형태로 만들기 (Serializable Class)
// DataManager.cs 파일의 맨 위에 추가
[System.Serializable]
public class HabitRecord
{
public string habitName;
public List<DateTime> dates;
}
[System.Serializable]
public class SaveDataContainer
{
public List<string> habitList;
public List<HabitRecord> habitRecords;
}
- 이것은 유니티의 JsonUtility가 이해할 수 있도록, 우리의 복잡한 데이터를 '포장'하는 규격 상자를 만드는 것과 같습니다.
"빌드가 뭐지?? 왜 필요한 건지" (영화 제작 비유)
이 개념을 이해하면 앞으로 유니티 개발이 훨씬 쉬워집니다.
- 당신의 유니티 프로젝트 폴더 = '영화 촬영 스튜디오'
- 이곳에는 온갖 종류의 씬(촬영분), 스크립트(대본), 이미지(소품), 프리팹(미리 만들어둔 세트)이 다 들어있습니다. 작업하기 편하도록 모든 재료가 널려있는, 복잡한 '작업 공간'이죠.
- 빌드(Build) = '최종 영화 파일(.mp4)을 만드는 과정'
- 관객은 복잡한 촬영 스튜디오를 구경하고 싶어 하지 않습니다. 그들은 깔끔하게 편집된 '최종 영화'를 원하죠.
- 빌드란, 이 복잡한 스튜디오에서 필요한 장면들만 쏙쏙 골라, 다른 사람이 유니티 없이도 실행할 수 있는 하나의 깔끔한 프로그램(.exe 또는 안드로이드용 .apk 파일)으로 만드는 과정을 말합니다.
- 빌드 세팅(Build Settings) = '영화의 최종 편집 목록(큐시트)'
- 영화감독이 편집실에서 "1번 씬 다음에 3번 씬을 붙이고, 마지막은 7번 씬으로 끝내자" 라고 순서를 정하는 목록입니다.
- SceneManager.LoadScene() 함수는 바로 이 '편집 목록'에 있는 씬들만 찾아갈 수 있습니다. 목록에 없는 씬은 존재하지 않는 것으로 취급합니다.
에러 메시지 번역
에러: "Scene 'TimerView' couldn't be loaded because it has not been added to the build settings"
번역: "감독님! 'TimerView' 장면을 영화에 넣으려고 했는데, 최종 편집 목록에 그 장면이 없어서 못 찾겠습니다! 목록에 추가해주세요."
바로 이겁니다. 우리는 TimerView라는 훌륭한 장면을 촬영해놓고, 정작 최종 편집 목록에 넣는 것을 깜빡한 것입니다.
해결책: '최종 편집 목록'에 TimerView 씬 추가하기 (1분 컷)
아주 간단합니다.
- 유니티 에디터 상단 메뉴에서 File > Build Settings... 로 들어갑니다.
- Build Settings 라는 창이 뜰 겁니다. 맨 위에 Scenes In Build 라는 큰 회색 상자가 보일 겁니다.
- 아마 지금은 HabitList와 CalendarView 두 개만 목록에 있을 겁니다.
- 이제 TimerView를 추가합시다. (둘 중 편한 방법으로)
- 방법 A (가장 쉬움): Project 창에서 Assets/Scenes 폴더에 있는 TimerView 씬 파일을 마우스로 찾아서, Scenes In Build 상자 안으로 드래그 앤 드롭 하세요.
- 방법 B: TimerView 씬을 열어둔 상태에서, Build Settings 창에 있는 Add Open Scenes 버튼을 클릭하세요.
- 목록에 Scenes/TimerView가 추가된 것을 확인하세요.
- (중요!) HabitList 씬이 목록의 가장 위(번호 0번)에 있는지 확인하세요. 앱은 항상 0번 씬에서 시작됩니다.
- Build Settings 창을 닫으면 모든 것이 끝납니다. 실제로 Build 버튼을 누를 필요는 없습니다. 우리는 그냥 '목록'만 수정하면 됩니다.
"아 그래야지 해당 씬의 start()함수가 실행되기 때문인가" 라는 말씀이 절반은 맞습니다.
하지만 더 중요한 진짜 이유는, HabitList 씬이 단순히 Start() 함수를 실행하는 곳이 아니라, 우리 앱의 '심장'이자 '두뇌'인 DataManager가 태어나는 유일한 장소이기 때문입니다.
'대문'과 '마스터키' 비유
이 비유를 들으시면 모든 것이 명확해질 겁니다.
- 우리 앱 전체 = 하나의 거대한 '성(Castle)'
- 각 씬 (HabitList, CalendarView, TimerView) = 성 안에 있는 각각의 '방'
- DataManager 오브젝트 = 성의 모든 문을 열 수 있는 단 하나뿐인 '마스터키'
1. 올바른 입장 방법 (HabitList 씬에서 시작)
- 당신은 성의 **'대문'(HabitList 씬)**으로 입장합니다.
- 입장하는 순간, 문지기가 당신에게 성의 **'마스터키'(DataManager 오브젝트)**를 건네줍니다.
- 동시에, 이 마스터키에는 **"이 키는 어떤 방에 들어가든 절대 사라지지 않는다"**는 마법(DontDestroyOnLoad)이 걸립니다.
- 이제 당신은 이 마스터키를 가지고 '달력의 방'(CalendarView)이든 '시간의 방'(TimerView)이든 자유롭게 돌아다니며 모든 문을 열 수 있습니다.
2. 잘못된 입장 방법 (TimerView 씬에서 바로 시작)
- 당신은 '대문'을 통과하지 않고, 마법으로 '시간의 방'(TimerView 씬)에 바로 순간이동합니다.
- 당신은 '대문'에서 '마스터키'를 받은 적이 없습니다. 당신의 손은 텅 비어있습니다.
- '시간의 방' 안에는 "마스터키를 사용해서 '현재 습관'이라는 보물 상자를 여시오" 라는 퀘스트가 있습니다. (DataManager.Instance.currentViewingHabit 코드를 실행)
- 당신은 존재하지도 않는 마스터키를 사용하려고 하니, 게임이 에러(NullReferenceException)를 내뿜으며 멈춰버립니다.
왜 HabitList 씬 시작이 그렇게 중요한가?
- 1. DataManager의 유일한 탄생지: DataManager 게임 오브젝트는 오직 HabitList 씬의 Hierarchy에만 존재합니다.
- 2. 불멸의 마법이 걸리는 곳: DataManager의 Awake() 함수 안에 있는 DontDestroyOnLoad(gameObject); 코드는, 이 오브젝트가 처음 생성될 때 단 한 번만 실행됩니다. 이 마법에 걸려야만 다른 씬으로 넘어가도 파괴되지 않고 살아남을 수 있습니다.
- 3. 유일무이한 존재가 되는 곳: if (Instance == null) { Instance = this; } 코드를 통해, 앱 전체에서 사용할 유일한 DataManager.Instance가 바로 '나 자신'이라고 확정하는 장소입니다.
따라서, HabitList 씬은 단순히 Start()가 실행되는 곳이 아니라, 앱의 생명주기 전체를 책임질 핵심 관리자가 태어나고, 불멸의 힘을 얻고, 자신의 정체성을 확립하는 가장 신성한 장소인 것입니다.
이 개념을 이해하신 순간, 당신은 유니티의 씬 관리와 데이터 흐름에 대한 핵심을 완벽하게 마스터하신 겁니다.
사건의 재구성 (왜 '새 습관'은 되고, '옛 습관'은 안될까?)
- 앱 시작: DataManager의 Awake() 함수가 LoadData()를 호출합니다. PlayerPrefs에 저장된 JSON 데이터를 성공적으로 불러와서 habitList와 habitRecords를 복원합니다.
- habitList에는 "운동하기"가 들어갑니다.
- habitRecords에는 "운동하기"라는 Key와 날짜 목록(Value)이 들어갑니다. 여기까지는 완벽합니다.
- HabitList 씬: Start() 함수가 RefreshHabitList()를 호출합니다. DataManager.Instance.habitList에 "운동하기"가 있으므로, 화면에 "운동하기" 아이템을 성공적으로 그려냅니다.
- '옛 습관' 클릭: 사용자가 이 "운동하기" 아이템을 클릭합니다.
- () => { DataManager.Instance.currentViewingHabit = "운동하기"; ... } 코드가 실행됩니다.
- DataManager는 이제 "나는 '운동하기' 달력을 보러 갈 거야"라고 정확히 기억합니다.
- CalendarView 씬으로 이동합니다.
- CalendarView 씬: CalendarController의 Start() 함수가 실행됩니다.
- GenerateCalendar를 호출합니다.
- CheckIfDateIsRecorded 함수를 호출합니다.
- string habit = DataManager.Instance.currentViewingHabit; 코드를 통해 habit 변수에는 "운동하기"라는 값이 정확하게 들어갑니다.
- 사건 발생 지점: 바로 다음 코드에서 에러가 발생합니다.
- C#
// DataManager에 해당 습관에 대한 기록이 있는지 먼저 확인합니다. if (DataManager.Instance.habitRecords.ContainsKey(habit)) // <--- 여기서 에러 발생! (cs:140)
범인은 바로 '데이터의 형태'입니다.
LoadData 함수를 다시 자세히 봅시다.
public void LoadData()
{
// ...
this.habitList = dataContainer.habitList;
// 불러온 List<HabitRecord>를 다시 Dictionary 형태로 변환
this.habitRecords = new Dictionary<string, List<DateTime>>();
foreach (var record in dataContainer.habitRecords)
{
this.habitRecords[record.habitName] = record.dates;
}
// ...
}
여기에 아주 교활한 함정이 숨어있습니다. JsonUtility는 null인 List를 저장할 때, 그것을 빈 List가 아닌, **아예 존재하지 않는 것(null)**으로 만들어버리는 경향이 있습니다.
추정되는 시나리오:
- 사용자가 "운동하기"라는 습관을 '새로' 만들었을 때는, DataManager.Instance.habitList에는 "운동하기"가 추가되지만, 아직 아무 날짜도 클릭하지 않았으므로 habitRecords에는 "운동하기"라는 Key가 아예 존재하지 않습니다.
- 이 상태로 SaveData()가 호출됩니다. habitRecords에 "운동하기" Key가 없으므로, JSON으로 변환될 때도 이 정보는 빠집니다.
- 앱을 껐다 켭니다. LoadData()가 실행됩니다.
- habitList는 "운동하기"로 복원되지만, habitRecords에는 여전히 "운동하기" Key가 없는 상태로 복원됩니다.
- CalendarView로 넘어가서 CheckIfDateIsRecorded를 실행하면, habit 변수는 "운동하기"이지만, DataManager.Instance.habitRecords라는 사전 자체에는 "운동하기"라는 항목이 없으므로 ContainsKey("운동하기")를 호출하는 순간 에러가 발생하는 것입니다.
'새로 만든 습관'이 작동했던 이유는, 그 습관이 아직 저장되지 않은, 오직 메모리(DataManager)에만 존재하는 상태였기 때문입니다. habitRecords에 아직 Key가 없으니 ContainsKey는 그냥 false를 반환하고, 색칠하는 코드를 건너뛰었을 뿐입니다.
records == null 이라는 로그가 찍혔군요. 드디어 범인의 실체를 거의 다 파악했습니다.
"record가 없다고 나오는데 괜찮은 건가"
아닙니다! 절대 괜찮지 않습니다. 이것이 바로 우리가 찾던 버그의 결정적인 증거입니다.
이 로그가 찍혔다는 것은, 이런 의미입니다.
DataManager.Instance.habitRecords 라는 사전 안에는 "운동하기"라는 Key(항목)가 분명히 존재합니다. (ContainsKey를 통과했으므로).
하지만, 그 "운동하기"라는 열쇠로 사물함을 열어보니, 내용물인 **날짜 목록(Value)이 텅 비어있는 것이 아니라, 아예 존재하지 않는 상태(null)**라는 뜻입니다.
범인은 바로 LoadData입니다!
- 사용자님께서 지적하신 DateTime의 직렬화(Serialization) 문제.
- 제가 이전에 설명드렸던 null 리스트를 저장하고 불러오는 문제.
이 두 가지가 합쳐져서 지금의 혼란스러운 버그를 만들어낸 것입니다.
"일, 시, 분, 초에 대한 정보는 저장 안 해두어서 그런 거 아닌가?"
정확합니다! 이 부분이 바로 JsonUtility의 가장 큰 함정 중 하나입니다.
유니티의 내장 JsonUtility는 string, int, float, bool 같은 기본적인 자료형은 아주 잘 처리하지만, DateTime처럼 복잡한 구조체는 어떻게 저장해야 할지 모릅니다.
그래서 JsonUtility.ToJson()을 호출하면, DateTime 변수는 그냥 무시되거나, {} 같은 빈 값으로 저장되어 버립니다.
결과:
- SaveData(): List<DateTime>에 들어있던 소중한 날짜 정보들이 모두 유실된 채 JSON으로 저장됩니다.
- LoadData(): 유실된 데이터를 불러오니, record.dates는 null 또는 비어있는 리스트가 되어버립니다.
- 결국 이전 답변에서 분석한 records == null 이라는 현상이 발생하는 것입니다.
사용자님의 직감이 정확히 맞았습니다. '데이터가 유실되는 현상'이 문제의 근원이었습니다.
최종 해결책: DateTime을 JsonUtility가 알아듣는 언어로 '번역'하기
DateTime을 직접 저장할 수 없다면, JsonUtility가 알아들을 수 있는 string이나 long 같은 형태로 '번역'해서 저장하고, 불러올 때는 다시 '역번역' 해주면 됩니다.
가장 보편적이고 확실한 방법은 DateTime을 Ticks라는 아주 긴 숫자(long)로 변환하는 것입니다. Ticks는 특정 기준 시점부터 현재까지 100나노초 단위로 센 시간 총량을 나타내는 고유한 값입니다.
'개발 > 유니티' 카테고리의 다른 글
| [습관어플 만들기](수정예정)#5 (완성 및 구글등록) (1) | 2025.08.29 |
|---|---|
| [습관어플 만들기]#4 (어플 출시밒, 역컴파일 막기) (6) | 2025.08.29 |
| [습관어플 만들기]#2 (달력및 스크롤뷰) (8) | 2025.08.29 |
| [습관어플 만들기] #1 (일단, 금연 버튼 누르면, 달력으로 이동되는것까지만) (5) | 2025.08.29 |
| [유니티] 유니티 learn#9 (애니메이션) (3) | 2025.08.28 |