Mini
ThreadLocal로 싱글톤에서 발생하는 동시성문제 쉽게 풀기! 본문
문제상황
어느부분에서 병목이 발생하는지, 그리고 어떤 부분에서 예외가 발생하는지를 로그를 통해 확인하기 위해 아래 요구사항에 맞춰 로그 추적기를 만들던 도중 문제가 발생했습니다.
- 요구사항
모든 PUBLIC 메서드의 호출과 응답 정보를 로그로 출력
애플리케이션의 흐름을 변경하면 안됨
로그를 남긴다고 해서 비즈니스 로직의 동작에 영향을 주면 안됨
메서드 호출에 걸린 시간
정상 흐름과 예외 흐름 구분
예외 발생시 예외 정보가 남아야 함
메서드 호출의 깊이 표현
HTTP 요청을 구분
HTTP 요청 단위로 특정 ID를 남겨서 어떤 HTTP 요청에서 시작된 것인지 명확하게 구분이 가능해야 함
트랜잭션 ID (DB 트랜잭션X), 여기서는 하나의 HTTP 요청이 시작해서 끝날 때 까지를 하나의 트랜잭션이
라 함 - 작동예시
정상 요청
[796bccd9] OrderController.request()
[796bccd9] |-->OrderService.orderItem()
[796bccd9] | |-->OrderRepository.save()
[796bccd9] | |<--OrderRepository.save() time=1004ms
[796bccd9] |<--OrderService.orderItem() time=1014ms
[796bccd9] OrderController.request() time=1016ms
예외 발생
[b7119f27] OrderController.request()
[b7119f27] |-->OrderService.orderItem()
[b7119f27] | |-->OrderRepository.save()
[b7119f27] | |<X-OrderRepository.save() time=0ms
ex=java.lang.IllegalStateException: 예외 발생!
[b7119f27] |<X-OrderService.orderItem() time=10ms
ex=java.lang.IllegalStateException: 예외 발생!
[b7119f27] OrderController.request() time=11ms
ex=java.lang.IllegalStateException: 예외 발생!
문제가되는 실행결과는 다음과 같습니다.
상품목록을 연달아 조회했을때, 예상대로 작동되지 않고 트랜잭션ID또한 중복되어 출력되는 문제가 있습니다.
원인분석
원인은 싱글톤인 스프링빈에서 공유자원으로 사용되는 필드에 있었습니다.
FieldLogTrace 는 싱글톤으로 등록된 스프링 빈입니다. 이 객체의 인스턴스가 애플리케이션에 딱 1 존재한다는 뜻입니다.이렇게 하나만 있는 인스턴스의 `FieldLogTrace.traceIdHolder` 필드를 여러 쓰레드가 동시에 접근하기 때문에 문제가 발생합니다.
여러 쓰레드에서 같은 필드를 조회, 수정하므로 예측하기 어렵고 의도와 다르게 작동합니다.
public class FieldLogTrace implements LogTrace {
private TraceId traceIdHolder; //traceId 동기화, 동시성 이슈 발생
...
}
@Bean
public LogTrace logTrace() {
return new FieldLogTrace();
}
해결
이를 해결하는 방법인 ThreadLocal에 대해 살펴 보겠습니다.
ThreadLocal을 사용하면 각 스레드는 자신의 고유한 변수를 저장하는 공간을 갖습니다. 즉, 하나의 인스턴스를 여러 스레드가 공유하더라도, 각 스레드는 별도로 데이터를 보관합니다.
스레드-로컬 맵 구조
내부적으로, 자바의 각 Thread 객체는 ThreadLocalMap이라는 맵을 가지고 있습니다. 이 맵은 ThreadLocal 객체와 해당 값(value)을 연결합니다. 따라서 get, set 메서드를 호출할 때마다, 현재 스레드의 ThreadLocalMap에 접근하여 값을 읽거나 씁니다.
- get() 호출
스레드의 ThreadLocalMap에서 현재 ThreadLocal 인스턴스를 키로 사용하여 값을 찾습니다. - set() 호출
현재 스레드의 ThreadLocalMap에 값을 저장합니다. 다른 스레드는 자신의 ThreadLocalMap에서 별도로 값을 관리하므로, 동일한 ThreadLocal 객체를 공유하더라도 서로 다른 값을 가질 수 있습니다.
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
그림으로 살펴보면 다음과 같습니다.
구현방법은 싱글톤 빈의 필드를 ThreadLocal로 선언하고, ThreadLocal 의 get - set 함수를 통해 데이터를 조회하고 수정하도록 작성하면 됩니다.
ThreadLocal로 필드를 대체한 결과, 의도한 결과를 얻을 수 있었습니다.
로그를 분리해서 확인해보면 쓰레드별로 로그가 정확히 나누어 진것을 확인 할 수 있습니다.
주의사항
WAS는 쓰레드풀을 사용하기 때문에, 사용자 A의 요청이 종료된후, 사용자 B의 요청이 들어올때, 사용자A의 쓰레드를 재사용하게 됩니다. 이때, 사용자 A의 데이터가 쓰레드로컬에 남아있기때문에 사용자B가 사용자A의 정보를 얻게되는 심각한 문제가 발생 할 수 있습니다.
그림으로 살펴보겠습니다.
이런 문제를 예방하려면 사용자A의 요청이 끝날 때 쓰레드 로컬의 값을 ThreadLocal.remove() 를 통해서 꼭 제
거해야 줘야 합니다.
정리
스프링의 빈은 싱글톤이기 때문에 싱글톤빈에 상태를 보관하는 것을 지양하도록 합시다.
보관해야한다면, 절대 일반 변수를 사용하면 안되고 쓰레드로컬을 사용해야 합니다.
이런 문제는 현업에서도 자주 발생되고 찾기도 어려운 문제이기 때문에 꼭 숙지하도록 합시다.
레퍼런스
https://docs.oracle.com/javase/8/docs/api/java/lang/ThreadLocal.html
ThreadLocal (Java Platform SE 8 )
Returns the current thread's "initial value" for this thread-local variable. This method will be invoked the first time a thread accesses the variable with the get() method, unless the thread previously invoked the set(T) method, in which case the initialV
docs.oracle.com
필드 동기화 - 동시성 문제 | 스프링 핵심 원리 - 고급편
필드 동기화 - 동시성 문제
www.inflearn.com
'기술블로그' 카테고리의 다른 글
No Offset 으로 페이징 성능 개선하기 (0) | 2025.05.01 |
---|---|
@ExceptionHandler로 예외처리문제 쉽게 풀기! (0) | 2025.04.12 |
비관적 락으로 재고감소 로직에서 발생하는 '동시성 문제' 쉽게 풀기! (0) | 2025.04.03 |