개발/유니티

[충동멈춰어플] 새로운 상황에 대한 버튼들

kimchangmin02 2025. 9. 14. 07:15

두 방식의 성능 및 설계 비교

방법 1: 통합 리스트 + 필터링 (.Where, LINQ 사용)

  • 코드: totalMissionDeck.Where(m => m.category == category).ToList();
  • 장점:
    • 학술적인 우아함: 데이터는 하나로 관리하고, 필요할 때마다 동적으로 조회(필터링)하는 방식은 데이터베이스의 원리와 가깝습니다. "모든 상황에 유용한 일반 미션"(General)을 처리하기가 매우 깔끔합니다.
    • 데이터 관리의 단일성: 모든 미션은 totalMissionDeck 하나에만 추가하면 되므로, 실수로 다른 리스트에 넣을 가능성이 없습니다.
  • 단점:
    • 가독성 저하: 사용자님께서 느끼신 그대로입니다. => 같은 람다식이나 LINQ 문법은 초보자에게 매우 어렵고 직관적이지 않습니다.
    • 미미한 성능 부하: 미션을 뽑을 때마다 매번 전체 리스트를 순회하며 필터링하는 과정이 필요합니다. 미션이 수백 개 수준에서는 전혀 문제없지만, 수만 개가 되면 약간의 성능 저하가 발생할 수 있습니다.

방법 2: 분리된 리스트 + switch문 (사용자님 제안, 추천!)

  • 코드: switch (category) { case Boredom: return GetRandomFromList(boredomMissions); ... }
  • 장점:
    • 압도적인 가독성 (★★★★★): 코드가 위에서 아래로 흐르며, "지루함 버튼을 누르면, 지루함 미션 리스트에서 하나를 뽑는다"는 로직이 너무나도 명확하게 보입니다.
    • 빠른 성능: 이미 분류된 리스트에서 바로 랜덤 인덱스를 뽑기만 하면 되므로, 필터링 과정이 전혀 없어 가장 빠릅니다.
    • 관심사 분리: InitializeMissions 함수를 InitializeBoredomMissions, InitializeStressMissions 등으로 깔끔하게 나눌 수 있습니다. 사용자님께서 원하신 바로 그 구조입니다.
  • 단점:
    • '일반 미션' 처리의 복잡함: "모든 상황에 유용한 일반 미션"을 처리하려면, 각 카테고리별 미션을 뽑은 후에 generalMissions 리스트와 합쳐서 다시 뽑는 등의 추가적인 로직이 필요해져서 코드가 약간 지저분해질 수 있습니다. (하지만 해결 가능합니다)

 

 

 

 

 

 

 

1. enum (열거형)은 왜 필요한가요?

enum을 우리말로 번역하면 **'열거형'**입니다. 아주 쉽게 말해, 숫자 대신 사람이 알아보기 쉬운 '이름표'를 붙여서 실수를 방지하는 도구입니다.

만약 enum이 없다면?
코드를 이렇게 짜야 할 겁니다.

C#
 
// StartSceneUIManager.cs
public void OnClickBoredomButton()
{
    // "지루함" 버튼은 1번으로 하자고 '우리끼리 약속'합니다.
    MissionDataManager.instance.selectedCategory = 1; 
    SceneManager.LoadScene("DoItScene");
}

// MissionDataManager.cs
public MissionData GetRandomMission()
{
    // selectedCategory가 1이면 "지루함"이었지...
    if (selectedCategory == 1) 
    {
        targetDeck = boredomMissions;
    }
    // ...
}

이 방식의 문제점은 명확합니다.

  • 1이 "지루함"인지, 2가 "스트레스"인지 매번 기억하거나 주석을 찾아봐야 합니다.
  • 만약 selectedCategory = 100; 처럼 실수로 없는 숫자를 넣어도 코드는 에러 없이 실행되고, 나중에 이상하게 동작하는 버그를 찾기가 매우 힘듭니다.

enum을 사용하면?
컴퓨터는 내부적으로 Boredom을 0, Stress를 1... 로 처리하지만, 프로그래머는 숫자 대신 의미가 명확한 이름을 사용하게 됩니다.

