<Spring> ThreadLocal

라모스·2022년 5월 4일
0

Spring☘️

목록 보기
10/18
post-thumbnail

동시성 문제

싱글톤으로 등록된 스프링 빈은 해당 객체의 인스턴스가 애플리케이션에 딱 1개만 존재한다. 이러한 인스턴스에 여러 쓰레드가 동시에 접근하면 문제가 발생한다. 이러한 문제를 동시성 문제라 한다.

FieldLogTrace.java

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

    @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", traceId.getId(), 
            		addSpace(EX_PREFIX, traceId.getLevel()), status.getMessage(),
                    resultTimeMs, e.toString());
        }

        releaseTraceId();
    }

    private void syncTraceId() {
        if (traceIdHolder == null) {
            traceIdHolder = new TraceId();
        } else {
            traceIdHolder = traceIdHolder.createNextId();
        }
    }
    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();
    }
}

어느 정도 텀을 두고 같은 요청을 여러 번 호출하면 다음과 같은 결과가 나타난다.
동시 요청 시에도 이와 같은 결과가 나타나길 기대하고 있다.

하지만, 1초 안에 같은 요청을 여러 번 호출하면 실제론 다음과 같은 결과가 나타난다.

위 결과를 보면 기대한 바와 전혀 다른 문제가 발생한다.
Transaction ID도 동일하고, level도 많이 꼬였다.

동시성 문제는 여러 쓰레드가 같은 인스턴스의 필드에 접근해야 하기 때문에 트래픽이 적은 상황에선 확률상 잘 나타나지 않고, 트래픽이 점점 많아질수록 자주 발생한다.
특히 스프링 빈처럼 싱글톤 객체의 필드를 변경하며 사용할 때 이러한 동시성 문제를 조심해야 한다.

📌 참고
동시성 문제는 지역 변수에선 발생하지 않는다. 지역 변수는 쓰레드마다 각각 다른 메모리 영역이 할당된다. 동시성 문제가 발생하는 곳은 같은 인스턴스의 필드(주로 싱글톤), 또는 static 같은 공용 필드에 접근할 때 발생한다. 동시성 문제는 값을 읽기만 하면 발생하지 않고, 어디선가 값을 변경하기 때문에 발생한다.

ThreadLocal

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

일반적인 변수 필드의 경우, 여러 쓰레드가 같은 인스턴스의 필드에 접근하면 처음 쓰레드가 보관한 데이터가 사라질 수 있다.

쓰레드 로컬을 사용하면 각 쓰레드마다 별도의 내부 저장소를 제공한다. 따라서 같은 인스턴스의 쓰레드 로컬 필드에 접근해도 문제 없다.

Java는 언어 차원에서 쓰레드 로컬을 지원하기 위해 java.lang.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.set(xxx)
  • 값 조회: ThreadLocal.get()
  • 값 제거: ThreadLocal.remove()
@Slf4j
public class ThreadLocalServiceTest {
    private ThreadLocalService service = new ThreadLocalService();
    
    @Test
    void threadLocal() {
    	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();
        sleep(100);
        threadB.start();
        
        sleep(2000);
        log.info("main exit");
    }
    
    private void sleep(int millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

쓰레드 로컬 덕분에 쓰레드마다 각각 별도의 데이터 저장소를 가지게 되었고, 결과적으로 동시성 문제도 해결되었다.

주의사항

쓰레드 로컬의 값을 사용 후 제거하지 않고 그냥 두면 WAS(톰캣)처럼 쓰레드 풀을 사용하는 경우 심각한 문제가 발생할 수 있다.

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

강의 Q&A 모음

Spring MVC의 Thread

Controller가 받은 요청 쓰레드는 Tomcat의 쓰레드 풀에 있는 쓰레드를 할당 뒤 스프링 애플리케이션으로 이동해 필요한 로직 수행 뒤 다시 Tomcat으로 이동하여 응답/반환 후 쓰레드 풀로 돌아간다.

Request 스코프와 쓰레드 로컬

  • Request 스코프: HTTP 요청 + 스프링 안에서만 동작
  • 쓰레드 로컬: HTTP 요청과 무관하게 동작. 스프링과 상관 없이 순수 자바의 기능

WAS가 여러 대일 경우?

WAS가 여러 대일 경우에도 쓰레드 로컬이 동시성 이슈를 막을 수 있다.

References

profile
Step by step goes a long way.

0개의 댓글