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번으로 감소 |
+
+
+
+
+
\ 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(