Spring Boot, MySql - DB 제약 조건으로 동시성 이슈 해결하기

도비·2023년 12월 15일
0

Spring Boot

목록 보기
7/13
post-thumbnail

이전 글에 이어서..

좋아요 DB를 다음과 같이 구현하고,

아래와 같이 상품 좋아요 로직을 구현해놓은 상황에 예상치 못한 에러를 발견했다!

좋아요 삭제/생성을 PUT 방식으로 하나의 API로 처리할 경우 다음과 같은 방식으로 구현된다.


소스 코드

Optional<Heart> existingHeart = heartRepository.findByMemberIdAndProductId(memberId, productId);
return existingHeart
	.map(heart -> {
     deleteHeart(heart);
     return HeartPutResponse.of(null);
     })
     .orElseGet(() -> {
            Heart newHeart = createHeart(memberId, productId);
            return HeartPutResponse.of(newHeart);
    });

에러 발생 다이어그램

이 때, 위 버그처럼 같은 상품과 사용자에 대해 요청을 동시에 하게 된다면 같은 상품과 같은 사용자에 대해 2개의 좋아요가 생기게 된다.

동시성 이슈는 자주 발생하지 않는 문제이지만, 이에 대해서 서버 측에서 트랜잭션 격리 방식이나, Lock을 활용하여 해결하려고 했다.

첫 번째 시도

synchronized 키워드 활용

synchronized란?
여러 개의 스레드가 한개의 자원을 사용하고자 할 때, 현재 데이터를 사용하고 있는 해당 스레드를 제외하고 나머지 스레드들은 데이터에 접근 할 수 없도록 막는 개념이다.

synchronized 를 아래와 같이 메서드 선언부에 붙이게 되면 해당 메서드는 멀티 스레드 환경에서도 한 스레드에서만 실행된다. 다음 요청이 들어와도 선행하는 요청이 완료되었을 때 실행된다.

public synchronized HeartPutResponse toggleHeart(Long memberId, Long productId)

하지만, 스레드를 관리하는 것은 프로세스 단위이기 때문에 여러 대의 서버가 있을 경우는 적용되지 않는다.

두 번째 시도는 아니지만.. 생각

@Lock 활용

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Heart> findByMemberIdAndProductId(final Long memberId, final Long productId);

트랜잭션 잠금에 대한 이해가 높지 않았을 때 @Lock과 같이 트랜잭션에 락을 걸어주면 되지 않을까라는 생각을 했다.

위와 같이 비관적 락을 걸어주게 되면 기본적인 select 문이 아니라 select for update문으로 쿼리가 날라가게 되는데, 이렇게 되면 해당 자원을 읽을 때 해당 자원을 다른 트랜잭션이 변경하지 못하게 할 수 있다.

그런데 이 로직에서 발생하는 쿼리를 살펴보면 다음과 같았다.

Hibernate: 
    select
        h1_0.id,
        h1_0.member_id,
        h1_0.product_id 
    from
        heart h1_0 
    where
        h1_0.member_id=? 
        and h1_0.product_id=? for update
Hibernate: 
    select
        h1_0.id,
        h1_0.member_id,
        h1_0.product_id 
    from
        heart h1_0 
    where
        h1_0.member_id=? 
        and h1_0.product_id=? for update
        # ====== heart에 대한 select 문 ====== 여기서 두 쿼리의 결과가 다 존재하지 않기 때문에 새로운 자원을 생성한다.
Hibernate: 
    select
        m1_0.id,
        m1_0.user_id,
        m1_0.user_name 
    from
        member m1_0 
    where
        m1_0.id=?
Hibernate: 
    select
        m1_0.id,
        m1_0.user_id,
        m1_0.user_name 
    from
        member m1_0 
    where
        m1_0.id=?
       # ======member 에 대한 select 문======
Hibernate: 
    select
        p1_0.id,
        p1_0.brand,
        p1_0.details,
        p1_0.heart_amount,
        p1_0.image_url,
        p1_0.discount,
        p1_0.price,
        p1_0.title,
        p1_0.version 
    from
        product p1_0 
    where
        p1_0.id=?
