[스프링 핵심 원리 - 고급편] #3. 쓰레드 로컬 - ThreadLocal #649
Develop-KIM
started this conversation in
동환
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
필드 동기화 - 개발
앞서 로그 추적기를 만들면서 다음 로그를 출력할 때
트랜잭션ID와level을 동기화 하는 문제가 있었다.이 문제를 해결하기 위해
TraceId를 파라미터로 넘기도록 구현했다.이렇게 해서 동기화는 성공했지만, 로그를 출력하는 모든 메서드에
TraceId파라미터를 추가해야 하는 문제가 발생했다.TraceId를 파라미터로 넘기지 않고 이 문제를 해결할 수 있는 방법은 없을까?LogTrace 인터페이스
LogTrace인터페이스에는 로그 추적기를 위한 최소한의 기능인begin(),end(),exception()를 정의했다.이제 파라미터를 넘기지 않고
TraceId를 동기화 할 수 있는FieldLogTrace구현체를 만들어보자.FieldLogTrace
FieldLogTrace는 기존에 만들었던HelloTraceV2와 거의 같은 기능을 한다.TraceId를 동기화 하는 부분만 파라미터를 사용하는 것에서TraceId traceIdHolder필드를 사용하도록 변경되었다.이제 직전 로그의
TraceId는 파라미터로 전달되는 것이 아니라FieldLogTrace의 필드인traceIdHolder에 저장된다.여기서 중요한 부분은 로그를 시작할 때 호출하는
syncTraceId()와 로그를 종료할 때 호출하는releaseTraceId()이다.syncTraceId()TraceId를 새로 만들거나 앞선 로그의TraceId를 참고해서 동기화하고,level도 증가한다.TraceId를 새로 만든다.TraceId를 참고해서 동기화하고,level도 하나 증가한다.traceIdHolder에 보관한다.releaseTraceId()level이 하나 증가해야 하지만, 메서드 호출이 끝나면level이 하나 감소해야 한다.releaseTraceId()는level을 하나 감소한다.level==0)이면 내부에서 관리하는traceId를 제거한다.FieldLogTraceTest
begin_end_level2() - 실행 결과
begin_exception_level2() - 실행 결과
실행 결과를 보면
트랜잭션ID도 동일하게 나오고,level을 통한 깊이도 잘 표현된다.FieldLogTrace.traceIdHolder필드를 사용해서TraceId가 잘 동기화 되는 것을 확인할 수 있다.이제 불필요하게
TraceId를 파라미터로 전달하지 않아도 되고, 애플리케이션의 메서드 파라미터도 변경하지 않아도 된다.필드 동기화 - 적용
LogTrace 스프링 빈 등록
FieldLogTrace를 수동으로 스프링 빈으로 등록하자.수동으로 등록하면 향후 구현체를 편리하게 변경할 수 있다는 장점이 있다.
LogTraceConfig
OrderController
OrderServiceV3
OrderRepository
정상 실행 로그
예외 실행 로그
필드 동기화 - 동시성 문제
현재
FieldLogTrace는 심각한 동시성 문제를 가지고 있다.동시성 문제 확인
기대하는 결과
동시에 여러 사용자가 요청하면 여러 쓰레드가 동시에 애플리케이션 로직을 호출하게 된다.
따라서 로그는 이렇게 섞여서 출력된다.
기대하는 결과 - 로그 분리해서 확인하기
로그가 섞여서 출력되더라도 특정 트랜잭션ID로 구분해서 직접 분류해보면 이렇게 깔끔하게 분리된 것을 확인할 수 있다.
그런데 실제 결과는 기대한 것과 다르게 다음과 같이 출력된다.
실제 결과
실제 결과 - 로그 분리해서 확인하기
기대한 것과 전혀 다른 문제가 발생한다.
동시성 문제
FieldLogTrace는 싱글톤으로 등록된 스프링 빈이다. 이 객체의 인스턴스가 애플리케이션에 딱 1 존재한다는 뜻이다.이렇게 하나만 있는 인스턴스의
FieldLogTrace.traceIdHolder필드를 여러 쓰레드가 동시에 접근하기 때문에 문제가 발생한다.동시성 문제 - 예제 코드
테스트에서도 lombok을 사용하기 위해 다음 코드를 추가하자.
build.gradle이렇게 해야 테스트 코드에서
@Slfj4같은 애노테이션이 작동한다.FieldService
파라미터로 넘어온
name을 필드인nameStore에 저장한다.그리고 1초간 쉰 다음 필드에 저장된
nameStore를 반환한다.FieldServiceTest
순서대로 실행
sleep(2000)을 설정해서thread-A의 실행이 끝나고 나서thread-B가 실행되도록 해보자.참고로
FieldService.logic()메서드는 내부에sleep(1000)으로 1초의 지연이 있다.따라서 1초 이후에 호출하면 순서대로 실행할 수 있다. 여기서는 넉넉하게 2초 (2000ms)를 설정했다.
실행 결과
실행 결과를 보면 문제가 없다.
Thread-A는userA를nameStore에 저장했다.Thread-A는userA를nameStore에서 조회했다.Thread-B는userB를nameStore에 저장했다.Thread-B는userB를nameStore에서 조회했다.동시성 문제 발생 코드
이번에는
sleep(100)을 설정해서thread-A의 작업이 끝나기 전에thread-B가 실행되도록 해보자.참고로
FieldService.logic()메서드는 내부에sleep(1000)으로 1초의 지연이 있다. 따라서 1초 이후에 호출하면 순서대로 실행할 수 있다.다음에 설정할 100(ms)는 0.1초이기 때문에
thread-A의 작업이 끝나기 전에thread-B가 실행된다.실행 결과
thread-A가userA값을nameStore에 보관한다.thread-B가userB의 값을nameStore에 보관한다.기존에
nameStore에 보관되어 있던userA값은 제거되고userB값이 저장된다.thread-A의 호출이 끝나면서nameStore의 결과를 반환받는데, 이때nameStore는 앞의 2번에서userB의 값으로 대체되었다.따라서 기대했던
userA의 값이 아니라userB의 값이 반환된다.thread-B의 호출이 끝나면서nameStore의 결과인userB를 반환받는다.정리하면 다음과 같다.
Thread-A는userA를nameStore에 저장했다.Thread-B는userB를nameStore에 저장했다.Thread-A는userB를nameStore에서 조회했다.Thread-B는userB를nameStore에서 조회했다.동시성 문제
결과적으로
Thread-A입장에서는 저장한 데이터와 조회한 데이터가 다른 문제가 발생한다.이처럼 여러 쓰레드가 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제를 동시성 문제라 한다.
이런 동시성 문제는 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 하기 때문에
트래픽이 적은 상황에서는 확률상 잘 나타나지 않고, 트래픽이 점점 많아질수록 자주 발생한다.
특히 스프링 빈 처럼 싱글톤 객체의 필드를 변경하며 사용할 때 이러한 동시성 문제를 조심해야 한다.
ThreadLocal - 소개
쓰레드 로컬은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 말한다.
일반적인 변수 필드

