Java의 ThreadLocal 개념, 사용법, 주의사항

조갱·2024년 3월 9일
0

스프링 강의

목록 보기
10/16

들어가기 앞서

지역변수와 전역변수

일반적으로, 변수(객체)의 종류는 크게 지역변수전역변수 2가지로 나눌 수 있다.
지역변수는 thread마다 할당되는 stack 메모리 영역에 저장되며,
전역변수는 모든 thread가 공유하는 heap 메모리 공간에 저장된다.

아래 예제 코드를 확인해보자.

class VariableTest {
    companion object {
        var staticValue = 0 // 전역변수, 자바에서 static 과 같다.
    }

    @Test
    fun localValueTest() {
        var localValue = 0 // 지역변수
    }
}

지역변수는 하나의 메소드, if문, for문 등 짧은 범위 내에서의 생명주기를 갖는다.

동시성 문제

단일 스레드에서는 상관 없지만, 멀티 스레드 환경에서 전역변수를 사용할 때에는 주의해야 한다.

단일 스레드 환경

class VariableTest {
    companion object {
        var staticValue = 0 // Java 의 static 변수, 전역변수
    }

    @Test
    fun singleThreadTest() {
        object : Thread() { // 새로운 스레드를 익명으로 생성함
            var localValue = 0
            override fun run() {
                repeat(10000) {
                    localValue += 1
                    staticValue += 1
                }
                Assertions.assertEquals(10000, localValue)
            }
        }.start()

        Thread.sleep(100)
        Assertions.assertEquals(10000, staticValue)
    }
}
Process finished with exit code 0

멀티 스레드 환경

class VariableTest {
    companion object {
        var staticValue = 0 // Java 의 static 변수, 전역변수
    }

    @Test
    fun multiThreadTest() {
        object : Thread() { // 새로운 스레드를 익명으로 생성함
            var localValue = 0
            override fun run() {
                repeat(10000) {
                    localValue += 1
                    staticValue += 1
                }
                Assertions.assertEquals(10000, localValue)
            }
        }.start()

        object : Thread() { // 새로운 스레드를 익명으로 생성함
            var localValue = 0
            override fun run() {
                repeat(10000) {
                    localValue += 1
                    staticValue += 1
                }
                Assertions.assertEquals(10000, localValue)
            }
        }.start()

        Thread.sleep(100)
        Assertions.assertEquals(20000, staticValue)
    }
}
org.opentest4j.AssertionFailedError: 
Expected :10000
Actual   :16336

1을 10,000번 더하는 스레드 2개가 있기 때문에 20,000을 기대했지만
실제로는 그보다 작은 값이 나왔다.
Actual 값은 매 실행마다 달라지는 것을 확인할 수 있다.

실제 개발환경에서의 실수

class VariableTest {
    companion object {
        var userName = ""
    }

    fun setUserName(name: String){
        userName = name
    }

    fun getUserName(): String {
        return userName
    }

    fun logic(){
        Thread.sleep((20..50).random().toLong())
    }

    @Test
    fun realTest() {
        val request1 = object: Thread() { // 새로운 스레드를 익명으로 생성함
            override fun run() {
                setUserName("김씨")
                logic()
                Assertions.assertEquals("김씨", getUserName())
            }
        }

        val request2 = object : Thread() { // 새로운 스레드를 익명으로 생성함
            override fun run() {
                setUserName("박씨")
                logic()
                Assertions.assertEquals("박씨", getUserName())
            }
        }

        request1.start()
        request2.start()

        Thread.sleep(100)
    }
}

2개의 요청이 있다.
각 요청의 플로우는, 이름 설정 -> (로직, 20~50ms 소요됨) -> 이름 불러오기
로 이루어진다.

이를 실행해보면 잘 될때도 있지만, 오류가 발생할 때도 있다.

Exception in thread "Thread-0" org.opentest4j.AssertionFailedError: expected: <김씨> but was: <박씨>
	...

이처럼 멀티 스레드에서 공유자원을 할당하고 불러오는 것은 잘못된 결과를 초래할 수 있다.

ThreadLocal

ThreadLocal은 이름에서도 볼 수 있듯, 쓰레드와 관련된 기능이다.
java 1.2 부터 지원하며, java.lang 패키지에 위치한다.

ThreadLocal은 각 스레드마다 저장공간을 할당해준다.
ThreadLocal을 전역변수로 할당하고, 각 스레드에서는 편하게 값을 읽고 쓰면 된다.

사용 방법

ThreadLocal을 사용하기 위해서는 생성 방식과, 3가지 메소드만 기억하면 된다.

생성 방식

val threadLocal = ThreadLocal<String>()

메소드

public class ThreadLocal<T> {
   public void set(T value) {...}
   public T get() {...}
   public void remove() {...}
   ...
}

ThreadLocal을 활용한 코드

class VariableTest {
    companion object {
        var userName = ThreadLocal<String>()
    }

    fun setUserName(name: String){
        userName.set(name)
    }

    fun getUserName(): String {
        return userName.get()
    }

    fun release() {
        userName.remove()
    }

    fun logic(){
        Thread.sleep((20..50).random().toLong())
    }

    @Test
    fun realTestWithThreadLocal() {
        val request1 = object: Thread() { // 새로운 스레드를 익명으로 생성함
            override fun run() {
                setUserName("김씨")
                logic()
                Assertions.assertEquals("김씨", getUserName())
                release() // ThreadLocal 사용 후에는 반드시 remove
            }
        }

        val request2 = object : Thread() { // 새로운 스레드를 익명으로 생성함
            override fun run() {
                setUserName("박씨")
                logic()
                Assertions.assertEquals("박씨", getUserName())
                release() // ThreadLocal 사용 후에는 반드시 remove
            }
        }

        request1.start()
        request2.start()

        Thread.sleep(100)
    }
}

이제 멀티스레드 환경에서도 각 스레드마다 값을 관리할 수 있다.
ThreadLocal 후에는 반드시 release 해주는 것을 잊지 말자.
(뒤에서 발생할 수 있는 이슈를 설명)

동작 플로우 비교

기존

ThreadLocal

주의사항

ThreadPool을 사용하는 경우

ThreadLocal은 내부적으로 데이터를 ThreadLocalMap 으로 관리하는데,
ThreadLocalMap의 key는 Thread 정보이다.

Spring-MVC의 내장 톰캣같이 Thread를 재활용하는 경우, 이전에 사용하던 데이터가 남아있을 수 있다. 따라서 반드시 ThreadLocal을 사용한 뒤에는 remove()를 호출해주자.

Spring webflux를 사용하는 경우

Webflux는 내부 WAS를 Tomcat이 아닌 Netty를 사용한다.

Tomcat은 request마다 thread를 할당하여 사용하는 반면
Netty는 NIOEventLoop 방식을 사용하기 때문에, 1개의 request 안에서 thread가 바뀔 수 있다.

그래서 ThreadLocal의 값을 보장할 수 없고, 다른 request에서 값을 조회해갈 수도 있다.

-> Webflux를 사용한다면 ThreadLocal 을 사용하면 안된다.

profile
A fast learner.

0개의 댓글