본문 바로가기
감자-갱생-프로젝트

동시성의 이름으로 날 용서하지 않겠다 - Optimistic Lock 편

by kriorsen 2024. 12. 2.

감자가, 감자가 말했어!!

 

이 시리즈는 글을 쓸 때마다 큰 용기가 필요하네요 👽

📌  긍정적인 아이 부정적인 아이


 

비관락과 낙관락은 데이터베이스에서 동시성 제어를 관리하는 두 가지 접근 방식으로 서로 다른 장단점을 가진다.

비관락(Pessimistic Lock)

이름 그대로 트랜잭션 시작 시 충돌이 일어날 것이라고 비관적으로 가정하고, 이를 피하기 위해 미리 잠금을 거는 방식이다. 

장점

  • 데이터 충돌이 아예 발생하지 않으므로, 동시성 문제가 완전히 제거된다.
  • 충돌이 자주 발생하거나 트랜잭션이 길어도 안정적으로 작동한다.

단점

  • 잠금이 걸린 동안 다른 트랜잭션은 대기해야 하므로 성능이 저하된다.
  • 교착 상태가 발생할 수 있다.
  • 데이터 잠금에 따른 오버헤드가 발생한다.

낙관락(Optimistic Lock)

비관락과 반대로 데이터 충돌 가능성을 허용하고, 충돌이 발생하면 이를 감지하고 해결하는 보다 낙관적인 방식이다. 데이터를 수정하려고 할 때 충돌 여부를 확인하고, 충돌이 발생하면 트랜잭션을 롤백하거나 재시도한다.

장점

  • 잠금을 사용하지 않으므로, 읽기 작업에 대한 병렬성이 높아지고 대기 시간이 줄어든다.
  • 데이터 충돌 발생 시 다양한 방식으로 처리할 수 있어, 비관락보다 유연하게 대처할 수 있다.

단점

  • 충돌 처리에 따른 오버헤드가 발생한다.
  • 충돌 감지를 위한 버전 관리, 충돌 처리 로직 등의 추가 구현으로 개발 복잡성이 증가한다.

낙관락은 비관락의 단점인 병렬성을 보완해, 동시성이 높은 시스템에서 효율적으로 동작한다. 하지만 충돌 처리에 따른 오버헤드가 있기 때문에, 충돌이 빈번한 환경에서는 비관락이 더 적합할 수 있다. 정리하자면 시스템의 특성과 데이터 충돌 패턴을 분석하여 적합한 방식을 선택하는 과정이 필요하다.

 

📌 Optimistic Lock 작동 방식


낙관락은 데이터베이스에서 버전 관리를 통해 충돌을 감지하고 처리하는 구조로, 다음과 같은 과정을 거친다.

1. 데이터 읽기

  • 데이터베이스에서 데이터를 읽을 때, 엔티티의 특정 버전 정보(버전 번호나 타임스탬프)를 함께 가져온다.
  • 예를 들어, 데이터 테이블에 version이라는 숫자 필드를 추가하고, 엔티티에 이를 매핑하여 관리한다.
SELECT id, name, version FROM product WHERE id = 1;

2. 데이터 수정 

  • 트랜잭션에서 데이터를 수정하려고 할 때, 애플리케이션은 수정 대상 데이터의 현재 상태와 조회 시의 버전이 같은지 비교한다.
  • 데이터베이스에 업데이트 쿼리를 보낼 때, 조건절에 버전 번호를 포함한다.
UPDATE product 
SET name = '상품B', version = version + 1
WHERE id = 1 AND version = 5;

 

즉, 업데이트하려는 데이터의 버전 번호가 수정 전과 동일한 경우에만 업데이트가 성공한다.

3. 충돌 감지

  • 데이터베이스는 위 쿼리에서 버전에 대한 조건이 만족되지 않으면 어떠한 행도 업데이트하지 않는다.
  • 그리고 수정된 행이 없을 경우, 애플리케이션은 이를 충돌로 간주해 예외를 던진다.

4. 충돌 해결

  • 충돌이 발생하면 애플리케이션은 충돌 처리 로직에 따라 재시도하거나, 유저가 수동으로 데이터를 확인 후 수정하도록 안내할 수 있다.

 

📌 낙관락으로 문제 해결하기


낙관락의 충돌 처리를 위해 synchronized 편에서 했던 것과 같이, MemberTicketIssueService를 분리하고 MemberTicketSerivce에 재시도 로직을 작성했다.

@Service
@RequiredArgsConstructor
@Slf4j
public class MemberTicketService {

    private final MemberTicketIssueService memberTicketIssueService;

    public void issue(Long memberId, Long ticketId) {
        int tryCount = 0;
        while (tryCount < 15) {
            tryCount++;
            try {
                memberTicketIssueService.issue(memberId, ticketId);
                return;
            } catch (CannotAcquireLockException e) {
                log.error("데드락 발생 {} {} {}", memberId, ticketId, e.getMessage());
            } catch (ObjectOptimisticLockingFailureException e) {
                log.error("버전 충돌 발생 {} {} {}", memberId, ticketId, e.getMessage());
            } catch (Exception e) {
                log.error("예외 발생 {} {} {}", memberId, ticketId, e.getMessage());
                throw e;
            }
        }
        throw new IllegalArgumentException("티켓 발행 시도 횟수 초과");
    }
}

 

 

 

