이전에 썼던 상품 좋아요 생성 API를 구현하면서 "상품 좋아요 수"의 동시성 문제에 대해 고민하게 되었다.
👍 상품 좋아요 수
회원이 상품 좋아요를 생성하면 해당 상품은 좋아요 개수가 1 증가한다.
트래픽이 많아지고 동시에 요청하게 될 경우 동시성 문제가 발생할 것이다.
🧐 DB 락을 사용해 볼까?
🔓 Optimistic Lock(낙관적 락)
대부분의 트랜잭션이 충돌이 발생하지 않을 것이라고 낙관적으로 가정하는 방법이다. 따라서 데이터베이스가 제공하는 락 기능을 사용하지 않고, 엔티티의 버전을 통해 동시성을 제어한다. 즉, DB Transaction을 이용하는 것이 아닌 애플리케이션 레벨에서 지원하는 락이다.
낙관적락은 version 등의 구분 컬럼을 이용해서 충돌을 예방한다. (version 뿐 아니라 hashcode 또는 timestamp를 이용하기도 한다.)
🔓 Pessimistic Lock(비관적 락)
비관적 락은 Repatable Read 또는 Serializable 정도의 트랜잭션 격리 수준을 제공한다.
트랜잭션 격리 수준에 대해서는 이 글을 참고하면 될 것 같다.
비관적 락이란 트랜잭션이 시작될 때 Shared Lock 또는 Exclusive Lock을 걸고 시작하는 방법이다.
Shared Lock을 걸게 되면 write를 하기 위해서 Exclucive Lock을 얻어야 하는데 Shared Lock이 다른 트랜잭션에 의해서 걸려 있으면 해당 Lock을 얻지 못해서 업데이트를 할 수 없다. 수정을 하기 위해서는 해당 트랜잭션을 제외한 모든 트랜잭션이 종료(commit) 되어야 한다. 따라서 여러 테이블에 비관적 락을 거는 경우 불필요한 경우에도 Lock을 걸기 때문에 성능이 저하되고 데드락이 발생할 수 있다.
이렇듯 Transaction을 이용하여 충돌을 예방하는 것이 비관적 락(Pessimistic Lock)이다.
🆚 언제 어느 락을 사용하는 게 좋을까?
낙관적 락은 트랜잭션을 필요로 하지 않기 때문에 아래와 같은 로직의 흐름을 가질 때도 충돌 감지를 할 수 있다.
만약 비관적 락이라면 1번에서 3번 사이의 트랜잭션을 유지할 수가 없다.
- 클라이언트가 서버에 정보를 요청
- 서버에서는 정보를 반환
- 클라이언트에서 이 정보를 이용하여 수정 요청
- 서버에서는 수정 적용 ( 충돌 감지 가능 )
또한 비관적 락보다 성능이 좋다. 따라서 충돌이 빈번하게 발생하지 않는 경우 낙관적 락을 사용하는 것이 좋다.
하지만 낙관적 락의 최대 단점은 롤백이다. 충돌 시 비관적 락이라면 트랜잭션을 롤백하면 끝나는 작업이지만 낙관적 락은 충돌을해결하려면 개발자가 수동으로 롤백처리를 해줘야 한다. 수동 롤백처리는 구현도 까다롭지만 성능적으로도 update를 한 번씩 더 해줘야 한다. 따라서 결과적으로 비관적 락 보다 성능이 좋지 않을 수 있다. 이러한 단점 때문에 낙관적 락은 충돌이 많이 예상되거나 충돌이 발생했을 때 비용이 많이 들것이라고 판단되는 곳에서는 사용하지 않는 것이 좋다.
🧐 JPA에서 낙관적 Lock을 어떻게 설정하는데?
@Version
JPA는 @Version 어노테이션을 통해 엔티티의 버전을 관리할 수 있다.
@Version 적용이 가능한 타입은 Long(long), Integer(int), Short(short), Timestamp이다.
@Entity
// 롬복 생략
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
private String name;
private Integer price;
@Version
private Long version;
}
버전 관리를 위해 version 필드에 @Version 어노테이션을 적용하였다.
이제 Product 엔티티가 변경될 때마다 version 이 자동으로 하나씩 증가한다.
그리고 엔티티를 수정할 때, 엔티티를 조회한 시점의 버전과 수정한 시점의 버전이 일치하지 않으면 예외가 발생한다.
1️⃣ 버전 정보 비교 방법
JPA가 엔티티 수정 후 트랜잭션을 커밋하는 시점에, 영속성 컨텍스트를 flush 하면서 아래의 UPDATE 쿼리를 실행한다.
UPDATE PRODUCT_LIKE
SET
LIKE_COUNT = ?,
version = ? # 버전 + 1 증가
WHERE
id = ?,
and version = ? # 버전 비교
위와 같이 데이터가 수정될 때마다 엔티티의 버전 정보를 증가시킨다.
위 쿼리의 WHERE 절에서 엔티티 조회 시점의 버전으로 데이터를 찾는 조건을 볼 수 있다.
만약 데이터 조회 이후 엔티티가 수정되었다면 위 WHERE 문에서 나오는 엔티티가 없기 때문에 JPA가 예외를 던진다.
2️⃣ 주의할 점
Embedded 타입의 경우 논리적으로 해당 엔티티의 값이므로 수정하면 엔티티의 버전이 증가한다. 반면 연관관계 필드의 경우 연관관계의 주인 필드를 변경할 때에만 버전이 증가한다.
또, @Version으로 추가한 버전 관리 필드는 JPA가 직접 관리하므로 임의로 수정해서는 안된다. 그러나 대량의 데이터를 한 번에 수정하거나 삭제하는 벌크 연산의 경우 버전을 무시하므로 아래와 같이 버전 필드를 강제로 증가시켜야 한다.
update PRODUCT p set p.name = '이름 변경', p.version = p.version + 1
낙관적 락의 LockModeType
LockModeType을 통해 락 옵션을 변경할 수 있다.
🌱 NONE
엔티티에 @Version 을 적용하면 기본으로 적용되는 락 옵션
- 용도 : 조회한 엔티티를 수정하는 시점에 다른 트랜잭션으로부터 변경(또는 삭제)되지 않음을 보장한다.
즉, 조회 시점부터 수정 시점까지를 보장한다. - 동작 : 엔티티를 수정하는 시점에 엔티티의 버전을 증가시킨다. 이때 엔티티의 버전이 조회 시점과 다르다면 예외가 발생한다.
- 이점 : 두 번의 갱신 분실 문제를 해결한다.
🌱 OPTIMISTIC
NONE 옵션의 경우 엔티티를 수정할 때 버전을 체크하지만, OPTIMISTIC 옵션은 엔티티를 조회만 해도 버전을 체크한다.
즉, 한번 조회한 엔티티가 트랜잭션 동안 변경되지 않음을 보장한다.
- 용도 : 엔티티의 조회 시점부터 트랜잭션이 끝날 때까지 다른 트랜잭션에 의해 변경되지 않음을 보장한다.
- 동작 : 트랜잭션을 커밋하는 시점에 버전정보를 체크한다.
- 이점 : 애플리케이션 레벨에서 DIRTY READ와 NON-REPEATABLE READ를 방지한다.
🌱 OPTIMISTIC_FORCE_INCREMENT
엔티티가 물리적으로 변경되지 않았지만, 논리적으로는 변경되었을 경우 버전을 증가하고 싶을 때 사용한다.
예를 들어 게시물과 첨부파일 엔티티가 1:N 관계일 때 게시물에 첨부파일이 하나 추가된 상황은 게시물 엔티티의 물리적 변경은 일어나지 않았지만, 논리적인 변경은 일어났다. 이때 버전을 변경하고 싶다면 해당 락 옵션을 사용하면 된다.
- 용도 : 논리적인 단위의 엔티티 묶음을 관리할 수 있다.
- 동작 : 엔티티가 직접적으로 수정되어 있지 않아도, 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해 버전 정보를 강제로 증가시킨다. 이때 엔티티의 버전을 체크하고 일치하지 않으면 예외가 발생한다. 이때 추가로 엔티티의 정보도 실제로 변경되었다면 2번의 버전 증가가 발생한다.
- 이점 : 강제로 버전을 변경하여 논리적인 단위의 엔티티 묶음을 버전관리할 수 있다.
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query(value = "select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
}
🧐 이제 재시도 로직을 만들어야지
낙관적 락에서 사용하는 version 비교를 했을 때 버전이 맞지 않을 경우 예외를 던진다.
- javax.persistence.OptimisticLockException (JPA)
- org.hibernate.StaleObjectStateException (Hibernate)
- org.springframework.orm.ObjectOptimisticLockingFailureException (Spring)
상품 좋아요 서비스 컨트롤러 코드에서 Breakepoint를찍어 api 실행을 잠시 멈춘 후 데이터베이스에서 직접 version을 다른 값으로 수정했을 때 아래와 같이 ObjectOptimisticLockingFailureException 예외가 발생하는 것을 확인할 수 있었다.
그래서 컨트롤러 단에서 ObjectOptimisticLockingFailureException 예외를 잡는 로직을 만들어주었다.
@PostMapping("/{productId}/likes")
@ResponseStatus(HttpStatus.CREATED)
public SuccessResponse<Void> createProductLike(@LoginUser User user,
@PathVariable Long productId) {
try {
productService.createProductLike(user, productId);
} catch (ObjectOptimisticLockingFailureException e) {
log.info("상품 좋아요 생성 동시성 문제 발생");
throw e;
}
return SuccessResponse.create();
}
https://nesoy.github.io/articles/2019-05/Database-Transaction-isolation
https://sabarada.tistory.com/175
https://hudi.blog/jpa-concurrency-control-optimistic-lock-and-pessimistic-lock/
https://jaehoney.tistory.com/159
https://velog.io/@lsb156/JPA-Optimistic-Lock-Pessimistic-Lock
https://isntyet.github.io/jpa/JPA-%EB%82%99%EA%B4%80%EC%A0%81-%EC%9E%A0%EA%B8%88(Optimisstic-Lock)
'Back-end > TroubleShooting' 카테고리의 다른 글
[Spring] 로그인 서비스 테스트 AuthenticationManagerBuilder NPE (0) | 2024.05.14 |
---|---|
[Spring Security] AuthenticationException과 JWTException 분리 (0) | 2024.05.10 |
[Spring Security] OncePerRequestFilter.shouldNotFilter 잘 사용하기 (0) | 2024.05.10 |
[Spring Security] SecurityConfig permitAll() 적용 안 되는 이유 (0) | 2024.05.10 |
[Spring Boot] JPA delete 후 insert 시 duplicate entry 에러 (1) | 2023.05.06 |