문제상황

  1. Mysql 8.0를 사용하고 있었고, B table에 A 테이블에 대한 FK 걸려있었다. 즉 A가 부모, B가 자식 관계이다.
  2. @OneToMany 를 사용하는 A Entity에서 B Entity에 CascadeType.ALL 설정으로 A Entity를 Aggregate Root로 사용하고 있다.
  3. 동시에 같은 A Entity를 update 시도하한다. 두 요청 모두 A Entity의 자식은 B Entity를 변경하는 작업이다.
  4. 이 때 DeadLockException 발생한다.
org.springframework.dao.CannotAcquireLockException: could not execute statement; SQL [n/a]; nested exception is org.hibernate.exception.LockAcquisitionException: could not execute statement
...
Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
    at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:123)
    at com.mysql.cj.jdbc.exceptions.SQLExceptionsMapping.translateException(SQLExceptionsMapping.java:122)
    at com.mysql.cj.jdbc.ClientPreparedStatement.executeInternal(ClientPreparedStatement.java:953)

 

 

  • 예시 코드
class A(
    var name: String,

    @OneToMany(mappedBy = "a", cascade = [CascadeType.ALL], orphanRemoval = true)
    val bList = MutableList<B>,
) {
    fun update(name: String, bList: List<B>) {
        this.name = name
        this.bList.clear()
        this.bList.addAll(bList)
    }
}
class B(
    val name: String,

    @ManyToOne
    @JoinColumn(name = "a_id")
    val a: A,
)
@Service
class AService {
    @Transactional
    fun updateById(id: Int, name: String, bNameList: List<String>): A {
        val a = aRepository.findById(id)!!
	
        a.update(
            name = name,
            bList = bNameList.map { B(it, a) }
        )
				
        return aRepository.save(a)
    }
}

 

  • 테스트코드
@SpringBootTest
class ATest(
    private val aService: A,
    private val aRepository: ARepository,
) : BehaviorSpec({
    Given("A entity가 주어지고") {
        val A = aRepository.save(A("test", mutableListOf()))
	
        When("동시에 A entity를 수정 시도하면") {
            val try1 = CoroutineScope(Dispatchers.IO).async {
                aService.updateById(a.id, "update1", listOf("b1", "b2"))
            }
            val try2 = CoroutineScope(Dispatchers.IO).async {
                aService.updateById(a.id, "update2", listOf("b3", "b4"))
            }
						
            listOf(try1, try2).awaitAll()
						
            Then("동시성 이슈 발생함") {
                ...
            }
        }
    }
})

 

원인 분석

결론부터 말하자면, FK 때문에 발생하는 DeadLock이다.

MySQL에서는 FK가 걸린 데이터를 조회하면 FK에 해당하는 테이블의 row에 S-Lock을 건다. FK 데이터의 일관성 유지를 위함인데, 이것이 문제 발생의 원인이 된다. [공식 문서]

 

일반 조회 상황이나, 자식 테이블의 데이터만 수정하는 경우에는 문제가 없는데, 부모 데이터까지 같이 수정하는 경우에 문제가 발생한다. 어떻게 Deadlock이 발생할 수 있는지 아래 흐름으로 자세히 보자.

 

  1. 1번 Thread에서 aRepository.save(a) 코드 실행할 때, B entity를 insert 먼저 시도
    • MySQL에서는 자식 table에 insert 시 FK에 해당하는 부모 row에 S Lock 을 검
  2. 2번 Thread에서도 aRepository.save(a) 코드 실행할 때, B entity를 insert 시도함.
    • 동일하게 S Lock 을 걸기 때문에 문제 X
  3. 1번 Thread에서 A Entity에 update 시도 함
    • 본인이 건 S Lock을 X Lock 으로 바꾸려고 시도하지만, 2번 Thread가 건 S Lock 으로 인해 대기
  4. 2번 Thread에서 A Entity에 update 시도 함
    • 본인이 건 S Lock을 X Lock 으로 바꾸려고 시도하지만, 1번 Thread가 건 S Lock 으로 인해 대기
  5. DeadLock 발생

 

결국에는 FK가 있는 데이터만 수정하는 것이 아닌, 부모 데이터까지 수정하는 것이 문제인 것이다.

그러나 JPA에서는 ID 생성 전략을 IDENTITY로 설정하여 DB에 위임할 경우, save 호출 시 insert는 불가피하다. 따라서 부모 entity를 aggregate root로 설정할 경우, 자식 entity가 먼저 save 되고 부모 entity는 transactional이 종료될 때 저장 된다.

 

즉, FK가 걸려있는 entity에서 OneToMany 설정에 cascade.ALL 설정이 들어가 있으면 반드시 발생할 수 있는 케이스이기 때문에 주의가 필요하다.

해결 방법

1. A Entity 조회 단계에서 X Lock 을 걸고 조회하기

  • JPA의 @Lock 활용
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM A e WHERE e.id = :id")
fun findByIdWithLock(id: Int): A?

 