낙관락을 사용하기 위해서는 충돌 감지에 필요한 버전 정보를 엔티티에 추가해야 한다.

@Entity
@Getter
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Ticket {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private Long quantity;

    @Version
    private Long version;

    public Ticket(String name, Long quantity) {
        this(null, name, quantity);
    }

    public Ticket(Long id, String name, Long quantity) {
        this.id = id;
        this.name = name;
        this.quantity = quantity;
    }

    public boolean issuable() {
        return quantity > 0;
    }

    public void decrementQuantity() {
        quantity--;
    }
}

 

재고 정보를 가지고 있는 Ticket 엔티티에 version 필드를 추가했다. @Version은 낙관락을 구현하기 위한 어노테이션으로, 해당 어노테이션이 적용된 필드는 버전 관리 역할을 한다. 보통 Integer나 Long 타입으로 선언되며, 엔티티가 업데이트될 때마다 자동으로 버전 정보가 증가한다.

 

😈  이쯤에서 다시 알아보는 Lock Mode

저번 글에서는 비관락과 관련된 세 개의 모드에 대해 알아 보았다. 낙관락의 경우 OPTIMISTICOPTIMISTIC_FORCE_INCREMENT 두 가지가 주로 사용된다.

LockModeType.OPTIMISTIC

이 모드는 버전 필드를 사용하는 엔티티에 적용된다. 트랜잭션이 데이터를 읽은 후 수정할 때, 데이터의 버전 정보가 변경되었는지 확인한다. 버전 정보가 달라지면 충돌이 발생한 것이므로 예외를 던져서 이를 처리할 수 있다.

 

LockModeType.OPTIMISTIC_FORCE_INCREMNET

이 모드는 낙관락을 적용하는 것에 더해, 엔티티의 버전 번호를 강제로 증가시키는 추가적인 동작을 수행한다. 즉, 트랜잭션이 데이터를 수정할 때마다 버전이 반드시 증가해야 하므로, 다른 트랜잭션이 수정할 때 이 버전 정보를 기반으로 충돌을 감지하게 된다.


우선 Ticket에 OPTIMISTIC을 적용하고, 쿼리가 어떤 식으로 실행되는지 살펴보자.

@Repository
public interface TicketRepository extends JpaRepository<Ticket, Long> {

    @Lock(LockModeType.OPTIMISTIC)
    @Query("select t from Ticket t where t.id = :ticketId")
    Optional<Ticket> findByIdWithLock(Long ticketId);
}

 

비즈니스 로직에서 멤버 티켓 발행 호출 시 마지막에 Ticket의 재고를 감소시키는 코드가 있다. 따라서 변경 감지에 의해 자동으로 update 쿼리가 실행된다. 

 

재고 감소와 함께 version이 0에서 1로 설정된 것을 볼 수 있다. 

 

그리고 id가 63인 Ticket의 버전 정보를 조회하는 쿼리가 추가로 실행되었다. 아마 이때 버전 충돌이 발생하지 않았는지 확인하는 것 같다.

 

데드락이 주를 이루는 테스트 로그를 열심히 들여다보면 ObjectOptimisticLockingFailureException이라는 예외를 발견할 수 있다. 조회한 데이터가 다른 트랜잭션에 의해 수정 혹은 삭제되었을 경우에 이 예외가 발생한다.

 

티켓 재고는 낙관락의 버전 관리를 통해 정합성이 보장되지만, 각 멤버의 티켓 발행 상한은 보장되지 않는다.

@Entity
@Getter
@ToString
@EqualsAndHashCode(of = "id")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    
    @Version
    private Long version;

    public Member(String name) {
        this(null, name);
    }

    public Member(Long id, String name) {
        this.id = id;
        this.name = name;
    }
}

 

Member에도 마찬가지로 버전 정보를 추가하고 OPTIMISTIC 락을 걸었지만, 테스트는 여전히 통과하지 않았다.

 

Ticket과 달리 Member 엔티티는 티켓 발행 후에 변경되는 값이 없기 때문에 버전이 수정되지 않는다.

 
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {

    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    @Query("select m from Member m where m.id = :memberId")
    Optional<Member> findByIdWithLock(Long memberId);
}
 
이때 MemberRepository에서 Member 조회에 사용되는 락 타입을 OPTIMISTIC_FORCE_INCREMENT로 수정하면 비즈니스 로직이 수행된 후에 다음과 같은 쿼리를 실행한다.

 

설명에서 볼 수 있 듯, 변경 사항이 없을 때에도 Member 엔티티에 대해 강제로 버전 번호를 증가시키는 역할을 수행한다.

 

당연히 테스트도 아주 잘 통과한다 🥸