지인에게 데이터베이스에서의 DeadLock을 경험해본적이 있냐는 질문을 받았다. 없었다. 지금까지 만들어왔던 모든 기능들에서 데드락에 대한 처리를 한 적이 없었다..
그래서 가상의 숫자 카운팅 어플리케이션을 통해 데드락이 일어날 상황을 시뮬레이션해보고 테스트해봤다.
M1 MAC, JAVA 11, MySQL
들어온 요청만큼 숫자가 반드시 1이 증가해야하는 어플리케이션이 있다. 요청은 많은 사람들이 동시에 지속적으로 요청을 보낸다고 가정한다.
아무 처리를 하지 않았을 땐, 동시 요청이 들어올 경우 MySQL의 기본 Isolation인 REPEATABLE READ로 적용된다.
따라서, 트랜잭션이 시작될때 읽은 값이 끝날때 까지 유지되므로, 동시에 N개의 요청이 들어왔을 때의 숫자가 0이라면, 모두 1로 업데이트 될 것이다.
요구사항에 맞추기 위해 데드락이 자주 발생할 것이라고 생각하고, Select 시 배타적 락(X Lock)을 걸어 블락을 걸고, 순서를 보장한다.
=> 공유 락(S Lock)이 안되는 이유 : 한 트랜잭션이 공유 락을 걸었을 경우, 다른 트랜잭션 또한 공유 락을 걸어 둘 다 읽을 수는 있다.
하지만, 요구사항처럼 데이터를 수정해야하는 경우, 공유 락이 걸린 데이터는 수정할 수 없기 때문에 배타적 락을 걸어야 하는데 공유 락과 배타적 락은 함께 사용할 수 없어 데드락이 발생한다.
Serializable로 데드락을 유도하고, 발생 시 리트라이 한다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
public class NumberEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
private Long count;
@Builder
private NumberEntity(int id, Long count) {
this.id = id;
this.count = count;
}
public static NumberEntity of(int id, Long count){
return NumberEntity.builder()
.id(id)
.count(count)
.build();
}
public Long increment(){
return ++this.count;
}
public Long decrement(){
return --this.count;
}
}
@Repository
public interface NumberRepository extends JpaRepository<NumberEntity, Integer> {
Optional<NumberEntity> findById(int id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select n from NumberEntity n where n.id = :id")
Optional<NumberEntity> findByNumberIdLock(@Param("id") int id);
@Query("select n from NumberEntity n where n.id = :id")
Optional<NumberEntity> findByNumberIdIsolation(@Param("id") int id);
}
@RequiredArgsConstructor
@Service
@Slf4j
public class NumberService {
private final NumberRepository numberRepository;
private final int numberId = 1;
@Transactional
public void incrementNumberNormal(){
NumberEntity numberEntity = numberRepository.findById(numberId).orElseThrow(NoSuchElementException::new);
System.out.println(Thread.currentThread().getName() + " : " + numberEntity.increment() + " " + numberEntity);
}
@Transactional
public void incrementNumberLock() {
NumberEntity numberEntity = numberRepository.findByNumberIdLock(numberId).orElseThrow(NoSuchElementException::new);
System.out.println(Thread.currentThread().getName() + " : " + numberEntity.increment() + " " + numberEntity);
}
@Transactional(isolation = Isolation.SERIALIZABLE)
public void incrementNumberSerializable(){
NumberEntity numberEntity = numberRepository.findByNumberIdIsolation(numberId).orElseThrow(NoSuchElementException::new);
System.out.println(Thread.currentThread().getName() + " : " + numberEntity.increment());
}
}
테스트는 @SpringBootTest를 활용해 어플리케이션 구동환경과 맞춘상태로 진행한다. 또한 동시 요청 테스트를 위해 쓰레드 100개를 활용해 각각 진행하며, 모든 테스트의 숫자는 0에서 시작한다.
@SpringBootTest
public class NumberServiceTest {
private static final int COUNT = 100;
private static final ExecutorService service = Executors.newFixedThreadPool(COUNT);
@Autowired
private NumberService numberService;
}
@Test
@DisplayName("요청 수 만큼 숫자 증가 (Normal)")
void incrementNumber_normal() throws InterruptedException {
// given
CountDownLatch latch = new CountDownLatch(COUNT);
Long before = numberService.getNumber();
// when
for (int i = 0; i < COUNT; ++i) {
service.execute(() -> {
numberService.incrementNumberNormal();
latch.countDown();
});
}
// then
latch.await();
assertEquals(before + COUNT, numberService.getNumber());
}
결과
이론과 같이 요청은 100번 들어왔지만, 실제 업데이트된 숫자는 14임을 볼 수 있다. 모든 요청이 숫자가 증가됨을 보장할 수 없다는 것을 알 수 있다.
@Test
@DisplayName("요청 수 만큼 숫자 증가 (Lock - PESSIMISTIC_WRITE)")
void incrementNumber_concurrency() throws InterruptedException {
// given
CountDownLatch latch = new CountDownLatch(COUNT);
Long before = numberService.getNumber();
// when
for (int i = 0; i < COUNT; ++i) {
service.execute(() -> {
numberService.incrementNumberLock();
latch.countDown();
});
}
// then
latch.await();
assertEquals(before + COUNT, numberService.getNumber());
}
이론과 같이 모든 트랜잭션이 블로킹에 의해 순서대로 처리되어 정상적으로 100 증가되었다.
@Test
@DisplayName("요청 수만 큼 증가 (Isolation - SERIALIZABLE)")
void incrementNumber_Isolation() throws InterruptedException {
// given
CountDownLatch latch = new CountDownLatch(COUNT);
Long before = numberService.getNumber();
// when
for (int i = 0; i < COUNT; ++i) {
service.execute(() -> {
numberService.incrementNumberSerializable();
latch.countDown();
});
}
// then
latch.await();
assertEquals(before + COUNT, numberService.getNumber());
}
데드락이 발생했다. 그런데, 데드락 이후에 리트라이를 해주더라도 요청이 지속되는 어플리케이션이라면 리트라이에도 성공한다는 보장이 없다. 상황에 따라 다르겠지만, 이 어플리케이션의 요구사항에서는 데드락자체를 발생시키는 것이 적합하지 않다는 생각이 든다.
이론 중 최종선택을 한다면, Lock을 선택할 것이다.
하지만, 자료들을 찾아보면서 Lock과 Isolation 모두 신중하게 적용해야한다는 내용들을 공통적으로 볼 수 있었다. 확실히 Lock에 의한 순서 보장이 장점일 수도 있지만, 요청이 많아진다면 언제까지 기다릴수도 없다고 생각된다. 특히나 서버 여러 개가 물려있어 요청에 요청을 무는 구조에서는 더더욱 그럴 것이다.
앞으로의 과제는 이 동시성과 일관성의 균형을 상황에 맞게 잘 결정하는 것이 될 것 같다.
감사합니다. 덕분에 많은부분 도움되었습니다!