실제로 나간 조회 쿼리를 보면, FOR UPDATE 를 사용하여 조회 시 X LOCK 을 명시적으로 획득하는 것을 확인할 수 있다.

SELECT
    ...
FROM
    A
WHERE
    a.id = ? for update

 

OneToMany 사용시 이렇게 컨벤션을 가져가자고 하는 것도 어색하고, 개발자가 빠뜨리고 실수할 확률이 높아 보인다. 또한 X Lock을 잡아놓는 것이기 때문에 잘못 사용하면 성능에 큰 영향을 미칠 수 있다.

따라서 이 방법은 추천하지 않는다.

2. FK 제거하기

지금 상황을 회사 DBA 분께 설명하고, 어떻게 하는 것이 좋은지 조언을 구하였다.

질문
위 상황에서 FK로 인해 DeadLock이 발생하고 있습니다. 여러 블로그 글에서 FK를 제거하라는 솔루션을 많이 제안하는데, RDB에서 FK를 제거하는 것이 맞는 판단인지 모르겠습니다. RDB에서 FK라는 연관관계가 없다면 RDB의 의미가 많이 퇴색되어 RDB의 철학과 멀어지는 것 아닌가 하는 생각도 드는데 DBA분께서는 어떻게 생각하시나요? 다른 곳에서는 어떻게 FK를 다루나요?

 

DBA 답변
실제 많은 서비스에서는 데이터 무결성이 매우 중요해서 성능을 포기해도 되는 상황이 아니면 대부분 외래키를 사용하지 않습니다. 데이터 모델링 단계에서도 실제 물리 모델링시에는 관계를 표현만하고 외래키를 제거하는 것이 거의 표준처럼 이뤄집니다... 제가 있던 환경에서는 스키마 변경가능성, 락 발생, 성능과 DML 작업시 편의성을 위해 외래키를 없애는 것이 표준이었고 추가적으로도 락 범위를 최소화시키기 위해 MSA 구조의 전체 DB가 read-commited 격리레벨을 사용합니다. 락이 그만큼 무서운 것 같네용 RDBMS를 사용하면서 FK를 사용하지 않는 것이 조금 어색해보이긴 하지만... 물리레벨의 관계는 아니더라도 application 단에서 논리적 관계를 맺는 것도 데이터 무결성은 소스코드단에서 더 신경써야겠지만 넓은 의미에서는 여전히 논리적인 관계는 존재하기 때문에 RDBMS 철학과 크게 벗어나지는 않는다고 생각합니다.

 

이 질문으로 생각보다 많은 것을 깨달았고 사고가 조금 바뀌는 계기가 되었다. 결국 트레이드 오프이고, 성능이 일반적인 서비스에서 제일 중요한 가치가 될 것이기 때문에, RDB를 쓰기 때문에 FK는 포기못해! 이런 생각은 위험한 생각일 수 있겠다고 느꼈다.

또한 물리적인 관계 뿐만 아니라 코드로 생기는 논리적인 관계도 RDB 철학과 벗어나지 않는다는 말씀에, 철학과 개념에 나 스스로 가두는 느낌을 받았다. 좀 더 실용적이고 유연한 사고를 가지도록 생각을 바꿔야겠다.

 

FK 얘기가 나온 김에 좀 더 찾아보았는데, 이런 비슷한 고민을 한 사람들과 회사들이 많았다. 재밌었던 것은, Github에서는 FK를 어디에서도 사용하지 않는다고 한다. 정확하는 MySQL용 스키마 이관 도구인 gh-ost 의 얘기라 github 전체를 대변하지는 않는다. [관련 글] 

 

비고

  • 실제로 해보니 thread1은 성공하고 thread2는 실패하는데?
    맞다. 하나의 쓰레드는 성공하고 다른 하나는 deadlock 에러를 뱉으며 실패할 것이다.
    mysql에서 deadlock 감지를 통해 문제되는 transaction 하나를 kill하기 때문에, 하나는 deadlock에서 풀려나서 성공하고, 나머지 하나는 kill되어 exception이 발생하는 것이다.

 

  • 자매품으로 서브쿼리가 있다.
    JPA가 아닌, MyBatis를 사용하는 다른 프로젝트에서도 동일하게 발생 했었다. INSERT 쿼리에 서브 쿼리가 들어가 있었고, 서브쿼리는 조회한 데이터에 S LOCK을 잡는다. 
    그리고 transaction가 끝나지 않고 S LOCK을 잡은 데이터에 update를 시도하며 발생하였다.

 

  • 해당 이슈 원인 분석에 도움이 많이 된 글이 있다. 한 번씩 읽어보면 도움이 될 것이다. [블로그 글]

'JPA' 카테고리의 다른 글

JPA EntityEventListener의 위험성  (0) 2024.08.16
@UpdateTimestamp의 비밀  (1) 2023.04.30

+ Recent posts