Hibernate: 
    select
        p1_0.id,
        p1_0.brand,
        p1_0.details,
        p1_0.heart_amount,
        p1_0.image_url,
        p1_0.discount,
        p1_0.price,
        p1_0.title,
        p1_0.version 
    from
        product p1_0 
    where
        p1_0.id=?
        # =====product에 대한 select 문=====
Hibernate: 
    insert 
    into
        heart
        (member_id, product_id) 
    values
        (?, ?)
Hibernate: 
    insert 
    into
        heart
        (member_id, product_id) 
    values
        (?, ?)
    	# =====heart에 대한 insert 문=====

이렇게 member select -> product select -> heart insert 순으로 발생하고, 사실 select for update를 사용해도, insert는 해당 자원의 변경이 아니기 때문에 락에 걸리지 않고 실행되게 된다.

따라서, @Lock으로는 이 문제를 해결할 수 없었다. (@Lock을 사용했던 예제는 여기..있다.)

최종 시도 및 해결

어떻게 해결할 수 있을까 생각하다가, 내가 놓치고 있었던 부분을 알게 되었다.

한 제품과 한 유저가 unique 키로 heart 테이블 안에 있어야 한다는 점이다.

문제는 스프링 로직 내부에 있을것이라고 생각해서 자바 코드로만 해결하려고 했지만 DB 내부 설정부터 해줬어야 하는 것이었다.

unique 키 설정은 create table 할 때 아래와 같이 설정해주는 방법과

create table if not exists sopt.heart
(
    id bigint auto_increment primary key,
    member_id  bigint null,
    product_id bigint null,
    constraint uk_product_member unique (member_id, product_id), # 이렇게 unique key 제약 조건을 추가한다.
    constraint FKiabfybq4nkjndkwptwe38yuj7 foreign key (product_id) references sopt.product (id),
    constraint FKiqbtbunbl2h0r928gnlg7ncta foreign key (member_id) references sopt.member (id)
);

두 번째로 alter 문으로 설정해주는 방법이 있다.

ALTER TABLE heart ADD CONSTRAINT uk_product_member UNIQUE (member_id, product_id);

이렇게 unique 제약조건을 추가하고 두 개의 요청을 보내면 두 번째 요청에 대해 내부에서 DataIntegrityViolationException이 발생한다.

그러면, 이제 만일 새롭게 요청이 들어왔을 때 DataIntegrityViolationException이 발생하고 우리는 이 예외를 잡고, 발생했을 경우 좋아요를 삭제하면 된다.

그렇게 개선된 코드는 다음과 같다

try {
    Heart newHeart = create(memberId, productId);
    return HeartPutResponse.of(newHeart);
} catch (DataIntegrityViolationException e) {
    delete(memberId, productId);
    return HeartPutResponse.of(null);
}

💡 깨달은 점

이렇게 트러블 슈팅을 하는 과정에서 가장 문제라고 생각했던 부분은 좋아요 로직을 하나의 PUT 메서드로 구현하려고 했던 부분이다.

에러를 추적할 때 상세하게 로깅이 되어있지 않아서 어떤 부분에서 에러가 발생하는지 알기가 쉽지 않았다.

단순히 읽기 관련 로직이 아니라, 트랜잭션이 들어가는 생성이나 삭제 로직에 관련해서는 무조건 API를 분리하자! 라는 깨달음을 얻었다.

두 번째로는, 등잔 밑이 어둡다고...

DB의 격리 레벨도 살펴보고, 결국 DB의 제약조건을 추가해주면서 문제를 해결해보니 코드 단의 해결 전에 DB의 상태를 먼저 인지하고 있어야 한다는 점을 알았다.

항상 개발할 때 등잔 밑을 잘 살펴보면서 해야겠다..😅

profile
하루에 한 걸음씩

0개의 댓글