BackEnd/Spring

[Spring] 쓰레드 로컬 - ThreadLocal - 2

샤아이인 2022. 8. 5.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

[Spring] 쓰레드 로컬 - ThreadLocal - 2

 

5. ThreadLocal - 소개

쓰레드 로컬은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 말한다.

쉽게 이야기해서 물건 보관 창구를 떠올리면 된다.

여러 사람이 동일한 물건 보관 창구의 직원에게 물건을 전달하면, 직원은 사용자를 인식해서 사용자별로 확실하게 물건을 구분해준다.

 

쓰레드 로컬을 사용하면 각 Thread마다 별도의 내부 저장소를 제공한다.

따라서 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제 없다.

 

[Spring] 쓰레드 로컬 - ThreadLocal - 2 - 				
    
    	5. ThreadLocal - 소개
출처 - 인프런 김영한 고급편

thread-A 가 userA 라는 값을 저장하면 쓰레드 로컬은 thread-A 전용 보관소에 데이터를 안전하게 보관한다.

 

[Spring] 쓰레드 로컬 - ThreadLocal - 2 - 				
    
    	5. ThreadLocal - 소개
출처 - 인프런 김영한 고급편

thread-B 가 userB 라는 값을 저장하면 쓰레드 로컬은 thread-B 전용 보관소에 데이터를 안전하게 보관한다.

 

[Spring] 쓰레드 로컬 - ThreadLocal - 2 - 				
    
    	5. ThreadLocal - 소개

쓰레드 로컬을 통해서 데이터를 조회할 경우:

thread-A 가 조회하면 쓰레드 로컬은 thread-A 전용 보관소에서 userA 데이터를 반환해준다.

thread-B 가 조회하면 쓰레드 로컬은 thread-B 전용 보관소에서 userB 데이터를 반환해준다.

 

6. ThreadLocal - 예제 코드

기존의 코드와 거의 동일하지만, ThreadLocal을 사용하도록 코드를 변경시켜보자!

@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);
        sleep(1000);
        log.info("조회 nameStore={}", nameStore.get());
        return nameStore.get();
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

- ThreadLocal 사용법
값 저장: ThreadLocal.set(xxx)

값 조회: ThreadLocal.get()
값 제거: ThreadLocal.remove()

 

실제로 동작하는 흐름을 테스트 해보자

@Slf4j
public class ThreadLocalServiceTest {

    ThreadLocalService service = new ThreadLocalService();

