성능이 나아졌나요? 아니요 제자리걸음이에요

김형준·2025년 3월 7일
0

성능 테스트에 사용된 코드

1차 최적화

@Transactional
public ReservationCreateResDto createReservation(Long restaurantId, Long memberId, ReservationPostReqDto reservationPostReqDto) {

    Member member = memberRepository.findById(memberId)
            .orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 회원입니다"));

    if (!lockingService.atomicDecreaseRemainingTableCount(restaurantId)) {
        throw new IllegalArgumentException("해당 식당이 없거나 여유 테이블이 없습니다.");
    }

    Restaurant restaurant = restaurantRepository.findById(restaurantId)
            .orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 식당입니다"));

    Reservation newReservation = Reservation.builder()
            .member(member)
            .restaurant(restaurant)
            .reservationTime(reservationPostReqDto.reservationTime())
            .build();

    reservationRepository.save(newReservation);

    return ReservationCreateResDto.from(newReservation);
}

@Service
@RequiredArgsConstructor
public class RestaurantLockingService {
    private final RestaurantRepository restaurantRepository;

    @Transactional
    public boolean atomicDecreaseRemainingTableCount(Long restaurantId) {
        return (restaurantRepository.decreaseRemainingTableCount(restaurantId) > 0);
    }
}

// 영속성 컨텍스트와 DB 간 동기화를 위한 옵션들
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query(
        "UPDATE Restaurant r " +
        "SET r.remainingTableCount = r.remainingTableCount - 1 " +
        "WHERE r.id = :restaurantId AND r.remainingTableCount > 0"
)
int decreaseRemainingTableCount(@Param("restaurantId") Long restaurantId);

비관적 lock

@Service
@RequiredArgsConstructor
public class ReservationPessimisticLockService {

    private final ReservationRepository reservationRepository;
    private final RestaurantRepository restaurantRepository;
    private final MemberRepository memberRepository;

    @Transactional
    public ReservationCreateResDto createReservation(@LockKey Long restaurantId, Long memberId, ReservationPostReqDto reservationPostReqDto) {

        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 회원입니다"));

        Restaurant restaurant = restaurantRepository.findFirstById(restaurantId)
                .orElseThrow(() -> CustomException.of(ErrorCode.NOT_FOUND, "존재하지 않는 식당입니다"));

        restaurant.decreaseRemainingTableCount();

        Reservation newReservation = Reservation.builder()
                .member(member)
                .restaurant(restaurant)
                .reservationTime(reservationPostReqDto.reservationTime())
                .build();

        reservationRepository.save(newReservation);

        return ReservationCreateResDto.from(newReservation);
    }
}

@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Restaurant> findFirstById(Long id);

테스트 구성

로그인 → 예약 요청의 순서로 진행되며 각 작업들의 응답 속도를 별개로 측정할 수 있도록 구성하였다.

로그인

예약 요청

테스트 수행 결과

1차 최적화

(마지막 예약 저장 시간) - (첫 예약 저장 시간) = 24.215745(초)

평균 예약 저장 시간: 24.239(밀리초)

비관적 lock

(마지막 예약 저장 시간) - (첫 예약 저장 시간) = 17.751056(초)

평균 예약 저장 시간: 17.768(밀리초)

결과 분석

DB 저장 속도에서 차이가 나는 이유

똑같은 쿼리를 수행한다면 성능 차이가 사실상 나지 않을 것이라고 판단하고, 각각의 경우에 어떤 쿼리가 수행되는지 정리해보았다.

- 1차 최적화의 경우

SELECT member → UPDATE restaurant(즉시 flush) → clear context → SELECT restaurant → INSERT reservation

- 비관적 lock의 경우

SELECT member → SELECT restaurant FOR UPDATE → Dirty Checking UPDATE restaurant → INSERT reservation(이 시점에 flush)

비교해보니 1차 최적화 방식이 flush를 1번 더 많이 수행하고 clear 또한 수행하는 점이 눈에 걸렸다.

또한 각 방식 수행 시 실제 MySQL 쿼리가 어떤 식으로 생성되는지 p6spy를 통해 확인해보았다.

- 1차 최적화의 경우

    select m1_0.id,m1_0.created_at,m1_0.email,m1_0.is_deleted,m1_0.member_type,m1_0.name,m1_0.password,m1_0.updated_at 
    from member m1_0 
    where m1_0.email='test1@mail.com';
    
    select m1_0.id,m1_0.created_at,m1_0.email,m1_0.is_deleted,m1_0.member_type,m1_0.name,m1_0.password,m1_0.updated_at 
    from member m1_0 
    where m1_0.id=1;
    
    update restaurant r1_0 
    set remaining_table_count=(r1_0.remaining_table_count-1) 
    where r1_0.id=1 and r1_0.remaining_table_count>0;
    
    select r1_0.id,r1_0.address,r1_0.category,r1_0.created_at,r1_0.is_deleted,r1_0.max_table_count,r1_0.name,r1_0.member_id,r1_0.remaining_table_count,r1_0.updated_at 
    from restaurant r1_0 
    where r1_0.id=1;
    
    insert into reservation (created_at,is_deleted,member_id,reservation_time,restaurant_id,status,updated_at) 
    values ('2025-03-07T15:34:45.068+0900',false,1,'2025-03-18T18:30:00.000+0900',1,'CONFIRMED','2025-03-07T15:34:45.068+0900');

