[ 목차 ]
오늘은 게임 디자인 패턴에 대해서 알아본다.
그중 싱글톤, 옵저버, 팩토리 3개를 알아보기!
게임 디자인 패턴은 코드를 문서화하는 거다.
게임 내 상호작용의 반복적 구성요소로, 플레이어가 특정한 방식으로 게임을 플레이하도록 유도하는 설계적 해결책이다.
1. 싱글톤(Singleton) 패턴
싱글톤이란?
클래스의 인스턴스(객체)가 오직 하나만 생성되고, 어디서든 그 인스턴스에 접근할 수 있게 하는 패턴이다.
쉽게 말하자면, 게임 전체에서 딱 하나만 존재하는 객체로 생성해 어디서든 사용하자는 것이다.
왜 쓰는 걸까?
매번 새로 만들기엔 메모리를 낭비하고, 관리가 어렵기 때문에 최적화를 위한 것이다.
어디에 쓰일까?
예를 들어 게임 설정(음량, 그래픽 옵션 등), 점수 관리, 게임 진행 정보(체력, 레벨, 경험치 등), 데이터 저장 & 불러오기 등이다.
우선 인스턴스를 저장할 정적 변수를 작성한다.
static을 사용해 게임 실행 중 어디서든 접근할 수 있다.
public을 사용해 외부에서 접근을 가능하게 하고,
프로퍼티를 사용해서 다른 곳에서 새로운 객체를 만들지 않고, 싱글톤 인스턴스를 반환하도록 한다.
//싱글톤 인스턴스를 저장할 정적 변수
private static GameManager _instance;
//외부에서 인스턴스에 접근할 수 있는 프로퍼티
public static GameManager Instance
{
get
{
//인스턴스가 없으면 찾기
if (_instance == null)
{
_instance = FindFirstObjectByType<GameManager>();
//씬에서도 찾을 수 없으면 새로 생성
if (_instance == null)
{
GameObject singletonObject = new GameObject("GameManager");
_instance = singletonObject.AddComponent<GameManager>();
}
}
return _instance;
}
}
Awake() 메서드에서 게임 시작 시에 호출한다.
이미 인스턴스가 있는지 확인하고, 중복되면 제거를 한다.
씬을 전환해도 유지한다.
private void Awake()
{
// 이미 인스턴스가 있는지 확인
if (_instance != null && _instance != this)
{
// 중복된 인스턴스는 제거
Destroy(gameObject);
return;
}
// 이 인스턴스를 싱글톤으로 설정
_instance = this;
// 씬 전환 시에도 유지
DontDestroyOnLoad(gameObject);
}
private로 클래스 내부에서 점수 저장용 변수 만들고 초기값을 정한다.
잃기 전용 프로퍼티를 만든다. ( ⇒ 표현식으로 get만 있는 프로퍼티를 만든다. )
그리고 AddScore 메서드로 점수 추가 메서드를 만든다.
int points 값을 받아서 출력한다.
private int _score = 0;
public int Score => _score;
public void AddScore(int points)
{
_score += points;
Debug.Log($"Score updataed: {_score}");
}
이제 코인에 충돌했을 때 점수가 오르는 것을 구현해 볼 것이다.
새로운 스크립트를 추가한다.
OnTriggerEnter()는 트리거(Collider) 충돌이 발생했을 때 자동으로 호출되는 것이다.
Collier로 설정한 Is Trigger 옵션을 체크하면 트리거로 설정된다.
그리고 Tag가 Coin으로 설정된 객체가 맞다면
싱글톤에 접근해 점수를 추가한다.
마지막으로 특정 오브젝트를 삭제하는 Destroy를 사용해
닿으면 코인(오브젝트)을 삭제한다.
using UnityEngine;
public class PlayerController : MonoBehaviour
{
private void OnTriggerEnter(Collider other)
{
if(other.CompareTag("Coin"))
{
//싱글톤 인스턴스에 접근하여 점수 추가
GameManager.Instance.AddScore(10);
Destroy(other.gameObject);
}
}
}
이렇게 코인을 먹을 때마다 콘솔에 점수가 오른 것을 알 수 있다.
2. 옵저버 패턴
옵저버란?
객체 간 일대다 의존성을 정의하며, 한 객체의 상태가 변경되면 의존하는 모든 객체에게 통보하여 자동으로 업데이트된다.
쉽게 말하자면, 어떤 객체의 상태가 변경될 때 이를 감지하고 반응하는 여러 객체에게 자동으로 알림을 보내는 패턴이다.
마치 구독과 비슷하다고도 한다.
옵저버의 핵심 개념을 알고 가야 한다.
Subject (주제) : 옵저버에게 알리는 역할
Observer (옵저버) : 변경 사항을 듣고 반응하는 역할
이제 새로운 스크립트를 만들 것이다.
EventManager 스크립트를 만들고 싱글톤 구현으로 시작한다.
//싱글톤 구현
private static EventManager _instance;
public static EventManager Instance
{
get
{
if (_instance == null)
{
GameObject go = new GameObject("EventManager");
_instance = go.AddComponent<EventManager>();
DontDestroyOnLoad(go);
}
return _instance;
}
}
이벤트 이름인 string과
이벤트가 발생했을 때 실행할 함수(옵저버) Action<object>를 쓴다.
변수를 입력해 특정 이벤트가 발생하면 해당 이벤트를 구독한 함수들이 실행된다.
AddListener() 메서드를 통해 이벤트(string)에 새로운 함수를 추가하는 역할이다.
_eventDictionary에서 eventName에 해당하는 이벤트가 있는지 찾고 있다면 out 키워드를 사용해 thisEvent 변수에 저장한다.
기존 이벤트에 새로운 옵저버(리스너)를 추가하고, 업데이트된 이벤트 목록을 다시 저장한다.
//이벤트와 옵저버를 연결하는 딕셔너리
private Dictionary<string, Action<object>> _eventDictionary = new Dictionary<string, Action<object>>();
public void AddListener(string eventName, Action<object> listener)
{
if(_eventDictionary.TryGetValue(eventName, out Action<object> thisEvent))
{
thisEvent += listener;
_eventDictionary[eventName] = thisEvent;
}
else
{
_eventDictionary.Add(eventName, listener);
}
}
RemoveListener() 메서드는 이벤트에서 특정 옵저버를 제거하는 역할이다.
eventName을 찾고, eventName이 존재하면 thisEvent에 저장한다.
그리고 기존 이벤트에서 특정 리스너를 제거하는 것이다.
왜 필요할까?
메모리 누수를 방지하고, 불필요한 이벤트 실행을 막을 수 있다.
//이벤트에서 옵저버 제거
public void RemoveListener(string eventName, Action<object> listener)
{
if (_eventDictionary.TryGetValue(eventName, out Action<object> thisEvent))
{
thisEvent -= listener;
_eventDictionary[eventName] = thisEvent;
}
}
TriggerEvent() 메서드는 등록된 모든 옵저버에게 이벤트 발생을 알리는 역할이다.
이벤트가 발생하면 모든 함수를 실행한다는 것이다.
thisEvent가 null이 아니면 모든 구독된 옵저버가 실행된다.
data 값을 옵저버 함수에 전달하는 것이다.
public void TriggerEvent(string eventName, object data = null)
{
if (_eventDictionary.TryGetValue(eventName, out Action<object> thisEvent))
{
thisEvent?.Invoke(data);
}
}
이제 UI에서 플레이어의 체력 변화를 감지하고 UI 업데이트하는 역할을 구현해 볼 것이다.
UIHealthDisplay라는 스크립트를 만들어본다.
Start 메서드에서 EventManager.Instance.AddListener()를 사용해 이벤트를 구독한다.
"PlayerHealthChanged" 이벤트가 발생하면 OnPlayerHealthChanged() 실행하도록 된다.
마찬가지로 "PlayerDied" 이벤트가 발생하면 OnPlayerDied() 실행한다.
void Start()
{
// 이벤트 구독 (체력 변경 & 사망 이벤트)
EventManager.Instance.AddListener("PlayerHealthChanged", OnPlayerHealthChanged);
EventManager.Instance.AddListener("PlayerDied", OnPlayerDied);
}
OnDestroy() 메서드는 객체가 삭제될 때 실행되며, 이벤트 구독 해제하는 것이다.
private void OnDestroy()
{
// 객체가 삭제될 때 이벤트 구독 해제
EventManager.Instance.RemoveListener("PlayerHealthChanged", OnPlayerHealthChanged);
EventManager.Instance.RemoveListener("PlayerDied", OnPlayerDied);
}
PlayerDied 이벤트 발생 시 실행되는 메서드이다.
플레이어가 체력이 다 닳아 죽으면 게임 오버 화면을 표시하는 등의 작업을 수행한다.
private void OnPlayerDied(object data)
{
Debug.Log("UI 업데이트: 플레이어가 사망했습니다.");
// 게임 오버 화면 표시 등의 동작 수행
}
그렇게 된 전체코드이다.
using UnityEngine;
//이벤트 리스너 (옵저버)
public class UIHealthDisplay : MonoBehaviour
{
void Start()
{
//이벤트 구독
EventManager.Instance.AddListener("PlayerHealthChanged", OnPlayerHealthChanged);
EventManager.Instance.AddListener("PlayerDied", OnPlayerDied);
}
private void OnDestroy()
{
//객체가 삭제될 때 동작하는 함수
EventManager.Instance.RemoveListener("PlayerHealthChanged", OnPlayerHealthChanged);
EventManager.Instance.RemoveListener("PlayerDied", OnPlayerDied);
}
private void OnPlayerHealthChanged(object data)
{
int health = (int)data;
Debug.Log($"UI 업데이트: 플레이어 체력이 {health}로 변경되었습니다.");
//실제로는 여기서 UI 요소를 업데이트한다.
}
private void OnPlayerDied(object data)
{
Debug.Log("UI 업데이트: 플레이어가 사망했습니다.");
//게임 오버 화면 표시 등의 동작 수행
}
}
이제 Player 스크립트를 만든다.
체력을 설정하고, 체력 이벤트를 발생하는 코드이다.
우선 체력을 임시로 100 설정한다.
private int _health = 100;
Helath 프로퍼티를 이용해 체력을 설정할 때 이벤트 발생을 한다.
public int Health
{
get => _health;
set
{
_health = value;
// 체력 변경 이벤트 발생
EventManager.Instance.TriggerEvent("PlayerHealthChanged", _health);
if (_health <= 0)
{
// 플레이어 사망 이벤트 발생
EventManager.Instance.TriggerEvent("PlayerDied");
}
}
}
TakeDamage() 메서드를 사용해 데미지를 입으면 체력을 줄어드는 것을 표현한다.
private void TakeDamage(int damage)
{
Health -= damage; // 체력을 감소시키고 이벤트 발생
}
Update에서 임시로 스페이스바를 누르면 체력이 10 감소하는 것을 작성한다.
private void Update()
{
if (Input.GetKeyDown(KeyCode.Space))
{
TakeDamage(10);
}
}
이렇게 된 전체코드이다.
using UnityEngine;
public class Player : MonoBehaviour
{
private int _health = 100;
public int Health
{
get => _health;
set
{
_health = value;
//체력 변경 이벤트 발생
EventManager.Instance.TriggerEvent("PlayerHealthChanged", _health);
if(_health <= 0)
{
//플레이어 사망 이벤트 발생
EventManager.Instance.TriggerEvent("PlayerDied");
}
}
}
private void TakeDamage(int damage)
{
Health -= damage;
}
private void Update()
{
if(Input.GetKeyDown(KeyCode.Space))
{
TakeDamage(10);
}
}
}
게임을 실행하고 스페이스바를 클릭하면 콘솔로 출력이 된다.
3. 팩토리 패턴
팩토리란?(Factory)
캡슐화하여 클라이언트 코드와 분리하는 패턴이다.
Unity에서는 다양한 적, 아이템, 효과 등을 생성할 때 유용하다.
쉽게 말하면, 객체 생성을 담당하는 공장을 만들어 생성하는 패턴이다.
왜 사용할까?
객체 생성 코드가 팩토리 패턴에서 관리되므로 유지보수가 좋다.
이제 이 팩토리 패턴을 활용해 적을 생성하고 관리하는 구조를 실습해 보겠다.
우선 적의 종류를 열거형으로 정의한다.
실습으로 4가지 타입의 적을 생성하였다.
public enum EnemyType
{
Grunt,
Runner,
Tank,
Boss
}
이제 IEnemy 인터페이스에서 모든 적들이 반드시 구현해야 하는 기능을 정의한다.
여기에서는 적의 위치, 적의 공격, 적이 데미지를 받았을 때의 동작을 정의하였다.
public interface IEnemy
{
void Initialize(Vector3 position);
void Attak();
void TakeDamage(float damage);
}
그리고 모든 적들의 기본 클래스를 만든다.
공통으로 가지는 체력, 이동속도, 공격력을 정의한다.
public abstract class EnemyBase : MonoBehaviour, IEnemy
{
public float health;
public float speed;
public float damage;
이제 위에서 인터페이스로 정의한 기능의 메서드를 작성한다.
먼저 위치를 설정하는 함수이다.
virtual로 선언되어 있어서 각 적이 필요하면 오버라이드도 가능하다.
public virtual void Initialize(Vector3 position)
{
transform.position = position;
}
공격 기능 메서드이다. 적마다 다르게 구현되므로 abstract로 선언한다.
public abstract void Attak();
적의 공격을 받아 체력이 줄어드는 기능에 대한 메서드이다.
체력이 0 이하가 되면 Die() 호출한다.
public virtual void TakeDamage(float damage)
{
health -= damage;
if (health <= 0)
{
Die();
}
}
그 Die() 메서드이다.
0 이하가 되면 게임 오브젝트를 삭제한다.
protected virtual void Die()
{
Destroy(gameObject);
}
그렇게 EnemyBase 전체 코드이다.
using UnityEngine;
//## 3. 팩토리(Factory) 패턴
//팩토리 패턴은 객체 생성 로직을
//캡슐화하여 클라이언트 코드와 분리하는 패턴입니다.
//Unity에서는 다양한 적, 아이템, 효과 등을 생성할 때 유용합니다.
//적 타입 열거형
public enum EnemyType
{
Grunt,
Runner,
Tank,
Boss
}
//모든 적의 기본 인터페이스
public interface IEnemy
{
void Initialize(Vector3 position);
void Attak();
void TakeDamage(float damage);
}
//기본적 클래스
public abstract class EnemyBase : MonoBehaviour ,IEnemy
{
public float health;
public float speed;
public float damage;
public virtual void Initialize(Vector3 position)
{
transform.position = position;
}
public abstract void Attak();
public virtual void TakeDamage(float damage)
{
health -= damage;
if(health <= 0)
{
Die();
}
}
protected virtual void Die()
{
Destroy(gameObject);
}
}
이제 위에서 정의한 적 캐릭터를 구현해 준다.
EnemyBase에서 상속받아 Grunt 적 캐릭터를 구현한다.
Grunt만의 체력, 속도, 공격 방식 등을 추가한다.
EnemyBase를 상속받는 것으로 수정한다.
public class Grunt : EnemyBase
적이 생성될 때 위치를 초기화한다. 이것은 EnemyBase에 있는 위치 설정 기능을 가져온다.
체력과 이동 속도, 공격력의 초기값을 정해준다.
public override void Initialize(Vector3 position)
{
base.Initialize(position); // 부모 클래스(EnemyBase)의 위치 설정 기능 사용
health = 50; // Grunt의 체력 설정
speed = 3f; // 이동 속도 설정
damage = 10f; // 공격력 설정
}
EnemyBase에서의 Attack()은 abstract(추상 메서드)로 선언되어 있어서 반드시 구현해야 한다.
public override void Attak()
{
Debug.Log("Grunt가 근접 공격을 합니다.");
}
그렇게 Grunt의 전체 코드이다.
using UnityEngine;
public class Grunt : EnemyBase
{
public override void Initialize(Vector3 position)
{
base.Initialize(position);
health = 50;
speed = 3f;
damage = 10f;
}
public override void Attak()
{
Debug.Log("Grunt가 근접 공격을 합니다.");
}
}
이런 식으로 Runner의 전체 코드이다.
using UnityEngine;
public class Runner : EnemyBase
{
public override void Initialize(Vector3 position)
{
base.Initialize(position);
health = 30;
speed = 6f;
damage = 5f;
}
public override void Attak()
{
Debug.Log("Runner가 빠르게 연속 공격을 합니다.");
}
}
Tank의 전체 코드이다.
using UnityEngine;
public class Tank : EnemyBase
{
public override void Initialize(Vector3 position)
{
base.Initialize(position);
health = 200;
speed = 1.5f;
damage = 25f;
}
public override void Attak()
{
Debug.Log("Tank가 강력한 충격파 공격을 합니다.");
}
}
이제 팩토리 패턴을 적용해 적을 생성하는 역할을 하는 스크립트를 작성할 것이다.
이것을 이용하면 적을 직접 생성하는 것이 아니라,
이 스크립트를 통해 적을 만들고 반환하는 구조이다.
EnemyFactory 이름의 스크립트를 만든다.
public class EnemyFactory : MonoBehaviour
싱글톤 패턴을 적용한다.
private static EnemyFactory _instance;
public static EnemyFactory Instance
{
get
{
if (_instance == null)
{
GameObject go = new GameObject("EnemyFactory");
_instance = go.AddComponent<EnemyFactory>();
DontDestroyOnLoad(go);
}
return _instance;
}
}
Awake()에서 싱글톤을 초기화한다. (중복 생성 방지)
private void Awake()
{
if (_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
}
적 프리팹 변수를 선언한다.
생성된 적의 오브젝트를 저장하고 할당해서 사용하기 위함이다.
public GameObject gruntPrefab;
public GameObject runnerPrefab;
public GameObject tankPrefab;
CreateEnemy()로 적 생성 메서드를 작성한다.
EnemyType을 통해 어떤 적을 생성할 건지 결정하고,
switch 문을 통해 적 프리팹을 선택한다.
선택된 해당 프리팹을 복제하여 게임에 생성한다.
public IEnemy CreateEnemy(EnemyType type, Vector3 position)
{
GameObject enemyObject = null;
switch(type)
{
case EnemyType.Grunt:
enemyObject = Instantiate(gruntPrefab);
break;
case EnemyType.Runner:
enemyObject = Instantiate(runnerPrefab);
break;
case EnemyType.Tank:
enemyObject = Instantiate(tankPrefab);
break;
default:
Debug.LogError($"Unknown enemy type: {type}");
return null;
}
// 생성된 적 초기화
IEnemy enemy = enemyObject.GetComponent<IEnemy>();
enemy.Initialize(position);
return enemy;
}
그렇게 EnemyFactory 전체 코드이다.
using UnityEngine;
//적 팩토리 클래스
public class EnemyFactory : MonoBehaviour
{
//싱글톤 패턴 적용
private static EnemyFactory _instance;
public static EnemyFactory Instance
{
get
{
if(_instance == null)
{
GameObject go = new GameObject("EnemyFactory");
_instance = go.AddComponent<EnemyFactory>();
DontDestroyOnLoad(go);
}
return _instance;
}
}
//프리팹 참조
public GameObject gruntPrefab;
public GameObject runnerPrefab;
public GameObject tankPrefab;
private void Awake()
{
if(_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
}
//적 생성 메서드
public IEnemy CreateEnemy(EnemyType type, Vector3 position)
{
GameObject enemyObject = null;
//적 타입에 따라 다른 프리팹 사용
switch(type)
{
case EnemyType.Grunt:
enemyObject = Instantiate(gruntPrefab);
break;
case EnemyType.Runner:
enemyObject = Instantiate(runnerPrefab);
break;
case EnemyType.Tank:
enemyObject = Instantiate(tankPrefab);
break;
default:
Debug.LogError($"Unknow enemy type: {type}");
return null;
}
//생성된 적 초기화
IEnemy enemy = enemyObject.GetComponent<IEnemy>();
enemy.Initialize(position);
return enemy;
}
}
이제 일정한 시간 간격으로 적을 생성하는 역할을 하는 스크립트를 만들 것이다.
팩토리 패턴을 활용해 랜덤 위치에 랜덤 한 적을 생성하는 구조이다.
EnemySpawner 스크립트를 생성한다.
적의 생성 간격에 대해 적는다.
[SerializeField]는 Unity에서 priavte 변수라도 인스펙터에서도 수정할 수 있도록 만드는 속성이다.
spawnInterval로 적이 생성되는 주기를 설정한다. 임시로 5초 간격으로 설정하였다.
시간을 체크하는 변수(_timer)를 설정한다.
[SerializeField] private float spawnInterval = 5f;
private float _timer;
Update()에서 적 생성 타이밍을 체크한다.
게임이 실행된 시간을 계속 더하고,
_timer가 설정한 시간(5초)을 초과하면 적을 생성 후 _timer를 다시 0으로 리셋해 반복하는 것이다.
void Update()
{
_timer += Time.deltaTime; // 시간이 흐름
if(_timer >= spawnInterval) // 만약 설정한 시간(5초)이 지났다면
{
SpawnRandomEnemy(); // 랜덤 적 생성
_timer = 0; // 타이머 초기화
}
}
랜덤 위치를 생성하는 메서드를 작성한다.
private void SpawnRandomEnemy()
{
// 1. 랜덤한 위치를 계산
Vector3 spawnPosition = new Vector3(Random.Range(-10f, 10f), 0, Random.Range(-10f, 10f));
// 2. 랜덤한 적 타입 선택 (0~2 사이의 랜덤 숫자로 선택)
EnemyType randomType = (EnemyType)Random.Range(0, 3);
// 3. 팩토리를 통해 적을 생성
IEnemy enemy = EnemyFactory.Instance.CreateEnemy(randomType, spawnPosition);
// 4. 로그 출력 (디버깅 용도)
Debug.Log($"{randomType} 적이 {spawnPosition}에 생성되었습니다.");
}
이렇게 된 EnemyFactory의 전체 코드이다.
using UnityEngine;
//적 팩토리 클래스
public class EnemyFactory : MonoBehaviour
{
//싱글톤 패턴 적용
private static EnemyFactory _instance;
public static EnemyFactory Instance
{
get
{
if(_instance == null)
{
GameObject go = new GameObject("EnemyFactory");
_instance = go.AddComponent<EnemyFactory>();
DontDestroyOnLoad(go);
}
return _instance;
}
}
//프리팹 참조
public GameObject gruntPrefab;
public GameObject runnerPrefab;
public GameObject tankPrefab;
private void Awake()
{
if(_instance != null && _instance != this)
{
Destroy(gameObject);
return;
}
_instance = this;
DontDestroyOnLoad(gameObject);
}
//적 생성 메서드
public IEnemy CreateEnemy(EnemyType type, Vector3 position)
{
GameObject enemyObject = null;
//적 타입에 따라 다른 프리팹 사용
switch(type)
{
case EnemyType.Grunt:
enemyObject = Instantiate(gruntPrefab);
break;
case EnemyType.Runner:
enemyObject = Instantiate(runnerPrefab);
break;
case EnemyType.Tank:
enemyObject = Instantiate(tankPrefab);
break;
default:
Debug.LogError($"Unknow enemy type: {type}");
return null;
}
//생성된 적 초기화
IEnemy enemy = enemyObject.GetComponent<IEnemy>();
enemy.Initialize(position);
return enemy;
}
}
코드를 문서화하는데 중요한 게임디자인패턴 중 싱글톤, 옵저버, 팩토리에 대해서 알아보았다.
이 부분은 상당히 어려운 부분이지만, 사용하면 효율적이고 체계적으로 설계할 수 있다.
개인으로 제작하는데 아직 초보다?
그렇다면 굳이? 일 수도 있다.
이 어려운 과정을 거친 프로젝트가 시간은 오래 걸리더라도
추후에는 효율적으로 업데이트나 커뮤니케이션이 잘 될 수 있으니
활용해 보는 시간을 가지는 것을 추천한다!
'Development > 멋쟁이사자처럼 게임개발 부트캠프' 카테고리의 다른 글
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 26일차 (2) - 2D 횡스크롤 (0) | 2025.04.16 |
---|---|
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 26일차 (1) - 게임디자인패턴 : 스트래티지(Strategy), 스테이트(State) (0) | 2025.04.13 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 24일차 - URP 2D Light (0) | 2025.04.12 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 23일차 - 유니티 게임 수학 & 물리 (0) | 2025.04.08 |
[멋쟁이사자처럼 Unity 게임 부트캠프 4기] 23일차 - Katana ZERO (4) (0) | 2025.04.08 |