레코드의 정합성 문제 해소를 위한 고민 - SERIALIZABLE, x 락(for update), update - affected 검토

들어가며

  • 격리수준, 락, 데이터 정합성에 대하여 앞서 4개의 글로 정황하게 정리했던 이유는, 사실 지금의 문제를 해소하기 위해서였다.
  • 문제가 발생한 환경은 mysql, mybatis, 격리수준은 Repeatable Read이다.
  • 문제가 발생했던 코드는 대략 아래와 같다.
// service
public void execute(Long firstId){
    // select count(*) from first where status = 'NEW' and id = :id
    if(firstRepository.countByIdAndStatus(NEW, firstId)==0)
        throw new IllegalArgumentException("first 테이블 중 상태가 NEW인 레코드가 없다. first.id : "+  firstId);

    // select count(*) from second where first_id = :first_id
    if(secondRepository.countByFirstId(firstId)>0)
        throw new IllegalArgumentException("second 테이블에 요청한 first_id를 가진 레코드가 이미 존재한다. first_id : "+  firstId);

    sleep(...); // 다양한 문제로 현 위치에 여러 요청이 대기 중이다.

    // update first set status = 'DONE' where status = 'NEW' and id = :id
    firstRepository.updateStatusNewToDone(firstId);

    // insert into second (first_id) values (:first_id)
    secondRepository.save(Second.builder().firstId(firstId).build());
}
  • first, second 테이블이 있다. second에 레코드를 삽입하는 것이 목표이며 이에 대한 전제조건은 1) 요청한 first 레코드가 ‘NEW’ 이며 2) second에 first_id를 가진 레코드가 없어야 한다. 모든 로직이 종료되면 first 레코드의 상태가 ‘NEW’에서 ‘DONE’이 된다.
  • 만약 save 메서드에 두 개의 동일한 요청이 들어왔다면 어떻게 될까? 더 정확하게, secondRepository.countByFirstId를 통과한 동일한 요청 2개가 갱신을 앞두고 있다면 어떻게 될까?
    • second 테이블의 first_id에 uk가 존재하고 동일한 first_id에 대한 요청이 uk로 인해 에러를 발생시키는 것이 가장 이상적이다.
    • 안타깝게도 uk는 없었고 완전 동일한 레코드 두 개가 insert가 될 수 있는 조건이었다.
    • 테이블의 변경이 부담스러운 상태이다. 현 상황에서 문제를 해결하고자 하였다.

1차 시도 : SERIALIZABLE

  • 동일한 insert가 중복되는 일을 막아야 했다. 격리수준을 SERIALIZABLE으로 변경했다.
@Transactional(isolation = Isolation.SERIALIZABLE)
public void execute(Long firstId){
    // 상동
}
  • 당장은 기대하는 방향대로 동작하였다. first와 second를 검증하는 쿼리를 실행하면 s lock을 얻는다. count가 0을 호출한다는 의미는 해당 테이블 전체를 탐색했다는 의미이며 모든 레코드에 대하여 갭락이 생긴다.
  • 다만, 이로 인한 사이드 이펙트가 발생하였다. 위의 쿼리는 first 혹은 second 중 하나에 반드시 전체 레코드에 대한 갭락이 발생한다. 왜냐하면 first를 검증할 때는 count가 1이 되기를 바라며 second를 검증할 때는 count가 0이 되기를 바라기 때문이다. 그리고 로직에는 first와 second 둘 다 갱신된다.
  • 이로 인하여 SERIALIZABLE로 sleep() 메서드까지 도착한 여러 개의 트랜잭션이 있을 경우
    • 같은 요청이라면 데드락을 통해 하나를 죽여서 기대하는 방식대로 동작하지만
    • 다른 요청이라도 데드락으로 인해 하나가 죽는 일이 발생한다.
  • 클라이언트 입장에서는 사용성이 떨어지며, 서버 입장에서는 굳이 필요 없는 락으로 인한 성능 문제가 발생했다.

2차 시도 : 해당 로직은 하나의 스레드만 동기적으로 select … for update

  • 최초에 나는 x락으로 해소하려 하였다. 이유는 x lock이 select에 대한 배타적 락을 가진다고 이해했기 때문이다.
public void execute(Long firstId){
    // select count(*) from first where status = 'NEW' and id = :id for update
    if(firstRepository.countByIdAndStatusForUpdate(NEW, firstId)==0)
        throw new IllegalArgumentException("first 테이블 중 상태가 NEW인 레코드가 없다. first.id : "+  firstId);

    // select count(*) from second where first_id = :first_id for update
    if(secondRepository.countByFirstIdForUpdate(firstId)>0)
        throw new IllegalArgumentException("second 테이블에 요청한 first_id를 가진 레코드가 이미 존재한다. first_id : "+  firstId);
    
    // 이하 상동
}
  • x 락은 실제로 동작하였다.
    • 같은 요청이 들어올 경우 첫 번째 검증 로직에서 동기적으로 처리된다. first_id가 같기 때문이다. 그리고 예외가 발생한다.
    • 다른 요청이 들어올 경우 첫 번째 검증 로직에서 비동기적으로 처리되나, 두 번째 검증 로직에서 테이블 락이 걸리며 동기적으로 처리된다. 이로 인한 성능 문제가 발생하지만, 원하는 방향으로 정상 동작한다.
    • 값이 없을 경우 첫 번째 검증 로직에서 예외가 발생한다.

