본문 바로가기
Java

Java 동시성 문제와 ThreadLocal

by 배털 2022. 3. 25.

이 글은 2021년 12월에 작성되었으며 블로그를 이전하며 옮기게 되었습니다.

제 글에 문제가 있다면 댓글로 알려주시면 감사하겠습니다! 🙇‍♂️

ThreadLocal 그게 뭐야 ?

간단히 말하자면 스레드 단위로 로컬 변수를 할당하는 기능이다.

ThreadLocal의 필요성을 느끼기 위해선 먼저 동시성 문제를 느끼고 알아야 한다.

동시성 문제, 그건 또 뭐야?

스프링 빈은 싱글톤이 보장된다.
이 객체의 인스턴스가 애플리케이션에 딱 1개만 존재한다는 뜻이다.
이렇게 하나만 있는 인스턴스의 필드를 여러 스레드가 동시에 접근하기 때문에 동시성 문제가 발생한다.

예를 들어 회원을 저장하고 1초 쉰 뒤 조회하는 코드가 있다고 가정해보자

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

이 로직을 한번 실행하고 1초 이상 기다린 뒤 다시 실행한다면
동시성 문제는 일어나지 않을 것이고 실행 결과는 아래와 같을 것이다.

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

하지만 1초가 되기 전 거의 동시에 한번 더 실행된다면 어떻게 될까?

userA가 저장되고 1초 쉬는 시점에 userB의 저장 요청이 들어온다고 가정해보자

그렇게 되면 실행결과는

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

이와 같을 것이다.

실행된 순서로 나열해 본다면 이와 같다.

  1. Thread-AuserAnameStore 에 저장했다.
  2. Thread-BuserBnameStore 에 저장했다.
  3. Thread-AuserBnameStore 에서 조회했다.
  4. Thread-BuserBnameStore 에서 조회했다.

이런 동시성 문제는 지역변수에서는 발생하지 않는다.
지역 변수는 스레드마다 각각 다른 메모리 영역이 할당된다.
동시성 문제가 발생하는 곳은 같은 인스턴스의 필드(주로 싱글톤에서 자주 발생),
또는 static 같은 공용 필드에 접근할 때 발생한다.

또한 동시성 문제는 값을 읽기만 한다면 발생하지 않는 문제이다.
어디선가 값을 변경하기 때문에 발생하는 문제이다.

그럼 동시성 문제는 어떻게 해결하는데?

이렇게 동시성 문제가 발생했을 때 사용하는 게 ThreadLocal이다.

위에서 말했듯이 ThreadLocal은
스레드 단위로 로컬 변수를 할당하는 기능(스레드만 접근할 수 있는 특별한 저장소)을 말한다.
ThreadLocal을 사용하면 각 스레드마다 별도의 내부 저장소를 제공한다.
따라서 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제가 없다.

Thread-AuserA 라는 값을 저장하면 스레드 로컬은 Thread-A 전용 보관소에 데이터(userA)를 안전하게 보관한다는 것이다!
또한 Thread-B도 마찬가지로
Thread-BuserB 라는 값을 저장하면 스레드 로컬은 Thread-B 전용 보관소에 데이터(userB)를 안전하게 보관한다는 것이다!

이렇게 된다면 ThreadLocal을 통해 데이터를 조회할 때에도
Thread-A가 조회하면 ThreadLocal은 Thread-A 전용 보관소에서 userA를 반환해준다!
물론 Thread-B 가 조회하면 Thread-B 전용 보관소에서 userB 데이터를 반환해준다!

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

그럼 ThreadLocal은 어떻게 사용하는 거야?

  • ThreadLocal 객체 생성
  • 값 저장: ThreadLocal.set(value)
  • 값 조회: ThreadLocal.get()
  • 값 제거: ThreadLocal.remove()

사용법도 알아보았으니 위에서 동시성 문제가 일어났던 코드를 ThreadLocal로 변경해보자.

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

위에서 사용했던 코드와 거의 비슷한데
nameStore 필드가 일반 String타입에서 ThreadLocal을 사용하도록 변경되었다.
ThreadLocal로 변경했으니 아까 동시성 문제가 일어났던 상황
userA가 저장되고 1초 쉬는 시점에 userB의 요청이 들어오는 상황이 발생한다면 실행결과는

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

이렇게 나올 것이다.

ThreadLocal 덕분에 스레드마다 각각의 별도의 데이터 저장소를 가지게 되었고
결과적으로 동시성 문제도 해결
되었다.

ThreadLocal 사용 시 주의사항이 있어요!

ThreadLocal의 값을 사용 후 제거하지 않고 그냥 두면
WAS(톰캣)처럼 스레드 풀을 사용하는 경우에 심각한 문제가 발생할 수 있다.

저장 요청을 한다고 가정하고 (userB를 저장한다고 가정)
요청을 수행하기 위해 톰캣에서는 쓰레드 풀에서 스레드를 하나 조회해 작업 주체에 쓰레드를 할당해 줄 것이다.
그럼 그 요청(작업 수행)이 끝난 뒤 사용이 끝난 스레드를 쓰레드 풀에 반환한다.

반환된 스레드는 쓰레드 풀에 살아있게 된다.
따라서 쓰레드 로컬의 전용 보관소에 작업할 때 저장된 데이터(userB)도 함께 살아있게 된다.

스레드를 생성하는 비용은 비싸기 때문에 쓰레드를 제거하지 않고,
보통 쓰레드 풀을 통해서 쓰레드를 재사용한다.

그럼 이번엔 조회 요청이 들어왔다고 가정해보자 (userA가 이미 저장되어있던 자신 userA를 조회한다고 가정)
조회 요청을 수행하기 위해 스레드 풀에서 스레드를 하나 조회해 작업 주체에 쓰레드를 할당해줄 때
할당된 스레드가 userB를 저장할 때 사용했던 쓰레드 라면 (물론 다른 쓰레드가 할당될 수 있음)
ThreadLocal은 쓰레드 전용 보관소에 남아있던 데이터(userB)를 반환하게 된다.
결과적으로 저장 요청에서 스레드 전용 보관소에 저장되었던 데이터(userB)를 반환하게 되는 것이다.

userAuserB의 데이터를 확인하게 되는 심각한 문제가 발생한다.

이 문제의 해결 방법은 간단하다.
요청이 끝날 때마다 ThreadLocal의 값을 ThreadLocal.remove()를 이용하여 꼭 제거해야 한다.
ThreadLocal을 사용할 때는 이 부분을 꼭! 주의하자.

글을 마치며

동시성 문제를 실무에서 만난다면 개발자를 가장 괴롭히는 문제라고 들었습니다.
전 아직 프로젝트를 하며 필요성을 느끼지는 못했지만 이 내용을 공부하며
실력 있는 서버 개발자가 되기 위해선 꼭 숙지해야 하는 내용이라는 생각이 들었습니다.
글 읽어주셔서 감사합니다 😊

댓글