프로토타입과 요청 스코프
긴 수명의 싱글톤이 짧은 수명 빈을 언제 조회하는지 구분하고, 프로토타입의 종료 소유권과 요청 프록시의 활성 범위를 실제 Spring 테스트로 검증합니다.
한 번의 내보내기 작업 진행률은 작업마다 새로 필요하고, HTTP 트레이스 정보는 한 요청 안에서만 공유해야 합니다.
이 객체들의 스코프를 짧게 선언하는 것만으로는 수명 설계가 끝나지 않습니다.
더 오래 사는 싱글톤이 짧은 수명 빈을 언제 조회하는지, 사용이 끝난 뒤 누가 정리하는지, 활성 범위를 벗어날 때 어떤 값만 전달할지까지 결정해야 합니다.
짧은 수명 빈은 조회 시점이 재사용을, 호출 경계가 종료 책임을 결정한다
호출마다 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 정리 책임을 가집니다. |
직접 주입은 싱글톤 생성 시 한 번만 일어납니다
프로토타입 정의는 컨테이너가 그 빈을 요청받을 때마다 새 인스턴스를 만듭니다.
그러나 프로토타입을 싱글톤 생성자에 직접 주입하면 의존성 해결은 싱글톤을 만드는 시점에 한 번만 일어납니다.
싱글톤 필드에는 그때 받은 첫 객체 참조가 남으므로 같은 메서드를 여러 번 호출해도 같은 작업 객체를 사용합니다.
“메서드를 호출할 때마다”가 아니라 “컨테이너에 빈을 요청할 때마다” 새 인스턴스라는 뜻입니다.
반복 작업마다 새 인스턴스가 필요하면 작업 시작이라는 수명 경계에서 다시 조회해야 합니다.
조회 경계와 종료 소유권을 코드에 드러냅니다
다음 프로토타입 작업 객체는 한 작업의 상태만 보관하고 AutoCloseable로 종료 경계를 제공합니다.
@PreDestroy가 붙은 close()는 관리되는 스코프가 파괴 콜백을 실행할 때도 사용할 수 있지만, 프로토타입에는 컨테이너 종료가 이 콜백을 자동 호출하지 않습니다.
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를 대신 실행하지 않으므로 사용자가 직접 닫아야 한다는 경계를 고정합니다.
| 단계 | 싱글톤 | 프로토타입 |
|---|---|---|
| 생성·의존성 주입·초기화 | 컨테이너 | 컨테이너 |
| 사용 중 추적 | 컨텍스트 종료까지 | 호출자에게 반환한 뒤 추적하지 않음 |
| 정상 종료 콜백 | 컨텍스트 종료에서 실행 | 호출자가 명시적으로 정리 |
작업 ID와 진행 값만 가진 일반 Java 객체에 의존성 주입이나 컨테이너 콜백이 필요 없다면 도메인 팩토리가 new로 만드는 편이 더 단순합니다.
프로토타입은 짧은 수명 자체보다 그 객체를 만드는 데 컨테이너의 의존성 해결이 필요할 때 가치가 있습니다.
요청 스코프는 호출 시점의 대상을 찾습니다
요청 스코프 빈은 같은 HTTP 요청 안에서 같은 대상이고 다음 요청에서는 새 대상입니다.
싱글톤에 직접 주입할 때는 실제 요청 객체가 아니라 현재 요청의 대상을 찾아 주는 범위 프록시가 필요합니다.
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();
}
}RequestTraceConfig는 다른 패키지의 애플리케이션 부트스트랩이나 테스트에서도 등록할 수 있도록 공개 구성 클래스로 둡니다.
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()가 호출되면 프록시가 현재 스레드에 바인딩된 요청 속성에서 대상을 찾고, 그 요청에 대상이 없을 때 한 번 생성합니다.
요청이 없는 실행자에는 프록시 대신 값을 넘깁니다
범위 프록시 자체는 싱글톤 의존성으로 보관할 수 있지만 요청 대상은 활성 요청 속성이 있어야 찾을 수 있습니다.
요청 속성이 바인딩되지 않은 스케줄러, 시작 리스너, 별도 실행자 스레드에서 프록시 메서드를 호출하면 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 요청입니다. 그 경계를 넘는 순간에는 객체 그래프가 아니라 필요한 값만 명시적 메시지로 바꿉니다.
명시적 값 전달을 먼저 검토합니다
스코프 선택 기준은 “객체를 새로 만들고 싶다”보다 값의 실제 수명입니다.
| 실제 수명 | 우선 표현 | Spring 스코프가 맞는 경우 |
|---|---|---|
| 메서드 호출 | 파라미터·지역 변수 | 거의 필요 없음 |
| 도메인 작업 하나 | 일반 Java 팩토리·try-with-resources | DI가 필요한 프로토타입 작업 객체 |
| HTTP 요청 하나 | 컨트롤러 파라미터·불변 컨텍스트 | 여러 웹 컴포넌트가 같은 인프라 정보를 공유 |
| 회원 | 명시적 저장소 | 정말 회원 수명인 UI 상태 |
| 애플리케이션 | 무상태 싱글톤 | 서비스·리포지토리·클라이언트 |
요청 스코프를 사용자 ID 저장소처럼 사용하면 인증 경계가 숨고 백그라운드 실행과 테스트가 어려워집니다.
Spring Security처럼 프레임워크가 제공하는 인증 문맥을 사용하거나 컨트롤러에서 필요한 식별자만 명령으로 넘기면 업무 흐름과 수명 경계가 함께 보입니다.
연습 문제
한 HTTP 요청에서 생성한 RequestTrace ID를 컨트롤러와 예외 처리기가 동일하게 읽고, 다음 요청에서는 다른 ID를 읽는 MVC 테스트를 작성하세요.
이어 요청 속성이 없는 실행자에서 범위 프록시 호출이 실패하는지 확인하고, 활성 요청에서 읽은 트레이스 ID를 ExportPostsCommand에 복사하면 같은 실행자에서 성공하도록 바꾸세요.
해설 보기
먼저 컨트롤러와 예외 처리기에 같은 RequestTrace 프록시를 생성자로 주입합니다.
모의 요청 하나에서 두 컴포넌트가 반환한 ID가 같은지 확인하고, 다음 모의 요청에서는 ID가 달라지는지 확인합니다.
실패 재현에서는 요청 스레드와 실행자 스레드가 실제로 다른지도 함께 검증하고, Future.get()의 직접 원인이 ScopeNotActiveException인지 확인합니다.
수정 후에는 컨트롤러가 활성 요청에서 audit.currentTraceId()를 먼저 읽어 불변 명령을 만듭니다.
실행자에는 프록시가 아니라 명령만 전달하고, 테스트의 finally에서 요청 완료와 RequestContextHolder.resetRequestAttributes()를 모두 수행합니다.
예시: 같은 요청의 컨트롤러와 예외 처리기를 비교하기
앞의 RequestScopeTest는 요청 속성을 직접 바인딩한 스코프 단위 테스트입니다. 다음 연습 해설은 MockMvc가 DispatcherServlet을 거치게 하여 컨트롤러와 예외 처리기를 한 요청 안에서 비교합니다. 실제 TCP 서버를 띄우는 테스트는 아닙니다. 첫 장의 웹·테스트 의존성과 앞의 완전한 RequestTrace, RequestTraceConfig 파일이 먼저 있어야 합니다.
MVC 연습의 완전한 해설 테스트
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);
}
}이 테스트는 준비한 예외를 일부러 던져 HTTP 400 응답에 컨트롤러 ID|예외 처리기 ID 두 값을 넣습니다. 한 응답 안의 두 값은 같고, 별도 요청의 값은 다르다는 세 비교를 확인합니다. 한 프록시를 공유했다고 해서 애플리케이션 전체에 요청 대상 하나만 존재하는 것은 아닙니다. MVC가 각 요청에 연결한 대상을 그 요청 안에서 공유합니다.
프로젝트 루트에서 ./gradlew test --tests example.scope.RequestTraceMvcExerciseTest로 실행합니다. MvcConfig와 가져온 요청 구성만 열고 운영 게시판 전체를 스캔하지 않습니다. 여기서는 실제 서버와 별개로 MockMvc의 MVC 처리 경로를 검증합니다.
실행자 경계는 앞의 완전한 RequestScopeTest의 두 실행자 테스트와 함께 확인하세요. MVC 요청이 끝나기 전에는 트레이스 문자열을 읽어 ExportPostsCommand에 복사할 수 있고, 실행자에는 그 불변 명령만 전달합니다. 실행자에서 프록시를 조회하는 실패와 문자열을 읽는 성공은 서로 다른 테스트 조건입니다. 직접 바인딩한 테스트는 자신의 requestCompleted·초기화 해제·실행자 종료를 유지하고, 이 MVC 테스트에서는 요청 처리를 프레임워크에 맡기며 남은 스레드 상태를 AfterEach에서 비웁니다.
다음 장에서는 이 컨테이너 경계에서 한 단계 내려가 DNS 이름 해석과 IP·TCP·UDP, TLS, HTTP가 브라우저 요청을 서버까지 운반하는 경로를 검증합니다.