예약 시스템 - 중복 예약 처리 2 synchronized 와@Trasactional

Chan Young Jeong·2024년 3월 12일
0

프로젝트 Dplanner

목록 보기
2/5

이번 글에서는 synchronized를 이용해 중복 예약 처리해서 적용하는 과정에 대해 알아보겠습니다.

RabbitMq 적용해서 예약 시스템 구현하기

간단하게 다시 한번 예약 요청이 처리되는 과정을 보면

Reservation.class

특정 공유 자원(Resource)에 대해 언제부터 언제까지(Period) 누가(ClubMember) 예약을 했는지에 대한 정보를 저장하는 Reservation Entity입니다.


@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Reservation extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "resource_id")
    private Resource resource;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "club_member_id")
    private ClubMember clubMember;

    @Embedded
    private Period period; //start, end time 포함

    ...
}

ReservationService.class

기본적으로 existsBetween 메서드를 통해 기존에 예약을 하고자 하는 resource에 요청하는 시간대에 겹치는 예약이 있는지를 확인합니다. 그리고 예약이 없으면 reservation을 save합니다.

	@Transactional
    public ReservationDto.Response createReservation(Long clubMemberId, ReservationDto.Create createDto) {
        Long resourceId = createDto.getResourceId();
        LocalDateTime startDateTime = createDto.getStartDateTime();
        LocalDateTime endDateTime = createDto.getEndDateTime();
        // 이미 예약이 있는지 검사
        if (reservationRepository.existsBetween(startDateTime, endDateTime, resourceId)) {
            throw new ServiceException(RESERVATION_UNAVAILABLE);
        }

          ...
            
         // 예약을 생성합니다.
        Reservation reservation = reservationRepository.save(createDto.toEntity(clubMember, resource));
        return ReservationDto.Response.of(reservation);
    }

TestCode

(다음과 같은 테스트를 약 500번 반복합니다. 너무 많이 돌리면 heap memory 부족 OutOfMemory 오류 발생합니다. 적당히 조절)

given

  • 동시에 업데이트할 스레드의 개수 10개로 지정
  • CountDownLatch도 스레드 개수만큼 생성자에 인자를 줘 생성
  • 백그라운드에서 돌릴 스레드를 관리하는 ExecutorService 객체 생성
  • 필요한 객체들 미리 ~Repository.save()를 통해 저장해서 데이터베이스에 넣기

when

  • 설정한 스레드 수만큼 for문을 돌린다.
  • 백그라운드 스레드 실행
    - reservationService.createReservation 실행
  • CountDownLatch 이용해 모든 스레드가 작업 완료할 때까지 메이 스레드 대기

then

  • 성공한 reservation 수가 1개여야 함.
  • 실패한 reservation은 스레드 수 - 1 개여야 함.
    @BeforeEach
    void setUp() {

        member = Member.builder().name("testMember").build();
        club = Club.builder().clubName("testClub").build();
        memberRepository.save(member);
        clubRepository.save(club);

        resource = Resource.builder().club(club).name("testResource").build();
        resourceRepository.save(resource);

        clubMember = ClubMember.builder().member(member).club(club).build();
        clubMemberRepository.save(clubMember);

    }
    @RepeatedTest(500)
    @DisplayName("중복 예약 테스트")
    public void ReservationCreateSynchronizeTest() throws Exception
    {
        //given
        int threadCount = 20;
        Long clubMemberId = clubMember.getId();
        Long resourceId = resource.getId();

        AtomicInteger failCount = new AtomicInteger();
        AtomicInteger successCount = new AtomicInteger();
        ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        assert clubMemberId != null;
        assert resourceId != null;

        //when

        for (int i = 0; i < threadCount; i++) {
            executorService.submit(() -> {
                        try {
                            reservationService.createReservation(clubMemberId,
                                    getCreateDto(resourceId,
                                            "reservation",
                                            "usage",
                                            false,
                                            LocalDateTime.of(2023, 8, 10, 20, 0),
                                            LocalDateTime.of(2023, 8, 10, 21, 0))
                            );
                            successCount.addAndGet(1);
                        } catch (Exception e) {
                            failCount.addAndGet(1);
                            System.out.println(e.getMessage());
                        } finally {
                            latch.countDown();
                        }
                    }
            );
        }

        latch.await(1,TimeUnit.SECONDS);

        System.out.println("successCount = " + successCount);
        System.out.println("failCount = " + failCount);

        assertThat(failCount.get()).isEqualTo(threadCount-1);
        assertThat(successCount.get()).isEqualTo(1);

    }

결과 java.util.NoSuchElementException

로그를 보면 java.base/java.util.Optional.orElseThrow(Optional.java:403) 이 부분에서 Error가 발생합니다. 즉 clubMember를 가지고 오는 부분에서 에러가 발생함을 알 수 있습니다.

    ClubMember clubMember = clubMemberRepository.findById(clubMemberId)
                .orElseThrow(() -> new ServiceException(CLUBMEMBER_NOT_FOUND));
