목차
개요
작업 중에 동시성 이슈에 대한 부분을 구현 할 일이 있었습니다.
시나리오는 아래와 같습니다.
- 사용자가 작업을 요청합니다.
- 사용자는 언제든 작업 취소 요청을 할 수 있습니다.
- 사용자가 작업 취소 요청을 한 경우 반드시 작업 취소가 이루어져야 합니다.
- 작업이 완료된 경우에는 특정 상태값이 업데이트 되며 상위 서비스에서 이를 파악하도록 업데이트 해줍니다.
이런 규칙 속에서 문제는 아래와 같습니다.
작업이 완료되어 상태값이 업데이트 되어야하는 순간과 작업 취소가 동시에 이루어지는 경우 입니다.
동시에 이루어질 경우 순서를 정하게 하면 아래의 두 순서가 존재할 것입니다.
작업 완료 -> 작업 취소
작업 취소 -> 작업 완료
두 순서 중 어느 것이든 간에 작업 취소가 들어왔다는 사실이 있다면 저장된 결과를 모두 날리고 초기 상태로 변경해야 한다는 사실은 변함이 없죠.
그래서 제가 생각한 로직은 아래 이미지와 같습니다.
위 로직을 통해 수행한다면 문제가 해결된다고 생각했습니다.
다만 문제는 이걸 어떻게 테스트하냐는 것이었죠.
이 과정에서 찾은 방법이 Spring 멀티스레드 동시성 테스트 였습니다.
@Test에서 멀티스레드로 동시성 테스트하기
제가 찾은 방법은 ExecutorService를 이용하는 것이었습니다.
해당 서비스를 이용하여 몇 개의 스레드를 사용할지 지정하고 수행할 동작을 지정해준 뒤 모든 스레드의 작업이 완료될 때 까지 대기 후, 결과를 보는 것이었죠. 예시는 아래와 같습니다.
@Test
void multiThreads() throws InterruptedException {
// thread 사용할 수 있는 서비스 선언, 몇 개의 스레드 사용할건지 지정
ExecutorService executorService = Executors.newFixedThreadPool(2);
// 다른 스레드 작업 완료까지 기다리게 해주는 클래스
// 몇을 카운트할지 지정
// countDown()을 통해 0까지 세어야 await()하던 thread가 다시 실행됨
CountDownLatch latch = new CountDownLatch (2);
// thread 실행
// 보통 for문안에서 여러번 같은 코드를 실행시킴
executorService.execute(() -> {
// thread가 실행할 작업 코드 ...
// CountDownLatch의 카운트 감소
latch.countDown();
// count가 0이 될 때까지 대기
latch.await();
});
// 또는 executorService에 Runnable을 상속받은 클래스를 직접 submit할 수도 있음.
executorService.submit(new CustomRunnable());
}
해당 방법을 이용해 실제 서비스와 동일한 로직을 가진 더미 프로젝트에서 테스트한 결과를 공유합니다.
상태값은 다음과 같게 설정해보겠습니다. S(Start), I(Ing), C(Complete) 세 가지 작업 상태값을 설정하였습니다.
취소 상태값은 0 또는 1 입니다. 0이면 OFF, 1이면 ON 인 상태값인거죠.
저희의 시나리오는 상태값을 I로 설정해놓고 시작합니다. 작업이 시작되어 있는 상태라고 가정하는 것 입니다.
이후 작업 완료 기능과 작업 취소 기능을 동시에 실행하도록 설정합니다. 각 기능은 위에 설명한 이미지 로직과 동일합니다.
동시성 테스트가 실행되어 어느 로직이 먼저 실행되든간에 취소 상태값은 0으로 유지되어야하며 상태값은 S로 초기화 되어야 합니다.
테스트를 진행하면 다음과 같이 값이 바뀐 것을 확인 할 수 있습니다.
실제 테스트 코드는 아래와 같습니다.
@Test
public void tmpTest() throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(2);
CountDownLatch latch = new CountDownLatch(2);
executorService.execute(() -> {
try {
tmpService.getTmpComplete(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
latch.countDown();
});
executorService.execute(() -> {
try {
tmpService.getTmpCancelState(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
latch.countDown();
});
latch.await();
String status = tmpMapper.selectStatus(1);
assertThat(status).isEqualTo("S");
}
여러번 실행하다보면 순서가 랜덤하게 실행되는 것을 알 수 있습니다.
두 케이스에 대한 로그도 함께 첨부합니다.
(취소 먼저 실행되는 경우)
[10:39:13,272-DEBUG] org.apache.commons.dbcp.DelegatingStatement.executeQuery(DelegatingStatement.java:208)
1. SELECT 1
{executed in 1 msec} (Slf4jSpyLogDelegator.java.sqlTimingOccured():365) [jdbc.sqltiming]
[10:39:13,408-DEBUG] org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
7. UPDATE test_row_lock
SET cancel_state = '1'
WHERE 1=1
AND id = 1
AND status NOT IN ('C')
{executed in 2 msec} (Slf4jSpyLogDelegator.java.sqlTimingOccured():365) [jdbc.sqltiming]
실패값 업데이트
기타 작업들 진행
기타 작업들 종료
[10:39:16,419-DEBUG] org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
6. UPDATE test_row_lock
SET status = 'C'
WHERE 1=1
AND id = 1
AND cancel_state != 1
{executed in 3014 msec} (Slf4jSpyLogDelegator.java.sqlTimingOccured():365) [jdbc.sqltiming]
완료 상태값 업데이트 진행 X
진행된 작업 삭제 로직 진행
진행된 작업 삭제 로직 종료
getTmpComplete 상태값 초기화 업데이트
[10:39:21,436-DEBUG] org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
6. UPDATE test_row_lock
SET status = 'S'
WHERE 1=1
AND id = 1
{executed in 1 msec} (Slf4jSpyLogDelegator.java.sqlTimingOccured():365) [jdbc.sqltiming]
[10:39:21,438-DEBUG] org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
6. UPDATE test_row_lock
SET cancel_state = '0'
WHERE 1=1
AND id = 1
{executed in 1 msec} (Slf4jSpyLogDelegator.java.sqlTimingOccured():365) [jdbc.sqltiming]
기타 작업 진행
기타 작업 종료
(완료 먼저 실행되는 경우)
6. UPDATE test_row_lock
SET status = 'C'
WHERE 1=1
AND id = 1
AND cancel_state != 1
{executed in 1 msec} (Slf4jSpyLogDelegator.java.sqlTimingOccured():365) [jdbc.sqltiming]
완료값 업데이트
상위 서비스에 상태값 업데이트 전달 로직 진행
상위 서비스에 상태값 업데이트 전달 로직 종료
[11:07:16,171-DEBUG] org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
7. UPDATE test_row_lock
SET cancel_state = '1'
WHERE 1=1
AND id = 1
AND status NOT IN ('C')
{executed in 5010 msec} (Slf4jSpyLogDelegator.java.sqlTimingOccured():365) [jdbc.sqltiming]
실패값 업데이트 X
진행된 작업 삭제 로직 진행
진행된 작업 삭제 로직 종료
getTmpCancelState 상태값 초기화 업데이트
[11:07:21,197-DEBUG] org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
7. UPDATE test_row_lock
SET status = 'S'
WHERE 1=1
AND id = 1
{executed in 3 msec} (Slf4jSpyLogDelegator.java.sqlTimingOccured():365) [jdbc.sqltiming]
기타 작업 진행
기타 작업 종료
[11:07:24,210-DEBUG] org.apache.commons.dbcp.DelegatingPreparedStatement.execute(DelegatingPreparedStatement.java:172)
7. SELECT status
FROM test_row_lock
WHERE 1=1
AND id = 1
{executed in 1 msec} (Slf4jSpyLogDelegator.java.sqlTimingOccured():365) [jdbc.sqltiming]
[Spring Boot] @Test에서 multi thread로 동시성 테스트하기
이 글은 인프런 '재고시스템으로 알아보는 동시성이슈 해결방법' 강의를 듣고 작성한 글입니다. 테스트 작성 시 multi thread로 동시에 일어나는 일을 가정한다.멀티 스레드란?정의 : 프로세스 내에
velog.io
https://parkjeongwoong.github.io/articles/Failure/5
Java (Spring Boot) 동시성 테스트
# Java (Spring Boot) 동시성 테스트 ``` 이 글은 한옥 스테이의 예약 시스템을 만들며 마주한 동시성 문제를 해결한 과정을 다룹니다. ``` ## 서론 최근 한옥 스테이에 사용할 예약 시스템을 만들고 있
parkjeongwoong.github.io