본문 바로가기
빌드노트 검색

빌드노트

prototype 빈을 주입했는데 메서드를 불러도 같은 객체다

singleton에 직접 주입한 prototype은 메서드를 다시 호출해도 같은 객체다. 짧은 생성자 수정과 작업 시 ObjectProvider 조회를 비교하고, 실제 ID·참조·생성 수로 생성자 캐시와 singleton 대상의 재사용도 구분한다.

singleton 서비스에 @Scope("prototype") 빈을 생성자로 주입했는데, 메서드를 다시 불러도 같은 객체가 나올 수 있다. 직접 주입은 singleton을 만들 때 한 번 이뤄진다. 그 필드를 다시 읽는 메서드 호출은 새 빈 조회가 아니다. 작업마다 새 객체가 필요하면 ObjectProvider를 주입받고 작업 시작 시 getObject()를 호출한다.

필드 대신 Provider를 보관한다

아래 코드는 Work가 prototype 빈 하나로, 소비자가 기본 singleton 빈으로 등록된 경우다. 직접 주입한 소비자는 받은 참조를 계속 돌려준다.

class DirectConsumer {
    private final Work work;

    public DirectConsumer(Work work) { this.work = work; }

    Work beginWork() { return work; }
}

작업마다 새 Work가 필요하다면 생성자에는 ObjectProvider<Work>를 받고, 실제 조회를 beginWork() 안으로 옮긴다. ObjectProvider의 import는 org.springframework.beans.factory.ObjectProvider다.

class ProviderConsumer {
    private final ObjectProvider<Work> provider;

    public ProviderConsumer(ObjectProvider<Work> provider) {
        this.provider = provider;
    }

    Work beginWork() { return provider.getObject(); }
}

자기 서비스에서는 기존 소비자의 생성자와 필드를 이 방식으로 바꾼다. 서비스 수명 동안 같은 작업 객체를 쓰려던 설계라면 직접 주입을 유지할 수 있다.

prototype은 컨테이너가 그 빈을 요청받을 때 새 인스턴스를 만든다. singleton의 생성자 의존성은 singleton 생성 시 해결하므로 직접 받은 객체는 이후에도 필드에 남는다. Spring의 singleton과 prototype 의존성 설명 (새 탭에서 열림)에서 이 시점을 구분한다.

두 번 호출했을 때 생성 수를 비교했다

Windows의 OpenJDK 17.0.2와 Spring Framework 6.2.11로 별도 컨텍스트를 열어 위 두 코드를 실행했다. Work에는 org.springframework.context.annotation.Scope의 @Scope("prototype")을 붙였고, 생성될 때만 증가하는 합성 ID를 넣었다. 비교용으로 두 소비자를 함께 등록했다. 초기화 직후 생성 수는 1이었다.

같은 컨텍스트에서 직접 주입 경로를 두 번, Provider 경로를 두 번 호출한 실제 출력이다. same은 두 반환값을 ==로 비교한 결과다.

created after refresh=1
direct=1,1; same=true
provider=2,3; same=false
singleton consumers=true,true
created total=3

1번은 DirectConsumer의 생성자를 채우면서 만들어졌다. 그 소비자의 beginWork()를 두 번 불러도 생성 수는 1이었다. ProviderConsumer를 통해 조회한 두 번에만 2번과 3번이 생겼다. 두 소비자 자체는 각각 같은 singleton 참조를 유지했다.

Provider 소비자만 등록한 별도 컨텍스트에서는 초기화 직후 Work 생성 수가 0, 두 번 호출한 ID가 1,2였다. Provider를 주입받는 것과 실제 대상을 조회하는 것은 다른 시점이다. 6.2.11 ObjectProvider의 공식 API 주석 (새 탭에서 열림)도 컨테이너에서 받은 Provider가 호출마다 대상을 다시 구한다고 설명한다.

자기 코드에 적용할 때는 spring-context를 포함한 Spring 의존성과 실제 빈 등록을 확인한다. 이 비교는 JDK 17 이상에서 쓸 수 있는 org.springframework:spring-context:6.2.11과 전이 의존성을 사용했다. 실험에서는 new AnnotationConfigApplicationContext(Work.class, DirectConsumer.class, ProviderConsumer.class)로 세 타입을 명시 등록했다. 이 API의 import는 org.springframework.context.annotation.AnnotationConfigApplicationContext다.

기존 앱에서 기본 컴포넌트 스캔을 쓴다면 두 타입을 스캔 범위의 @Component 또는 @Service 클래스로 등록한다. Work에는 @Scope("prototype")도 둔다. @Bean 방식이라면 팩터리 메서드에 scope를 지정한다. 공식 컴포넌트 등록·scope 규칙 (새 탭에서 열림)에서 두 등록 방식을 확인할 수 있다.

Provider를 써도 같은 객체가 나오는 경우

생성자에서 this.cached = provider.getObject()로 한 번 조회하고 메서드가 cached를 반환하는 대조도 실행했다. 초기화 때 하나가 생성됐고 연속 호출의 ID는 1,1, 참조 비교는 true였다. Provider로 바꿨더라도 조회 결과를 singleton 필드에 저장하면 재사용은 계속된다. 새 객체가 필요한 작업의 시작점에서 조회해야 한다.

한 작업 안의 여러 단계가 같은 상태를 써야 한다면 작업 시작 시 한 번 조회한 결과를 지역 변수에 보관해 넘긴다. 단계마다 getObject()를 다시 호출하면 prototype의 상태도 새로 시작한다. 생성 단위를 작업 하나로 할지, 단계 하나로 할지 먼저 정한다.

대상 빈의 scope도 본다. 별도 대조에서 Work의 빈 정의만 singleton으로 바꾸자 Provider를 두 번 호출해도 ID는 1,1, 같은 참조였고 총 생성 수는 1이었다. ObjectProvider는 조회 시점을 옮길 수 있게 하며, 대상 빈의 scope를 prototype으로 바꾸지는 않는다.

수정 후에도 같은 객체가 나온다면 getObject()가 실제로 어디서 몇 번 호출되는지, 반환값을 필드에 저장하는지, 등록된 대상 빈이 prototype인지 순서대로 확인한다. 다른 빈이 먼저 생성되는 앱에서는 ID 시작값이 달라질 수 있다. 숫자 자체보다 생성 횟수와 참조 비교를 함께 봐야 한다.

실행한 범위는 프록시 없는 prototype과 singleton 소비자의 단일 스레드 호출이다. HTTP request scope, scoped proxy, 동시 호출은 검증하지 않았다. 자원을 보유하는 prototype의 정리 책임도 별도로 정해야 한다. Spring이 prototype의 소멸 콜백을 자동 호출하지 않는다는 것은 공식 scope 문서 (새 탭에서 열림)의 설명이며, 이번에 자원 정리 콜백을 실행한 것은 아니다.

링크한 6.2 참조 문서는 확인 당시 6.2.19를 표시했다. 주입 시점과 prototype 수명 설명은 실행 버전인 6.2.11 태그의 공식 문서 원본 (새 탭에서 열림)에서도 대조했다.

← 전체 글 보기