여러 쓰레드가 같은 인스턴스의 필드에 접근하면 처음 쓰레드가 보관한 데이터가 사라질 수 있다.
thread-A가userA라는 값을 저장하고thread-B가userB라는 값을 저장하면 직전에thread-A가 저장한userA값은 사라진다.쓰레드 로컬

쓰레드 로컬을 사용하면 각 쓰레드마다 별도의 내부 저장소를 제공한다.
따라서 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제 없다.
thread-A가userA라는 값을 저장하면 쓰레드 로컬은thread-A전용 보관소에 데이터를 안전하게 보관한다.thread-B가userB라는 값을 저장하면 쓰레드 로컬은thread-B전용 보관소에 데이터를 안전하게 보관한다.쓰레드 로컬을 통해서 데이터를 조회할 때도
thread-A가 조회하면 쓰레드 로컬은thread-A전용 보관소에서userA데이터를 반환해준다.물론
thread-B가 조회하면thread-B전용 보관소에서userB데이터를 반환해준다.자바는 언어차원에서 쓰레드 로컬을 지원하기 위한
java.lang.ThreadLocal클래스를 제공한다.ThreadLocal - 예제 코드
ThreadLocalService
기존에 있던
FieldService와 거의 같은 코드인데,nameStore필드가 일반String타입에서ThreadLocal을 사용하도록 변경되었다.ThreadLocal 사용법
값 저장:
ThreadLocal.set(xxx)값 조회:
ThreadLocal.get()값 제거:
ThreadLocal.remove()주의
ThreadLocalServiceTest
실행 결과
쓰레드 로컬 덕분에 쓰레드 마다 각각 별도의 데이터 저장소를 가지게 되었다. 결과적으로 동시성 문제도 해결되었다.
쓰레드 로컬 동기화 - 개발
FieldLogTrace에서 발생했던 동시성 문제를ThreadLocal로 해결해보자.TraceId traceIdHolder필드를 쓰레드 로컬을 사용하도록ThreadLocal<TraceId> traceIdHolder로 변경하면 된다.필드 대신에 쓰레드 로컬을 사용해서 데이터를 동기화하는
ThreadLocalLogTrace를 새로 만들자.ThreadLocalLogTrace
traceIdHolder가 필드에서ThreadLocal로 변경되었다. 따라서 값을 저장할 때는set(..)을 사용하고, 값을 조회할 때는get()을 사용한다.ThreadLocal.remove()
추가로 쓰레드 로컬을 모두 사용하고 나면 꼭
ThreadLocal.remove()를 호출해서 쓰레드 로컬에 저장된 값을 제거해주어야 한다.쉽게 이야기해서 다음의 마지막 로그를 출력하고 나면 쓰레드 로컬의 값을 제거해야 한다.
여기서는
releaseTraceId()를 통해level이 점점 낮아져서 2 1 0이 되면 로그를 처음 호출한 부분으로 돌아온 것이다.따라서 이 경우 연관된 로그 출력이 끝난 것이다. 이제 더 이상
TraceId값을 추적하지 않아도 된다.그래서
traceId.isFirstLevel()(level==0)인 경우ThreadLocal.remove()를 호출해서 쓰레드 로컬에 저장된 값을 제거해준다.ThreadLocalLogTraceTest
begin_end_level2() - 실행 결과
begin_exception_level2() - 실행 결과
멀티쓰레드 상황에서 문제가 없는지는 애플리케이션에
ThreadLocalLogTrace를 적용해서 확인해보자.쓰레드 로컬 동기화 - 적용
LogTraceConfig - 수정
동시성 문제가 있는
FieldLogTrace대신에 문제를 해결한ThreadLocalLogTrace를 스프링 빈으로 등록하자.정상 실행 로그
예외 실행 로그
동시 요청
실행 결과
로그 분리해서 확인하기
로그를 직접 분리해서 확인해보면 각각의 쓰레드
nio-8080-exec-3,nio-8080-exec-4별로 로그가 정확하게 나누어 진 것을 확인할 수 있다.쓰레드 로컬 - 주의사항
사용자A 저장 요청

thread-A가 할당되었다.thread-A는사용자A의 데이터를 쓰레드 로컬에 저장한다.thread-A전용 보관소에사용자A데이터를 보관한다.사용자A 저장 요청 종료

thread-A를 쓰레드 풀에 반환한다. 쓰레드를 생성하는 비용은 비싸기 때문에 쓰레드를 제거하지 않고보통 쓰레드 풀을 통해서 쓰레드를 재사용한다.
thread-A는 쓰레드풀에 아직 살아있다. 따라서 쓰레드 로컬의thread-A전용 보관소에사용자A데이터도 함께 살아있게 된다.사용자B 조회 요청

thread-A가 할당되었다. (물론 다른 쓰레드가 할당될 수 도 있다.)thread-A는 쓰레드 로컬에서 데이터를 조회한다.thread-A전용 보관소에 있는사용자A값을 반환한다.사용자A값이 반환된다.All reactions