트러블슈팅

[트러블슈팅] 외래키로 인한 데드락 발생

snow(이슬) 2024. 3. 15. 21:28

투표 참여 기능 설명

투표는 최대 1000명까지의 참여 인원 수를 설정할 수 있으며, 설정된 참여 인원 수가 모두 차면 투표는 즉시 종료됩니다. 회원은 투표 아이템 중 하나를 선택하여 투표에 참여할 수 있습니다.
 
투표 도메인에는 votes(투표) 테이블과 voters(투표자) 테이블이 있으며, 이 두 테이블은 1:N 관계를 가집니다. voters(투표자) 테이블에서 votes(투표) 테이블의 기본키를 외래키로 사용합니다.

votes와 voters 테이블

 
투표 참여 기능에 사용되는 코드는 아래와 같습니다. Vote 엔티티와 Voter 엔티티는 양방향 매핑을 통해 연결됩니다.

VoteManager.participate()

@Transactional
public void participate(
	final Vote vote,
	final Long memberId,
	final Long itemId
) {
	final Voter voter = new Voter(vote, memberId, itemId);
        
	voter.participate(itemId); // 투표 아이템 중 하나를 선택하여 투표에 참여

	if (vote.reachMaximumParticipants()) {
    	vote.close(LocalDateTime.now());
	}
}

Voter 엔티티

@Entity
@Table(name = "voters")
public class Voter {
    // 필요한 코드만 표시하였습니다.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "vote_id", nullable = false)
    private Vote vote;
    
    public void participate(final Long itemId) {
    	this.itemId = itemId;
        this.vote.addVoter(this);
    }
}

Vote 엔티티

@Entity
@Table(name = "votes")
public class Vote {
    // 필요한 코드만 표시하였습니다.
    @OneToMany(mappedBy = "vote", cascade = CascadeType.ALL)
    private final List<Voter> voters = new ArrayList<>();
    
    public void addVoter(final Voter voter) {
    	if (!this.voters.contains(voter)) {
        	this.voters.add(voter);
        }
    }
    
    public boolean reachMaximumParticipants() {
    	return this.maximumParticipants <= this.voters.size();
    }
    
    public void close(final LocalDateTime now) {
    	this.endTime = now;
    }
}

 

문제 상황

투표 참여 기능의 성능 테스트 중, 예상치 못한 문제가 발생했습니다.
 

테스트 환경

  • Apple M1 Pro
  • IntelliJ 2023.1.5
  • Spring Boot 3.1.5
  • MySQL 8.0.32
  • Jmeter 5.6.2

테스트 조건

  • 대상 투표 ID: 1번
  • 설정된 제한 인원: 1000명
  • 동시 참여 사용자 수: 1010명 

테스트 결과

기대했던 1000명의 투표자와 달리, 실제로는 1005명의 투표자가 확인되었고 5개의 데드락이 발생했습니다. 10번의 반복 테스트에서도 제한 인원을 초과하는 투표자가 생겼고 참여 실패 인원 수만큼 데드락이 발생했습니다.
 
🚨 데드락과 동시성 이슈가 동시에 발생한 것입니다... 머리가 띵

투표 제한 인원을 초과하는 투표자 수
데드락으로 인한 오류(테스트 실패)

 

데드락 발생 예외

데드락으로 인해 발생한 예외부터 살펴보겠습니다.

org.springframework.dao.CannotAcquireLockException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [update votes set content=?,end_time=?,hobby=?,item1_id=?,item2_id=?,maximum_participants=?,member_id=?,modified_at=?,start_time=? where id=?]; SQL [update votes set content=?,end_time=?,hobby=?,item1_id=?,item2_id=?,maximum_participants=?,member_id=?,modified_at=?,start_time=? where id=?]
...
Caused by: org.hibernate.exception.LockAcquisitionException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction] [update votes set content=?,end_time=?,hobby=?,item1_id=?,item2_id=?,maximum_participants=?,member_id=?,modified_at=?,start_time=? where id=?]
...
Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
...

이 예외 로그로부터 도출할 수 있는 두 가지 포인트는 다음과 같습니다.
 

  1. 락을 획득하려 할 때 데드락이 감지되었으며, MySQL에서 해당 트랜잭션을 롤백했다.
  2. votes(투표) 테이블의 update문 실행 중 데드락이 발생했다.

 
먼저 1번부터 알아보겠습니다.
 

데드락이 감지되면 트랜잭션 롤백?! 🤔

