안녕하세요!
예약 시스템을 개발해 보면서
@Transactional 멀티스레드 테스트 시 발생하는 트러블슈팅과
동시성 이슈를 해결한 경험을 되짚기 위해 공유하려고 합니다.
관리가 필요해
누구나 꽤 높은 확률로 비슷한 경험을 해봤을 것이다.
"그건 다 팔고 1인분 밖에 안남았는디~ 친구는 다른 거 주문혀~"
친구와 동일한 메뉴를 주문했지만 재료가 부족해 주문이 실패한 케이스다.
베테랑 아주머니께서는 냉장고의 상태를 고려하여 주문을 받은 것이다.
주문을 했어도 냉장고의 상태에 따라 주문이 됐을 수도 있고 메뉴를 변경해야 할 수도 있다.
그 말은 즉 냉장고의 상태는 공유되는 자원이고, 공유되는 자원은 정확한 상태 관리가 필요하다는 것이다.
이처럼 프로그래밍도 공유되는 자원은 정확하게 관리가 될 수 있도록 레이스컨디션*을 고려한 개발이 되어야 한다.
(* 2개 이상의 스레드가 공유 데이터에 액세스 할 수 있고, 동시에 변경을 하려고 할 때 발생하는 문제)
내가 개발 중인 상황도 마찬가지다.
수업이 개설되면 회원들은 수업을 예약할 것이다.
그리고 수업 정원이 가득 차면 예약을 받지 말아야 한다.
하지만 베테랑 아주머니처럼 냉장고의 상태를 고려하지 않은 채 개발이 된다면
정원이 제대로 카운팅 되지 못해 정원 이상의 예약을 받게 되는... 불편한 상황이 발생하고 말 것이다.
정말 그럴까?
테스트 전, 확인 사항 (@Transactional 멀티스레드 테스트 트러블슈팅)
@Transactional은 단일 스레드에서 유효한 로컬 스레드를 사용하고 있다.
따라서 테스트에 @Transactional이 있으면 격리 수준이 테스트 클래스부터 잡히므로 멀티스레드 테스트는 실패한다.
@SpringBootTest
class ReservationServiceImplMultiThreadTest {
...
@Test
void 수업예약_FULL_동시성_테스트() throws Exception {
// given
int memberCount = 19;
List<Member> members = createMembers(memberCount);
Member instructor = createInstructor();
Course course = createCourse(instructor);
Ticket ticket = createTicket();
createMemberTickets(members, ticket);
// when
ExecutorService executorService = Executors.newFixedThreadPool(8);
CountDownLatch latch = new CountDownLatch(memberCount);
for (int i = 0; i < memberCount; i++) {
int idx = i;
executorService.submit(() -> {
try {
reservationService.reserve(course.getId(), members.get(idx).getMemberTickets().get(0).getId());
} catch (Exception e) {
System.out.println("e = " + e.getMessage());
} finally {
latch.countDown();
}
});
}
latch.await();
// then
List<Reservation> reservations = reservationRepository.findAll();
Course savedCourse = courseRepository.findAll().get(0);
assertThat(reservations.size()).isEqualTo(19);
assertThat(savedCourse.getReservationCount()).isEqualTo(16);
assertThat(savedCourse.getWaitingCount()).isEqualTo(3);
assertThat(savedCourse.getCourseStatus()).isEqualTo(CourseStatus.FULL);
}
...
}
예약을 해야 하는데 수업을 찾지 못한다. 하지만 수업은 등록이 되어 있다.
왜 안 됐을까? 이유는 이렇다.
1. 테스트 실행 시 테스트 클래스 단위의 트랜잭션 발생
2. 수업 등록 시Test worker 스레드에서 1차 캐시 저장 (트랜잭션이 종료되어야 DB에 저장)
2024-03-10T16:51:47.469+09:00 INFO 10520 --- [ Test worker] .s.ReservationServiceImplMultiThreadTest : Started ReservationServiceImplMultiThreadTest in 2.629 seconds (process running for 3.266)
3. executorService.submit 시점에 새로운 스레드 생성.
4. 스레드-1에서 Course를 조회.
2024-03-10T16:53:59.938+09:00 DEBUG 10483 --- [pool-2-thread-1] o.s.orm.jpa.JpaTransactionManager : Creating new transaction with name [com.jm.hinamaste.domain.reservation.service.ReservationServiceImpl.reserve]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
5. Test worker 스레드에서 수업을 생성했으니 1차 캐시에 존재할 것으로 예상했지만 수업을 찾는 select 쿼리 발생
(select 쿼리가 발생했다는 것은 1차 캐시에 존재하지 않아 DB에서 찾아봤다는 것을 알 수 있음.)
6. 따라서 스레드-1에서는 수업이 존재하지 않으므로 CourseNotFound 발생
정리하면 다음과 같다.
executorService.submit() 시점에 별도의 새로운 스레드가 생성되는데
수업을 저장한 Test worker 스레드와 executorService이 발행한 스레드-n이 1차 캐시를 공유하고 있지 않다.
해결방법으로는
1. 미리 생성할 data.sql 파일을 만들고 테스트메서드에 아래 @Sql를 붙여주는 것이다.
@Sql(scripts = "data.sql", config = @SqlConfig(transactionMode = TransactionMode.ISOLATED))
2. 또는 @Transactional을 제거해 트랜잭션 범위를 테스트클래스 단위에서 메서드 단위로 가져간다.
2번 방법을 사용해 @Transactional이 없는 멀티스레드용 테스트 클래스를 만들어서 진행하도록 하자.
별도로 분리해서 다시 테스트를 진행해 보면
각각의 회원이 수업 예약을 진행했을 때, 예약 카운트가 올라가기 전에 공유 데이터를 조회해 오기 때문에
예상한 대로 19개의 예약은 됐지만 예약 카운트에 문제가 발생했다. (레이스컨디션)
예약 카운트는 수업 엔티티가 가지고 있는 공유되는 자원이기 때문이다.
즉, 수업의 상태가 제대로 고려되지 못했다.
문제 해결
Spring Data JPA에서는 이러한 문제를 해결하기 위해 DB Lock 메커니즘을 지원한다.
이번에는 실제 DB Lock을 사용하는 Pessimistic Lock(비관적 락)과 Optimistic Lock(낙관적 락)을 적용해 테스트해보려고 한다.
이전 포스트에서 GOTO를 사용해서 Optimistic Lock과 결이 비슷한 구성으로 동시성 이슈를 해결했었다.
[Procedure] 동시성 문제, GOTO를 활용한 PK에러 해결
안녕하세요! 동시성 문제를 저의 상황에 맞게 해결했던 경험을 공유하려고 합니다. 상황은 이렇습니다. 제가 운영 중이던 서비스는 시퀀스가 단순히 번호를 매기는 의미가 아닌 비즈니스적으로
choicode.tistory.com
Pessimistic Lock
Pessimistic Lock은 액세스 하는 row에 Lock을 걸어
트랜잭션이 종료되기 전까지 다른 스레드가 접근하지 못하도록 하는 메커니즘이다.
그렇기 때문에 순차적으로 공유 자원에 접근하게 된다.
적용
public interface CourseRepository extends JpaRepository<Course, Long> {
...
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select c from Course c where c.id = :courseId")
Optional<Course> findByIdWithPessimisticLock(@Param("courseId") Long courseId);
}
수업을 조회해 올 때 PessimisticLock을 사용해 조회한 courseId는 다른 스레드가 접근하지 못하도록 Lock을 건다.
결과
select ~~~ for update 쿼리가 나가면서 해당 수업에 대해 Lock을 거는 것을 확인할 수 있다.
순차적으로 공유 자원에 액세스 하는 것을 확인할 수 있다.
Optimistic Lock
OptimisticLock은 버전을 이용한 애플리케이션 레벨의 락이다.
버전이 맞으면 예약이 성공되지만 (version +1)
버전이 맞지 않으면 ObjectOptimisticLockingFailureException을 발생하면서 재시도 로직을 실행한다.
적용
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class Course extends BaseEntity {
@Version
private Long version;
...
}
OptimisticLock은 version을 기준으로 업데이트 유무가 결정되므로 공유 객체에 version을 추가한다.
public interface CourseRepository extends JpaRepository<Course, Long> {
...
@Lock(LockModeType.OPTIMISTIC)
@Query("select c from Course c where c.id = :courseId")
Optional<Course> findByIdWithOptimisticLock(@Param("courseId") Long courseId);
}
OptimisticLock 세팅
@Slf4j
@Component
@RequiredArgsConstructor
public class OptimisticLockReservationFacade {
private final ReservationService reservationService;
public Long reserve(Long courseId, Long memberTicketId) throws InterruptedException {
while (true) {
try {
return reservationService.reserve(courseId, memberTicketId);
} catch (ObjectOptimisticLockingFailureException e) {
log.info("동시성 에러 발생 50ms후 재시도");
Thread.sleep(50);
}
}
}
}
재시도 로직 작성.
1. 수업 예약을 진행한다.
2. version이 맞지 않아 수업예약에 실패했을 경우 50ms후에 수업예약을 다시 시도한다.
3. while문으로 재시도에 성공할 때까지 반복한다.
테스트 시 OptimisticLockReservationFacade를 사용.
결과
Lock을 걸지 않으므로 동시에 여러 스레드가 예약을 진행하는 것을 확인할 수 있다.
버전이 업데이트되어 현재 버전과 일치하지 않으면재시도 로직을 실행한다.
최종적으로 19번의 수업 엔티티가 수정되면서 version이 18번으로 변경된 것을 확인할 수 있다.
(최초 업데이트 시 0부터 시작)
어떤 걸 사용할까?
PessimisticLock과 OptimisticLock의 적용기준은 "동시에 수정하는 일이 빈번하게 일어나는가?"이다.
PessimisticLock은 실제 DB에 Lock을 걸어
다른 스레드의 접근을 원천 차단하기 때문에 DeadLock에 주의하여 사용하여야 한다.
하지만 Lock을 통해 업데이트를 제어하기 때문에 정합성이 보장되며
OptimisticLock처럼 version 불일치 시 재시도 로직이 존재하지 않기 때문에 성능상 이점이 있을 수 있다.
따라서 충돌이 빈번하게 일어난다면 PessimisticLock을 사용하는 것이 좋을 수 있다.
OptimisticLock은 별도의 Lock을 잡지 않으므로 PessimisticLock 보다 성능이 좋다.
하지만 업데이트에 실패했을 경우 재시도 로직이 실행되어야 하므로
충돌이 빈번하게 일어난다면 성능이 좋지 않을 수 있다.
나의 경우
현재 사용자가 많지 않고, 동시에 수업을 예약될 상황이 많지 않을 거라는 가정하에
OptimisticLock을 적용해보려고 한다.
추후에 분산환경이 됐을 경우엔 Redis도 고려해 보자.
참고
'Spring' 카테고리의 다른 글
[Spring] IoC(Inversion of Control : 제어의 역전)컨테이너란? (0) | 2021.09.29 |
---|---|
[Spring] 스프링 빈(Bean)이란? (0) | 2021.09.24 |
[Spring] MVC 패턴 & Spring MVC Architecture & Spring 설정 파일 (0) | 2021.09.06 |
댓글