Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,3 +431,60 @@ GlobalExceptionHandler 로그 추가
- 락 획득 실패 수

</details>

<details>
<summary>8주차 데이터베이스 심화: 트랜잭션과 쿼리 분석</summary>

#### 트랜잭션 전파 속성 조사해보기

| 전파 속성 | 의미 | 기존 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번으로 감소 |
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인덱스 뿐 아니라 다양한 방법으로 쿼리 성능을 개선한게 인상깊었습니다~!


<img src="/images/10-1_explain.png" alt="EXPLAIN">
<img src="/images/10-2_query.png" alt="query">

</details>
Binary file added images/10-1_explain.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added images/10-2_query.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ItemOrder, Long> {

List<ItemOrder> 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<ItemOrder> 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<ItemOrder> findAllWithDetailsByUserId(@Param("userId") Long userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -116,7 +116,7 @@ public List<ItemOrderResponse> getOrdersByUser(Long userId) {
validateId(userId);
validateUserExists(userId);

return itemOrderRepository.findAllByUserId(userId).stream()
return itemOrderRepository.findAllWithDetailsByUserId(userId).stream()
.map(ItemOrderResponse::from)
.toList();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ public ItemOrder createOrderAndDecreaseStock(ItemOrderRequest request) {
User user = loadUser(request.getUserId());
Theater theater = loadTheater(request.getTheaterId());

int totalPrice = calculateTotalPrice(request);
Map<Long, Item> 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;
}
Expand Down Expand Up @@ -85,18 +86,22 @@ public ItemOrder cancelOrder(Long orderId) {
return itemOrder;
}

private int calculateTotalPrice(ItemOrderRequest request) {
private int calculateTotalPrice(ItemOrderRequest request, Map<Long, Item> 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<Long, Item> itemMap
) {
List<OrderItemRequest> sortedOrderItems = request.getItems().stream()
.sorted(Comparator.comparing(OrderItemRequest::getItemId))
.toList();
Expand All @@ -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) {
Expand Down Expand Up @@ -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<Long, Item> loadItemMap(ItemOrderRequest request) {
List<Long> itemIds = request.getItems().stream()
.map(OrderItemRequest::getItemId)
.distinct()
.toList();

Map<Long, Item> 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<Long, Item> 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Movie> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,15 +85,14 @@ public Reservation payReservation(Long reservationId) {
}
}

@Transactional
public void cancel(Long reservationId) {
Reservation reservation = loadReservation(reservationId);

if (reservation.isPaid()) {
paymentGateway.cancel(reservation.getPaymentId());
}

reservationRepository.delete(reservation);
reservationTransactionService.deleteReservation(reservationId);
}

private Reservation handlePaymentException(
Expand Down