네, 맞습니다. MySQL InnoDB에서는 데드락을 감지하면 자동으로 트랜잭션을 롤백합니다.
 
MySQL에서 데드락이란 서로 다른 트랜잭션이 모두 다른 쪽의 작업이 완료되기를 기다리고 있기 때문에 어느 한 쪽도 진행할 수 없게 되는 상태를 말합니다. InnoDB는 이를 감지하고 자동으로 하나의 트랜잭션을 롤백하여 문제를 해결합니다.
 

InnoDB 스토리지 엔진은 데드락 감지 스레드를 가지고 있어서 데드락 감지 스레드가 주기적으로 잠금 대기 그래프를 검사해 교착 상태에 빠진 트랜잭션들을 찾아서 그중 하나를 강제 종료한다. 이때 어느 트랜잭션을 먼저 강제 종료할 것인지를 판단하는 기준은 트랜잭션의 언두 로그 양이며, 언두 로그 레코드를 더 적게 가진 트랜잭션이 일반적으로 롤백의 대상이 된다.

Real MySQL 8.0 (1권)104 페이지

교착 상태 감지가 활성화 되면 (기본값) InnoDB는 자동으로 트랜잭션 교착 상태를 감지하고 트랜잭션을 롤백하여 교착 상태를 해제합니다. InnoDB는 롤백할 작은 트랜잭션을 선택하려고 시도합니다. 여기서 트랜잭션의 크기는 삽입, 업데이트 또는 삭제된 행 수에 따라 결정됩니다.

MySQL 공식 문서

 

외래키 때문에 데드락 발생?! 🤔

InnoDB의 외래키 관리에는 중요한 두 가지 특징이 있다.
테이블의 변경(쓰기 잠금)이 발생하는 경우에만 잠금 경합(잠금 대기)이 발생한다.
• 외래키와 연관되지 않은 칼럼의 변경은 최대한 잠금 경합(잠금 대기)을 발생시키지 않는다.

물리적으로 외래키를 생성하면 자식 테이블에 레코드가 추가되는 경우 해당 참조키가 부모 테이블에 있는지 확인한다는 것은 이미 다들 알고 있을 것이다. 하지만 물리적인 외래키의 고려 사항은 이러한 체크 작업이 아니라 이러한 체크를 위해 연관 테이블에 읽기 잠금을 걸어야 한다는 것이다.

Real MySQL 8.0 (1권) 280~281 페이지

 
InnoDB는 외래키를 관리하기 위해 자식 테이블에서 부모 테이블로 잠금이 확장되고, 이때 읽기 잠금을 사용합니다. 이로 인한 잠금 확장은 데드락의 원인이 될 수 있습니다.
 
저의 상황도 이로 인한 잠금 확장으로 데드락이 발생한 것이라고 추측합니다.
 

상황 분석

투표 참여 기능에서 제한 인원이 모두 찼을 경우, Voter 엔티티 생성과 Vote 엔티티 수정이 발생합니다. 이 과정에서 voters 테이블(자식)에 insert 연산 후 votes 테이블(부모)에 update 연산이 진행될 것 입니다.

@Transactional
public void participate(
	final Vote vote,
	final Long memberId,
	final Long itemId
) {
	final Voter voter = new Voter(vote, memberId, itemId); // Voter 엔티티 생성 -> 자식 insert
        
	voter.participate(itemId);

	if (vote.reachMaximumParticipants()) {
    	vote.close(LocalDateTime.now()); // Vote 엔티티 업데이트 -> 부모 update
	}
}

자식 테이블에 insert 할 때, 외래키에 해당하는 부모 테이블 레코드에 공유락이 걸리고, 이 상태에서 부모 테이블 update 시도 시 필요한 베타락을 획득할 수 없어 데드락이 발생한 것이라고 추측합니다.
 
제 추측이 맞는지 확인해보겠습니다.
 

최근에 발생한 데드락 확인

최근에 발생한 데드락을 확인하기 위해서는 MySQL에서 제공하는 아래 명령어를 사용할 수 있습니다. 이 명령어를 실행하면, 'LATEST DETECTED DEADLOCK' 섹션에서 가장 최근에 감지된 데드락에 대한 정보를 확인할 수 있습니다.

show engine innodb status;

위 내용의 핵심만 추출하면 아래와 같습니다.

(1) TRANSACTION:
*** (1) HOLDS THE LOCK(S):
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:    // 'THIS LOCK'은 X락을 의미합니다.

(2) TRANSACTION:
*** (1) HOLDS THE LOCK(S):
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:     // 'THIS LOCK'은 X락을 의미합니다.

