요청 객체를 전역 필드에 두면 무엇이 달라질까요?
Servlet 컨테이너는 여러 HTTP 요청을 서로 다른 스레드에서 동시에 처리할 수 있습니다. 각 요청의 HttpServletRequest는 해당 요청의 파라미터와 헤더, 속성을 담고 있습니다. 따라서 요청이 끝날 때까지 그 요청의 범위 안에서 사용해야 합니다.
호출 코드를 줄이려고 요청 객체를 공통 유틸의 static 필드에 저장하면 이 범위가 깨집니다. 모든 요청이 같은 필드 하나를 읽고 쓰기 때문에, 먼저 들어온 요청이 자신이 등록한 객체를 계속 사용한다는 보장이 없어집니다.
아래 코드는 동시 요청에서 값이 섞이는 과정을 설명하기 위해 만든 일반 기술 예시입니다. 특정 시스템의 소스나 장애 기록은 아닙니다.
public final class RequestParams {
private static HttpServletRequest request;
public static void bind(HttpServletRequest currentRequest) {
request = currentRequest;
}
public static String get(String name) {
return request.getParameter(name);
}
}RequestParams.get("orderId")처럼 짧게 호출할 수 있지만, 호출부에서는 어떤 요청 객체를 읽는지 알 수 없습니다. 편의 메서드가 요청 객체의 소유자와 생명주기를 감춘 셈입니다.
두 요청의 실행 순서가 겹치면 값이 바뀝니다
요청 A와 B가 거의 동시에 실행되는 상황을 생각해 보겠습니다.
| 시점 | 요청 A | 요청 B | 공유 필드 |
|---|---|---|---|
| T1 | A의 request 등록 | — | A |
| T2 | 다음 로직 수행 | B의 request 등록 | B |
| T3 | orderId 조회 | — | B |
| T4 | B의 값을 읽을 수 있음 | orderId 조회 | B |
요청 A가 bind()를 호출한 뒤 get()을 호출하기 전에 B가 필드를 바꾸면, A는 B의 파라미터를 읽을 수 있습니다. 값이 우연히 같으면 문제를 알아채기 어렵고, 필요한 파라미터가 B에 없을 때만 간헐적인 오류처럼 보일 수도 있습니다.
volatile을 붙여도 해결되지 않습니다. 최신 참조를 보게 할 뿐, 각 요청이 자신이 등록한 참조를 유지하도록 만들지는 못합니다. synchronized로 개별 메서드만 잠가도 bind()와 get() 사이에 다른 요청이 들어올 수 있습니다. 두 호출 전체를 잠그면 요청 처리를 사실상 직렬화하게 됩니다.
요청마다 바뀌는 값을 공유한 것이 문제였습니다
상태를 저장하지 않는 static 메서드는 여러 스레드가 함께 사용해도 요청 객체를 섞지 않습니다.
public static String get(
HttpServletRequest request,
String name) {
return request.getParameter(name);
}위 메서드는 필요한 객체를 인자로 받고 내부에 보관하지 않습니다. 각 호출이 어느 요청에 의존하는지 코드에도 드러납니다.
반대로 필드가 static이 아니어도 공유 객체에 요청 상태를 저장하면 같은 문제가 생길 수 있습니다. 기본 범위의 Spring Bean은 Singleton이므로, 다음처럼 멤버 변수에 요청 값을 보관하는 서비스도 여러 요청이 상태를 공유합니다.
@Service
public class OrderService {
private String currentOrderId;
public void prepare(String orderId) {
this.currentOrderId = orderId;
}
}요청마다 달라지는 값을 여러 요청이 접근하는 공간에 저장하는지 확인해야 합니다.
요청 값은 진입점에서 꺼내 명시적으로 전달합니다
Spring MVC를 사용한다면 Controller에서 필요한 값을 바인딩하고 서비스에는 DTO나 값만 전달할 수 있습니다.
@GetMapping("/orders/{orderId}")
public OrderResponse getOrder(
@PathVariable String orderId,
@RequestParam String locale) {
return orderService.find(orderId, locale);
}이 방식에는 몇 가지 장점이 있습니다.
- 서비스가 Servlet API에 의존하지 않습니다.
- 메서드가 어떤 입력을 사용하는지 시그니처에서 확인할 수 있습니다.
- 단위 테스트에서 요청 객체를 만들지 않아도 됩니다.
- 요청별 값이 공유 필드에 남지 않습니다.
요청 객체 자체가 필요한 낮은 수준의 처리라면 메서드 인자로 HttpServletRequest를 전달할 수 있습니다. 여러 값이 함께 움직인다면 불변 DTO로 묶는 편이 좋습니다.
요청 범위 기능도 사용 조건을 확인해야 합니다
Spring의 request scope나 RequestContextHolder는 요청별 컨텍스트를 다룰 수 있습니다. 하지만 전역 필드를 단순히 다른 저장소로 바꾸는 만능 해법은 아닙니다.
- request scope Bean은 HTTP 요청 밖의 작업에서 사용할 수 없습니다.
RequestContextHolder는 의존성을 호출부에서 숨길 수 있습니다.- 비동기 작업은 원래 요청 스레드의 컨텍스트를 자동으로 이어받는다고 가정하면 안 됩니다.
ThreadLocal을 직접 사용한다면 스레드 풀 재사용 전에 반드시 값을 제거해야 합니다.
가능하면 Controller에서 필요한 값을 꺼내 명시적으로 전달하고, 요청 컨텍스트가 꼭 필요한 경계에서만 프레임워크 기능을 제한적으로 사용하는 편이 안전합니다.
동시 요청으로 불변 조건을 검사합니다
순차 테스트만으로는 공유 상태 문제를 찾기 어렵습니다. 요청마다 다른 식별값을 보내고 응답이 자신의 입력과 일치하는지 병렬로 반복 확인해야 합니다.
@Test
void concurrent_requests_keep_their_own_parameter() throws Exception {
var ids = IntStream.range(0, 100)
.mapToObj(index -> "order-" + index)
.toList();
var responses = parallelRequester.getAll(ids);
assertThat(responses)
.allSatisfy(response ->
assertThat(response.orderId()).isEqualTo(response.requestedOrderId())
);
}이 코드는 검증 방식을 보여 주기 위한 의사 코드입니다. 실제 테스트에서는 동시에 시작하도록 barrier를 두거나 부하 테스트 도구를 사용하고, 각 요청에 고유한 추적값을 넣어 요청과 응답을 대조할 수 있습니다.
다음 조건도 함께 확인해야 합니다.
- 요청 수와 실행 순서를 바꿔도 값이 섞이지 않습니까?
- 예외가 발생한 요청의 상태가 다음 요청에 남지 않습니까?
- 비동기 처리로 넘어갈 때 필요한 값이 명시적으로 전달됩니까?
- 테스트가 한 번 성공한 것이 아니라 반복 실행에서도 같은 결과를 냅니까?
디버거는 공유 필드가 바뀌는 지점을 이해하는 데 도움을 줄 수 있지만 스레드 실행 순서에도 영향을 줍니다. 한 번 관찰한 순서만으로 결론 내리기보다 병렬 테스트로 불변 조건을 반복 검증해야 합니다.
상태의 소유자와 생명주기를 코드에 드러냅니다
공통 유틸은 반복 코드를 줄일 수 있지만, 요청별 상태까지 안에 보관하면 의존성과 생명주기가 보이지 않게 됩니다. 요청 데이터는 요청 범위에 두고, 공유 서비스와 유틸은 가능한 한 무상태로 유지하는 편이 안전합니다.
같은 원칙이 Spring Singleton 서비스의 멤버 변수에 어떻게 적용되는지는 Spring Singleton 서비스에 요청 데이터를 멤버 변수로 두면 생기는 일에서 살펴볼 수 있습니다.