HikariCP Deadlock

이광훈·2024년 9월 28일

사건의 발단은 쿠폰 발행 서비스를 제작해서 동시성 문제를 직접 보고 이를 해결해보자는 작은 프로젝트에서 시작했다. 쿠폰 발행 서비스 시나리오는 다음과 같았다.

  • 총 쿠폰은 100개를 발행한다. 만약 현재 발행된 쿠폰의 개수가 100개가 넘으면 쿠폰 발행을 멈춘다. 아래는 이를 작성한 서비스 로직이다.
@Trace  
@Transactional  
public void publishCoupon(String nickname){  
  
    User user = isMember(nickname);  
  
    if(user != null){  
  
        long couponCount = this.couponRepository.count();  
  
        if(couponCount < 100L){  
  
            String couponCode = this.publishCouponCode();  
  
		    Coupon isCouponExists = this.couponRepository.findByCode(couponCode);  
  
            if(isCouponExists == null){  
  
                Coupon coupon = Coupon.builder().code(couponCode).user(user).build();  
                this.couponRepository.save(coupon);  
  
            }else{  
                throw new IllegalStateException("중복된 쿠폰 번호입니다");  
            }  
        }  
        else{  
            throw new IllegalArgumentException("늦었습니다");  
        }  
    }else {  
        throw new IllegalArgumentException("없는 유저입니다");  
    }  
}

아래는 Coupon Entity 이다.

@Entity  
@Getter  
@Builder  
@NoArgsConstructor  
@AllArgsConstructor  
public class Coupon {  
  
    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    private Long id;  
  
    private String code;  
  
    @ManyToOne  
    private User user;  
  
}
  • 그리고 Jmeter 를 이용하여 500명이 동시 요청을 보내는 상황을 가정하여 이 상황에서 정확하게 100개의 쿠폰이 발행이 되는지 확인하려했다. 그래서 요청을 보내보았는데 벌써부터 문제가 생겼다....

  • 에러 로그를 보면 Unable to acquire JDBC Connection 이라 되어있다. 그리고 그 아래에 Connection is not available, request timed out after ... ms. 그 옆을 보면 (total = 10 , active = 10 , idle = 0 , waiting = 186) ...

  • 의미를 파악해 보면 connection 을 얻지 못하고 time out 이 되어서 오류가 뜬 것 같았다. 현재 connection pool 에는 10개의 connection 을 갖고 있는데, 이 10개의 connection 들이 모두 일하는 중이라 idle 한것이 없고 186 개의 요청이 이 connection 을 기다리고 있다.

  • 아래는 내가 어떻게 해결해야 할까 생각해본 것 들 이었다....

그러면 어떻게 해결할 수 있을까?

  1. Connection 이 요청을 빠르게 처리하고 connection pool 로 복귀하여 다음 요청을 처리한다. 즉 요청의 처리속도 자체를 빠르게한다.
  2. Connection pool 에 있는 connection 수 자체를 늘린다.
  3. connection-timeout 이 발생하는 대기 시간을 길게 잡는다.

위의 3가지 방식이 가능할 것 같았다. 일단 이 방법들을 적용하기 전에 몇개까지의 스레드 그룹 (동시 요청) 까지 버틸 수 있는지 궁금해졌다. 그래서 스레드 그룹의 개수를 줄여서 시도해봤다. 결과적으로 80개 정도의 스레드 그룹이 동시에 요청을 보내면 응답의 일부가 실패하는것으로 나타났다.

먼저 HikariCP 의 connection 수가 부족하여 이런 문제가 발생하나 싶었다. 그래서 이 HikariCP 의 connection 수를 20 으로 늘려봤다. 하지만 connection 을 늘려도 에러가 계속 떴다.....

혹시 중복되는 쿠폰 코드일 경우 재수행하는 retry AOP 로직이 문제일까? retry 를 하는 동안 connection 을 점유하고 있어서 connection 부족 현상이 발생하나? 라는 생각에 retry AOP 로직을 지우고 다시 실행해보았다.

그대로였다... retry 로직은 일단 문제가 아니다... 그러면 로직을 더 빠르게 만들어보자, 일단 닉네임에 index 를 걸면 조금은 더 빠를수도 있지 않을까? 라고 생각했지만 그래도 해결이 되지 않았다....이렇게 삽질을 하던 도중.... 한줄기 빛을 찾았다. 우아한형제들 블로그를 보고 뭔가 해결책이 될 듯한 글이 있어서 쭉 따라가봤다.

