데드락 실제 사례: Connection Pool Deadlock

아투·2026년 5월 27일

Operation System

목록 보기
21/22
post-thumbnail

문제

커넥션 풀 데드락은 한정된 DB 연결권을 나눠 가진 작업들이, 각자 마무리에 필요한 추가 연결권을 서로 기다리며 멈춘 상태이다. 모두 쥐고만 있고 반납하지 않아 시스템이 마비되는 교착 현상이다.

아래는 송금 서비스에서 은행 API를 호출해서 송금을 한다고 가정한 예시 코드이다.

@Service
@RequiredArgsConstructor
public class TransferService {
    private final TransferRepository transferRepository;
    private final BankingRequestRepository bankingRequestRepository;
    private final BankingApiAdaptor bankingApiAdaptor;

    @Transactional
    public void transfer(TransferRequest transferRequest) {
        // Transfer를 생성 후 저장
        Transfer transfer = new Transfer(
            transferRequest.senderAccountId(),
            transferRequest.receiverAccountId(),
            transferRequest.amount()
        );
        transferRepository.save(transfer);

        // BankRequest 생성 후 저장
        BankingRequest bankingRequest = BankingRequest.from(transfer);
        bankingRequestRepository.save(bankingRequest);

        // BankingAPIAdaptor 호출
        // BankingAPI에서 처리 후 BankingRequest의 상태를 변경
        BankingResponse response = bankingApiAdaptor.banking(bankingRequest);

        // Transfer 상태 조정
        if (response.isSuccess())
            transfer.setSuccess();
        else
            transfer.setFail();
    }
}

코드를 보면 큰 문제가 없어 보이지만, 해당 코드는 데드락이 발생하는 코드이다.

BankingAPI에서 아래 로그가 찍혀있다.

MySQLTransasctionRollbackException:
    Lock wait timeout exceeded; try restarting transaction

락을 얻기 위해 기다릴 수 있는 시간을 초과하여 발생한 에러이다.

참고로 이러한 타임아웃은 해당 코드가 데드락이 발생했을 때 무한정으로 대기하면서 시스템이 멈춰버려 심각한 문제를 초례할 수 있는 것을 사전에 방지하기 위한 탐지 및 무시 기법이라고 생각하면 된다.

락으로 인한 문제는 아래 SQL로 확인이 가능하다.

# 실행중인 락 조회
SELECT * FROM performance_schema.data_locks;
# 실행중인 트랜잭션 조회
SELECT * FROM performance_schema.innodb_trx;

원인을 확인한 결과는 다음과 같다.

TransferService에서 아직 Commit을 수행하기 전이므로 BankingService에서 BankingRequest에 대한 잠금을 획득할 수 없다. 즉, 상태 변경을 할 때 Lock wait timeout이 발생하는 것이다.

Transfer()가 실행되고 Start Transaction가 실행되면서 언제든 락을 걸어 둘 준비가 되었다는 뜻이다. 실질적인 락이 걸리는 것은 이 Start Transaction가 아닌 transferRepository.save(transfer);가 실행되어 DB에 해당 행(ROW)가 업데이트가 되는 순간 락이 걸린다.

이때부터 트랜잭션이 업데이트한 행(ROW)에 대해, 다른 트랜잭션은 해당 트랜잭션이 Commit이 될때까지 수정할 수 없으며, 조회는 DB 격리 수준에 따라 조회는 DB 격리 수준에 따라 수정전의 데이터를 보거나 대기하게 된다.

여기서 문제는 아직 Transfer()가 Commit이 되지 않는 상태에서 BankingRequest가 Transfer()와 같은 행(ROW)에 접근하여 BankingRequest의 값을 업데이트해야 하는데 아직 Commit이 되지않이서 해당 행(ROW)에 접근하기 위해서 필요한 락을 얻을 수 없는 상황인 것이다.

설상가상 Transfer()가 Commit이 되려면 BankingRequest의 응답값을 받아야만 종료되도록 로직이 짜져있다. 결국 Transfer()도 응답만 무한정으로 기다려야하는 완벽한 데드락에 빠진 상태인 것이다.

(1) 해결방법 : REQUIRES_NEW

위 문제를 해결하는 방법 중 하나는 propagation으로 REQUIRES_NEW를 사용해서 BankingRequest를 먼저 commit하는 것이다.

@Transactional을 제거하는 것은 기존 코드가 너무 복잡해진다.

@Service
@RequiredArgsConstructor
public class TransferService {
    private final TransferRepository transferRepository;
    private final BankingRequestSavePort bankingRequestSavePort;
    private final BankingApiAdaptor bankingApiAdaptor;

    @Transactional
    public void transfer(TransferRequest transferRequest) {
        // Transfer를 생성 후 저장
        Transfer transfer = new Transfer(
            transferRequest.senderAccountId(),
            transferRequest.receiverAccountId(),
            transferRequest.amount()
        );
        transferRepository.save(transfer);

        // BankRequest 생성 후 저장
        BankingRequest bankingRequest = BankingRequest.from(transfer);
        bankingRequestSavePort.save(bankingRequest);

        // BankingAPIAdaptor 호출
        // BankingAPI에서 처리 후 BankingRequest의 상태를 변경
        BankingResponse response = bankingApiAdaptor.banking(bankingRequest);

        // Transfer 상태 조정
        if (response.isSuccess())
            transfer.setSuccess();
        else
            transfer.setFail();
    }
}

BankingRequestRepository를 직접적으로 호출하지 않고, BankingRequestSavePort를 사용해서 호출한다.

@Component
@RequiredArgsConstructor
public class BankingRequestSavePort {
    private final BankingRequestRepository repository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void save(BankingRequest bankingRequest) {
        repository.save(bankingRequest);
    }
}

BankingRequestSavePort는 Propagation.REQUIRES_NEW로 repository.save()를 호출하고 있다.

기능이 정상적으로 동작한다. 인수 테스트도 통과를 했고 해당 코드로 배포를 했다. 그런나 부하 테스트를 하는데 성공하다가, 성공 건 없이 블록되는 것을 반복하며 아래 에러가 계속 올라온다.

데드락을 의심할 수 있지만 생각해보면 데드락일 수가 없다. 해당 쿼리는 단일 레코드에 대해 수행하는 쿼리였다.

이 문제의 진짜 이유는 아래 HikariCP 로그에서 확인할 수 있다.

HikariPool을 보면 active 상태인 것이 10개이고, idle상태인 것이 없는 것을 확인할 수 있다.


0개의 댓글