    @Test
    void field() {
        log.info("main start");

        Runnable userA = () -> {
            service.logic("userA");
        };
        Runnable userB = () -> {
            service.logic("userB");
        };

        Thread threadA = new Thread(userA);
        threadA.setName("thread-A");
        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start(); // A실행
        sleep(2000);
        threadB.start();
        sleep(3000); // 메인 쓰레드 종료 대기

        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 이전에 위에서 진행했던 코드와 거의 동일하다, 변경된 점은 ThreadLocalService를 사용하는 부분일 뿐이다.

 

실행결과를 살펴보자, 각각 2초, 0.2초를 sleep한 경우이다.

[Spring] 쓰레드 로컬 - ThreadLocal - 2 - 				
    
    	6. ThreadLocal - 예제 코드
    
[Spring] 쓰레드 로컬 - ThreadLocal - 2 - 				
    
    	6. ThreadLocal - 예제 코드

이전과 달리 Thread마다 개별 저장소가 있기 때문에, 각자 자신의 것을 출력하고 있다!

동시성 문제가 해결되었다! 

 

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

우리의 애플리케이션에 ThreadLocal을 적용시켜보자!

@Slf4j
public class ThreadLocalLogTrace implements LogTrace {

    private static final String START_PREFIX = "-->";
    private static final String COMPLETE_PREFIX = "<--";
    private static final String EX_PREFIX = "<X-";

    // ThreadLocal로 변경
    private ThreadLocal<TraceId> traceIdHolder = new ThreadLocal<>();

    @Override
    public TraceStatus begin(String message) {
        syncTraceId();
        TraceId traceId = traceIdHolder.get();
        Long startTimeMs = System.currentTimeMillis();
        log.info("[{}] {}{}", traceId.getId(), addSpace(START_PREFIX, traceId.getLevel()), message);
        return new TraceStatus(traceId, startTimeMs, message);
    }

    private void syncTraceId() {
        TraceId traceId = traceIdHolder.get();
        if(traceId == null) {
            traceIdHolder.set(new TraceId());
        } else {
            traceIdHolder.set(traceId.createNextId());
        }
    }

    @Override
    public void end(TraceStatus status) {
        complete(status, null);
    }

    @Override
    public void exception(TraceStatus status, Exception e) {
        complete(status, e);
    }

    private void complete(TraceStatus status, Exception e) {
        Long stopTimeMs = System.currentTimeMillis();
        long resultTimeMs = stopTimeMs - status.getStartTimeMs();
        TraceId traceId = status.getTraceId();
        if (e == null) {
            log.info("[{}] {}{} time={}ms", traceId.getId(), addSpace(COMPLETE_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs);
        } else {
            log.info("[{}] {}{} time={}ms ex={}", traceId.getId(), addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(), resultTimeMs, e.toString());
        }

        releaseTraceId();
    }

    private void releaseTraceId() {
        TraceId traceId = traceIdHolder.get();
        if(traceId.isFirstLevel()) {
            traceIdHolder.remove();
        } else {
            traceIdHolder.set(traceId.createPreviousId());
        }
    }

    private static String addSpace(String prefix, int level) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < level; i++) {
            sb.append((i == level - 1) ? "|" + prefix : "|   ");
        }
        return sb.toString();
    }
}

ThreadLocal을 통해 LogTrace를 개선하였다.

 

추가로 쓰레드 로컬을 모두 사용하고 나면 꼭 ThreadLocal.remove() 를 호출해서 쓰레드 로컬에 저장된 값을 제거해주어야 한다.

만약 제거해주지 않는다면, 메모리 누수가 발생할수도 있다.

 

이전과 동일한 테스트로 실행결과를 살펴보자.

class ThreadLocalLogTraceTest {

    ThreadLocalLogTrace trace = new ThreadLocalLogTrace();

    @Test
    public void begin_end_level2() {
        TraceStatus status1 = trace.begin("hello1");
        TraceStatus status2 = trace.begin("hello2");
        trace.end(status2);
        trace.end(status1);
    }

    @Test
    void begin_exception_level2() {
        TraceStatus status1 = trace.begin("hello");
        TraceStatus status2 = trace.begin("hello2");
        trace.exception(status2, new IllegalStateException());
        trace.exception(status1, new IllegalStateException());
    }
}

정상적으로 다음과 같이 출력된다.

[Spring] 쓰레드 로컬 - ThreadLocal - 2 - 				
    
    	7. 쓰레드 로컬 동기화 - 개발

 

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

이전까지 동시성 문제가 있는 FieldLogTrace 대신에 문제를 해결한 ThreadLocalLogTrace 를 스프링 빈으로 등록하자.

@Configuration
public class LogTraceConfig {

    @Bean
    public LogTrace logTrace() {
        return new ThreadLocalLogTrace();
    }
}

다음 URI로 정상 요청을 연속해서 1초안에 2번 보내보자!

http://localhost:8080/v3/request?itemId=hello

 

실행 결과는 다음과 같다.

[Spring] 쓰레드 로컬 - ThreadLocal - 2 - 				
    
