본문 바로가기
Spring

Thread Local

by sangyunpark99 2025. 2. 28.
쓰레드 로컬..?
대충 트래잭션 컨텍스트에서 사용한다는 건 들었는데..

 

 

이번 글은 ThreadLocal이 무엇이고, 왜 사용되는지 정리했습니다.

 

Thread Local이란 무엇일까요?

 

Thread Local

스레드 로컬은 각 스레드마다 독립적인 값을 저장할 수 있는 공간을 제공합니다.
같은 변수를 참조해도, 스레드마다 서로 다른 값을 갖게 되는 특징이 있습니다.

즉, 스레드 로컬은 멀티 스레드 환경에서 각 스레드가 독립적인 데이터를 유지할 수 있도록 도와주는 유용한 도구입니다

 

 

Spring에서 Thread Local이 어떻게 사용이 되나요?

 

Spring에서 스레드 로컬 여러 사용법이 있지만 그중 트랜잭션 관리(Transaction Context)를 보겠습니다.

 

Transaction Context

스프링은 요청마다 트랜잭션 정보를 ThreadLocal에 저장해두고, 같은 요청(스레드) 내에서는 언제든 이 정보를 꺼내서 같은 트랜잭션을 유지 합니다. 

 

하나의 트랜잭션은 "한 요청"이 끝날 때까지 같은 커넥션을 유지해야 합니다. 하지만 서비스 메서드가 여러 단계로 나눠져 있고, 서로 다른 곳에서 호출될 수도 있기 때문에, 매번 같은 커넥션을 파라미터로 넘겨주기보다는, ThreadLocal에 트랜잭션 정보를 저장해두고 같은 스레드 안에서는 언제든 그걸 꺼내 쓸 수 있게 관리해줍니다.

 

Spring에서 TransactionSynchronizationManger는 현재 스레드에서 트랜잭션 관련 리소스 동기화 작업을 보관,관리하는 역할입니다.

 

 

아래는 TransactionSynchronization 공식 문서 링크 입니다.

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/transaction/reactive/TransactionSynchronizationManager.html

 

TransactionSynchronizationManager (Spring Framework 6.2.3 API)

Return whether there currently is an actual transaction active. This indicates whether the current context is associated with an actual transaction rather than just with active transaction synchronization. To be called by resource management code that want

docs.spring.io

 

공식문서에서는 TransactionSynchronizationManager에 대해서 다음과 같이 말합니다.

Central delegate that manages resources and transaction synchronizations per subscriber context. To be used by resource management code but not by typical application code.

 

의역하면 "각 요청(스레드)별로 트랜잭션과 관련된 리소스 동기화 작업을 중앙에서 관리하는 유틸성 클래스 입니다.

이 클래스는 스프링 내부의 리소스 관리 코드에서 사용되고, 일반적인 애플리케이션 개발자가 직접 사용할 일은 거의 없습니다."라고 명시되어 있습니다.

 

TransactionSynchronization Manager 내부에서 스레드 로컬을 사용하는 확인해 보겠습니다.

 

아래 링크는 TransactionSynchronization Manager 클래스가 정의된 공식 문서입니다.

https://github.com/spring-projects/spring-framework/blob/main/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java

 

spring-framework/spring-tx/src/main/java/org/springframework/transaction/support/TransactionSynchronizationManager.java at main

Spring Framework. Contribute to spring-projects/spring-framework development by creating an account on GitHub.

github.com

 

public abstract class TransactionSynchronizationManager {

	private static final ThreadLocal<Map<Object, Object>> resources =
			new NamedThreadLocal<>("Transactional resources");

	private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
			new NamedThreadLocal<>("Transaction synchronizations");

	private static final ThreadLocal<String> currentTransactionName =
			new NamedThreadLocal<>("Current transaction name");

	private static final ThreadLocal<Boolean> currentTransactionReadOnly =
			new NamedThreadLocal<>("Current transaction read-only status");

	private static final ThreadLocal<Integer> currentTransactionIsolationLevel =
			new NamedThreadLocal<>("Current transaction isolation level");

	private static final ThreadLocal<Boolean> actualTransactionActive =
			new NamedThreadLocal<>("Actual transaction active");

 

TransactionSynchronizationManger 클래스의 필드 변수에 여러개의 ThreadLocal이 사용됩니다.

 

멀티 스레드에서 Thread Local 

Thread Local은 멀티 스레드 환경에서 각 스레드별 독립적인 저장 공간을 제공한다고 합니다.

이로 인해, 멀티 스레들 환경에서도 각 스레드가 독립적인 데이터를 유지할 수 있도록 도와준다고 합니다.

 

ThreadLocal을 그림을 표현하면 다음과 같습니다.

[이미지 출처] "스프링 핵심 원리 - 고급편" - 김영한

그림을 보면 각 스레드마다(Thread-A, Thread-B) 전용 보관소가 있습니다.

 

 

정말, Thread Local이 멀티 스레드 환경에서 스레드가 독립적인 데이터를 유지할 수 있도록 도와줄까요?

 

하나의 예시 코드로 알아보겠습니다.

@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();
        }
    }
}

 

