프로토타입과 요청 스코프
긴 수명의 싱글톤이 짧은 수명 빈을 언제 조회하는지 구분하고, 프로토타입의 종료 소유권과 요청 프록시의 활성 범위를 실제 Spring 테스트로 검증합니다.
한 번의 내보내기 작업 진행률은 작업마다 새로 필요하고, HTTP 트레이스 정보는 한 요청 안에서만 공유해야 합니다.
이 객체들의 스코프를 짧게 선언하는 것만으로는 수명 설계가 끝나지 않습니다.
더 오래 사는 싱글톤이 짧은 수명 빈을 언제 조회하는지, 사용이 끝난 뒤 누가 정리하는지, 활성 범위를 벗어날 때 어떤 값만 전달할지까지 결정해야 합니다.
LOOKUP TIME · LIFETIME OWNER
짧은 수명 빈은 조회 시점이 재사용을, 호출 경계가 종료 책임을 결정한다
prototype은 컨테이너가 그 정의를 조회할 때마다 새 인스턴스를 만듭니다. 긴 수명의 싱글톤에 직접 주입하면 조회는 싱글톤 생성 때 한 번뿐이므로, 작업마다 새 객체가 필요할 때는 조회와 정리 경계를 호출 코드에 드러내야 합니다.
DIRECT INJECTION · FIXED REFERENCE
싱글톤 필드에는 생성 시점에 해석한 프로토타입 하나가 남는다
싱글톤 생성 시작
컨테이너가
BatchExporter의 생성자 의존성을 한 번 해석합니다.프로토타입 조회
ExportJob하나를 만들고 의존성 주입과 초기화 callback을 완료합니다.필드에 참조 저장
생성자에 전달된 작업 객체가 싱글톤의 필드에 고정됩니다.
여러 번 호출
첫 번째와 다음
export()가 같은 작업 ID와 같은 객체 상태를 다시 사용합니다.
컨텍스트에서 프로토타입을 따로 조회하면 새 객체가 나오지만, 이미 완성된 싱글톤의 필드가 자동으로 바뀌지는 않습니다.
PROVIDER LOOKUP · EXPLICIT CLOSE
작업을 시작할 때 조회하고 그 호출이 사용 완료 시점까지 소유한다
작업 호출 진입
export()가 작업 하나의 시작·끝 경계를 엽니다.호출별 조회
ObjectProvider.getObject()가 이번 호출을 위한 새ExportJob을 요청합니다.초기화된 객체 사용
컨테이너가 주입과 초기화를 마친 객체로 현재 batch만 처리합니다.
호출자가 즉시 정리
try-with-resources를 빠져나오며 객체의close()를 호출합니다.
컨테이너는 반환한 프로토타입의 전체 수명을 추적하거나 destroy callback을 자동 실행하지 않습니다. 사용 완료를 아는 코드가 정리를 책임집니다.
DESIGN RULE · MAKE THE BOUNDARY VISIBLE
스코프는 생성·재사용 범위를 정하고 자원 소유권은 별도로 표현한다
값과 상태만 필요한 짧은 작업
일반 생성자나 도메인 factory로 만들고 메서드의 지역 변수와 명시적 인자로 수명을 보입니다.
Spring DI·초기화가 필요한 작업
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와 진행 값만 가진 평문 객체에 의존성 주입이나 컨테이너 콜백이 필요 없다면 도메인 팩토리가 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가 아니라 현재 호출 스레드의 활성 요청에서 대상을 찾는 프록시를 보관합니다. 요청이 끝난 뒤 실행할 작업에는 프록시나 서블릿 요청을 넘기지 않고, 요청 안에서 읽은 불변 값만 복사합니다.
ACTIVE REQUEST · TARGET RESOLUTION
같은 요청은 같은 대상을 공유하고 다음 요청은 새 대상을 만든다
싱글톤 구성
RequestAudit필드에는 클래스 기반 요청 스코프 프록시가 한 번 주입됩니다.Request A 활성화
첫
trace.id()호출이 A에 속한RequestTrace대상을 만들고 찾습니다.A 안에서 재호출
컨트롤러와 다른 웹 컴포넌트가 같은 요청 대상과 같은 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 상관관계를 유지합니다.
요청 스코프의 공유 단위는 현재 HTTP 요청입니다. 그 경계를 넘는 순간에는 객체 그래프가 아니라 필요한 값만 명시적 메시지로 바꿉니다.
명시적 값 전달을 먼저 검토합니다
스코프 선택 기준은 “객체를 새로 만들고 싶다”보다 값의 실제 수명입니다.
| 실제 수명 | 우선 표현 | Spring 스코프가 맞는 경우 |
|---|---|---|
| 메서드 호출 | 파라미터·지역 변수 | 거의 필요 없음 |
| 도메인 작업 하나 | 평문 팩토리·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()를 모두 수행합니다.
다음 장에서는 이 컨테이너 경계에서 한 단계 내려가 DNS 이름 해석과 IP·TCP·UDP, TLS, HTTP가 브라우저 요청을 서버까지 운반하는 경로를 검증합니다.