Spring/[인프런 김영한 스프링 핵심 원리 - 고급편]

[인프런 김영한 스프링 핵심 원리 - 고급편] 쓰레드 로컬 - ThreadLocal

h2boom 2024. 12. 3. 20:22

필드 동기화

필드 동기화 - 개발, 적용

  • 이전 예제에서는 로그 출력 시 필요한 트랜잭션ID와 level을 동기화하기 위해서 TraceId를 파라미터로 넘겨서 구현했다.

 

public interface LogTrace {
    TraceStatus begin(String message);

    void end(TraceStatus status);

    void exception(TraceStatus status, Exception e);
}
  • 새 로그 추적기 인터페이스 예제

 

@Slf4j
public class FieldLogTrace implements LogTrace {
    private static final String START_PREFIX = "-->";
    private static final String COMPLETE_PREFIX = "<--";
    private static final String EX_PREFIX = "<X-";

    private TraceId traceIdHolder; //traceId 동기화, 동시성 이슈 발생

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

    private void syncTraceId() {
        if (traceIdHolder == null) {
            traceIdHolder = new TraceId();
        } else {
            traceIdHolder = traceIdHolder.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() {
        if (traceIdHolder.isFirstLevel()) {
            traceIdHolder = null; //destroy
        } else {
            traceIdHolder = traceIdHolder.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();
    }
}
  • TraceId 동기화를 위한 LogTrace 인터페이스의 구현체
    • 이전 HelloTraceV2 예제와 유사한 기능 제공
      • TraceId를 동기화하는 부분이 파라미터를 사용하는 것에서 traceHolder 필드를 사용하도록 변경
    • traceHolder : TraceId를 필드에서 저장하고 관리해서 동기화하기 위한 필드
    • syncTraceId() : 기존 TraceId가 없는 경우 새로 만들고 있는 경우 기존 로그의 TraceId를 참고해서 동기화하고 level을 증가시키는 기능
    • releaseTraceId() : 메소드 추가 호출 시 level이 증가한 것을 메소드 종료시 level이 감소하도록 하는 기능
      • level이 0인 최초 호출의 경우 traceHolder 필드를 null로 초기화한다.

 

  • V2->V3로 변경
    • V2에서 파라미터로 받던 TraceId를 제거
    • Repository, Service에서 사용한던 beginSync() -> begin()으로 통일

필드 동기화 - 동시성 문제

  • 이전 예제 FieldLogTrace는 동시성 문제가 발생한다.
    • 동시에 여러 사용자가 요청하면 여러 쓰레드가 동시에 어플리케이션 로직을 호출하게 되면서 로그가 섞여서 출력될 뿐만아니라 트랜잭션ID도 동일하고 level도 꼬이게 된다.
  • 동시성 문제가 발생하는 이유
    • FieldLogTrace는 싱글톤으로 등록된 스프링 빈인데 traceHolder 필드를 여러 쓰레드가 동시에 접근하게 되면서 문제가 발생한다.

동시성 문제 - 예제 코드

//테스트에서 lombok 사용
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
  • 테스트에서 롬복을 사용하기 위해서 application.properties에 추가

 

@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();
        }
    }
}

@Slf4j
public class FieldServiceTest {
    private FieldService fieldService = new FieldService();

