스프링 부트는 내장 톰캣 컨테이너를 지원한다. 톰캣 컨테이너는 작업(요청)이 들어왔을 때 작업 큐에 담아두고 쓰레드에 하나씩 할당하여 요청을 처리한다.
각 작업은 다른 쓰레드에서 처리가 되며, 작업이 완료되면 쓰레드는 유휴 상태로 돌아가 새로운 작업을 받아들인다. 이러한 작동 방식 때문에 스프링 부트에서 내가 의도하지 않은 동작(race condition)을 불러 일으킬 수 있는데, 예시를 통해서 문제 상황과 해결 방법을 알아보자.
Race Condition(경쟁 상태)란 둘 이상의 프로세스, 쓰레드가 공유 자원에 동시 접근할 때 실행 순서에 따라 결과값이 달라질 수 있는 현상을 말한다. 공유 자원에 차례대로 접근하는 것이 아니라 한 쓰레드가 공유 자원을 사용 중일 때 다른 쓰레드가 또 접근하는 상황이 생긴다면 의도하지 않은 결과를 불러 일으킬 수 있다.
위에서 설명한 것처럼 스프링 부트에서 요청마다 쓰레드가 할당되어 작업을 처리하는데, 이 때 여러 쓰레드가 동일한 자원에 접근하면 경쟁 상태가 발생한다.
입금을 하는 예시 코드로 Race Condition 상태를 재현해보자.
@Component
@Slf4j
public class Database {
private Map<String, Long> database = new HashMap<>();
public void add(String key, Long value) {
sleep(100);
if (database.containsKey(key)) {
database.put(key, database.get(key) + value);
} else {
database.put(key, value);
}
}
public Long get(String key) {
if (database.containsKey(key)) {
return database.get(key);
} else {
return 0L;
}
}
private void sleep(int ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
@Service
public class OrderService {
private Database database;
public OrderService(Database database) {
this.database = database;
}
public void addOrder(String orderId, Long amount) {
database.add(orderId, amount);
}
public Long getOrderAmount(String orderId) {
return database.get(orderId);
}
}
@SpringBootTest
public class BeforeTest {
@Autowired
private OrderService orderService;
@Test
void test() throws InterruptedException {
// 30개의 쓰레드 풀 생성
ExecutorService executorService = Executors.newFixedThreadPool(30);
// 쓰레드 작업 종료를 기다리기 위해 쓰레드 개수만큼의 CountDownLatch 생성
CountDownLatch countDownLatch = new CountDownLatch(30);
// 작업 시작
for (int i = 0; i < 30; i++) {
executorService.submit(() -> {
orderService.addOrder("order1", 100L);
countDownLatch.countDown();
});
}
countDownLatch.await();
// 100을 30번 입금하였으니 3000이 나오는 것이 정상
assertEquals(3000L, orderService.getOrderAmount("order1"));
}
}
예상 결과는 3000이지만 1400이 나온 것을 볼 수 있다. 이유는 경쟁상태이다. 다른 쓰레드가 작업 중인 데이터베이스에 접근하여 값을 가져오고, 그 상태에서 계산하여 틀린 결과가 나오는 것이다.
경쟁 상태를 해결하기 위해 synchronized
키워드를 사용할 수 있다. synchronized
키워드를 메서드에 명시하면 하나의 쓰레드만 접근이 가능하게 해준다.
synchronized
키워드를 사용하면 현재 작업 중인 쓰레드를 제외한 나머지 쓰레드는 공유 자원을 사용하기 위해 기다리며 순차적으로 공유 자원에 접근할 수 있다.
사용 방법은 간단하다.
@Service
public class NewOrderService {
private Database database;
public NewOrderService(Database database) {
this.database = database;
}
public synchronized void addOrder(String orderId, Long amount) {
database.add(orderId, amount);
}
public Long getOrderAmount(String orderId) {
return database.get(orderId);
}
}
위 코드의 addOrder
메서드와 같이 synchronized
키워드를 붙여주면 된다.
동일한 테스트 진행 시 테스트가 통과되고, 동시성 문제가 해결된 것을 볼 수 있다.
다만 순차적으로 쓰레드가 접근하기 때문에 실행 속도가 상대적으로 느리다. 또한 단일 프로세스 안에서의 공유자원 접근 문제를 해결하는 것이기 때문에 여러 서버를 사용하는 환경에서는 다른 방법으로 경쟁 상태를 해결해야한다.
GitHub - junho100/spring-duplicated-request-prevention: 스프링 부트 단일 서버 동시성 처리 예시코드
스프링부트는 어떻게 다중 유저 요청을 처리할까? (Tomcat9.0 Thread Pool)
[ 운영체제 ] 경쟁상태(Race Condition)와 동기화(Synchronization)의 필요성, 임계 구역(Critical Section)