동시성 제어하기(2): Lock


이 글은 동시성 제어하기(1): Synchronization에서 이어집니다.

🤘🏻 나락도 락이다. 비관적 락도 락인가? Lock은 락이다(아니다).

🔒 Lock

Lock은 동시성을 제어하기 위한 하나의 수단이다. Race condition을 막기 위해 공유된 자원 (Shared resource, Critical section)에 접근하는 스레드를 제한한다. 다만 이러한 과정 속에서 높은 정합성은 유지할 수 있지만, Lock을 획득하기 위해 기다리는 시간이 전체 실행 시간에 포함되므로 연산을 완료할 때까지 오랜 시간이 걸린다.

결국 Lock의 범위를 최소화하는 것이 성능에 큰 영향을 미친다. 이전 Synchronized 키워드에서는 어플리케이션 영역을 전부 잠그는 역할로 해결할 수 있었지만 많은 티켓을 구매하는 데 오랜 시간이 걸렸다. (Synchronization 또한 Lock을 활용한 동시성 제어 방법 중 하나라고 할 수 있겠다) 어플리케이션과 DB 사이를 통하는 모든 과정에서 하나의 스레드만 접근할 수 있도록 제어했다. 실제 동시성이 요구되지 않는 연산들까지 함께 묶이니 성능에 제한이 발생했다.

이 구역을 조금 더 좁혀 보자. 어플리케이션은 DB가 어떻게 되는지는 몰라도 DB의 연산 결과를 바탕으로 비즈니스 로직을 수행한다. DB의 연산 결과가 동시성/정합성이 보장된다면 어플리케이션 계층에서는 동시성 제어를 신경쓰지 않아도 된다. synchronized 키워드 없이도 동시성과 정합성을 챙길 수 있다.

MySQL에서는 Isolation level에 따라 암묵적으로 여러 가지 Lock을 걸고 있지만, 개발자의 요구에 따라 추가적인 Lock을 가질 수 있다. 이번에는 이를 활용해 동시성을 제어하고, Critical Section의 크기를 줄여 처리량(Throughput)을 키워 보자.

🗂️ DB Lock

Race Condition은 기본적으로 자원을 가져오는 시각과 자원에 변화를 가하는 시각에서 차이가 발생하면서 일어난다. 즉, 자원을 변화시키는 연산이 Atomic하지 않기 때문이다. 자원에서 값을 읽어오는 시각과 자원에 값을 쓰는 시각 사이에 다른 스레드가 읽기/쓰기 연산을 하지 못한다면 원자성을 보장할 수 있다.

MySQL과 같은 RDBMS에서는 기본적으로 Isolation level을 제공하며, 많은 동시성 안에서도 (가능한 한) 정확한 연산을 할 수 있도록 지원한다. 전 편에서도 이야기했지만 S-Lock, X-Lock과 같은 락 매커니즘을 활용해 읽기 연산일 때에는 다른 스레드도 읽을 수 있도록, 그렇지 않을 때에는 배타적으로 연산하도록 구현했다.

X-Lock을 자원을 읽는 시점에서 가져올 수 있을까? 그렇다면 자원을 읽고 – 수정을 가하는 시점까지 다른 스레드가 접근할 수 없다. 동시성에서의 성능 감소는 트레이드 오프다.

InnoDB 엔진에서는 SELECT ~ FOR UPDATE 구문으로 X-Lock을 가져올 수 있다. 이때에도 Isolation level에 따라 Gap lock을 추가로 걸 수 있다(기본 레벨인 Repeatable read에서는 Phantom read를 방지하기 위해 다음 레코드까지의 Gap lock을 수행한다).

지난 글에서 아래와 같은 코드를 synchronized 구문으로 해결했었다. 이번에는 DB 수준의 record lock을 통해 문제를 해결해 보자.

⏰ Lock으로 동시성 제어하기

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

동시성과 관련해서는 다음과 같은 두 가지 정책이 있었다.

  1. 한 사람은 2개의 티켓을 구매할 수 있다.
  2. 티켓 수량을 넘게 티켓을 구매할 수 없다.

ticket.decrementQuantity에서 티켓 수량을 확인하고, validateIssuable에서 현재 사용자가 2장 이상 티켓을 구매했는지를 검증한다. 이 과정 모두 DB와의 소통이 필요하다. getMember, getTicketsave, decrementQuantity 사이 여러 스레드가 같은 작업을 진행할 수 있다는 이야기다. 이 과정에서 정합성이 깨지므로 Member, Ticket을 가져올 때부터 배타적으로 락을 챙겨와야 한다.

JPQL에서는 FOR UPDATE 구문을 지원하지 않는다. 따라서 nativeQuery를 활용해 직접 FOR UPDATE를 추가해줄 수 있다.

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

    @Query(value = "select * from Ticket t where t.id = :ticketId for update", nativeQuery = true)
    Optional<Ticket> findByIdForUpdate(long ticketId);
}

혹은, Spring에서 이를 추상화한 @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> findByIdForUpdate(long ticketId);
}

PESSIMISTIC에서 알 수 있듯, 낙관/비관 락으로도 많이 알려져 있다. 해당 시나리오는 많은 사람들이 동시에 티켓을 예매하는 것을 가정했기 때문에, @Version을 관리하며 어플리케이션 단에서 버전을 확인하는 낙관적 락보다는 비관적으로 배타 락을 챙겨오는 것이 성능에 유리하다고 판단했다. (이곳에서 말하는 성능은 때에 따라 달라질 수 있다. 많은 사람들이 동시에 연산을 진행하는 경우 모든 트랜잭션이 다시 재시도돼야 하기에, 그만큼 드는 비용이 많아진다)

🎫 서버는 여러 대, DB는 한 대

DB 단에서 락을 잡음으로써 여러 대의 서버가 존재하더라도 효과적으로 동시성 제어를 할 수 있게 되었다. 다음에는 조금 더 어려운 부분까지 들어가 보자. DB가 여러 대 존재한다면 DB 내부의 락은 synchronized 키워드와 같이 동작하게 될 것이다. 다른 DB에 영향을 미칠 수 있는 상태가 될 테니, 이 때에는 중앙 락 저장소를 두어 동기화를 해줄 필요가 있겠다.

Categories