    @Test
    public void field() {
        log.info("main start");
        Runnable userA = () -> {
            fieldService.logic("userA");
        };
        Runnable userB = new Runnable() {
            @Override
            public void run() {
                fieldService.logic("userB");
            }
        };
        Thread threadA = new Thread(userA);
        threadA.setName("thread-A");
        Thread threadB = new Thread(userB);
        threadB.setName("thread-B");

        threadA.start();
//        sleep(2000); //동시성 문제 발생 x
        sleep(100); //동시성 문제 발생
        threadB.start();
        sleep(3000); //메인 쓰레드 종료 대기
        log.info("main exit");
    }

    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
  • 동시성 문제 테스트 예제 코드
    • FieldService의 logic() : nameStore 필드에 파라미터로 넘어온 name을 저장하고 1초 대기했다가 nameStore를 조회한다.
    • FieldServiceTest의 field() : 2개의 쓰레드를 통해 각각 logic()을 호출한다.
      • Runnable과 Thread는 유사하지만 Runnable은 인터페이스지만 Thread는 클래스로 단일 상속만 가능하며 유연성이 떨어진다.
      • 각 쓰레드 호출 간 대기 시간이 2초일 때
        1. threadA가 저장 -> 1초간 대기 -> 조회 -> threadB 시작하기 전까지 1초 더 대기
        2. threadB가 저장 -> 1초간 대기 -> 조회 -> 메인 쓰레드 종료 대기를 위해 2초 더 대기
          => 결과적으로 threadA 실행이 끝난 후 threadB가 실행된다.
        3. 순서
          • threadA가 userA를 nameStore에 저장
          • threadA가 userA를 nameStore에서 조회
          • threadB가 userB를 nameStore에 저장
          • threadB가 userB를 nameStore에서 조회
실행결과
=========================================
[Test worker] main start
[Thread-A] 저장 name=userA -> nameStore=null
[Thread-A] 조회 nameStore=userA
[Thread-B] 저장 name=userB -> nameStore=userA
[Thread-B] 조회 nameStore=userB [Test worker] main exit

 

  • 각 쓰레드 호출 간 대기 시간이 0.1초일 때
    1. threadA호출 -> threadA가 저장 -> 0.1초간 대기 => threadB 호출
    2. threadB 저장 -> 0.9초 대기 => threadA가 조회 => 0.1초 대기 => threadB가 조회
      => 결과적으로 threadA의 작업이 끝나기 전에 threadB가 실행된다.
    3. 순서
      • threadA가 userA를 nameStore에 저장
      • threadB가 userB를 nameStore에 저장
      • threadA가 userB를 nameStore에서 조회
      • threadB가 userB를 nameStore에서 조회
        => 결과적으로 threadA 입장에서는 저장한 데이터와 조회한 데이터가 다르게되는 동시성 문제가 발생한다.

 

실행 결과
===================================
[Test worker] main start

[Thread-A] 저장 name=userA -> nameStore=null
[Thread-B] 저장 name=userB -> nameStore=userA
[Thread-A] 조회 nameStore=userB
[Thread-B] 조회 nameStore=userB [Test worker] main exit

 

  • 동시성 문제 : 여러 쓰레드가 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제이다.
    • 여러 쓰레드가 같은 인스턴스 필드에 접근해야 하기에 트래픽이 많아질수록 자주 발생한다.
    • 지역 변수는 쓰레드마다 각각 다른 메모리 영역이 할당되기 때문에 동시성 문제가 발생하지 않는다.
    • 값을 변경하지 않고 읽기만 하는 경우에도 발생하지 않는다.
      • 스프링 빈과 같이 싱글톤 객체의 필드를 변경하며 사용할 때 동시성 문제를 조심해야 한다.

쓰레드 로컬 ThreadLocal

  • ThreadLocal : Thread Safe한 기술로 멀티 쓰레드 환경에서 해당 쓰레드만 접근할 수 있는 특별한 저장소를 뜻한다.
    • 자바에서는 java.lang.ThreadLocal 클래스를 제공

 

  • 일반적인 변수 필드에 여러 쓰레드가 접근하는 경우
    • 쓰레드A가 필드에 userA라는 값을 저장
    • 이후 쓰레드B가 필드에 userB라는 값을 저장하게되면 직전에 쓰레드A가 저장한 userA 값은 사라진다.
      => 동시성 문제 발생
  •  쓰레드 로컬을 사용하는 경우
    • 쓰레드A가 userA라는 값을 저장하면 쓰레드 로컬은 쓰레드A 전용 보관소에 데이터를 안전하게 보관
    • 이후 쓰레드B가 userB라는 값을 저장하면 쓰레드 로컬은 쓰레드B 전용 보관소에 데이터를 안전하게 보관
      => 데이터 조회 시에도 쓰레드A가 조회하면 쓰레드A 전용 보관소에서 데이터를 반환하고 B가 조회하면 B 전용 보관소에서 데이터를 반환해준다.


쓰레드 로컬 - 예제 코드

