ThreadLocal

어제 우아한 형제들 루터회관 14층에서 SLiPP 스터디 중간 세미나가 있었고, 우아한 테크 코스 크루 자격으로 참석할 수 있었다. 첫번째 세션에서 김문수님의 게임 재화 및 재화 로그 시스템 개발기 를 들었는데, ThreadLocal이란 용어를 처음 접하게 되었다. 궁금증이 생겨서 찾아보고 이해한 결과를 정리하고, 구현해본 코드를 공유해보려고 한다.

ThreadLocal이란?

오라클 Docs 에서 ThreadLocal 클래스를 다음과 같이 설명한다.

ThreadLocal 클래스는 thread-local 변수들을 제공한다. 이 변수들은 get 또는 set 메소드를 통해 접근하는 각 스레드가 독립적으로 변수의 초기화 된 사본을 가지고 있다는 점에서 다르다. ThreadLocal 인스턴스들은 보통 스레드와 상태를 연결하려고 하는 클래스들의 private static 필드들이다. (예를들어, 유저 ID 또는 트랜잭션 ID)

좀 더 쉽게 말하자면, ThreadLocal 변수를 선언하면 멀티 스레드 환경에서 각 스레드마다 독립적인 변수를 가지고, get(), set() 메소드를 통해 값에 대해 접근할 수 있다. 간단한 코드로 예를 들어보자.

private static final ThreadLocal<Integer> threadLocalId = new ThreadLocal<>();

위와 같이 threadLocalId 변수를 선언하고, threadLocalId.set(1) 으로 값을 지정해주면, 지정해준 스레드 안에서 threadLocalId.get()을 호출했을 때 지정해준 값인 1을 리턴한다.

각 스레드는 스레드가 종료되기 전까지 ThreadLocal 인스턴스를 참조한다.

어떻게 사용할까?

WAS

우아한 테크 코스 레벨 3의 첫번째 미션인 WAS 구현 미션 코드의 일부로 ThreadLocal의 예를 들겠다. Session의 생성, 저장, 조회, 삭제를 담당하는 SessionManager 클래스가 있다.

public class SessionManager {
    private static Map<String, HttpSession> sessionMap = new HashMap<>();

    public static HttpSession createEmptySession() {
        return new HttpSession();
    }

    public static String addSession(HttpSession session) {
        sessionMap.put(session.getId(), session);
        return session.getId();
    }

    public static HttpSession findSession(String key) {
        return sessionMap.get(key);
    }

    public static void deleteSession(String key) {
        sessionMap.remove(key);
    }
}

만약 코드의 여러 부분에서 세션을 사용해야한다고 할 때 기존의 코드에서는 매번 다음과 같은 과정을 통해 세션을 불러와야 한다.

  1. HttpRequest의 쿠키 안에 있는 String 타입의 SessionId를 가져온다.
  2. SessionManger.findSession() 메소드와 1번의 SessionId 값을 통해 Map에 저장되어있는 Session을 가져온다.

이 방법의 문제는 세션을 가져오기 위해서는 HttpRequest을 항상 필요로 하고, 세션을 관리하는 Map에서 불필요하게 반복하여 검색하는 과정을 거친다.

하지만 ThreadLocal을 사용하면 처음에 요청이 들어올 때 인스턴스에 세션을 할당해주기만 하면 세션을 인스턴스로부터 계속 꺼내쓸 수 있다.

  • ThreadLocal 인스턴스를 통해 현재 스레드의 세션을 관리하는 클래스
public class SessionThreadLocal {
    private static final ThreadLocal<HttpSession> threadLocal;

    static {
        threadLocal = new ThreadLocal<>();
    }

    public static void set(HttpSession session) {
        threadLocal.set(session);
    }

    public static void unset() {
        threadLocal.remove();
    }

    public static HttpSession get() {
        return threadLocal.get();
    }
}
  • 요청이 들어왔을 때 세션 ID를 이미 쿠키 값으로 가지고 있으면 SessionManager로 부터 세션을 조회하고, 가지고 있지 않으면 새로 세션을 생성 후 세션을 SessionThreadLocal에 저장한다.
public class SessionInitiator {
    public void handle(HttpRequest request, HttpResponse response) {
        if (request.getHeader(COOKIE) == null || request.getSessionId() == null) {
            HttpSession session = SessionManager.createEmptySession();
            SessionManager.addSession(session);
            Cookie cookie = new Cookie("SESSIONID", session.getId());
            response.addCookie(cookie);
            SessionThreadLocal.set(session);
            return;
        }
        HttpSession session = SessionManager.findSession(request.getSessionId());
        SessionThreadLocal.set(session);
    }
}
  • 세션을 필요로 하는 경우 ThreadLocal의 get() 메소드를 통해 저장했던 세션을 불러온다.
public class LoginController extends AbstractController {
    @Override
    public View doPost(HttpRequest httpRequest, HttpResponse httpResponse) {
        User user = DataBase.findUserById(httpRequest.getRequestBody("userId")).orElse(null);

        if (user != null && user.matchPassword(httpRequest.getRequestBody("password"))) {
            HttpSession session = SessionThreadLocal.get();
            session.setAttributes("user", user);
            Cookie cookie = new Cookie("logined", "true");
            httpResponse.addCookie(cookie);

            return new RedirectView("index.html");
        }
        Cookie cookie = new Cookie("logined", "false");
        httpResponse.addCookie(cookie);

        return new RedirectView("user/login_failed.html");
    }
}
  • ThreadPool을 사용하는 경우 ThreadLocal의 값을 초기화 시켜주는 과정이 필요하다. 만약 초기화 해주지 않으면, 스레드가 다른 요청을 받을 때, 이전에 처리했던 요청에 대한 세션 값을 사용할 수도 있다.
