Gathering은 간단한 프로젝트지만 진생하면서 동시성 문제를 가지고 있는 곳이 2군데가 있었다. 이걸 각각 낙관적 락과 비관적 락으로 나눠서 이슈를 관리한 이유와 방법을 나눠 보려고 한다.
첫 번째 문제는 동시에 여러유저가 같은 모임에 참가할 때 최대 인원이 넘는 유저가 참가 되는 문제다.
Gathering의 모임 참가 로직은
현재 참여인원 < 최대 참여인원
의 조건이 만족해야 참가 할 수 있다.
참가 완료시 현재 참여인원을 +1 해주고, 모임참가 table에 유저를 삽입한다.
예상 되겠지만 모임 참가 요청이 동시에 여러 유저가 요청한다면 현재 참가 인원과 모임참가 table에는 유저의 수가 일치 하지 않는 문제가 발행하여 이 것을 낙관적 락과 Retry를 통해 최대 인원까지만 참여 되도록 해결했다.
두 번째 문제는 모임의 예약 문제였다. 당연하지만 여러 모임이 같은 장소, 같은 시간에 예약 할 수 없도록 해줘야만 했다.
Gathering은 미리 정해진 장소와 1시간 단위로 정해진 운영시간을 예약하도록 되어있어, 예약 할 수 있는 공간과 장소는 미리 정해져서 제공되고 있다.
모임을 가질 장소와 시간 예약은 당연히 선착순이라고 생각하며, 단 하나의 모임만 사용할 수 있으니 비관적 락을 통해 먼저 접근한 요청을 예약 할 수 있도록 했다.
낙관적 락을 간단히 설명하자면 version컬럼을 이용해 통해 DB가 아닌 서버에서 동시성을 제어하는 방법이다.
JPA는 간단한 version관리 기능을 제공해준다.
문제1은 모임의 최대인원을 넘지 않도록 하는게 제1목적이었다.
비관적 락을 사용해서 핸들링 할 수도 있지만 DB에서 베타락 이용하기 때문에 데드락의 원인이 될 수도 있고, 모임 테이블은 가장 많은 조회가 예상되어서 최대한 조회에 영향을 주고 싶지 않았다.
때문에 version 컬럼을 추가하고 version관리를 통한 낙관적 락으로 모임 참가의 동시성 문제를 해결 했다.
그리고 version이 맞지 않다고 무조건 모임참가 실패라는 응답을 주는 것 보단, version이 맞진 않아도 최대 참가 인원에 도달하지 않았다면 Retry 로직을 통해 참가 재시도를 할 수 있도록 했다.
JPA를 이용한 version관리는 @Version 어노테이션으로 간단하게 구현이 가능하다.
@Slf4j @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Table(indexes = { @Index(name = "IX_member", columnList = "member_id") }) public class Gathering extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "gathering_id") private Long id; ... @Version @ColumnDefault(value = "0") private int version; ... }
Retry는 @Retry 어노테이션을 하나 만들어서 AOP를 이용해 낙관적 락을 재시도 하고 싶은 곳에 달아 주었다.
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Retry { int value() default 10; }
@Slf4j @Order(Ordered.LOWEST_PRECEDENCE - 1) //@Transactional annotation보다 먼저 실행되어야하므로. @Aspect @Component public class RetryAspect { @Around("@annotation(retry)") public Object doRetry(ProceedingJoinPoint joinPoint, Retry retry) throws Throwable { int maxRetry = retry.value(); Exception exceptionHolder = null; for (int retryCount = 1; retryCount <= maxRetry; retryCount++) { try{ return joinPoint.proceed(); }catch (Exception e){ log.error("[retry] try count ={}/{}", retryCount, maxRetry); exceptionHolder = e; } } throw exceptionHolder; } }
비관적 락을 간단히 설명하자면 반드시 충돌이 일어난다고 생각 되는 곳에 DB의 lock기능인 Shared Lock(읽기 락) 또는 Exclusive Lock(쓰기 락)을 걸고 시작하는 것 이다.
Shared Lock 사용하면 다른 트랜젝션에선 조회만 가능
하고, Exclusive Lock 사용하면 다른 트랙젝션에선 조회도 불가능
하게 된다. 이 중 Gathering의 모임예약에는 Exclusive Lock락을 사용했다.
모임 참가에서 동시성 문제는 사실 최대인원을 1~2명 더 참가해도 사전에 알 수 있어, 핸들링이 충분이 가능해 비관적 락을 이용할 만큼 데이터 정합성을 맞춰줄 필요가 없다고 생각했다.
하지만 모임 장소와 시간을 예약하는 기능은 다른 모임과 겹치게 된다면 유저 입장에서 미리 알 수있는 방법이 없다. 때문에 예약 시간에 하나 이상의 모임이 겹처 자리싸움이 날 수 있기에 확실한 데이터 정합성을 맞춰줄 필요가 있다고 생각했다.
JPA에서 비관적 락을 사용하는 방법은 @Lock 어노테이션을 사용하면 된다.
@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT rs FROM RoomSchedule rs WHERE rs.id IN (:ids)") List<RoomSchedule> findAllByIdForUpdate(@Param("ids") Iterable<Long> ids);
LockModeType.PESSIMISTIC_WRITE = for update사용