🗨 개인적인 공부 기록용으로 정리한 내용입니다. 잘못된 내용에 대한 피드백은 언제나 감사합니다 :)
⭐ 싱글톤 패턴은 클래스가 인스턴스를 단 하나만 갖도록 보장하며, 어디에서든 그 인스턴스에 접근할 수 있도록 전역 액세스를 제공하는 디자인 패턴이다.
클래스를 만들면 해당 클래스로부터 여러 개의 인스턴스를 생성할 수 있게 된다.
하지만, 클래스에 대해 하나의 인스턴스만을 생성하고, 이 인스턴스를 전역적으로 관리해야 할 때도 생긴다. 예를 들면, 게임의 설정 정보를 한 곳에서 관리하는 경우가 있다. 이런 상황에서 가장 적합한 디자인 패턴이 바로 싱글톤(Singleton) 패턴이다.
📌 싱글톤 패턴이란?
- 클래스가 인스턴스를 단 하나만 갖도록 보장
- 어디에서든 그 단일 인스턴스에 접근할 수 있도록 전역 액세스를 제공
- 안티 패턴으로 취급되기도 하며, 특히 멀티스레드 환경에서 사용할 때 주의해야한다. (`Race condition` 발생)
📌 싱글톤을 왜 사용하는가?
하나의 클래스 인스턴스만 필요하며, 그 인스턴스를 전역적으로 공유해야 하는 상황에서 사용된다.
이는 효율적인 자원 관리와 데이터의 일관성을 제공한다.
- 글로벌 액세스를 제공한다.
- 예: 유니티의 `GameManager`는 전역에서 게임 상태를 관리하기 위해 접근한다.
- 자원의 효율적인 관리 및 성능을 보장한다.
- 자원을 많이 사용하는 객체(예: 데이터베이스 연결, 파일 시스템)는 하나만 생성하고 모든 곳에서 공유하는 것이 효율적이다.
- 속도가 느린 경향이 있는 `GetComponent` 또는 `Find` 연산의 결과를 캐시 하지 않아도 되므로 성능을 기대할 수 있다.
- 따라서 요청이 많은 곳에서 사용하면 효율을 높일 수 있다.
- 데이터 일관성 유지
- 모든 클래스가 동일한 인스턴스를 사용하므로, 데이터를 일관성 있게 유지할 수 있다.
- 객체가 여러개 생성되면 데이터가 중복되거나 엉키는 문제가 발생할 수 있지만, 싱글톤으로 이를 방지할 수 있다.
- 예: `모든 플레이어의 점수`와 같은 공통 데이터를 관리할 때
📌 싱글톤 패턴의 구조와 구현
싱글톤 패턴은 다음과 같은 구조를 가진다.
- Private Constructor :외부에서 클래스 인스턴스를 생성하지 못하도록 제한
- Static Variable : 클래스 내부에 인스턴스를 저장
- Public Static Method : 해당 인스턴스를 반환하는 메서드 제공
🔖 SimpleSingleton (단순 싱글톤)
유니티에서 사용하는 가장 단순한 싱글톤 예시이다.
예시 코드 | SimpleSingleton
using UnityEngine;
public class SimpleSingleton : MonoBehaviour
{
public static SimpleSingleton Instance; // 공용 정적 필드 Instance 선언
private void Awake() // Awake 메서드에서 인스턴스 설정 유무 확인
{
if (Instance == null) // 생성된 인스턴스가 없으면,
{
Instance = this; // 자신(object)을 인스턴스로 설정
}
else // 이미 생성된 인스턴스가 존재하면,
{
Destroy(gameObject); // 생성을 시도한 오브젝트(중복)를 파괴
}
}
}
🔎 만약 두 개의 인스턴스를 생성하려고 시도하면?
유니티의 계층 창(Hierarchy)에서 둘 이상의 게임 오브젝트에 해당 스크립트를 연결하는 경우, Awake의 로직은 첫 오브젝트만 유지하고 나머지는 제거한다.
🔖 개선된 Singleton
위 SimpleSingleton은 `MonoBehaviour`를 상속받기 때문에 다음과 같은 문제점이 있다.
- 사용하기 전에 반드시 유니티 계층 창(Hierarchy)에서 싱글톤을 설정해야한다. (오브젝트에 연결)
- 씬(`Scene`) 전환 시 오브젝트가 파괴된다.
이를 해결하기 위해 `DontDestroyOnLoad`를 사용하여 씬 전환 시에도 파괴되지 않도록 한다.
아래는 위의 문제를 해결한 예시 코드이다.
예시 코드 | Singleton
public class Singleton : MonoBehaviour
{
private static Singleton instance;
public static Singleton Instance
{
get
{
if (instance == null)
{
SetupInstance();
}
return instance;
}
}
private void Awake()
{
if (instance == null)
{
instance = this;
DontDestroyOnLoad(this.gameObject);
}
else
{
Destroy(gameObject);
}
}
private static void SetupInstance()
{
instance = FindObjectOfType<Singleton>();
if (instance == null) // 싱글톤 타입의 객체가 씬에 없는 경우,
{
GameObject gameObj = new GameObject();
gameObj.name = "Singleton";
instance = gameObj.AddComponent<Singleton>();
DontDestroyOnLoad(gameObj);
}
}
}
🧩 코드 흐름
- `Instance`는 정적 속성으로, 싱글톤 인스턴스를 제공하는 역할을 한다.
- 첫 호출 시 `instance`가 `null`인지 확인한다.
- `null`일 경우 `SetupInstance()`를 호출하여 인스턴스를 생성하거나 찾는다.
- `instance`가 이미 초기화되어 있다면 해당 인스턴스를 반환한다.
- 첫 호출 시 `instance`가 `null`인지 확인한다.
- `Awake 메서드`
- Unity에서 `Awake`는 게임 오브젝트가 활성화될 때 가장 먼저 호출되는 메서드이다. 🔗 이벤트 함수 생명주기 참고
- `instance == null`
- 현재 객체(`this`)를 싱글톤 인스턴스로 설정한다.
- `DontDestroyOnLoad()`를 호출하여 해당 객체가 씬 전환 시에도 파괴되지 않도록 설정한다.
- `instance != null`
- 이미 싱글톤 인스턴스가 존재하므로, 새로 생성된 객체를 제거(`Destroy(gameObject)`)한다.
- `SetupInstance 메서드`
- `FindObjectOfType<Singleton>()` 를 사용하여 현재 씬에 존재하는 `Singleton` 타입의 객체를 찾는다.
- 기존 객체가 없는 경우
- 새로운 게임 오브젝트 생성 - 오브젝트 이름 "Singleton" 설정 - 해당 오브젝트에 `Singleton` 컴포넌트 추가
- `DontDestroyOnLoad()`를 호출하여 해당 객체가 씬 전환 시에도 파괴되지 않도록 설정한다.
📌 싱글톤 패턴은 왜 안티 패턴으로 불리는가? ⭐
싱글톤은 안티 패턴으로 취급되기도 한다. 이유는 다음과 같은 문제점들 때문이다.
- 테스트 및 디버깅을 어렵게 만든다.
- 보통은 각 객체를 해당 클래스가 관리하도록 설계되지만, 싱글톤은 이와 다르게 독립적인 개념으로 생성된다. 심지어 모든 곳에서 접근이 가능하므로 생성, 호출, 변경 시점을 알기 어렵다.
- 클래스간 강한 커플링이 생길 수 있다.
- 다른 패턴들은 종속성 분리를 시도하지만, 싱글톤은 반대로 작용한다.
- 전역 인스턴스로 사용하므로, 하나를 변경하면 연결된 다른 클래스에 영향을 준다.
- => 결합도가 높아진다 => 의존성 역전 원칙에 위배된다.
- 멀티스레드 환경에서 문제가 발생한다.
- 여러 스레드가 동시에 싱글톤 인스턴스를 생성하려고 할 때 문제가 발생한다.
- 이를 방지하지 않으면, 여러 개의 인스턴스가 생기면서 싱글톤 패턴의 핵심 목적을 훼손하게 된다.
📌 멀티스레드 환경에서 문제를 해결하는 방법 | Thread-Safe
멀티스레드 환경에서도 안전하게 싱글톤 패턴을 구현하려면, 아래와 같은 방법들을 이용하여 스레드 세이프(Thread-Safe)를 보장해야 한다.
🔖 Eager Initialization(이른 초기화)
이 방식은 클래스가 로드될 때 싱글톤 인스턴스를 생성하므로 스레드 세이프 문제를 완전히 해결할 수 있다.
public class Singleton
{
private static readonly Singleton instance = new Singleton(); // 📌
private Singleton() { }
public static Singleton Instance
{
get { return instance; }
}
}
기존 방식들은 누군가 호출하기 전까지 인스턴스가 생성되지 않는 특징이 있다. (Lazy Initialization)
하지만 처음부터 `new Singleton()`을 `static`으로 선언하면, 코드를 로드하면서 스스로 생성이 되는 셈이다.
이 방식을 이용하면 `lock`을 사용할 필요가 없다.
다만, 인스턴스를 사용하지 않아도 처음부터 메모리를 할당하며, 로딩시간이 길어질 수 있다.
🔖 Lazy Initialization(게으른 초기화) + 동기화(Synchronized) + 이중 검사 잠금(Double-Checked Locking)
이 방식은 게으른 초기화(Lazy Initialization)방식에서 동기화 키워드(`lock`)를 사용해 인스턴스 생성 과정을 보호한다.
public class Singleton
{
private static volatile Singleton instance = null; // 📌 volatile 변수로 선언
private static readonly object _lock = new object();
private Singleton() { }
public static Singleton Instance {
get {
if (instance == null) { // Check!
lock (_lock) { // 📌 Lock
if (instance == null) { // Deouble Check!
instance = new Singleton(); // 인스턴스 생성
}
}
}
return instance;
}
}
}
🔎 이중 검사 잠금(Double-Checked Locking)을 사용하는 이유
인스턴스를 생성하는 부분 `instance = new Singleton()`은 코드상에서는 한 줄이지만 내부적으로 실행되는 과정(어셈블리 시점)을 보면 여러 과정으로 풀어지게 된다. 이 생성 과정 중, 운이 나쁘게도 다른 스레드가 접근을 한다면, `if (instance == null)` 체크 부분에서 인스턴스가 아직 생성되지 않았다고 판단하여 인스턴스 생성을 시도하는 상황이 발생한다.
이를 방지하기 위해 이중 검사 잠금 방식을 사용한다.
🔎 `volatile` 키워드 사용하는 이유
`volatile` 키워드로 멀티스레드 환경에서의 안정성을 높일 수 있다.
CPU는 메인 메모리에서 자주 사용하는 부분을 캐시에 담아두는 데(최적화), 이 캐시의 데이터가 메인 메모리 데이터와 일치하지 않는 타이밍이 있다.
이 타이밍을 피하기 위해, 멀티스레드 환경에서 `volatile`선언하여 , 인스턴스를 최적화(캐시에 담아두는 기능)에서 제외시킨다.
즉, 캐시 최적화를 건너뛰는 대신에 멀티스레드 환경에서 안정적으로 접근할 수 있도록 하는 키워드이다.
참고
'👻 Unity' 카테고리의 다른 글
[Unity] 오브젝트 풀 패턴(Object Pool Pattern): 효율적인 오브젝트 재사용 전략 (2) | 2024.12.02 |
---|---|
[Unity] 유니티 설치 실패: Validation Failed 원인 및 해결 방법 (0) | 2024.08.20 |