Spring, JPA, MySQL 동시성 문제 해결하기 - 외래키가 데드락을 일으킬 수 있는 이유

@melonturtle · March 13, 2024 · 25 min read

아래 환경 기반 내용입니다.

  • MySQL 8.0.33

    • Storage Engine: InnoDB (default)
    • Transaction Isolation Level: REPEATABLE READ (default)
  • Spring Data JPA 3.1

    • Hibernate 6.2

프로젝트를 개발하다가 갑자기 데드락을 만나게 되면서, 프로젝트의 데드락이나 race condition이 일어날만한 상황을 정리해보고, 각자 상황에 대한 방법을 고안해봤다. 이 글에선 첫 번째 상황을 살펴보자.

데드락 발생 상황

우선 데드락을 처음 발견한 건 스케쥴러였다. 개발 db와 연결된 개발 서버에 1분마다 스케쥴러가 실행되고 있었는데, 로컬에서 개발 db에 연결하고 테스트 하던 중 데드락이 발생했다.

이 때 관련된 연관관계만 나타내면 아래와 같다.

관련된 연관관계

index 1

현재 프로젝트는 게시글처럼 토픽을 작성할 수 있고, 토픽마다 마감시간이 있다. 1분마다 실행되는 스케쥴러가 토픽의 마감시간을 확인하고, 마감시간이 되면 Topicstatusclosed로 바꾸고, 이 closed event와 관련된 Notification을 생성한다. Notification은 토픽이 close되면서 생긴 알림이므로 어떤 토픽인지 알기 위해 Topic 엔티티를 외래키로 참조하고 있다.

스프링 실행 로그를 봐보면, UPDATE topic SET status = "closed" ... 을 시도하다가 데드락이 발생하는 걸 볼 수 있었다.

사실 이 경우는 로컬에서 개발 db에 연결하고 테스트하다가 발생한 경우로, 실제 운영에선 db 하나당 서버가 하나만 실행되기 때문에 실제로 일어날 일은 없다. 그래서 아 그냥 로컬에서 서버를 하나 더 띄워서 데드락이 발생했구나.. 라고 생각하고 넘어가려다가, 생각해보니 여기서 데드락이 왜 발생하는지 이유를 파악할 수가 없었다. 그래서 그냥 넘어가기엔 정상적인 상황에서도 데드락이 발생할 수 있을 것 같아 관련 책과 MySQL 레퍼런스를 쭉 살펴봤다.

MySQL 8.0 InnoDB Locking and Transaction Model

그 중에서도 특히 이 레퍼런스가 관련이 크다. MySQL을 사용하면서 스토리지 엔진을 바꾸지 않았으므로, InnoDB의 Locking과 Transaction Model에 관한 내용이다.

레퍼런스를 다 읽어보고, 지금 문제 관련하여 중요한 것만 요약해보겠다.

두가지 종류의 Lock

우선 InnoDB의 Locking에 대해 알아야 한다. 기본적으로 row-level locking 시에 2가지 종류의 lock이 있다.

  • shared(S) lock
  • exclusive(X) lock

각각 innodb 로그를 살펴볼 때 S, X로 표시되기 때문에 S lock, X lock이라고 알아두면 된다.

S락은 말 그대로 공유가 되는 락으로, 트랜잭션이 row에 대한 S락을 얻으려고 할 때, 다른 트랜잭션이 그 row에 대한 S락을 들고 있더라도 락을 얻는 것이 가능하다.

반면 X락은 독점적 락으로, 어떤 row에 대해 X락을 획득한 트랜잭션을 제외하곤 다른 트랜잭션은 S락이든, X락이든 얻을 수가 없고 기다려야 한다.

X락은 일반적으로 DML, 즉 INSERT, UPDATE, DELETE 등의 SQL 문을 실행하기 위해 얻어야 하는 락이다.

데드락 로그

위 데드락이 발생한 상황에 대한 로그를 살펴보자. 가장 최근 발생한 데드락은 SHOW ENGINE INNODB STATUS 문을 통해 나오는 로그에 포함되어 있다. 로그에서 중요한 부분만 살펴보자.

