프로토타입과 요청 스코프
긴 수명의 싱글톤이 짧은 수명 빈을 언제 조회하는지 구분하고, 프로토타입의 종료 소유권과 요청 프록시의 활성 범위를 실제 Spring 테스트로 검증합니다.
내보내기 작업 하나의 진행률은 작업마다 새로 필요하고, HTTP 추적(trace) 정보는 한 요청 안에서만 함께 써야 합니다. 이런 객체의 스코프(scope)를 짧게 선언하는 것만으로 수명 설계가 끝나지는 않습니다. 더 오래 사는 싱글톤이 짧게 사는 빈을 언제 조회하는지, 다 쓴 뒤에 누가 정리하는지, 스코프를 벗어날 때 어떤 값만 넘길지까지 정해야 합니다. 이 글의 파일은 앞 글과 같은 프로젝트의 src/main/java와 src/test/java 아래 example.scope 패키지에 둡니다.
짧은 수명 빈은 조회 시점이 재사용을, 호출 경계가 종료 책임을 결정한다
호출마다 prototype을 열어 batch를 쓰고 caller가 close하는 수명과 싱글톤 필드 고정 사례를 구별한다.
LOOKUP TIME · LIFETIME OWNER
짧은 수명 빈은 조회 시점이 재사용을, 호출 경계가 종료 책임을 결정한다
prototype은 컨테이너가 그 정의를 조회할 때마다 새 인스턴스를 만듭니다. 긴 수명의 싱글톤에 직접 주입하면 조회는 싱글톤 생성 때 한 번뿐이므로, 작업마다 새 객체가 필요할 때는 조회와 정리 경계를 호출 코드에 드러내야 합니다.
prototype은 컨테이너 조회 때마다 새 객체입니다. 반복 메서드 호출만으로 고정 필드가 바뀌지 않습니다. 호출별 조회 경로에서는 사용 완료를 아는 호출자가 닫으며, 정상 컨텍스트 종료만으로 prototype이 닫히지는 않습니다.
DIRECT INJECTION · FIXED REFERENCE
싱글톤 필드에는 생성 시점에 해석한 프로토타입 하나가 남는다
싱글톤 생성 시작
컨테이너가 직접 주입 fixture인
FixedExporter의 의존성을 한 번 해석합니다.프로토타입 조회
ExportJob하나를 만들고 의존성 주입과 초기화 callback을 완료합니다.필드에 참조 저장
생성자에 전달된 작업 객체가 싱글톤의 필드에 고정됩니다.
여러 번 호출
첫 번째와 다음
FixedExporter.start()가 같은 작업 객체의 ID를 다시 읽습니다.
컨텍스트에서 프로토타입을 따로 조회하면 새 객체가 나오지만, 이미 완성된 싱글톤의 필드가 자동으로 바뀌지는 않습니다.
PROVIDER LOOKUP · EXPLICIT CLOSE
작업을 시작할 때 조회하고 그 호출이 사용 완료 시점까지 소유한다
작업 호출 진입
export()가 작업 하나의 시작·끝 경계를 엽니다.호출별 조회
BatchExporter가 평문jobs.open()을 호출합니다. 구성에서 연결한jobs::getObject가 새 프로토타입ExportJob을 요청합니다.초기화된 객체 사용
컨테이너가 주입과 초기화를 마친 객체로 현재 batch만 처리합니다.
호출자가 즉시 정리
try-with-resources를 빠져나오며 객체의close()를 호출합니다.
컨테이너는 반환한 프로토타입의 전체 수명을 추적하거나 destroy callback을 자동 실행하지 않습니다. 사용 완료를 아는 코드가 정리를 책임집니다.
DESIGN RULE · MAKE THE BOUNDARY VISIBLE
스코프는 생성·재사용 범위를 정하고 자원 소유권은 별도로 표현한다
값과 상태만 필요한 짧은 작업
일반 생성자나 도메인 factory로 만들고 메서드의 지역 변수와 명시적 인자로 수명을 보입니다.
Spring DI·초기화가 필요한 작업
prototype과 호출 시점 조회를 사용하되, 반환된 객체의 종료 책임을 호출자에게 둡니다.검증할 두 질문
“새 객체를 언제 조회하는가?”와 “사용이 끝났음을 누가 아는가?”가 코드에서 각각 답을 가져야 합니다.
짧은 스코프를 선언하는 것만으로 작업 단위가 생기지는 않습니다. 조회 시점과 종료 책임이 같은 호출 경계에 보일 때 실제 수명이 설계와 일치합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 위 FixedExporter는 싱글톤 생성 때 prototype ExportJob 한 개를 주입받아 필드에 고정하고 반복 start가 같은 ID를 읽는다. 별도로 아래 BatchExporter.export는 호출마다 jobs.open을 부르고 구성의 jobs::getObject가 새 prototype을 얻는다. 초기화된 그 Job으로 batch를 쓴 뒤 try-with-resources가 close를 호출한다. 그림은 같은 fixture의 객체 수를 섞지 않으며 prototype의 container-close 자동 destroy를 가정하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 8 | 위 FixedExporter는 싱글톤 생성 때 prototype ExportJob 한 개를 주입받아 필드에 고정하고 반복 start가 같은 ID를 읽는다. 별도로 아래 BatchExporter.export는 호출마다 jobs.open을 부르고 구성의 jobs::getObject가 새 prototype을 얻는다. 초기화된 그 Job으로 batch를 쓴 뒤 try-with-resources가 close를 호출한다. 그림은 같은 fixture의 객체 수를 섞지 않으며 prototype의 container-close 자동 destroy를 가정하지 않는다. |
| 51 | 가 일반 메서드 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 조회 시점의 두 정책 FixedExporter는 싱글톤 생성 때 Job 하나를 필드에 받고 BatchExporter는 export 호출마다 jobs.open으로 새 prototype을 조회합니다. | FixedExporter: 생성 때 조회한 Job 참조 고정 · BatchExporter: 호출마다 새 Job 조회 · 비교: 두 독립 fixture | 조회 시점이 각 정책의 객체 생성·재사용 범위를 정합니다. |
| 각 호출에서 사용 FixedExporter.start는 같은 Job ID를 읽고 BatchExporter.export는 이번에 열린 초기화된 Job으로 현재 batch를 씁니다. | FixedExporter: 반복 start는 같은 Job ID · BatchExporter: 현재 새 Job으로 batch write | 반복 start가 고정 참조를 바꾸지는 않으며 export는 호출별 객체를 사용합니다. |
| 각 종료 책임 FixedExporter의 고정 prototype도 컨텍스트 종료만으로 닫히지 않습니다. BatchExporter는 호출의 try-with-resources가 이번 Job을 닫습니다. | FixedExporter: 컨텍스트의 자동 close 없음 · BatchExporter: 호출자가 try-with-resources close · prototype: 컨테이너 자동 destroy 없음 | 조회 정책과 별개로 사용 완료를 아는 호출 코드가 prototype 정리 책임을 가집니다. |
짧은 수명 빈은 조회 시점이 재사용을, 호출 경계가 종료 책임을 결정한다
호출마다 prototype을 열어 batch를 쓰고 caller가 close하는 수명과 싱글톤 필드 고정 사례를 구별한다.
LOOKUP TIME · LIFETIME OWNER
짧은 수명 빈은 조회 시점이 재사용을, 호출 경계가 종료 책임을 결정한다
prototype은 컨테이너가 그 정의를 조회할 때마다 새 인스턴스를 만듭니다. 긴 수명의 싱글톤에 직접 주입하면 조회는 싱글톤 생성 때 한 번뿐이므로, 작업마다 새 객체가 필요할 때는 조회와 정리 경계를 호출 코드에 드러내야 합니다.
prototype은 컨테이너 조회 때마다 새 객체입니다. 반복 메서드 호출만으로 고정 필드가 바뀌지 않습니다. 호출별 조회 경로에서는 사용 완료를 아는 호출자가 닫으며, 정상 컨텍스트 종료만으로 prototype이 닫히지는 않습니다.
DIRECT INJECTION · FIXED REFERENCE
싱글톤 필드에는 생성 시점에 해석한 프로토타입 하나가 남는다
싱글톤 생성 시작
컨테이너가 직접 주입 fixture인
FixedExporter의 의존성을 한 번 해석합니다.프로토타입 조회
ExportJob하나를 만들고 의존성 주입과 초기화 callback을 완료합니다.필드에 참조 저장
생성자에 전달된 작업 객체가 싱글톤의 필드에 고정됩니다.
여러 번 호출
첫 번째와 다음
FixedExporter.start()가 같은 작업 객체의 ID를 다시 읽습니다.
컨텍스트에서 프로토타입을 따로 조회하면 새 객체가 나오지만, 이미 완성된 싱글톤의 필드가 자동으로 바뀌지는 않습니다.
PROVIDER LOOKUP · EXPLICIT CLOSE
작업을 시작할 때 조회하고 그 호출이 사용 완료 시점까지 소유한다
작업 호출 진입
export()가 작업 하나의 시작·끝 경계를 엽니다.호출별 조회
BatchExporter가 평문jobs.open()을 호출합니다. 구성에서 연결한jobs::getObject가 새 프로토타입ExportJob을 요청합니다.초기화된 객체 사용
컨테이너가 주입과 초기화를 마친 객체로 현재 batch만 처리합니다.
호출자가 즉시 정리
try-with-resources를 빠져나오며 객체의close()를 호출합니다.
컨테이너는 반환한 프로토타입의 전체 수명을 추적하거나 destroy callback을 자동 실행하지 않습니다. 사용 완료를 아는 코드가 정리를 책임집니다.
DESIGN RULE · MAKE THE BOUNDARY VISIBLE
스코프는 생성·재사용 범위를 정하고 자원 소유권은 별도로 표현한다
값과 상태만 필요한 짧은 작업
일반 생성자나 도메인 factory로 만들고 메서드의 지역 변수와 명시적 인자로 수명을 보입니다.
Spring DI·초기화가 필요한 작업
prototype과 호출 시점 조회를 사용하되, 반환된 객체의 종료 책임을 호출자에게 둡니다.검증할 두 질문
“새 객체를 언제 조회하는가?”와 “사용이 끝났음을 누가 아는가?”가 코드에서 각각 답을 가져야 합니다.
짧은 스코프를 선언하는 것만으로 작업 단위가 생기지는 않습니다. 조회 시점과 종료 책임이 같은 호출 경계에 보일 때 실제 수명이 설계와 일치합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 위 FixedExporter는 싱글톤 생성 때 prototype ExportJob 한 개를 주입받아 필드에 고정하고 반복 start가 같은 ID를 읽는다. 별도로 아래 BatchExporter.export는 호출마다 jobs.open을 부르고 구성의 jobs::getObject가 새 prototype을 얻는다. 초기화된 그 Job으로 batch를 쓴 뒤 try-with-resources가 close를 호출한다. 그림은 같은 fixture의 객체 수를 섞지 않으며 prototype의 container-close 자동 destroy를 가정하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 8 | 위 FixedExporter는 싱글톤 생성 때 prototype ExportJob 한 개를 주입받아 필드에 고정하고 반복 start가 같은 ID를 읽는다. 별도로 아래 BatchExporter.export는 호출마다 jobs.open을 부르고 구성의 jobs::getObject가 새 prototype을 얻는다. 초기화된 그 Job으로 batch를 쓴 뒤 try-with-resources가 close를 호출한다. 그림은 같은 fixture의 객체 수를 섞지 않으며 prototype의 container-close 자동 destroy를 가정하지 않는다. |
| 51 | 가 일반 메서드 |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| 조회 시점의 두 정책 FixedExporter는 싱글톤 생성 때 Job 하나를 필드에 받고 BatchExporter는 export 호출마다 jobs.open으로 새 prototype을 조회합니다. | FixedExporter: 생성 때 조회한 Job 참조 고정 · BatchExporter: 호출마다 새 Job 조회 · 비교: 두 독립 fixture | 조회 시점이 각 정책의 객체 생성·재사용 범위를 정합니다. |
| 각 호출에서 사용 FixedExporter.start는 같은 Job ID를 읽고 BatchExporter.export는 이번에 열린 초기화된 Job으로 현재 batch를 씁니다. | FixedExporter: 반복 start는 같은 Job ID · BatchExporter: 현재 새 Job으로 batch write | 반복 start가 고정 참조를 바꾸지는 않으며 export는 호출별 객체를 사용합니다. |
| 각 종료 책임 FixedExporter의 고정 prototype도 컨텍스트 종료만으로 닫히지 않습니다. BatchExporter는 호출의 try-with-resources가 이번 Job을 닫습니다. | FixedExporter: 컨텍스트의 자동 close 없음 · BatchExporter: 호출자가 try-with-resources close · prototype: 컨테이너 자동 destroy 없음 | 조회 정책과 별개로 사용 완료를 아는 호출 코드가 prototype 정리 책임을 가집니다. |
그림의 왼쪽 FixedExporter는 싱글톤이 만들어질 때 프로토타입 ExportJob 하나를 주입받아 필드에 고정하므로, start()를 여러 번 불러도 같은 작업 식별자를 읽습니다. 오른쪽의 BatchExporter는 export()를 호출할 때마다 jobs.open()으로 새 프로토타입을 조회해 이번 일만 처리한 뒤 try-with-resources로 닫습니다. 두 경로 모두 컨텍스트를 닫는다고 프로토타입이 저절로 닫히지는 않으며, 다 쓴 시점을 아는 코드가 정리해야 합니다.
직접 주입은 싱글톤 생성 시 한 번만 일어납니다
프로토타입 스코프의 빈은 컨테이너에 그 빈을 요청할 때마다 새 인스턴스가 만들어집니다. 그런데 프로토타입을 싱글톤의 생성자에 직접 주입하면, 의존성은 싱글톤을 만드는 그때 한 번만 해석됩니다. 싱글톤의 필드에는 그때 받은 첫 객체의 참조가 남으므로, 같은 메서드를 여러 번 호출해도 같은 작업 객체를 씁니다. "메서드를 호출할 때마다"가 아니라 "컨테이너에 빈을 요청할 때마다" 새 인스턴스라는 뜻입니다. 작업을 반복할 때마다 새 인스턴스가 필요하다면, 작업이 시작되는 지점에서 다시 조회해야 합니다.
조회 시점과 종료 소유권을 코드에 드러냅니다
다음 프로토타입 작업 객체는 작업 하나의 상태만 들고 있고, AutoCloseable로 종료 지점을 제공합니다. close()에 @PreDestroy를 붙여 두면 관리되는 스코프가 소멸 콜백을 실행할 때도 쓸 수 있지만, 프로토타입은 컨텍스트를 닫아도 이 콜백이 자동으로 호출되지 않습니다.
package example.scope;
import jakarta.annotation.PreDestroy;
import java.util.UUID;
public final class ExportJob implements AutoCloseable {
private final UUID id = UUID.randomUUID();
private int writtenPosts;
private boolean closed;
public UUID id() {
return id;
}
public int writtenPosts() {
return writtenPosts;
}
public boolean isClosed() {
return closed;
}
public void write(BatchExporter.PostBatch batch) {
if (closed) {
throw new IllegalStateException("export job is closed");
}
writtenPosts += batch.titles().size();
}
@PreDestroy
@Override
public void close() {
closed = true;
}
}애플리케이션 클래스가 Spring API에 의존하지 않도록, 작은 팩토리 인터페이스를 수명을 다루는 포트로 둘 수 있습니다. BatchExporter는 작업을 시작할 때 이 팩토리를 호출하고, 돌려받은 작업 객체를 try-with-resources로 닫습니다.
package example.scope;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
public final class BatchExporter {
private final ExportJobFactory jobs;
public BatchExporter(ExportJobFactory jobs) {
this.jobs = Objects.requireNonNull(jobs);
}
public UUID export(PostBatch batch) {
try (var job = jobs.open()) {
job.write(batch);
return job.id();
}
}
@FunctionalInterface
public interface ExportJobFactory {
ExportJob open();
}
public record PostBatch(List<String> titles) {
public PostBatch {
titles = List.copyOf(Objects.requireNonNull(titles));
}
}
}Spring 구성에서는 주입받은 ObjectProvider<ExportJob>의 jobs::getObject를 이 포트에 연결합니다. ObjectProvider는 호출할 때마다 대상 빈을 다시 해석하고, 그 정의가 프로토타입이므로 매번 새 ExportJob이 만들어집니다. 다음 테스트는 직접 주입, 지연 조회, 종료를 맡는 쪽을 따로 확인합니다.
package example.scope;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
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 PrototypeScopeTest {
@Test
void directInjectionReusesOnePrototype() {
try (var context = new AnnotationConfigApplicationContext(
DirectInjectionConfig.class)) {
var exporter = context.getBean(FixedExporter.class);
assertEquals(exporter.start(), exporter.start());
assertNotSame(
context.getBean(ExportJob.class),
context.getBean(ExportJob.class));
}
}
@Test
void providerLookupCreatesAndClosesOneJobPerExport() {
try (var context = new AnnotationConfigApplicationContext(
ProviderConfig.class)) {
var exporter = context.getBean(BatchExporter.class);
var batch = new BatchExporter.PostBatch(List.of("first"));
UUID first = exporter.export(batch);
UUID second = exporter.export(batch);
assertNotEquals(first, second);
var created = context.getBean(ProviderConfig.class).createdJobs();
assertEquals(2, created.size());
assertTrue(created.stream().allMatch(ExportJob::isClosed));
assertTrue(created.stream()
.allMatch(job -> job.writtenPosts() == 1));
}
}
@Test
void contextShutdownDoesNotCloseAPrototype() {
var context = new AnnotationConfigApplicationContext(
PrototypeOnlyConfig.class);
var job = context.getBean(ExportJob.class);
context.close();
assertFalse(job.isClosed());
job.close();
assertTrue(job.isClosed());
}
@Configuration(proxyBeanMethods = false)
static class DirectInjectionConfig {
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
ExportJob exportJob() {
return new ExportJob();
}
@Bean
FixedExporter fixedExporter(ExportJob job) {
return new FixedExporter(job);
}
}
@Configuration(proxyBeanMethods = false)
static class ProviderConfig {
private final List<ExportJob> created = new ArrayList<>();
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
ExportJob exportJob() {
var job = new ExportJob();
created.add(job);
return job;
}
@Bean
BatchExporter batchExporter(ObjectProvider<ExportJob> jobs) {
return new BatchExporter(jobs::getObject);
}
List<ExportJob> createdJobs() {
return List.copyOf(created);
}
}
@Configuration(proxyBeanMethods = false)
static class PrototypeOnlyConfig {
@Bean
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)
ExportJob exportJob() {
return new ExportJob();
}
}
static final class FixedExporter {
private final ExportJob job;
FixedExporter(ExportJob job) {
this.job = job;
}
UUID start() {
return job.id();
}
}
}첫 테스트는 프로토타입 정의가 올바르더라도 싱글톤에 직접 주입한 참조는 고정된다는 것을 보여 줍니다. 둘째 테스트는 export가 호출될 때마다 새 작업 객체를 받고, 호출한 쪽이 두 객체를 모두 닫았는지 확인합니다. 셋째 테스트는 컨텍스트를 닫아도 이미 돌려준 프로토타입의 @PreDestroy가 실행되지 않으므로 사용자가 직접 닫아야 한다는 것을 확인합니다.
| 단계 | 싱글톤 | 프로토타입 |
|---|---|---|
| 생성, 의존성 주입, 초기화 | 컨테이너 | 컨테이너 |
| 사용 중 추적 | 컨텍스트를 닫을 때까지 | 호출한 쪽에 돌려준 뒤로는 추적하지 않음 |
| 정상 종료 콜백 | 컨텍스트를 닫을 때 실행 | 호출한 쪽이 직접 정리 |
작업 식별자와 진행 값만 가진 평범한 Java 객체에 의존성 주입이나 컨테이너 콜백이 필요 없다면, 도메인 팩토리가 new로 만드는 편이 더 단순합니다. 프로토타입은 수명이 짧다는 것보다, 그 객체를 만드는 데 컨테이너의 의존성 해석이 필요할 때 쓸모가 있습니다.
요청 스코프는 호출 시점의 대상을 찾습니다
요청 스코프의 빈은 같은 HTTP 요청 안에서는 같은 대상이고, 다음 요청에서는 새 대상입니다. 이 빈을 싱글톤에 직접 주입하려면 실제 요청 객체가 아니라, 호출될 때 현재 요청의 대상을 찾아 주는 스코프 프록시(scoped proxy)가 필요합니다.
package example.scope;
import java.util.UUID;
public class RequestTrace {
private final String id = UUID.randomUUID().toString();
public String id() {
return id;
}
}@RequestScope의 기본 프록시 방식은 클래스를 상속하는 방식이므로, 예제의 RequestTrace와 호출할 id() 메서드는 final로 선언하지 않습니다. 싱글톤인 RequestAudit에는 이 프록시가 한 번 주입됩니다.
package example.scope;
import java.util.Objects;
public final class RequestAudit {
private final RequestTrace trace;
public RequestAudit(RequestTrace trace) {
this.trace = Objects.requireNonNull(trace);
}
public String currentTraceId() {
return trace.id();
}
}RequestAudit의 trace 필드는 생성자에서 한 번 정해진 뒤 바뀌지 않지만, 여기에 들어오는 것은 실제 RequestTrace가 아니라 스코프 프록시입니다. 그래서 currentTraceId()를 부를 때마다 그 호출이 속한 요청의 대상에게 id()가 전달되고, 요청이 다르면 다른 값이 나옵니다. RequestTraceConfig는 다른 패키지의 애플리케이션 시작 코드나 테스트에서도 등록할 수 있도록 공개 구성 클래스로 둡니다. 아래 구성의 @RequestScope가 RequestTrace 정의를 요청 스코프로 만들고, requestAudit()의 파라미터로는 그 프록시가 들어갑니다.
package example.scope;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.context.annotation.RequestScope;
@Configuration(proxyBeanMethods = false)
public class RequestTraceConfig {
@Bean
@RequestScope
RequestTrace requestTrace() {
return new RequestTrace();
}
@Bean
RequestAudit requestAudit(RequestTrace trace) {
return new RequestAudit(trace);
}
}프록시를 주입할 때 요청 대상이 미리 만들어지는 것은 아닙니다. currentTraceId()가 호출되면 프록시가 현재 스레드에 연결된 요청 속성에서 대상을 찾고, 그 요청에 아직 대상이 없으면 그때 한 번 만듭니다.
더 깊이: 빈 이름 뒤에 숨은 두 정의와 요청이 스레드에 붙는 곳
스코프 프록시를 만들면 빈 정의도 둘로 나뉩니다. Spring Framework 7.0.10의 ScopedProxyUtils는 원래 정의를 scopedTarget.requestTrace처럼 scopedTarget. 접두사가 붙은 이름으로 따로 등록하고, 원래 이름 requestTrace에는 ScopedProxyFactoryBean이 만드는 프록시의 정의를 둡니다. 대상 정의는 자동 주입 후보에서 빠지고 기본 후보(primary) 표시도 꺼지므로, 타입으로 주입받는 쪽은 언제나 프록시를 받습니다. 프록시의 메서드가 호출될 때마다 이 접두사 이름으로 현재 스코프의 대상을 찾는 구조입니다.
현재 요청을 스레드에 연결하는 일은 RequestContextHolder가 맡습니다. 레퍼런스 문서는 DispatcherServlet, RequestContextListener, RequestContextFilter가 모두 똑같이 요청 객체를 그 요청을 처리하는 스레드에 묶는다고 설명합니다. 다음 절의 테스트가 가짜 요청을 직접 연결하는 곳도 바로 이 자리입니다.
이 연결을 자식 스레드까지 물려주는 옵션도 있습니다. FrameworkServlet의 threadContextInheritable을 켜면 InheritableThreadLocal로 요청 정보를 자식 스레드에 넘기지만, 기본값은 false입니다. API 문서는 필요할 때 새 스레드를 더하는 ThreadPoolExecutor 같은 풀에서는 상속된 문맥이 풀의 스레드에 남아 노출될 수 있으므로 이 옵션을 쓰지 말라고 경고합니다. 그래서 백그라운드 작업에는 이 글처럼 필요한 값을 명시적으로 복사해 넘기는 편이 안전합니다.
요청이 없는 실행자에는 프록시 대신 값을 넘깁니다
스코프 프록시 자체는 싱글톤의 의존성으로 들고 있을 수 있지만, 요청 대상은 진행 중인 요청의 속성이 있어야 찾을 수 있습니다. 요청 속성이 연결되지 않은 스케줄러나 시작 리스너, 별도의 실행기 스레드에서 프록시의 메서드를 호출하면 ScopeNotActiveException이 납니다. 그래서 HTTP 처리 뒤의 백그라운드 작업에 추적 정보가 필요하다면 요청 객체나 스코프 프록시를 넘기지 않고, 요청이 진행 중인 스레드에서 필요한 값을 먼저 읽어 불변 명령에 복사합니다.
package example.scope;
import java.time.LocalDate;
import java.util.Objects;
public record ExportPostsCommand(
long memberId,
String requestTraceId,
LocalDate from,
LocalDate to) {
public ExportPostsCommand {
if (memberId <= 0) {
throw new IllegalArgumentException("memberId must be positive");
}
requestTraceId = Objects.requireNonNull(requestTraceId);
from = Objects.requireNonNull(from);
to = Objects.requireNonNull(to);
if (to.isBefore(from)) {
throw new IllegalArgumentException("to must not precede from");
}
}
}다음 테스트는 실제 MVC 디스패치가 아니라, RequestContextHolder에 가짜 요청을 직접 연결한 스코프 단위 테스트입니다. 같은 요청에서 대상을 다시 쓰는지, 다음 요청에서 새 대상을 만드는지, 실행기 스레드에서 스코프가 비활성이라 실패하는지, 불변 값만 넘기면 성공하는지를 각각 확인합니다.
package example.scope;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.time.LocalDate;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.support.ScopeNotActiveException;
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;
class RequestScopeTest {
@AfterEach
void clearRequestAttributes() {
RequestContextHolder.resetRequestAttributes();
}
@Test
void oneTargetPerRequestAndANewTargetForTheNextRequest() {
try (var context = webContext()) {
var audit = context.getBean(RequestAudit.class);
String first = inRequest(() -> {
String id = audit.currentTraceId();
assertEquals(id, audit.currentTraceId());
return id;
});
String second = inRequest(audit::currentTraceId);
assertNotEquals(first, second);
}
}
@Test
void executorWithoutRequestAttributesFailsOnItsOwnThread()
throws Exception {
try (var context = webContext()) {
var audit = context.getBean(RequestAudit.class);
Thread requestThread = Thread.currentThread();
var workerThread = new AtomicReference<Thread>();
ExecutorService executor = Executors.newSingleThreadExecutor();
var attributes = requestAttributes();
RequestContextHolder.setRequestAttributes(attributes);
try {
String requestId = audit.currentTraceId();
assertEquals(requestId, audit.currentTraceId());
var future = executor.submit(() -> {
workerThread.set(Thread.currentThread());
return audit.currentTraceId();
});
var failure = assertThrows(
java.util.concurrent.ExecutionException.class,
future::get);
assertTrue(workerThread.get() != null);
assertNotSame(requestThread, workerThread.get());
assertInstanceOf(
ScopeNotActiveException.class,
failure.getCause());
assertInstanceOf(
IllegalStateException.class,
failure.getCause().getCause());
} finally {
try {
completeRequest(attributes);
} finally {
stop(executor);
}
}
}
}
@Test
void immutableTraceValueCrossesTheBackgroundBoundary()
throws Exception {
try (var context = webContext()) {
var audit = context.getBean(RequestAudit.class);
String traceId = inRequest(audit::currentTraceId);
var command = new ExportPostsCommand(
7L,
traceId,
LocalDate.of(2026, 8, 1),
LocalDate.of(2026, 8, 31));
Thread requestThread = Thread.currentThread();
var workerThread = new AtomicReference<Thread>();
var workerRequestAttributesPresent =
new AtomicReference<Boolean>();
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
var future = executor.submit(() -> {
workerThread.set(Thread.currentThread());
workerRequestAttributesPresent.set(
RequestContextHolder.getRequestAttributes() != null);
return consume(command);
});
assertEquals(command.requestTraceId(), future.get());
assertTrue(workerThread.get() != null);
assertNotSame(requestThread, workerThread.get());
assertEquals(
Boolean.FALSE,
workerRequestAttributesPresent.get());
} finally {
stop(executor);
}
}
}
private static AnnotationConfigWebApplicationContext webContext() {
var context = new AnnotationConfigWebApplicationContext();
context.register(RequestTraceConfig.class);
context.refresh();
return context;
}
private static <T> T inRequest(Supplier<T> work) {
var attributes = requestAttributes();
RequestContextHolder.setRequestAttributes(attributes);
try {
return work.get();
} finally {
completeRequest(attributes);
}
}
private static ServletRequestAttributes requestAttributes() {
return new ServletRequestAttributes(new MockHttpServletRequest());
}
private static void completeRequest(
ServletRequestAttributes attributes) {
try {
attributes.requestCompleted();
} finally {
RequestContextHolder.resetRequestAttributes();
}
}
private static void stop(ExecutorService executor)
throws InterruptedException {
executor.shutdownNow();
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
}
private static String consume(ExportPostsCommand command) {
return command.requestTraceId();
}
}둘째 테스트에서 요청 스레드에는 진행 중인 요청이 있지만, 별도의 실행기 스레드에는 요청 속성이 없습니다. 테스트는 Future.get()이 던진 예외의 직접 원인이 ScopeNotActiveException이고, 그 안쪽 원인이 요청 속성이 없음을 알리는 IllegalStateException인지 구분해 확인합니다. 셋째 테스트는 요청을 완료하고 RequestContextHolder를 비운 뒤에도, 문자열을 담은 명령만으로 실행기 작업이 성공한다는 것을 보여 줍니다. 프록시를 다른 스레드로 옮기지 않았으므로 백그라운드 실행이 서블릿 요청의 수명에 묶이지 않습니다.
SCOPED PROXY · VALUE HANDOFF
요청 프록시는 현재 요청에서만 대상을 찾고 경계 밖에는 값만 전달한다
싱글톤은 실제 RequestTrace가 아니라 현재 호출 스레드의 활성 요청에서 대상을 찾는 프록시를 보관합니다. 요청이 끝난 뒤 실행할 작업에는 프록시나 서블릿 요청을 넘기지 않고, 요청 안에서 읽은 불변 값만 복사합니다.
화살표는 현재 활성 범위에 따른 조회 결과입니다. 프록시 참조가 존재하는 것과 대상이 현재 요청에서 조회 가능한 것은 다릅니다. 실행자 스레드에 요청 속성을 자동 전달하는 선은 없습니다.
요청이 활성일 때 값을 먼저 읽어 불변 명령에 담습니다. 아래 실행자는 그 값만 사용합니다. 모의 요청 속성 단위 테스트와 MVC 디스패치 연습은 본문에서 구별하며 실제 외부 HTTP 서버 실행을 보장하지 않습니다.
ACTIVE REQUEST · TARGET RESOLUTION
같은 요청은 같은 대상을 공유하고 다음 요청은 새 대상을 만든다
싱글톤 구성
RequestAudit필드에는 클래스 기반 요청 스코프 프록시가 한 번 주입됩니다.Request A 활성화
첫
trace.id()호출이 A에 속한RequestTrace대상을 만들고 찾습니다.A 안에서 재호출
같은
RequestAudit를 반복 호출해 같은 요청 대상의 trace ID를 읽습니다.A 완료 후 Request B
A의 요청 완료와 속성 해제 뒤 B의 첫 호출이 별도 대상과 다른 trace ID를 만듭니다.
NO BOUND REQUEST · FAIL FAST
요청 속성이 없는 실행자 스레드에서는 프록시가 대상을 찾을 수 없다
프록시 자체는 존재
요청 밖에서도 싱글톤과 그 필드의 스코프 프록시 참조는 조회할 수 있습니다.
별도 실행자에서 메서드 호출
현재 스레드에
ServletRequestAttributes가 바인딩되지 않은 상태로trace.id()를 호출합니다.대상 조회 실패
활성
requestscope가 없으므로ScopeNotActiveException이 발생합니다.
프록시는 요청 컨텍스트를 백그라운드 스레드로 운반하는 객체가 아닙니다. 스케줄러와 실행자는 자신의 명시적 입력만 받아야 합니다.
SAFE HANDOFF · COPY IMMUTABLE DATA
요청 스레드에서 필요한 값을 읽고 요청과 독립적인 명령을 넘긴다
활성 요청에서 읽기
요청 처리 코드가 프록시를 호출해 현재
requestTraceId문자열을 얻습니다.불변 command 구성
회원 ID, 기간, trace ID처럼 작업에 필요한 값만
ExportPostsCommand에 복사합니다.대기열·실행자에 값 전달
백그라운드 worker는 요청 프록시나 서블릿 객체 없이 command만 받아 처리합니다.
요청과 독립적으로 완료
원래 HTTP 요청이 종료된 뒤에도 worker는 복사된 값으로 결과와 trace 상관관계를 유지합니다.
이 문서의 테스트는 모의 요청 속성을 직접 바인딩한 스코프 테스트입니다. 실제 MVC 디스패치와 컨트롤러·예외 처리기 공유는 연습 문제의 검증 대상입니다. 요청 스코프의 공유 단위는 현재 HTTP 요청입니다. 그 경계를 넘는 순간에는 객체 그래프가 아니라 필요한 값만 명시적 메시지로 바꿉니다.
그림의 위쪽은 같은 프록시가 요청 A에서는 A의 대상을, 다음 요청 B에서는 B의 대상을 찾고, 요청 속성이 없는 스레드에서는 ScopeNotActiveException으로 실패하는 세 경우입니다. 세 화살표는 조건에 따른 결과이며 이어서 실행되는 흐름이 아닙니다. 아래쪽은 요청이 진행 중일 때 추적 식별자 문자열을 읽어 ExportPostsCommand에 복사하고, 별도의 작업자가 그 값만으로 요청이 끝난 뒤에도 처리하는 안전한 넘김을 보여 줍니다.
명시적 값 전달을 먼저 검토합니다
스코프를 고르는 기준은 "객체를 새로 만들고 싶다"가 아니라 그 값이 실제로 얼마나 오래 사느냐입니다.
| 실제 수명 | 먼저 쓸 표현 | Spring 스코프가 맞는 경우 |
|---|---|---|
| 메서드 호출 한 번 | 파라미터와 지역 변수 | 거의 필요 없음 |
| 도메인 작업 하나 | 평범한 Java 팩토리와 try-with-resources | 의존성 주입이 필요한 프로토타입 작업 객체 |
| HTTP 요청 하나 | 컨트롤러 파라미터와 불변 컨텍스트 | 여러 웹 컴포넌트가 같은 인프라 정보를 함께 쓸 때 |
| 회원 | 명시적인 저장소 | 정말 회원 단위로 사는 화면 상태 |
| 애플리케이션 | 상태 없는 싱글톤 | 서비스, 저장소, 클라이언트 |
요청 스코프를 사용자 식별자를 넣어 두는 저장소처럼 쓰면 인증이 어디서 일어나는지가 감춰지고, 백그라운드 실행과 테스트도 어려워집니다. Spring Security처럼 프레임워크가 제공하는 인증 정보를 쓰거나, 컨트롤러에서 필요한 식별자만 명령으로 넘기면 업무 흐름과 수명이 함께 드러납니다.
정리
- 프로토타입은 컨테이너에 요청할 때마다 새로 만들어지므로, 싱글톤에 직접 주입하면 처음 받은 하나가 계속 쓰입니다.
- 작업마다 새 객체가 필요하면 작업을 시작하는 지점에서
ObjectProvider로 다시 조회하고, 다 쓴 객체는 호출한 쪽이 직접 닫습니다. - 컨테이너는 돌려준 프로토타입을 추적하지 않으므로, 컨텍스트를 닫아도 프로토타입의
@PreDestroy는 실행되지 않습니다. - 요청 스코프의 빈을 싱글톤에 주입하면 스코프 프록시가 들어가고, 프록시는 호출될 때 현재 스레드의 요청에서 대상을 찾습니다.
- 요청 밖의 스레드에서는 프록시가 대상을 찾지 못하므로, 요청이 진행 중일 때 필요한 값만 읽어 불변 명령으로 넘깁니다.
연습 문제
1. 같은 요청의 컨트롤러와 예외 처리기
한 HTTP 요청에서 만든 RequestTrace의 식별자를 컨트롤러와 예외 처리기가 똑같이 읽고, 다음 요청에서는 다른 식별자를 읽는 MVC 테스트를 작성하세요. 앞의 RequestScopeTest는 요청 속성을 직접 연결한 스코프 단위 테스트였지만, 이번에는 MockMvc가 DispatcherServlet을 거치게 해 한 요청 안에서 두 컴포넌트를 비교합니다. 실제 TCP 서버를 띄우는 테스트는 아닙니다. 1장의 웹과 테스트 의존성, 앞의 RequestTrace와 RequestTraceConfig 파일이 먼저 있어야 합니다.
해설 보기
컨트롤러와 예외 처리기에 같은 RequestTrace 프록시를 생성자로 주입하고, 준비한 예외를 일부러 던져 HTTP 400 응답 본문에 컨트롤러 ID|예외 처리기 ID 두 값을 넣습니다.
package example.scope;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockServletContext;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
final class RequestTraceMvcExerciseTest {
@AfterEach
void clearThreadState() {
RequestContextHolder.resetRequestAttributes();
}
@Configuration(proxyBeanMethods = false)
@EnableWebMvc
@Import(RequestTraceConfig.class)
static class MvcConfig {
@Bean
TraceController traceController(RequestTrace trace) {
return new TraceController(trace);
}
@Bean
TraceAdvice traceAdvice(RequestTrace trace) {
return new TraceAdvice(trace);
}
}
@RestController
static class TraceController {
private final RequestTrace trace;
TraceController(RequestTrace trace) {
this.trace = trace;
}
@GetMapping("/scope-trace/fail")
String fail() {
throw new TraceFailure(trace.id());
}
}
static final class TraceFailure extends RuntimeException {
private final String controllerTraceId;
TraceFailure(String controllerTraceId) {
super("trace exercise");
this.controllerTraceId = controllerTraceId;
}
}
@RestControllerAdvice(assignableTypes = TraceController.class)
static class TraceAdvice {
private final RequestTrace trace;
TraceAdvice(RequestTrace trace) {
this.trace = trace;
}
@ExceptionHandler(TraceFailure.class)
ResponseEntity<String> handle(TraceFailure failure) {
return ResponseEntity.badRequest().body(
failure.controllerTraceId + "|" + trace.id());
}
}
@Test
void 같은_요청의_두_컴포넌트는_같고_다음_요청은_다르다()
throws Exception {
try (var context = new AnnotationConfigWebApplicationContext()) {
context.setServletContext(new MockServletContext());
context.register(MvcConfig.class);
context.refresh();
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).build();
String[] first = readTwoIds(mvc);
String[] second = readTwoIds(mvc);
assertEquals(2, first.length);
assertEquals(2, second.length);
assertEquals(first[0], first[1]);
assertEquals(second[0], second[1]);
assertNotEquals(first[0], second[0]);
}
}
private static String[] readTwoIds(MockMvc mvc) throws Exception {
String response = mvc.perform(get("/scope-trace/fail"))
.andExpect(status().isBadRequest())
.andReturn().getResponse().getContentAsString();
return response.split("\\|", -1);
}
}./gradlew test --tests example.scope.RequestTraceMvcExerciseTest로 실행합니다. 테스트는 한 응답 안의 두 값이 같고, 별도 요청의 값은 다르다는 것을 확인합니다. 프록시 하나를 함께 쓴다고 해서 애플리케이션 전체에 요청 대상이 하나뿐인 것은 아니며, MVC가 요청마다 연결한 대상을 그 요청 안에서만 함께 씁니다. 이 테스트는 MvcConfig와 가져온 요청 구성만 띄우고 운영 게시판 전체를 스캔하지 않으며, 실제 서버가 아니라 MockMvc의 MVC 처리 경로를 확인합니다.
실행기 쪽은 앞의 RequestScopeTest의 두 실행기 테스트로 함께 확인합니다. MVC 요청이 끝나기 전에 추적 문자열을 읽어 ExportPostsCommand에 복사하고 실행기에는 그 불변 명령만 넘기면, 실행기에서 프록시를 조회해 실패하는 경우와 달리 성공합니다. 직접 연결한 테스트는 자신이 요청 완료와 연결 해제, 실행기 종료를 맡고, 이 MVC 테스트는 요청 처리를 프레임워크에 맡긴 뒤 남은 스레드 상태만 @AfterEach에서 비웁니다.
이것으로 3장을 마칩니다. 이제 컨테이너가 빈을 어떻게 등록하고 고르며, 언제 만들고 정리하는지를 설명할 수 있습니다. 다음 장의 DNS·IP·TCP·UDP와 연결 경계부터는 컨테이너에서 한 단계 내려가, 브라우저의 요청이 DNS 이름 해석과 IP, TCP, UDP, TLS, HTTP를 거쳐 서버에 닿는 경로를 살펴봅니다.