[ 김영한 스프링 핵심 원리 - 고급편 #2 ] 쓰레드 로컬 - ThreadLocal (2)

김수호·2023년 11월 10일
0
post-thumbnail

지난 포스팅에 이어, 이번 포스팅에서는 5) ~ 10) 까지의 내용을 정리한다.

👉 목차는 다음과 같다.

1) 필드 동기화 - 개발
2) 필드 동기화 - 적용
3) 필드 동기화 - 동시성 문제
4) 동시성 문제 - 예제 코드
5) ThreadLocal - 소개
6) ThreadLocal - 예제 코드
7) 쓰레드 로컬 동기화 - 개발
8) 쓰레드 로컬 동기화 - 적용
9) 쓰레드 로컬 - 주의사항
10) 정리

바로 하나씩 확인해보자.


5) ThreadLocal - 소개

쓰레드 로컬은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 말한다.
쉽게 이야기해서 물건 보관 창구를 떠올리면 된다. 여러 사람이 같은 물건 보관 창구를 사용하더라도, 창구 직원은 사용자를 인식해서 사용자별로 확실하게 물건을 구분해준다. 사용자A, 사용자B 모두 창구 직원을 통해서 물건을 보관하고, 꺼내지만 창구 직원이 사용자에 따라 보관한 물건을 구분해주는 것이다.

일반적인 변수 필드

  • 여러 쓰레드가 같은 인스턴스의 필드에 접근하면 처음 쓰레드가 보관한 데이터가 사라질 수 있다.
    • thread-AuserA 라는 값을 저장하고 thread-BuserB 라는 값을 저장하면, 직전에 thread-A 가 저장한 userA 값은 사라진다. ( 이렇게 되면 동시성 문제가 발생할 수 있다. 따라서 이러한 문제를 해결해주는 것이 쓰레드 로컬이라는 기능이다. )

 

쓰레드 로컬

  • 쓰레드 로컬을 사용하면 각 쓰레드마다 별도의 내부 저장소를 제공한다. 따라서 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제 없다.
  • thread-AuserA 라는 값을 저장하면 쓰레드 로컬은 thread-A 전용 보관소에 데이터를 안전하게 보관한다.
  • thread-BuserB 라는 값을 저장하면 쓰레드 로컬은 thread-B 전용 보관소에 데이터를 안전하게 보관한다.
  • 쓰레드 로컬을 통해서 데이터를 조회할 때도 thread-A 가 조회하면 쓰레드 로컬은 thread-A 전용 보관소에서 userA 데이터를 반환해준다. 물론 thread-B 가 조회하면 thread-B 전용 보관소에서 userB 데이터를 반환해준다.
  • 자바는 언어차원에서 쓰레드 로컬을 지원하기 위한 java.lang.ThreadLocal 이라는 클래스를 제공한다.

6) ThreadLocal - 예제 코드

👉 예제 코드를 통해서 ThreadLocal 을 학습해보자.

  • ThreadLocalService 생성: test > java > hello > advanced > trace > threadlocal > code 패키지 내부에 ThreadLocalService 클래스를 생성하자.
    • (참고) 기존에 있던 FieldService 와 거의 같은 코드인데, nameStore 필드가 일반 String 타입에서 ThreadLocal 을 사용하도록 변경되었다.
    • ThreadLocal 사용법
      • 값 저장: ThreadLocal.set(xxx)
      • 값 조회: ThreadLocal.get()
      • 값 제거: ThreadLocal.remove()
      • (주의) 해당 쓰레드가 쓰레드 로컬을 모두 사용하고 나면 ThreadLocal.remove() 를 호출해서 쓰레드 로컬에 저장된 값을 제거해주어야 한다. 제거하는 구체적인 예제는 조금 뒤에 설명하겠다.
  • ThreadLocalServiceTest 생성: test > java > hello > advanced > trace > threadlocal 패키지 내부에 ThreadLocalServiceTest 클래스를 생성하자.
  • 실행해보자.
    • 쓰레드 로컬 덕분에 쓰레드 마다 각각 별도의 데이터 저장소를 가지게 되었다. 결과적으로 동시성 문제도 해결되었다.

