Notice
Recent Posts
Recent Comments
Link
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- 자바의 정석 기초편 ch12
- 자바의 정석 기초편 ch9
- 게시글 목록 api
- 자바의 정석 기초편 ch3
- 2024 정보처리기사 시나공 필기
- 자바의 정석 기초편 ch14
- 자바의 정석 기초편 ch8
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch4
- 자바의 정석 기초편 ch2
- 스프링 입문(무료)
- 자바의 정석 기초편 ch11
- 2024 정보처리기사 수제비 실기
- jpa - 객체지향 쿼리 언어
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch5
- 자바의 정석 기초편 ch13
- 타임리프 - 기본기능
- 스프링 mvc1 - 스프링 mvc
- 스프링 mvc2 - 검증
- 스프링 db1 - 스프링과 문제 해결
- 스프링 mvc2 - 로그인 처리
- 스프링 mvc1 - 서블릿
- 스프링 db2 - 데이터 접근 기술
- 코드로 시작하는 자바 첫걸음
- @Aspect
- jpa 활용2 - api 개발 고급
- 자바의 정석 기초편 ch1
- 자바의 정석 기초편 ch6
- 자바의 정석 기초편 ch7
Archives
- Today
- Total
나구리의 개발공부기록
쓰레드 로컬(ThreadLocal), 필드 동기화(개발/적용/동시성문제/예제코드), ThreadLocal(소개/예제코드), 쓰레드 로컬 동기화(개발/적용), 쓰레드 로컬 - 주의사항 본문
인프런 - 스프링 완전정복 코스 로드맵/스프링 핵심원리 - 고급편
쓰레드 로컬(ThreadLocal), 필드 동기화(개발/적용/동시성문제/예제코드), ThreadLocal(소개/예제코드), 쓰레드 로컬 동기화(개발/적용), 쓰레드 로컬 - 주의사항
소소한나구리 2024. 11. 5. 16:58출처 : 인프런 - 스프링 핵심 원리 - 고급편 (유료) / 김영한님
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용
1. 필드 동기화 - 개발
1) 정식버전 개발
(1) 프로토타입 버전의 문제점
- 앞서 개발한 프로토타입의 로그 추적기는 다음 로그를 출력할 때 트랜잭션ID와 level을 동기화를 하는 방법으로 해당 정보를 가지고 있는 TraceId를 파라미터로 넘기도록 구현하였음
- 동기화는 성공했지만 로그를 출력하는 모든 메서드에 TraceId를 파라미터에 추가해야하는 문제가 있었는데 이를 해결하는 정식 버전을 개발
(2) LogTrace 인터페이스
- 향후 다양한 구현체로 변경할 수 있도록 LogTrace 인터페이스를 생성
- 로그 추적기를 위한 최소한의 기능을하는 메서드들을 선언
public interface LogTrace {
TraceStatus begin(String message);
void end(TraceStatus traceStatus);
void exception(TraceStatus traceStatus, Exception e);
}
(3) FieldLogTrace
- 기존의 HelloTraceV2와 대부분의 기능이 동일함
- beginSync()메서드를 통해 파라미터를 사용하여 TraceId를 동기화했던 부분을 로그 추적기 자체의 변수(TraceId traceIdHolder)에서 보관하고 사용하도록 변경됨
- 여기서 추가된 syncTraceId()와 releaseTraceId()가 중요함
- syncTraceId()는 최초 호출일때는 TraceId를 새로 만들고 앞선 로그의 TraceId를 참고해서 동기화 하고 level을 증가시키며 결과를 traceIdHolder에 보관함
- releaseTraceId()는 메서드 호출이 끝날 때 동작하도록 complete 메서드의 마지막에서 호출하여 동작하고, level이 0(최초호출이면)이면 traceIdHolder를 null로 초기화시켜 TraceId를 제거하고 그게 아니면 level을 1 감소함
** 참고
- TraceId traceIdHolder 변수를 통해 traceId를 동기화하면 동시성 이슈가 발생하는데 뒤에서 해결함
package hello.advanced.trace.logtrace;
@Slf4j
public class FieldLogTrace implements LogTrace {
// ... PREFIX 상수 동일
private TraceId traceIdHolder; // traceId 동기화, **동시성 이슈 발생함
@Override
public TraceStatus begin(String message) {
// traceId를 바로 생성하지 않고 syncTraceId 메서드를 통해서 생성
syncTraceId();
TraceId traceId = traceIdHolder; // syncTraceId 메서드를 통해 생성된 TraceId가 담긴 traceIdHolder에서 꺼내서 사용
// ... 이하 로직 동일
}
// TraceId를 생성하거나 level을 1 증가시키는 메서드
private void syncTraceId() {
if (traceIdHolder == null) {
traceIdHolder = new TraceId();
} else {
traceIdHolder = traceIdHolder.createNextId();
}
}
// end(), exception() 동일
private void complete(TraceStatus status, Exception e) {
// 기존 로직 동일, releaseTraceId()호출 코드 추가
releaseTraceId();
}
// TraceId의 level을 감소하거나 null로 초기화 시키는 메서드
private void releaseTraceId() {
if (traceIdHolder.isFirstLevel()) {
traceIdHolder = null; // 첫번째 레벨이면 traceIdHolder가 동작하지 않도록 null로 초기화
} else {
traceIdHolder = traceIdHolder.createPreviousId();
}
}
// addSpace() 로직 동일
}
(3) 테스트
- FieldLogTrace를 테스트해보면 트랜잭션ID도 동일하게 나오고 level을 통한 계층 표현도 잘동작하고 있음
- 변경된 로그 추적기를 적용하면 TraceId를 파라미터로 전달하지 않아도 애플리케이션의 메서드 파라미터도 변경하지 않아도 됨
package hello.advanced.trace.logtrace;
class FieldLogTraceTest {
FieldLogTrace trace = new FieldLogTrace();
@Test
void begin_end_level2() {
TraceStatus status1 = trace.begin("hello1");
TraceStatus status2 = trace.begin("hello2");
TraceStatus status3 = trace.begin("hello3");
trace.end(status3);
trace.end(status2);
trace.end(status1);
}
@Test
void begin_exception_level2() {
TraceStatus status1 = trace.begin("hello1");
TraceStatus status2 = trace.begin("hello2");
TraceStatus status3 = trace.begin("hello3");
trace.exception(status3, new IllegalStateException());
trace.exception(status2, new IllegalStateException());
trace.exception(status1, new IllegalStateException());
}
}
/* 실행결과
end() 실행결과
[7f980bfa] hello1
[7f980bfa] |-->hello2
[7f980bfa] | |-->hello3
[7f980bfa] | |<--hello3 time=1ms
[7f980bfa] |<--hello2 time=1ms
[7f980bfa] hello1 time=4ms
exception() 실행결과
[fe4f0c39] hello1
[fe4f0c39] |-->hello2
[fe4f0c39] | |-->hello3
[fe4f0c39] | |<X-hello3 time=0ms ex=java.lang.IllegalStateException
[fe4f0c39] |<X-hello2 time=0ms ex=java.lang.IllegalStateException
[fe4f0c39] hello1 time=0ms ex=java.lang.IllegalStateException
*/
2. 필드 동기화 - 적용
1) FieldLogTrace를 애플리케이션에 적용
(1) 스프링 빈 등록
- FieldLogTrace를 수동으로 스프링 빈으로 등록
- 수동으로 등록하면 향후 구현체를 편리하게 변경할 수 있는 장점이 있음
- @Configuration으로 컴포넌트스캔이 대상이되며 @Bean으로 FieldLogTrace를 싱글톤으로 스프링 빈에 등록
@Configuration
public class LogTraceConfig {
@Bean
public LogTrace logTrace() {
return new FieldLogTrace();
}
}
(2) v2 -> v3 복사
- 코드 내부의 의존관계 클래스 변경
- 매핑정보를 v3/request로 변경
- HelloTraceV2를 LogTrace 인터페이스를 사용하도록 변경
- TraceId traceId 파라미터를 모두 제거하고 beginSync()메서드를 begin()로 모두 변경
(3) OrderControllerV3 적용
package hello.advanced.app.v3;
@RestController
@RequiredArgsConstructor
public class OrderControllerV3 {
private final LogTrace trace; // LogTrace 인터페이스로 변경
@GetMapping("/v3/request") // 매핑 변경
public String request(String itemId) {
// getTraceId() 제거
orderService.orderItem(itemId);
// .. 나머지 코드 동일
}
(4) OrderServiceV3 적용
package hello.advanced.app.v3;
@Service
@RequiredArgsConstructor
public class OrderServiceV3 {
private final LogTrace trace; // LogTrace 적용
public void orderItem(String itemId) { // traceId 파라미터 제거
// begin()으로 변경
status = trace.begin("OrderService.orderItem()");
orderRepository.save(itemId); // getTraceId() 제거
// ... 기존코드 동일
}
(5) OrderRepositoryV3 적용
package hello.advanced.app.v3;
@Repository
@RequiredArgsConstructor
public class OrderRepositoryV3 {
private final LogTrace trace; // LogTrace 인터페이스 적용
public void save(String itemId) { // traceId 파라미터 제거
// begin()으로 변경
status = trace.begin("OrderRepository.save()");
// ... 기존코드 동일
}
(6) 실행 결과
- 매핑된 url로 요청 파라미터를 전송하면 정상적으로 로그들이 남겨진 것을 확인할 수 있음
3. 필드 동기화 - 동시성 문제
1) 동시성 문제 발생
(1) 동시성 문제 확인
- 위에서 만든 로그 추적기는 사실 심각한 동시성 문제를 가지고 있음
- 동시성 문제를 확인하려면 동시에 여러번 호출해보면 알 수 있음
(2-1) 기대하는 결과
- nio-8080-exec-숫자는 tomcat이 제공해주는 Thread Pool에 있는 쓰레드임
- 두번 연속으로 요청을 했을 때 기대하는 결과는 각각의 쓰레드에 따라서 트랜잭션ID가 부여되고 계층구조도 각 트랜잭션ID에 따라 표현되도록 동작해야함
[nio-8080-exec-3] [52808e46] OrderController.request()
[nio-8080-exec-3] [52808e46] |-->OrderService.orderItem()
[nio-8080-exec-3] [52808e46] | |-->OrderRepository.save()
[nio-8080-exec-4] [4568423c] OrderController.request()
[nio-8080-exec-4] [4568423c] |-->OrderService.orderItem()
[nio-8080-exec-4] [4568423c] | |-->OrderRepository.save()
[nio-8080-exec-3] [52808e46] | |<--OrderRepository.save() time=1001ms
[nio-8080-exec-3] [52808e46] |<--OrderService.orderItem() time=1001ms
[nio-8080-exec-3] [52808e46] OrderController.request() time=1003ms
[nio-8080-exec-4] [4568423c] | |<--OrderRepository.save() time=1000ms
[nio-8080-exec-4] [4568423c] |<--OrderService.orderItem() time=1001ms
[nio-8080-exec-4] [4568423c] OrderController.request() time=1001ms
(2-2) 기대하는 결과 - 로그 분리해서 확인
- 각 쓰레드 즉, 트랜잭션ID별로 분리해서 로그를 묶어서 확인해보면 아래처럼 로그가 구성되어야 정상적으로 동작한 결과임
- 로그가 섞여서 출력되더라도 트랜잭션ID나 쓰레드로 구분해서 분류해보면 깔끔하게 분리되어있어야 함
[52808e46]
[nio-8080-exec-3] [52808e46] OrderController.request()
[nio-8080-exec-3] [52808e46] |-->OrderService.orderItem()
[nio-8080-exec-3] [52808e46] | |-->OrderRepository.save()
[nio-8080-exec-3] [52808e46] | |<--OrderRepository.save() time=1001ms
[nio-8080-exec-3] [52808e46] |<--OrderService.orderItem() time=1001ms
[nio-8080-exec-3] [52808e46] OrderController.request() time=1003ms
[4568423c]
[nio-8080-exec-4] [4568423c] OrderController.request()
[nio-8080-exec-4] [4568423c] |-->OrderService.orderItem()
[nio-8080-exec-4] [4568423c] | |-->OrderRepository.save()
[nio-8080-exec-4] [4568423c] | |<--OrderRepository.save() time=1000ms
[nio-8080-exec-4] [4568423c] |<--OrderService.orderItem() time=1001ms
[nio-8080-exec-4] [4568423c] OrderController.request() time=1001ms
(3-1) 실제결과
- 실제 실행된 결과를 보면 쓰레드는 각각 별도의 쓰레드로 실행 되었는데 로그의 출력결과가 이상함
- 동일한 트랜잭션ID로 출력되었으며 계층구조도 마치 하나의 HTTP 요청인듯 아닌듯 이상하게 표현되었음
(3-2) 실제결과 - 로그 분리해서 확인
- 트랜잭션ID가 같으니 쓰레드별로 구분해서 로그를 확인해보면 9번쓰레드의 결과는 정상적인 것 같은데 1번 쓰레드의 로그를 보면 계층표현이 기대하는 결과와 완전히 상이함
- 분명히 테스트 코드나 하나씩 실행했을때는 문제가 없었는데 결과를 보면 문제가 발생되었음
(4) 동시성 문제가 발생함
- FieldLogTrace는 이 객체의 인스턴스는 애플리케이션 딱 1개 존재하는 싱글톤으로 등록된 스프링 빈인데, FieldLogTrace.traceIdHolder 필드를 여러 쓰레드가 동시에 접근하기 때문에 이런 동시성 문제가 발생함
- 실무에서 한번 나타나면 개발자를 가장 괴롭히는 문제도 이런 동시성 문제이며 난이도 있는 면접에서 이런 질문이 나오기도 하기때문에 필수로 학습을 해야함
4. 동시성 문제 - 예제 코드
- 동시성 문제가 어떻게 발생하는지 단순화해서 알아보기
1) 테스트 세팅
(1) build.gradle
- 테스트에서 롬복 사용을 위해 build.gradle 설정 추가
- 이렇게 해야 테스트 코드에서 @Slf4j 같은 애노테이션이 동작함
dependencies {
...
//테스트에서 lombok 사용
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
}
(2) FieldService 추가
- src/test의 ~.threadlocal.code 경로에 따라 패키지를 생성하여 클래스를 작성
- 파라미터로 넘어온 name을 필드인 nameStore에 저장하고 1초간 쉰다음 필드에 저장된 nameStore를 반환하는 매우 단순한 로직으로 구성
@Slf4j
public class FieldService {
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();
}
}
}
2) 테스트 코드
(1) 동시성 문제가 발생하지 않는 테스트코드
- Thread를 threadA, threadB 두개를 생성하고 start로 각 스레드를 동작시킬 때 두 쓰레드 사이에 sleep(2000)으로 2초간 대기시간을 두어 threadA가 끝나고 threadB가 실행되도록 테스트 코드를 작성
- fieldService.login() 메서드의 내부에 sleep(1000) 메서드로 1초를 지연시키는 코드가 있어서 1초 이후에 다음 스레드를 호출하면 순서대로 시작할 수 있음
- 즉, 해당 코드는 각 쓰레드의 동작 사이에 sleep(2000)메서드가 존재하여 모든 쓰레드가 동시성 문제없이 정상 동작함
** 쓰레드 관련 - 자바 기본
- 원래는 threadA와 threadB는 각각의 일반쓰레드(데몬쓰레드가 아닌 쓰레드)이므로 원래의 애플리케이션 환경이였다면 main 쓰레드가 종료되어도 각각의 일반쓰레드가 모두 종료될 때까지 애플리케이션이 유지 됨
- 그러나 지금처럼 테스트인 JUnit에서는 메인 스레드가 종료되면 테스트가 조기에 완료된 것으로 처리되기 때문에 마지막 쓰레드의 작업이 모두 끝날 때까지 sleep()으로 main쓰레드가 종료되지 않도록 대기시간을 주어야 모든 쓰레드가 동작할 수 있음
- CountDownLatch()를 사용하면 더 효율적이지만 단순한 예제에 적용하면 오히려 코드가 더 복잡해질 수 있어서 지금은 sleep을 적용
package hello.advanced.trace.threadlocal;
@Slf4j
public class FieldServiceTest {
private FieldService fieldService = new FieldService();
@Test
void field() {
log.info("main start");
Runnable userA = () -> {
fieldService.logic("userA");
};
Runnable userB = () -> {
fieldService.logic("userB");
};
Thread threadA = new Thread(userA);
threadA.setName("threadA");
Thread threadB = new Thread(userB);
threadB.setName("threadB");
threadA.start();
sleep(2000); // 동시성 문제가 발생하지 않도록 2초를 쉼
threadB.start();
sleep(3000); // 메인 쓰레드 종료 대기
log.info("main exit");
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
(2) 실행 결과
- 각각의 쓰레드가 저장하고 조회하여 동시성 문제가 없이 정상적으로 수행 된 것을 알 수 있음
- 로그를 보면 순차적으로 threadA가 동작할 때 파라미터로 넘어온 userA를 nameStore에 저장한뒤 userA가 조회되고 threadB가 동작할 때 파라미터로 넘어온 userB를 userA가 저장되어있는 nameStore에 저장하고 조회함으로써 userB가 반환되고 있음
(3) 강제로 동시성 문제를 발생
- threadA와 threadB사이에 있는 sleep(2000)메서드를 sleep(100)로 변경
- 각 쓰레드가 동작하는 logic()메서드의 내부에 1초 지연하는 코드가 있어 threadA의 작업이 끝나기 전에 threadB가 실행되어 동시성 문제가 나타남
threadA.start();
// sleep(2000); // 동시성 문제가 발생하지 않도록 2초를 쉼
sleep(100); // 동시성 문제 발생
threadB.start();
(4) 동시성 문제가 발생한 실행 결과
- 결과를 보면 저장하는 부분은 문제가 없어보이는데 조회하는 부분이 threadA, threadB모두 userB를 조회하고있고 동작 순서도 저장 -> 조회, 저장 -> 조회가 아닌 모두 저장 후 조회가 되고있는것을 확인할 수 있음
- 즉, threadA가 먼저 동작하여 nameStore에 userA를 저장했는데 threadA가 조회하기 전에(1초 후 조회) threadB가 동작(0.1초 만에 동작)하면서 nameStore의 값을 userB로 바꿔버림
- 그래서 threadA가 조회하는 동작하는 시점에 threadB가 바꿔버린 nameStore의 값 userB를 조회하게 되는데 바로 동시성문제가 발생된 것임
(5) 동시성 문제
- ThreadA입장에서는 저장한 데이터와 조회한 데이터가 다른 문제가 발생했는데, 이처럼 여러 쓰레드가 동시에 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제를 동시성 문제라고 함
- 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 발생하기 때문에 트래픽이 적은 상황에서는 확률상 잘 나타나지 않는데 트래픽이 점점 많아질 수록 자주 발생하게되며 특히 스프링 빈처럼 싱글톤 객체의 필드를 변경하며 사용할 때 이러한 동시성 문제를 조심해야함
** 참고
- 이런 동시성 문제는 쓰레드마다 각각 다른 메모리 영역이 할당되는 지역변수에서는 발생하지 않음
- 동시성 문제가 발생하는 곳은 같은 인스턴스의 필드(주로 싱글톤에서 자주 발생)나 static 같은 공용 필드에 접근할 때 발생함
- 또한 값을 읽기(조회)만하는 경우에는 발생하지않고 동시에 접근한 어디에선가 값을 변경할 때 발생함
5. ThreadLocal - 소개
1) ThreadLocal
(1) 소개
- 쓰레드 로컬은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 뜻하는데 쉽게 이야기하면 물건 보관 창구와 비슷한 느낌임
- 여러 사람이 같은 물건 보관 창구를 이용하더라도 창구 직원은 사용자를 인식해서 사용자별로 확실하게 물건을 구분해주는 것과 같이 각 쓰레드는 쓰레드로컬이라는 창구 직원을 통해 값을 보관하고 꺼내지만, 창구 직원인 쓰레드로컬이 사용자에 따라 보관한 물건을 구분해주는 것임
(2) 일반적인 변수 필드
- 여러 쓰레드가 같은 인스턴스의 필드에 접근하면 처음 쓰레드가 보관한 데이터가 사라질 수 있음
- 아래의 이미지 처럼 일반적인 변수 필드는 thread-A가 userA를 저장후 thread-B가 userB를 다시 저장하면 thread-A가 저장한 userA의 값은 사라짐
(3) 쓰레드 로컬
- 그러나 쓰레드 로컬을 사용하면 각 쓰레드마다 별도의 내부 저장소를 제공하기에 같은 인스턴스의 쓰레드 로컬 필드에 접근을해도 문제가 없음
- 아래의 이미지처럼 동일 쓰레드 로컬에 thread-A와 thread-B가 접근하여 값을 저장을 해도 쓰레드 로컬은 각각의 전용 보관소에 따로 안전하게 값을 보관해두고 thread-A와 thread-B가 조회를 각 쓰레드의 전용 보관소에 보관해둔 값을 각 쓰레드에 맞게 반환해줌
- 쓰레드로컬은 완전히 동시에 여러쓰레드가 접근을하여 값을 변경해도 값이 안전하게 보장되도록 동작함
- 자바는 언어차원에서 쓰레드 로컬을 지원하기 위해 java.lang.ThreadLocal 클래스를 제공함
6. ThreadLocal - 예제 코드
1) 테스트코드에 ThreadLocal 적용
(1) ThreadLocalService
- 기존 코드와 거의 동일하며 일반 필드였던 nameStore를 new ThreadLocal<>() 객체를 생성한 참조값을 참조하도록 변경
- ThreadLocal은 객체이기 때문에 값을 저장하고 조회할때 .set()과 .get()메서드를 통해 저장 및 조회를 할 수 있음
- 값을 제거할 때는 .remove() 메서드를 통해서 할 수 있음
** 주의
- 해당 쓰레드가 쓰레드 로컬은 모두 사용하고 나면 .remove()메서드를 호출해서 쓰레드 로컬에 저장된 값을 꼭 제거해주어야 함
- 뒤에서 보충 설명함
@Slf4j
public class ThreadLocalService {
private ThreadLocal<String> nameStore = new ThreadLocal<>();
public String logic(String name) {
log.info("저장 name={} -> nameStore={}", name, nameStore.get());
nameStore.set(name); // .set으로 쓰레드 로컬에 값을 저장
sleep(1000);
log.info("조회 nameStore={}", nameStore.get());
return nameStore.get(); // .get으로 쓰레드 로컬의 값을 조회
}
// 기존코드 동일
}
(2) 테스트 및 결과
- 테스트 로직은 똑같고 생성하는 Service객체를 ThreadLocalService()로 변경하고 각 쓰레드가 수행할 동작을 service의 메서드로 변경
- 적용하고 테스트를 해보면 threaA와 threadB사이에 존재하던 sleep() 메서드의 인수를 100으로하든, sleep()메서드를 제거를 하든 각 쓰레드가 저장했던 값이 정상적으로 저장되고 조회됨
- 즉, 동시성 문제가 해결되었음
@Slf4j
public class ThreadLocalServiceTest {
private ThreadLocalService service = new ThreadLocalService();
@Test
void field() {
log.info("main start");
Runnable userA = () -> {
service.logic("userA");
};
Runnable userB = () -> {
service.logic("userB");
};
// 이하 테스트 로직은 동일
}
7. 쓰레드 로컬 동기화 - 개발
1) 로그추적기에 쓰레드 로컬 적용
(1) ThreadLocalLogTrace생성
- 대부분의 로직은 기존 FieldLogTrace와 동일
- FieldLogTrace의 traceIdHolder 필드를 ThreadLocal로 변경하여 동시성 문제를 해결
- traceIdHolder가 쓰레드 로컬 객체로 변경됨에 따라 begin(), syncTraceId(), releaseTraceId() 메서드에서 traceIdHolder를 저장, 조회, 삭제하는 코드를 get(), set(), remove() 메서드를 활용하도록 수정
- 마지막 로그를 출력하고 나면 remove()를 호출해서 쓰레드 로컬에 저장된 값을 제거해주어야 메모리 누수가 발생하지 않음
package hello.advanced.trace.logtrace;
@Slf4j
public class ThreadLocalLogTrace implements LogTrace {
// PREFIX 변수 동일
// 쓰레드 로컬로 변경
private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>();
@Override
public TraceStatus begin(String message) {
syncTraceId();
TraceId traceId = traceIdHolder.get(); // 쓰레드 로컬에서 traceId를 꺼내기
// 기존 코드 동일
}
private void syncTraceId() {
TraceId traceId = traceIdHolder.get(); // traceIdHolder.get()으로 traceId를 꺼내기
if (traceId == null) {
traceIdHolder.set(new TraceId()); // set으로 traceId 생성
} else {
traceIdHolder.set(traceId.createNextId());
}
}
// 기존 코드 동일
private void releaseTraceId() {
TraceId traceId = traceIdHolder.get();
if (traceId.isFirstLevel()) {
traceIdHolder.remove(); // .remove로 쓰레드로컬을 제거(각 쓰레드의 전용 보관소를 제거)
} else {
traceIdHolder.set(traceId.createPreviousId());
}
}
// 기존 코드 동일
}
(2) ThreadLocalLogTrace테스트
- 기존의 FieldLogTraceTest와 동일한 테스트케이스로 ThreadLocalLogTrace를 생성하여 테스트를 해보면 정상적으로 로그가 수행됨
- 멀티쓰레드 상황은 아니지만 코드가 정상적으로 수행되는 것을 확인할 수 있음
8. 쓰레드 로컬 동기화 - 적용
1) 애플리케이션 적용
(1) LogTraceConfig수정
- 스프링 빈으로 등록했던 LogTrace의 구현체만 ThreadLocalLogTrace()로 변경하면 애플리케이션에 적용됨
- 모든 계층에서 구현체를 직접 의존하는것이 아니라 인터페이스를 의존하고있기 때문에 클라이언트의 코드 수정이 없이 한줄의 수정만으로 OCP를 지키며 의존관계주입을 통해 애플리케이션에 변경된 로그 추적기를 적용할 수 있음
@Configuration
public class LogTraceConfig {
@Bean
public LogTrace logTrace() {
return new ThreadLocalLogTrace(); // 새로운 로그 추적기 적용
}
}
(2) 애플리케이션 실행
- 테스트를 해보면 기존에 동시성 문제가 해결되어 정상적으로 로그가 출력됨
- 연속적으로 빠르게 동일 요청을 보내봐도 문제가 없이 각 쓰레드, 트랜잭션ID별로 계층을 표시하며 로그가 출력되는 것을 확인할 수 있음
- 트랜젝션ID별로 로그를 구분하는 것은 생략(어차피 정상 동작 되었기에..)
9. 쓰레드 로컬 - 주의사항
1) 문제의 상황
- 쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 WAS처럼 쓰레드 풀을 사용하는 경우에 심각한 문제가 발생할 수 있음
(1) 사용자 A가 저장을 요청 후 종료
- 사용자 A가 저장 HTTP를 요청하면 WAS는 쓰레드 풀에서 쓰레드를 하나 조회하고 할당된 쓰레드로 사용자 A의 데이터를 쓰레드 로컬에 저장
- 쓰레드 로컬에 할당된 쓰레드의 전용 보관소에 사용자 A의 데이터를 보관 후 사용자 A의 HTTP응답이 종료됨
- WAS는 사용이 끝난 쓰레드를 쓰레드 풀에 반환함(쓰레드를 생성하는 비용은 비싸기 때문에 쓰레드를 제거하지 않고 보통 쓰레드 풀을 통해서 쓰레드를 재사용함)
- 즉, 사용자 A가 저장할 때 사용한 쓰레드가 살아있으므로 쓰레드 로컬에 해당 쓰레드의 전용보관소도 사용자A의 데이터와 함께 살아있음
(2) 사용자 B가 조회를 요청 - 문제 발생
- 사용자 B가 조회를 위한 새로운 HTTP를 요청하였고 WAS는 쓰레드 풀에서 쓰레드를 할당하는데 하필 사용자 A가 저장할때 사용하였던 쓰레드가 할당됨
- 조회 요청이기에 할당된 쓰레드는 쓰레드 로컬에서 데이터를 조회하고 쓰레드 로컬은 전용 쓰레드에 보관되어있던 사용자 A의 정보를 반환함
- 결과적으로 사용자 B가 사용자 A의 정보를 조회하게되는 심각한 문제가 발생함
(3) 문제 예방
- 이와같은 문제를 예방하기 위해서 사용자 A의 요청이 끝났을 때 쓰레드 로컬의 값을 ThreadLocal.remove()로 꼭 제거해야 함
- 이부분은 쓰레드 로컬을 사용할 때 매우 중요한 부분이므로 꼭 기억해야함