C#
 
// 사람이 읽기 쉬운 코드가 됩니다.
MissionDataManager.instance.selectedCategory = MissionCategory.Boredom; 

// switch문과 함께 쓰면 가독성이 폭발적으로 증가합니다.
switch (selectedCategory)
{
    case MissionCategory.Boredom: // "1" 보다 훨씬 명확합니다.
        targetDeck = boredomMissions;
        break;
}
  • MissionCategory.SomethingWrong 처럼 존재하지 않는 카테고리를 쓰려고 하면 즉시 컴파일 에러가 발생하여 실수를 원천

 

 

 

 

 

 

 

 

 

 

 

// --- 1. 싱글톤 인스턴스 변수 (빠진 부분 추가!) --- public static MissionDataManager instance;

void Awake() {

if (instance == null) { instance = this; DontDestroyOnLoad(gameObject); }

else { Destroy(gameObject); return; }

 

 

 

 

1. "이 미션을 찾는 건 왜 있는 거지? 용도가 뭔지"

 FindMissionByName 함수의 용도는 바로 **'앱이 갑자기 꺼졌을 때의 복구 기능'**입니다.

사용자 경험의 흐름을 따라가 보면 그 필요성이 명확해집니다.

  1. 미션 받기: 사용자가 '스트레스' 버튼을 눌러 "심호흡 1분"이라는 미션을 받았습니다. DoItScene 화면에 미션이 떠 있습니다.
  2. 앱 강제 종료: 이때 사용자에게 갑자기 전화가 오거나, 배터리가 나가서 앱이 꺼져버립니다.
  3. 앱 재실행: 사용자가 다시 앱을 켰습니다.

만약 FindMissionByName 기능이 없다면?
앱은 "아, 새로 시작했구나!" 라고 생각하고, DoItScene에서 GetRandomMission()을 다시 호출하여 "팔굽혀펴기 5회" 같은 완전히 새로운 미션을 보여줄 겁니다. 사용자는 "어? 내가 하던 미션 어디 갔지?"라며 당황하고 나쁜 경험을 하게 됩니다.

FindMissionByName 기능이 있다면?

  • 저장 (OnApplicationPause): 앱이 꺼지기 직전, DoItSceneManager PlayerPrefs라는 간단한 저장소에 "현재 보고 있던 미션의 이름은 '심호흡 1분'입니다" 라고 기록해 둡니다. (PlayerPrefs.SetString("LastMissionName", "심호흡 1분"))
  • 복구 (Start): 앱이 다시 켜지면, DoItSceneManager는 가장 먼저 PlayerPrefs를 확인합니다. "혹시 저장된 미션 이름이 있나?"
  • '이름표'로 '실물' 찾기: 저장된 이름인 "심호흡 1분"을 발견합니다. 하지만 이것은 그냥 '텍스트'일 뿐, 미션 설명이나 레벨 정보가 담긴 MissionData 객체 '실물'이 아닙니다.
  • 바로 이때! MissionDataManager.instance.FindMissionByName("심호흡 1분")을 호출합니다. 이 함수는 MissionDataManager가 가진 모든 미션 목록을 뒤져서, 이름이 "심호흡 1분"인 MissionData 객체 **'실물'**을 찾아 돌려줍니다.
  • 결과: 사용자는 앱을 다시 켰을 때, 꺼지기 직전에 보았던 "심호흡 1분" 미션을 그대로 다시 보게 되어 자연스럽게 앱 사용을 이어갈 수 있습니다.

 

 

 

문제 상황: '휘발성' 메모리와 '영구적인' 저장소

컴퓨터의 작동 방식을 아주 간단하게 비유해 보겠습니다.

  • 게임 실행 중 (RAM 메모리): 이것은 우리가 작업하는 **'깨끗한 책상'**과 같습니다.
    • MissionDataManager InitializeMissions()를 실행하면, 모든 MissionData 객체('미션 카드'들)가 이 책상 위에 깔끔하게 펼쳐집니다.
    • DoItSceneManager가 "심호흡 1분" 미션을 받으면, 그 미션 카드를 손에 들고 있는 것과 같습니다. 이 카드에는 설명, 레벨 등 모든 정보가 적혀있죠.
    • 이 책상 위의 모든 것들(객체들)은 전기가 꺼지면(앱이 종료되면) 모두 사라집니다. 그래서 '휘발성'이라고 부릅니다.
  • 게임 종료 후 (하드 디스크/SSD): 이것은 우리가 중요한 서류를 보관하는 **'서류 보관함'**과 같습니다.
    • PlayerPrefs PlayerData.json 파일이 바로 이 서류 보관함입니다.
    • 이곳에 저장된 정보는 전기가 꺼져도 절대 사라지지 않습니다. 그래서 '영구적' 또는 '비휘발성'이라고 부릅니다.

문제의 핵심: '깨끗한 책상(RAM)'에 있는 복잡한 객체(MissionData 실물)를, 있는 그대로 '서류 보관함(파일)'에 집어넣는 것은 매우 어렵거나 불가능합니다. 서류 보관함에는 오직 간단한 텍스트나 숫자만 기록할 수 있다고 생각하시면 됩니다.


"MissionData 자료형으로 저장하면 되잖아"가 안 되는 이유

MissionData는 단순한 텍스트가 아니라, 여러 변수(missionName, missionDescription, missionLevel)가 묶인 '구조화된 데이터 덩어리' 즉, **'객체(Object)'**입니다.

PlayerPrefs는 아주 원시적인 저장소라서, int, float, string 같은 아주 기본적인 자료형만 저장할 수 있습니다. MissionData 같은 복잡한 '객체'를 통째로 저장하는 기능 자체가 없습니다.

"PlayerPrefs야, 이 MissionData 객체 좀 저장해줘!" → (불가능)

물론, MissionData를 JSON 문자열로 변환(JsonUtility.ToJson)해서 저장하는 방법도 이론적으로는 가능합니다.

"PlayerPrefs야, 이 MissionData를 JSON 문자열로 바꿔서 저장해줘!" → (가능은 하지만...)

하지만 이 방법은 훨씬 더 큰 문제를 야기합니다.

만약 '심호흡 1분' 미션의 설명을 나중에 업데이트하면 어떻게 될까요?

  • 현재 방식: MissionDataManager의 코드만 수정하면 끝입니다. 사용자가 앱을 업데이트하고 실행하면, FindMissionByName("심호흡 1분") 새로운 설명이 담긴 MissionData 실물을 찾아줍니다.
  • JSON으로 저장하는 방식: 사용자의 PlayerPrefs에는 '옛날 설명'이 담긴 JSON 문자열이 그대로 저장되어 있습니다. 앱을 업데이트해도 사용자는 영원히 옛날 설명만 보게 됩니다. 모든 사용자의 저장 데이터를 강제로 수정하지 않는 이상 해결할 수 없습니다.

 

 

 

 

 

두 종류의 데이터: '나의 정보' vs '게임의 규칙'

우리가 저장하는 데이터는 성격이 완전히 다른 두 종류로 나뉩니다.

1. 플레이어의 상태 데이터 (Player State Data)

  • 이것이 바로 PlayerData.json에 저장되는 것들입니다.
  • 특징: 이 데이터는 '나'에게만 고유하며, 게임 플레이를 통해 계속해서 변합니다.
    • 나의 골드, 나의 레벨, 나의 HP, 내가 가진 아이템 목록(inventory), 상점의 남은 재고(shopQuantities) 등...
  • 비유: 이것은 게임 세상 속 **'나의 개인 수첩'**과 같습니다. 게임을 하면서 겪는 모든 변화를 이 수첩에 기록합니다. 게임을 껐다 켤 때, 이 수첩을 다시 펼쳐서 내가 어디까지 진행했는지 확인하는 것입니다.

2. 게임의 원본 데이터 (Game Design Data)

  • 이것이 바로 MissionDataManager가 가지고 있는 것들입니다.
  • 특징: 이 데이터는 모든 플레이어에게 동일하며, 개발자가 앱을 업데이트하지 않는 이상 **절대 변하지 않는 '규칙'**입니다.
    • "심호흡 1분" 미션의 설명, "집중의 영약"의 효과, 모든 아이템의 아이콘 등...
  • 비유: 이것은 게임 개발자가 만든 '공식 게임 설명서' 또는 **'미션 대백과사전'**과 같습니다. 이 책은 게임을 설치하면 모든 플레이어가 똑같은 버전을 한 권씩 받습니다.

 FindMissionByName이 필요한가? (문제 상황의 재구성)

이제 진짜 문제 상황을 비유로 설명해 드리겠습니다.

당신은 '미션 대백과사전'(MissionDataManager)을 가지고 있는 플레이어(DoItSceneManager)입니다. 당신은 사전의 35페이지를 펴서 "심호흡 1분" 미션을 보고 있습니다.

이때, 갑자기 엄마가 심부름을 시켜서 책을 급하게 덮고 외출합니다 (앱 종료).

집에 돌아와서 다시 책을 펼칩니다. 당신은 몇 페이지를 보고 있었는지 기억나지 않습니다.

여기서 FindMissionByName이 없으면 어떻게 될까요?
당신은 그냥 책의 아무 페이지나 다시 펼쳐볼 수밖에 없습니다. ("팔굽혀펴기 5회" 미션이 나옴)

FindMissionByName이 있으면 어떻게 될까요?

  1. 저장 (이해하신 부분): 책을 덮기 직전, 당신은 **포스트잇(PlayerPrefs)**에 딱 한 단어, "심호흡 1분" 이라고만 적어서 책상에 붙여놓고 나갑니다. (이게 OnApplicationPause에서 하는 일입니다)
  2. 복구 (이해가 안 가시는 부분): 집에 돌아온 당신은 책상 위의 포스트잇을 봅니다. "아, 내가 '심호흡 1분'을 보고 있었구나!"
    • "뭘 찾는다는 거지??" -> 당신은 이제 '미션 대백과사전'(MissionDataManager)에서 목차를 보고 "심호흡 1분"이라는 제목이 있는 페이지(35페이지)를 다시 찾아 펼칩니다.
    • FindMissionByName의 역할 -> 이 함수가 바로 '목차를 보고 페이지를 찾아주는' 역할입니다. '심호흡 1분'이라는 **이름표(string)**를 주면, 설명과 레벨이 모두 들어있는 **실제 페이지 내용(MissionData 객체)**을 찾아주는 것이죠.

 MissionData 객체를 직접 저장하지 않는가? (가장 중요한 이유)

"그냥 35페이지 내용을 통째로 복사해서 포스트잇에 적어두면 되잖아!" 라고 생각하실 수 있습니다. (JSON으로 저장하자는 아이디어)

여기에 치명적인 문제가 있습니다.

만약 개발자가 게임 업데이트를 통해 "심호흡 1분" 미션의 설명을 더 친절하게 바꾸거나, 레벨을 1에서 2로 올렸다고 상상해 보세요.

  • '이름표' 방식 (현재 방식): 당신은 "심호흡 1분"이라는 이름표만 가지고 있습니다. 그래서 **업데이트된 새 '미션 대백과사전'**에서 "심호흡 1분"을 찾습니다. 당연히 새롭게 바뀐 내용을 보게 됩니다. (올바른 동작)
  • '내용 복사' 방식 (JSON 저장 방식): 당신의 포스트잇에는 **업데이트 전의 '옛날 내용'**이 통째로 적혀있습니다. 당신은 대백과사전이 업데이트된 사실조차 모르고, 영원히 옛날 버전의 미션 정보만 보게 됩니다. (잘못된 동작)

결론:

  • **플레이어의 상태 (PlayerData)**는 계속 변하는 '나의 기록'이므로 JSON으로 저장하는 것이 맞습니다.
  • **게임의 규칙 (MissionData)**은 개발자가 언제든 업데이트할 수 있는 '공식 설명서'이므로, 객체 실물을 저장하는 것이 아니라 '이름표'만 저장해두고, 필요할 때마다 최신 버전의 설명서에서 다시 찾아보는 것이 훨씬 안전하고 올바른 방법입니다.