------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 172571, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 78865, OS thread handle 23104474183424, query id 7744376 root updating
update topic set ...status='CLOSED'... where id=80

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 188 page no 4 n bits 120 index PRIMARY of table `topic` trx id 172571 lock mode S locks rec but not gap

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 188 page no 4 n bits 120 index PRIMARY of table `topic` trx id 172571 lock_mode X locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 172572, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1128, 3 row lock(s), undo log entries 1
MySQL thread id 78896, OS thread handle 23104389515008, query id 7744377 root updating
update topic set ...status='CLOSED'... where id=80

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 188 page no 4 n bits 120 index PRIMARY of table `topic` trx id 172572 lock mode S locks rec but not gap

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 188 page no 4 n bits 120 index PRIMARY of table `topic` trx id 172572 lock_mode X locks rec but not gap waiting

*** WE ROLL BACK TRANSACTION (2)

데드락 상태였던 두 트랜잭션이 어떤 락을 갖고 있었고 어떤 락을 기다렸는지 나와있다. 두 트랜잭션 모두 테이블의 한 row의 primary 인덱스에 lock mode S인 RECORD LOCK을 갖고 있었고, update topic set stats='CLOSED'... 로 업데이트를 하기 위해 이 record에 대해 X락을 기다리다가 데드락을 감지하여 InnoDB가 2번째 트랜잭션을 롤백했다는 걸 알 수 있다.

InnoDB는 index record에 S나 X락을 걸어서 row-level locking을 진행하므로, 여기서 인덱스에 락을 걸었다는 건 즉 테이블 topic의 한 row에 대학 락이라고 이해하면 된다. 실행 중이던 sql 문을 보면 두 트랜잭션 모두 동일한 id의 topic에 대해 update를 시도했고, 두 트랜잭션 모두 같은 row에 대한 S락을 들고 있었고 X락을 원하던 상황임을 알 수 있다.

왜 S락을 들고 있을까

앞에서 본 것처럼, S락은 다른 트랜잭션이 들고 있어도 점유할 수 있지만 X락은 다른 트랜잭션이 S락이든 X락이든 들고 있다면 점유할 수 없다. 따라서 두 트랜잭션 모두 서로가 들고 있는 S락 해제를 기다려야 하는 상황이 오면서 데드락 상태에 빠진 걸 볼 수 있다.

업데이트를 하려면 X락을 얻어야 한다는 걸 알았다. 그럼 X락을 얻으려 하는 게 문제는 아니고, S락을 얻은 상태에서 X락을 얻으려 하는게 문제이다. 그럼 X락을 얻으려면 S락을 먼저 얻어야 하는 걸까?

그건 아니다. Transaction Isolation Level이 SEREALIZABLE이 아니라면 일반 SELECT는 락이 필요하지 않다. 명시적으로 locking read (SELECT ... FOR SHARE, SELECT ... FOR UPDATE, JPA에선 비관적 락)를 해야 필요하다.

  • READ UNCOMMITTED: 어차피 락이 필요 없음
  • READ COMMITTED: read 시 시점 기준 자신의 스냅샷 읽으므로 락 필요 없음
  • REPEATABLE READ (default): 처음 read 시 고정된 자신의 스냅샷 읽으므로 락 필요 없음

그럼 왜 이 상황에선 S락을 걸고 있던 걸까?

외래키로 참조하는 행에 대한 S락

바로 외래키 때문이다. foreign key constraint가 테이블에 정의되어 있다면, insert, update, delete의 경우 constraint를 확인하는데, 이 때 확인한 record 모두에 S락을 건다. constraint가 실패했을 때도 마찬가지다. 이를 통해 현재 트랜잭션이 커밋되거나 롤백될때까지 다른 트랜잭션이 확인한 row를 수정하거나 삭제하는 일이 없다는 걸 보장할 수 있고, 이를 통해 참조 무결성을 강화할 수 있다.

이는 MySQL에서 InnoDB와 NDB 테이블은 외래키 제약사항 체크를 지원하고, foreign_key_checks가 디폴트로 enable되어 있기 때문에 발생한 일이다. 이를 수정하지 않은 내 프로젝트에서도 역시 적용되는 일이다.

