프로젝트/기술적 선택

[기술적 선택] JPA - Optimistic Lock 도입 이유 및 적용 방법

배발자 2023. 6. 6.
반응형

 

'바꾸바꾸' 프로젝트는 Voda, HelloWorld 이전에 싸피에서 진행한 공통 프로젝트이다. 해당 프로젝트에서 담당했던 역할 중에 다음과 같은 문제가 발생하였다. 

 

바꾸바꾸 프로젝트(물건 교환 플랫폼)에서 물건 교환 신청을 요청할 때 하나의 아이템은 최대 10명의 사용자가 요청이 가능하다. 만약 하나의 아이템의 요청 상태가 7명일 때 5명의 사용자가 동시 요청을 하게 된다면, 그 중 3명의 사용자는 요청이 완료돼야하고 2명의 사용자는 요청이 취소돼야한다. 하지만 프로젝트 코드에서는 이러한 동시성 이슈 문제를 해결할 수 있는 로직이 구현되어있지않았다. 즉, 하나의 아이템에 12명의 사용자가 요청 완료가 처리된다.

 

왜 이러한 문제가 발생했는지, 그리고 이를 해결하기 위해 어떠한 기술을 도입했는지에 대한 회고하려고 한다.

 

트랜잭션 격리 수준

 

트랜잭션은 ACID(원자성, 일관성, 격리성, 지속성)을 보장해야한다. 트랜잭션은 원자성, 일관성, 지속성을 보장하지만 문제는 격리성이다. 트랜잭션간 완전한 격리를 보장하기 위해서는 동시성 측면에서 많은 손해를 본다. 예를 들어 테이블 따라서 트랜잭션 격리 수준을 4단계로 구분하여 병행성과 격리성을 설정할 수 있다. 격리성과 병행성은 서로 역비례 관계이므로 무턱대고 격리 수준을 최대로 높이게되면 성능이 악화될 수 있으므로 적절한 격리 수준 설정이 중요하다.

하지만 이런 트랜잭션 격리 수준으로도 해결하지 못하는 문제가 존재한다.

 

 

갱신 분실 문제 

 

 

위의 그림을 보면 어느정도 예상을 할 수 있다. 사용자 A와 사용자 B가 동시에 특정 레코드에 변경 트랜잭션을 수행한다고 가정했을 때 트랜잭션 1이 정상적으로 "이씨"라는 이름으로 content를 변경하고 커밋을 했지만 트랜잭션 2가 "김씨"라는 이름으로 content로 변경하게 되면 트랜잭션 1의 작업이 분실 되어버린다는 얘기다. 

 

위의 예제에서는 간단한 예로 들었지만 만약 은행 시스템에서 개발자 배씨에 사용자 A와 B가 1억씩 후원을 한다면, 위의 같은 상황에서는 2억이 아닌 1억만 송금될 것이다. 

 

 

이러한 문제점을 해결하기 위해 락 방식을 구현하기로 하였다. 

바꾸바꾸 프로젝트에서 JPA를 활용하였다. JPA는 데이터베이스에 대한 동시 접근으로부터 엔티티에 대한 무결성을 유지할 수 있게 해주는 동시성 제어 매커니즘을 지원한다. 이 매커니즘에는 낙관적 락과 비관적 락이 존재한다. JPA는 데이터베이스의 트랜잭션 격리 레벨을 READ COMMITTED 정도로 가정한다.

 

 

비관적 락 vs 낙관적 락 

 

비관적 락(Pessimistic Locking)

비관적 락은 동시성 문제를 해결하기 위해 데이터를 수정하려는 트랜잭션이 해당 데이터에 먼저 락을 걸어 다른 트랜잭션이 접근하지 못하게 하는 방식이다. 이러한 방식은 동시성 문제를 피하기 위해 트랜잭션 충돌이 발생할 것이라 가정하고 미리 락을 걸어 동시 접근을 차단한다. 비관적 락은 대기 시간이 길어질 수 있으며, 데드락(Deadlock) 문제에 대한 처리가 필요하다.

 

낙관적 락(Optimistic Locking)

낙관적 락은 동시성 문제가 발생할 가능성이 낮다고 가정하고, 여러 트랜잭션이 동시에 데이터에 접근할 수 있도록 허용한다. 하지만 실제로 데이터를 변경할 때 충돌이 발생하면, 해당 트랜잭션은 롤백되고 에러를 반환한다. 낙관적 락은 충돌 발생 시점에만 데이터에 대한 동시성 제어를 수행하기 때문에, 성능상 이점이 있다. 일반적으로 동시성 충돌이 자주 발생하지 않는 경우에 적합한 방식이다. 특징은 DB에서 제공해주는 락이 아닌 Application Level에서 잡아주는 락이다. 

 

요약하면, 비관적 락은 동시성 충돌이 발생할 것으로 가정하고 미리 락을 걸어 동시 접근을 차단하는 방식이며, 낙관적 락은 동시성 충돌이 거의 발생하지 않을 것으로 가정하고 동시 접근을 허용하되, 충돌 발생 시점에 처리하는 방식이다. 

 

