트랜잭션·데이터 정합성 · Java Design

동시에 요청하면 결과가 뒤바뀌었다: static HttpServletRequest가 만든 동시성 버그

요청 파라미터 접근을 줄이기 위해 HttpServletRequest를 static 필드에 보관한 유틸에서 동시 요청 간 상태가 덮어써진 원인을 재현하고 추적한 과정

JavaServletConcurrencyDebuggingLegacy System

문제의 출발점

초기 경력 2~3년 차에 유지보수하던 Java 웹 시스템에는 request.getParameter() 호출을 간단하게 사용하기 위한 공통 유틸이 있었다.

매번 HttpServletRequest를 전달하지 않아도 파라미터를 조회할 수 있도록 요청 객체를 유틸 클래스에 한 번 등록하고, 이후에는 축약된 메서드만 호출하는 구조였다. 단일 요청만 처리할 때는 별다른 문제가 없어 보였지만, 여러 요청이 동시에 들어오면 일부 요청이 정상적으로 처리되지 않거나 서로 다른 요청의 결과가 뒤바뀐 것처럼 보이는 현상이 발생했다.

간헐적인 현상을 단순한 업무 조건 문제로 넘기지 않고, 개별 요청과 동시 요청의 차이를 재현 조건으로 삼았다. 요청 객체를 등록하고 다시 조회하는 흐름을 브레이크포인트로 추적한 끝에, 여러 요청이 공유하는 static 필드가 덮어써지는 구조를 원인으로 확인했다.

직접 수행한 범위는 현상 재현, 동시 요청 조건 식별, 브레이크포인트를 이용한 실행 흐름 추적, static 공유 상태가 원인임을 확인한 단계까지다. 초기 경력 사례라는 이유로 이 진단 범위를 축소하지 않지만, 프로젝트 전체를 주도했거나 최종 수정·운영 배포까지 수행했다고 확대하지도 않는다. 아래의 개선 방법과 검증 항목은 일반적인 기술 방향이다.

호출 편의 뒤에 숨어 있던 공유 상태

아래 코드는 실제 사내 소스가 아니라 당시 구조를 설명하기 위해 단순화한 의사 코드다.

// 요청별 객체를 전역 공유 상태에 저장
private static HttpServletRequest request;

public static void bind(HttpServletRequest currentRequest) {
    request = currentRequest;
}

public static String getParameter(String name) {
    return request.getParameter(name);
}

호출 코드는 짧아졌지만 요청마다 달라야 하는 HttpServletRequest가 모든 요청 스레드에서 공유하는 static 필드에 저장됐다.

호출부에서는 request가 어디에서 왔고 언제 등록됐는지 보이지 않는다. 공통 유틸이 편의를 제공하는 동시에 위험한 공유 상태와 의존성을 감춘 것이다.

단일 요청과 동시 요청의 차이

한 번에 하나의 요청만 처리하면 다른 요청이 필드를 덮어쓰지 않기 때문에 문제가 드러나지 않는다. 하지만 Servlet 컨테이너는 여러 요청을 서로 다른 스레드에서 동시에 처리할 수 있다.

단일 요청과 동시 요청의 차이 데이터 표
시점요청 A요청 Bstatic request
T1A의 request 등록A
T2다음 로직 수행B의 request 등록B
T3파라미터 조회B
T4B의 값으로 후속 처리될 수 있음파라미터 조회B

요청 A가 자신이 등록한 객체를 사용한다고 생각하더라도, 실제 조회 시점에는 필드가 요청 B의 객체로 교체되어 있을 수 있다. 따라서 요청 A가 B의 파라미터를 읽거나 필요한 값이 없다고 판단해 처리를 중단하는 상황이 발생할 수 있다.

브레이크포인트로 등록과 조회 흐름을 추적하다

개별적으로 요청하면 정상적으로 처리되고, 한 번에 여러 요청을 보내면 이상 현상이 발생한다는 공통점이 있었다.

이 차이를 기준으로 요청 객체를 등록하는 지점과 파라미터를 읽는 지점에 브레이크포인트를 설정해 흐름을 추적했다. 그 결과 한 요청의 처리가 끝나기 전에 다른 요청이 static 필드의 값을 교체하는 구조를 확인할 수 있었다.

문제는 getParameter() 자체가 아니라 다음 조건의 결합이었다.

  • 요청별로 달라지는 객체를 공유 필드에 저장했다.
  • 여러 요청 스레드가 해당 필드를 동시에 사용했다.
  • 객체 등록과 파라미터 조회 사이의 실행 순서를 보장할 수 없었다.

브레이크포인트는 실행 순서에 영향을 줄 수 있지만, 공유 상태가 변경되는 지점을 확인하고 가설을 세우는 데 유용했다. 여러 요청을 함께 보내 동일한 조건에서 현상이 발생하는지도 반복해서 확인하며 원인을 좁혔다.

문제는 static 메서드가 아니라 전역 가변 상태였다

다음처럼 입력값만 받아 결과를 반환하고 내부 상태를 저장하지 않는 static 메서드는 요청별 가변 상태를 공유하지 않는다.

// 필요한 요청 객체를 명시적으로 전달
public static String getParameter(
        HttpServletRequest request,
        String name) {

    return request.getParameter(name);
}

문제의 핵심은 static 메서드가 아니라 static 필드에 요청별 가변 상태를 저장한 것이다.