*** WE ROLL BACK TRANSACTION (2)
  1. 첫 번째 트랜잭션은 이미 S락(공유 락)을 보유하고 있고, 추가적으로 X락(베타 락)을 획득하기 위해 대기 중입니다.
  2. 두 번째 트랜잭션도 마찬가지로 S락(공유 락)을 보유하고 있으며 X락(베타 락)을 획득하기 위해 대기 중입니다.

이 상황에서 양쪽 트랜잭션 모두 X락을 획득하려고 대기하지만 이미 S락을 보유하고 있기 때문에 획득하지 못해서 데드락이 발생한 것으로 보입니다. 문제가 발생한 테이블은 votes(부모) 테이블이며, 결국 두 번째 트랜잭션이 롤백되어 데드락이 해소되었습니다.
 
데드락의 발생 원인을 분석한 결과, 추측대로 외래키로 인한 잠금 확장인 것 같습니다. 하지만 아직도 100% 이해하지는 못했습니다. 더 잘 이해하기 위해 데드락 상황을 재연해보겠습니다.
 

데드락 상황 재연

간단한 parent와 child 테이블을 생성하여 테스트를 진행하겠습니다. 두 테이블은 votes(투표)와 voters(투표자) 테이블의 관계처럼 부모-자식 관계로 설정합니다.

parent와 child 테이블

 
저의 상황과 똑같이 자식 테이블에 대한 insert 작업을 먼저 수행한 뒤, 부모 테이블에 대해 update 작업을 진행하겠습니다.

예상대로 데드락이 발생했습니다.
 
MySQL에서 data_locks 테이블을 활용하여 현재 획득하거나 대기 중인 락을 확인할 수 있습니다. 각각 insert와 update 명령 실행 시, 어떻게 락이 걸리는지 확인해보겠습니다.

select * from performance_schema.data_locks;

1. 트랜잭션1, 자식 테이블에 parent = 1인 테이터 삽입

2. 트랜잭션2, 자식 테이블에 parent = 1인 테이터 삽입

3. 트랜잭션1, 부모 테이블에서 parent = 1인 레코드 업데이트

트랜잭션1에서 진행되는 parent 테이블은 X락을 획득하기 위해 대기 중인 것을 확인할 수 있습니다.

4. 트랜잭션2, 부모 테이블에서 parent = 1인 레코드 업데이트 -> 데드락 발생 🚨🚨🚨

데드락이 감지되어 트랜잭션2가 롤백되었습니다. data_locks 테이블을 통해 트랜잭션2에서 발생한 락이 모두 해제된 것을 볼 수 있습니다.

 
각 쿼리문을 실행할 때마다 새롭게 획득한 락을 표로 간단하게 정리해 보았습니다.

정리하면, insert 명령을 사용할 때 외래키 관계로 인해 parent 테이블에 공유 락이 걸리는 것이 확인됩니다. 이후 update 명령을 시도할 때, 필요한 베타 락을 얻기 위해 데드락 상태에 빠지는 상황이 발생합니다.

데드락이 감지되는 순간, InnoDB는 언두 로그(undo log)의 양이 적은 트랜잭션을 선택해 롤백합니다. 이 기준에 따라 가장 최근에 시작된 트랜잭션인 2번 트랜잭션이 롤백됩니다. 이로 인해 트랜잭션 1번은 문제 없이 정상적으로 진행될 수 있게 됩니다.

 

새로운 의문

갑자기 새롭게 생긴 의문 하나가 있습니다.
 
"트랜잭션1번이 이미 공유 락과 베타 락이 걸려 있는 상태인데, 어떻게 정상적으로 실행될 수 있는걸까? 🧐"
 
이는 락에 대한 이해가 부족해서 생긴 궁금증이었습니다. 궁금증을 해소하기 위해, 트랜잭션 하나로 insert와 update를 실행한 후 락 테이블을 확인해보았습니다. 그 결과, parent 테이블의 id가 1인 레코드에 공유 락과 베타 락이 동시에 걸려있는 것을 확인할 수 있었습니다.

더더욱 헷갈렸습니다. 🤯  제가 알고 있던 바로는 공유 락과 베타 락이 동시에 존재할 수 없다고 알고 있었기 때문입니다. 또한 이렇게 락이 동시에 걸린 상태에서도 commit을 하면 정상적으로 작동한다는 사실을 보고, commit하는 과정에서 명령어가 순차적으로 실행되며 락이 차례대로 해제되는 것은 아닐까? 하는 생각도 들었습니다.
 