7) 쓰레드 로컬 동기화 - 개발

FieldLogTrace 에서 발생했던 동시성 문제를 ThreadLocal 로 해결해보자.
TraceId traceIdHolder 필드를 쓰레드 로컬을 사용하도록 ThreadLocal traceIdHolder 로 변경하면 된다.

👉 필드 대신에 쓰레드 로컬을 사용해서 데이터를 동기화하는 ThreadLocalLogTrace 를 새로 만들자.

  • ThreadLocalLogTrace 생성: src > main > java > hello > advanced > trace > logtrace 패키지 내부에 ThreadLocalLogTrace 클래스를 생성하자.
    • traceIdHolder 가 필드에서 ThreadLocal 로 변경되었다. 따라서 값을 저장할 때는 set(..) 을 사용하고, 값을 조회할 때는 get() 을 사용한다.

 

✔️ ThreadLocal.remove()

  • 추가로 쓰레드 로컬을 모두 사용하고 나면 꼭 ThreadLocal.remove() 를 호출해서 쓰레드 로컬에 저장된 값을 제거해주어야 한다. 쉽게 이야기해서 다음의 마지막 로그를 출력하고 나면 쓰레드 로컬의 값을 제거해야 한다.
    • 여기서는 releaseTraceId() 를 통해 level 이 점점 낮아져서 2 -> 1 -> 0이 되면 로그를 처음 호출한 부분으로 돌아온 것이다.
    • 따라서 이 경우 연관된 로그 출력이 끝난 것이다. 이제 더 이상 TraceId 값을 추적하지 않아도 된다. 그래서 traceId.isFirstLevel() ( level==0 )인 경우, ThreadLocal.remove() 를 호출해서 쓰레드 로컬에 저장된 값을 제거해준다.

 

👉 코드에 문제가 없는지 간단한 테스트를 만들어서 확인해보자.

  • ThreadLocalLogTraceTest 생성: test > java > hello > advanced > trace > logtrace 패키지 내부에 ThreadLocalLogTraceTest 클래스를 생성하자.
  • begin_end_level2() - 실행 결과
  • begin_exception_level2() - 실행 결과
  • 모두 정상적으로 동작하는 것을 확인할 수 있다. ( 물론 멀티쓰레드까지 테스트 해보면 좋겠지만, 이는 다음 내용에서 실제 애플리케이션에 적용해서 확인해보자. )

멀티쓰레드 상황에서 문제가 없는지는 애플리케이션에 ThreadLocalLogTrace 를 적용해서 확인해보자.


8) 쓰레드 로컬 동기화 - 적용

  • LogTraceConfig - 수정: 다음과 같이 수정하자.
    • 동시성 문제가 있는 FieldLogTrace 대신에, 문제를 해결한 ThreadLocalLogTrace 를 스프링 빈으로 등록하자.
  • 실행해보자. ( 정상 실행 - http://localhost:8080/v3/request?itemId=hello )
  • 실행해보자. ( 예외 실행 - http://localhost:8080/v3/request?itemId=ex )
  • 실행해보자. ( 동시성 문제 확인 - http://localhost:8080/v3/request?itemId=hello 을 1초 안에 2번 실행해보자. )
    • 로그 분리해서 확인하기 (쓰레드로 분리해보았다.)
      • 로그를 직접 분리해서 확인해보면 각각의 쓰레드 ( nio-8080-exec-3 , nio-8080-exec-4 ) 별로 로그가 정확하게 나누어 진 것을 확인할 수 있다.

9) 쓰레드 로컬 - 주의사항

쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 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 를 쓰레드 풀에 반환한다. ( 쓰레드를 생성하는 비용은 비싸기 때문에 쓰레드를 제거하지 않고, 보통 쓰레드 풀을 통해서 쓰레드를 재사용한다. )
    • 3) thread-A 는 쓰레드 풀에 아직 살아있다. 따라서 쓰레드 로컬의 thread-A 전용 보관소에 사용자A 데이터도 함께 살아있게 된다.
  • 사용자B 조회 요청
    • 1) 사용자B가 조회를 위한 새로운 HTTP 요청을 한다.
    • 2) WAS는 쓰레드 풀에서 쓰레드를 하나 조회한다.
    • 3) 쓰레드 thread-A 가 할당되었다. (물론 다른 쓰레드가 할당될 수 도 있다.)
    • 4) 이번에는 조회하는 요청이다. thread-A 는 쓰레드 로컬에서 데이터를 조회한다.
    • 5) 쓰레드 로컬은 thread-A 전용 보관소에 있는 사용자A 값을 반환한다.
    • 6) 결과적으로 사용자A 값이 반환된다.
    • 7) 사용자B는 사용자A의 정보를 조회하게 된다.