Spring Singleton 객체나 다른 공용 객체의 인스턴스 필드에 HttpServletRequest를 저장해도 같은 유형의 문제가 발생할 수 있다. 필드가 static이 아니더라도 그 객체를 여러 요청이 공유한다면 요청별 상태가 서로 덮어쓸 수 있다.

프레임워크 기능을 우회하면서 생명주기까지 감춰졌다

목적이 request.getParameter() 호출을 줄이는 것이었다면 요청 객체를 전역 상태로 만들 필요는 없었다. Spring MVC는 요청 파라미터 바인딩과 요청 범위 객체 관리를 위한 기능을 제공한다.

상황에 따라 다음과 같은 구조를 선택할 수 있다.

  • Controller 메서드의 @RequestParam으로 필요한 값을 직접 바인딩한다.
  • command object 또는 DTO로 관련 요청 값을 묶어 받는다.
  • 요청 객체 자체가 필요하다면 메서드 인자로 HttpServletRequest를 전달한다.
  • 요청 범위 상태가 꼭 필요하다면 request scope로 생명주기를 제한한다.
  • 서비스와 유틸에는 요청 객체 전체가 아니라 실제로 필요한 값만 명시적인 인자로 전달한다.
  • 레거시 제약으로 직접 전달이 어려운 경우에만 RequestContextHolder를 제한적으로 검토한다.
@GetMapping("/example")
public String example(
        @RequestParam String parameterName) {

    return service.process(parameterName);
}

이 선택지를 당시 시스템에 모두 적용했다는 의미는 아니다. 일반적인 대안과 개선 방향을 비교한 것이다.

당시 공통 유틸은 프레임워크가 관리하던 요청 객체를 별도의 static 필드에 저장했다. 호출 코드는 짧아졌지만 의존성과 상태의 소유자가 코드에서 보이지 않게 됐고, 그 결과 요청 생명주기와 동시성 안전성까지 깨졌다. 프레임워크의 요청 관리 기능을 우회하면서 생명주기까지 추상화 뒤에 감춰진 셈이다.

RequestContextHolder도 요청 컨텍스트에 대한 의존성을 호출부에서 숨길 수 있고, 비동기 처리나 테스트에서는 별도 주의가 필요하다. 따라서 단순히 기존 static 필드를 바꾸는 만능 대안으로 제시할 수 없다.

무상태 유틸과 명시적인 의존성 전달

더 안전한 방향은 서비스와 유틸을 가능한 한 무상태로 유지하는 것이다. 요청 객체가 꼭 필요한 경계에서는 메서드 인자로 전달하고, 그 아래 계층에는 요청 처리 초기에 추출한 DTO나 필요한 값만 넘긴다.

String value = RequestParamUtil.getParameter(
    request,
    "parameterName"
);

이렇게 하면 요청 객체의 생명주기가 호출 흐름에 드러나고, 하위 로직이 어떤 입력에 의존하는지도 확인하기 쉬워진다. 코드가 조금 길어지더라도 의존성이 명시적으로 보이는 편이 전역 가변 상태를 숨긴 짧은 코드보다 안전할 수 있다.

ThreadLocal이나 synchronized도 단순한 만능 해결책은 아니다. 스레드 풀에서는 정리되지 않은 ThreadLocal 값이 다음 작업에 남을 수 있다. 일부 메서드에만 synchronized를 적용하면 객체 등록부터 사용까지의 전체 흐름을 보호하지 못할 수 있고, 요청 처리를 직렬화해 처리량을 떨어뜨릴 수도 있다. 핵심은 공유 상태를 다른 장치로 감싸는 것이 아니라 요청별 상태를 공유 공간에서 제거하는 것이다.

동시성 문제는 동시 요청으로 검증해야 한다

이런 구조의 개선안을 검증한다면 요청마다 서로 다른 식별값을 사용해 여러 요청을 병렬로 전송하고, 각 응답이 자신에게 전달된 값과 일치하는지 반복해서 확인해야 한다.

  • 요청 A의 입력이 A의 처리에만 사용되는가
  • 요청 B가 들어와도 A의 상태가 변경되지 않는가
  • 반복 실행해도 요청과 결과의 대응 관계가 유지되는가
  • 동시 요청 수와 실행 순서를 바꿔도 같은 불변 조건이 유지되는가

단순히 예외가 발생하지 않는 것만으로는 공유 상태가 제거됐다고 판단할 수 없다. 브레이크포인트 자체가 스레드 실행 순서를 바꿀 수 있으므로, 디버거에서 본 한 번의 순서만으로 결론 내리지 않고 병렬 요청을 반복해 가설을 검증해야 한다.

마무리

이 문제에서 가장 중요한 단서는 “개별 요청은 정상인데 여러 요청이 함께 들어오면 이상해진다”는 현상의 차이였다.

그 차이를 기준으로 실행 흐름을 추적했고, 요청별 객체가 static 필드에 저장되면서 다른 요청에 의해 덮어써지는 구조를 원인으로 확인했다.

공통 유틸은 코드를 줄여주지만 내부에 숨은 상태와 의존성까지 감춰버릴 수 있다. 편리한 축약보다 중요한 것은 상태의 소유자와 생명주기를 코드에서 명확하게 드러내는 것이다. 요청별 상태는 요청 범위 안에 머물러야 하고, 공유 객체는 가능한 한 무상태여야 한다.

같은 원칙이 Spring Singleton 객체의 요청 상태에도 어떻게 적용되는지는 Spring Singleton Service에 요청 데이터를 멤버변수로 두면 생기는 일에서 별도 사례로 정리했다.