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

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

by kriorsen 2024. 11. 19.

내가 왜 이런 컨셉을 잡았을까...

 

📌  데이터베이스 락이 필요한 이유


1. 분산 환경에서의 동시성 제어

  • synchronized 편에서 언급했 듯, synchronized는 단일 JVM 내에서만 작동한다.
    • 여러 서버에서 동일한 데이터에 접근하는 분산 환경에서는 동작하지 않으므로, 데이터를 공유하는 모든 서버에서 동기화가 이루어지지 않는다.
  • 데이터베이스 락은 데이터베이스 자체에서 적용되므로, 분산 시스템에서도 동시성 문제를 해결할 수 있다.

2. 데이터 무결성 보장

  • synchronized는 메모리 수준에서 동기화를 제공하기 때문에, 트랜잭션 제어가 별도로 필요하다.
  • 데이터베이스 락은 트랜잭션 수준에서 동시성을 제어하여 데이터의 정확성과 일관성을 보장한다.

3. 스케일링과 성능 문제

  • 분산 환경에서 데이터를 보호하기 위해 synchronized를 사용할 경우, 락을 중앙화 하기 위해 별도의 메커니즘이 필요할 수 있다.
  • 데이터베이스 락은 데이터베이스 자체에 내장된 기능으로, 별도의 중앙화된 락 메커니즘 없이 효율적으로 동작한다.

정리하자면, synchronized는 단일 JVM 내에서 프로그래밍 수준의 간단한 동기화를 제공하지만, 데이터베이스 락은 데이터 일관성을 보장하고 분산 환경에서 동시성 문제를 해결할 수 있어 더 안정적인 선택이다.

 

 

📌  Pessimistic Lock


Pessimistic Lock은 데이터의 동시성을 제어하기 위해 데이터를 수정하거나 읽기 전에 해당 데이터에 잠금을 거는 방식이다.

 

데이터에 접근하기 전 잠금을 획득하므로 동시에 접근하려는 트랜잭션 간 충돌이 발생하지 않는다. 충돌을 예방하기 때문에, 경쟁 상황이 자주 발생되는 환경에서 사용된다.

잠금 경합(Lock Contention)

잠금 경합은 다중 트랜잭션이나 프로세스가 동일한 리소스에 대해 락을 획득하려고 시도하면서 발생하는 경쟁 상황을 의미한다. 현재 티켓 애플리케이션에서는 다음과 같은 경합이 발생할 수 있다.

  1. 두 트랜잭션(T1, T2)이 동시에 티켓 id 16에 접근하여 재고를 수정하려고 한다.
  2. T1이 먼저 쓰기 잠금을 획득한다.
  3. T2는 T1의 트랜잭션이 끝날 때까지 대기 상태에 들어간다.
  4. 경합이 발생하며 T2의 처리 속도가 지연된다.

두 트랜잭션이 재고를 업데이트할 가능성을 줄이기 위해 수정 작업을 큐로 관리하거나, 낙관적 락을 이용해 경합을 줄일 수 있다.

 

📌 Pessimistic Lock Mode

  •  

Spring Data JPA는 세 가지 비관적 락 모드를 제공한다.

1. PESSIMISTIC_READ

다른 트랜잭션에서 데이터를 읽을 수는 있지만, 수정하거나 삭제할 수 없도록 공유 락(Shared Lock)을 설정한다. 데이터를 조회하면서, 해당 데이터가 트랜잭션 종료 전까지 수정되지 않도록 보장하고 싶을 때 사용할 수 있다. 

 

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

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

 

PESSIMISTIC_READ가 적용된 메서드를 실행하면 다음과 같은 쿼리문이 실행된다.

 

2. PESSIMISTIC_WRITE

데이터를 읽거나, 수정하거나, 삭제하는 다른 트랜잭션을 모두 차단하는 배타적 락(Exclusive Lock)을 설정한다. 주로 데이터를 수정하려는 트랜잭션에서, 일관성을 보장하기 위해 사용한다.

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

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

 

PESSIMISTIC_WRITE가 적용된 메서드는 for update가 추가된 쿼리문이 실행된다.

 

3. PESSIMISTIC_FORCE_INCREMENT

배타적 락을 설정하는 동시에 버전을 증가시킨다. 버전 속성을 사용하는 엔티티에 유용하며, 버전 속성은 다른 트랜잭션에서 해당 데이터가 변경되었음을 명시적으로 확인할 수 있도록 돕는다. @Version으로 지정된 속성이 있는 엔티티에서만 사용 가능한데, FORCE_INCREMENT 잠금 모드는 낙관 락으로 동시성을 해결하는 부분에서 더 자세하게 다룰 예정이다.

 

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


@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberTicketService {

    private final MemberRepository memberRepository;
    private final TicketRepository ticketRepository;
    private final MemberTicketRepository memberTicketRepository;

    @Transactional
    public synchronized void issue(Long memberId, Long ticketId) {
        Member member = getMember(memberId);
        Ticket ticket = getTicket(ticketId);
        validateIssuable(member, ticket);
        memberTicketRepository.save(new MemberTicket(member, ticket));
        ticket.decrementQuantity();
    }

    private Member getMember(Long memberId) {
        return memberRepository.findById(memberId)
                .orElseThrow(() -> new IllegalArgumentException("멤버가 존재하지 않습니다."));
    }

    private Ticket getTicket(Long ticketId) {
        return ticketRepository.findById(ticketId)
                .orElseThrow(() -> new IllegalArgumentException("티켓이 존재하지 않습니다."));
    }

    private void validateIssuable(Member member, Ticket ticket) {
        if (!ticket.issuable()) {
            throw new IllegalArgumentException("티켓 재고가 소진되었습니다.");
        }
        int issuedMemberTicketCount = memberTicketRepository.countByMember(member);
        if (issuedMemberTicketCount >= MemberTicket.MEMBER_TICKET_COUNT_MAX) {
            throw new IllegalArgumentException("계정당 구매할 수 있는 티켓 수량을 넘었습니다.");
        }
    }
}

 

Member와 Ticket을 조회하는 부분에 베타 락을 걸어 주면 동시성 문제를 해결할 수 있다.

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

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select m from Member m where m.id = :memberId")
    Optional<Member> findByIdWithLock(Long memberId);
}

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

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

 

Repository에 비관 락을 걸어 놓은 메서드를 추가하고 서비스에서 이를 호출하도록 코드를 수정했다.

   private Member getMember(Long memberId) {
        return memberRepository.findByIdWithLock(memberId)
                .orElseThrow(() -> new IllegalArgumentException("멤버가 존재하지 않습니다."));
    }

    private Ticket getTicket(Long ticketId) {
        return ticketRepository.findByIdWithLock(ticketId)
                .orElseThrow(() -> new IllegalArgumentException("티켓이 존재하지 않습니다."));
    }

 

그러자 테스트가 아주아주 잘 통과하는 걸 볼 수 있다 🥔