스프링의 동시성 문제와 해결
스프링은 기본적으로 빈을 등록할 때, 싱글톤 패턴으로 스프링 컨테이너에 하나의 객체만 가지고 있는 형태를 띄고 있다.
하나의 객체만을 가지고 있기 때문에, 톰캣같은 WAS는 멀티 쓰레드를 지원하기 때문에, 하나의 객체에 여러 클라이언트들이 접근을 하게 되었을 때, 데이터의 변경이 발생한다면 동시성 문제가 발생한다.
동시성 문제란
동시성 문제는 임계 영역에 각기 다른 쓰레드가 동일한 데이터에 접근하고 어떠한 쓰레드가 임계 영역의 데이터를 변경하려고 할 때 발생을 하게 되는데, 임계 영역이란 어떠한 자원을 공유할 수 있는 공간을 말한다.
따라서, 우리가 코드를 사용할 때, 임계영역이 발생할 수 있는 부분은 static 변수를 선언하거나 혹은 싱글톤 패턴으로 인해 새로운 객체의 생성없이 하나의 객체에만 접근하려고 할 때 발생한다.
반대로 생각해보면 지역 변수나 객체를 새로 생성하여 다른 힙 영역에 저장되는 경우에는 동시성 문제가 발생하지 않는다.
먼저, 두 개의 쓰레드가 작업을 진행하는 동안 두 쓰레드가 임계 영역 내부의 같은 데이터를 접근하게 될 때, 동시성 문제가 어떻게 발생하는지 코드로 한 번 살펴보자.
먼저 어떠한 서비스 클래스와 로직을 먼저 생성했다. 해당 클래스는 String 변수를 담기 위한 멤버 변수인 nameStore를 가지고 있다.
- nameStore 라는 String을 담을 수 있는 객체를 멤버 변수로 등록한다.
- logic을 수행하면, 매개변수로 들어온 값을 nameStore에 저장한다.
- 해당 쓰레드를 1초 동안 잠시 일시 정지 시킨다.
- InterruptedException 발생 후 쓰레드가 다시 일어나면 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();
}
}
}
이어서 Thread 2개를 생성하고, 해당 서비스 로직을 수행하는 코드를 살펴보도록 하자.
@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("thread-A");
Thread threadB = new Thread(userB);
threadB.setName("thread-B");
threadA.start();
sleep(2000); // 동시성 문제가 발생하지 않음.
// threadA는 최소 1초가 지나야 종료가 되는데, threadA가 종료되지 않은 상태에서 threadB 실행
threadB.start();
// 서로가 같은 critical section 에 있는 값을 사용하게 됨, 즉 threadB가 nameStore 를 변경하고, threadA는 변경된 값을 반환함.
sleep(3000); // 메인 쓰레드가 종료하지 않도록 대기
log.info("main exit");
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
먼저 동시성 문제가 발생하지 않는 상황에서의 테스트이다.
Runnable 함수형 인터페이스의 run() 을 람다식으로 구현하여 저장하고, 이를 통해 Thread를 실행하도록 했다.
여기서 실행 할 때, threadA.run()을 수행하지 않도록 주의해야 한다.
Thread.start()는 다른 스레드를 생성하여, 해당 스레드 내에서 새로운 콜 스택이 형성되는 반면,
Thread.run()은 현재 진행되고 있는 스레드에서 콜 스택에 기존에 메소드를 실행하는 것 처럼 생기기 때문에, 정상적으로 동작하지 않을 수 있다. 그림으로 보충 설명을 하자면 다음과 같은 그림이다.
특히, 쓰레드의 정상 수행되는 결과를 확인하기 위해 main Thread가 종료되기 전에 main 쓰레드를 잠시 일시정지 하도록 하였다. 기본적으로 프로그램은 모든 쓰레드가 종료되어야 종료가 되는데 쓰레드가 종료되는게 출력되는 것 보다 훨씬 빨라서 출력되기 전에 프로그램이 종료되서 확인을 못할 수 도 있기 때문이다.
수행 결과는 다음과 같이 나온다.
02:23:54.534 [Test worker] INFO hello.advanced.trace.threadlocal.FieldServiceTest - main start
02:23:54.536 [thread-A] INFO hello.advanced.trace.threadlocal.code.FieldService - 저장 name = userA -> nameStore = null
02:23:55.543 [thread-A] INFO hello.advanced.trace.threadlocal.code.FieldService - 조회 nameStore = userA
02:23:56.541 [thread-B] INFO hello.advanced.trace.threadlocal.code.FieldService - 저장 name = userB -> nameStore = userA
02:23:57.546 [thread-B] INFO hello.advanced.trace.threadlocal.code.FieldService - 조회 nameStore = userB
02:23:58.546 [Test worker] INFO hello.advanced.trace.threadlocal.FieldServiceTest - main exit
쓰레드 이름과 값을 자세히 살펴보면 Test worker 쓰레드에서 동작을 실행하고, threadA의 스레드가 실행된다. 이 때, threadB는 threadA 실행 이후 2초가 지나서야 실행이 되기 때문에 1초 기다렸다가 종료되는 서비스 로직은 방해없이 정상수행 된다.
따라서, 처음에 threadA에 있는 nameStore에 접근하게 되면, null인 상태에서 데이터를 저장하니 userA가 저장이 된 모습을 볼 수 있고,
이어서 threadB가 접근해보니 nameStore에는 이전에 threadA가 저장한 userA가 담겨있는 것을 볼 수 있고, threadB가 userB를 담고 조회하고 정상 종료되는 것을 볼 수 있다.
그러나 이는 우리가 각 쓰레드들이 동시에 실행되지 않도록 막아두었기 때문에 발생한 현상인데, 실제로 동시에 일어나는 상황에는 어떤 일이 발생하는지 한 번 살펴보자.
기존의 threadA.start()와 threadB.start() 사이에 있던 sleep()을 100ms로 바꿔서 실행을 해봤다.
02:28:25.948 [Test worker] INFO hello.advanced.trace.threadlocal.FieldServiceTest - main start
02:28:25.954 [thread-A] INFO hello.advanced.trace.threadlocal.code.FieldService - 저장 name = userA -> nameStore = null
02:28:26.058 [thread-B] INFO hello.advanced.trace.threadlocal.code.FieldService - 저장 name = userB -> nameStore = userA
02:28:26.963 [thread-A] INFO hello.advanced.trace.threadlocal.code.FieldService - 조회 nameStore = userB
02:28:27.064 [thread-B] INFO hello.advanced.trace.threadlocal.code.FieldService - 조회 nameStore = userB
02:28:28.063 [Test worker] INFO hello.advanced.trace.threadlocal.FieldServiceTest - main exit
결과는 다음과 같이 나오게 된다. threadA가 userA 값을 저장하고 100ms 뒤에 threadB가 실행되면서, userB를 저장했다.
이후 1초가 지나 조회를 하려고 하니 threadA는 마지막으로 저장된 threadB가 저장한 값을 조회하는 상황이 발생하게 된다.
이렇듯 데이터를 공유하는 상황에서 어떠한 변경이 발생하고, 트래픽이 발생하게 되면 원하지 않는 데이터를 조회 할 수 있는 것이다.
지금 쓰레드들을 강제적으로 일시정지 시켜서 상황을 연출한 것 처럼, 만약에 트래픽이 거의 발생이 없이 로직이 수행된다면 이러한 결과는 잘 보이지 않을 수 있다. 하지만 나중에 서비스가 잘 돼서 트래픽이 발생하게 되면 거의 무조건 발생 할 수 밖에 없는 상황이 생긴다.
동시성 문제 해결방법
이를 해결하기 위해 두 가지 방법이 있다. 하나는 자바 키워드인 synchronized 를 사용하는 것이고, 두 번째는 ThreadLocal 을 사용하는 방법이다.
synchronized 키워드와 ThreadLocal의 차이
synchronizde 키워드
synchronized 키워드는 monitor 인터페이스를 이용해서, 각각 쓰레드들이 임계영역에 접근하려고 할 때, 해당 영역을 사용하고 있는 쓰레드가 있다면 접근하지 못하게 하는 방식으로 동시성 문제를 해결한다.
임계영역에 동시에 접근하지 못하면 변수 또한 동시에 변경을 못하는 것이기 때문에 위와 같은 상황이 발생하지 않지만, 로직을 수행하는 동안 다른 스레드들이 대기하는 상황이 발생해서 오히려 서비스가 느려지는 문제가 발생할 수 있다.
ThreadLocal
ThreadLocal 은 각 Thread들마다 해당 객체에 대한 고유한 저장 공간을 만들어주는 것이다.
즉, 기존에는 어떤 쓰레드가 어떤 필드에 접근하는지 모르기 때문에 들어온 대로 수행을 해주었지만, 이제는 어떤 쓰레드가 요청을 했는지 알 수 있는 상황이 된 것이다. 각 쓰레드 별로, 고유한 저장공간을 가지고 있기 때문에 동시성 문제는 발생하지 않지만, 만약에 100개의 스레드가 동시에 요청을 했고, 그 저장공간을 가지고 있는다고 하면 이것 또한 나름대로 메모리적으로 비용이 발생할 것이다.
이제 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(), 호출을 위해 get() 을 사용했는데, 추가적으로 중요한 메소드에는 remove() 가 있다.
특히 remove() 는 굉장히 중요한데 이건 서비스 로직 수행 결과가 어떻게 이루어지는지 보고 살펴보자.
@Slf4j
public class ThreadLocalServiceTest {
private ThreadLocalService threadLocalService = new ThreadLocalService();
@Test
void field() {
log.info("main start");
Runnable userA = () -> threadLocalService.logic("userA");
Runnable userB = () -> threadLocalService.logic("userB");
Thread threadA = new Thread(userA);
threadA.setName("thread-A");
Thread threadB = new Thread(userB);
threadB.setName("thread-B");
threadA.start();
// sleep(2000); // 동시성 문제가 발생하지 않음.
sleep(100); // 동시성 문제 발생 X, 각각 다른 저장소에서 값을 저장하고 가져오기 때문에 동시성 문제가 발생 절대 X
threadB.start();
sleep(3000); // 메인 쓰레드가 종료하지 않도록 대기
log.info("main exit");
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
ThreadLocal을 사용하여 각 스레드 별로 다른 저장 공간을 사용할 수 있게 되었다. 따라서 실행 결과는 다음과 같이 되는 것을 볼 수 있다.
02:48:57.016 [Test worker] INFO hello.advanced.trace.threadlocal.ThreadLocalServiceTest - main start
02:48:57.018 [thread-A] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 저장 name = userA -> nameStore = null
02:48:57.123 [thread-B] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 저장 name = userB -> nameStore = null
02:48:58.025 [thread-A] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 조회 nameStore = userA
02:48:58.128 [thread-B] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 조회 nameStore = userB
02:49:00.124 [Test worker] INFO hello.advanced.trace.threadlocal.ThreadLocalServiceTest - main exit
각기 서로 다른 저장소를 가지고 있기 때문에, 처음에 저장하는 nameStore 자체에도 저장하기 전에는 둘 다 null 이 담겨있음을 확인 할 수 있다. 이제, 우리는 스프링에서 싱글턴으로 등록되어 있는 빈들을 사용하는데에 있어서도, ThreadLocal을 사용하면 동시성을 침해받지 않고 이를 해결 할 수 있게 되었다. 하지만 아직 한 가지 문제가 남아있다.
바로 쓰레드 풀을 사용했을 때의 문제이다.
Thread Pool 과 remove()를 안해 줬을 때의 문제
톰캣과 같은 WAS는 쓰레드를 요청할 때마다 생성하는 방식이 아닌 쓰레드 풀 방식을 택하고 있다. 쓰레드가 생길 때마다 요청을 하게 되면 3-way-handshake 비용만 생각해도, 많은 시간이 할애가 된다.
따라서 애플리케이션이 시작하면 미리 여러 개의 쓰레드를 생성하고 쓰레드 풀에 해당 쓰레드를 담아놓는 방식을 택하고 있는데, 쓰레드를 사용하고 반환하면 해당 쓰레드가 소멸하지 않고, 다시 쓰레드 풀로 돌아가서 문제가 생긴다. 먼저 문제가 생기는 예제 코드부터 살펴보자.
@Test
void threadLocalPool() {
log.info("main start");
Runnable userA = () -> threadLocalService.logic("userA");
Runnable userB = () -> threadLocalService.logic("userB");
ExecutorService service = Executors.newFixedThreadPool(2);
service.execute(userA);
service.execute(userB);
sleep(1500);
service.execute(userA);
sleep(2000);
service.shutdown();
log.info("main exit");
}
기존 코드와 달리 ThreadPool에 2개의 스레드가 담겨있다고 가정하고 테스트 코드를 작성하였다.
먼저 테스트 수행 로그부터 확인해보자.
02:55:44.068 [Test worker] INFO hello.advanced.trace.threadlocal.ThreadLocalServiceTest - main start
02:55:44.070 [pool-1-thread-1] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 저장 name = userA -> nameStore = null
02:55:44.070 [pool-1-thread-2] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 저장 name = userB -> nameStore = null
02:55:45.072 [pool-1-thread-1] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 조회 nameStore = userA
02:55:45.074 [pool-1-thread-2] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 조회 nameStore = userB
02:55:45.574 [pool-1-thread-1] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 저장 name = userA -> nameStore = userA
02:55:46.579 [pool-1-thread-1] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 조회 nameStore = userA
02:55:47.580 [Test worker] INFO hello.advanced.trace.threadlocal.ThreadLocalServiceTest - main exit
우리는 분명히 ThreadLocal을 사용하고 있어서, 각 쓰레드별로 로직이 실행 할 때마다 nameStore에는 null이 나와야 정상이다.
하지만 nameStore에 값이 들어있는 모습을 확인할 수 있다. 실행한 쓰레드의 이름을 보면 위에서 이전에 시작했던 [pool-1-thread-1]과 동일한 쓰레드임을 확인 할 수 있다.
이는 우리가 [pool-1-thread-1] 에서 ThreadLocal의 저장공간을 사용하였는데, thread-1이 반환되고 쓰레드 풀로 되돌아갔지만 우리가 해당 thread-1의 저장소를 초기화하지 않아서, 다음에 해당 쓰레드 풀에 반환됐던 thread-1이 다시 로직을 수행하면 똑같은 저장 공간에 접근하게 되어 이와 같은 사태가 발생한 것이다.
실제로 서비스를 하게 될 때 WAS 가 쓰레드 풀에 있는 어떤 쓰레드를 실행 할 지 예측을 할 수 없다. 따라서 하나의 쓰레드에 대한 수행이 끝났을 때 해당 쓰레드에 대한 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());
nameStore.remove(); // ThreadLocal 저장소 삭제
return nameStore.get();
}
private void sleep(int millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
마지막에 nameStore.remove() 를 해준 것을 볼 수 있다. 이에 따라 결과는 다음과 같이 변하게 된다.
03:01:22.250 [Test worker] INFO hello.advanced.trace.threadlocal.ThreadLocalServiceTest - main start
03:01:22.252 [pool-1-thread-2] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 저장 name = userB -> nameStore = null
03:01:22.252 [pool-1-thread-1] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 저장 name = userA -> nameStore = null
03:01:23.260 [pool-1-thread-1] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 조회 nameStore = userA
03:01:23.260 [pool-1-thread-2] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 조회 nameStore = userB
03:01:23.758 [pool-1-thread-1] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 저장 name = userA -> nameStore = null
03:01:24.762 [pool-1-thread-1] INFO hello.advanced.trace.threadlocal.code.ThreadLocalService - 조회 nameStore = userA
03:01:25.763 [Test worker] INFO hello.advanced.trace.threadlocal.ThreadLocalServiceTest - main exit
아까와 동일하게 thread-1이 저장되고 다시 해당 스레드가 다시 접근을 했음에도 불구하고 null 임을 확인 할 수 있다.
이로써 쓰레드 풀 문제를 해결 할 수 있었다. 사실 이 문제를 해결하면서 한 가지 잘못된 상황을 만든 적이 있는데, 하나의 사이클이 끝나고 remove()를 해주지 않고 그냥 nameStore를 null로 바꿔버린 것이다. 그러다 보니 처음 접근한 thread가 nameStore를 없애버리게 되어 이후에 접근하는 쓰레드들이 nameStore 자체에 접근하지 못하고 nullPointerException을 발생시키고 말았다.
따라서, null로 바꿔주지 말고 꼭 remove() 메소드를 실행해주자!
'Backend > Spring' 카테고리의 다른 글
자바 리플렉션과 이를 이용한 JDK 동적 프록시와 CGLIB (0) | 2023.04.20 |
---|---|
프록시 (0) | 2023.04.18 |
템플릿 콜백 패턴 (1) | 2023.04.17 |
1. SPRING (0) | 2022.06.13 |
sts4 응용프로그램을 열 수 없습니다. (0) | 2022.06.07 |