필드 동기화 - 개발
- 직전의 로그 추적기 v2에서는 TraceId를 파라미터로 넘김으로써 트랜잭션 ID를 공유하고, 메소드의 깊이를 나타낼 수 있었다.
- 다만, 로그를 출력해야 하는 곳에서는 무조건 TraceId가 파라미터로 있어야 한다는 문제점이 있다.
- 해당 문제를 해결하기 위한 방법을 알아보자.
LogTrace
- 향후 다양한 방식으로 구현할 수 있게 로그용 인터페이스를 생성한다.
- 로그 추적기를 위한 최소한의 기능인
begin
,end
,excption
을 정의한다.
FieldLogTrace
- LogTrace를 구현했다.
- 실제 기능은 이전의 TraceV2과 거의 동일하다.
테스트 생성 및 실행
Ctrl + Shift + T
를 통해 테스트를 생성 및 실행해보자.
- begin_end_level2 실행 로그
[ca243076] hello1
[ca243076] |–>hello2
[ca243076] |<–hello2 time=1ms
[ca243076] hello1 time=2ms - begin_exception_level2 실행 로그
[777ea750] hello
[777ea750] |–>hello2
[777ea750] |<X-hello2 time=0ms ex=java.lang.IllegalStateException
[777ea750] hello time=2ms ex=java.lang.IllegalStateException
테스트 결과
- 트랜잭션 ID도 동일하게 나오고, 메소드 호출에 따른 깊이도 잘 표현된다.
- FieldLogTrace.traceIdHolder 필드를 사용해서 TraceId가 동기화된다.
- 결론
- 불필요하게 TraceId 를 파라미터로 전달하지 않아도 된다.
- 메소드의 파라미터에 불필요하게 TraceId를 선언할 필요가 없어졌다.
필드 동기화 - 적용
스프링 빈 등록
- 우선 FieldLogTrace를 스프링 빈으로 등록하는 것이 좋다.
- 수동으로 등록하면 추후에 구현체를 편리하게 변경할 수 있다.
v2 → v3
- FieldLogTrace를 적용하기 위해 기존의 v2 패키지를 복사해서 v3으로 추가하자.
- v3 패키지 내부의 클래스명에서 v2을 v3로 변경한다.
- 각 클래스의 내부 로직에서 참고하는 타 클래스도 v3인지 확인한다.
- 컨트롤러에서 매핑 정보를
/v2/request
에서/v3/request
로 변경한다.
- TraceV2를 LogTrace로 변경한다.
- 각 메소드에서 사용하는 파라미터에 있는
TraceId taceId
를 모두 제거한다. - beginSync를 begin으로 변경한다.
컨트롤러
서비스
리포지토리
적용 결과 확인
- 정상 실행 (http://localhost:8081/v3/request?itemId=test)
[27735fbd] OrderController.request()
[27735fbd] |–>OrderService.orderItem()
[27735fbd] | |–>OrderRepository.save()
[27735fbd] | |<–OrderRepository.save() time=1001ms
[27735fbd] |<–OrderService.orderItem() time=1001ms
[27735fbd] OrderController.request() time=1002ms - 예외 실행 (http://localhost:8081/v3/request?itemId=ex)
[b4a6f209] OrderController.request()
[b4a6f209] |–>OrderService.orderItem()
[b4a6f209] | |–>OrderRepository.save()
[b4a6f209] | |<X-OrderRepository.save() time=1ms ex=java.lang.IllegalStateException: 예외 발생!
[b4a6f209] |<X-OrderService.orderItem() time=1ms ex=java.lang.IllegalStateException: 예외 발생!
[b4a6f209] OrderController.request() time=1ms ex=java.lang.IllegalStateException: 예외 발생!
적용 후기
- traceIdHolder를 필드를 통해 동기화를 했더니 파라미터를 추가할 필요없이 깔끔한 로그추적기를 만들 수 있엇다.
- 하지만 실무에 적용하기에는 아직 문제점이 있다.
필드 동기화 - 동시성 문제
- 로그 추적기 자체에는 문제가 없어보인다.
- 하지만 사실 FieldLogTrace에는 심각한 동시성 문제가 숨어있다.
문제 확인해보기
- v3 API를 1초 안에 여러 번 호출해보자 (http://localhost:8081/v3/request?itemId=test)
[nio-8081-exec-5] com.example.trace.FieldLogTrace : [84dee891] OrderController.request() [nio-8081-exec-5] com.example.trace.FieldLogTrace : [84dee891] |–>OrderService.orderItem() [nio-8081-exec-5] com.example.trace.FieldLogTrace : [84dee891] | |–>OrderRepository.save() [nio-8081-exec-7] com.example.trace.FieldLogTrace : [84dee891] | | |–>OrderController.request() [nio-8081-exec-7] com.example.trace.FieldLogTrace : [84dee891] | | | |–>OrderService.orderItem() [nio-8081-exec-7] com.example.trace.FieldLogTrace : [84dee891] | | | | |–>OrderRepository.save() [nio-8081-exec-5] com.example.trace.FieldLogTrace : [84dee891] | |<–OrderRepository.save() time=1013ms [nio-8081-exec-5] com.example.trace.FieldLogTrace : [84dee891] |<–OrderService.orderItem() time=1013ms [nio-8081-exec-5] com.example.trace.FieldLogTrace : [84dee891] OrderController.request() time=1013ms [nio-8081-exec-7] com.example.trace.FieldLogTrace : [84dee891] | | | | |<–OrderRepository.save() time=1002ms [nio-8081-exec-7] com.example.trace.FieldLogTrace : [84dee891] | | | |<–OrderService.orderItem() time=1002ms [nio-8081-exec-7] com.example.trace.FieldLogTrace : [84dee891] | | |<–OrderController.request() time=1002ms
- 분명 실행되는 쓰레드는 다를텐데 트랜잭션 ID는 동일하고, 순서는 뒤죽박죽 섞여있다.
- 어째서 이런 문제가 발생하는 것일까?
동시성 문제
- 사실 원인은 동시성때문이다.
- FieldLogTrace는 현재 싱글톤으로 등록되어 있는 스프링 빈이다.
- 즉, FieldLogTrace의 인스턴스는 애플리케이션에 단 1개만 존재한다.
- 싱글톤 빈에 여러 쓰레드가 동시에 접근하기 때문에 동시성이 발생하는 것이다.
- 이러한 동시성 문제는 실무에서 개발자를 유난히 괴롭히는 문제 중 하나이다.
동시성 문제 - 예제 코드
- 동시성 문제가 어떻게 발생하는지 단순화해서 알아보자.
- 우선 build.gradle의 dependencies에 아래의 코드를 추가하자.
- 아래 코드를 추가해야지 테스트 코드에서 @Slfj4 같은 애노테이션이 작동한다.
테스트 생성
- 우선 test쪽에 동시성 테스트를 위한 FieldService 클래스를 추가하자.
- 최대한 단순한 로직으로 확인해보자.
- 파라미터로 넘어온 name을 필드인 nameStore에 저장 후,
1초 간 정지한 뒤에 필드에 저장된 nameStore를 반환한다.
- 이번에는 FieldService를 테스트하기 위한 FieldServiceTest 클래스를 추가하자.
테스트 실행 (sleep 2000ms)
- logic 메소드 내부에 sleep(1000)으로 1초의 지연시간이 있다.
- 정상적인 결과를 확인하기 위해 테스트에서는 넉넉하게 2초(2000ms)의 지연시간을 설정했다.
- 지연시간을 넉넉하게 잡았기 때문에 쓰레드는 A → B 순으로 차례대로 진행됬다.
[Test worker] INFO com.example.trace.FieldServiceTest – main start [thread-A] INFO com.example.trace.FieldService – 저장 name=userA -> nameStore=null [thread-A] INFO com.example.trace.FieldService – 조회 nameStore=userA [thread-B] INFO com.example.trace.FieldService – 저장 name=userB -> nameStore=userA [thread-B] INFO com.example.trace.FieldService – 조회 nameStore=userB [Test worker] INFO com.example.trace.FieldServiceTest – main exit
테스트 실행 (sleep 1000ms)
- logic 메소드 내부에 sleep(1000)으로 1초의 지연시간이 있다.
- 정상적이지 않은 결과를 확인하기 위해 이번 테스트에서는 0.1초(100ms)의 지연시간을 설정했다.
- 지연시간을 짧게 잡았기 때문에 쓰레드 A와 B가 뒤죽박죽 실행되었다.
[Test worker] INFO com.example.trace.FieldServiceTest – main start [thread-A] INFO com.example.trace.FieldService – 저장 name=userA -> nameStore=null [thread-B] INFO com.example.trace.FieldService – 저장 name=userB -> nameStore=userA [thread-A] INFO com.example.trace.FieldService – 조회 nameStore=userB [thread-B] INFO com.example.trace.FieldService – 조회 nameStore=userB [Test worker] INFO com.example.trace.FieldServiceTest – main exit
동시성 문제가 발생하는 범위
- 지역 변수에서는 동시성 문제가 발생하지 않는다.
- 지역 변수는 쓰레드마다 각각 다른 메모리 영역이 할당된다.
- 동시성 문제는 같은 인스턴스의 필드에서 발생한다.
- 주로 싱글톤에서 자주 발생한다.
- static같은 공용 필드에 접근할 때 발생한다.
- 동시성 문제는 값을 읽기만 하면 발생하지 않는다.
- 동시성 문제는 어디선가 값을 변경하기 때문에 발생한다.
ThreadLocal
- 해당 쓰레드만 접근할 수 있는 특별한 저장소
- 싱글톤 객체의 필드를 사용하면서 동시성 문제를 해결할 수 있다.
- java.lang.ThreadLocal
일반적인 변수 필드와 ThreadLocal의 차이
- 일반적인 변수 필드
- 여러 쓰레드가 같은 인스턴스의 필드에 접근하면 처음 쓰레드가 보관한 데이터가 사라질 수 있다.
- ThreadLocal
- 각 쓰레드마다 별도의 내부 저장소를 제공한다.
- 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제 없다.
사용법
- 값 저장
- ThreadLocal.set(xxx)
- 값 조회
- ThreadLocal.get()
- 값 제거
- ThreadLocal.remove()
주의사항 (★★★)
- 해당 쓰레드가 쓰레드 로컬을 모두 사용하고 나면 ThreadLocal.remove() 를 호출해서
쓰레드 로컬에 저장된 값을 반드시 제거해주어야 한다. - 저장된 값을 제거하지 않으면 해당 쓰레드에서 계속 값을 가지고 있기 때문에,
항상 메모리를 차지하고 있어서 메모리 누수의 원인이 된다.
ThreadLocal - 예제 코드
테스트 생성 및 실행
- ThreadLocal의 사용법을 익히기 위해 test에 ThreadLocalService 클래스를 추가한다.
- 이제 ThreadLocalService를 테스트 하기 위해 ThreadLocalServiceTest 클래스를 추가한다.
- threadLocal 실행 로그
[Test worker] INFO com.example.trace.ThreadLocalServiceTest – main start
[thread-A] INFO com.example.trace.ThreadLocalService – 저장 name=userA -> nameStore=null
[thread-B] INFO com.example.trace.ThreadLocalService – 저장 name=userB -> nameStore=null
[thread-A] INFO com.example.trace.ThreadLocalService – 조회 nameStore=userA
[thread-B] INFO com.example.trace.ThreadLocalService – 조회 nameStore=userB
[Test worker] INFO com.example.trace.ThreadLocalServiceTest – main exit
테스트 결과
- 쓰레드 로컬을 통해 쓰레드는 각각 별도의 데이터 저장소를 가지게 되었다.
- 드디어 동시성 문제가 해결되었다.
쓰레드 로컬 동기화 - 개발
- FieldLogTrace 에서 발생했던 동시성 문제를 ThreadLocal로 해결해보자.
ThreadLocalLogTrace
- 필드 대신에 쓰레드 로컬을 사용해서 데이터를 동기화하는 ThreadLocalLogTrace를 생성한다.
테스트 생성 및 실행
Ctrl + Shift + T
를 통해 ThreadLocalLogTrace를 테스트하기 위한 클래스를 생성한다.
- begin_end_level2 실행 로그
[31295c64] hello1
[31295c64] |–>hello2
[31295c64] |<–hello2 time=0ms
[31295c64] hello1 time=8ms - begin_exception_level2 실행 로그
[e7c1fc9e] hello
[e7c1fc9e] |–>hello2
[e7c1fc9e] |<X-hello2 time=1ms ex=java.lang.IllegalStateException
[e7c1fc9e] hello time=7ms ex=java.lang.IllegalStateException
테스트 결과
- ThreadLocalLogTrace 자체는 문제가 없다.
- 다만 확실한 결과를 얻기 위해서는 애플리케이션에 ThreadLocalLogTrace를 적용해서 확인해봐야할 것 같다.
쓰레드 로컬 동기화 - 적용
- LogTraceConfig에서
return new FieldLogTrace();
를return new ThreadLocalLogTrace();
로 수정한다. - 그런 다음에 실제 API를 호출해서 로그를 확인해보자. (http://localhost:8081/v3/request?itemId=test)
실행 결과
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] OrderController.request()
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] |–>OrderService.orderItem()
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] | |–>OrderRepository.save()
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] OrderController.request()
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] |–>OrderService.orderItem()
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] | |–>OrderRepository.save()
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] | |<–OrderRepository.save() time=1002ms
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] |<–OrderService.orderItem() time=1002ms
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] OrderController.request() time=1003ms
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] | |<–OrderRepository.save() time=1014ms
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] |<–OrderService.orderItem() time=1014ms
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] OrderController.request() time=1014ms
로그를 트랜젹션 ID 기준으로 분리
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] OrderController.request()
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] |–>OrderService.orderItem()
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] | |–>OrderRepository.save()
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] | |<–OrderRepository.save() time=1002ms
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] |<–OrderService.orderItem() time=1002ms
[nio-8081-exec-1] com.example.trace.ThreadLocalLogTrace : [8e08ea14] OrderController.request() time=1003ms
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] OrderController.request()
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] |–>OrderService.orderItem()
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] | |–>OrderRepository.save()
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] | |<–OrderRepository.save() time=1014ms
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] |<–OrderService.orderItem() time=1014ms
[nio-8081-exec-2] com.example.trace.ThreadLocalLogTrace : [d4fcf2b4] OrderController.request() time=1014ms
적용 결과
- 각 쓰레드별로 로그가 정확하게 나누어져 있는 것을 확인할 수 있다.
쓰레드 로컬 - 주의사항
- 쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두게 된다면,
WAS(톰캣)처럼 쓰레드 풀을 사용하는 경우에 심각한 문제가 발생할 수 있다. - 문제를 가정해보자.
- 현재 쓰레드 풀은 3개의 쓰레드를 사용하도록 설정되어 있다.
- 이 때 사용자 A가 HTTP 요청을 시도했다.
- WAS는 사용자 A에게 쓰레드-1을 할당한다.
- 쓰레드-1은 사용자 A의 데이터를 쓰레드 로컬에 저장한다.
- 사용자 A의 HTTP 응답이 끝난다.
- 이 때, WAS는 쓰레드-1을 제거하는 게 아닌 쓰레드 풀에 반환한다.
- 즉, 쓰레드-1은 쓰레드 풀에서 계속 살아있기 때문에 쓰레드 로컬의 쓰레드-1에 대한 전용 보관소에 사용자 A의 데이터도 함께 살아있다.
- 왜 쓰레드를 제거하지 않고 쓰레드 풀에 반환할까?
- 쓰레드를 생성하는 비용이 비싸기 때문이다.
- 무엇이 문제가 될까?
- 데이터가 살아있기 때문에 그만큼 메모리를 잡아먹는다.
- 이는 메모리 누수의 원인이 된다.
- 쓰레드 풀에 반환된 쓰레드는 다른 사용자에게도 할당될 수 있다.
- 쓰레드 로컬의 값을 제거하지 않으면 데이터가 살아있는데 그 데이터를 다른 사용자가 볼 수 있게 된다.
- 데이터가 살아있기 때문에 그만큼 메모리를 잡아먹는다.