Java 동시성 문제와 ThreadLocal

배태현·2021년 12월 1일
1
post-thumbnail

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

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을 사용할 때는 이 부분을 꼭! 주의하자.

글을 마치며

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

profile
일상의 불편함을 기술로 해결 할 방법을 고안합니다.

0개의 댓글