• spring
  • java
  • concurrency

싱글톤 빈의 동시성 문제와 ThreadLocal

싱글톤 객체의 필드를 여러 쓰레드가 공유할 때 생기는 동시성 문제를 재현하고 ThreadLocal로 해결하는 방법과 주의점을 정리한다.

시리즈 · Spring14 / 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

클라이언트 요청은 한 번에 하나씩 오지 않고 동시다발적으로 들어온다. 스프링 애플리케이션에서는 톰캣이 미리 만들어 둔 쓰레드가 요청을 하나씩 맡아 처리한다. 이때 여러 쓰레드가 같은 객체의 필드를 공유하면 동시성 문제가 생긴다.

동시성 문제 재현

값을 저장했다가 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다. 원인은 다음과 같다.

  1. FieldService 인스턴스는 하나뿐이다.
  2. 따라서 nameStore 필드도 하나이고, 여러 쓰레드가 동시에 접근할 수 있다.
  3. thread-A가 값을 저장하고 조회하기 전에 thread-B가 nameStore를 덮어썼다.

ThreadLocal

이 문제는 쓰레드마다 별도의 저장 공간을 할당하면 해결된다. 자바는 이를 위해 ThreadLocal 클래스를 제공한다.

내부 구조

Thread 클래스는 ThreadLocal.ThreadLocalMap 타입의 필드를 가지고 있다.

public class Thread implements Runnable {
    // ...
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

ThreadLocalMap은 ThreadLocal 인스턴스를 key로, 저장한 값을 value로 하는 맵이다. 값이 ThreadLocal 객체 안이 아니라 각 쓰레드 객체 안에 저장되는 구조다.

Thread가 가진 ThreadLocalMap에 ThreadLocal 참조를 key로, 값을 value로 저장하는 구조

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()로 값을 제거해야 한다.

참고

  • 김영한, 스프링 핵심 원리 - 고급편 (인프런)