JPA, MySQL DeadLock

신민철·2024년 3월 14일
4

Spring

목록 보기
3/5
post-thumbnail

어느 날 갑자기 개발 서버에서 데드락이 발생했다는 알림이 왔다.

그리고 에러 알림에서 2번의 요청이 연속적으로 왔다는 것을 알게 되었다. 그래서 클라이언트 측에 해당 상황을 어떻게 테스트 하셨는지 문의하였다

다음은 디스코드에 왔던 에러 알림이다.

🔖 Caused by: org.hibernate.exception.LockAcquisitionException: could not execute statement [Deadlock found when trying to get lock; try restarting transaction][insert into task (idx,key_result_id,title) values (?,?,?)]
at org.hibernate.dialect.MySQLDialect.lambda$buildSQLExceptionConversionDelegate$3(MySQLDialect.java:1211)
at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:58)
at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:108)
at org.hibernate.engine.jdbc.internal.ResultSetReturnImpl.executeUpdate(ResultSetReturnImpl.java:197)

응..? 데드락..? 데드락이 왜 발생했지?

이런 반응이 먼저 나왔고 다음에는 데드락이 어느 부분에서 발생했는지 원인을 파악하려고 했다.

먼저 데드락이 발생한 API의 Service 코드는 다음과 같다.

public void createTask(final TaskSingleCreateRequestDto request, final Long userId) {
    KeyResult keyResult = keyResultRepository.findKeyResultAndObjective(request.keyResultId())
                .orElseThrow(() -> new NotFoundException(NOT_FOUND_KEY_RESULT));
    validateUserAuthorization(keyResult.getObjective().getUser().getId(), userId);

    List<Task> taskList = taskRepository.findAllByKeyResultOrderByIdx(keyResult);
    validateActiveTaskSizeExceeded(taskList.size());
    validateIndexUnderMaximum(request.idx(), taskList.size());

    taskRepository.bulkUpdateTaskIdxIncrease(request.idx(), taskList.size(), keyResult.getId(), -1L);

    saveTask(keyResult, request);
}
    
public void saveTask(final KeyResult keyResult, final TaskCreateRequestDto request) {
    if (!request.taskTitle().isEmpty()) {
        taskRepository.save(Task.builder()
                      .title(request.taskTitle())
                      .idx(request.taskIdx())
                      .keyResult(keyResult)
                      .build());
    }
}

에러 StackTrace에서 확인할 수 있었던 내용은 save()를 할 때 DeadLock이 감지되었다는 것 뿐이었다.

그래서 DeadLock이 무엇인지부터 정의하기로 하였다.

DeadLock이란?

둘 이상의 프로세스(쓰레드)가 있을 때 두 프로세스(쓰레드)가 서로의 점유하고 있는 자원을 기다릴 때 무한 대기에 빠지는 상황을 의미한다.

환형 대기를 하며 자원을 요청하고 자신의 자원은 해제하지 않는 형국에서 발생한다.

그 이후에는 두 개의 프로세스(쓰레드)가 자원을 가지고 있고 서로의 자원을 요청하고 대기할 때 발생하니, 해당 로직에서 어떤 자원을 점유하고 요청하는지를 파악하려고 하였다.

핵심 다이어그램만 그려보았다.

Task에서 @ManyToOne으로 KeyResult 엔티티를 멤버 변수로 두고 있다.

public class Task {

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

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private Integer idx;

    @JsonIgnore
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "key_result_id")
    private KeyResult keyResult;

    public void incrementIdx() {
        ++this.idx;
    }

    public void modifyIdx(Integer idx) {
        this.idx = idx;
    }

    @Builder
    private Task(String title, Integer idx, KeyResult keyResult) {
        this.title = title;
        this.idx = idx;
        this.keyResult = keyResult;
    }
}

그럼 task 테이블에 대한 락을 시간 순서대로 중요한 부분만 한번 알아보자. (일반적인 Locking 상황)