https://techblog.woowahan.com/2664/

Insert 쿼리와 Sequence 전략을 사용하는 것 까지 나와 경우가 거의 유사했다. 이 글을 간추려서 내 경우에 맞춰 정리해보면 "데이터베이스에 쿼리를 날릴 때, Conncection 내에서 여러개의 subConnection 이 나갈 수 있고 이 때문에 deadlock 현상이 생겼다" 이다. 그리고 이 sub Connection 이 생기는 경우도 글과 동일하다. sequence 전략을 사용하기 때문에, 엔티티의 id 를 가져오는 과정에서 connection 내의 sub connection 이 생기고, 트래픽이 몰리는 상황에서 모든 connection 이 id 를 가져오는 요청이 아닌 insert 를 하는 요청을 할당받고 남는 connection 이 pool 없어서 이 id 를 가져오는 작업을 수행할 수 없다.

이를 조금 더 확실하게 확인하기 위해서 DB 에 전송되는 쿼리를 직접 찍어서 확인을 해 보았다. Sequece 전략을 조금 변경해서, allocationsize = 1 로 설정하였다. 그러면 모든 데이터베이스 접근 마다 id 를 가져오는 sub connection 이 필요하게 되기 때문에 이 deadlock 을 조금 더 쉽게 확인 할 수 있을거라 판단했다.

  • sequence 전략의 allocationSize = 1
  • HikariCP Connection Pool size = 10
  • Jmeter 를 이용해 동시에 보내는 요청 수 = 30
  • Tomcat 서버의 스레드 풀 내 스레드 갯수 = default (200)

위 환경에서 실험을 진행해보았다

쿼리들을 보면 , select 쿼리 10개 , 그 이후에 쿠폰 갯수를 확인하는 select count 쿼리가 10개가 나간것을 확인할 수 있다. 그런데 이 10개는 현재 HikariCP connection pool 에 있는 connection 의 갯수와 동일하다. 이를 통해 현재 모든 connection 들이 insert 쿼리를 수행하기 위해 할당되었고, 이에 파생되어서 나오는 id 를 채번해야 할 connection 이 connection pool 에 남아있지 않다 는 것을 알 수 있다. 그림으로 표현하면 아래와 같을것이다.

그러면 이 Deadlock 을 피하려면 어떻게 해야할까? 일단은 첫번째로, sequence 전략 대신 identity 전략을 사용하면 해결이 된다. 하지만 sequece 전략을 꼭 사용해야 하는 경우나 하나의 task 당 여러개의 connection 이 꼭 필요한 경우가 있을 수 있다. 그 경우 아래가 deadlock 을 피할 수 있는 connection pool size 이다.

현재 8개의 tomcat 스레드를 사용하고 있고, 내 경우와 같이 한 task 당 2개의 connection 이 필요하다면, 9개가 될 것이다. 스레드가 최대 8개인데 이 보다 1개가 많은 상태이고 이렇게 되면 8개의 connection 중 1개는 id 를 채번할 sub connection 에 할당 할 수 있게 되는 것이다. 그리고 트래픽이 엄청나게 몰리는 상황에서도, tomcat 스레드를 애초에 8개로 설정을 해 놓으면, 맨 처음 8개를 제외한 요청들은 큐에서 (handoffqueue 가 아님 , 이 큐는 request 를 처리하기 위해 스레드 할당 대기를 위한 큐) 대기하게 된다.

지금까지 생각했던 것은 스레드들이 connection 을 잡고 connection 을 반환하는 속도가 너무 느려서 time out 이 나는 것이라 생각했다. 하지만 위 글을 보고 직접 실험을 해보니 deadlock 이라는 문제가 실제로 발생할 수 있고 connection 의 반환 속도가 문제가 아니라 아예 반환을 하지 못하는 상태이기 때문에 time out 이 발생했구나 라는 것을 알 수 있었다. 학교 운영체제 시간에서만 배우던 deadlock 을 실제 프로젝트에서 만나니 신기했다.

그리고 이제야 동시성 문제를 직접 보기위한 세팅이 끝났따.....

profile
허허,,,

0개의 댓글