위 두 레퍼런스를 통해 lock 종류와 언제 lock이 걸리는지 알 수 있다. Gap lock이나 여러가지 경우가 있는데, 이런 락을 거는 이유를 생각해보면 각 lock의 종류와 의도가 쉽게 이해가 간다.

Gap lock은 기본적으로 일관된 읽기를 제공하기 위해 존재하는 것이다. gap lock을 통해 phantom row를 방지할 수 있다.

Locking read, INSERT, UPDATE, DELETE 시엔 스냅샷이 아닌 실제 테이블을 스캔하게 되는데, lock이 없다면 일관된 결과를 얻을 수 없을 것이다. 따라서 이런 SQL문을 실행시에 스캔한 모든 곳에 S락을 걸게 된다. Gap Lock, next-key lock, 본문에 적은 foreign-key constraint, 혹은 duplicate-key의 경우에도 마찬가지다. 만약 락을 걸지 않으면 다른 트랜잭션이 이를 수정하여 일관되지 않은 결과를 얻을 수 있다.

이 때 기본적으로 gap lock처럼 gap, 즉 범위로 적용되는 이유는 인덱스의 btree 저장구조 특성상 그런게 아닌가 생각하고 있다. 기본적으로 WHERE절에서 스캔하는 모든 곳에 락을 건다고 이해하면 되는데, btree를 타고 내려가면서 스캔한 곳은 범위 기반일 것이기 때문이다.

어쨌든 스캔한 모든 곳에 락을 건다고 이해하면 쉽다. 그러므로 참조 무결성을 위해 외래키로 참조된 row를 확인했으면 락을 거는 것이다.

처음 봤던 다이어그램처럼, Notification 엔티티가 Topic을 참조하고 있기 때문에, Notification 엔티티를 save하고 notification 테이블에 row를 INSERT할 때 row에 topic_id = 80 이 포함되어 있었을 것이다. 그 과정에서 외래키 체크를 위해 id = 80인 topic 테이블의 row를 확인하고 S락을 걸었을 것이다.

그럼 외래키 체크를 안하면?

실제로 외래키 체크를 다음 SQL 문으로 끌 수가 있다.

SET foreign_key_checks = 0;

끄고 확인해보면 외래키로 참조한 row에 대해 S락을 얻지 않는다. 하지만 외래키 체크를 끄면 외래키를 사용하는 이유가 없을 것이다. 다른 방법으로 해결해보자.

예전에 실무에선 외래키를 잘 사용하지 않는다는 말을 들은 적이 있다. 이유는 외래키가 참조하고 있으면 delete나 drop하기도 어렵고 여러 이유가 있다.. 라고만 들었는데 이런 경우 데드락에도 영향을 미칠 수 있다는 걸 알게 되었다. 이런 것도 외래키를 안쓰게 되는 이유 중 하나 아닐까?

찾아보던 중 같은 생각의 stackoverflow 답변을 확인했다.

MySQL InnoDB: locking the destination of foreign keys

This is a behavior of MySQL foreign keys that frankly convinces many projects to avoid using foreign key constraints, even though their database logically has foreign key references.

즉 MySQL에서 논리적으로 외래키 관계에 있더라도 여러 이유때문에 실제로 외래키 제약사항을 안쓰게 되는 경우가 있는데, 이런 동작이 바로 그 이유 중 하나라는 것 같다.

주의하고 사용하자.

해결 방법

코드에선 topic.close()Topic 엔티티 인스턴스의 status를 먼저 CLOSED로 업데이트하고, 후에 Notification 엔티티 인스턴스를 만든다.

하지만 이는 어플리케이션 코드일 뿐이고, 실제 JPA가 변환한 코드는 이 순서가 아니다. 현재 Notification 엔티티는 auto-increment되는 기본키 id를 사용하고 있으므로, 인스턴스를 save할 때 기본키 값을 받아오기 위해 먼저 insert가 일어났을 것이다. 그리고 Topicstatus는 후에 더티체킹시 CLOSED로 값이 바뀐 걸 확인하고 그 때 UPDATE 문으로 업데이트 했을 것이다. 실제 실행되는 순서를 봐도 이렇다.

