TIL

JPA와 외래키로 인한 데드락 해결기

프로젝트를 진행하던 중 배포를 한 뒤 에러가 발생해서 로그를 봤더니 데이터베이스에서 데드락이 발생하고 있었다.

따로 명시적인 락을 잡지 않았는데도 불구하고 데드락이 발생하니 처음엔 무슨 상황인지 이해할 수 없었다.

엔티티 구조

프로젝트의 핵심 도메인은 ‘지원서’이다. 동아리나 기업에 지원할 때 자소서나 지원서를 작성하게 되는데 지원서 작성과 보관을 도와주는 서비스기 때문이다.

하나의 ‘지원서’에는 여러 ‘문항’이 존재하기에 ‘지원 문항’과 그 ‘답변’을 하나로 묶은 ‘지원 항목’을 ‘지원서’와 일대다로 맺어주었다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ApplicationForm {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "application_form_id")
    private Long id;

    // ...

    @Embedded
    private ApplicationItems applicationItems = new ApplicationItems();
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class ApplicationItems {

    @OneToMany(mappedBy = "applicationForm", cascade = {CascadeType.PERSIST, CascadeType.REMOVE})
    private List<ApplicationItem> values = new ArrayList<>();

JPA를 사용하면서 일대다 양방향 매핑을 맺어주었다. 그 이유는 영속성 전이를 사용해서 ApplicationForm 내부의 일급 컬렉션인 applicationItemsApplicationItem을 추가하기만 하더라도 insert 쿼리를 내보내기 위해서고 ApplicationForm이 삭제되었을 때 ApplicationItem도 모두 삭제해 주기 위해서다. ‘지원서’와 ‘지원 항목’이 비즈니스 로직 상 밀접한 관련이 있고 같은 생명 주기를 공유하고 있기 때문에 이와 같이 설계했다.

데드락 발생 코드

public Long addItem(Long applicationFormId,
                    LoginMember loginMember,
                    ApplicationItemCreateRequest request) {
        Writer writer = new Writer(loginMember.id());
        ApplicationForm form = writerCheckedFormService.checkWriterAndGet(applicationFormId, writer);
        ApplicationItem item = form.addItem(
                new ApplicationQuestion(request.applicationQuestion()),
                new ApplicationAnswer(request.applicationAnswer())
        );
        applicationFormRepository.flush();
        return item.getId();
}

위 코드는 ‘지원서’에 ‘지원 항목’을 추가하는 로직을 담당하는 코드다. 이 기능을 실행하면 ‘지원서’ 하나에 ‘지원 항목’이 3개였다면 4개가 되도록 추가할 수 있는 것이다. 영속성 전이를 사용해서 구현했기에 ApplicationItem에 대한 별도의 save() 호출 없이 자바 컬렉션에 저장하는 것 만으로 데이터베이스에 영속시킬 수 있었다.

flush()를 호출하는 이유는 영속성 전이로 저장되는 엔티티의 경우 그냥 save()를 호출했을 때와 달리 PK를 바로 가져와주지 않기 때문이다. 트랜잭션 커밋 시점에 flush를 통해 영속성 전이에 의한 insert 쿼리가 발생하기에 flush()를 명시적으로 호출해서 ‘지원 항목’의 PK를 반환하고 있다.

Hibernate: 
    insert 
    into
        application_item
        (application_item_id, application_answer, application_form_id, application_question, category_id) 
    values
        (default, ?, ?, ?, ?)
Hibernate: 
    update
        application_form 
    set
        last_modified_time=?,
        recruiter=?,
        semester=?,
        member_id=?,
        writing_state=?,
        years=? 
    where
        application_form_id=?

쿼리는 위와 같이 발생한다.

  1. 영속성 전이에 의한 insert 쿼리 발생
  2. ApplicatinoForm 내부의 ApplicatinoItem 컬렉션에 변경이 발생했으니 변경 감지가 발생해서 update 쿼리 발생

필요없는 update 쿼리가 발생하는 것이 조금 마음에 걸렸지만 크게 신경쓰지는 않았다. 영속성 전이 기능을 통해 데이터베이스 종속적이지 않은 코드를 작성하는 것을 더 중요시 했었기 때문이다.

데드락 로그 확인

‘지원 항목 추가’ 기능에서 데드락이 발생한 것인데 동시에 2개 이상의 ‘지원 항목’을 추가하게 되면 데드락이 발생하는 것을 확인했다.

사용자가 만약 한 번에 2개 이상의 지원 항목을 추가한다면 프론트에서 2개의 API 요청을 한 번에 날리게 된다. 이 동시 요청에서 데드락이 발생하는 것인데 데이터베이스에 접속해서 로그를 확인해보니 아래와 같았다.

LATEST DETECTED DEADLOCK
------------------------
2023-05-19 14:10:34 22487951918848
*** (1) TRANSACTION:
TRANSACTION 11701, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 888, OS thread handle 22487844890368, query id 75644 10.0.0.157 root updating
update application_form set last_modified_time='2023-05-19 23:10:34.142917', recruiter='우아한테크코스 4기', semester='SECOND_HALF', member_id=1, writing_state='COMPLETE', years=2021 where application_form_id=3

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 19 page no 4 n bits 80 index PRIMARY of table `wannafly`.`application_form` trx id 11701 lock mode S locks rec but not gap
Record lock, heap no 6 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
 0: len 8; hex 8000000000000003; asc         ;;
 1: len 6; hex 000000002da5; asc     - ;;
 2: len 7; hex 01000001960795; asc        ;;
 3: len 8; hex 8000000000000001; asc         ;;
 4: len 8; hex 434f4d504c455445; asc COMPLETE;;
 5: len 26; hex ec9ab0ec9584ed959ced858ced81acecbd94ec8aa42034eab8b0; asc                       4   ;;
 6: len 5; hex 99b0277217; asc   'r ;;
 7: len 4; hex 800007e5; asc     ;;
 8: len 11; hex 5345434f4e445f48414c46; asc SECOND_HALF;;

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 19 page no 4 n bits 80 index PRIMARY of table `wannafly`.`application_form` trx id 11701 lock_mode X locks rec but not gap waiting
Record lock, heap no 6 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
 0: len 8; hex 8000000000000003; asc         ;;
 1: len 6; hex 000000002da5; asc     - ;;
 2: len 7; hex 01000001960795; asc        ;;
 3: len 8; hex 8000000000000001; asc         ;;
 4: len 8; hex 434f4d504c455445; asc COMPLETE;;
 5: len 26; hex ec9ab0ec9584ed959ced858ced81acecbd94ec8aa42034eab8b0; asc                       4   ;;
 6: len 5; hex 99b0277217; asc   'r ;;
 7: len 4; hex 800007e5; asc     ;;
 8: len 11; hex 5345434f4e445f48414c46; asc SECOND_HALF;;

*** (2) TRANSACTION:
TRANSACTION 11699, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 886, OS thread handle 22488434579200, query id 75646 10.0.0.157 root updating
update application_form set last_modified_time='2023-05-19 23:10:34.135738', recruiter='우아한테크코스 4기', semester='SECOND_HALF', member_id=1, writing_state='COMPLETE', years=2021 where application_form_id=3

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 19 page no 4 n bits 80 index PRIMARY of table `wannafly`.`application_form` trx id 11699 lock mode S locks rec but not gap
Record lock, heap no 6 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
 0: len 8; hex 8000000000000003; asc         ;;
 1: len 6; hex 000000002da5; asc     - ;;
 2: len 7; hex 01000001960795; asc        ;;
 3: len 8; hex 8000000000000001; asc         ;;
 4: len 8; hex 434f4d504c455445; asc COMPLETE;;
 5: len 26; hex ec9ab0ec9584ed959ced858ced81acecbd94ec8aa42034eab8b0; asc                       4   ;;
 6: len 5; hex 99b0277217; asc   'r ;;
 7: len 4; hex 800007e5; asc     ;;
 8: len 11; hex 5345434f4e445f48414c46; asc SECOND_HALF;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 19 page no 4 n bits 80 index PRIMARY of table `wannafly`.`application_form` trx id 11699 lock_mode X locks rec but not gap waiting
Record lock, heap no 6 PHYSICAL RECORD: n_fields 9; compact format; info bits 0
 0: len 8; hex 8000000000000003; asc         ;;
 1: len 6; hex 000000002da5; asc     - ;;
 2: len 7; hex 01000001960795; asc        ;;
 3: len 8; hex 8000000000000001; asc         ;;
 4: len 8; hex 434f4d504c455445; asc COMPLETE;;
 5: len 26; hex ec9ab0ec9584ed959ced858ced81acecbd94ec8aa42034eab8b0; asc                       4   ;;
 6: len 5; hex 99b0277217; asc   'r ;;
 7: len 4; hex 800007e5; asc     ;;
 8: len 11; hex 5345434f4e445f48414c46; asc SECOND_HALF;;

*** WE ROLL BACK TRANSACTION (2)

로그가 복잡하지만 간단하게 정리하면 다음과 같다.

  1. 트랜잭션 11699번과 11701번이 동시에 ‘지원 항목 추가’ 쿼리 실행
  2. 두 트랜잭션 모두 application_form 테이블에 S Lock을 획득
  3. 두 트랜잭션 모두 application_form에 대해 X Lock 획득 시도
  4. 다른 트랜잭션에서 S Lock을 걸고 있기 때문에 X Lock을 획득하기 위해 대기
  5. 서로 기다리게 되면서 데드락 발생
  6. 트랜잭션 11699번을 롤백

데드락 원인 파악

우선 S Lock과 X Lock이 무엇인지 이해하는 것이 필요하다.

더 쉽게 풀자면 S Lock은 한 트랜잭션이 어떤 테이블 row를 읽을 때 다른 트랜잭션에서 이 row을 변경하지 못하도록 막는 잠금이다. 변경만 막고 단순 select나 S Lock을 함께 거는 것도 막지 않는다. 반면 X Lock은 다른 트랜잭션의 단순 select까지는 허용하지만 그 어떤 잠금(S Lock도 포함)도 거는 것을 허용하지 않는다.

위 데드락 로그에서 살펴 봤듯이 두 트랜잭션에서 같은 테이블 row에 대해 S Lock → X Lock 순으로 잠금을 획득하려고 할 때 동시 접근이 발생하면 데드락이 발생할 수밖에 없는 것이다. 두 트랜잭션이 서로가 S Lock을 해제하기를 기다리며 X Lock을 걸려고 기다리고 있기 때문이다.

트랜잭션 1 트랜잭션 2
S Lock 획득  
  S Lock 획득
X Lock 획득 시도, 대기  
  X Lock 획득 시도, 대기
  데드락 탐지, 롤백
X Lock 획득  
커밋  

외래키 제약과 잠금 전파

‘지원 항목 추가’ 기능의 쿼리가 S Lock → X Lock 순으로 잠금을 획득하는 것은 외래키 제약과 큰 관련이 있다. application_formapplication_item은 외래키 제약을 맺고 있다.

Hibernate: 
    insert 
    into
        application_item
        (application_item_id, application_answer, application_form_id, application_question, category_id) 
    values
        (default, ?, ?, ?, ?)
Hibernate: 
    update
        application_form 
    set
        last_modified_time=?,
        recruiter=?,
        semester=?,
        member_id=?,
        writing_state=?,
        years=? 
    where
        application_form_id=?

위 SQL을 다시 살펴보면 applciation_item에 insert 후 application_form에 update를 하고 있다. application_form에 대한 update 쿼리 수행 시 X Lock을 획득하는 것은 당연한 흐름이다. 그런데 S Lock은 대체 어디서 획득하는 것일일까? 답은 외래키 제약과 참조 무결성에 있다.

참조 무결성(referential integrity)은 관계 데이터베이스 관계 모델에서 외래키 제약이 걸린 테이블 간의 일관성(데이터 무결성)을 말한다. 쉽게 말하면 appcliation_item을 insert하거나 update하는 과정에서 다른 트랜잭션에서 application_form을 변경하거나 삭제하게 되면 무결성을 침해할 수 있다. 때문에 부모 테이블을 변경하지 못하도록 부모 테이블에 S Lock을 걸게 되는 것이다.

데드락 해결

원인은 파악했으니 이제 해결을 해야 한다. 해결 방법은 몇 가지가 있다.

  1. 프론트에서 ‘지원 항목 추가’ API를 동시에 날리지 못하게 한다.

    → 사용자 경험 상 좋지 않고 애초에 동시에 날리면 안 되는 API가 말이 되지 않는다.

  2. 외래키 제약을 해제한다.

    → 실제로 참조 관계를 갖고 있지만 외래키 제약을 맺지 않는 다른 테이블이 존재하기는 한다. 하지만 ‘지원서’와 ‘지원 항목’은 밀접한 관련을 갖고 있고 참조 무결성을 지켜야 하는 중요한 관계라고 판단해서 해제하지 않는 것으로 했다.

  3. 필요 없는 update 쿼리를 삭제한다.

    → 위에서도 언급했듯이 update 쿼리는 영속성 전이, 변경 감지를 사용하면서 딸려 나가는 쿼리고 비즈니스 로직상 필요가 없다.

필요 없는 update 쿼리, 필요 없는 X Lock을 제거하는 것이 가장 좋다고 판단해서 3번 해결 방법을 채택하고 코드를 변경했다.

public Long addItem(Long applicationFormId,
                        LoginMember loginMember,
                        ApplicationItemCreateRequest request) {
        Writer writer = new Writer(loginMember.id());
        ApplicationForm form = writerCheckedFormService.checkWriterAndGet(applicationFormId, writer);
        ApplicationItem item = new ApplicationItem(
                form,
                new ApplicationQuestion(request.applicationQuestion()),
                new ApplicationAnswer(request.applicationAnswer())
        );
        applicationItemRepository.save(item);
        return item.getId();
}

ApplicationItem을 영속성 전이에 의한 저장이 아닌 명시적인 save() 호출로 저장함으로써 insert 쿼리만 발생하게 하였다.

Hibernate: 
    insert 
    into
        application_item
        (application_item_id, application_answer, application_form_id, application_question, category_id) 
    values
        (default, ?, ?, ?, ?)

‘지원 항목 추가’에는 영속성 전이가 필요 없어졌지만 ‘지원서’를 저장할 때 ‘지원 항목’도 함께 저장하도록 되어 있기 때문에 CascadeType.PERSIST는 유지하고 있다.

결론