우리 프로젝트에서는 충돌 가능성이 상당히 낮을 것이라고 판단되었다. 해당 서비스는 금전적인 거래가 아닌 요청에 대한 처리이기 때문에 중요도가 크지 않다는 점이다. 또한, 트래픽이나 서비스의 크기 등을 고려해봤을 때, 충돌이 발생할 가능성이 매우 적다. 그렇기 때문에 어플리케이션 레벨에서 락을 처리하고 만약 충돌이 발생할 경우 처리를 하는 로직을 작성하면 된다.  

 

 


 

@Version

 

public class Item  {

  (생략) 

  @Version
  private Long version;
}

 

JPA는 @Version 어노테이션을 제공하는데, 이를 사용하여 엔티티의 버전을 관리할 수 있다. @Version 적용이 가능한 타입은 Long(long), Integer(int), Short(short), Timestamp 이다. @Version 은 아래와 같이 버전 관리용 필드를 만들어 적용한다.

 

Item 클래스에는 '@Version' 어노테이션을 사용하여 버전 정보를 관리한다.

Item Entity의 데이터를 수정하는 트랜잭션이 수행될 때 해당 버전 정보는 1씩 증가하게 된다. 만약 A 트랜잭션이 해당 Entity의 레코드 값에 조회를 하였고, B 트랜잭션이 해당 레코드 값을 수정하는 트랜잭션을 수행했다고 가정해보자. 이때 Item Entity의 version 정보는 1이 증가하게 되고 이후 A 트랜잭션에서 해당 레코드를 수정하려고 할 때 이전에 조회 당시 version 정보와 비교해서 다르다면 이를 충돌이라고 여기는 것이다. 이때 활용하는 것이 version 컬럼이다. 

 

 

버전 정보 비교 방법

 

JPA가 엔티티를 수정하고 트랜잭션을 커밋하는 시점에, 영속성 컨텍스트를 flush 하면서 아래의 UPDATE 쿼리를 실행한다.

 

UPDATE ItemSET column= ?, version = ? # 버전 + 1 증가 WHERE id = ?, and version = ? # 버전 비교

 

위와 같이 데이터가 수정되었을 때, 엔티티의 버전 정보를 증가시킨다. 위 쿼리에서 WHERE 절에서 엔티티 조회 시점의 버전으로 데이터를 찾는 조건을 볼 수 있다. 만약 데이터 조회 이후 엔티티가 수정되었다면 위 WHERE 문으로 엔티티를 찾을 수 없다. 이 때 JPA가 예외를 던진다.

 

 

LockModeType.OPTIMISTIC

 

JPA에서 낙관적 락의 LockModeType을 제공한다.

OPTIMISTIC 옵션은 엔티티를 조회만 해도 버전을 체크한다. 즉, 한번 조회한 엔티티가 트랜잭션 동안 변경되지 않음을 보장한다.

 

  • 용도 : 엔티티의 조회 시점부터 트랜잭션이 끝날 때 까지 다른 트랜잭션에 의해 변경되지 않음을 보장한다.
  • 동작 : 트랜잭션을 커밋하는 시점에 버전정보를 체크한다.
  • 이점 : 애플리케이션 레벨에서 DIRTY READ와 NON-REPEATABLE READ를 방지한다.

 

  @Lock(LockModeType.OPTIMISTIC)
  @Query("select i from Item i where i.itemIdx = :itemIdx" )
  Item findByIdLock(@Param("itemIdx") Long itemIdx);

 

 

ItemRepository에서 위와 같이 @Lock 어노테이션을 선언하여 적용하였다.  LockModeType.OPTIMISTIC 옵션을 준다. 

 

 

public class OptimisticLockRaceConditionFacade {

  private final ItemService itemService;
  public TradeRequestNotifyDto tradeRequest(Long itemIdx, TradeRequestDto tradeRequestDto) throws InterruptedException{

    Map<Exception, TradeRequestNotifyDto> map = new HashMap<>();

    while(true){
      try {
        TradeRequestNotifyDto tradeRequestNotifyDto = itemService.tradeRequest(itemIdx, tradeRequestDto);
        return tradeRequestNotifyDto;
      }
      catch (Exception e) {
        Thread.sleep(20);
      }
    }
  }
}

 

 

실제 API 요청이 들어오면 ItemController에서 OptimisticLockRaceConditionFacade 클래스의 'tradeRequest()' 메소드를 호출하고, 해당 메소드에서는 'While(true)' 루프를 이용하여 예외 발생 시 일정 시간 동안 대기한 후 다시 ItemService의 교환 요청 메소드를 호출하도록 구현한다. 이를 통해, 데이터 처리 시 예외가 발생하더라도, 다시 시도하여 Race Condition 문제를 해결할 수 있다. 

 

itemService.tradeRequest 메소드에서 교환 요청 비지니스 로직이 수행되며, 해당 로직 내에서 findByIdLock을 호출하여 실제 레코드 값을 호출하며 해당 호출은 OptimisticLock 옵션이 적용되어 Version 정보를 비교하여 충돌 여부를 확인하게 된다. 

 

 

반응형

댓글