• spring
  • concurrency
  • servlet

쓰레드 풀과 싱글톤 서블릿의 동시성 문제

WAS의 쓰레드 풀이 필요한 이유를 정리하고, 서블릿 멤버 변수에 model을 두면 왜 안 되는지 직접 부딪혀 본 기록.

시리즈 · Spring3 / 14
  1. SOLID 원칙과 OCP·DIP를 지키는 방법
  2. 의존성 주입(DI)과 스프링 컨테이너
  3. 쓰레드 풀과 싱글톤 서블릿의 동시성 문제
  4. FrontController 패턴과 핸들러 어댑터로 스프링 MVC 구조 이해하기
  5. @ModelAttribute와 PRG 패턴으로 폼 요청 처리하기
  6. System.out 대신 SLF4J로 로그 남기기
  7. 세션 직접 만들어 보기와 HttpSession
  8. 스프링 인터셉터와 예외 발생 시 오류 페이지 흐름
  9. 서블릿 Part와 MultipartFile로 파일 업로드 처리하기
  10. JDBC로 CRUD 구현하기와 커넥션 풀, DataSource
  11. JDBC 트랜잭션에서 스프링 트랜잭션 동기화와 트랜잭션 AOP까지
  12. JPA 변경 감지(dirty checking)와 병합(merge)
  13. 서블릿 컨테이너 초기화와 스프링 컨테이너 등록 과정
  14. 싱글톤 빈의 동시성 문제와 ThreadLocal

쓰레드

  • 쓰레드는 코드를 순차적으로 하나씩 실행한다. 한 번에 한 줄의 코드만 수행한다.
  • 동시 처리가 필요하면 쓰레드를 추가로 생성해야 한다.

요청이 올 때마다 쓰레드를 생성하는 방식에는 단점이 있다.

  • 쓰레드 생성 비용이 매우 비싸다. 요청마다 생성하면 응답 속도가 늦어진다.
  • 컨텍스트 스위칭 비용이 발생한다.
  • 쓰레드 생성에 제한이 없으므로 CPU, 메모리 임계점을 넘어 서버가 다운될 수 있다.

쓰레드 풀

필요한 쓰레드를 미리 생성해 풀에 보관하고, 생성 가능한 쓰레드의 최대 개수를 관리한다.

  • 쓰레드가 미리 생성되어 있으므로 생성 비용이 절약되고 응답 속도가 개선된다.
  • 최대치가 정해져 있으므로 CPU, 메모리 임계점을 넘는 상황을 막을 수 있다.

WAS가 요청마다 쓰레드 풀에서 쓰레드를 꺼내 서블릿을 실행하는 구조

최대 쓰레드 수 설정

최대 쓰레드(max thread) 수는 WAS의 주요 튜닝 포인트다.

  • 너무 낮으면 서버 리소스는 여유로운데 응답이 지연된다.
  • 너무 높으면 CPU, 메모리 임계점 초과로 서버가 다운된다.

적정 값은 로직의 복잡도와 서버 리소스에 따라 달라지므로 계산으로 구하기 어렵다. 그래서 주로 성능 테스트를 통해 어느 정도의 요청을 버틸 수 있는지 확인하며 찾는다.

멀티 쓰레드 처리는 WAS가 담당하므로 개발자는 멀티 쓰레드 관련 코드를 신경 쓰지 않아도 된다. 다만 멀티 쓰레드 환경에서 싱글톤 객체(서블릿, 스프링 빈) 는 주의해서 사용해야 한다. 아래는 이 주의점을 직접 겪은 사례다.

서블릿 멤버 변수로 model을 두면 안 되는 이유

FrontController 서블릿 하나로 모든 요청을 받는 MVC 프레임워크를 직접 만들어 보던 중 궁금한 점이 생겼다.

@Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    String requestURI = request.getRequestURI();

    ControllerV4 controller = controllerMap.get(requestURI);
    if (controller == null) {
        response.setStatus(HttpServletResponse.SC_NOT_FOUND);
        return;
    }

    Map<String, String> paramMap = createParamMap(request);
    Map<String, Object> model = new HashMap<>(); // 요청마다 새로 생성
    String viewName = controller.process(paramMap, model); // 컨트롤러가 model.put

    MyView view = viewResolver(viewName);
    view.render(model, request, response);
}

흐름은 다음과 같다.

  1. 요청 파라미터를 paramMap에 저장한다.
  2. model 객체를 생성한다.
  3. 컨트롤러의 process에 paramMap과 model을 전달한다.
  4. 컨트롤러가 paramMap으로 member를 만들고 model.put("member", member)를 호출한다.
  5. render에서 model의 정보를 request에 setAttribute 한다.

의문

model은 데이터를 전달하는 역할만 한다. 그렇다면 요청이 올 때마다 새로 만들지 말고 FrontController의 멤버 변수로 선언해 계속 재사용하면 되지 않을까? 실제로 그렇게 바꿔서 테스트해 보니 잘 동작했다.

답

서블릿 컨테이너에 등록된 서블릿은 싱글톤으로 관리된다. 따라서 멤버 변수로 선언한 model은 모든 요청 쓰레드가 공유하게 되고, 동시성 문제가 생긴다.

시점 요청 동작
1초 A model에 ("Hi", 1) 저장
2초 B model에 ("Hello", 2) 저장
3초 A setAttribute 수행 → A가 저장한 값이 아닌 B가 저장한 값이 사용된다

혼자 테스트할 때는 요청이 겹치지 않아 문제가 보이지 않았을 뿐이다. 요청마다 달라지는 상태는 싱글톤 객체의 필드가 아니라 지역 변수로 다뤄야 한다.

참고

  • 김영한, 스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 (인프런)