Backend/Java

[Java] ThreadLocal

제이동 개발자 2023. 7. 16. 21:06
728x90

ThreadLocal

 실무에서 개발자를 괴롭히는 대표적인 문제 중 하나가 바로 동시성 문제입니다. Spring Container는 기본적으로 사용하는 모든 빈들을 싱글톤으로 관리합니다. 즉, 객체의 인스턴스가 애플리케이션에서 하나만 존재한다는 뜻인데 만약 싱글톤 객체가 가변 객체일 경우 여러 개의 Thread가 싱글톤 객체가 갖고 있는 필드를 공유하게 되면 동시성 문제가 발생하게 됩니다.

 

 예제 코드는 Github에서 확인할 수 있습니다.

 

1. 동시성 문제 예시

  • 조건 1 : 싱글톤 객체인 FieldService는 불변 객체가 아닌 가변 객체일 경우
  • 조건 2 : nameStore 저장하는 시간은 1초 소요
  • 요청 1 : Thread-A가 nameStore에 "userA"를 저장 후 조회
  • 요청 2 : Thread-B가 nameStore에 "userB"를 저장 후 조회
@Slf4j
@Service
public class FieldService {

    // 공유 필드
    private String nameStore;

    public String logic(String name) {
        log.info("저장 nameStore={} -> name={}", nameStore, name);
        nameStore = name;
        sleep(1000);
        log.info("조회 nameStore={}",nameStore);
        return nameStore;
    }
    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

테스트 코드

@Slf4j
@SpringBootTest
class FieldServiceTest {

    @Autowired
    FieldService 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(); // Thread-A 실행
        sleep(100); // 0.1초 대기 후 Thread-B 실행
        threadB.start(); // Thread-B 실행
        
        // 동시성 이슈 발생

        sleep(3000); //메인 쓰레드 종료 대기
        log.info("main exit");
    }

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

// 결과 로그
[Test worker] main start
[Thread-A] 저장 nameStore=null -> name=userA
[Thread-B] 저장 nameStore=userA -> name=userB
[Thread-A] 조회 nameStore=userB // ⭐ 동시성 문제 - userA가 아닌 userB를 조회하게 된다.
[Thread-B] 조회 nameStore=userB
[Test worker] main exit

 

 결과 로그를 보면 Thread-A는 "userA"가 아닌 "userB"를 조회하게 됩니다. 이와 같은 이유는 Thread-A가 실행하게 되면 해당 로직이 완료되기까지 1초가 걸리는데 그 사이에 Thread-B가 공유 필드에 저장한 nameStore의 값을 "userB"로 저장을 하여 Thread-A는 nameStore에 저장된 "userB"를 조회하게 됩니다. 

 

 

2. ThreadLocal

 싱글톤 객체의 공유 필드로 인한 동시성 문제를 해결할 수 있는 방법은 첫 번째로는 불변 객체로 사용하는 것입니다. 하지만 각 Thread들이 별도의 데이터를 가져야 할 경우(트랜잭션 관리, 세션 관리, 로깅 등)가 있는데 이때 사용하는 것이 바로 ThreadLocal 입니다. ThreadLocal은 각 Thread들이 사용할 수 있는 변수를 개별로 저장하여 사용하는 방법으로 동시성 문제를 해결할 수 있습니다.

 

메서드 설명
ThreadLocal.set(XXX) ThreadLocal에 데이터 저장
ThreadLocal.get() ThreadLocal에 저장되어 있는 데이터 조회
ThreadLocal.remove() ThreadLocal에 저장되어 있는 데이터 제거

 

2-1. ThreadLocal 예제 코드

@Slf4j
@Service
public class ThreadLocalService {

    // ThreadLocal 선언
    private ThreadLocal<String> nameStore = new ThreadLocal<>();

    public String logic(String name) {
        log.info("nameStore={} -> 저장 name={}", nameStore.get(), name);
        nameStore.set(name); // ThreadLocal에 데이터 저장
        sleep(1000);
        log.info("조회 nameStore={}",nameStore.get());
        return nameStore.get(); // ThreadLocal에 데이터 조회
    }
    
    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

테스트 코드

@Slf4j
@SpringBootTest
class ThreadLocalTest {

    @Autowired
    ThreadLocalService threadLocalService;
    
    @Test
    void threadLocal() {
        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(2000);
        log.info("main exit");
    }
    
    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

// 실행 결과
Test worker] main start
[Thread-A] 저장 nameStore=null -> name=userA
[Thread-B] 저장 nameStore=null -> name=userB
[Thread-A] 조회 nameStore=userA
[Thread-B] 조회 nameStore=userB
[Test worker] main exit

 

 위 테스트 결과로 알 수 있듯이 ThreadLocalService에 여러 개의 Thread가 동시에 접근을 하더라도 각 ThreadLocal을 이용하기 때문에 동시성 이슈가 발생하지 않게 됩니다.

 

2-2. ThreadLocal의 주의사항

 톰캣과 같은 웹 서버처럼 Thread Pool을 사용하는 경우에는 ThreadLocal에 있는 데이터를 삭제하지 않는 경우에 문제가 발생합니다. 그 이유는 Thread를 생성하는 비용이 비싸기 때문에 톰캣과 같은 웹 서버에서는 매번 요청이 발생할 때마다 Thread를 생성하고 응답 이후 Thread를 제거하는 것이 아닌 Thread Pool에 설정한 개수만큼 미리 Thread를 만들어 놓고, 요청이 발생했을 때 Thread Pool에서 가져와 사용하고 반납하는 방법을 사용합니다. 즉, 웹 서버에서 Thread들은 재사용이 되는데 이때 ThreadLocal에 있는 데이터를 삭제하지 않게 되면 ThreadLocal에 데이터가 남게 되어 다른 사용자가 ThreadLocal에 저장되어 있는 데이터에 접근할 수 있게 되는 문제가 발생하게 됩니다. 따라서 ThreadLocal을 사용할 때는 반드시 ThreadLocal.remove()를 통해 데이터를 제거해줘야 합니다.

 

728x90