[Spring] 쓰레드 로컬 (ThreadLocal)

yrok·2023년 11월 21일
0
post-thumbnail

💡 개요

로그 추적기를 구현하는 과정에서 동시성 문제를 마주했다. 쓰레드 로컬을 사용해서 문제를 해결하기위해 쓰레드 로컬에 대해 알아보고자 한다.

🤔 문제

동시성 문제가 발생한 코드를 먼저 살펴보자.

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

파라미터로 넘어온 name을 nameStore에 저장하고 1초 뒤에 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();
        }
    }
}

위에서 정의한 서비스를 이용해 작성한 테스트 케이스다.

  • new Thread(userA) : start() 메서드를 사용해 실행하면 Runnable 객체 userA가 실행된다.

따라서, 테스트 코드의 실행 순서를 살펴보자면 fieldService.logic("userA")가 실행되고 0.1초 후에 fieldService.logic("userB")가 실행된다.
지금부터 fieldService.logic("userA")를 taskA, fieldService.logic("userB")를 taskB라고 하겠다.

내가 예상한 결과는 taskA에서 조회한 nameStore 값이 userA였지만 로그를 출력해보니 userB가 출력됐다. 이는 taskA가 끝나기 전에 taskB가 실행되기 때문이다.
nameStore의 변화 순서를 살펴보면 null -> userA -> userB 순서이고 userA가 1초를 기다리는 동안 nameStore는 taskB에 의해 userB로 변하므로 taskA에서 nameStore를 조회할 시점에 nameStore 값은 userB이다.

💡 쓰레드 로컬 (ThreadLocal)

쓰레드 로컬은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 말한다.

thread-A가 데이터를 저장, 조회할 때 쓰레드 로컬은 thread-A 전용 보관소에서 데이터를 저장, 반환한다. thread-B도 마찬가지다.

자바는 언어차원에서 쓰레드 로컬을 지원하기 위한 java.lang.ThreadLocal 클래스를 제공한다.

쓰레드 로컬을 사용해서 위에서 발생한 동시성 문제를 해결해보자.

@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();
        }
    }
}
  • 값 저장 : ThreadLocal.set(xxx)
  • 값 조회 : ThreadLocal.get()
  • 값 제거 : ThreadLocal.remove()
@Slf4j
public class ThreadLocalServiceTest {

    private ThreadLocalService threadLocalService = new ThreadLocalService();

    @Test
    void field() {

        log.info("main start");
        Runnable userA = () -> {
            threadLocalService.logic("userA");
        };

        Runnable userB = () -> {
            threadLocalService.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();
        }
    }
}
동시성 이슈가 발생했던 테스트 결과와 달리 쓰레드 로컬이 각 쓰레드 별로 데이터를 저장, 조회하는 것을 확인할 수 있었다.

❗ 쓰레드 로컬 주의 사항

쓰레드 로컬의 값은 사용하고 난 후에 제거해야한다. ThreadLocal.remove()
왜냐하면, 쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 WAS처럼 쓰레드 풀을 사용하는 경우에 심각한 문제가 발생할 수 있기 때문이다.

사용자A 저장 요청

  1. 사용자A가 저장 HTTP를 요청
  2. WAS는 쓰레드 풀에서 쓰레드를 하나 조회
  3. thread-A 할당
  4. thread-A사용자A의 데이터를 쓰레드 로컬에 저장
  5. 쓰레드 로컬의 thread-A전용 보관소에 사용자A 데이터 보관

사용자A 저장 요청 종료

  1. 사용자A의 HTTP 응답 끝
  2. WAS는 사용이 끝난 thread-A를 쓰레드 풀에 반환 -> thread-A는 아직 쓰레드 풀에 살아있다.
  3. thread-A의 쓰레드 로컬 값을 제거하지 않았을 경우, thread-A 전용 보관소에 사용자A의 데이터도 존재한다.

사용자B 조회 요청

  1. 사용자B가 조회를 위해 새로운 HTTP 요청

  2. WAS는 쓰레드 풀에서 쓰레드 조회

  3. thread-A 할당

  4. thread-A는 쓰레드 로컬에서 데이터 조회

  5. 쓰레드 로컬은 thread-A 전용 보관소에 있는 사용자A 데이터 반환

  6. 결과적으로 사용자B는 사용자A의 정보를 조회하게 된다 !!

📌 이러한 심각한 오류를 범하지 않기 위해서 요청이 끝날 때 ThreadLocal.remove() 메서드를 사용해 쓰레드 로컬의 값을 제거해야 한다.

profile
공부 일기장

0개의 댓글