x락의 한계와 sql에 의존적인 코드

  • 결과적으로 나는 for udpate의 사용을 하지 않았다. 그 이유는 세 가지였다.
  • 첫 번째는 도메인과 인프라스트럭처 간 결합도를 높이는 방향이었기 때문이다. 리포지토리 인터페이스에 countByIdAndStatus와 더불어 countByIdAndStatusForUpdate란 메서드를 추가해야하며 이로 인한 복잡도가 올라간다. 메서드 명이 보여주는 것처럼, 서비스 레이어 코드를 이해하기 위해서는 for update를 이해야만 한다. 레이어 간 강한 결합이 발생한다. 결과적으로 DB의 x lock에 대한 선행지식이 필요로 한 서비스 레이어가 되어 버린다. 락에 대한 지식이 없는 개발자가 유지보수하는 경우 문제가 발생할 수도 있다.
  • 다음으로 x락의 한계가 존재하기 때문이다. 첫 번째는 성능 문제다. 같은 요청이 들어올 경우 나머지 한 요청은 대기해야만 한다. 불필요한 대기가 발생한다. 두 번째는 (이전 블로그에서 작성한 내용처럼) 존재하지 않는 레코드를 for update로 조회할 경우 테이블 전체에 s 락을 걸어버린다. 존재하지 않는 난수를 요청할 가능성이 충분히 있는 현 시스템에서 향후 치명적인 문제로 발전할 수 있다.

3차 시도 : UPDATE… affected 0

  • update는 격리수준에 관계 없이 실제 DB에서 해당 쿼리가 성공했는지의 여부를 반환한다. affcted rows 0 혹은 1이라는 형태로 반환한다.
  • 이에 대한 테스트는 아래와 같다. 트랜잭션 A, B가 동시에 동작한다. 주석(–) 처리된 것이 트랜잭션 B이다.
select * from test where seq = 1 and age = 10 and name = 'kim';
-- select * from test where seq = 1 and age = 10 and name = 'kim';
-- update test set age = 11 where seq = 1 and age = 10 and name = 'kim'; -- 1 row(s) affected 
-- commit;
update test set age = 11 where seq = 1 and age = 10 and name = 'kim'; -- 0 row(s) affected 
  • 위의 쿼리를 보면 요청은 동일하다. update test set age = 11 where seq = 1 and age = 10 and name = 'kim'; 하지만 먼저 처리한 트랜잭션은 1 row가 영향을 받았지만, 다음 트랜잭션은 0 row가 영향을 받는다.
  • 이러한 원리를 통해 아래와 같은 로직을 만들 수 있다.
public void execute(Long firstId){
    if(firstRepository.updateStatusNewToDone(firstId)==0)
        throw new IllegalArgumentException("first 테이블 중 상태가 NEW인 레코드가 없다. first.id : "+  firstId);

    if(secondRepository.countByFirstId(firstId)>0)
        throw new IllegalArgumentException("second 테이블에 요청한 first_id를 가진 레코드가 이미 존재한다. first_id : "+  firstId);

    secondRepository.save(Second.builder().firstId(firstId).build());
}
  • 더 단순하고 명확하게 로직을 문제를 해결할 수 있다.
  • update의 성공 갯수는 격리수준이나 트랜잭션의 교착 등과 관계 없다. 언제나 분명한 성공 갯수를 반환하고, 레코드 락(x락)을 걸어버린다.
  • first의 update의 성공 갯수를 기준으로 로직을 짠 결과
    • first의 count 쿼리 하나를 없앴고
    • 격리수준이나 락에 대한 선행 지식 없이 이해할 수 있는 코드를 작성할 수 있었다.
    • forUpdate와 달리 테이블 락이 없어 성능이 좋다.
  • 격리수준이 REPEATABLE READ이기 때문에 여러 트랜잭션이 동시에 update 및 insert를 할 수 있어서 성능상 좋다.

나아가며

  • DB에 잘 알지 못하는 상황에서 혼자 머리 싸매고 끙끙거리며 학습 및 테스트하느라 힘들었다. 하지만 트랜잭션이나 락 등에 대하여 정말 깊이있게 이해할 수 있는 계기가 되었다. 역시 학습은 실천과 결합될 때 최고의 성과가 나오는가? 결과적으로 문제도 해결해서 기분도 무척 좋았다.
  • 처음부터 DDL을 잘 설계해야 함을 절실히 깨달았다. uk를 적절하게 활용했다면 지금처럼 트랜잭션이나 락에 대해 고민할 필요가 없었다. 나중에 가서 uk를 설정하는 것은 무척 부담스러운 일이다.
  • 데이터 정합성 측면에서 데드락이 오히려 고마운 존재(?)임을 느끼게 된 계기가 되었다. 물론 결과적으로 없애야 할 에러이긴 하지만.

추가 22.10.20

  • 전반적으로 오류나 글을 수정하였다.
  • 실제로 이러한 문제를 자바 어플리케이션으로 시연할 수 없을까 고민하였다. 많은 삽질 끝에 그때의 상황을 테스트 코드로 재현할 수 있었다. 멀티 스레드와 다중 트랜잭션 상황을 재현하고 테스트 코드로 작성하기 위한 고민까지 많이 녹였다.
  • https://github.com/infoqoch/openstudy/tree/master/spring-jdbc-template
  • qoch.springjdbctemplate.service.DeadlockServiceTest 참고