Spring MVC 프레임워크에서 컨트롤러(Controller) 빈은 기본적으로 싱글톤(Singleton) 스코프로 관리됩니다. 애플리케이션 컨텍스트가 초기화될 때 단 하나의 인스턴스만 생성되며, 들어오는 모든 HTTP 요청은 이 동일한 인스턴스를 공유하게 됩니다.
싱글톤 스코프를 채택한 이유
- 성능 및 리소스 효율성: 매 요청마다 새로운 객체를 생성하고 가비지 컬렉션(GC)을 유발하는 오버헤드를 방지합니다.
- 초기화 비용 절감: 의존성 주입(DI) 및 초기화 로직을 단 한 번만 수행하면 됩니다.
- 무상태(Stateless) 아키텍처 지향: 수평적 확장이 용이한 RESTful 설계 원칙과 자연스럽게 부합합니다.
인스턴스 변수로 인한 동시성 문제
싱글톤 컨트롤러 내부에 변경 가능한 상태(Mutable State)를 필드로 선언하면, 다중 스레드 환경에서 심각한 데이터 무결성 문제가 발생합니다.
@RestController
@RequestMapping("/api/traffic")
public class TrafficMonitorController {
// 공유되는 인스턴스 변수 (동시성 위험)
private long requestVolume = 0;
@PostMapping("/record")
public String recordAccess() {
// 원자적이지 않은 연산으로 인한 경쟁 상태(Race Condition) 발생
requestVolume++;
return "Total accesses: " + requestVolume;
}
}
여러 스레드가 동시에 requestVolume++ 연산을 수행할 경우, 값이 덮어쓰여져 정확한 트래픽 집계가 불가능해집니다.
스레드 안전성을 확보하는 4가지 전략
1. 무상태(Stateless) 설계 원칙 준수 (권장)
가장 근본적이고 권장되는 방식은 컨트롤러 클래스 내에 어떠한 인스턴스 변수도 선언하지 않는 것입니다. 모든 데이터는 메서드의 매개변수나 지역 변수로 처리해야 합니다.
@RestController
@RequestMapping("/api/orders")
public class OrderProcessingController {
private final OrderService orderService;
public OrderProcessingController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping("/checkout")
public ResponseEntity<String> handleCheckout(@RequestParam Long orderId) {
// 지역 변수 사용 (스레드 안전)
OrderResult result = orderService.finalizePayment(orderId);
return ResponseEntity.ok("Checkout status: " + result.getStatus());
}
}
이때 주입받는 OrderService와 같은 의존성 객체들 역시 내부적으로 스레드 안전해야 합니다.
2. 동시성 컬렉션 및 원자적 클래스 활용
상태를 반드시 유지해야 하는 경우, java.util.concurrent 패키지에서 제공하는 스레드 안전 클래스를 사용합니다.
@RestController
@RequestMapping("/api/metrics")
public class MetricsCollectorController {
// LongAdder는 높은 경합 환경에서 AtomicInteger보다 뛰어난 성능을 제공합니다.
private final LongAdder activeSessions = new LongAdder();
@GetMapping("/sessions")
public String getActiveSessions() {
activeSessions.increment();
return "Current active sessions: " + activeSessions.sum();
}
}
3. ThreadLocal을 통한 스레드 격리
요청 처리 전반에 걸쳐 특정 데이터를 전달해야 할 때 ThreadLocal을 사용하면 각 스레드마다 독립적인 저장소를 할당할 수 있습니다.
@RestController
@RequestMapping("/api/audit")
public class AuditContextController {
private final ThreadLocal<String> traceIdContext = new ThreadLocal<>();
@PostMapping("/init")
public String initTrace(@RequestHeader("X-Trace-Id") String traceId) {
traceIdContext.set(traceId);
return "Trace initialized";
}
@GetMapping("/log")
public String writeLog() {
String currentTraceId = traceIdContext.get();
// 로깅 작업 수행
return "Logged with trace: " + currentTraceId;
}
}
주의사항: ThreadLocal은 스레드 풀 환경에서 재사용될 경우 데이터 오염을 일으킬 수 있으므로, 요청이 종료되는 시점(Interceptor의 afterCompletion 등)에서 반드시 remove() 메서드를 호출하여 메모리 누수를 방지해야 합니다.
4. 프로토타입(Prototype) 스코프로 변경
컨트롤러의 스코프를 프로토타입으로 변경하여 매 요청마다 새로운 인스턴스를 생성하도록 강제할 수 있습니다.
@RestController
@RequestMapping("/api/cart")
@Scope(value = WebApplicationContext.SCOPE_PROTOTYPE)
public class StatefulLegacyController {
private int userCartSize = 0;
@PostMapping("/add")
public String addItem() {
userCartSize++;
return "Cart size: " + userCartSize;
}
}
이 방식은 스레드 안전성을 보장하지만, 객체 생성 및 GC 비용을 증가시키고 Spring의 기본 최적화 이점을 포기하게 되므로 레거시 코드 호환 등 특수한 상황이 아니라면 권장되지 않습니다.
Spring MVC의 기본 스레드 안전 컴포넌트
프레임워크 차원에서 요청마다 새로 생성되거나 격리되어 제공되는 객체들은 안전하게 주입받아 사용할 수 있습니다.
| 컴포넌트 | 스레드 안전성 보장 메커니즘 |
|---|---|
Model / ModelMap |
요청 처리가 시작될 때마다 새로운 인스턴스가 생성되어 핸들러에 전달됨 |
HttpServletRequest |
웹 컨테이너(Tomcat 등)가 요청마다 독립적인 객체를 생성 |
HttpServletResponse |
요청에 종속되는 독립적인 응답 객체로 생성 |
BindingResult |
특정 요청의 유효성 검사 결과를 담는 요청 수준(Request-scoped) 객체 |