- 비관적 lock의 경우

    select m1_0.id,m1_0.created_at,m1_0.email,m1_0.is_deleted,m1_0.member_type,m1_0.name,m1_0.password,m1_0.updated_at 
    from member m1_0 
    where m1_0.email='test1@mail.com';
    
    select m1_0.id,m1_0.created_at,m1_0.email,m1_0.is_deleted,m1_0.member_type,m1_0.name,m1_0.password,m1_0.updated_at 
    from member m1_0 
    where m1_0.id=1;
    
    select r1_0.id,r1_0.address,r1_0.category,r1_0.created_at,r1_0.is_deleted,r1_0.max_table_count,r1_0.name,r1_0.member_id,r1_0.remaining_table_count,r1_0.updated_at 
    from restaurant r1_0 
    where r1_0.id=1 
    limit 1 for update;
    
    insert into reservation (created_at,is_deleted,member_id,reservation_time,restaurant_id,status,updated_at) 
    values ('2025-03-07T15:40:56.768+0900',false,1,'2025-03-18T18:30:00.000+0900',1,'CONFIRMED','2025-03-07T15:40:56.768+0900');
    
    update restaurant 
    set address='addr1',category='KOREAN',created_at='2025-02-11T10:00:00.000+0900',is_deleted=false,max_table_count=1001,name='restaurant1',member_id=2,remaining_table_count=1000,updated_at='2025-03-07T15:27:24.321+0900' 
    where id=1;

이 중에서 공통된 쿼리를 제외하고 각 쿼리들의 성능을 EXPLAIN_ANALYZE, performance_schema로 측정해보았다.

- 1차 최적화

![1차 최적화_update restaurant.png](attachment:fd8f29e6-b5f8-41a7-b09a-2acf66c584d3:1%EC%B0%A8_%EC%B5%9C%EC%A0%81%ED%99%94_update_restaurant.png)

![1차 최적화_select restaurant.png](attachment:9576cb21-b02d-49c6-80c8-5ca41ad6a1cc:1%EC%B0%A8_%EC%B5%9C%EC%A0%81%ED%99%94_select_restaurant.png)

- 비관적 lock

![비관적 lock_update restaurant.png](attachment:8abeabd3-11cb-4894-9c79-b106cf039997:%EB%B9%84%EA%B4%80%EC%A0%81_lock_update_restaurant.png)

![비관적 lock_select restaurant.png](attachment:958cdb6c-9b6c-4d2e-af72-0ab005a712a6:%EB%B9%84%EA%B4%80%EC%A0%81_lock_select_restaurant.png)

측정 결과 오히려 비관적 lock의 경우가 더 실행시간이 오래 걸리는 것으로 나타났다.

따라서 @Modifying(flushAutomatically = true, clearAutomatically = true) 로 인해 발생하는 영속성 컨텍스트 오버헤드가 크게 작용한다고 결론을 내릴 수 있었다.

개선 방안에 대한 고민

현재 @Modifying(flushAutomatically = true, clearAutomatically = true)는 JpaRepository 상에서 UPDATE 쿼리를 사용할 경우 영속성 컨텍스트를 무시하고 바로 DB에 쿼리를 전송하기에 DB와 컨텍스트를 동기화시키기 위해 도입하였다.

하지만 이러한 옵션들로 인해 해당 UPDATE 쿼리가 수행될 때 마다 컨텍스트 전체가 flush(), clear()되며 이로 인해 오버헤드 문제가 발생했다.

따라서 이러한 작업의 타겟을 특정 Restaurant 객체로 한정시켜 평균 응답 시간을 어느 정도 향상시켰다.

@Service
@RequiredArgsConstructor
public class RestaurantLockingService {
    private final RestaurantRepository restaurantRepository;

		// 컨텍스트 작업을 위해 EntityManager 객체를 직접 사용
    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    public boolean atomicDecreaseRemainingTableCount(Long restaurantId) {
        int updated = restaurantRepository.decreaseRemainingTableCount(restaurantId);

        // 수동으로 flush()를 호출해 DB와 동기화
        entityManager.flush();

        Restaurant restaurant = entityManager.find(Restaurant.class, restaurantId);
        if (restaurant != null && entityManager.contains(restaurant)) {
            // detach()로 해당 restaurant만 캐싱 해제
            entityManager.detach(restaurant);
        }

        return updated > 0;
    }
}

하지만 여전히 간단하게 적용 가능한 비관적 lock과 비슷해진 수준이기에, 추가적으로 최적화할 수 있는 방안들에 대해 고민해봐야 할 것 같다.

0개의 댓글

관련 채용 정보