문제 발생 배경
인턴을 하면서 담당했던 프로젝트 중, 매달 1일만 되면 프로그램 예약 신청을 위한 트래픽이 몰리는 상황이 항상 발생했다. 문제는 프로그램의 정원의 수 보다 예약된 사람의 수가 더 많다는 것이었다.
문제의 원인을 분석하자면 다음과 같았다.
- A가 프로그램을 예약하기 위해 ‘프로그램 정원 수’를 조회한다. (정원 : 19/20)
- B가 프로그램을 예약하기 위해 ‘프로그램 정원 수’를 조회한다. (정원 : 19/20)
- A가 프로그램 예약을 확정한다.
- B가 프로그램 예약을 확정한다.
- 해당 프로그램의 정원수를 조회. (정원 21/20)
- A,B가 프로그램을 예약하는 시점에서는 정원을 초과하기 전이므로 문제없이 예약 가능한 상태임
즉, 문제의 원인은 ‘프로그램 정원 수’를 조회하는 과정에서 적절한 동시성 제어가 이뤄지지 않아서 생긴 문제였다.
@Trasacitonal 어노테이션 파헤치기
문제의 원인은 @Trasactional 어노테이션에 있었다. 보통 조회를 제외한 등록/수정/삭제 로직을 처리할 때 트랜잭션을 적용하고 있었다. 여기서 허점은 @Trasacitonal 어노테이션을 적용하는것 만으로 동시성 문제를 무조건 적으로 해결한다고 생각했다는 것이다.
트랜잭션의 특징, ACID중 I(Isolation - 격리성)을 유지하면서 동시성을 체크하는 것은 프로그래머의 몫이었다.
이 문제를 해결하는 방법은 Lock을 걸어놓는 것인데 Lock은 크게 2가지 종류가 있다.
낙관적 락(Optimistic Lock)과 비관적 락(Pessimisitc Lock)
예를 들어 특정 회원이 자신의 정보를 수정하는 로직이 있다고 가정해보겠습니다. 자연스럽게 특정 회원의 정보는 자신을 제외한 다른 회원이 수정하는것이 불가능하기 때문에 동시성 문제가 발생할 확률이 매우 적다고 확신할 수 있을 것입니다.
이처럼 동시성 문제가 발생하지 않다고 예상하면서 자원을 선점할 락을 걸지 않고 만약 동시성 문제가 발생하면 나중에 처리하자라는 식의 처리 방법을 ‘낙관적 락’이라고 합니다.
비관적 락은 당연히 낙관적 락과 반대로 생각하면 됩니다. 제가 발생한 ‘예약 시스템’처럼 동시성 문제가 발생할것이라고 미리 예상을 하고 락을 걸어버리는 처리 방법을 ‘비관적 락’이라고 합니다.
낙관적 락과 비관적 락의 가장 큰 차이점은 락을 걸어두는 것도 있지만 락을 어디서 처리하는지에 대한 문제입니다.
낙관적 락은 애플리케이션 단에서 동시성 문제를 처리하고 비관적 락은 DB에서 아예 자원에 접근하지 못하도록 처리합니다. 낙관적 락의 경우 애플리케이션 단에서 동시성 문제를 처리하기 때문에 당연히 ‘롤백’에 대한 처리도 개발자의 몫입니다.
@Lock(with @QueryHints) 어노테이션으로 데이터의 정합성 엄격하게 보장하기
해당 프로젝트는 Java/Spring + Mybatis를 사용했기 때문에 트랜잭션을 처리하기 위해서는 Spring의 힘을 빌려서 트랜잭션을 처리해야했습니다. 스프링의 트랜잭션 관리 클래스는 DataSourceTransactionManager로 해당 부분은 주제와 벗어난 내용이라 이번 글에서는 Spring JPA를 사용해 Lock을 사용하는 방법에 대해 정리하고 마무리 하겠습니다.
// 위의 상황에서 현재까지 예약자 수를 조회하는 로직을 예로 들었습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="10000")})
Integer countAllReservation(Long programId);
JPA에서 제공하는 @Lock 어노테이션은 비관적 락을 구현할 수 있는 기술입니다. 락을 걸 수 있는 모드는 다음과 같습니다.
락 모드 | 타입 | 설명 |
---|---|---|
낙관적 락 | OPTIMISTIC | 낙관적 락을 사용한다. |
낙관적 락 | OPTIMISTIC_FORCE_INCREMENT | 낙관적 락 + 버전정보를 강제로 증가한다. |
비관적 락 | PESSIMISTIC_READ | 비관적 락, 읽기 락을 사용한다. |
비관적 락 | PESSIMISTIC_WRITE | 비관적 락, 쓰기 락을 사용한다. |
비관적 락 | PESSIMISTIC_FORCE_INCREMENT | 비관적 락 + 버전 정보를 강제로 증가한다. |
락 X | NONE | 락을 걸지 않는다. |
자세한 내용은 해당 링크에 자세히 나와있으니까 참고하면 좋을 듯하다. @Lock에 대한 추가적 정보
추가로 @QueryHints 어노테이션은 락을 획득할 때까지 트랜잭션이 무한적 대기하는 상황을 방지하기 위해 타임아웃 걸어 시간을 줄일 수 있는 어노테이션이다.
이번 포스팅을 통해 @Trasactional 어노테이션이 동시성 문제를 해결하는데 만능이 아님임을 알 수 있었고 비즈니스 요구에 따라서 적절하게 락을 거는것은 개발자의 몫임을 알 수 있었던것 같다.
출처:
-
https://woonys.tistory.com/entry/Transactional%EC%9D%80-%EB%A7%8C%EB%8A%A5%EC%9D%B4-%EC%95%84%EB%8B%99%EB%8B%88%EB%8B%A4-2-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%98-%EA%B2%A9%EB%A6%AC%EC%84%B1%EA%B3%BC-lock
-
https://velog.io/@backtony/JPA-Lock
-
https://unluckyjung.github.io/db/2022/03/07/Optimistic-vs-Pessimistic-Lock/