com.dp.dplanner.exception.ServiceException: clubMember is not found, request is invalid
	at com.dp.dplanner.service.ReservationService.lambda$createReservation$0(ReservationService.java:47)
	at java.base/java.util.Optional.orElseThrow(Optional.java:403)
	at com.dp.dplanner.service.ReservationService.createReservation(ReservationService.java:47)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
	at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.base/java.lang.reflect.Method.invoke(Method.java:568)
	at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:343)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.invokeJoinpoint(ReflectiveMethodInvocation.java:196)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:163)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:756)
	at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97)
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
	at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.proceed(CglibAopProxy.java:756)
	at org.springframework.aop.framework.CglibAopProxy$DynamicAdvisedInterceptor.intercept(CglibAopProxy.java:708)
	at com.dp.dplanner.service.ReservationService$$SpringCGLIB$$0.createReservation(<generated>)
	at com.dp.dplanner.integration.ReservationSynchronizeTest.lambda$ReservationCreateSynchronizeTest$0(ReservationSynchronizeTest.java:108)
	at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:539)
	at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:264)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)
	at java.base/java.lang.Thread.run(Thread.java:833)

이유

분명 @Before에서 member, club, resource, clubMember 를 미리 save 했는데, NoSuchElementException 발생하는 이유에 대해 알아보겠습니다.

먼저 이를 이해하기 위해서는 스프링컨테이너는 트랜잭션 범위의 영속성 컨텍스트 전략을 기본으로 사용한다것을 알아야합니다.

  • 트랜잭션의 범위와 영속성 컨텍스트의 생존 범위가 같다.

  • 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료한다.

  • 같은 트랜잭션 안에서는 여러 위치(여러 레포지토리)의 엔티티 매니저를 사용해도 같은 영속성 컨텍스트에 접근한다.

  • 반면, 트랜잭션이 다르면 동일한 엔티티 메니저를 사용해도 다른 영속성 컨텍스트를 사용한다.

  • Transaction 범위의 A 클래스, Transaction 범위의 B 클래스가 한 repo의 em.~~() 를 동시에 사용해도 트랜잭션에 따라 접근하는 컨텍스트가 다르다.

  • 스프링 컨테이너는 스레드마다 각각 다른 트랜잭션을 할당한다.

로그 레벨을 debug로 하고 로그를 살펴보면

logging.leve.root: debug

여러개의 EntityManager가 생성되었음을 확인할 수 있고 트랜잭션도 마찬가지로 여러개 생겼음을 확인할 수 있습니다.

또한 로그를 보면
2024-03-12T20:51:55.077+09:00 DEBUG 37224 --- [ Test worker] o.s.orm.jpa.JpaTransactionManager : Opened new EntityManager [SessionImpl(78453645<open>)] for JPA transaction
...

2024-03-12T20:51:55.130+09:00 DEBUG 37224 --- [ Test worker] o.s.orm.jpa.JpaTransactionManager : Closing JPA EntityManager [SessionImpl(78453645<open>)] after transaction
main thread에서 관리하는 transaction이 가장 먼저 열리고, 가장 나중에 닫힙니다.

따라서 main thread에서 ~Repository.save()를 해서 영속성 컨텍스트에 저장을 해도 아직 commit 된 상태가 아니기 때문에 main thread 트랜잭션에서 관리하는 영속성 컨텍스트에만 저장이된 상태입니다. 따라서 thread pool 에서 생성된 트랜잭션에서 findById를 통해 찾으려고 해도 찾을 수 없다는 NoSuchElementException이 발생하게 된 겁니다.

해결방법

정리하면
BeforeEach에서 insert한 데이터를 새롭게 생성한 쓰레드에서 조회하려고 하면 조회되지 않습니다. 그 이유는 BeforeEach부터 테스트코드가 끝날 때까지 데이터가 실제 DB에 커밋되지 않으며 트랜잭션이 다른 상태에서 커밋되지 않은 데이터를 조회하는게 불가능하기 때문입니다. 따라서 가장 정확한 방법은 미리 해당 데이터를 집어 넣어 놓는 것입니다.

이 방법 말고 @Transactional(isolation = Isolation.READ_UNCOMMITTED) 있지만 service 코드가 변경되기도 하고, 변경하지 않고 test에서 TransactionTemplate을 이용해서 만들어도 됐지만 , 이렇게 하면 reservation.save() 됐을 때 uncommit된 상태이기 때문에 다른 스레드에서도 uncommit된 reservation을 볼 수 있어 reservationRepository.existsBetween 에서 잘못된 결과를 반환할 가능성이 있어 배제했습니다.

@Sql을 사용하여 직접 데이터를 넣어 줍니다. 이때 config 값을
ISOLATED로 해야 바로 commit 됩니다.

config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED)

또한 테스트를 @RepeatedTest로 했기 때문에 삭제 스크립트 또한 필요합니다.

executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD))

Can be set to SqlConfig.TransactionMode.ISOLATED to ensure that the SQL scripts are executed in a new, isolated transaction that will be immediately committed.

정리

일단 findById 에서 NoSuchElementException 발생해서 위에서 처럼 해결했습니다. 하지만 당연히 createReservation 메서드가 동기처리가 안되어 있기 때문에 실행이 돼도 해당 테스트는 실패합니다.

너무 길어서 다음 포스트로..


참고
스레드를 사용한 테스트에서 @Transactional을 사용하면 테스트가 실패하는 이유
JUnit의-BeforeEach와-JPA를-MultiThread에서-사용하기
낙관적락-테스트하기
선착순 티켓 예매의 동시성 문제: 잠금으로 안전하게 처리하기

0개의 댓글