public void doDispatch(HttpRequest httpRequest, HttpResponse httpResponse) throws IOException {
        try {
            if (httpRequest.isStaticRequest()) {
                handleStaticRequest(httpRequest, httpResponse);
                return;
            }
            Controller controller = HandlerMapping.handle(httpRequest);
            View view = controller.service(httpRequest, httpResponse);
            view.render(httpRequest, httpResponse);
        } catch (AbstractHttpException e) {
            log.error(e.getMessage());
            httpResponse.sendError(e);
        } catch (URISyntaxException e) {
            log.error(e.getMessage());
        } finally {
            SessionThreadLocal.unset();
        }
    }

Thread 공유시 초기화 해주지 않을 때

만약 ThreadPool을 사용할 때, ThreadLocal 인스턴스를 초기화해주지 않으면 어떻게 될까?

간단한 테스트를 해보겠다.

public class ExecutorsTest {
    private static final Logger logger = LoggerFactory.getLogger(ExecutorsTest.class);

    private static AtomicInteger counter = new AtomicInteger(0);
    private static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        ExecutorService es = Executors.newFixedThreadPool(5);

        for (int i = 0; i < 5; i++) {
            es.execute(() -> {
                int idx = counter.addAndGet(1);
                threadLocal.set(idx);
                logger.info("Thread {}", threadLocal.get());
            });
        }
        try {
            Thread.sleep(1000);
            System.out.println();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        for (int i = 0; i < 15; i++) {
            es.execute(() -> {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                logger.info("Thread {}", threadLocal.get());
            });
        }

        es.shutdown();
        es.awaitTermination(100, TimeUnit.SECONDS);
    }
}

스레드 5개를 가지고 있는 스레드 풀을 만든 후, 1에서 5까지 각각의 번호를 각 스레드의 ThreadLocal 인스턴스에 할당해줬다. 1초를 기다리고 나서 스레드에게 가지고 있는 ThreadLocal 값을 출력하는 코드이다. 결과값은 다음과 같이 나왔다.

19:27:34.824 [INFO ] [pool-1-thread-1] [webserver.ExecutorsTest] - Thread 1
19:27:34.824 [INFO ] [pool-1-thread-4] [webserver.ExecutorsTest] - Thread 4
19:27:34.824 [INFO ] [pool-1-thread-3] [webserver.ExecutorsTest] - Thread 3
19:27:34.824 [INFO ] [pool-1-thread-2] [webserver.ExecutorsTest] - Thread 2
19:27:34.824 [INFO ] [pool-1-thread-5] [webserver.ExecutorsTest] - Thread 5

19:27:36.828 [INFO ] [pool-1-thread-2] [webserver.ExecutorsTest] - Thread 2
19:27:36.828 [INFO ] [pool-1-thread-3] [webserver.ExecutorsTest] - Thread 3
19:27:36.828 [INFO ] [pool-1-thread-5] [webserver.ExecutorsTest] - Thread 5
19:27:36.828 [INFO ] [pool-1-thread-1] [webserver.ExecutorsTest] - Thread 1
19:27:36.828 [INFO ] [pool-1-thread-4] [webserver.ExecutorsTest] - Thread 4
19:27:37.829 [INFO ] [pool-1-thread-4] [webserver.ExecutorsTest] - Thread 4
19:27:37.829 [INFO ] [pool-1-thread-2] [webserver.ExecutorsTest] - Thread 2
19:27:37.829 [INFO ] [pool-1-thread-3] [webserver.ExecutorsTest] - Thread 3
19:27:37.829 [INFO ] [pool-1-thread-5] [webserver.ExecutorsTest] - Thread 5
19:27:37.829 [INFO ] [pool-1-thread-1] [webserver.ExecutorsTest] - Thread 1
19:27:38.830 [INFO ] [pool-1-thread-1] [webserver.ExecutorsTest] - Thread 1
19:27:38.831 [INFO ] [pool-1-thread-2] [webserver.ExecutorsTest] - Thread 2
19:27:38.831 [INFO ] [pool-1-thread-5] [webserver.ExecutorsTest] - Thread 5
19:27:38.830 [INFO ] [pool-1-thread-3] [webserver.ExecutorsTest] - Thread 3
19:27:38.830 [INFO ] [pool-1-thread-4] [webserver.ExecutorsTest] - Thread 4

각 스레드가 종료되지 않고, 사용되기 때문에 처음에 할당해준 값들이 반복해서 출력된다.

3줄 요약

  1. ThreadLocal 변수를 선언하면 각 스레드가 별도의 변수처럼 사용할 수 있다.
  2. 스레드가 종료되기 전까지 변수를 사용할 수 있다.
  3. 스레드풀을 통해 스레드를 재사용하는 경우 이전에 사용했던 값을 공유할 수 있다. (이해하고 목적에 맞게 사용하자!)