TX1TX2Lock
BEGIN ;
select
t1_0.task_id,
t1_0.idx,
t1_0.key_result_id,
t1_0.title
from
task t1_0
where
t1_0.key_result_id=?
order by
t1_0.idx
TX1 task 테이블에 대한 S락 획득.
BEGIN ;
select
t1_0.task_id,
t1_0.idx,
t1_0.key_result_id,
t1_0.title
from
task t1_0
where
t1_0.key_result_id=?
order by
t1_0.idx
TX2 task 테이블에 대한 S락 획득.
update
task
set
idx=(idx+1)
where
idx>=? and idx<?
and key_result_id=?
and task_id<>?
TX1 task 테이블에 대한 X락 획득 요청. TX2가 S락을 소유중이라 해당 자원을 해제할 때까지 대기.
update
task
set
idx=(idx+1)
where
idx>=? and idx<?
and key_result_id=?
and task_id<>?
TX2 task 테이블에 대한 X락 획득 요청. TX1가 S락을 소유중이라 해당 자원을 해제할 때까지 대기.
Deadlock found when trying to get lock; try restarting transactionDeadlock found when trying to get lock; try restarting transactionTX2가 X lock이 필요하지만 TX1는 S lock 상태임.
이를 해소하기 위해서 TX1이 커밋 필요함.
TX1이 커밋하기 위해선 반대로 TX2가 커밋 필요. → 데드락 발생.

하지만 서비스에 사용하는 MySQL 스토리지 엔진은 InnoDB이다. MVCC를 통해 Lock 없이도 동시성을 제어한다.

정확한 테스트를 위해 JMeter의 다중 쓰레드 요청으로 테스트하기로 했다.

테스트 환경 및 조건

  • 두 개의 쓰레드로 같은 요청을 동시에 보낸다.
  • 동시 요청을 2개의 케이스로 보낼 것인데, 첫 번째는 update 대상이 없는 상황 하나, update 대상이 있는 상황에 테스트를 진행한다.

해당 조건으로 테스트를 돌려 보았다.

update 대상이 없을 때는 하나의 요청만 성공하고 대상이 있을 때는 두 개의 요청이 모두 성공한다.

추가로 정확히 어느 메소드에서 터지는지 확인하기 위해 print문을 추가해보았다.


update 대상이 없을 때는 해당 row에 대한 update가 되지 않아 바로 insert 문으로 넘어가져 동시에 insert가 되는 상황을 볼 수 있다.

반면 오른쪽에서 update 대상 row가 있을 때는 transaction 1이 update를 수행하고 transaction 2도 update를 수행하기 때문에 insert가 동시에 발생하지 않는다.


정말 해당 부분 때문에 로직이 실패하는지를 확인하기 위해 첫 번째 쓰레드는 로직을 그대로 실행하고 두번째 쓰레드는 1ms의 sleep을 걸고 save()를 수행하도록 해봤다. 하지만 JPA는 쓰기 지연을 사용하기 때문에 update 문이 실행된다면 바로 flush를 하도록 하였다.

그럼 update 대상 row가 없을 때 해당 환경으로 테스트 해보겠다.

정말 save()가 동시에 발생할 때 데드락이 발생했던 것이다.

그런데 왜 동시에 save()가 될 때 데드락이 발생할까?

MySQL 트랜잭션 2개를 실행시켜두고 동시에 insert를 하도록 해봤는데 실패하지 않았다.

.. Spring 내부 구현 상이나 이유가 있을텐데 아직 그 해법을 찾지 못했다. @Transactional 이나 EntityManager 내부 구현상의 이유 때문에 발생하는지를 찾아보려고 했는데 답이 안보인다.

일단 넘어가보고 추후 찾게 된다면 업데이트 하겠다..!


그럼 우리 비즈니스 특성을 한번 고려해보자.

우리 서비스는 Personal O-KR 서비스로서 개인이 가지고 있는 Task에 대해서 다른 사람이 접근할 일이 없다. (추후에 소셜 서비스가 오픈되기 전까지는)

그래서 같은 row의 task를 읽는 동시에 쓰는 것이 사실상 없다.

하지만 이런 상황이 없다고 해서 데드락을 회피하는 방안을 추가하지 않는 것이 옳은 방안일까? 라는 생각이 들었다.