그럼 어떻게 해결할 수 있을까? 세 가지 방법을 생각해봤는데, 하나씩 살펴보자.

1. 처음부터 X락을 얻고 notification에 insert 후 topic 업데이트하기

  • SELECT ... FOR UPDATE로 조회하면 읽을 때부터 X락을 얻으므로 지금 상황처럼 두 트랜잭션이 S락을 얻고 서로의 락을 원하는 상황은 일어나지 않는다. 한 트랜잭션이 topic 조회, notification 업데이트, topic 업데이트할 동안 다른 트랜잭션은 기다렸다가 트랜잭션이 커밋되면 X락을 얻을 수 있다.
  • JPA에선 비관적 락을 이용해서 SELECT 시 X락을 얻게 할 수 있다.

2. topic을 먼저 업데이트하고 notification에 insert

  • 1번과 비슷해보이지만, 명시적으로 비관적 락을 이용하라고 할 필요 없이, 업데이트가 먼저 일어나면서 트랜잭션이 topic에 대한 X락을 얻고 notification에 insert할 때까지 활용한다.
  • EntityManager::flush를 이용해서 topic이 업데이트된 내용을 먼저 db에 반영하고 Notification 인스턴스를 save하면 된다.

3. notification을 다른 트랜잭션에서 만들기

  • 현재 구조는 아래와 같다.

    index 3

    • VotingTopicService에서 topic을 close해야하는지 확인하고, close해야하면 topic.close()status를 바꾼다.
    • EventPublisher를 이용해 topic이 close됐다는 걸 event로 알린다.
    • EventListenr로 등록된 쪽에서 NotificationService의 메서드를 호출한다.
    • 그 메서드 안에서 Notification 인스턴스를 저장한다.
    • 이 때 VotingTopicServiceNotificationService의 메서드 모두 @Transactional로 어노테이션되어 있다. @Transactionalpropagation의 기본값은 Propagation.REQUIRED로 세팅되어 있다.

    Transaction Propagation

    • PROPAGATION_REQUIRED: 각 메서드별로 논리적인 트랜잭션 스코프가 생기게 된다. 그리고 이 스코프들은 모두 실제로는 같은 physical 트랜잭션으로 매핑된다. 그러므로 내부 트랜잭션에서 롤백된게 외부 트랜잭션까지 영향을 미칠 수 있다.
    • PROPAGATION_REQUIRES_NEW: 항상 독립적인 physical 트랜잭션을 사용한다. 그러므로 독립적으로 커밋/롤백 될 수 있다.

      In such an arrangement, the underlying resource transactions are different and, hence, can commit or roll back independently, with an outer transaction not affected by an inner transaction’s rollback status and with an inner transaction’s locks released immediately after its completion.

      • 독립적인 트랜잭션 이므로, 내부 트랜잭션에서 사용하던 lock은 커밋되거나 롤백될 때 다 release될 것이다.

      📌 이 때 외부 트랜잭션에서 사용되는 리소스들은 내부 트랜잭션이 새로운 데이터베이스 커넥션으로 실행되고 끝날때까지 계속 남아있게 된다. 이는 커넥션 풀에 영향을 줄 수 있고, 만약 내부 트랜잭션이 새 커넥션을 할당받아야 한다면 외부 트랜잭션은 리소스를 잡고 계속 기다리므로 내부 트랜잭션이 계속 진행되지 못하는 상태가 될 수 있으므로 주의해야 한다.

    • 따라서 Propagation.REQUIRES_NEW로 설정한다면 새로운 트랜잭션에서 notification을 insert할 수 있을 것이다.

트랜잭션을 분리하자

개인적으로는 이 경우엔 세번째 방법이 가장 괜찮은 것 같다.

MySQL 레퍼런스에서 데드락을 최소화하는 방법으로 insert나 update하는 트랜잭션을 작게 유지해서 오랫동안 열려있지 않게 하라는 언급이 있었다. 세 번째 방법을 통해 이 방법처럼 각 트랜잭션이 insert과 update를 각각 하나씩만 하게 되어 트랜잭션을 더 작게 할 수 있다.

