해당 글은 MySql, H2 데이터베이스 기준으로 작성하였습니다.
Q. 주문을 할 때 특정 시간에 사용자 1명만 주문을 할까?
Q. 우연히 2명 이상의 사용자가 같은 시각에 동시에 주문을 할 수 있지 않을까?
Q. 한 사람이 주문을 동시에 여러 개할 수 있지 않을까?
'데이터베이스에서의 동시성 문제'라고 부르며, '고립성(Isolation)'이 제대로 보장되지 않아 발생하는 문제가 위와 같다. 동시에 여러 트랜잭션이 수행될 때, 트랜잭션들이 서로 간섭하지 않도록 하는 것이 고립성의 목표이다.
트랜잭션의 격리 수준(Isolation Level)을 설정하여 동시성 문제를 일정 수준까지 제어할 수 있지만, 완벽하게 해결하기 위해서는 추가적인 동시성 제어 기법이 필요하다. 이 때, 낙관적 락이나 비관적 락 등의 동시성 제어 기법을 사용하여 데이터의 일관성을 유지할 수 있다.
데이터베이스에 접근해서 데이터를 입력하거나 수정할 때 동시에 연산이 일어나 충돌이 발생할 수 있다.
대표적으로 DB Lock이 충돌방지를 위한 해결책이다. 그 중 낙관적 락에 알아보자.
사용자A와 사용자B가 동시에 단팥빵을 주문하려고 한다. 낙관적 락이 어떻게 동작할지 과정을 보자.
'마지막 커밋만 인정하기'인 일반적으로 데이터베이스의 기본 동작이다. 즉, 나중에 커밋한 트랜잭션의 변경이 이전에 커밋한 트랜잭션의 변경을 덮어쓰는 방식이 1번 상황이다.
그러나 '최초 커밋만 인정하기'는 데이터베이스의 기본 기능만으로는 구현하기 어렵다.
이를 위해 낙관적 락은 데이터의 버전 정보를 통해 동시에 같은 데이터를 수정하려는 상황을 방지하고, 이로 인한 충돌을 관리한다. 이를 통해 데이터의 일관성을 보장하고, 동시성을 높일 수 있다.
낙관적 락(Optimistic Locking)은 주로 애플리케이션 레벨에서 작동하는 기능이다. 데이터베이스가 아닌 애플리케이션에서 트랜잭션 충돌을 관리하고자 할 때 사용되는 전략이다.
Java의 JPA(Java Persistence API)와 같은 ORM(Object-Relational Mapping) 도구는 낙관적 락을 지원하며, JPA에서는 @Version 어노테이션을 사용하여 엔티티의 특정 필드를 버전으로 지정할 수 있다. 이 필드의 값은 엔티티가 수정될 때마다 자동으로 증가하며, 이를 통해 낙관적 락을 구현할 수 있다.
@Entity
public class Bread {
@Id
private Long id;
private int stockQuantity;
@Version
private Long version;
}
Menu Entity가 수정될 때 마다 version이 자동으로 1씩 증가한다. Entity를 수정할 때, Entity를 조회 시점의 버전과 수정 시점의 버전이 일치하지 않으면 예외가 발생한다.
트랜잭션A와 트랜잭션B가 조회한 단팥빵의 버전은 v1이었다. 트랜잭션A가 트랜잭션B 보다 먼저 주문을 해 재고를 수정하고 커밋했다. 단팥빵은 v2가 된다. 이후 트랜잭션B가 주문을 하고 커밋을 시도한다. 트랜잭션B의 조회 시점은 v1이었는데, 수정 시점은 v2다. 버전의 불일치가 발생하였으므로 OptimisticLockingFailureException 등의 예외를 받게 된다.
UPDATE Bread
SET stockQuantity = stockQuantity - 1, version = version + 1
WHERE id = ? AND version = ?
이 쿼리에서는 version 필드의 값이 트랜잭션 A가 읽어온 값과 같은 경우에만 stockQuantity와 version을 업데이트하도록 조건을 설정하였다. 이를 통해 다른 트랜잭션이 동시에 같은 데이터를 수정하려고 하면 충돌이 발생하게 된다.
이후 트랜잭션 B가 같은 단팥빵 재고를 줄이려고 할 때, 트랜잭션 A가 이미 버전 값을 증가시켜 데이터베이스에 반영하였으므로, 트랜잭션 B가 생성하는 SQL 쿼리는 실패한다. 이는 트랜잭션 B가 읽어온 version 값과 데이터베이스의 version 값이 일치하지 않기 때문이다.
메서드를 호출하면서 예외가 발생하면 잠시 대기한 후 동일한 메서드를 다시 호출하는 로직을 반복적으로 수행하고 있다. 특정 작업을 성공할 때까지 계속해서 시도하는 재시도 로직이다. (retryCount 변수를 이용해 재시도 횟수를 제한)
재시도 로직은 몇 가지 주의사항이 필요하다.
@Transactional 어노테이션이 붙은 order() 메서드 안에서 menu.decrease(quantity);와 menu.increaseOrderCount(quantity);를 호출하여, 이 두 작업은 하나의 트랜잭션으로 묶어 처리하였다. order() 메서드가 실행될 때 새로운 트랜잭션이 시작되며, 이 트랜잭션은 order() 메서드가 종료될 때 커밋되거나 롤백된다.
낙관적 락(Optimistic Locking)은 데이터의 충돌이 자주 발생하지 않는 경우에 적합한 방법이다. 충돌이 발생하면 해당 트랜잭션을 롤백하고 다시 실행하는 방식으로 동작한다.
낙관적 락이 적합한 경우는, 웹사이트의 글이나 댓글 등에 대한 좋아요 기능을 들 수 있다. 사용자들이 각자 독립적으로 좋아요를 누르는 경우, 동시성 충돌은 거의 발생하지 않는다. 이런 경우에는 낙관적 락을 사용하여 락으로 인한 성능 저하를 최소화하면서도, 충돌이 발생했을 때의 영향을 최소화할 수 있다.
하지만, 주문과 같이 동시에 많은 요청이 발생하고, 그 요청들이 동일한 데이터에 대한 변경을 시도하는 경우에는 충돌이 자주 발생할 수 있다.
이러한 상황에서 낙관적 락을 사용하면, 충돌이 발생할 때마다 트랜잭션을 롤백하고 다시 실행해야 하므로 성능 저하로 야기된다.
그럼 어떤 방법으로 해결하면 좋을지 다음 글에서 알아보자.