이에 대한 답을 계속 찾아봤지만 명확한 답을 얻지 못했습니다. 결국 Real MySQL 오픈채팅방에 질문을 던졌고, Real MySQL 저자이신 성욱님께서 직접 답변을 주셨습니다.
 

의문 해결

제가 Real MySQL 오픈채팅방에 질문한 내용은 다음와 같습니다.

안녕하세요! 락 관련해서 잘 모르는 부분이 있어서 질문 드립니다!

상황을 먼저 설명드리자면, parent 테이블과 child 테이블은 부모-자식 관계로, child 테이블에서 parent의 pk를 외래키로 들고 있습니다.
```sql
(1) START TRANSACTION;
(2) INSERT INTO child (id, vote_id) VALUES (1, 1);
(3) UPDATE parent SET name = '박길동' WHERE id = 1;
```
이때 위의 명령어들을 하나씩 실행시켰습니다. 트랜잭션을 시작하고 자식 테이블에 insert를 하면 외래키에 의해 락이 전파되어 부모 테이블의 id=1 레코드에도 s락이 걸립니다.
이후 부모 테이블의 id=1인 레코드를 업데이트 할 때 x락이 걸립니다. 그래서 락 테이블을 확인하면 부모 테이블의 id=1 레코드에는 s락과 x락 모두 걸린 것을 확인할 수 있었습니다.

질문1. s락과 x락은 서로 함께 걸릴 수 있는 건가요?
질문2. 커밋 시점에 명령어가 순차적으로 진행되면서 락이 하나씩 풀리는건가요?

 
아래는 제가 받은 답변입니다.

질문-1) S-lock과 X-lock은 서로 호환되지 않지만, 지금 보여주신 예제는 한 트랜잭션내에서 S-lock을 가진 상태에서 X-lock을 취득하는 것이므로 아무런 제한없이 획득 가능해집니다. 그런데 문제는 2개 트랜잭션에서 동시에 1번 을 실행(parent.id가 같은 레코드 insert)하고 2번 문장을 실행하려고 하면 deadlock이 걸리게 됩니다.

질문-2) INSERT와 UPDATE는 각 statement가 server로 전달되는 시점에 실행되지, commit 시점에 insert & update가 실행되는 것은 아닙니다. commit이 실행되면, 해당 트랜잭션의 변경을 영구적으로 disk에 동기화하고, 가진 잠금을 모두 일시에 Release하게 됩니다.

 
정리하면, 공유 락과 베타 락은 일반적으로 서로 호환되지 않지만, 같은 트랜잭션 내에서는 공유 락을 가진 상태에서 베타 락을 얻는 것이 가능하다고 합니다.
 
또한 명령어은 커밋 시점에서 실행되는 것이 아니라, 서버로 전달되는 즉시 실행되고, 커밋 명령은 트랜잭션의 변경 사항을 디스크에 영구적으로 동기화하고, 트랜잭션이 가진 모든 락을 일시에 해제하는 작업을 수행한다고 합니다.
 
(성욱님 답변 감사합니다!!🙇🏻‍♀️🙇🏻‍♀️🙇🏻‍♀️)
 

지금까지 내용 총 정리

  1. 한 트랜잭션에서 자식 테이블에 데이터를 삽입할 때, 해당 데이터가 참조하는 부모 테이블의 레코드에는 공유 락이 자동으로 걸립니다.
  2. 다른 트랜잭션도 같은 방식으로, 같은 부모 테이블의 레코드를 참조하여 자식 테이블에 데이터를 삽입하게 되면, 해당 레코드에 또 다른 공유 락이 걸립니다.
  3. 이후 각 트랜잭션이 해당 부모 테이블의 레코드를 업데이트하려고 할 때, 이미 걸려 있는 공유 락 때문에 배타 락을 획득할 수 없게 됩니다.
  4. 한 트랜잭션 내에서는 공유 락을 가진 상태에서 배타 락으로의 전환은 가능하지만, 저의 상황은 다른 트랜잭션에 의해 설정된 공유 락 때문에 이러한 변환이 불가능합니다.
  5. 트랜잭션1이 트랜잭션2에 의해 걸린 공유 락 때문에 배타 락을 획득할 수 없고, 반대로 트랜잭션2도 트랜잭션1에 의해 설정된 공유 락 때문에 배타 락을 얻을 수 없습니다. 결과적으로, 두 트랜잭션이 서로의 락 해제를 기다리며 진행할 수 없는 상황, 즉 데드락에 빠지게 됩니다.

 