결과적으로 사용자B는 사용자A의 데이터를 확인하게 되는 심각한 문제가 발생하게 된다.
이런 문제를 예방하려면 사용자A의 요청이 끝날 때 쓰레드 로컬의 값을 ThreadLocal.remove() 를 통해서 꼭 제거해야 한다.
쓰레드 로컬을 사용할 때는 이 부분을 꼭! 기억하자.


10) 정리

  • 필드 동기화 - 개발
    • 이전 섹션에서의 파라미터 동기화는 관련 메서드마다 파라미터 수정이 필요하는 등 여러가지 비효율적인 부분이 많았다. 그래서 필드로 동기화하는 방식으로 개발했다.
  • 필드 동기화 - 적용
    • 필드 동기화 방식으로 애플리케이션에 적용해보았다. 일반적인 테스트시에도 문제없었다.
  • 필드 동기화 - 동시성 문제
    • 그런데 1초에 2번 이상 호출하면 동시성 문제가 발생하는 것을 확인하였다. (요청별로 트랜잭션ID도 구분되지 않고 레벨 표현도 뭔가 이상하게 출력되는 것을 확인할 수 있었다.)
  • 동시성 문제 - 예제 코드
    • 동시성 문제와 관련한 예제 코드를 확인해보았다. (동시성 문제는, 같은 인스턴스의 필드를 여러 쓰레드가 접근해서 사용할 때 발생한다는 것을 확인했다.)
  • ThreadLocal - 소개
    • 동시성 문제를 해결할 수 있는 방안 중 하나로 쓰레드 로컬에 대해서 알아보았다. ( 쓰레드 로컬을 사용하면 각 쓰레드마다 별도의 내부 저장소를 제공한다. 따라서 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제 없다. )
  • ThreadLocal - 예제 코드
    • 예제 코드를 통해 자세히 알아보았다.
  • 쓰레드 로컬 동기화 - 개발
    • 실제 우리의 어플리케이션이 적용하기 위해 개발해보았다. (FieldLogTrace에서 발생했던 동시성 문제를 해결하기 위해 ThreadLocalLogTrace를 추가했다.)
  • 쓰레드 로컬 동기화 - 적용
    • 쓰레드 로컬을 적용한 ThreadLocalLogTrace를 애플리케이션에 적용해보았다.
  • 쓰레드 로컬 - 주의사항
    • 웹애플리케이션은 일반적으로 쓰레드 풀을 사용하기 때문에, 마지막에 항상 사용이 끝나고 나면 remove()로 반드시 쓰레드 로컬 전용 보관소에 있는 값을 제거해야 한다.

강의를 듣고 정리한 글입니다. 코드와 그림 등의 출처는 김영한 강사님께 있습니다.

profile
현실에서 한 발자국

1개의 댓글

comment-user-thumbnail
2024년 10월 4일

안녕하세요~! 고급편- 패턴 부분 정리를 너무너무 잘해주셔서 도움 받으며 공부하고 있었는데
글이 갑자기 삭제되어서..... 다시 오픈해주실 수 있으실지... 조심스럽게 여쭈어봐도 될까요....?!

답글 달기