How to Minimize and Handle Deadlocks

  • Keep transactions small and short in duration to make them less prone to collision.
  • Commit transactions immediately after making a set of related changes to make them less prone to collision. In particular, do not leave an interactive mysql session open for a long time with an uncommitted transaction.

사실 근데 지금 문제 상황만 생각한다면 세번째 방법조차도 적용하지 않아도 된다. 스케쥴러이므로 운영중엔 이 메서드가 동시에 실행될 일은 없기 때문이다.

그래도 해결하려는 이유는, topic 테이블을 이 메서드만 참조하는게 아니기 때문이다. topic을 외래키로 참조하는 row를 insert하고 다시 topic의 컬럼을 update해야하는 모든 곳은 topic에 대해 S락을 얻고 다시 X락을 얻으려고 하므로 모두 데드락 후보가 된다.

그리고 현재 프로젝트에서 Notification이 생성되는 경우는 스케쥴러 말고도 다양하므로 수정이 필요하다.


세 번째 방법을 적용하려면, Notification 엔티티 인스턴스를 save하는 메서드에 있던 @Transactional 어노테이션의 propagationPropagation.REQUIRES_NEW로만 수정하면 된다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void notifyVoteResult(Topic topic) {
	// ...
	notificationRepository.save(notification);
}

이런 식으로 할 수 있을 것이다. 실제로 Propagation.REQUIRED일 때와 Propagation.REQUIRES_NEW 방법을 중단점을 찍으면서 확인해봤다.

아래 sql문을 통해 현재 lock들을 확인할 수 있다.

SELECT ENGINE_TRANSACTION_ID as Trx_Id,
       OBJECT_NAME as `Table`,
       INDEX_NAME as `Index`,
       LOCK_DATA as Data,
       LOCK_MODE as Mode,
       LOCK_STATUS as Status,
       LOCK_TYPE as Type
FROM performance_schema.data_locks;

기존 방법일때는 EventPublisher의 처리가 끝난 후에도 같은 physical 트랜잭션이기 때문에 락이 남아있는걸 볼 수 있었다. index 2

세 번째 방법을 적용하고 나니 EventPulblisher의 처리가 끝나면 락이 release된 걸 확인할 수 있었다.

테스트 주의점

  • 이 코드와 관련된 기존테스트가 있거나, 새로 테스트를 작성한다면 한 가지 주의할 점이 있다.
  • 기존 테스트 코드를 @Transactional을 이용해 작성하고 테스트 후 자동으로 롤백되는 점을 이용했다면, REQUIRES_NEW를 적용했을 때는 테스트 트랜잭션과 분리되기 때문에 롤백이 안된다.
  • 따라서 수동으로 @AfterEach에서 지워주도록 하자.

Spring: Test-managed Transactions

Spring-managed and application-managed transactions typically participate in test-managed transactions. However, you should use caution if Spring-managed or application-managed transactions are configured with any propagation type other than REQUIRED or SUPPORTS (see the discussion on transaction propagationfor details).

스프링이 관리하거나 어플리케이션이 관리하는 트랜잭션은 일반적으로 테스트에서 관리하는 트랜잭션에 포함된다. 하지만 만약 REQUIREDSUPPORT가 아닌 propagation type으로 설정되어 있다면 주의하자. 현재 REQUIRES_NEW를 사용하므로 이 경우에 적용된다. 테스트에서 관리하는 트랜잭션에 포함되지 않으므로 자동으로 롤백을 해줄 수 없다.

결론

현재 상황에서 세 번째 방법으로 해결할 수 있었던 이유는 notification 인스턴스를 save해주는 곳과 topic 인스턴스를 update 해주는 곳이 분리될 수 있고, API로 실행되는 게 아니고 스케쥴러에 의해 일정하게 실행되므로 커넥션 리소스에 큰 영향을 미치지 않기 때문이다. 항상 이렇게 간단하게 해결되진 않을 것이다. 다음 글에선 또 다른 경우를 살펴보자.

@melonturtle
공부하면서 남긴 기록입니다. 문제가 있다면 언제든지 알려주세요!