해결 방법

1. 부모 update 후 자식 insert

데드락은 자식 테이블에 데이터를 삽입하는 과정에서 부모 테이블의 레코드에 공유 락이 자동으로 걸리고, 이후 부모 테이블을 업데이트하려 할 때 발생합니다. 이 문제를 해결하기 위해, 부모 테이블을 먼저 업데이트한 후 자식 테이블에 데이터를 삽입하는 순서로 작업을 진행하면 됩니다.

코드에서 순서를 변경하여도 실제 데이터베이스에서 실행되는 쿼리 순서는 바뀌지 않습니다. 이는 하이버네이트가 내부적으로 쿼리의 실행 순서를 관리하기 때문입니다.

flush() 함수를 사용하면 쓰기 지연 저장소에 있는 쿼리를 DB에 전송하여, 부모 테이블 업데이트 후 자식 테이블 삽입이라는 원하는 순서대로 쿼리를 실행할 수 있습니다.

@Transactional
public void participate(
	final Vote vote,
	final Long memberId,
	final Long itemId
) {
	if (vote.reachMaximumParticipants()) {
		vote.close(LocalDateTime.now()); // 부모 update
	}
    
	voterRepository.flush(); // flush() 사용

	final Voter voter = new Voter(vote, memberId, itemId); // 자식 insert

	voter.participate(itemId);
	voterRepository.flush();
}

 
하지만 flush() 사용 시 주의해야 할 점이 있습니다.

  1. 코드를 처음 보는 사람이 flush()의 사용 목적을 이해하기 어려울 수 있습니다.
  2. 객체지향 프로그래밍은 객체 간의 메시지 교환을 통해 시스템이 상호작용 하는 것입니다. 이러한 관점에서, flush()의 명시적 호출은 DB와의 직접적인 상호작용을 강제함으로써, 객체지향 설계의 순수성을 해칠 수 있습니다.
  3. JPA와의 결합도가 증가합니다.

이는 코드의 가독성과 유지 보수성을 저하시키는 요인이 됩니다. 따라서 flush()는 신중하게 사용해야 하며, 가능하면 다른 방법을 모색하는 것이 좋습니다. 
 

2. 외래키 제거

데드락 문제를 근본적으로 해결하기 위해 외래키 제거 방식을 선택했습니다.
 
구체적으로, 이전에 양방향 매핑으로 연결되어 있던 엔티티 간의 관계를 해제하였습니다. Voter 엔티티와 votes 테이블 사이의 연관 관계를 없애고, Voter 엔티티에서는 votes 테이블의 id를 단순한 컬럼으로만 가지고 있도록 변경하였습니다. 이로써, 엔티티 간의 직접적인 연결을 제거함으로써 데드락의 원인이 되었던 복잡한 관계를 단순화시켜 문제를 해결할 수 있었습니다.

VoteManager.participate()

@Transactional
public void participate(
	final Vote vote,
	final Long memberId,
	final Long itemId
) {
	final Voter voter = new Voter(vote, memberId, itemId);
    voterRepository.save(voter); // 추가
    
	final int participants = voterReader.count(vote.getId());
	if (vote.reachMaximumParticipants(participants)) {
		vote.close(LocalDateTime.now());
	}
}

Vote 엔티티

@Entity
@Table(name = "votes")
public class Vote {
    // @OneToMany(mappedBy = "vote", cascade = CascadeType.ALL) 삭제
    // private final List<Voter> voters = new ArrayList<>(); 삭제
    
    // 다른 코드 동일
}

Voter 엔티티

@Entity
@Table(name = "voters")
public class Voter {
    @Column(name = "vote_id", nullable = false) // 변경
    private Long voteId; // 변경
    
    // 다른 코드 동일
}

 

마치며

이번 트러블슈팅을 통해, 데드락 상황을 직접 재연하고 그 원인을 차근차근 파악해나가는 과정을 경험했습니다. 오랜만에 해본 깊은 몰입이라 너무나 즐거웠던 트러블슈팅이었습니다. 😆
 
또한, 자신이 작성한 코드와 사용한 기술들이 어떤 원리로 작동하는지 정확히 알고 사용하는 것이 중요하다는 것을 다시 한 번 깨달았습니다. 끝이 없는 개발 공부...😂
 
다음 시간에는 아직 해결하지 못한 동시성 문제에 대한 트러블슈팅을 진행해보겠습니다.