프로토타입과 요청 스코프
긴 수명의 싱글톤에 짧은 수명 빈을 직접 주입할 때의 고정을 재현하고 ObjectProvider와 범위가 지정된 프록시의 조회 시점·종료 책임을 검증합니다.
기본 싱글톤으로 만들기 어려운 객체도 있습니다. 한 번의 내보내기 작업 진행률을 담는 작업은 사용마다 새 객체가 필요하고, HTTP 트레이스 정보는 한 요청 안에서만 공유해야 합니다. 스코프를 짧게 바꾸는 것만으로는 끝나지 않습니다. 더 오래 사는 싱글톤이 그 객체를 언제 조회하고 누가 정리하는지까지 결정해야 합니다.
프로토타입 주입 시점
프로토타입 정의는 getBean 요청마다 새 객체를 만듭니다.
그러나 싱글톤 팩토리 메서드의 파라미터로 프로토타입을 받으면 싱글톤을 만드는 그 한 시점에만 조회합니다.
싱글톤 필드에는 첫 객체 참조가 계속 남습니다.
package board.scope;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
class PrototypeInjectionFailureTest {
record ExportJob(UUID id) {
}
static final class BatchExporter {
private final ExportJob job;
BatchExporter(ExportJob job) {
this.job = job;
}
UUID start() {
return job.id();
}
}
@Configuration(proxyBeanMethods = false)
static class ScopeConfig {
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
ExportJob exportJob() {
return new ExportJob(UUID.randomUUID());
}
@Bean
BatchExporter batchExporter(ExportJob job) {
return new BatchExporter(job);
}
}
@Test
void singleton_field에는_첫_prototype이_고정된다() {
try (var context =
new AnnotationConfigApplicationContext(
ScopeConfig.class)) {
var exporter = context.getBean(BatchExporter.class);
assertThat(exporter.start())
.isEqualTo(exporter.start());
assertThat(context.getBean(ExportJob.class))
.isNotSameAs(context.getBean(ExportJob.class));
}
}
}프로토타입이 고장 난 것이 아닙니다. 두 번 직접 조회하면 다른 객체가 나오지만, 싱글톤 생성자 주입은 한 번 일어납니다. “주입할 때마다”가 아니라 “컨테이너에 요청할 때마다” 새 객체라는 의미입니다.
PrototypeInjectionFailureTest
> singleton_field에는_첫_prototype이_고정된다() PASSED
exporter.start #1 == exporter.start #2
context lookup #1 != context lookup #2ObjectProvider 지연 조회
싱글톤 내보내기 도구가 작업을 시작할 때 공급자에서 작업을 꺼내면 호출마다 새 프로토타입을 받습니다.
package board.export;
import java.util.UUID;
import org.springframework.beans.factory.ObjectProvider;
public final class BatchExporter {
private final ObjectProvider<ExportJob> jobs;
public BatchExporter(ObjectProvider<ExportJob> jobs) {
this.jobs = jobs;
}
public UUID export(PostBatch batch) {
try (var job = jobs.getObject()) {
job.write(batch);
return job.id();
}
}
}@Bean
BatchExporter batchExporter(
ObjectProvider<ExportJob> jobs) {
return new BatchExporter(jobs);
}
@Test
void export마다_새_job을_얻는다() {
try (var context =
new AnnotationConfigApplicationContext(
ProviderScopeConfig.class)) {
var exporter = context.getBean(BatchExporter.class);
var first = exporter.export(batch("first"));
var second = exporter.export(batch("second"));
assertThat(first).isNotEqualTo(second);
assertThat(ExportJob.closedIds())
.containsExactlyInAnyOrder(first, second);
}
}애플리케이션 클래스가 Spring API에 의존하지 않게 하려면 Provider<ExportJob> 역할의 작은 팩토리 인터페이스를 정의하고 구성에서 ObjectProvider::getObject를 어댑터로 넘길 수 있습니다.
조회 위치가 인프라 성격이면 Spring 공급자를 직접 쓰는 것도 실용적입니다.
중요한 것은 작업 시작이라는 수명 경계가 코드에 보이는 것입니다.
프로토타입 종료 책임
Spring은 프로토타입 객체를 만들고 의존성 주입과 초기화 콜백까지 수행하지만, 반환한 뒤 전체 수명을 추적하지 않습니다.
컨텍스트를 닫아도 이미 전달한 프로토타입의 @PreDestroy를 자동 호출하지 않습니다.
언제 사용이 끝나는지 컨테이너가 알 수 없기 때문입니다.
따라서 파일·연결 같은 자원을 가진 프로토타입은 사용자가 try-with-resources로 닫거나 명시적 릴리스 API를 호출합니다.
위 BatchExporter가 try (var job = jobs.getObject())를 사용하는 이유입니다.
| 단계 | 싱글톤 | 프로토타입 |
|---|---|---|
| 정의와 생성 | 컨테이너 | 컨테이너 |
| 의존성 주입·초기화 | 컨테이너 | 컨테이너 |
| 사용 중 참조 추적 | 컨텍스트 종료까지 | 호출자에게 전달 후 추적 안 함 |
| 종료 콜백 | 컨텍스트 종료에서 실행 | 호출자가 직접 정리 |
프로토타입에 자원까지 넣을 필요가 없는지도 먼저 봅니다.
작업 ID와 진행 값만 가진 평문 객체라면 도메인 팩토리가 new로 만드는 편이 단순합니다.
Spring 스코프는 의존성 주입이 필요한 작업 객체나 프레임워크 콜백과 결합된 경우에만 이점이 큽니다.
요청 스코프 결합
요청 스코프 빈은 같은 HTTP 요청 안에서는 같은 객체이고 다음 요청에서는 새 객체입니다. 싱글톤에 주입하려면 범위가 지정된 프록시가 필요합니다. 싱글톤 필드에는 실제 요청 객체가 아니라 현재 요청의 대상을 찾아 주는 프록시가 들어갑니다.
package board.web;
import java.util.UUID;
public class RequestTrace {
private final String id = UUID.randomUUID().toString();
public String id() {
return id;
}
}package board.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.annotation.RequestScope;
import board.web.RequestAudit;
import board.web.RequestTrace;
@Configuration(proxyBeanMethods = false)
class RequestTraceConfig {
@Bean
@RequestScope
RequestTrace requestTrace() {
return new RequestTrace();
}
@Bean
RequestAudit requestAudit(RequestTrace trace) {
return new RequestAudit(trace);
}
}@RequestScope는 요청 스코프와 클래스 기반 스코프 프록시를 함께 선언하는 축약 애노테이션입니다.
RequestAudit 싱글톤은 시작할 때 프록시를 받고, trace.id()를 호출하는 순간 현재 요청 대상으로 위임합니다.
package board.scope;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import board.config.RequestTraceConfig;
import board.web.RequestAudit;
class RequestScopeTest {
@AfterEach
void clearRequest() {
RequestContextHolder.resetRequestAttributes();
}
@Test
void 같은_request에서는_같고_다음_request에서는_바뀐다() {
try (var context =
new AnnotationConfigWebApplicationContext()) {
context.register(RequestTraceConfig.class);
context.refresh();
var audit = context.getBean(RequestAudit.class);
var firstRequest = new ServletRequestAttributes(
new MockHttpServletRequest());
RequestContextHolder.setRequestAttributes(firstRequest);
var first = audit.currentTraceId();
assertThat(audit.currentTraceId()).isEqualTo(first);
firstRequest.requestCompleted();
var secondRequest = new ServletRequestAttributes(
new MockHttpServletRequest());
RequestContextHolder.setRequestAttributes(secondRequest);
var second = audit.currentTraceId();
assertThat(second).isNotEqualTo(first);
secondRequest.requestCompleted();
}
}
}PrototypeLookupTest > export마다_새_job을_얻는다() PASSED
RequestScopeTest
> 같은_request에서는_같고_다음_request에서는_바뀐다() PASSED
BUILD SUCCESSFUL비활성 요청의 프록시 실패
범위가 지정된 프록시 자체는 싱글톤으로 조회할 수 있지만 대상은 현재 요청이 있어야 찾습니다.
스케줄러, 애플리케이션 시작 리스너, 별도 실행자에서 요청 스코프 메서드를 호출하면 활성 스코프가 없어 ScopeNotActiveException이 발생합니다.
HTTP 처리 뒤 백그라운드 작업에 트레이스가 필요하면 요청 빈 자체를 넘기지 않습니다. 필요한 값만 불변 명령에 복사합니다.
public record ExportPostsCommand(
long memberId,
String requestTraceId,
LocalDate from,
LocalDate to) {
}
@PostMapping("/exports")
ResponseEntity<Void> export(
@RequestBody ExportRequest request) {
var command = new ExportPostsCommand(
currentMember.id(),
requestTrace.id(),
request.from(),
request.to());
exportQueue.enqueue(command);
return ResponseEntity.accepted().build();
}대기열 사용자는 HTTP 요청이 끝난 뒤에도 명령 값만으로 실행됩니다. 요청 범위 프록시와 서블릿 요청을 스레드 밖으로 운반하지 않으므로 수명 불일치가 없습니다.
명시적 값 전달 우선
스코프 선택 기준은 “객체를 새로 만들고 싶다”보다 값의 실제 수명입니다.
| 수명 | 우선 표현 | Spring 스코프가 맞는 경우 |
|---|---|---|
| 메서드 호출 | 파라미터·지역 변수 | 거의 필요 없음 |
| 도메인 작업 하나 | 평문 팩토리 / try-with-resources 구문 | DI가 필요한 프로토타입 작업 객체 |
| HTTP 요청 하나 | 컨트롤러 파라미터·불변 컨텍스트 | 여러 웹 컴포넌트가 같은 인프라 정보를 공유 |
| 회원 | 명시적 저장소 | 정말 사용자 회원 수명인 UI 상태 |
| 애플리케이션 | 무상태 싱글톤 | 서비스·리포지토리·클라이언트 |
요청 스코프를 사용자 ID 저장소로 사용하면 인증 경계가 숨고 백그라운드 실행과 테스트가 어려워집니다. Spring 보안 컨텍스트처럼 이미 프레임워크가 제공하는 값을 사용하거나 컨트롤러에서 필요한 식별자를 명령으로 넘기는 편이 업무 흐름을 읽기 쉽습니다.
연습 문제
한 HTTP 요청에서 생성한 RequestTrace ID를 컨트롤러와 예외 핸들러가 동일하게 읽고, 다음 요청에서는 다른 ID를 읽는 테스트를 작성하세요.
이어 요청이 없는 가상 스레드에서 프록시를 호출해 실패를 재현한 뒤, 트레이스 ID 문자열을 명령에 복사해 백그라운드 작업을 성공시킵니다.
해설 보기
두 웹 컴포넌트에는 같은 RequestTrace 프록시를 생성자로 주입합니다.
모의 요청을 활성화한 뒤 두 컴포넌트가 반환한 ID가 같은지 확인합니다.
요청을 완료하고 새 ServletRequestAttributes를 등록하면 새 대상이 생성되어 ID가 달라집니다.
assertThatThrownBy(() ->
executor.submit(audit::currentTraceId).get())
.hasRootCauseInstanceOf(ScopeNotActiveException.class);수정 후에는 활성 요청 스레드에서 먼저 String traceId = audit.currentTraceId()를 읽고 ExportPostsCommand에 넣습니다.
실행자에는 명령만 전달합니다.
var command = new ExportPostsCommand(
memberId, audit.currentTraceId(), from, to);
var result = executor.submit(
() -> worker.export(command)).get();
assertThat(result.traceId())
.isEqualTo(command.requestTraceId());프록시를 다른 스레드로 넘기지 않고 불변 값만 넘긴 것이 핵심입니다.
테스트의 finally 또는 @AfterEach에서 RequestContextHolder.resetRequestAttributes()를 호출해 다음 테스트에 요청이 남지 않게 합니다.
다음 장에서는 이 컨테이너 경계 위에 MVC 요청 매핑, 검증, 예외 응답, HTTP 테스트를 쌓아 게시판 API의 웹 계약을 확장합니다.