    	8. 쓰레드 로컬 동기화 - 적용

각각의 로그를 분리하여 확인해보면 다음과 같다.

[nio-8080-exec-3] [8fbdec22] OrderController.request()
[nio-8080-exec-3] [8fbdec22] |-->OrderService.orderItem()
[nio-8080-exec-3] [8fbdec22] |   |-->OrderRepository.save()
[nio-8080-exec-3] [8fbdec22] |   |<--OrderRepository.save() time=1002ms
[nio-8080-exec-3] [8fbdec22] |<--OrderService.orderItem() time=1002ms
[nio-8080-exec-3] [8fbdec22] OrderController.request() time=1003ms

[nio-8080-exec-4] [9d489286] OrderController.request()
[nio-8080-exec-4] [9d489286] |-->OrderService.orderItem()
[nio-8080-exec-4] [9d489286] |   |-->OrderRepository.save()
[nio-8080-exec-4] [9d489286] |   |<--OrderRepository.save() time=1005ms
[nio-8080-exec-4] [9d489286] |<--OrderService.orderItem() time=1006ms
[nio-8080-exec-4] [9d489286] OrderController.request() time=1007ms

로그를 직접 분리해서 확인해보면 각각의 쓰레드 nio-8080-exec-3 , nio-8080-exec-4 별로 로그가 정확하게 나누어 진 것을 확인할 수 있다.

 

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

Thread Local의 값을 사용 후 제거하지 않으면, WAS(톰캣)처럼 Thread Pool을 사용하는 경우에 심각한 문제가 발생할 수 있다.

 

다음 그림을 통해 확인해보자

 

▶ 사용자A 저장 요청

[Spring] 쓰레드 로컬 - ThreadLocal - 2 - 				
    
    	9. 쓰레드 로컬 - 주의사항
출처 - 인프런 김영한 고급편

1. 사용자가 저장 HTTP를 요청했다.

2. 사전에 생성된 Thread Pool에서 Thread를 하나 조회 한다.

3. 사용자의 작업을 처리하기 위해 Thread-A 가 할당되었다.

4. Thread-A 사용자A 의 데이터를 Thread Local에 저장한다.

5. Thread Local의 Thread-A 전용 보관소에 사용자A 데이터를 보관한다.

 

▶ 사용자A 저장 요청 종료

[Spring] 쓰레드 로컬 - ThreadLocal - 2 - 				
    
    	9. 쓰레드 로컬 - 주의사항
출처 - 인프런 김영한 고급편

사용자의 저장 요청 처리가 끝나면서 WAS는 Thread-A를 Thread Pool에 반환한다.

이때 Thread-A가 완전하게 소멸되는 것 이 아니다, Thread Pool 내부에 살아있다.

따라서 쓰레드 로컬의 thread-A 전용 보관소에 사용자A 데이터도 함께 살아있게 된다.

 

▶ 사용자B 조회 요청 (문제가 발생하는 상황)

[Spring] 쓰레드 로컬 - ThreadLocal - 2 - 				
    
    	9. 쓰레드 로컬 - 주의사항
출처 - 인프런 김영한 고급편

1. 사용자B가 조회를 위한 새로운 HTTP 요청을 한다.
2. WAS는 쓰레드 풀에서 쓰레드를 하나 조회한다.
3. 우연하게 쓰레드 thread-A 가 할당되었다.
4. 이번에는 조회하는 요청이다. thread-A 는 쓰레드 로컬에서 데이터를 조회한다.

5. 쓰레드 로컬은 thread-A 전용 보관소에 있는 사용자A 값을 반환한다.
6. 결과적으로 사용자A 값이 반환된다.
7. 사용자B는 사용자A의 정보를 조회하게 되는 문제가 발생한다.

 

이런 문제를 예방하려면 사용자A의 요청이 끝날 때 쓰레드 로컬의 값을 ThreadLocal.remove() 를 통해서 꼭 제거해야 한다.

'BackEnd > Spring' 카테고리의 다른 글

[Spring] 전략 패턴  (0) 2022.08.07
[Spring] 템플릿 메서드 패턴  (0) 2022.08.06
[Spring] 쓰레드 로컬 - ThreadLocal - 1  (0) 2022.08.04
[Spring] 예제 만들기  (0) 2022.08.03
[Spring] 스프링의 3대 요소 (IoC/DI, PSA, AOP)  (8) 2022.07.22

댓글