VContainer 의 수명은 받는 쪽이 정한다
Esc

    VContainer 의 수명은 받는 쪽이 정한다

    2026년 9월 30일 수정–UnityDI라이브러리
    public class AudioService : IAudioService
    {
        readonly IBattleSession session;
        public AudioService(IBattleSession session) => this.session = session;
    }
    
    builder.Register<IAudioService, AudioService>(Lifetime.Singleton);
    builder.Register<IBattleSession, BattleSession>(Lifetime.Scoped);

    전투 스코프를 새로 만들어도 AudioService 가 보는 세션은 안 바뀝니다. 예외도 로그도 없이 값만 계속 틀립니다.

    수명은 인스턴스가 놓이는 자리이고, 의존성을 몇 번 다시 받느냐는 받는 쪽 수명이 정합니다. 긴 수명이 짧은 수명을 생성자로 받으면 첫 인스턴스에 갇힙니다.

    수명은 인스턴스가 놓이는 자리다

    Transient Singleton Scoped
    저장 위치 저장 안 함 등록이 있는 스코프 (보통 최상위) 요청한 스코프의 컨테이너
    생성 시점 매 요청 첫 요청 그 스코프의 첫 요청
    자식 스코프에서 요청하면 그 자리에서 새로 등록이 있는 곳까지 올라가 공유 그 스코프가 자기 것을 만듦

    최상위에 놓이면 앱이 끝날 때까지, 스코프에 놓이면 그 스코프가 죽을 때까지 삽니다. Singleton 은 “앱 전체에 하나” 가 아니라 등록 1건당 하나입니다.

    Resolve 는 컨테이너에 “이 타입 하나 줘” 하고 인스턴스를 받는 일입니다. 받을 때 그 타입의 생성자가 요구하는 것까지 컨테이너가 찾아서 만들고 채워 줍니다. 그 요청을 받아 처리하는 쪽(최상위 컨테이너나 스코프, IObjectResolver)을 Resolver 라고 부릅니다.

    저장하지 않는 Transient 는 Resolve 할 때마다 객체를 새로 만들고 생성자를 부릅니다. Top(Mid, Leaf), Mid(Leaf), Leaf() 세 타입을 같은 수명으로 등록해 Resolve<Top>() 1회를 쟀습니다.

    수명 Mono IL2CPP 할당
    Transient 약 11.7 µs 약 4.2 µs 128 B
    Scoped 약 0.24 µs 약 0.34 µs 0 B
    Singleton 약 0.13 µs 약 0.27 µs 0 B

    Transient 는 Singleton 보다 Mono 에서 약 90배, IL2CPP 에서 약 16배 느렸습니다. 초기화 때 몇 번이면 문제가 안 되지만 반복해서 Resolve 하는 자리에서는 횟수만큼 쌓입니다.

    루트가 만든 Singleton 은 루트의 Scoped 를 계속 쥐고 있다

    맨 위 예제를 다시 봅니다.

    builder.Register<IAudioService, AudioService>(Lifetime.Singleton);   // 앱에 하나
    builder.Register<IBattleSession, BattleSession>(Lifetime.Scoped);   // 스코프마다 하나
    
    public AudioService(IBattleSession session) => this.session = session;

    IBattleSession 은 전투 한 판 동안만 쓰는 상태를 담는 객체이고, Lifetime.Scoped 로 등록했습니다. Scoped 는 스코프마다 인스턴스를 하나씩 따로 만든다는 뜻이라, 전투 스코프를 열 때마다 새 IBattleSession 이 생기고 그 전투가 끝나면 같이 버려집니다. 아래에서 “세션” 은 이 IBattleSession 인스턴스를 말합니다.

    AudioService 는 Singleton 이고, 생성자로 세션을 받습니다. 기대하는 것은 전투마다 그 전투의 세션을 쓰는 것입니다. 이 구성(Singleton 하나가 Scoped 세션을 생성자로 받음)으로 전투 스코프를 둘 열고, 각 스코프에서 세션과 Singleton 을 꺼내 Singleton 이 들고 있는 세션이 몇 번인지 봤습니다(에디트 모드 테스트).

    어디서 꺼냈나 그 스코프의 세션 Singleton 이 쥔 세션
    루트 1번 1번
    전투 스코프 1 2번 1번
    전투 스코프 2 3번 1번

    세션은 스코프마다 새로 만들어졌지만 Singleton 은 어디서 꺼내도 1번만 봅니다. 전투 코드는 2번, 3번 세션을 쓰는데 AudioService 는 1번 세션을 보고 있어서, 값이 서로 어긋납니다. 예외도 로그도 없습니다.

    이렇게 되는 순서입니다.

    1. AudioService 는 Singleton 이라 딱 한 번, 등록이 있는 루트가 만듭니다.
    2. 만들면서 생성자 인자 IBattleSession 도 루트가 Resolve 합니다. 루트 쪽에서 만든 Scoped 세션, 즉 1번이 들어갑니다.
    3. 그 참조가 필드에 들어간 뒤로는 생성자가 다시 불리지 않으니, 새 세션을 받을 길이 없습니다.

    소스에서는 이 두 줄입니다.

    // Singleton 을 만드는 쪽은 최상위 Container
    createInstance = registration =>
        new Lazy<object>(() => registration.SpawnInstance(this));   // this = 최상위 Container
    
    // 최상위 Container 에 Scoped 를 요청하면 루트 쪽 스코프(rootScope)가 만든다
    case Lifetime.Scoped:
        return rootScope.Resolve(registration);

    누수는 프로파일러가 잡지만, 값이 갱신되지 않는 이 오동작은 아무것도 잡지 않습니다.

    주는 쪽을 고치면 안 풀리고 받는 쪽을 고쳐야 풀린다

    같은 테스트로 셋을 재면 이렇게 갈립니다. 전투 스코프를 둘 열고, 각 스코프에서 받는 쪽이 얻은 세션이 몇 번인지 봤습니다.

    받는 쪽 수명 세션을 받는 법 전투 스코프 1 전투 스코프 2 전투마다 다른 세션인가
    Scoped 생성자 주입 1번 2번 예
    Scoped 팩토리 1번 2번 예
    Singleton 팩토리 1번 (루트의 세션) 1번 (루트의 세션) 아니오

    세 번째 줄이 핵심입니다. 팩토리는 받는 쪽 수명을 바꿔 주지 않습니다. 받는 쪽을 싱글턴으로 둬야 한다면 팩토리 자체를 Scoped 로 등록해서, 스코프마다 새 팩토리가 그 스코프의 Resolver 를 쥐게 합니다.

    builder.RegisterFactory<Session>(resolver => () => resolver.Resolve<Session>(), Lifetime.Scoped);

    받는 쪽 기준으로 정리하면 규칙은 한 줄입니다.

    받는 쪽 주는 쪽 결과
    Singleton Scoped 갇힘. 루트 것을 영원히 붙듭니다
    Singleton Transient 사실상 싱글턴이 됩니다
    Scoped Transient 사실상 Scoped 가 됩니다
    그 밖의 조합 정상

    긴 수명이 짧은 수명을 직접 의존하면 안 되고, 반대 방향은 짧은 쪽이 매번 다시 물어보므로 안전합니다.

    주의할 점

    1. 긴 수명이 짧은 수명을 생성자로 받지 않는다

    안 지키면 생기는 문제. VContainer 는 갇힌 의존성을 검출하지 않습니다. 빌드할 때 순환 참조는 잡지만 수명 방향은 보지 않아서, 빌드와 Resolve 에 예외 없이 값만 틀립니다. 유니티에서도 자식 스코프 둘에서 꺼낸 싱글턴 안의 Scoped 는 루트 스코프의 것이었습니다.

    이렇게 쓴다. 등록 목록을 직접 훑어 Lifetime.Singleton 으로 등록된 타입의 생성자 인자 중에 Scoped 로 등록된 것이 있는지 봅니다.

    2. 주는 쪽 수명을 바꿔서 풀려고 하지 않는다

    안 지키면 생기는 문제. 받는 쪽이 싱글턴이면 생성자는 한 번만 불리므로, 주는 쪽을 Transient 로 바꿔도 첫 인스턴스 하나에 갇힙니다. 싱글턴 팩토리를 넣어도 그 팩토리가 쥔 Resolver 가 루트라서 그대로입니다.

    이렇게 쓴다. 받는 쪽을 Scoped 로 내립니다. 받는 쪽을 싱글턴으로 둬야 하면 팩토리를 Scoped 로 등록합니다.

    3. 반복해서 Resolve 하는 자리에 Transient 를 두지 않는다

    안 지키면 생기는 문제. Transient 는 Resolve 마다 생성자를 부르고 할당합니다. 세 타입 그래프 하나에 Mono 약 11.7 µs, 128 B 였고, 반복 횟수를 늘려도 1회 값이 그대로라 Resolve 횟수만큼 쌓입니다.

    이렇게 쓴다. 상태가 없는 계산기처럼 가볍고 드물게 만드는 것만 Transient 로 두고, 자주 쓰는 것은 Scoped 나 Singleton 으로 한 번 만들어 재사용합니다.

    정리

    VContainer 의 수명은 받는 쪽이 몇 번 다시 받느냐로 정해집니다.

    • Singleton 은 최상위, Scoped 는 그 스코프, Transient 는 아무 데도 놓이지 않습니다.
    • 싱글턴이 생성자로 받은 Scoped 는 rootScope 것으로 고정되고, 증상은 값이 갱신되지 않는 조용한 오동작입니다.
    • 주는 쪽 수명이나 싱글턴 팩토리로는 안 풀리고, 받는 쪽이나 팩토리를 Scoped 로 내려야 풀립니다.