갓생사는 김초원의 개발 블로그
chocho_log
갓생사는 김초원의 개발 블로그
전체 방문자
오늘
어제
  • 분류 전체보기 (78)
    • 개발 (23)
      • Spring (4)
      • Java (4)
      • Database (2)
      • Elasticsearch (3)
      • ETC (3)
      • JPA (3)
      • 이슈 (1)
    • 코딩 테스트 (43)
      • 프로그래머스 (23)
      • 백준 (12)
      • TIP (8)
    • 자료구조 (2)
    • 알고리즘 (4)
    • 잡생각 (0)
    • 경험 (5)
      • AWS re:Invent 2024 (5)

블로그 메뉴

    공지사항

    인기 글

    태그

    • war
    • 지연로딩
    • jar
    • jpa
    • 디자인패턴 #SOLID 원칙
    • Spring Boot Embedded Tomcat
    • Lazy Loading
    • querydsl

    최근 댓글

    최근 글

    갓생사는 김초원의 개발 블로그

    chocho_log

    개발/Java

    ThreadLocal의 개념과 세션 관리에서의 사용

    2025. 10. 12. 14:15

    1. ThreadLocal이란?

    package java.lang;
    
    class ThreadLocal<T>

    ThreadLocal은 자바에서 스레드마다 독립적인 변수를 저장할 수 있게 해주는 클래스다.

     

    보통 static 변수나 인스턴스 변수는 여러 스레드가 동시에 접근하면 공유되므로 동기화(synchronized)가 필요하다. 반면, ThreadLocal은 각 스레드 전용 저장소를 만들어 같은 코드에서 같은 ThreadLocal 객체를 참조하더라도 스레드마다 별도 값을 꺼내 쓸 수 있다. 쉽게 말해 **“스레드 전용 전역 변수”**다.

     

    2. ThreadLocal과 InheritableThreadLocal

    ThreadLocal과 이를 상속한 InheritableThreadLocal의 가장 큰 차이는 자식 스레드로의 값 전파 여부다.

    • ThreadLocal: 스레드마다 완전히 독립. 부모 → 자식으로 값이 전파되지 않는다.
    • InheritableThreadLocal: 자식 스레드가 생성될 때 한 번만 부모 스레드의 값을 복사한다. 복사 이후에는 부모/자식이 서로 독립적으로 값을 관리한다.

    2.1 ThreadLocal 자식 전파 테스트 

    아래처럼 스레드별 독립 저장소를 제공하는 헬퍼를 만들었다고 하자.

    object ThreadLocalHolder {
        // ✅ ThreadLocal (전파 없음)
        private val threadLocalHolder = ThreadLocal<String>()
    
        fun set(value: String) = threadLocalHolder.set(value)
        fun get(): String? = threadLocalHolder.get()
        fun clear() = threadLocalHolder.remove()
    }

     

    HTTP 요청에서 헤더 값을 ThreadLocal에 넣어 쓰는 인터셉터는 다음과 같이 구현할 수 있다.

    class ThreadLocalHandlerInterceptor : HandlerInterceptor {
        override fun preHandle(req: HttpServletRequest, res: HttpServletResponse, handler: Any): Boolean {
            val threadLocalValue = req.getHeader("thread-local-value")
            if (threadLocalValue != null) {
                ThreadLocalHolder.set(threadLocalValue)
            }
            return true
        }
    
        override fun afterCompletion(req: HttpServletRequest, res: HttpServletResponse, handler: Any, ex: Exception?) {
            ThreadLocalHolder.clear() // 누수 방지
        }
    }
    
    @Configuration
    class WebConfiguration : WebMvcConfigurer {
        override fun addInterceptors(registry: InterceptorRegistry) {
            registry.addInterceptor(ThreadLocalHandlerInterceptor())
        }
    }

     

    테스트 컨트롤러:

    @RestController
    class ThreadLocalTestController {
        @GetMapping("/thread-local-test")
        fun test() {
            val threadLocalValue = ThreadLocalHolder.get()
            println("thread: ${Thread.currentThread().name}, threadLocalValue: $threadLocalValue")
    
            Thread {
                val childValue = ThreadLocalHolder.get()
                println("thread: ${Thread.currentThread().name}, threadLocalValue: $childValue")
            }.start()
        }
    }

     

    결과

    thread: http-nio-9196-exec-2, threadLocalValue: hello
    thread: Thread-10, threadLocalValue: null

     

    부모 스레드에서는 hello였지만, 자식 스레드에서는 null이 나온다.

    즉, ThreadLocal은 부모 → 자식으로 전파되지 않는다.

     

     

    2.2 InheritableThreadLocal 자식 전파 테스트 

    이제 ThreadLocal을 InheritableThreadLocal로 바꿔보자.

    object ThreadLocalHolder {
        // ✅ InheritableThreadLocal (자식 스레드 생성 시 1회 복사)
        private val threadLocalHolder = InheritableThreadLocal<String>()
    
        fun set(value: String) = threadLocalHolder.set(value)
        fun get(): String? = threadLocalHolder.get()
        fun clear() = threadLocalHolder.remove()
    }

     

    결과

    thread: http-nio-9196-exec-2, threadLocalValue: hello
    thread: Thread-10, threadLocalValue: hello

     

    이번에는 자식 스레드에서도 값이 보인다. 왜냐면 자식 스레드가 생성될 때 부모의 값을 복사하기 때문이다.

     

    복사 시점은 스레드 최초 생성 시 1회다. java.lang.Thread 생성자 내부에는 대략 아래와 같은 로직이 있다.

        Thread(ThreadGroup g, String name, int characteristics, Runnable task,
               long stackSize, AccessControlContext acc) {
    
            Thread parent = currentThread(); // 부모 스레드를 가져온다. 
            boolean attached = (parent == this);   // 현재 생성하는 스레드가 부모 스레드인지 여부
    
            ...
    
            // thread locals
            if (!attached) { // 현재 생성하는 스레드가 자식 스레드이면
                if ((characteristics & NO_INHERIT_THREAD_LOCALS) == 0) {
                    ThreadLocal.ThreadLocalMap parentMap = parent.inheritableThreadLocals;
                    if (parentMap != null && parentMap.size() > 0) {
                        // 부모 스레드의 값을 이용하여 ThreadLocalMap을 생성
                        this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parentMap);
                    }
                }
            }
            ...
        }

     

     

    세션 관리 용도로 ThreadLocal을 알아보턴터라 메인 스레드와 자식 스레드의 값이 달라야할 이유가 딱히 없었다. 오히려 세션값이 달라지면 장애가 발생하는 상황이므로 ThreadLocal대신 InheritableThreadLocal을 사용했다.

     

    3. InheritableThreadLocal을 사용한 SessionHolder 도입

    기존 코드가 세션을 매 컨텍스트마다 파라미터로 넘기고 있어 코드가 지저분하고 번거로웠다.

    그래서 요청 시점에 세션을 ThreadLocal에 저장해두고 필요한 곳에서 꺼내 쓰면 편리할 것 같아 이를 위해 SessionHolder를 만들었다.

    object SessionHolder {
        private val sessionHolder = InheritableThreadLocal<TestSession>()
    
        fun set(session: TestSession) = sessionHolder.set(session)
        fun get(): TestSession? = sessionHolder.get()
        fun clear() = sessionHolder.remove()
    
        // 필요 시 코루틴 연동을 위한 컨텍스트 요소 노출 등
        fun asContextElement(): ThreadContextElement<TestSession> = sessionHolder.asContextElement()
    
        fun getOrDefault(): TestSession = get() ?: defaultSession()
    }

     

     

     

    4. 스레드풀 환경에서 만난 장애와 대응

    운영 중 다음 제보가 들어왔다: “내가 쓴 리뷰인데 is_mine=false가 내려온다.”

    세션의 유저 ID와 DB의 유저 ID를 비교하는 로직에서 세션값이 엉켜 있는 현상이었다.

     

    로그로 확인해 보니, 커스텀 스레드풀의 스레드가 해당 메서드를 처리하고 있었다.

    [정의한 스레드풀]

        // 이름은 이해하기 쉽게 CustomThreadPoolExecutor라고 써놓겠다. 실제 운영에서 사용하는 이름은 아니다.
        val IO_EXECUTOR = CustomThreadPoolExecutor(
            IO_CORE_POOL_SIZE,
            IO_CORE_POOL_SIZE * 2,
            30L,
            TimeUnit.SECONDS,
            LinkedBlockingQueue(IO_CORE_POOL_SIZE * 10),
            CustomizableThreadFactory().apply {
                setThreadNamePrefix("IO-Thread-")
                isDaemon = true
            },
            ThreadPoolExecutor.CallerRunsPolicy(),
        )

     

    [메서드를 처리하는 스레드명]

    [logger] -issue method. thread: IO-Thread-3

     

    4.1 원인: 스레드풀 + InheritableThreadLocal의 함정

    • InheritableThreadLocal은 자식 스레드 생성 시점에 딱 한 번 값을 복사한다.
    • 스레드풀은 한 번 생성한 스레드(워커)를 계속 재사용한다.

    즉, 최초 생성 시점의 값만 복사되고, 이후 부모(요청) 스레드의 값이 바뀌어도 풀의 워커에는 반영되지 않는다.

     

    스레드를 1개 고정한 아래 미니 테스트로 재현해보았다. 

    import java.util.concurrent.LinkedBlockingQueue
    import java.util.concurrent.ThreadPoolExecutor
    import java.util.concurrent.TimeUnit
    
    fun main() {
        val executor = ThreadPoolExecutor(
            1, // corePoolSize
            1, // maximumPoolSize (== core → 스레드 1개만)
            0L, TimeUnit.SECONDS,
            LinkedBlockingQueue()
        ) { r ->
            Thread(r, "SingleThread").apply { isDaemon = false }
        }
    
        val inheritable = InheritableThreadLocal<String>()
    
        // 🔹 1회차 요청
        inheritable.set("userA")
        executor.execute {
            println("[1회차] 스레드: ${Thread.currentThread().name}, 값: ${inheritable.get()}")
        }
    
        Thread.sleep(1000)
    
        // 🔹 2회차 요청
        inheritable.set("userB")
        executor.execute {
            println("[2회차] 스레드: ${Thread.currentThread().name}, 값: ${inheritable.get()}")
        }
    
        Thread.sleep(1000)
        executor.shutdown()
    }

     

    결과

    [1회차] 스레드: SingleThread, 값: userA
    [2회차] 스레드: SingleThread, 값: userA

     

    스레드풀의 스레드에서는 1,2회차 모두 부모 스레드에서 처음 넣은 "userA"값이 나왔다. 작업이 처음 들어온 시점(=스레드가 생성된 시점)에 딱 한번 부모 스레드의 값을 복사하고 그 이후에 "userB"라는 값을 새로 넣어도 여전히 처음 복사한 값을 유지하는 것을 확인할 수 있다. 

     

    4.2 해결: 실행마다 최신 컨텍스트를 복사/정리

    위와 같은 문제를 해결하기 위해서는 스레드가 “재사용”될 때마다, 부모 스레드의 최신 값을 워커 스레드에 복사하고 작업이 끝나면 정리(clear)하면 된다. ThreadPoolExecutor#execute()를 오버라이드하여 코드를 구현했다.

    class CustomThreadPoolExecutor(
        corePoolSize: Int,
        maximumPoolSize: Int,
        keepAliveTime: Long,
        unit: TimeUnit,
        workQueue: BlockingQueue<Runnable>,
        threadFactory: ThreadFactory,
        handler: RejectedExecutionHandler?,
    ) : ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler) {
        
        override fun execute(command: Runnable) {
            val callerSession = SessionHolder.get()
            super.execute {
                try {
                    if (callerSession.isNotNull()) {
                        SessionHolder.set(callerSession)
                    }
                    command.run()
                } finally {
                    SessionHolder.clear()
                }
            }
        }
    }

     

    5.@Async에서 ThreadLocal(세션) 컨텍스트 전파하기

    앞서 살펴본 것처럼 InheritableThreadLocal은 스레드 생성 시점에 단 한 번만 부모 값을 복사하고, 스레드풀에서는 스레드가 재사용되기 때문에 이후 요청의 ThreadLocal 값이 반영되지 않는 문제가 있다.

    마찬가지로 Spring의 @Async를 사용할 때는 스레드 재사용 문제를 우회하면서도 요청 컨텍스트(세션, MDC, 보안 컨텍스트 등) 를 안전하게 전달해야 한다.

     

    Spring은 이를 위해 ThreadPoolTaskExecutor#setTaskDecorator(...)라는 훅을 제공한다. 비동기 작업이 제출될 때마다 Runnable/Callable을 감싸서 실행 전후로 우리가 원하는 처리를 할 수 있다. 즉, “매 실행마다” ThreadLocal을 복사/복원하고 종료 시 정리할 수 있게 해준다.

     

    5.1 구성: @EnableAsync + ThreadPoolTaskExecutor + TaskDecorator

     

    아래 설정은 @Async가 붙은 메서드들이 사용할 스레드풀을 정의하고, 세션 컨텍스트를 전파하는 데코레이터를 연결한다.

    @EnableAsync
    @Configuration
    class AsyncConfiguration : AsyncConfigurer {
        private val logger = KotlinLogging.logger {}
    
        override fun getAsyncExecutor(): Executor {
            return asyncTaskExecutor()
        }
    
        private fun asyncTaskExecutor() = ThreadPoolTaskExecutor().apply {
            corePoolSize = 20
            maxPoolSize = 100
            queueCapacity = 2000
            setThreadNamePrefix("AsyncTaskExecutor-")
            setTaskDecorator(SessionHolderTaskDecorator()) // ★ 핵심
            initialize()
        }
    }
    
    /**
     * @Async 작업마다 호출자 스레드의 SessionHolder (그리고 MDC 등)를 복원하고
     * 작업 종료 시 반드시 정리한다.
     */
    class SessionHolderTaskDecorator : TaskDecorator {
        override fun decorate(task: Runnable): Runnable {
            val callerSession = SessionHolder.get()
            return Runnable {
                try {
                    // 실행 스레드에 호출자 컨텍스트 복원
                    if (callerSession != null) SessionHolder.set(callerSession)
    
                    task.run()
                } finally {
                    // 스레드 재사용으로 인한 오염 방지
                    SessionHolder.clear()
                }
            }
        }
    }

     

    • 전파 시점: @Async 작업이 제출될 때마다 decorate()가 호출되므로, 스레드풀 재사용과 무관하게 “그때그때” 최신 컨텍스트가 복원된다.
    • 정리 시점: 스레드풀의 스레드는 재사용되므로, finally에서 무조건 clear() 해야 컨텍스트 오염을 방지할 수 있다. 

    '개발 > Java' 카테고리의 다른 글

    ThreadPoolExecutor 동작 원리  (0) 2025.03.10
    Java의 volatile 키워드  (0) 2021.10.21
    JAVA 버전별 정리  (0) 2021.07.22
      '개발/Java' 카테고리의 다른 글
      • ThreadPoolExecutor 동작 원리
      • Java의 volatile 키워드
      • JAVA 버전별 정리
      갓생사는 김초원의 개발 블로그
      갓생사는 김초원의 개발 블로그
      갓생사는 김초원의 개발 블로그 github: https://github.com/kimchowon

      티스토리툴바