diff --git a/README.md b/README.md index 81ea3c67..82101575 100644 --- a/README.md +++ b/README.md @@ -431,3 +431,60 @@ GlobalExceptionHandler 로그 추가 - 락 획득 실패 수 + +
+ 8주차 데이터베이스 심화: 트랜잭션과 쿼리 분석 + +#### 트랜잭션 전파 속성 조사해보기 + +| 전파 속성 | 의미 | 기존 TX가 있으면 | 기존 TX가 없으면 | 사용 예시 | +| --------------- | ------------------------- | --------------------- | -------------------- | ------------------------------ | +| `REQUIRED` | 기본값. 기존 트랜잭션에 참여하거나 새로 생성 | 기존 TX 참여 | 새 TX 생성 | 일반적인 서비스 로직 | +| `REQUIRES_NEW` | 항상 새 트랜잭션 생성 | 기존 TX 중단 후 새 TX 생성 | 새 TX 생성 | 로그 저장, 결제 결과 반영, 실패와 분리할 작업 | +| `SUPPORTS` | 있으면 참여, 없으면 없이 실행 | 기존 TX 참여 | TX 없이 실행 | 읽기 중심 로직 | +| `NOT_SUPPORTED` | 트랜잭션 없이 실행 | 기존 TX 중단 | TX 없이 실행 | 외부 API 호출, 오래 걸리는 작업 | +| `MANDATORY` | 반드시 기존 TX가 있어야 함 | 기존 TX 참여 | 예외 발생 | 상위 서비스 TX 안에서만 호출되어야 하는 내부 메서드 | +| `NEVER` | 트랜잭션이 있으면 안 됨 | 예외 발생 | TX 없이 실행 | 절대 TX 안에서 실행하면 안 되는 작업 | +| `NESTED` | 중첩 트랜잭션. savepoint 사용 | 기존 TX 안에 savepoint 생성 | `REQUIRED`처럼 새 TX 생성 | 일부 작업만 롤백하고 전체 TX는 유지하고 싶을 때 | + + +#### 트랜잭션 분석하기 + +| 기능 | 메서드 | 현재 TX 범위 | 외부 API 포함 여부 | 평가 | 개선안 | +| ----------- | ------------------------------------ | ------------- | --------------- | ----- | --------------- | +| 예매 생성 | `reserve()` | 전체 DB 작업 | 없음 | 적절 | 유지 | +| 예매 결제 | `payReservation()` | 없음 | TX 밖 | 적절 | 유지 | +| 결제 완료 반영 | `markPaid()` | DB 상태 변경만 | 없음 | 적절 | 유지 | +| 예매 삭제 | `deleteReservation()` | DB 삭제만 | 없음 | 적절 | 유지 | +| 예매 취소 | `cancel()` | 결제 취소 + DB 삭제 | TX 안에 외부 API 포함 | 개선 필요 | 결제 취소 TX 밖으로 분리 | +| 매점 주문 | `orderItems()` | 없음 | TX 밖 | 적절 | 유지 | +| 주문 생성/재고 차감 | `createOrderAndDecreaseStock()` | DB 작업 전체 | 없음 | 적절 | 유지 | +| 결제 실패/재고 복구 | `markPaymentFailedAndRestoreStock()` | DB 복구 작업 | 없음 | 적절 | 유지 | +| 매점 주문 취소 | `cancelOrder()` | DB 취소 처리만 분리 | TX 밖 | 적절 | 예매 취소도 이 방식 참고 | + +#### 인덱스 종류 조사해보기 + +| 인덱스 종류 | 설명 | 장점 | CGV 서비스 적용 예 | +| ---------- | -------------------- | ---------------------- | ------------------------------ | +| B-Tree 인덱스 | 정렬된 트리 구조의 기본 인덱스 | 동등 검색, 범위 검색, 정렬에 유리 | `reservation.screening_id` | +| 복합 인덱스 | 여러 컬럼을 묶은 인덱스 | 다중 조건 검색 최적화 | `(screening_id, seat_id)` | +| 유니크 인덱스 | 중복을 허용하지 않는 인덱스 | 중복 데이터 방지 | 같은 좌석 중복 예매 방지 | +| 커버링 인덱스 | 쿼리 필요 컬럼을 모두 포함한 인덱스 | 테이블 접근 없이 인덱스만으로 조회 가능 | `existsByScreeningIdAndSeatId` | +| 클러스터드 인덱스 | 데이터 자체가 인덱스 순서로 저장 | PK 조회 빠름 | `reservation.id` | +| 세컨더리 인덱스 | PK 외 컬럼에 적용한 인덱스 | 조건 조회 성능 개선 | `item_order.user_id` | +| 해시 인덱스 | 해시 기반 인덱스 | 정확히 일치하는 검색에 빠름 | 이메일, 토큰 검색 | +| 전문 검색 인덱스 | 긴 텍스트 검색용 인덱스 | 본문 검색에 유리 | 영화 설명, 리뷰 검색 | + + +#### 성능 최적화 해보기 (최소 3개) + +| 번호 | 최적화 | 상태 | 의미 | +| -- | ---------------------------------------- | | ------------------------ | +| 1 | `item_order(user_id, ordered_at)` 인덱스 추가 | 완료 | 사용자 주문 목록 조회 속도 개선 | +| 2 | 주문 목록 조회 N+1 제거 | 완료 | 쿼리 개수 감소 | +| 3 | 상품 가격 계산 반복 조회 제거 | 완료 | 반복 SELECT를 IN 조회 1번으로 감소 | + +EXPLAIN +query + +
\ No newline at end of file diff --git a/images/10-1_explain.png b/images/10-1_explain.png new file mode 100644 index 00000000..142cc013 Binary files /dev/null and b/images/10-1_explain.png differ diff --git a/images/10-2_query.png b/images/10-2_query.png new file mode 100644 index 00000000..d5a6f347 Binary files /dev/null and b/images/10-2_query.png differ diff --git a/spring-boot/src/main/java/com/ceos23/spring_boot/config/CacheConfig.java b/spring-boot/src/main/java/com/ceos23/spring_boot/config/CacheConfig.java index d7bdfe9e..aaefd0e4 100644 --- a/spring-boot/src/main/java/com/ceos23/spring_boot/config/CacheConfig.java +++ b/spring-boot/src/main/java/com/ceos23/spring_boot/config/CacheConfig.java @@ -2,25 +2,38 @@ import com.github.benmanes.caffeine.cache.Caffeine; import org.springframework.cache.CacheManager; -import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.cache.support.SimpleCacheManager; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.time.Duration; +import java.util.List; @Configuration public class CacheConfig { @Bean public CacheManager cacheManager() { - CaffeineCacheManager cacheManager = new CaffeineCacheManager("movies"); + CaffeineCache movieAllCache = new CaffeineCache( + "movieAll", + Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMinutes(10)) + .maximumSize(1) + .build() + ); - cacheManager.setCaffeine( + CaffeineCache movieCache = new CaffeineCache( + "movie", Caffeine.newBuilder() .expireAfterWrite(Duration.ofMinutes(10)) .maximumSize(100) + .build() ); + SimpleCacheManager cacheManager = new SimpleCacheManager(); + cacheManager.setCaches(List.of(movieAllCache, movieCache)); + return cacheManager; } } \ No newline at end of file diff --git a/spring-boot/src/main/java/com/ceos23/spring_boot/domain/ItemOrder.java b/spring-boot/src/main/java/com/ceos23/spring_boot/domain/ItemOrder.java index af773a74..41aea4dc 100644 --- a/spring-boot/src/main/java/com/ceos23/spring_boot/domain/ItemOrder.java +++ b/spring-boot/src/main/java/com/ceos23/spring_boot/domain/ItemOrder.java @@ -13,6 +13,9 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.PrePersist; +import jakarta.persistence.Index; +import jakarta.persistence.Table; + import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -24,6 +27,14 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + indexes = { + @Index( + name = "idx_item_order_user_ordered_at", + columnList = "user_id, ordered_at" + ) + } +) public class ItemOrder { @Id diff --git a/spring-boot/src/main/java/com/ceos23/spring_boot/repository/ItemOrderRepository.java b/spring-boot/src/main/java/com/ceos23/spring_boot/repository/ItemOrderRepository.java index 71084abf..fbdc82b8 100644 --- a/spring-boot/src/main/java/com/ceos23/spring_boot/repository/ItemOrderRepository.java +++ b/spring-boot/src/main/java/com/ceos23/spring_boot/repository/ItemOrderRepository.java @@ -2,10 +2,36 @@ import com.ceos23.spring_boot.domain.ItemOrder; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; +import java.util.Optional; public interface ItemOrderRepository extends JpaRepository { List findAllByUserId(Long userId); + + @Query(""" + select distinct io + from ItemOrder io + join fetch io.user + join fetch io.theater + left join fetch io.orderDetails od + left join fetch od.item + where io.id = :orderId + """) + Optional findWithDetailsById(@Param("orderId") Long orderId); + + @Query(""" + select distinct io + from ItemOrder io + join fetch io.user + join fetch io.theater + left join fetch io.orderDetails od + left join fetch od.item + where io.user.id = :userId + order by io.orderedAt desc + """) + List findAllWithDetailsByUserId(@Param("userId") Long userId); } \ No newline at end of file diff --git a/spring-boot/src/main/java/com/ceos23/spring_boot/service/ItemOrderService.java b/spring-boot/src/main/java/com/ceos23/spring_boot/service/ItemOrderService.java index ca8b4fa8..4350f8cb 100644 --- a/spring-boot/src/main/java/com/ceos23/spring_boot/service/ItemOrderService.java +++ b/spring-boot/src/main/java/com/ceos23/spring_boot/service/ItemOrderService.java @@ -105,7 +105,7 @@ private CustomException convertPaymentException(CustomException e) { public ItemOrderResponse getOrder(Long orderId) { validateId(orderId); - ItemOrder itemOrder = itemOrderRepository.findById(orderId) + ItemOrder itemOrder = itemOrderRepository.findWithDetailsById(orderId) .orElseThrow(() -> new CustomException(ErrorCode.ITEM_ORDER_NOT_FOUND)); return ItemOrderResponse.from(itemOrder); @@ -116,7 +116,7 @@ public List getOrdersByUser(Long userId) { validateId(userId); validateUserExists(userId); - return itemOrderRepository.findAllByUserId(userId).stream() + return itemOrderRepository.findAllWithDetailsByUserId(userId).stream() .map(ItemOrderResponse::from) .toList(); } diff --git a/spring-boot/src/main/java/com/ceos23/spring_boot/service/ItemOrderTransactionService.java b/spring-boot/src/main/java/com/ceos23/spring_boot/service/ItemOrderTransactionService.java index 37a74126..7928da16 100644 --- a/spring-boot/src/main/java/com/ceos23/spring_boot/service/ItemOrderTransactionService.java +++ b/spring-boot/src/main/java/com/ceos23/spring_boot/service/ItemOrderTransactionService.java @@ -42,12 +42,13 @@ public ItemOrder createOrderAndDecreaseStock(ItemOrderRequest request) { User user = loadUser(request.getUserId()); Theater theater = loadTheater(request.getTheaterId()); - int totalPrice = calculateTotalPrice(request); + Map itemMap = loadItemMap(request); + int totalPrice = calculateTotalPrice(request, itemMap); ItemOrder itemOrder = ItemOrder.of(user, theater, totalPrice); ItemOrder savedOrder = itemOrderRepository.saveAndFlush(itemOrder); - addOrderDetailsAndDecreaseStock(savedOrder, request); + addOrderDetailsAndDecreaseStock(savedOrder, request, itemMap); return savedOrder; } @@ -85,18 +86,22 @@ public ItemOrder cancelOrder(Long orderId) { return itemOrder; } - private int calculateTotalPrice(ItemOrderRequest request) { + private int calculateTotalPrice(ItemOrderRequest request, Map itemMap) { int totalPrice = 0; for (OrderItemRequest orderItemRequest : request.getItems()) { - Item item = loadItem(orderItemRequest.getItemId()); + Item item = getItemFromMap(itemMap, orderItemRequest.getItemId()); totalPrice += item.getPrice() * orderItemRequest.getCount(); } return totalPrice; } - private void addOrderDetailsAndDecreaseStock(ItemOrder savedOrder, ItemOrderRequest request) { + private void addOrderDetailsAndDecreaseStock( + ItemOrder savedOrder, + ItemOrderRequest request, + Map itemMap + ) { List sortedOrderItems = request.getItems().stream() .sorted(Comparator.comparing(OrderItemRequest::getItemId)) .toList(); @@ -114,7 +119,7 @@ private void addOrderDetailsAndDecreaseStock(ItemOrder savedOrder, ItemOrderRequ )); for (OrderItemRequest orderItemRequest : sortedOrderItems) { - Item item = loadItem(orderItemRequest.getItemId()); + Item item = getItemFromMap(itemMap, orderItemRequest.getItemId()); TheaterItemStock stock = stockMap.get(orderItemRequest.getItemId()); if (stock == null) { @@ -150,9 +155,33 @@ private Theater loadTheater(Long theaterId) { .orElseThrow(() -> new CustomException(ErrorCode.THEATER_NOT_FOUND)); } - private Item loadItem(Long itemId) { - return itemRepository.findById(itemId) - .orElseThrow(() -> new CustomException(ErrorCode.ITEM_NOT_FOUND)); + private Map loadItemMap(ItemOrderRequest request) { + List itemIds = request.getItems().stream() + .map(OrderItemRequest::getItemId) + .distinct() + .toList(); + + Map itemMap = itemRepository.findAllById(itemIds).stream() + .collect(Collectors.toMap( + Item::getId, + Function.identity() + )); + + if (itemMap.size() != itemIds.size()) { + throw new CustomException(ErrorCode.ITEM_NOT_FOUND); + } + + return itemMap; + } + + private Item getItemFromMap(Map itemMap, Long itemId) { + Item item = itemMap.get(itemId); + + if (item == null) { + throw new CustomException(ErrorCode.ITEM_NOT_FOUND); + } + + return item; } private TheaterItemStock loadStock(Long theaterId, Long itemId) { diff --git a/spring-boot/src/main/java/com/ceos23/spring_boot/service/MovieService.java b/spring-boot/src/main/java/com/ceos23/spring_boot/service/MovieService.java index ae2700a8..7703dcfb 100644 --- a/spring-boot/src/main/java/com/ceos23/spring_boot/service/MovieService.java +++ b/spring-boot/src/main/java/com/ceos23/spring_boot/service/MovieService.java @@ -18,19 +18,19 @@ public class MovieService { private final MovieRepository movieRepository; // 영화 생성 - @CacheEvict(value = "movies", allEntries = true) + @CacheEvict(value = "movieAll", allEntries = true) public Movie create(String title, String director) { return movieRepository.save(new Movie(title, director)); } // 전체 조회 - @Cacheable(value = "movies", key = "'all'") + @Cacheable(value = "movieAll", key = "'all'") public List findAll() { return movieRepository.findAll(); } // 단건 조회 - @Cacheable(value = "movies", key = "#id") + @Cacheable(value = "movie", key = "#id") public Movie findById(Long id) { return movieRepository.findById(id) .orElseThrow(() -> new CustomException(ErrorCode.MOVIE_NOT_FOUND)); diff --git a/spring-boot/src/main/java/com/ceos23/spring_boot/service/ReservationService.java b/spring-boot/src/main/java/com/ceos23/spring_boot/service/ReservationService.java index 2185fab5..b6bbe1a6 100644 --- a/spring-boot/src/main/java/com/ceos23/spring_boot/service/ReservationService.java +++ b/spring-boot/src/main/java/com/ceos23/spring_boot/service/ReservationService.java @@ -85,7 +85,6 @@ public Reservation payReservation(Long reservationId) { } } - @Transactional public void cancel(Long reservationId) { Reservation reservation = loadReservation(reservationId); @@ -93,7 +92,7 @@ public void cancel(Long reservationId) { paymentGateway.cancel(reservation.getPaymentId()); } - reservationRepository.delete(reservation); + reservationTransactionService.deleteReservation(reservationId); } private Reservation handlePaymentException(