logic 메서드의 로직은 nameStore에 전달받은 name을 저장하고, 1초 동안 sleep한 후, nameStore에 저장된 name 값을 출력하는 로직입니다.

 

 

멀티 스레드 환경에서 테스트 하는 코드는 다음과 같습니다.

@Slf4j
public class FieldServiceTest {

    private FieldService fieldService = new FieldService();

    @Test
    void field() {
        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);
        threadB.start();

        sleep(3000);
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

두개의 스레드가 FieldService 클래스의 필드 변수인 nameStore를 공유하기 때문에, 동시성 문제가 발생할 수 있습니다.

테스트 코드의 결과를 확인해 보겠습니다.

@Slf4j
public class FieldServiceTest {

    private 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);
        threadB.start();

        sleep(3000);
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

 

조회 부분을 보면 [thread-A]와 [thread-B] userB로 같게 나옵니다.

의도한 바는 [thread-A]와 [thread-B]의 조회 결과가 각각 userA, userB가 나오길 기대했는데 둘 다 userB가 나왔습니다.

 

왜 이런 결과가 나와게 된 것일까요?

 

그림을 통해 확인해 보겠습니다.

빨간색 점선 부분에서 동시성 문제가 발생하는 부분입니다.

Thread-A는 nameStore에 userA라는 데이터를 저장하고, 1초동안 sleep을 하는 동안에 Thread-B가 nameStore의 값을 userB로 변경하게 됩니다. 따라서, nameStore를 조회하는 경우 두 스레드 전부 userB 문자가 출력되게 됩니다.

 

Thread Local을 사용해서 이 문제를 해결해보겠습니다.

 

 

먼저, ThreadLocalService 코드에 Thread Local 클래스로 nameStore 필드 변수를 선언합니다.

@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();
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

테스트 코드는 아래와 같습니다.

@Slf4j
public class ThreadLocalServiceTest {

    private ThreadLocalService service = new ThreadLocalService();

    @Test
    void field() {
        Runnable userA = () -> {
            service.logic("userA");
        };
        Runnable userB = () -> {
            service.logic("userB");
        };

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A");
        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
        sleep(100);
        threadB.start();

        sleep(3000);
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

출력 결과는 다음과 같습니다.

 

[thread-A]와 [thread-B]의 두 스레드에서 조회한 값은 각각 userAuserB로 다르게 나옵니다.

 

ThreadLocal 사용시 주의할 점은 뭐가 있을까요? 

 

ThreadLocal 주의할 점

 

ThreadLocal은 반드시 요청 종료 시 정리해야 하며, 스레드 풀 환경에서는 특히 주의가 필요합니다.
또한, 공유 객체와의 조합은 피하고, 남발하지 않아야 합니다.

 

웹 서버는 보통 스레드 풀을 사용해서 요청을 처리합니다.

하나의 스레드가 여러 요청을 처리할 수 있는데, 이전 요청에서 ThreadLocal에 남긴 값이 다음 요청에 의도치 않게 섞이는 문제가 발생할 수 있습니다. 

 

아래와 같은 코드로 사용을 완료한 경우 제거해야 합니다.

private void releaseTraceId() {
    TraceId traceId = traceIdHolder.get();
    if (traceId.isFirstLevel()) {
        traceIdHolder.remove(); //destroy
    } else {
        traceIdHolder.set(traceId.createPreviousId());
    }
}

 

 

정리

  • ThreadLocal은 멀티 스레드 환경에서 각 요청이 서로의 데이터를 침범하지 않도록 보장해주는 강력한 도구입니다.
  • Spring은 이러한 ThreadLocal의 특성을 활용해, Transaction Context, Security Context, 요청 정보 등을 안전하게 스레드별로 관리하고 있습니다.
  • ThreadLocal은 반드시 요청 종료 시 반드시 remove()로 정리해야 안전하게 사용할 수 있습니다.

 

'Spring' 카테고리의 다른 글

트랜잭션 AOP 주의사항  (0) 2025.03.01
@Transactional 원리  (0) 2025.02.25
트랜잭션 전파 다양한 옵션  (0) 2025.02.17
싱글톤 컨테이너  (0) 2025.02.17
트랜잭션 전파 REQUIRES_NEW 활용  (0) 2025.02.15