필요 용어부터 알고가고싶다면 아래쪽 용어정리란을 먼저 확인한다.
---------
싱글톤에 대한 간단한이해
싱글톤 패턴이란 클래스의 인스턴스가 프로그램 전체에서 단 하나만 존재하도록 보장하는 구조
인스턴스는 클래스 내에서 정적 변수로 존재하며, 모든곳에서 접근가능하다..
예를들면 플레이어의 현재 골드는 클래스 여러곳에서 동시에 관리된다면
어떤골드가 플레이어의 진짜 골드인지 알수없어서 단하나의 객체만 존재하도록
싱글톤으로써 관리한다.
근데 싱글톤은 객체지향의 5원칙 중하나인
DIP(의존성 역전원칙)을 어긴다.
어떠한 객체가 구체적인 객체에 의존하는 것이 아닌 추상화에 의존해야한다는 원칙
다른객체들이 싱글톤에 막 참조를 하기 시작하는것부터 이미
클래스간의 결합성이 높아지고, 여기서 싱글톤에 수정이 일어나면
싱글톤을 참조하는 모든 클래스들에 영향을 미친다.
그리고 테스트가 어려워진다.
내가 변수를 건들지도않았는데 어딘가에서 참조된 싱글턴패턴에 의해 이곳저곳에
수정이 있을수있는 것이다.
클래스 구조상 이런 계층구조라 할때
A(싱글톤) -> B -> C -> D
D에서 A를 뭔가 참조해서 쓰고싶을때 A를 싱글톤해서 쓰면 편해보일수있는데
B,C도 쓰고있을때 문제가 될수있다.
높은 결합도 때문에 전역상태를 참조하는 모든 클래스는
누가 언제 어떤값으로 A를 초기화했나 암묵적으로 알아야하고
그때문에 버그가 발생했을때 원인추적이 매우어렵다.
싱글톤
유일하게 존재해야하고 어디서나 참조되어야함
퍼블릭으로 만들어서 객체생성후에는 다른 클래스에 참조 못시키나?
싱글톤으로 해야만할수있나?
답변하자면 꼭써야하는것은 아니다. 상황에 따라 더 단순한 방법이
오히려 유지보수, 테스트, 확장에 유리할 수 있음
작게 시작할때는 일반 인스턴스 패턴으로 하면된다.
시스템 레벨에서 단하나만 있어야할때 싱글턴을 고려한다.
| 일반 인스턴스 패턴 | 싱글턴 패턴
선언·초기화 | var mgr = new GameManager(); | GameManager.Instance
참조 전달 방식 | 필요한 곳에 mgr 참조를 파라미터로 넘김 | 어디서나 GameManager.Instance로 접근
테스트·유연성 | 높음 (여러 개 만들어 테스트, DI 주입 가능) | 낮음 (전역 상태라 Mocking 하려면 번거롭다)
전역 접근성 | 없음 (명시적으로 전달해야 함) | 있음 (코드 어디서나 접근)
비교예제:
일반 public class GameManager를 다른 클래스에서 받아서 이용하려면,
public class GameManager
{
public Player Player { get; private set; }
private Dictionary<KeyofScreen, Screen> _screens;
public void Initialize()
{
Player = new Player(/*…*/);
// this 는 현재 이 GameManager 인스턴스
_screens = new Dictionary<KeyofScreen, Screen>()
{
{ KeyofScreen.MainMenu, new MainmenuScreen(Player, this) },
// … 다른 스크린들
};
}
public void ChangeScreen(KeyofScreen key)
{
_screens[key].Show();
}
}
이렇게 만들었다 치고, 아래와 같이
받고 싶은 클래스에서 필드 설정하고 생성자로 주입받아서 할당하는 방식으로도 쓸수있다.
private Player _player;
private GameManager _manager;
public MainmenuScreen(Player player, GameManager manager)
{
_player = player;
_manager = manager;
}
-----------
싱글톤 테스트 용이성 - DI 또는 Mocking 기능이 뭐지?// 아래쪽에 용어정리완료
DI는 의존성주입방식, Mocking은 말그대로 목업 가짜 객체로 테스트하는것
싱글톤 사용에
로깅시스템, 설정관리자, 오디오매니저 ,게임매니저가 들어가는데
로깅시스템이 뭐야.
애플리케이션이 실행되는 동안 발생하는 이벤트(정보, 경고, 오류 등)를 파일,콘솔,원격 서버등에
가록하는 시스템
목적으로는 디버깅- 코드가 어디까지 실행되고, 예외는 어디서났는지추적
운영 모니터링 - 장애발생시 원인분석, 성능 병목 포착
감사 - 누가 언제 어떤 동작을 했는지 기록
문법초보라
static(전역)이랑 구현에 있어 큰 차이를 못느끼겠음
싱글톤 예제
private static readonly 로 필드 생성후 여기다가 객체 생성해줌
private 생성자로 설정등 내용초기화
그리고
public static 프로퍼티로 set 부분에 객체 연결해줌 Instance => instance; // this; 라고해도됌? this쓰려면 객체가 있어야하나?
람다식으로
프로퍼티 => 객체 식으로 정의내릴수도있음
생성자에 초기화했던 정보값들을 public 프로퍼티로 만들어줌
이라고함...
사용예제에서는
메인 함수에서
.Instance 로 접근가능
Unity 싱글톤 예제
public static GameManager Instance (get; private set;) // 왜 private set이지?
void Awake()
{
if ( Instance == null)
{
Instance = this;
DontDestroyOnload (gameobject); // 씬전환시 파괴안되도록하는것같은데 소괄호안에 게임오브젝트가 뭘지칭? 그리고
// 매개변수로 받은거같은데 뭐지?
}
else
{
Destroy(gameObject); // 중복생성방지라네.
}
-----
static
전역설정시에는 코드 가독성이 훨씬 간단
정적 생성자 (접근제한자없음)
으로 참조전 초기 세팅가능함
생명주기는 애플리케이션 실행 동안 계속 메모리에 상주한다
오직 static 필드, 메서드, 프로퍼티만 선언가능하며
non-static멤버는 정의자체가 불가능하다.
.(닷)으로 어디서든 쉽게 불러올수있음
의존성이 암묵적이라 알기는 좀 어려움
테스트가 불가능할정도로 용이하지않음
유연성도 낮음
객체 생성이 없다
인터페이스 상속도 불가능하다
클래스 로드 시점에서 초기화, 지연초기화 불가능
-----
반면
일반패턴 클래스
전역사용안함(DI등)
생성자 주입, 파라미터 전달방식
명시적
Mocking & 단위 테스트가 쉽다.
유연성이 높다.
인스턴스 계속 생성가능
인터페이스 상속가능
----
singleton 패턴
싱글턴
내부적으로 단 하나의 인스턴스 존재
인터페이스 상속 가능
인스턴스 객체가 생성되어있다면 .instance 로 어디서든 쉽게 불러올수있음
테스트도 가능 DI/Mq로 교체가능
지연 초기화 가능
보통 싱글턴 패턴의 인스턴스는 클래스 자신에 생성하도록 코드를 짠다.
그리고 어딘가에서 호출할때 바로 인스턴스는 생성된다.
그전에 공부해야할 부분이있다 static이라는 용어에 익숙해지자.
인스턴스 필드는 각 객체가 생성될때마다 힙위에 별도로 저장된다.
static 멤버는 클래스당 한번만 메모리에 올라간다.
.NET 런타임의 '정적영역'에 한번만 할당된다.
public class Foo
{
public static int Counter = 0; // 정적 필드
public int Value; // 인스턴스 필드
}
여기서 Foo.Counter는 프로그램 전체에서 하나만 존재하는것이다.
다른 클래스에서 참조하면 실제로는 항 상 같은 메모리위치에서 값을 읽거나 쓰는것이다.
그래서 모든 호출자가 같은 static 값을 공유하게된다.
이걸 알면 이제 싱글톤 패턴으로 확장가능하다
싱글톤패턴은 클래스 자체는 static이 아니지만, 프로퍼티라는 창구가 static으로 만들어진다.
그렇다면 구조적으로 여전히 static(전역)클래스와 싱글톤 패턴의 클래스는 비슷하지않나
생각이 들수있다.
하지만 구조적으로 간단히 말해차이가있다.
복습먼저하자면
static 맴버들은 클래스당 한번만 메모리에 올라간다는것을 배웠을것이다.
프로그램당 전체에서 하나만 존재한다.
static 맴버들이 실제 메모리할당 및 초기화되는 시점은
'바로 어딘가에서 해당 타입을 처음 참조' 할때이다.
먼저 전역클래스는 맴버들이 전부 static이다. 그리고 인스턴스가 없다.
또한 전역클래스는 전역생성자에서 로직을 작성하는것은 똑같이 가능한데 매개변수
즉 파라매터를 받을수는없다. 어딘가에서 static을 참조하는순간 값을 받을수없이
무조건 실행되는 내용들만 담겨있다.
그리고 인터페이스가 구현이 안된다는 점이있다.
반면 싱글턴패턴 클래스는 프로퍼티의 전역설정으로인한 창구역할이 중요하지
맴버중에 static이 아닌경우도 가능하며
private 생성자에 파라미터가 들어갈 수 있다. 물론 조건부다, 외부 new가 금지되어있어서
DI 컨테니어 쪽에서 생성자 파라미터를 주입하는 방식으로 가능하다.
AddSingleton<IMySvc, Mysvc>() 처럼 말이다.
또한 인터페이스 구현이 가능하다. 그리고 구현체를 바꿔줄수 있어 Mock(가짜 객체)를 통해 테스트가 용이하다.
이게 무슨 말인가하면, 인터페이스 상속, 인터페이스 타입의 외부 구현체 객체를 생성자나 메서드를 통해
의존성 주입이 가능하다라는 뜻이다.
DI 컨테이너부터 알아보자.
DI(의존성 주입)의 주입을 손으로 직접하려면 호출자 코드안에 new를 써서 필요한 객체를 만들어
생성자에 넘겨줘했다. 의존성 주입, 구현체, 구체에 대한 용어, 개념 정리는 하단에 있다.
//의존성 직접주입예시
var audio = new AudioManager();
var player = new PlayerController(audio);
PlayerController는 audio없이는 안돌아간다.
DI 컨테이너는 이 주입과정을 자동화해주는 도구라고 볼수있다.
DI 컨테이너는
등록 : 어떤 인터페이스,추상타입이 어떤 구현체,구체타입과 연결될지 미리 알려준다.
빌드 : 그 설정을 바탕으로 "이제부터 DI 컨테이너가 관리할 준비를 마쳤다"고 표시한다.
해결 : 실제 코드에서 "IAudioService가 필요해!" 라고 요청하면
컨테이너가 미리 등록해둔 구현체를 찾아 new 혹은 싱글턴 처럼 한번만 만들어서 반환한다.
주입 : 생성자 파라미터나 프로퍼티, 심지어 메서드 호출시점에도 자동으로 필요한 의존성을
넣어준다.
DI 컨테이너 예제는 추후 보완 예정.
그나마 전역클래스와 싱글톤의 비슷한 얘기라면 static클래스는 타입을 처음 참조하는 순간
초기화 타이밍, 전역접근이가능해지고
싱글턴 클래스또한 최초 호출할때 객체가 만들어지고 이 객체를 계속 반복사용한다는 부분은
비슷하다고 할 수 있겠다. 그리고 이것은 편리해보이지만, 유지보수 측면에서 좋지않은 방향이다.
즉 전역클래스, 싱글톤을 어디서든 접근할수있으니 빠르게 쓸수있다고 생각하고 사용한다면
추후 버그가 발생했을때, 그 원인을 찾는데있어 큰 문제를 겪을것이다.
mocking을 이용한 테스트도 힘들다.
정리하자면
싱글턴은 인터페이스, DI 컨테이너 없이 쓰기에는 그다지 매리트가 없다
그냥 전역으로 쓰거나, 아니면 일반클래스로 파라매터를 주고받을 수 있게 만드는게 낫다.
생성자 주입,테스트,다형성 이점을 포기한 셈이고
private 생성자, 지연초기화코드, instance 프로퍼티등 불필요한 복잡성이 추가된다.
아이러니하게도 싱글톤 클래스는 둘다 상속이나 인터페이스를 구현할일이
거의 없다.
유니티 한정으로 정적 클래스는 MonoBehaviour을 상속받을 수 없어 오브젝트에 붙일수없는데
싱글톤은 붙이고 직렬화해서 인스펙터에서 변수를 만져줄수있다.
Unity 용 c# 문법에서는 살짝다른데,
MonoBehaviour는 new로 생성할수없다.
일반 c#클래스는 new Myclass()로 인스턴스를 만들고 파라미터로 넘길 수 있지만
Unity의 MonoBehaviour는 절대 new로 생성할 수 없다.
var mgr = new GameObject().AddComponent<GameManager>();
는 불가능하다.
GameManager같은 매니저는 한번 만들어진 뒤 씬이 바뀌어도 계속 살아남아야 할때가
많다.
void Awake()
{
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}
Awake로 호출할때 맞춰 딱한번 인스턴스가 남고
나머지 복제본은 Destroy되서 게임 내내 하나만 유지된다.
그리고 어디서든 GameManager.Instance로 편하게 접근하는것도 매우 유용하다.
Unity 프로젝트는 수백개의 MonoBehaviour 스크립트가 뿔뿔이 붙어 움직인다.
이 스크립트들 사이에 매번 Inspector에 GameObject 레퍼런스를 드래그하고
할당하기 번거로우니,
static Instance 프로퍼티로 글로벌 싱글턴에 접근하는 편의성을 선택하게 된것이다.
그렇다고 무조건 좋은것은 당연아닌데,
C# 일반 프로젝트와 마찬가지로 전역의존성이 강해져
Mocking이나 DI 가 어렵다. 즉 테스트, 버그수정등에 대한 유연성이 떨어진다.
Awake 호출 순서문제: 어떤 오브젝트의 Awake가 먼저 실행될지 장담할수없다.
Instance가 null인 상태에서 접근 -> NullReferenceException 발생의 위험이 있다.
결국 싱글턴보다는 DI(의존성주입) + 인터페이스 기반 설계가 더 견고하다.
그렇다면
언제 싱글턴이 “실용적”일까?
작고 단순한 프로젝트에서,
DI 인프라를 도입하기 과도하게 느껴질 때,
Unity 매니저처럼 new 불가 + 씬 전환 유지가 필요할 때
이럴 땐 “한 방에 딱 하나, 어디서든 꺼내쓰기”라는 싱글턴 패턴이 개발 생산성을 올려 주기도
프로젝트 규모,요구사항에 맞춰서 쓸수있다.
싱글턴을 쓸때도
대안이 있기는한데 이건 추후 공부한다.
아래에 작성만해놓겠다. 보완 필요
-----
Zenject, UniInject, Extenject 같은 Unity DI 컨테이너를 쓰면,
MonoBehaviour 생성도 컨테이너가 관리하고,
GameManager를 싱글톤처럼 등록하되 인터페이스 기반으로 주입할 수 있습니다.
ScriptableObject 싱글턴 패턴:
ScriptableObject 자산(Asset)을 전역 설정·매니저로 사용하면,
Inspector에서 에셋 하나만 참조해 두고, 장면에 배치하지 않아도 데이터를 유지할 수 있습니다.
-------
어느정도 개념이 잡혔다면 싱글턴을 만드는 방법을 보자
c# 스크립트에는
두가지 방식이있다.
1. 정적 초기화방식 2. 지연초기화방식이다.
그리고 Unity 에서 싱글턴하는 방식도 있다.
하나씩 보자면
먼저 정적 초기화방식
public class EagerSingleton
{
// ① 타입이 로드될 때 곧바로 인스턴스 생성
private static readonly EagerSingleton _instance = new EagerSingleton();
// ② 외부에서 new 못 하도록
private EagerSingleton()
{
// 초기화 로직
}
// ③ 외부에 제공하는 전역 접근점
public static EagerSingleton Instance => _instance;
public void DoWork()
{
Console.WriteLine("EagerSingleton이 일합니다.");
}
}
장점 : 멀티스레드에서도 안전
단점: 타입이 처음사용될때 무조건 생성, Instance프로퍼티 호출하지않아도
타입사용이 트리거가되어 생성될 수 있음. static 맴버 접근, typeof, new 등
static 멤버 접근이란.
static 키워드가 붙은 필드, 메서드,프로퍼티에 읽기,쓰기 호출할때를 모두 포함하는말이다.
type of는 내용이 길어 아래쪽 용어정의로 기입하였다.
그다음 지연 초기화방식
public class LazySingleton
{
// ① Lazy<T>를 쓰면 thread‑safe + 지연 초기화
private static readonly Lazy<LazySingleton> _lazy =
new Lazy<LazySingleton>(() => new LazySingleton());
private LazySingleton()
{
// 초기화 로직
}
// ② Value에 처음 접근하는 순간에만 생성
public static LazySingleton Instance => _lazy.Value;
public void DoWork()
{
Console.WriteLine("LazySingleton이 일합니다.");
}
}
장점: 실제 사용할때까지 인스턴스 생성 지연, _lazy.value를 실제 처음 호출할때 객체생성
단점: 복잡도 약간 증가, Lazy<T> 이해필요
그다음 마지막으로 Unity MonoBehaviour 싱글턴 패턴이다.
using UnityEngine;
public class GameManager : MonoBehaviour
{
// ① static 창구
public static GameManager Instance { get; private set; }
private void Awake()
{
// ② 중복 방지 + 씬 전환 시 파괴되지 않도록
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
Initialize();
}
else
{
Destroy(gameObject);
}
}
private void Initialize()
{
// 초기화 로직 (예: 데이터 로드, 서비스 세팅 등)
}
public void DoWork()
{
Debug.Log("GameManager가 일합니다.");
}
}
new 로 생성할 수 없고, Unity가 씬에 배치된 GameObject를 통해 인스턴스화 한다
Awake()에서 Instance에 자신을 할당하고, 중복은 파괴하여 "항상 하나만" 유지
DontDestroyOnLoad로 씬 전환에도 살아남는다.
꼭하나만 필요한 매니저들은 싱글톤으로 제한하고
전역상태는 지양한다. 생성자 주입 또는 파라미터전달방식을 기본으로 쓴다.
-----------
용어정리:
의존성: 어떤 클래스, 모듈이 기능을 수행하기 위해 다른 클래스,모듈을 필요로하는 관계
public class PlayerController
{
private AudioManager _audio; // AudioManager이라는 클래스에 의존
public PlayerController()
{
_audio = new AudioManager();
}
}
여기서 PlayerController는 AudioManager 없이는 동작하지않으므로 AudioManager에
의존한다고 표현한다.
유연성이높다/낮다:
유연성 : 시스템이나 코드가 변경,확장에 얼마나 쉽게 대응 할 수 있는지.
높다: 기능 추가, 수정시 기존코드변경이 적고, 여러상황을 지원하기 쉬움
낮다: 작은 요구사항 변경에도 코드 전체를 고쳐야함
예시-
낮은 유연성 : PlayerController 내부에 직업 AudioManager을 new해서 쓸때
높은 유연성: 외부에서 어떤 IAudioService 구현체를 주입해서 사용할때
명시적이다 암묵적이다:
(Explicit)명시적:
개발자가 어디서 어떤값을 쓰는지 코드에 분명히드러남
예: 메서드의 파라미터로 AudioManager를 전달받아서 사용하는 방식
(Implicit)암묵적:
코드상에 드러나지않고 배경으로 숨어있음
예: static AudioManager.Instance 처럼 어디서든 전역 접근 가능한 경우
테스트, 디버깅시, 명시적일수록 이 메서드는 무엇에 의존하는지 파악하기 쉽다.
DI(Dependency Injection): 의존성 주입
객체가 스스로 의존 대상을 생성하지않고, 외부(컨테이너나 호출자)가 대신 만들어
주입해주는 기법
장점- 의존성이 명시적이고 교체가능하여 테스트,유연성이 올라감
호출자는 아래에서도 설명하겠지만 간단히 말해 특정 기능(메서드)을 호출해서 사용하는 주체다.
만들어둔 메서드,생성자(메서드)를 통해 다른 특정 기능을 호출해줄때,
이 메서드나 생성자를 호출자라고한다.
의존성이란 아까 말했지만 어떤 클래스,모듈이 기능하려면 다른 클래스,모듈을 필요로하는것이다.
즉 의존성을 주입한다는것은
해당 클래스에서 필요한 외부기능을 위한 외부타입 객체를 다시 생성하는것이아니라,
호출자가되는 메서드나 생성자, 컨테이너를 통해 참조하여 의존성을 가져오는것이다.
DI(의존성 주입)에는 메서드,생성자,컨테이너가 필요하겠다고 판단하면 좋다.
이렇게하면 의존 대상을 쉽게 바꿀수있으며
의존관계가 코드에 명확히 드러나고
재사용,확장성이 좋아진다.
의존성 주입으로 의존대상을 쉽게 바꿀수있다는 점은
외부 구현체에 한한다, 외부 구체타입의 클래스인스턴스를 생성자에 인자로 받아오는경우는
구조적으로 new로 외부 구체타입의 클래스 인스턴스를 해당 클래스에 만들어주는것과
별반차이가 없다. 단단하게 결합하고 코드들을 수정해줘야한다.
느슨하게 결합하고 의존대상을 쉽게 바꿀수있으려면
바로 상속가능한 인터페이스나 추상클래스의 하위 클래스인 구현체를 사용할때에
해당 구현체를 객체화하는 과정에서
상위 클래스 또는 인터페이스 타입으로 인스턴스화 하기때문에
이 구현체의 인스턴스를 파라메터로 받을때 타입을 상위클래스 또는 인터페이스 타입으로 받을 수 있기때문이다.
이럴경우 해당 구현체의 객체를 할당하기위한 필드선언에 있어 상위클래스 또는 인터페이스 타입으로 선언하면되니까
파라메터 이름만 구현체를 받기위한 이름으로 바꿔주어 명시적으로 알아볼 수 있게할수있다.
구현체에 한하여 직접 생성 new 방식으로 외부 클래스의 객체를 생성하는것과
생성자 주입으로 의존성주입받는 방식에 차이를 이제 알수있다.
예를들어 클래스 내부 에서 직접생성시
public class Foo
{
private readonly Bar _bar;
public Foo()
{
_bar = new Bar(); // 직접 생성 → Bar에 “강하게 결합”
}
public void DoWork()
{
_bar.Execute();
}
}
이지만
생성자 주입방식이라면,
public class Foo
{
private readonly IBar _bar; // 1) 인터페이스(또는 구체 타입)로 선언
// 2) main() 함수 같은 호출자(호스트 코드)가 Bar 인스턴스를 만들어 bar 파라메터에 주입
public Foo(IBar bar)
{
_bar = bar; // 할당하여 사용
}
public void DoWork()
{
_bar.Execute();
}
}
Bar을 만드는것은 외부에 넘겨주고, Bar을 이용해 무언가를 한다에만 집중한다.
객체 생성 책임을 호출자에(main()함수같은) 위임한것이다.
생성자 주입방식이면 Bar자체는 파라매터니까 이 Foo 클래스를 내부를 수정할 필요가없다.
직접 생성시에는 Foo 클래스에 객체를 다른걸 생성해줘야할 것이었을것이다.
예를들면 인터페이스와 구현체를 정의할때,
// 1) 계약 정의
public interface IBar
{
void Execute();
}
// 2) 실제 구현체
public class Bar : IBar
{
public void Execute()
{
Console.WriteLine("Bar: 실제 동작 수행");
}
}
// 3) 테스트용(모킹) 구현체
public class MockBar : IBar
{
public void Execute()
{
Console.WriteLine("MockBar: 테스트용 동작");
}
}
그리고 Foo 클래스는 그대로두고,
public class Foo
{
private readonly IBar _bar;
// 생성자 주입
public Foo(IBar bar)
{
_bar = bar;
}
public void DoWork()
{
// 호출자에서 주입된 구현체의 Execute()가 실행됩니다
_bar.Execute();
}
}
호출자 코드에서 사용할때,,
static void Main(string[] args)
{
// ─── 운영 환경: 실제 Bar 삽입 ─────────────────
IBar realBar = new Bar(); // Bar 인스턴스 생성
var fooReal = new Foo(realBar); // Foo에 주입
fooReal.DoWork(); // "Bar: 실제 동작 수행"
// ─── 테스트 환경: MockBar 삽입 ─────────────────
IBar mockBar = new MockBar(); // MockBar 인스턴스 생성
var fooTest = new Foo(mockBar); // Foo에 주입
fooTest.DoWork(); // "MockBar: 테스트용 동작"
}
이런식으로 호출자 코드에서만 바꿔주면 되는 부분이다.
어떤 객체를 넣을지 전적으로 호출자에서 결정한다.
Foo 클래스는 하나도 수정할 필요가 없어지고 이렇게 느슨하게 결합하면
실운영로직, 테스트 로직을 분리할 수 있어 매우 유용하다.
다시 DI로 돌아가서,
예시-
//인터페이스 계약
public interface IAudioService { void PlaySound(); }
// 구현체
public class AudioManager : IAudioService { /* 계약메서드 구현, 객체생성등 */ }
//DI 적용
public class PlayerController
{
private readonly IAudioService _audio;
public PlayerController(IAudioService audio) // 생성자에 주입 - 주입하려는 audio 라는 매개변수는 뭐 어떤애일까?
{
_audio = audio;
}
}
생성자에서 할당해서 쓴다는건 해당 _audio가 AudioManager 클래스에서 생성한 객체이려나?
PlayerController에서는 해당객체를 참조값으로 가져온것같은데?
맞구나, 이렇게하면 Playercontroller가 직접 new AudioManager() 하지않으니까
나중에 다른 오디오 시스템으로 교체하기 쉬워지고
테스트할때는 Mock 객체를 주입해 사운드 재생 로직만 검증할 수도있겠군.
정리하자면 인터페이스 계약을 구현한 구현체에서 객체를 생성하고(main이나 awake, start 같은 지점에서 할수도있음)
DI 적용 즉 의존성을 주입하는 단계에서
PlayerController에서는 IAudioService 타입을 받은 AudioManager 객체를
매개변수로 생성자에서 할당하는구나.
이렇게하면 PlayerController 객체를 생성하였을때, audio로 받은 AudioManager 인스턴스를 가지고있겠군
_audio에 할당받은상태일테니
_audio로 PlaySound() 메서드를 꺼내쓰는 PlayerController 메서드 같은것도 만들 수 있을테고
의존성을 주입하면
Dependency(의존성, 결합도)를 낮추는것이로군;
생성자 주입방식말고도 setter, 필드를 이용한 방법도있다. 보완 필요
Unity에서는 엔진상에서 게임오브젝트를 만들고 인스팩터창에 스크립트를 넣으면
씬이 로드되는 순간 인스턴스가 생성된다.
따라서 생성자에 의존성주입하는 방식은 역할을 제대로안할수있다.
Unity에는 MonoBehaviour 스크립트가 있다.
MonoBehaviour은
스크립트를 GameObject에 붙여 동작시키기 위해 반드시 상속받아야하는 기본베이스클래스다.
씬 상의 GameObject에 상속받은 해당 스크립트를 붙이면
엔진이 백그라운드에서 new MyScript()를 대신 호출해 인스턴스를 생성하고
GameObject와 연동해준다.
new로 직접만들수는 없다.
씬로드나 AddComponent 시 Unity가 인스턴스 생성한다.
주요 콜백 메서드로는
Awake(): 컴포넌트가 활성화되자마자 가장먼저
OnEnable(): 활성화될 때마다
Start(): 첫 프레임 업데이트 전에 한번
Update(): 매 프레임마다
OnDisable()/ OnDestroy() : 비활성화,파괴 시
왜 MonoBehaviour을 쓰나하면
엔진과 연동된 라이프사이클을 가지기때문 Update()에서 매 프레임 로직실행
자동으로 Coroutine(코루틴) 지원
Inspector(검사창) 노출되는데
public, [SerializedField] private 필드를 에디터에서 설정가능
Unity API 접근 - transform, gameObject,GetComponent<>() 등 GameObject관련 기능 바로 사용
물리, 렌더러, 네비게이션 등 엔진 모듈 API 활용가능하다.
Unity에서 MonoBehaviour형식의 스크립트같은경우
new MyBehaviour(arg)를 호출하지 않고,
씬 로드나 AddComponent<MyBehaviour>()로 파라미터 없는 생성자만을 사용해
인스턴스를 만든다.
생성자에 의존성주입이 불가능하다.
그래서 의존성 주입의 대안으로
필드/속성 주입을 하는데
public class PlayerController : MonoBehaviour
{
[SerializeField] IAudioService _audio;
// 에디터나 부트스트래퍼에서 직접 할당
void Awake()
{
// 또는 코드에서 ServiceLocator 같은 전역에서 꺼내서 할당
_audio = ServiceLocator.Audio;
}
}
혹은 메서드 주입을하기도한다.
public class EnemySpawner : MonoBehaviour
{
private IEnemyFactory _factory;
public void Initialize(IEnemyFactory factory)
{
_factory = factory;
}
void Start()
{
// 부트스트래퍼가 Start 전에 Initialize를 불러 줘야 함
var enemy = _factory.Create();
}
}
보완 필요
Zenject/ Extenject 같은 DI 컨테이너를 쓰기도한다.
public class GameInstaller : MonoInstaller
{
public override void InstallBindings()
{
Container.Bind<IAudioService>().To<AudioManager>().AsSingle();
Container.Bind<PlayerController>().FromComponentInHierarchy().AsSingle();
}
}
public class PlayerController : MonoBehaviour
{
[Inject] IAudioService _audio; // 생성자 대신 속성 주입
void Start() => _audio.PlaySound();
}
프로젝트가 커진다면
MonoBehaviour는 부트스트랩 역할만 맡기고
핵심 로직은 순수 c# 클래스에 생성자 DI 적용을 하기도한다.
Zenject/Extenject 같은 Unity DI 컨테이너로 필드/프로퍼티 주입하기도한다.
물론 꼭 의존성 주입을 가능하게 만들필요는없다.
테스트, 유연성을 위한 방법일뿐이며
당장 유닛테스트, 구현체 교체등이 필요없다면 간단한 방식으로 가되
나중에 필요해질시 위의 대안 중하나를 단계적으로 도입해보면 좋다고한다.
Mocking :
단위 테스트시, 실제 의존객체 대신 "동작만 흉내내는 가짜 객체(mock)"를 사용해 테스트하는기법
외부시스템에 영향주지않고 순수하게 관심 있는 코드만 검증가능
var mockAudio = new Mock<IAudioService>();
mockAudio.Setup(a => a.PlaySound()).Verifiable();
var player = new PlayerController(mockAudio.Object);
player.Jump(); // 점프할 때 사운드 재생 요청
mockAudio.Verify(); // PlaySound()가 호출됐는지 검증
잘 모르겠으나 대충이해하였음. 추후 예제를 통해 보완
Lifetime(생명주기): 객체가 생성되어 메모리에 머무르는 '기간' 또는 그 관리 정책
주요 유형:
Singleton 싱글턴: 애플리케이션 시작부터 종료시까지 단 하나만 존재
Transient : 요청할때마다 새 인스턴스 생성, 메서드 호출 후 즉시 소멸
Scoped : 주로 웹,서버사이드 : 요청(또는 세션) 단위로 하나의 인스턴스 유지
예시- Unity DontDestoryOnLoad 로 만든 GameManager -> 애피르리케이션 전체 Singleton
게임 내 총알(Bullet) 오브젝트 -> 발사 시마다 새로 생성하고 사라질때파괴하는 Transient
잘모르겠으나 대충 이해하였음 . 추후 예제를 통해 보완
모듈: 기능 단위로 코드를 묶은 논리적 단위, 크게 보면 라이브러리, 어셈블리(네임스페이스)
,프로젝트 수준일 수 있고, 작게보면 개별 클래스 집합일수도있다.
AudioModule- 네임스페이스 안에 오디오재생과 관련된 인터페이스, 클래스등을 모아두면
이것이 하나의 오디오 모듈이 된다.
Unity에서는 하나의 패키지나 어셈블리 정의 파일(asmdef)을 모듈단위로 관리하기도한다.
namespace AudioModule
{
public interface IAudioService { void Play(string clip); }
public class AudioManager : IAudioService { /* ... */ }
public class SoundClipLoader { /* ... */ }
}
폴더 단위로 내가 원하는 구조대로 스크립트를 구분하면 그 폴더가 모듈단위라고도 할수있겠네
네임스페이스가 폴더를 기준으로 정해지니까..
구현체:
인터페이스나 추상클래스가 정의한 기능(계약)을 실제로 "구현"한 구체적 클래스
예 - IAudioService인터페이스 의 구현체는 AudioManager 클래스이다.
만약 테스트용으로 사운드를 재생하지않는 가짜 객체를 만들고싶으면
MockAudioService라는 또다른 구현체를 만들 수도 있는 것이다.
public interface IAudioService
{
void PlaySound();
}
// 실제 게임에서 사용할 구현체
public class AudioManager : IAudioService
{
public void PlaySound() { /* 실제 오디오 재생 로직 */ }
}
// 테스트 시에 사용할 가짜 구현체
public class MockAudioService : IAudioService
{
public void PlaySound() { /* 아무 동작 없음 */ }
}
구체: 구현체랑 다르다, 구체라는것은 인터페이스나 추상클래스같은 상위클래스가아닌
그냥 일반 클래스를 말한다.
DI 방식을 구체를 이용해 할경우엔, 만약 다른 구체를 받고싶을때는
생성자 시그니처가 다르므로 (여기서 매개변수로 받는 구체의 타입을 말함)
바꿔줘야한다. 구현체라면 그 상위클래스가 타입일테니 굳이 바꿔주지않아도된다.
호출자(caller):
어떤 기능(메서드, 서비스)를 호출해서 사용하는 주체
이 주체라는것은 그럼 메서드나 클래스나 다 되는건가?
아니다, 클래스 자체가 호출자라기보다는, 클래스 안의 메서드(또는 함수, 람다 등 코드블록)
이 호출자에 해당한다. 생성자도 메서드의 일종으로
객체를 생성할때 생성자가 호출된다는 말을 이래서 쓸수있다.
보통 객체가 생성될때 생성자에는 초기화할 내용들을 담는다.
생성자 자체가 호출자가 될수도있긴하다. 바로 생성자 안에 초기화 할내용중에
특정 메서드 실행내용을 담는다면 그 특정메서드의 호출자가 생성자가 될수는 있다.
// 프로그램 진입점(Main 메서드)이 PlayerController를 생성하는 부분
static void Main()
{
IAudioService audioService = new AudioManager();
var player = new PlayerController(audioService);
// 여기 Main()이 PlayerController 생성자를 호출하는 “호출자”가 됩니다.
}
// 생성자가 호출자가 되는경우
public class Foo
{
private readonly IBar _bar;
public Foo(IBar bar)
{
_bar = bar;
// 생성자 안에서 다른 메서드를 호출 ⇒ 생성자가 호출자 역할
_bar.Initialize();
}
}
// 특정 메서드가 호출자가 되는경우
public class AudioManager : IAudioService
{
public void PlaySound()
{
// 사운드 재생 로직
}
}
public class PlayerController
{
private readonly IAudioService _audio;
public PlayerController(IAudioService audio)
{
_audio = audio;
}
public void Jump()
{
_audio.PlaySound();
// 여기서 PlaySound()를 “호출”하는 메서드 Jump()가 바로 PlaySound()의 호출자(caller)
}
}
컨테이너: DI 방식으로 생성, 관리할 객체들의 생성 규칙(인터페이스 -> 구현체 매핑),
생명주기(Lifetime)을 설정하고, 필요할때 자동으로 인스턴스를 만들어(혹은 주입해)
주는 프레임워크나 라이브러리
1. 등록 IAudioService -> AudioManager 매핑
2. 해결 PlayerController가 필요로 하면 생성자 파라미터에 맞춰 AudioManager 인스턴스 주입
// 1) 패키지 설치: Microsoft.Extensions.DependencyInjection 예시
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// 2) 인터페이스와 구현체 등록
services.AddSingleton<IAudioService, AudioManager>();
services.AddTransient<PlayerController>();
// 3) 컨테이너 빌드
var provider = services.BuildServiceProvider();
// 4) 필요한 곳에서 꺼내쓰기
var player = provider.GetService<PlayerController>();
player.Jump();
잘이해안가지만 대충은 이해함, 보완 필요
// Unity 예시
using Zenject;
public class GameInstaller : MonoInstaller
{
public override void InstallBindings()
{
// 싱글톤으로 바인딩
Container.Bind<IAudioService>().To<AudioManager>().AsSingle();
// 필요할 때마다 새로 생성
Container.Bind<PlayerController>().AsTransient();
}
}
// 이후 씬의 MonoBehaviour에서 [Inject]로 주입받아 사용
public class PlayerBehaviour : MonoBehaviour
{
private PlayerController _controller;
[Inject]
public void Construct(PlayerController controller)
{
_controller = controller;
}
}
파라미터 : 함수 또는 메서드를 정의할때 사용하는 변수이름
이 함수가 어떤 값을 받을 것이다 선언해 놓은자리로
비유하자면 빈 우편함을 상상하자.
// x, y가 파라미터, 보통 개발자가 임의로 작성한다. 어딘가에 선언되어있지않다.
public int Add(int x, int y)
{
return x + y;
}
인자(arg 또는 argument): 함수를 호출할때 실제로 넘겨주는 값 또는 표현식
데이터다.
비유하자면 전달하는 편지같은 우편물을 상상하자.
int a = 3;
int b = 5;
int sum = Add(a, b);
// 여기서 a, b가 인자
시그니처: 이 메서드가 어떤 이름으로, 어떤 매개변수 타입, 갯수를 받는지 뜻
시그니처를 바꾼다는것은 매개변수타입을 바꾼다, 매개변수 개수를 바꾼다
매개변수 순서를 바꾼다 세가지중 하나를 뜻한다.
컴파일 타임(Compile Time):
여러분이 작성한 C# 코드를 빌드(compile) 해서 **기계어(실행 파일)**로 바꾸는 시점입니다.
VS나 dotnet build를 누르면 일어나는 일이고, 이때 문법 오류를 잡아 줍니다.
런타임(Runtime):
실제로 프로그램을 실행(Play, dotnet run)해서 돌아갈 때의 시점입니다.
이때 메서드가 호출되고, 변수에 값이 들어가고, 화면에 결과가 출력됩니다.
컴파일 타임은 요리 레시피(코드)를 작성·검토하는 단계
런타임은 실제로 요리(프로그램 실행) 하는 단계
System.Type:
C#에서는 모든 클래스·구조체에 대응하는 “타입 정보”를 담은 객체가 있습니다.
그 객체의 타입이 바로 System.Type 입니다.
Type t = typeof(string); // 문자열(string) 타입 정보를 가져와 t에 담는다
Console.WriteLine(t.FullName); // "System.String" 출력
System.Type = 클래스·구조체라는 ‘설계도’ 자체를 가리키는 손가락
메타데이터(Metadata):
“데이터에 대한 정보” 를 뜻합니다.
예) 책 데이터(metadata)
제목, 저자, 출판일, ISBN 등 → 책 자체(본문)가 아니라 책을 설명해 주는 정보
C# 메타데이터
클래스 이름, 멤버(메서드·필드) 목록, [Serializable] 같은 어트리뷰트 정보 등이 여기에 해당
리플렉션(Reflection):
런타임에 System.Type·메타데이터를 읽어서
“이 클래스에 어떤 메서드가 있는지”, “어떤 어트리뷰트가 붙었는지” 등을
동적으로 조사(inspect)하는 기능입니다.
쉽게 말해:
“실행 중에도 자동차(객체)를 멈추지 않고, 계기판·부품 목록을 뜯어볼 수 있는 기능”
직렬화/역직렬화(Serialization/Deserialization):
직렬화(Serialization)
메모리 위의 복잡한 객체를 문자열(JSON, XML) 또는 바이트(byte[]) 형태로 포장하는 과정
저장하거나 네트워크로 전송할 때 사용
역직렬화(Deserialization)
반대로, 그 패키지(JSON·바이트)를 풀어서 다시 원래 객체를 만드는 과정
var player = new Player("Alice", 5);
string json = JsonConvert.SerializeObject(player); // 직렬화
var clone = JsonConvert.DeserializeObject<Player>(json); // 역직렬화
타입 초기화 트리거(Type Initialization Trigger):
C#의 static(정적) 필드나 static 생성자는
“해당 타입을 처음 사용하려 할 때” 자동으로 한 번만 실행됩니다.
트리거 사례:
Foo.Counter 같이 static 멤버에 접근
typeof(Foo) 처럼 타입 자체를 가리키는 연산
new Foo() 처럼 인스턴스를 처음 생성
이 중 하나라도 발생하면, 그 순간에야 static 초기화 코드가 실행되는 거예요.
typeof: 타입자체를 가리키는 c#연산자
typeof(T)로 T라는 클래스의 System.Type 객체를 얻는다.
이 Type 객체에는 그 타입의 이름, 네임스페이스, 멤버정보 같은 메타데이터가 담겨있다.
typeof를 통해 리플렉션을 한다거나
Type t = typeof(MyClass);
foreach (var m in t.GetMethods())
Console.WriteLine(m.Name);
타입초기화를 강제할수있다.
static Foo() { /* static 생성자 */ }
// 아래 한 줄로 Foo의 static 생성자가 실행됨
var _ = typeof(Foo);
'Unity 개발 공부' 카테고리의 다른 글
| [내배캠] 본캠 13일차. 문자열 처리, 깃허브세팅법,제너릭T와 패턴매칭, 생성자 (5) | 2025.04.23 |
|---|---|
| [내배캠] 본캠 12일차.스테이트 머신, 스테이트패턴, FSM, 제너릭 T, 디자인패턴종류 (0) | 2025.04.22 |
| [내배캠] 본캠 10일차. 델리게이트, 클래스타입에 대한 복습 (0) | 2025.04.18 |
| [내배캠] 본캠 9일차. 메서드 연산자 복습 , 그리고 TextRpg 진행과정 (5) | 2025.04.17 |
| [내배캠] 본캠 8일차. 인터페이스, 열거형, 예외처리, 참조형, 값형, 델리게이트, 람다, LINQ (0) | 2025.04.16 |