쑥멍
쑥로그
쑥멍
전체 방문자
오늘
어제
  • 분류 전체보기 (65)
    • 메모 (0)
    • 안드로이드 (4)
      • 팁 (1)
      • 프로젝트 (3)
    • 파이썬 (1)
    • 스프링 (26)
      • 프로젝트 (16)
      • 에러 아카이빙 (1)
      • 해부 (9)
      • 튜토리얼 (0)
    • 리눅스 (3)
    • CS (14)
      • 컴퓨터구조 & OS (8)
      • 클린 아키텍처 (6)
    • 낙서 (5)
      • 일기 (0)
      • TIL (2)
      • 고민 (2)
    • 게임 (1)
      • 야숨 (1)
    • C (0)
    • Go (3)
    • Django (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • ㅜ
  • ㅁ

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
쑥멍

쑥로그

[스프링부트] 교착 상태 테스트 & 해결 과정
스프링/프로젝트

[스프링부트] 교착 상태 테스트 & 해결 과정

2023. 10. 29. 14:21

실제 운영하고 있는 서비스는 아니라 교착 상태가 발생한 건 아니지만, 교착 상태를 일으키는 테스트 코드를 작성하고 실험해봤다. synchronized, 비관적 락, 낙관적 락 모두 시도해봤지만 테스트가 계속 실패해서 디버깅해봤더니 원인은 락을 잘못 걸었다거나, 커넥션 풀 개수가 적어서가 아니었다. 엔티티 지연로딩 때문이었다. 그 메소드에 예외 잡는 코드를 써놓지 않아서 디버깅하기 전까지 전혀 모르고 있었다. 역시 테스트는 중요하다.

 

이 과정에서 알아낸 것들을 살펴보겠다.


1. 상태 변경 메소드

@Transactional
public void createPaymentNMessage(PaymentsCompleteDto dto, Users user, Gift gift) {
    IamportPayment payment = iamportPaymentService.createPayment(dto, user, gift);
    messageService.createMessage(payment);
    giftService.fund(gift, payment.getAmount());	// ⭐
}

이 코드는 사용자가 결제할 때 호출된다. payment 엔티티, message 엔티티를 연속으로 저장하고 그에 따라 gift의 상태를 변경한다. 여기서 교착 상태가 발생할 수 있는 부분이 별표친 라인의 gift 상태 변경 메소드이다. 

 

gift 엔티티에는 funded라는 int형 필드가 있다. 사용자가 후원한 만큼의 금액이 funded에 채워지는 방식이다. 여기서, 같은 gift에 여러 사용자들이 동시에 후원을 하면 교착 상태가 발생할 수 있다는 것을 예상할 수 있다. 

 


2. 교착 상태 발생시키기

@Test
public void test_concurrent_payments_causing_deadlock() throws Exception {
    // given
    Users user = userRepository.findByEmail("chick12@gmail.com").orElseThrow();
    Event event = eventRepository.findAllByUsers(user).get(0);
    Gift gift = giftRepository.findAllByEvent(event).get(0);

    // when
    ExecutorService executorService = Executors.newFixedThreadPool(15);
    CountDownLatch latch = new CountDownLatch(15);
    for(int i = 0; i < 15; i++) {
        Gift finalGift = gift;
        executorService.submit(() -> {
            try {
                paymentFactory.createPayment(10000, finalGift, user); // ⭐
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await();

    // then
    gift = giftRepository.findAllByEvent(event).get(0);
    assertThat(gift.getFunded(), equalTo(150000));
}

몇 가지 생소한 부분을 설명하겠다.

  • ExecutorService
    • 여러 스레드에서 비동기 작업을 실행하기 위한 
    • newFixedThreadPool(15)는 15개의 스레드로 구성된 스레드 풀을 만든다.
    • submit()은 15개의 동시 작업을 스레드 풀에 제출한다. 각 작업은 다른 스레드에서 비동기적으로 실행된다.
    • 요약: 15개의 스레드가 있는 스레드풀을 만들고 나서 15개의 동시 결제 작을 비동기적으로 실행
  • CountDownLatch
    • new CountDownLatch(15)는 15개의 작업이 완료될 때까지 기다리기 위해 설정
    • 결제 작업이 완료될 때마다 countDown()을 호출해 작업이 완료되었음을 통지
    • latch.await()는 15개의 작업이 완료될 때까지 기다리게 함

 

실행해보니 결제가 하나도 반영되지 않은 것을 볼 수 있다.

 

이 때 해결 방법은 세 가지가 있다.

  1. synchronized 키워드
    • 사용이 간편함
    • 단일 서버에서만 동기화 가능(서버가 두 대 이상이면 못 막음. 단일 서버 내에서의 동기화만 가능)
  2. JPA 비관적 락
    • 락이 걸려있는 동안 다른 트랜잭션이 접근하지 못하므로 대기시간 길어질 가능성 높음
    • 충돌 가능성 높을 때 사용
  3. JPA 낙관적 락 
    • 락을 쓰지 않고 버전 번호로 데이터 변경 감지
    • 성능 저하 최소화하고 싶을 때 + 충돌 가능성 낮을 때 사용

synchronized는 사용법이 간단하니 생략하고 비관적 락과 낙관적 락을 각각 설명하겠다.

 


3. JPA 비관적 락

mutex 락과 유사한 메커니즘이다. 트랜잭션 간에 충돌이 발생한다고 비관적으로 가정하고 우선 락을 거는 방식이다. 

public interface GiftRepository extends JpaRepository<Gift, String> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select g from Gift g where g.id = ?1")
    Optional<Gift> findByIdWithPessimisticLock(String id);
    
}

사용법은 간단하다. 위처럼 교착 상태가 발생하는 엔티티의 조회 메소드에 @Lock 어노테이션을 붙여주면 된다. 그리고 교착 상태가 발생하는 상태 변경 메소드에서 이 엔티티를 조회해오면 락이 적용된다.

 

PESSIMISTIC_WRITE 이외의 모드도 있다. PESSIMISTIC_WRITE 모드는 배타적 잠금으로, 다른 트랜잭션에서 읽기, 쓰기 작업 둘 다 수행하지 못하게 한다.

 

@Service
@RequiredArgsConstructor
public class GiftService {
	...
    public void syncFund(Gift gift, int amount) {
        gift = giftRepository.findByIdWithPessimisticLock(gift.getId()).orElseThrow();
        gift.pay(amount);
        if(gift.isGranterPrice()) {
            gift.changeState(GiftState.success);
            eventPublisher.publishEvent(new GiftCompletedEvent(gift, gift.getEvent().getUsers()));
        }
        giftRepository.saveAndFlush(gift);
    }
}

위에서 만든 조회 메소드를 호출해서 조회해오면 된다. 조회 메소드를 호출한 메소드가 락의 범위가 된다.

 

이렇게 끝내면 아쉬우니, JPA의 PESSIMISTIC_WRITE 락이 DB에서는 어떻게 적용되는지 알아보자.

@Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션을 조회 메소드에 붙이면 실제로 아래와 같은 쿼리가 실행되는 것과 같다.

START TRANSACTION;

SELECT * FROM gift WHERE id=1 FOR UPDATE;

COMMIT;

FOR UPDATE라는 키워드가 쿼리 뒤쪽에 붙는다. 트랜잭션이 커밋되기 전까지 락이 설정된다. 

이외에도 RDBMS에서 제공하는 여러 가지 락 기능이 있다. FOR UPDATE는 레코드 수준의 락으로 가장 간단하다.

 


4. JPA 낙관적 락

비관적 락과는 달리 DB 수준의 락이 아니라 애플리케이션 수준에서 버전 번호를 관리해 처리하는 방식이다. 트랜잭션 커밋 전에는 충돌 여부를 알 수 없는 것이 특징이다. 

 

사용법은 비관적 락과 마찬가지로 간단하지만 엔티티 클래스에 @Version 필드를 정의해야 한다.

@Version
private Long version;

이렇게 추가해준다. 이 버전 번호를 사용해서 엔티티의 변경된 상태를 감지한다.

 

public interface GiftRepository extends JpaRepository<Gift, String> {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("select g from Gift g where g.id = ?1")
    Optional<Gift> findByIdWithOptimisticLock(String id);
    
}

@Lock(LockModeType.OPTIMISTIC) 어노테이션을 붙인 조회 메소드를 새로 정의한다. 

이렇게 하면 JPA는 아래와 같은 형태의 SQL 쿼리를 생성한다.

 

UPDATE gift
SET funded = ?, version = version + 1
WHERE id = ? AND version = ?

현재 알고있는 version 값이 DB에 있는 version 값과 일치하는 경우에만 업데이트를 실행한다.

예를 들어 두 사용자가 거의 동시에 같은 엔티티를 수정하려고 할 때, 먼저 커밋한 사용자의 변경이 성공하고 나중에 커밋하려는 사용자의 변경은 앞선 사용자의 커밋으로 바뀐 version 값으로 인한 불일치로 실패한다. 이 경우 JPA는 OptimisticLockException을 발생시킨다. 

 

그런데 만약 실패해서 OptimisticLockException이 발생하면 비관적 락처럼 대기하는 것도 아니고 실패하고 마는 건데 그 이후의 로직이 필요하지 않을까? 이 점이 단점이다. 충돌하면 개발자가 수동으로 롤백 처리를 해줘야 한다. 

@Transactional
public void syncCreatePaymentNMessage(PaymentsCompleteDto dto, Users user, Gift gift) throws InterruptedException {
    IamportPayment payment = iamportPaymentService.createPayment(dto, user, gift); // IamportPayment 엔티티 save
    messageService.createMessage(payment, user, gift);

    while(true){
        try {
            giftService.syncFund(gift, payment.getAmount());
            break;
        } catch(OptimisticLockException | StaleStateException | LockAcquisitionException e) {
            e.printStackTrace();
            Thread.sleep(100);
        }
    }
}

나는 위와 같이 성공할 때까지 재시도하도록 처리해서 시도해봤다. 실패했을 때 사용자에게 재시도하라고 하면 사용자가 성가셔할 것 같아서 되도록 서버에서 해결하려고 했다. 

그런데 이 방법도 실패한다.

교착상태를 해결하려고 낙관적 락을 사용한 건데도 교착상태가 발생한다. 이유가 대체 뭘까. 

 

우선 MariaDB에서 SHOW ENGINE INNODB STATUS; 쿼리로 교착상태 관련 로그를 살펴봤더니, gift에 대한 공유 락을 얻은 후에 배타 락을 얻으려고 시도하다가 교착상태가 발생한다. 우선 여기서 궁금증 5개가 생긴다.

  1. DB 락을 적용한 적이 없고 낙관적 락을 적용했는데 왜 DB 락이 적용되었을까?
    • FK를 포함한 데이터를 insert, update, delete하는 쿼리는 제약조건을 확인하기 위해 공유 락을 설정하기 때문
    • 여기서는 Payment 엔티티가 Gift 엔티티를 FK로 갖는데, Gift를 FK로 갖는 Payment를 insert해서 공유 락이 설정됨
    • + update 쿼리에 사용되는 모든 레코드에 무조건 배타적 락을 설정
    • gift의 상태 변경 메소드에서 낙관적 락으로 조회를 했음에도 상태 변경이기 때문에 배타적 락이 적용된 것 같음
  2. 공유 락이 뭘까? 
    • 데이터를 읽을 때 사용하는 락
  3. 배타적 락이 뭘까?
    • 데이터를 변경할 때 사용하는 락
  4. 공유 락을 획득하고 배타적 락을 또 획득하려고 하는 이유가 뭘까?
  5. 공유 락을 획득하고 배타적 락도 획득하는 것이 가능할까?
    • 공유 락은 배타적 락과는 호환되지 않음
    • = 변경 중인 리소스를 동시에 읽을 수 없다는 뜻 

이제 교착상태 발생 원인을 알 수 있다. gift에 대한 공유 락을 획득하고 배타적 락도 획득하려고 하는데, 공유 락과 배타적 락은 호환이 되지 않기 때문에 공유 락이 풀릴 때까지 대기하지만 배타적 락을 획득하고 변경을 적용해야 공유 락이 풀리기 때문에 교착상태가 발생한다. 

 


5. 결론

따라서 굳이 낙관적 락을 고집할 이유는 없어서 비관적 락을 적용하기로 했다. 지속적인 재시도 요청도 필요 없고, 별도의 처리를 해줄 필요도 없고 테스트 시 교착상태가 발생하지도 않기 때문이다. 

 

하지만 그렇다고 비관적 락이 만능이라는 것은 아니다. 서비스의 로직에 따라 다르다. 만약 한 자리밖에 남지 않은 모임에 10명이 참가 요청을 했을 때, 낙관적 락을 적용했을 경우 최초의 1명만 모임에 가입된다. 그럼 나머지 9명은 인원이 꽉 차 마감된 모임이라는 메시지를 받아야 한다. 이 경우에는 재시도할 필요가 없고, 재시도하지 않는 것이 맞다.

 

 

참고

https://velog.io/@znftm97/%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0-V1-%EB%82%99%EA%B4%80%EC%A0%81-%EB%9D%BDOptimisitc-Lock-feat.%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%B2%AB-%EB%A7%8C%EB%82%A8



'스프링 > 프로젝트' 카테고리의 다른 글

[스프링부트] 트랜잭션에서 외부 API 호출하는 문제에 대해  (0) 2023.11.14
Spring WebFlux + OAuth2 로그인 구현 과정  (0) 2023.11.11
[스프링부트] 성능 테스트 & 튜닝 과정  (0) 2023.09.16
[스프링부트] 트랜잭션 때문에 애먹은 지점  (0) 2023.07.06
[스프링부트] 통합 테스트 작성 과정  (0) 2023.07.01
    '스프링/프로젝트' 카테고리의 다른 글
    • [스프링부트] 트랜잭션에서 외부 API 호출하는 문제에 대해
    • Spring WebFlux + OAuth2 로그인 구현 과정
    • [스프링부트] 성능 테스트 & 튜닝 과정
    • [스프링부트] 트랜잭션 때문에 애먹은 지점
    쑥멍
    쑥멍

    티스토리툴바