그래서 이를 해결하기 위해서 어떻게 해야 할지에 대한 것을 조사하였다.


Optimistic Lock (낙관적 락)

낙관적 락은 DB의 Lock을 사용하지 않고 Version 관리를 통해 어플리케이션 레벨에서 동시성을 제어하는 방법을 의미한다.

가정
1. 대부분의 트랜잭션이 충돌하지 않는다고 가정하는 방법이다.
2. 트랜잭션의 커밋 전에는 트랜잭션의 충돌을 알 수 없다.

@Version

@Version을 적용할 수 있는 데이터 타입은
1. Long, long
2. Integer, int
3. Short, short
4. Timestamp
라고 한다.

중요한 점은 조회 시점의 Version과 수정 시점의 버전이 다르면 예외가 발생한다는 것이다.

주의 사항

  • 임베디드 타입과 값 타입 컬렉션은 실제 DB에서는 다른 테이블이지만, JPA에서는 논리적인 개념해당 엔티티에 속한 값이므로 수정하면 엔티티의 버전이 증가한다.

  • 버전은 JPA가 직접 관리하므로 개발자가 수정하면 안된다.단 벌크연산시 JPA가 관리하지 않으므로 이 때는 직접 버전을 관리해줘야 한다.


Pessimistic Lock (비관적 락)

InnoDB에서 CUD 시 기본적으로 행 단위의 잠금(Row level Locking)이 발생한다.

처음에 task에 대한 row를 조회할 때 기본 조회가 아닌 select … for update를 통해 조회를 하게 되면 X락을 획득하여 다른 트랜잭션이 해당 row에 대한 S락을 소유할 수 없게 막게 된다.

따라서 그 이후에 task에 대한 쓰기 요청을 할 때 환형 대기 즉, 데드락이 발생하지 않는 조건을 만들 수가 있는 것이다.

그러면 JPA에서는 select … for update를 위한 락을 어떻게 걸까?

JPA에서는 비관적 잠금 모드가 3가지 존재한다.


Pessimistic Lock 종류

  • @Lock(LockModeType.PESSIMISTIC_READ)
    해당 리소스에 공유 락을 걸게 된다. 다른 트랙잭션에서 읽기는 가능하지만 쓰기는 불가능해진다.

  • @Lock(LockModeType.PESSIMISTIC_WRITE)
    해당 리소스에 배타 락을 걸게 됩니다. 다른 트랜잭션에서는 읽기와 쓰기 모두 불가능해집니다.

  • @Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
    PESSIMISTIC_WRITE와 유사하게 작동하지만 추가적으로 낙관적 락처럼 버저닝을 하게 됩니다. 따라서 버전에 대한 칼럼이 필요합니다.

Lock을 거는 과정에서 발생할 수 있는 예외

  • PessimisticLockException : 한 번에 하나의 Lock만 얻을 수 있으며, Lock을 가져오는데 실패하면 발생하는 예외입니다.
  • LockTimeoutException : 락을 기다리다가 설정해놓은 wait time을 지났을 경우 발생하는 예외입니다.
  • PersistanceException : 영속성 문제가 발생했을 때 발생하는 예외입니다.


그런데 비즈니스 로직 상 같은 row에 대해 update 요청이 발생할 일이 없어서 이런 락을 걸어야 할지에 대한 고민이 있다.

락을 걸면 기본적으로 성능상 단점이 존재하기 때문에 trade-off를 고려해서 적용을 해야 한다. 이 부분은 조금 더 고민을 해보고 위에서 만났던 이슈들에 대응하기 위해 적용을 해야 할지를 선택해봐야겠다!



위에 것을 조사하다가 또 흥미로운 사실을 알게 되었다.

task 테이블에서는 key_result_id를 외래키로 두고 있는데, 외래키와 관련한 이슈를 살펴보자.


Real MySQL 3장을 보게 되면,

🔖 외래키는 부모테이블이나 자식 테이블에 데이터가 있는지 체크하는 작업이 필요하므로 잠금이 여러 테이블로 전파되고, 그로 인해 데드락이 발생할 수 있다. 그래서 실무에서는 잘 사용하지 않는다.


