싱글톤 빈의 동시성 문제와 ThreadLocal
싱글톤 객체의 필드를 여러 쓰레드가 공유할 때 생기는 동시성 문제를 재현하고 ThreadLocal로 해결하는 방법과 주의점을 정리한다.
시리즈 · Spring14 / 14
- SOLID 원칙과 OCP·DIP를 지키는 방법
- 의존성 주입(DI)과 스프링 컨테이너
- 쓰레드 풀과 싱글톤 서블릿의 동시성 문제
- FrontController 패턴과 핸들러 어댑터로 스프링 MVC 구조 이해하기
- @ModelAttribute와 PRG 패턴으로 폼 요청 처리하기
- System.out 대신 SLF4J로 로그 남기기
- 세션 직접 만들어 보기와 HttpSession
- 스프링 인터셉터와 예외 발생 시 오류 페이지 흐름
- 서블릿 Part와 MultipartFile로 파일 업로드 처리하기
- JDBC로 CRUD 구현하기와 커넥션 풀, DataSource
- JDBC 트랜잭션에서 스프링 트랜잭션 동기화와 트랜잭션 AOP까지
- JPA 변경 감지(dirty checking)와 병합(merge)
- 서블릿 컨테이너 초기화와 스프링 컨테이너 등록 과정
- 싱글톤 빈의 동시성 문제와 ThreadLocal
클라이언트 요청은 한 번에 하나씩 오지 않고 동시다발적으로 들어온다. 스프링 애플리케이션에서는 톰캣이 미리 만들어 둔 쓰레드가 요청을 하나씩 맡아 처리한다. 이때 여러 쓰레드가 같은 객체의 필드를 공유하면 동시성 문제가 생긴다.
동시성 문제 재현
값을 저장했다가 1초 뒤에 조회하는 서비스가 있다. 테스트라서 @Service를 붙이지 않았지만, 스프링 빈으로 등록되어 싱글톤으로 쓰인다고 가정한다.
@Slf4j
public class FieldService {
private String nameStore;
public String logic(String name) {
log.info("저장 name={} -> nameStore={}", name, nameStore);
nameStore = name;
sleep(1000);
log.info("조회 nameStore={}", nameStore);
return nameStore;
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
FieldService는 인스턴스 변수로 nameStore를 가지고 있다. 두 쓰레드가 하나의 FieldService를 0.1초 간격으로 호출해 본다.
@Slf4j
public class FieldServiceTest {
private final FieldService fieldService = new FieldService();
@Test
void field() {
log.info("main start");
Runnable userA = () -> fieldService.logic("userA");
Runnable userB = () -> fieldService.logic("userB");
Thread threadA = new Thread(userA);
threadA.setName("thread-A");
Thread threadB = new Thread(userB);
threadB.setName("thread-B");
threadA.start();
sleep(100); // A의 로직(1초)이 끝나기 전에 B를 시작한다.
threadB.start();
sleep(3000); // 메인 쓰레드 종료 대기
log.info("main exit");
}
// sleep()은 FieldService와 동일
}
userA와 userB가 각자 저장한 값을 그대로 조회하기를 기대한다.
main start
저장 name=userA -> nameStore=null
조회 nameStore=userA
저장 name=userB -> nameStore=userA
조회 nameStore=userB
main exit
실제 결과는 다르다.
main start
저장 name=userA -> nameStore=null
저장 name=userB -> nameStore=userA
조회 nameStore=userB
조회 nameStore=userB
main exit
thread-A가 userA를 저장했는데 조회 결과는 userB다. 원인은 다음과 같다.
FieldService인스턴스는 하나뿐이다.- 따라서
nameStore필드도 하나이고, 여러 쓰레드가 동시에 접근할 수 있다. - thread-A가 값을 저장하고 조회하기 전에 thread-B가
nameStore를 덮어썼다.
ThreadLocal
이 문제는 쓰레드마다 별도의 저장 공간을 할당하면 해결된다. 자바는 이를 위해 ThreadLocal 클래스를 제공한다.
내부 구조
Thread 클래스는 ThreadLocal.ThreadLocalMap 타입의 필드를 가지고 있다.
public class Thread implements Runnable {
// ...
ThreadLocal.ThreadLocalMap threadLocals = null;
}
ThreadLocalMap은 ThreadLocal 인스턴스를 key로, 저장한 값을 value로 하는 맵이다. 값이 ThreadLocal 객체 안이 아니라 각 쓰레드 객체 안에 저장되는 구조다.

ThreadLocal의 get(), set(), remove()는 이 threadLocals를 간편하게 다루기 위한 메서드다. get()을 보면 현재 쓰레드를 가져와 그 쓰레드의 맵에서 자기 자신(this)을 key로 값을 꺼낸다.
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();
}
적용
@Slf4j
public class ThreadLocalService {
private ThreadLocal<String> nameStore = new ThreadLocal<>();
public String logic(String name) {
log.info("저장 name={} -> nameStore={}", name, nameStore.get());
nameStore.set(name);
sleep(1000);
log.info("조회 nameStore={}", nameStore.get());
return nameStore.get();
}
// sleep()은 FieldService와 동일
}
nameStore 필드는 여전히 하나지만, 각 쓰레드는 자신만 접근할 수 있는 저장 공간에서 값을 읽고 쓰므로 동시성 문제가 사라진다.
주의 사항: 반드시 remove()
톰캣은 요청마다 쓰레드를 새로 만들지 않고 쓰레드 풀에 미리 만들어 둔 쓰레드를 재사용한다. 쓰레드가 풀에 반납되어도 그 쓰레드의 ThreadLocalMap에 저장된 값은 사라지지 않는다.
따라서 요청 처리가 끝날 때 remove()를 호출하지 않으면, 같은 쓰레드를 할당받은 다음 사용자의 요청이 이전 사용자가 저장한 값을 조회하는 문제가 발생할 수 있다. ThreadLocal을 사용했다면 사용이 끝난 시점에 반드시 remove()로 값을 제거해야 한다.
참고
- 김영한, 스프링 핵심 원리 - 고급편 (인프런)