  • ThreadLocal 사용법
    • ThreadLocal.set(xxx) : 값 저장
    • ThreadLocal.get() : 값 조회
    • ThreadLocal.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);
        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 적용 예제
    • String nameStore -> ThreadLocal<String> nameStore로 변경
    • set()으로 값 저장, get()으로 값 조회

 

  • ThreadLocal을 사용하면 이전 예제와 같이 2개의 쓰레드를 동시에 호출하더라도 동시성 문제가 발생하지 않는다.
    • 차이점으로는 ThreadLocal로 별도의 저장소를 사용하기 때문에 초기에 nameStore에 값을 넣기 전에는 모두 null이다.

쓰레드 로컬 동기화 -- 개발, 적용

@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-";

    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(); //destroy
        } 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 적용 예제
    • 기존 TraceId만을 사용해서 동기화 문제가 발생하던 코드를 ThreadLocal을 적용함으로 동기화 문제 해결
    • ThreadLocal은 값을 저장할 때는 set(), 조회할 때는 get()을 사용
    • 쓰레드 로컬을 모두 사용하고 난 후 꼭 ThreadLocal.remove()를 호출해서 저장된 값을 제거해줘야 한다.

쓰레드 로컬 - 주의사항

  • 쓰레드 로컬 사용 후 값을 제거하지 않으면 WAS(톰켓)처럼 Thread Pool을 사용하는 경우 심각한 문제가 발생한다.

 

  • ThreadLocal 사용 후 값을 제거하지 않는 경우
    1. 사용자A가 저장 HTTP 요청
    2. WAS는 쓰레드 풀에서 쓰레드를 조회 후 ThreadA를 할당
    3. ThreadA는 사용자A의 데이터를 ThreadLocal에 저장
    4. ThreadA 전용 보관소에 사용자A의 데이터 보관
    5. ThreadLocal 사용이 끝나고 데이터를 제거하지 않고 WAS가 쓰레드 풀에 ThreadA 반납
      • 쓰레드 생성 비용이 비싸기에 쓰레드는 제거하지 않고 풀에 반납하고 재사용한다.
      • ThreadLocal의 ThreadA 전용 저장소에 사용자 A 데이터는 여전히 남아있는 상태이다.
    6. 사용자B가 조회 HTTP 요청
    7. WAS는 쓰레드 풀에서 쓰레드 조회 후 쓰레드 할당 (이때 ThreadA가 할당되었다고 가정)
    8. ThreadA는 쓰레드 로컬에서 데이터를 조회 -> ThreadA의 전용 저장소의 사용자A 데이터가 조회된다.
    9. 사용자B에게 사용자A의 데이터가 조회되게 된다.

 

  • 쓰레드 로컬 사용 시 사용자 요청이 끝난 후 쓰레드 로컬 값을 ThreadLocal.remove()로 꼭 지워줘야 한다!!

정리

  • 동시성 문제 : 여러 쓰레드가 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제이다.
    • 지역 변수는 쓰레드마다 각각 다른 메모리 영역이 할당되기 때문에 동시성 문제가 발생하지 않는다.
    • 값을 변경하지 않고 읽기만 하는 경우에도 발생하지 않는다.
      • 스프링 빈과 같이 싱글톤 객체의 필드를 변경하며 사용할 때 동시성 문제를 조심해야 한다.

 

  • ThreadLocal : 동시성 문제를 해결할 수 있는 Thread Safe한 기술로 멀티 쓰레드 환경에서 해당 쓰레드만 접근할 수 있는 특별한 저장소를 뜻한다.
  • ThreadLocal 사용법
    • ThreadLocal.set(xxx) : 값 저장
    • ThreadLocal.get() : 값 조회
    • ThreadLocal.remove() : 값 제거
  • 쓰레드가 쓰레드 로컬을 모두 사용하고 난 후 remove()를 호출해서 저장된 값을 모두 지워줘야 한다.
    • 쓰레드 로컬 사용 후 값을 제거하지 않으면 WAS(톰켓)처럼 Thread Pool을 사용하는 경우 다른 사용자 같은 쓰레드를 사용하게 되면  심각한 문제가 발생한다.

출처 : [인프런 김영한 스프링 핵심 원리 - 고급편]

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8/dashboard

 

스프링 핵심 원리 - 고급편 강의 | 김영한 - 인프런

김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기

www.inflearn.com