외래키를 가지고 있을 때 데드락의 위험이 있다고..?

@ManyToOne을 의식적으로 쓰다가 갑자기 쓰는게 맞는지에 대해 갑자기 깊은 고민에 빠졌다가 이 부분을 더욱 파보기로 하였다.

그런데 우리 비즈니스 로직 상 외래키 테이블인 key_result를 수정하는 사항이 없지만 @ManyToOne을 걸어두고 해당 엔티티에 대해 수정사항이 한 트랜잭션 내에서 발생하게 된다면 데드락이 발생할 수도 있다는 것이다.

child 테이블이 외래키를 보유하고 있는 테이블이고, parent가 외래키에 매핑되는 테이블이라고 가정해보자.

또한 두 트랜잭션이 외래 테이블에 대한 READ 후 UPDATE를 진행한다고 해보자.

TX1TX2Lock
SELECT * FROM parent;TX1 parent 테이블 S락 획득.
SELECT * FROM parent;TX2 parent 테이블 S락 획득.
UPDATE parent ~TX1 parent 테이블 X락 획득 요청. TX2가 parent 테이블에 대해 S락을 소유 중이기 때문에 대기.
UPDATE parent ~TX2 parent 테이블 X락 획득 요청. TX1이 parent 테이블에 대해 S락을 소유 중이기 때문에 대기.

이렇게 데드락이 쉽게 발생할 수 있다.

그럼 외래키로 인해 발생하는 데드락은 어떻게 해결해야 할까? 찾아본 바로는 다음과 같다.


분산 락

레디스를 예로 들면, 분산락을 설정해서 메소드를 동시에 실행하지 못하도록 막는 방법이 있다. 분산 락을 이용하면 여러 스레드가 동시에 접근하지 못하게 막을 수 있기 때문에 데드락을 막을 수는 있지만 근본적 해결책이 될 수 없다.

순서 변경

foreign key로 인해 S락 설정이 먼저 적용되는 부분을 X락이 먼저 적용 되도록 순서를 변경하는 방법이다. 다시 말해 insert와 update의 순서를 변경하면 자연스럽게 문제를 해결할 수 있는 것이다.

update를 먼저하게 될 경우 X락이 설정되기 때문에 foreign key로 인해 S락을 설정하려면 X락이 끝나기를 기다려야 하기 때문에 Deadlock이 발생하지 않는다.

JPA에서는 ID가 IDENTITY 방식인 경우 쓰기 지연이 안 되기 때문에 insert는 호출 즉시 바로 실행하게 되는데 update를 명시적으로 먼저 실행될 수 있도록 flush()를 하면 된다.

이 방법은 순서가 중요하다는 것을 개발하는 모두가 인지하고 주의해야 하기 때문에 주의하지 않으면 장기적으로는 결국 문제가 생길 가능성이 있다. (휴먼 에러 가능성 존재)

외래 키 제거

외래 키가 없다면 S락을 사용하지 않기 때문에 위 상황에서 데드락이 발생하지 않는다.

알아보니 여러 제약으로 실무에서는 외래 키를 사용하지 않고 개발하는 경우가 많다고 한다.


외래 키 제거로 생각을 해보면 JPA에서 @ManyToOne으로 엔티티 연관관계를 설정하지 않고 Long Wrapper 타입으로 하게 되면 또 하나의 장점이 있다.

바로 도메인 간 의존성이 제거될 수 있다는 것이다.


단점이 있는데, fetch join과 같은 쿼리문을 줄일 수 있다는 장점이 줄어들 수 있다는 것이다. 하지만 이 부분도 JPQL(or querydsl)에서 id 조인을 통해 해소할 수 있다.

이번 트러블슈팅을 통해 어떤 문제가 발생하더라도 해결책을 찾자마자 적용하는 것이 아니라 각 스킬이 어떤 장단점을 가지고 있는지 trade-off를 비교하며 적용 방안을 고민해야 한다는 것을 또 절실하게 느끼게 되었다.

0개의 댓글