필드 동기화
필드 동기화 - 개발, 적용
- 이전 예제에서는 로그 출력 시 필요한 트랜잭션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로 초기화한다.
- 이전 HelloTraceV2 예제와 유사한 기능 제공
- 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초일 때
- threadA가 저장 -> 1초간 대기 -> 조회 -> threadB 시작하기 전까지 1초 더 대기
- threadB가 저장 -> 1초간 대기 -> 조회 -> 메인 쓰레드 종료 대기를 위해 2초 더 대기
=> 결과적으로 threadA 실행이 끝난 후 threadB가 실행된다. - 순서
- 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초일 때
- threadA호출 -> threadA가 저장 -> 0.1초간 대기 => threadB 호출
- threadB 저장 -> 0.9초 대기 => threadA가 조회 => 0.1초 대기 => threadB가 조회
=> 결과적으로 threadA의 작업이 끝나기 전에 threadB가 실행된다. - 순서
- 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 사용 후 값을 제거하지 않는 경우
- 사용자A가 저장 HTTP 요청
- WAS는 쓰레드 풀에서 쓰레드를 조회 후 ThreadA를 할당
- ThreadA는 사용자A의 데이터를 ThreadLocal에 저장
- ThreadA 전용 보관소에 사용자A의 데이터 보관
- ThreadLocal 사용이 끝나고 데이터를 제거하지 않고 WAS가 쓰레드 풀에 ThreadA 반납
- 쓰레드 생성 비용이 비싸기에 쓰레드는 제거하지 않고 풀에 반납하고 재사용한다.
- ThreadLocal의 ThreadA 전용 저장소에 사용자 A 데이터는 여전히 남아있는 상태이다.
- 사용자B가 조회 HTTP 요청
- WAS는 쓰레드 풀에서 쓰레드 조회 후 쓰레드 할당 (이때 ThreadA가 할당되었다고 가정)
- ThreadA는 쓰레드 로컬에서 데이터를 조회 -> ThreadA의 전용 저장소의 사용자A 데이터가 조회된다.
- 사용자B에게 사용자A의 데이터가 조회되게 된다.
- 쓰레드 로컬 사용 시 사용자 요청이 끝난 후 쓰레드 로컬 값을 ThreadLocal.remove()로 꼭 지워줘야 한다!!
정리
- 동시성 문제 : 여러 쓰레드가 같은 인스턴스의 필드 값을 변경하면서 발생하는 문제이다.
- 지역 변수는 쓰레드마다 각각 다른 메모리 영역이 할당되기 때문에 동시성 문제가 발생하지 않는다.
- 값을 변경하지 않고 읽기만 하는 경우에도 발생하지 않는다.
- 스프링 빈과 같이 싱글톤 객체의 필드를 변경하며 사용할 때 동시성 문제를 조심해야 한다.
- ThreadLocal : 동시성 문제를 해결할 수 있는 Thread Safe한 기술로 멀티 쓰레드 환경에서 해당 쓰레드만 접근할 수 있는 특별한 저장소를 뜻한다.
- ThreadLocal 사용법
- ThreadLocal.set(xxx) : 값 저장
- ThreadLocal.get() : 값 조회
- ThreadLocal.remove() : 값 제거
- 쓰레드가 쓰레드 로컬을 모두 사용하고 난 후 remove()를 호출해서 저장된 값을 모두 지워줘야 한다.
- 쓰레드 로컬 사용 후 값을 제거하지 않으면 WAS(톰켓)처럼 Thread Pool을 사용하는 경우 다른 사용자 같은 쓰레드를 사용하게 되면 심각한 문제가 발생한다.
출처 : [인프런 김영한 스프링 핵심 원리 - 고급편]
스프링 핵심 원리 - 고급편 강의 | 김영한 - 인프런
김영한 | 스프링의 핵심 원리와 고급 기술들을 깊이있게 학습하고, 스프링을 자신있게 사용할 수 있습니다., 핵심 디자인 패턴, 쓰레드 로컬, 스프링 AOP스프링의 3가지 핵심 고급 개념 이해하기
www.inflearn.com
'Spring > [인프런 김영한 스프링 핵심 원리 - 고급편]' 카테고리의 다른 글
[인프런 김영한 스프링 핵심 원리 - 고급편] 스프링이 지원하는 프록시 (0) | 2024.12.10 |
---|---|
[인프런 김영한 스프링 핵심 원리 - 고급편] 동적 프록시 기술 (2) | 2024.12.08 |
[인프런 김영한 스프링 핵심 원리 - 고급편] 프록시 패턴과 데코레이터 패턴 (3) | 2024.12.07 |
[인프런 김영한 스프링 핵심 원리 - 고급편] 템플릿 메소드 패턴과 콜백 패턴 (1) | 2024.12.05 |
[인프런 김영한 스프링 핵심 원리 - 고급편] 예제 만들기 (1) | 2024.12.01 |