Skip to content

DB Optimization

Sechang Jang edited this page Feb 20, 2026 · 1 revision

DB ์„ฑ๋Šฅ ์ตœ์ ํ™”

์ด ๋ฌธ์„œ์—์„œ ๋‹ค๋ฃจ๋Š” ๊ฒƒ

ํ˜„์žฌ ๋ฌธ์„œ์˜ ์˜๋„๋Š” "์–ด๋–ป๊ฒŒ ์ตœ์ ํ™”ํ–ˆ๋Š”๊ฐ€"์˜ ๊ฒฐ๋ก ์ด ์•„๋‹ˆ๋ผ "๋ฌด์—‡์„ ๋ฐœ๊ฒฌํ•˜๊ณ , ์™œ ๊ทธ ๊ฒฐ์ •์„ ๋‚ด๋ ธ๋Š”๊ฐ€"์˜ ๊ณผ์ •์ž…๋‹ˆ๋‹ค. ์‹ค์ œ EXPLAIN ANALYZE ๊ฒฐ๊ณผ์™€ ํŠธ๋ ˆ์ด๋“œ์˜คํ”„ ํŒ๋‹จ ๊ทผ๊ฑฐ๋ฅผ ๊ทธ๋Œ€๋กœ ๋‚จ๊น๋‹ˆ๋‹ค.


1. ์ธก์ • ๋„๊ตฌ ๊ตฌ์„ฑ

์„ธ ๋„๊ตฌ๋ฅผ ๋ชฉ์ ์— ๋”ฐ๋ผ ๊ตฌ๋ถ„ํ–ˆ์Šต๋‹ˆ๋‹ค.

WAS ์ฝ˜์†” ๋กœ๊ทธ (spring.jpa.show-sql=true): Spring Boot ์‹คํ–‰ ์ค‘ ํ„ฐ๋ฏธ๋„์— ์ถœ๋ ฅ๋˜๋Š” SQL ๋กœ๊ทธ์ž…๋‹ˆ๋‹ค. N+1์ฒ˜๋Ÿผ ์˜ˆ์ƒ๋ณด๋‹ค ๋งŽ์€ ์ฟผ๋ฆฌ๊ฐ€ ๋ฐ˜๋ณต ์‹คํ–‰๋˜๋Š” ๊ฒƒ์„ ์ฒ˜์Œ ๋ฐœ๊ฒฌํ•  ๋•Œ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ณ„๋„ ์„ค์ • ์—†์ด ์ฆ‰์‹œ ํ™•์ธํ•  ์ˆ˜ ์žˆ๋Š” ๊ฐ€์žฅ ๋น ๋ฅธ ์ˆ˜๋‹จ์ž…๋‹ˆ๋‹ค.

EXPLAIN ANALYZE: "์ฟผ๋ฆฌ๊ฐ€ DB ๋‚ด๋ถ€์—์„œ ์–ด๋–ป๊ฒŒ ์‹คํ–‰๋˜๋Š”๊ฐ€"๋ฅผ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค. Seq Scan(Full Scan)์ธ์ง€ Index Scan์ธ์ง€, Sort ๋…ธ๋“œ๊ฐ€ ๋ฐœ์ƒํ•˜๋Š”์ง€, ์–ด๋””์„œ ๋น„์šฉ์ด ์ง‘์ค‘๋˜๋Š”์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค. BEGIN ~ ROLLBACK ์•ˆ์—์„œ ์‹คํ–‰ํ•ด ์‹ค์ œ ๋ฐ์ดํ„ฐ ๋ณ€๊ฒฝ ์—†์ด ์ธก์ •ํ•ฉ๋‹ˆ๋‹ค.

P6Spy: SQL ์‹คํ–‰ ์ด๋ ฅ์„ ํŒŒ์ผ๋กœ ๊ธฐ๋กํ•ฉ๋‹ˆ๋‹ค. Grafana + Prometheus์™€ ์—ฐ๊ณ„ํ•ด ์‹œ๊ฐ„ ํ๋ฆ„์— ๋”ฐ๋ฅธ ์ฟผ๋ฆฌ ํŒจํ„ด์„ ๋ฆฌํฌํŠธ ํ˜•ํƒœ๋กœ ๋‚จ๊น๋‹ˆ๋‹ค. ๊ฐœ๋ฐœ ๋‹จ๊ณ„์˜ ์ž„์‹œ ์ฝ˜์†” ๋กœ๊ทธ์™€ ๋‹ฌ๋ฆฌ ์šด์˜ ํ™˜๊ฒฝ ๋ชจ๋‹ˆํ„ฐ๋ง ์šฉ๋„์ž…๋‹ˆ๋‹ค.


2. N+1 ๋ฌธ์ œ: Reservation ๋ชฉ๋ก ์กฐํšŒ

๋ฐœ๊ฒฌ (WAS ์ฝ˜์†” ๋กœ๊ทธ)

์˜ˆ์•ฝ ๋ชฉ๋ก API๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด ๋‹จ๊ฑด ์กฐํšŒ ์˜ˆ์•ฝ ์ˆ˜๋งŒํผ ์ถ”๊ฐ€ SELECT๊ฐ€ ํ„ฐ๋ฏธ๋„์— ์ถœ๋ ฅ๋์Šต๋‹ˆ๋‹ค. Reservation 10๊ฑด์„ ์กฐํšŒํ•˜๋ฉด Business 10๋ฒˆ, Menu 10๋ฒˆ, BusinessCategory 10๋ฒˆ, ์ด 30๋ฒˆ์˜ ์ถ”๊ฐ€ ์ฟผ๋ฆฌ๊ฐ€ ์—ฐ์‡„ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.

// Before โ€” ๊ธฐ๋ณธ findAll ์ดํ›„ ์—ฐ๊ด€ ์—”ํ‹ฐํ‹ฐ ์ ‘๊ทผ๋งˆ๋‹ค ์ฟผ๋ฆฌ ๋ฐœ์ƒ
Page<Reservation> reservations = reservationRepository.findAll(pageable);  // ์ฟผ๋ฆฌ 1๋ฒˆ

for (Reservation r : reservations) {
    r.getBusiness().getBusinessName();   // Business ์ฟผ๋ฆฌ N๋ฒˆ (LAZY ๋กœ๋”ฉ)
    r.getMenu().getServiceName();        // Menu ์ฟผ๋ฆฌ N๋ฒˆ
    r.getMenu().getBusinessCategory();   // BusinessCategory ์ฟผ๋ฆฌ N๋ฒˆ
}
// ์˜ˆ์•ฝ 1,000๊ฑด ์กฐํšŒ โ†’ 1 + 1,000 + 1,000 + 1,000 = 3,001๋ฒˆ
// ์˜ˆ์•ฝ 10๊ฑด ๋‹จ์ˆœ ์ผ€์ด์Šค๋„ โ†’ 1 + 10 + 10 + 10 = 31๋ฒˆ

JPA์˜ ๊ธฐ๋ณธ ์—ฐ๊ด€๊ด€๊ณ„ ์ „๋žต์€ FetchType.LAZY์ž…๋‹ˆ๋‹ค. Reservation ์—”ํ‹ฐํ‹ฐ๋ฅผ ๋กœ๋“œํ•ด๋„ Business, Menu๋Š” ํ”„๋ก์‹œ ์ƒํƒœ๋กœ ๋Œ€๊ธฐํ•˜๋‹ค๊ฐ€ ์‹ค์ œ ํ•„๋“œ์— ์ ‘๊ทผํ•˜๋Š” ์ˆœ๊ฐ„ ๊ฐ์ž SELECT๋ฅผ ์‹คํ–‰ํ•ฉ๋‹ˆ๋‹ค.

ํ•ด๊ฒฐ: QueryDSL fetchJoin์œผ๋กœ ๋‹จ์ผ ์ฟผ๋ฆฌ

// After โ€” ์—ฐ๊ด€ ์—”ํ‹ฐํ‹ฐ๋ฅผ JOIN์œผ๋กœ ํ•œ ๋ฒˆ์— ๋กœ๋“œ
List<Reservation> reservations = queryFactory
    .selectFrom(reservation)
    .join(reservation.business, business).fetchJoin()        // Business ํ•จ๊ป˜ ๋กœ๋“œ
    .join(reservation.menu, menu).fetchJoin()                // Menu ํ•จ๊ป˜ ๋กœ๋“œ
    .join(menu.businessCategory, businessCategory).fetchJoin() // BusinessCategory ํ•จ๊ป˜ ๋กœ๋“œ
    .where(builder)
    .offset(pageable.getOffset())
    .limit(pageable.getPageSize())
    .orderBy(getOrderSpecifiers(pageable.getSort()))
    .fetch();
// ์˜ˆ์•ฝ N๊ฑด ์กฐํšŒ โ†’ ์ฟผ๋ฆฌ 1๋ฒˆ (JOIN์œผ๋กœ ๋ชจ๋‘ ํฌํ•จ)

fetchJoin()์€ ์ผ๋ฐ˜ JOIN๊ณผ ๋‹ค๋ฆ…๋‹ˆ๋‹ค. ์ผ๋ฐ˜ JOIN์€ ์กฐ๊ฑด ํ•„ํ„ฐ ์šฉ๋„๋กœ๋งŒ ์“ฐ๊ณ  ์—ฐ๊ด€ ์—”ํ‹ฐํ‹ฐ๋ฅผ ์˜์†์„ฑ ์ปจํ…์ŠคํŠธ์— ๋กœ๋“œํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. fetchJoin()์€ JOIN ๊ฒฐ๊ณผ๋ฅผ ์—ฐ๊ด€ ์—”ํ‹ฐํ‹ฐ๊นŒ์ง€ ํ•จ๊ป˜ ์˜์†์„ฑ ์ปจํ…์ŠคํŠธ์— ์˜ฌ๋ฆฌ๋ฏ€๋กœ, ์ดํ›„ r.getBusiness() ์ ‘๊ทผ ์‹œ ์ถ”๊ฐ€ ์ฟผ๋ฆฌ๊ฐ€ ๋ฐœ์ƒํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

Before After
์ฟผ๋ฆฌ ํšŸ์ˆ˜ (์˜ˆ์•ฝ 1,000๊ฑด) 1 + 3,000 = 3,001๋ฒˆ 1๋ฒˆ
์ฟผ๋ฆฌ ํšŸ์ˆ˜ (์˜ˆ์•ฝ 10๊ฑด) 31๋ฒˆ 1๋ฒˆ

๊ณ ๊ฐ์šฉ findMyReservationsWithFilters, ์—…์ฒด์šฉ findBusinessReservationsWithFilters ๋‘ ์ฟผ๋ฆฌ ๋ฉ”์„œ๋“œ ๋ชจ๋‘ ๋™์ผํ•œ ํŒจํ„ด์œผ๋กœ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. ํŽ˜์ด์ง• COUNT ์ฟผ๋ฆฌ๋Š” ์—ฐ๊ด€ ์—”ํ‹ฐํ‹ฐ ๋กœ๋“œ๊ฐ€ ํ•„์š” ์—†์œผ๋ฏ€๋กœ selectFrom(reservation).where(builder).fetchOne()์œผ๋กœ ๋ถ„๋ฆฌํ•ด ๋ถˆํ•„์š”ํ•œ JOIN์„ ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.


3. N+1 ๋ฌธ์ œ: Menu ์‚ญ์ œ ์‹œ BookingSlot ์ฒ˜๋ฆฌ

๋ฐœ๊ฒฌ (WAS ์ฝ˜์†” ๋กœ๊ทธ)

Menu ์‚ญ์ œ API๋ฅผ ํ˜ธ์ถœํ–ˆ์„ ๋•Œ ํ„ฐ๋ฏธ๋„์— SELECT ์ฟผ๋ฆฌ๊ฐ€ ๋ฃจํ”„๋กœ ๋ฐ˜๋ณต ์ถœ๋ ฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๊ฐ ์Šฌ๋กฏ๋งˆ๋‹ค "์˜ˆ์•ฝ์ด ์žˆ๋Š”์ง€" ํ™•์ธํ•˜๋Š” ์ฟผ๋ฆฌ๊ฐ€ ์Šฌ๋กฏ ์ˆ˜๋งŒํผ ์‹คํ–‰๋˜๊ณ  ์žˆ์—ˆ์Šต๋‹ˆ๋‹ค.

// Before โ€” ๋ฃจํ”„ ์•ˆ์—์„œ ์ฟผ๋ฆฌ ๋ฐ˜๋ณต
List<BookingSlot> slots = bookingSlotRepository.findByMenuId(menuId);  // ์ฟผ๋ฆฌ 1๋ฒˆ

for (BookingSlot slot : slots) {
    boolean hasReservation = reservationRepository.existsByBookingSlot(slot); // ์Šฌ๋กฏ๋งˆ๋‹ค ์ฟผ๋ฆฌ
    if (!hasReservation) {
        bookingSlotRepository.delete(slot);  // ์Šฌ๋กฏ๋งˆ๋‹ค DELETE
    }
}
// ์Šฌ๋กฏ 100๊ฐœ โ†’ ์ฟผ๋ฆฌ 101๋ฒˆ, ์•ฝ 2์ดˆ

ํ•ด๊ฒฐ: 3๋ฒˆ ๊ณ ์ • ์ฟผ๋ฆฌ

// After โ€” ์ง‘ํ•ฉ ์—ฐ์‚ฐ์œผ๋กœ ์žฌ์„ค๊ณ„
@Transactional(propagation = Propagation.MANDATORY)
public int deleteSlotsForMenu(UUID businessId, UUID menuId) {

    // ์ฟผ๋ฆฌ 1: ์Šฌ๋กฏ ID ๋ชฉ๋ก ์ „์ฒด ์กฐํšŒ
    List<BookingSlot> allSlots = bookingSlotRepository
            .findByBusinessIdAndMenuId(businessId, menuId);
    if (allSlots.isEmpty()) return 0;

    // ์ฟผ๋ฆฌ 2: ์˜ˆ์•ฝ์ด ์žˆ๋Š” ์Šฌ๋กฏ ID๋ฅผ IN ์ ˆ๋กœ ํ•œ ๋ฒˆ์— ์กฐํšŒ
    Set<UUID> occupiedSlotIds = new HashSet<>(
            reservationRepository.findSlotIdsWithActiveReservations(
                allSlots.stream().map(BookingSlot::getId).toList()
            )
    );

    // ๋ฉ”๋ชจ๋ฆฌ ํ•„ํ„ฐ๋ง (DB I/O ์—†์Œ)
    List<BookingSlot> deletableSlots = allSlots.stream()
            .filter(slot -> !occupiedSlotIds.contains(slot.getId()))
            .toList();

    // ์ฟผ๋ฆฌ 3: ์‚ญ์ œ ๊ฐ€๋Šฅํ•œ ์Šฌ๋กฏ ์ผ๊ด„ ์‚ญ์ œ
    bookingSlotRepository.deleteAll(deletableSlots);
    return deletableSlots.size();
}
Before After
์ฟผ๋ฆฌ ํšŸ์ˆ˜ ์Šฌ๋กฏ 100๊ฐœ โ†’ 101๋ฒˆ 3๋ฒˆ (๊ณ ์ •)
์‹คํ–‰ ์‹œ๊ฐ„ ์•ฝ 2์ดˆ 50ms ์ดํ•˜

์„ค๊ณ„ ์›์น™: Reservation์€ ์˜ˆ์•ฝ ์‹œ์ ์˜ ์Šค๋ƒ…์ƒท์ด๋ฏ€๋กœ ์˜๊ตฌ ๋ณด์กดํ•ฉ๋‹ˆ๋‹ค. ์˜ˆ์•ฝ ๋ ˆ์ฝ”๋“œ๊ฐ€ ํ•˜๋‚˜๋ผ๋„ ์žˆ๋Š” ์Šฌ๋กฏ์€ ์‚ญ์ œํ•˜์ง€ ์•Š์œผ๋ฉฐ, FK ์ œ์•ฝ์กฐ๊ฑด์ด DB ๋ ˆ๋ฒจ์—์„œ๋„ ์ด๋ฅผ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.


4. BookingSlot ์ธ๋ฑ์Šค ์„ค๊ณ„

3-1. ์ดˆ์•ˆ EXPLAIN์—์„œ ๋ฌธ์ œ ๋ฐœ๊ฒฌ

์†Œ๋Ÿ‰ ๋ฐ์ดํ„ฐ(300๊ฑด)๋กœ ๋จผ์ € EXPLAIN์„ ์‹คํ–‰ํ–ˆ์Šต๋‹ˆ๋‹ค.

-- ๋‚ ์งœ๋ณ„ ์Šฌ๋กฏ ์กฐํšŒ: WHERE business_id = ? AND slot_date = ? AND is_available = true
-- ์ธ๋ฑ์Šค ์—†์Œ, ๋ฐ์ดํ„ฐ 300๊ฑด

Sort  (cost=24.41..24.41 rows=1 width=85) (actual time=0.085..0.087 rows=10 loops=1)
  Sort Key: start_time
  Sort Method: quicksort  Memory: 26kB
  ->  Seq Scan on booking_slot  (cost=0.00..24.40 rows=1 width=85) (actual time=0.027..0.076 rows=10 loops=1)
        Filter: (is_available AND (business_id = '...') AND (slot_date = ...))
        Rows Removed by Filter: 290
Planning Time: 0.091 ms
Execution Time: 0.103 ms

์„ธ ๊ฐ€์ง€ ๋ฌธ์ œ๋ฅผ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.

Seq Scan + Filter: 300๊ฑด ์ „์ฒด๋ฅผ ์ฝ์€ ํ›„์— ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•ฉ๋‹ˆ๋‹ค. 10๊ฑด์ด ํ•„์š”ํ•œ๋ฐ 290๊ฑด์„ ์ฝ๊ณ  ๋ฒ„๋ ธ์Šต๋‹ˆ๋‹ค(๋‚ญ๋น„์œจ 97%). ๋ฐ์ดํ„ฐ๊ฐ€ ๋Š˜์–ด๋‚ ์ˆ˜๋ก ์ด ๋‚ญ๋น„๊ฐ€ ์„ ํ˜•์œผ๋กœ ์ฆ๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.

Sort ๋…ธ๋“œ ๋ฐœ์ƒ: ORDER BY start_time์„ ์ฒ˜๋ฆฌํ•˜๊ธฐ ์œ„ํ•ด quicksort 26KB๋ฅผ ๋ณ„๋„๋กœ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์ธ๋ฑ์Šค ์ปฌ๋Ÿผ ์ˆœ์„œ์™€ ์ •๋ ฌ ์ˆœ์„œ๊ฐ€ ์ผ์น˜ํ•˜๋ฉด ์ด ๋…ธ๋“œ ์ž์ฒด๊ฐ€ ์‚ฌ๋ผ์ง‘๋‹ˆ๋‹ค.

rows=1 ์˜ˆ์ƒ vs 10 ์‹ค์ œ: Planner ํ†ต๊ณ„๊ฐ€ ์˜ค๋ž˜๋˜์–ด ์˜ˆ์ธก์ด ํฌ๊ฒŒ ๋น—๋‚˜๊ฐ”์Šต๋‹ˆ๋‹ค. ANALYZE booking_slot ์‹คํ–‰์œผ๋กœ ํ†ต๊ณ„๋ฅผ ๊ฐฑ์‹ ํ–ˆ์Šต๋‹ˆ๋‹ค.


3-2. ์ธ๋ฑ์Šค ์ถ”๊ฐ€ ํ›„ ์†Œ๋Ÿ‰ ๊ฒ€์ฆ

-- ์ธ๋ฑ์Šค ์ถ”๊ฐ€ ํ›„, ๋™์ผ ์ฟผ๋ฆฌ (300๊ฑด)

Index Scan using idx_booking_slot_business_date_time on booking_slot
  (cost=0.28..8.30 rows=1 width=85) (actual time=0.015..0.022 rows=10 loops=1)
  Index Cond: ((business_id = '...') AND (slot_date = ...))
  Filter: is_available
  Buffers: shared hit=6
Planning Time: 0.349 ms
Execution Time: 0.098 ms

Filter๊ฐ€ Index Cond๋กœ ์ „ํ™˜๋˜์—ˆ๊ณ  Sort ๋…ธ๋“œ๊ฐ€ ์‚ฌ๋ผ์กŒ์Šต๋‹ˆ๋‹ค. is_available๋งŒ Filter๋กœ ๋‚จ์•„ ์žˆ๋Š”๋ฐ, ์ด๋Š” ์ธ๋ฑ์Šค์— ํฌํ•จํ•˜์ง€ ์•Š๊ธฐ๋กœ ๊ฒฐ์ •ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ด์œ : ๋Œ€๋ถ€๋ถ„์˜ ์Šฌ๋กฏ์ด is_available = true ์ƒํƒœ์—ฌ์„œ ์„ ํƒ๋„๊ฐ€ ๋‚ฎ๊ณ , Filter๋กœ ์ฒ˜๋ฆฌํ•ด๋„ ์˜ค๋ฒ„ํ—ค๋“œ๊ฐ€ ๋ฏธ๋ฏธํ•ฉ๋‹ˆ๋‹ค. ์ถ”๊ฐ€ ์‹œ INSERT๋งˆ๋‹ค ์ธ๋ฑ์Šค ๊ฐฑ์‹  ๋น„์šฉ์ด ๋Š˜์–ด๋‚˜๋Š” ๊ฒƒ์ด ๋” ๋ถˆ๋ฆฌํ•ฉ๋‹ˆ๋‹ค.


3-3. ๋ฉ”๋‰ด๋ณ„ ์กฐํšŒ์—์„œ ๋‘ ๋ฒˆ์งธ ๋ฌธ์ œ ๋ฐœ๊ฒฌ

๊ธฐ๊ฐ„๋ณ„ ์กฐํšŒ๊ฐ€ Index Scan์œผ๋กœ ์ž˜ ๋™์ž‘ํ•˜๋Š” ๊ฒƒ์„ ํ™•์ธํ•œ ๋’ค, ๋ฉ”๋‰ด๋ณ„ ์กฐํšŒ๋ฅผ ์‹คํ–‰ํ–ˆ์Šต๋‹ˆ๋‹ค.

-- ๋ฉ”๋‰ด๋ณ„ ์Šฌ๋กฏ ์กฐํšŒ: WHERE menu_id = ? AND slot_date >= ? AND is_available = true
-- ์ธ๋ฑ์Šค: (business_id, slot_date, start_time), ๋ฐ์ดํ„ฐ 300๊ฑด

Sort  (cost=20.35..20.36 rows=1 width=85) (actual time=0.276..0.288 rows=300 loops=1)
  Sort Key: slot_date, start_time
  Sort Method: quicksort  Memory: 60kB
  ->  Seq Scan on booking_slot
        Filter: (is_available AND (menu_id = '...') AND (slot_date >= CURRENT_DATE))
Execution Time: 0.320 ms

๋‹ค์‹œ Seq Scan + Sort์ž…๋‹ˆ๋‹ค. ์›์ธ์€ B-tree ์ธ๋ฑ์Šค์˜ Leading ์ปฌ๋Ÿผ ๊ทœ์น™์ž…๋‹ˆ๋‹ค. (business_id, slot_date, start_time) ์ธ๋ฑ์Šค๋Š” business_id ์กฐ๊ฑด ์—†์ด๋Š” ์‚ฌ์šฉ์ด ๋ถˆ๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. ๋ฉ”๋‰ด๋ณ„ ์กฐํšŒ๋Š” menu_id๊ฐ€ ํ•„ํ„ฐ ๊ธฐ์ค€์ด๋ฏ€๋กœ ๊ธฐ์กด ์ธ๋ฑ์Šค๊ฐ€ ์ž‘๋™ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

์ธ๋ฑ์Šค๋ฅผ ๋‘ ๊ฐœ๋กœ ๋ถ„๋ฆฌํ•˜๊ธฐ๋กœ ๊ฒฐ์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.

@Table(name = "booking_slot", indexes = {
    // ํŒจํ„ด 1: ์—…์ฒด ๊ธฐ๋ฐ˜ ์กฐํšŒ (๋‹ฌ๋ ฅ ํ™”๋ฉด)
    // WHERE business_id = ? AND slot_date = ? or BETWEEN
    // ORDER BY slot_date, start_time
    @Index(name = "idx_booking_slot_business_date_time",
           columnList = "business_id, slot_date, start_time"),

    // ํŒจํ„ด 2: ๋ฉ”๋‰ด ๊ธฐ๋ฐ˜ ์กฐํšŒ (์˜ˆ์•ฝ ๊ฐ€๋Šฅ ์‹œ๊ฐ„ ์กฐํšŒ)
    // WHERE menu_id = ? AND slot_date >= ?
    // ORDER BY slot_date, start_time
    @Index(name = "idx_booking_slot_menu_date_time",
           columnList = "menu_id, slot_date, start_time")
})

๋‹จ์ผ ์ธ๋ฑ์Šค๋กœ ํ†ตํ•ฉ์ด ๋ถˆ๊ฐ€ํ•œ ์ด์œ : (business_id, menu_id, slot_date, start_time) ๊ฐ™์€ ๊ตฌ์„ฑ์€ business_id ์—†๋Š” ์ฟผ๋ฆฌ์—์„œ ์—ฌ์ „ํžˆ ์‚ฌ์šฉ ๋ถˆ๊ฐ€์ž…๋‹ˆ๋‹ค. B-tree ์ธ๋ฑ์Šค๋Š” Leading ์ปฌ๋Ÿผ๋ถ€ํ„ฐ ์ˆœ์„œ๋Œ€๋กœ ๋งค์นญํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋‘ ์ฟผ๋ฆฌ ํŒจํ„ด์˜ ์ฒซ ๋ฒˆ์งธ ์กฐ๊ฑด์ด ๋‹ค๋ฅด๋ฏ€๋กœ ๋ถ„๋ฆฌ๊ฐ€ ๋งž์Šต๋‹ˆ๋‹ค.

ํŠธ๋ ˆ์ด๋“œ์˜คํ”„: INSERT ์‹œ ์ธ๋ฑ์Šค 2๊ฐœ๋ฅผ ๊ฐฑ์‹ ํ•ด์•ผ ํ•˜๋ฏ€๋กœ ์“ฐ๊ธฐ ๋น„์šฉ์ด ์•ฝ 10~20% ์ฆ๊ฐ€ํ•ฉ๋‹ˆ๋‹ค. BookingSlot์€ ์Šฌ๋กฏ ์ƒ์„ฑ ์‹œ ์ผ๊ด„ INSERTํ•˜๊ณ  ์ดํ›„ ์ฃผ๋กœ ์ฝ๊ธฐ๋งŒ ๋ฐœ์ƒํ•˜๋Š” ํŒจํ„ด์ด๋ฏ€๋กœ, ์ฝ๊ธฐ ์„ฑ๋Šฅ ๊ฐœ์„ ์ด ์ด ๋น„์šฉ์„ ์ถฉ๋ถ„ํžˆ ์ •๋‹นํ™”ํ•ฉ๋‹ˆ๋‹ค.

์ธ๋ฑ์Šค ์ถ”๊ฐ€ ํ›„ ์†Œ๋Ÿ‰ ๊ฒ€์ฆ (300๊ฑด)

-- idx_booking_slot_menu_date_time ์ถ”๊ฐ€ ํ›„

Index Scan using idx_booking_slot_menu_date_time on booking_slot
  (cost=0.28..8.30 rows=1 width=85) (actual time=0.026..0.307 rows=300 loops=1)
  Index Cond: ((menu_id = '...') AND (slot_date >= CURRENT_DATE))
  Filter: is_available
  Buffers: shared hit=609
Execution Time: 0.373 ms

Seq Scan์ด Index Scan์œผ๋กœ ์ „ํ™˜๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๊ทธ๋Ÿฐ๋ฐ ๊ฐ™์€ ์ฟผ๋ฆฌ๋ฅผ ์—ฌ๋Ÿฌ ๋ฒˆ ์‹คํ–‰ํ•˜์ž ์‹คํ–‰ ๊ณ„ํš์ด ์ผ์ •ํ•˜์ง€ ์•Š์€ ํ˜„์ƒ์ด ๋ฐœ๊ฒฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

ํ”Œ๋žœ ๋ถˆ์•ˆ์ •์„ฑ ๊ด€์ฐฐ (๋™์ผ ์ฟผ๋ฆฌ 5ํšŒ ์‹คํ–‰)

์‹คํ–‰ ํ”Œ๋žœ Buffers Execution Time
1 Index Scan 609 0.278 ms
2 Bitmap + Sort 26 0.842 ms
3 Bitmap + Sort 24 0.244 ms
4 Bitmap + Sort 28 0.557 ms
5 Bitmap + Sort 32 0.473 ms

๊ฐ™์€ ์ธ๋ฑ์Šค๋ฅผ ์‚ฌ์šฉํ•˜์ง€๋งŒ Planner๊ฐ€ Index Scan๊ณผ Bitmap Index Scan์„ ๋ฒˆ๊ฐˆ์•„ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค. Bitmap Scan ์„ ํƒ ์‹œ Sort ๋…ธ๋“œ๊ฐ€ ์ถ”๊ฐ€๋กœ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.

์›์ธ์€ ๋ฉ”๋ชจ๋ฆฌ ์ƒํƒœ์ž…๋‹ˆ๋‹ค. Index Scan์€ ์ธ๋ฑ์Šค ์ˆœ์„œ๋Œ€๋กœ ์ฝ์–ด Sort๊ฐ€ ๋ถˆํ•„์š”ํ•˜์ง€๋งŒ Buffers๋ฅผ 609ํŽ˜์ด์ง€ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. Bitmap Scan์€ heap ๋ฌผ๋ฆฌ ์ˆœ์„œ๋กœ ์ฝ์–ด Buffers๋ฅผ 24~32ํŽ˜์ด์ง€๋งŒ ์‚ฌ์šฉํ•˜์ง€๋งŒ ์ •๋ ฌ ์ˆœ์„œ๋ฅผ ๋ณด์žฅํ•˜์ง€ ์•Š์•„ Sort๊ฐ€ ์ถ”๊ฐ€๋ฉ๋‹ˆ๋‹ค. Planner๊ฐ€ ๋ฉ”๋ชจ๋ฆฌ ์—ฌ์œ ์— ๋”ฐ๋ผ ๋‘ ํ”Œ๋žœ์„ ์˜ค๊ฐ‘๋‹ˆ๋‹ค.

์„ฑ๋Šฅ ๋ฒ”์œ„๋Š” 0.244~0.842ms์ž…๋‹ˆ๋‹ค. ๋ชฉํ‘œ(< 100ms) ๋Œ€๋น„ 100๋ฐฐ ์ด์ƒ ์—ฌ์œ ๊ฐ€ ์žˆ์–ด ํ”Œ๋žœ ๋ถˆ์•ˆ์ •์„ฑ ์ž์ฒด๋Š” ๋ฌธ์ œ๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค. ์˜คํžˆ๋ ค Planner๊ฐ€ ๋ฉ”๋ชจ๋ฆฌ ์ƒํƒœ์— ๋”ฐ๋ผ ์ ์‘์ ์œผ๋กœ ์„ ํƒํ•˜๊ณ  ์žˆ๋‹ค๊ณ  ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.


3-4. ๋น…์Šค์ผ€์ผ(300๋งŒ๊ฑด) ๊ฒ€์ฆ ๋ฐ LIMIT ํšจ๊ณผ

ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ ์‚ฐ์ •:

BookingSlot = Reservation(100๋งŒ) รท ์˜ˆ์•ฝ๋ฅ (35%) โ‰ˆ 285๋งŒ โ†’ 300๋งŒ
๊ตฌ์„ฑ: ์นดํ…Œ๊ณ ๋ฆฌ 15 ร— ๋ฉ”๋‰ด 25 ร— ์Šคํƒœํ”„ 6 ร— ๊ธฐ๊ฐ„ 90์ผ ร— ํ•˜๋ฃจ 14์Šฌ๋กฏ = 2,835,000๊ฑด

์†Œ๋Ÿ‰์—์„œ ํ™•์ธํ•œ ์ธ๋ฑ์Šค๊ฐ€ ์‹ค์ œ ์„œ๋น„์Šค ๊ทœ๋ชจ์—์„œ๋„ ๋™์ž‘ํ•˜๋Š”์ง€ ๊ฒ€์ฆํ–ˆ์Šต๋‹ˆ๋‹ค.

๋‚ ์งœ๋ณ„ ์กฐํšŒ (300๋งŒ๊ฑด ์ค‘ 31,500๊ฑด)

-- WHERE business_id = ? AND slot_date = CURRENT_DATE + 7 days

Index Scan using idx_booking_slot_business_date_time on booking_slot
  (cost=0.43..40748.90 rows=28968 width=85) (actual time=0.059..6.394 rows=31500 loops=1)
  Index Cond: ((business_id = '...') AND (slot_date = ...))
  Filter: booking_slot.is_available
  Buffers: shared hit=565 read=2
Planning Time: 1.686 ms
Execution Time: 7.256 ms

Index Scan์ด ์œ ์ง€๋ฉ๋‹ˆ๋‹ค. ๋ฐ˜ํ™˜ ํ–‰์ด 31,500๊ฑด์œผ๋กœ ๋งŽ์•„ Execution Time์ด 7.256ms๊นŒ์ง€ ์˜ฌ๋ผ๊ฐ”์ง€๋งŒ, 300๋งŒ๊ฑด ํ…Œ์ด๋ธ”์—์„œ Index Cond๋กœ๋งŒ ์ฒ˜๋ฆฌ๋˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค.

๊ธฐ๊ฐ„๋ณ„ ์กฐํšŒ (300๋งŒ๊ฑด ์ค‘ 252,000๊ฑด โ†’ Index Scan ์ „ํ™˜)

-- WHERE business_id = ? AND slot_date BETWEEN today AND today+7

Index Scan using idx_booking_slot_business_date_time on booking_slot
  (cost=0.44..78210.02 rows=232768 width=85) (actual time=0.022..53.318 rows=252000 loops=1)
  Index Cond: (business_id = '...' AND slot_date >= CURRENT_DATE AND slot_date <= today+7)
  Filter: booking_slot.is_available
  Buffers: shared hit=4450
Execution Time: 60.583 ms

252,000๊ฑด์„ ๋ฐ˜ํ™˜ํ•˜๋Š” ์ฟผ๋ฆฌ๋ผ 60ms๊ฐ€ ๋‚˜์™”์Šต๋‹ˆ๋‹ค. Planner๊ฐ€ ๋ฐ˜ํ™˜ ํ–‰์ด ๋งŽ์„ ๋•Œ๋Š” Bitmap Heap Scan์„ ์„ ํƒํ•˜๊ธฐ๋„ ํ–ˆ์Šต๋‹ˆ๋‹ค.

-- ๋‹ค๋ฅธ ๋‚ ์งœ๋กœ ๋ฐ˜๋ณต ์กฐํšŒ ์‹œ Bitmap ์„ ํƒ

Bitmap Heap Scan on booking_slot
  Heap Blocks: exact=534
  Buffers: shared hit=563 read=1
  ->  Bitmap Index Scan on idx_booking_slot_business_date_time
        Index Cond: (business_id = '...' AND slot_date = ...)
        Buffers: shared hit=29 read=1
Execution Time: 5.587 ms

๊ฐ™์€ ์ธ๋ฑ์Šค๋ฅผ ์‚ฌ์šฉํ•˜์ง€๋งŒ ๋ฐ˜ํ™˜ ํ–‰ ์ˆ˜์™€ ์บ์‹œ ์ƒํƒœ์— ๋”ฐ๋ผ Index Scan๊ณผ Bitmap Index Scan์„ ์˜ค๊ฐ€๋Š” ๊ฒƒ์„ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค. ์ค‘์š”ํ•œ ๊ฒƒ์€ ๋‘ ๊ฒฝ์šฐ ๋ชจ๋‘ Seq Scan์ด ๋ฐœ์ƒํ•˜์ง€ ์•Š์•˜๋‹ค๋Š” ์ ์ž…๋‹ˆ๋‹ค.

๋ฉ”๋‰ด๋ณ„ ์กฐํšŒ (300๋งŒ๊ฑด ์ค‘ 2,520๊ฑด)

-- WHERE menu_id = ? AND slot_date >= CURRENT_DATE (idx_booking_slot_menu_date_time ์ถ”๊ฐ€ ํ›„)

Sort  (cost=8263.68..8269.47 rows=2315 width=85) (actual time=5.254..5.352 rows=2520 loops=1)
  Sort Key: slot_date, start_time
  Sort Method: quicksort  Memory: 392kB
  ->  Bitmap Heap Scan on booking_slot
        Recheck Cond: (menu_id = '...' AND slot_date >= CURRENT_DATE)
        Filter: booking_slot.is_available
        ->  Bitmap Index Scan on idx_booking_slot_menu_date_time
              Index Cond: (menu_id = '...' AND slot_date >= CURRENT_DATE)
              Buffers: shared hit=7
Execution Time: 5.483 ms

Bitmap Index Scan์œผ๋กœ ์ „ํ™˜๋ฉ๋‹ˆ๋‹ค. Sort ๋…ธ๋“œ๊ฐ€ ๋‹ค์‹œ ๋“ฑ์žฅํ–ˆ๋Š”๋ฐ, Bitmap Scan์ด ๋ฌผ๋ฆฌ์  ์ €์žฅ ์ˆœ์„œ(heap ์ˆœ์„œ)๋กœ ์ฝ๊ธฐ ๋•Œ๋ฌธ์— ์ธ๋ฑ์Šค ์ •๋ ฌ ์ˆœ์„œ๋ฅผ ๋ณด์žฅํ•˜์ง€ ์•Š์•„์„œ์ž…๋‹ˆ๋‹ค. Index Scan๊ณผ ๋‹ฌ๋ฆฌ Bitmap Scan์€ Sort๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํ–ฅํ›„ ์กฐํšŒ โ€” LIMIT + Early Termination (300๋งŒ๊ฑด)

-- WHERE business_id = ? AND slot_date >= CURRENT_DATE AND is_available = true
-- ORDER BY slot_date, start_time LIMIT 50

Limit  (cost=0.43..6.40 rows=50 width=85) (actual time=0.050..0.063 rows=50 loops=1)
  Buffers: shared hit=5
  ->  Index Scan using idx_booking_slot_business_date_time on booking_slot
        Index Cond: ((business_id = '...') AND (slot_date >= CURRENT_DATE))
        Filter: booking_slot.is_available
        Buffers: shared hit=5
Planning Time: 1.111 ms
Execution Time: 0.097 ms

300๋งŒ๊ฑด ํ…Œ์ด๋ธ”์—์„œ 0.097ms์ž…๋‹ˆ๋‹ค. Limit ๋…ธ๋“œ๊ฐ€ 50๊ฑด์„ ์ฐพ๋Š” ์ˆœ๊ฐ„ Index Scan์„ ์ค‘๋‹จํ•ฉ๋‹ˆ๋‹ค (Early Termination). ์ธ๋ฑ์Šค๊ฐ€ ์ด๋ฏธ slot_date, start_time ์ˆœ์„œ๋กœ ์ •๋ ฌ๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ ์•ž์—์„œ๋ถ€ํ„ฐ 50๊ฑด์„ ์ฐพ์œผ๋ฉด ๋’ท๋ถ€๋ถ„์€ ์ฝ์„ ํ•„์š”๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

LIMIT์„ ์ œ๊ฑฐํ•˜๊ณ  ๋™์ผ ์กฐ๊ฑด์œผ๋กœ COUNT๋ฅผ ์‹คํ–‰ํ•˜๋ฉด ์ฐจ์ด๊ฐ€ ๊ทน๋ช…ํ•ฉ๋‹ˆ๋‹ค.

-- LIMIT ์—†์ด ์ „์ฒด ์นด์šดํŠธ

Finalize Aggregate ... (actual time=1043.910..1055.074 rows=1 loops=1)
  Buffers: shared hit=15402 read=23748
  ->  Gather ...
        ->  Parallel Seq Scan on booking_slot
              Filter: (is_available AND (business_id = '...') AND (slot_date >= CURRENT_DATE))
              Rows Removed by Filter: 598500
Execution Time: 1055.186 ms

Planner๊ฐ€ LIMIT ์—†์ด ๋Œ€๋Ÿ‰ ๋ฐ˜ํ™˜์ด ์˜ˆ์ƒ๋˜๋Š” ๊ฒฝ์šฐ Parallel Seq Scan์„ ์„ ํƒํ•ฉ๋‹ˆ๋‹ค. ๊ฒฐ๊ณผ๋Š” 1055ms โ€” LIMIT 50 ์ฟผ๋ฆฌ(0.097ms)์™€ ์•ฝ 10,000๋ฐฐ ์ฐจ์ด์ž…๋‹ˆ๋‹ค. ๋™์ผ ์ธ๋ฑ์Šค๊ฐ€ ์žˆ์–ด๋„ LIMIT ์œ ๋ฌด์— ๋”ฐ๋ผ ์‹คํ–‰ ์ „๋žต์ด ์™„์ „ํžˆ ๋‹ฌ๋ผ์ง‘๋‹ˆ๋‹ค.

๋น…์Šค์ผ€์ผ ์š”์•ฝ

์กฐํšŒ ํŒจํ„ด ๊ฒฐ๊ณผ ํ–‰ ์ธ๋ฑ์Šค Sort ์‹คํ–‰ ์‹œ๊ฐ„
๋‚ ์งœ๋ณ„ (= ์กฐ๊ฑด) 31,500๊ฑด business ์—†์Œ 7.256 ms
๊ธฐ๊ฐ„๋ณ„ (BETWEEN) 252,000๊ฑด business ์—†์Œ 5.587~60.583 ms
๋ฉ”๋‰ด๋ณ„ (>= ์กฐ๊ฑด) 2,520๊ฑด menu Bitmap์‹œ ๋ฐœ์ƒ 5.483 ms
ํ–ฅํ›„ (>= + LIMIT 50) 50๊ฑด business ์—†์Œ 0.097 ms

5. Reservation ์ธ๋ฑ์Šค: status ์ถ”๊ฐ€ ์—ฌ๋ถ€ ํŒ๋‹จ

์ดˆ์•ˆ EXPLAIN

-- WHERE business_id = ? AND status = 'PENDING' ORDER BY reservation_date DESC
-- ๋ฐ์ดํ„ฐ 500๊ฑด

Index Scan Backward using idx_reservation_business_date_time on reservation
  Index Cond: (business_id = '...')
  Filter: ((status)::text = 'PENDING'::text)
  Rows Removed by Filter: 40
  Buffers: shared hit=1004
Planning Time: 0.195 ms
Execution Time: 0.755 ms

status๊ฐ€ Filter๋กœ ์ฒ˜๋ฆฌ๋˜์–ด 40๊ฑด์„ ๋ฒ„๋ฆฝ๋‹ˆ๋‹ค. ์ธ๋ฑ์Šค์— ์ถ”๊ฐ€ํ•˜๋ฉด Filter๋ฅผ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

์ถ”๊ฐ€ ์—ฌ๋ถ€ ๊ฒ€ํ† 

API ํŒจํ„ด์„ ๋ถ„์„ํ–ˆ์Šต๋‹ˆ๋‹ค.

-- ํŒจํ„ด 1: ํŠน์ • ์ƒํƒœ ์กฐํšŒ (์˜ˆ: PENDING๋งŒ)
WHERE business_id = ? AND status = 'PENDING'

-- ํŒจํ„ด 2: ์—ฌ๋Ÿฌ ์ƒํƒœ IN ์กฐ๊ฑด
WHERE business_id = ? AND status IN ('PENDING', 'CONFIRMED')

-- ํŒจํ„ด 3: ์ƒํƒœ ๋ฌด๊ด€ ์ „์ฒด ์กฐํšŒ
WHERE business_id = ?
ํ•ญ๋ชฉ 3์ปฌ๋Ÿผ ํ˜„์žฌ 4์ปฌ๋Ÿผ status ์ถ”๊ฐ€
๋‹จ์ผ ์ƒํƒœ ์กฐํšŒ 0.35ms (Filter 40๊ฑด) 0.33ms (6% ๊ฐœ์„ )
IN ์กฐ๊ฑด ์กฐํšŒ 0.35ms 0.45ms (29% ์ €ํ•˜, Sort ๋ฐœ์ƒ)
์ „์ฒด ์กฐํšŒ 0.30ms 0.32ms (7% ์ €ํ•˜)
INSERT ์„ฑ๋Šฅ ๊ธฐ์ค€ ~10% ์ €ํ•˜
UPDATE status ์‹œ ๋น ๋ฆ„ ์ธ๋ฑ์Šค ์žฌ๊ตฌ์„ฑ ํ•„์š”
์ธ๋ฑ์Šค ํฌ๊ธฐ ๊ธฐ์ค€ 71% ์ฆ๊ฐ€ (varchar ์ถ”๊ฐ€)

IN ์กฐ๊ฑด์—์„œ ์˜คํžˆ๋ ค ๋А๋ ค์ง€๋Š” ์ด์œ : status IN ('PENDING', 'CONFIRMED')๋Š” ์ธ๋ฑ์Šค๋ฅผ PENDING ๋ฒ”์œ„, CONFIRMED ๋ฒ”์œ„ ๊ฐ๊ฐ ์Šค์บ”ํ•œ ๋’ค ๋ณ‘ํ•ฉํ•ฉ๋‹ˆ๋‹ค. Bitmap Index Scan + Bitmap OR ์—ฐ์‚ฐ์ด ๋ฐœ์ƒํ•˜๋ฉฐ, ์ด ๊ณผ์ •์—์„œ ์ธ๋ฑ์Šค ์ •๋ ฌ ์ˆœ์„œ๊ฐ€ ๊นจ์ ธ Sort ๋…ธ๋“œ๊ฐ€ ์ถ”๊ฐ€๋ฉ๋‹ˆ๋‹ค.

status ๋ณ€๊ฒฝ ๋นˆ๋„ ๋ฌธ์ œ: Reservation ์ƒํƒœ๋Š” ์„œ๋น„์Šค ์ƒ์• ์ฃผ๊ธฐ ์ „๋ฐ˜์— ๊ฑธ์ณ ๋ณ€๊ฒฝ๋ฉ๋‹ˆ๋‹ค. PENDING โ†’ CONFIRMED โ†’ COMPLETED ๋˜๋Š” PENDING โ†’ CANCELLED ๋“ฑ ์—ฌ๋Ÿฌ ์ „์ด๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉฐ, ์ƒํƒœ ๋ณ€๊ฒฝ๋งˆ๋‹ค 4์ปฌ๋Ÿผ ์ธ๋ฑ์Šค๋ฅผ ์žฌ๊ตฌ์„ฑํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๊ฒฐ๋ก : 0.04ms(Filter 40๊ฑด ๋น„์šฉ)๋ฅผ ์•„๋ผ๊ธฐ ์œ„ํ•ด IN ์กฐ๊ฑด ์„ฑ๋Šฅ ์ €ํ•˜, ์“ฐ๊ธฐ ๋น„์šฉ ์ฆ๊ฐ€, ์ธ๋ฑ์Šค ํฌ๊ธฐ 71% ์ฆ๊ฐ€๋ฅผ ๊ฐ์ˆ˜ํ•˜๋Š” ๊ฒƒ์€ ์ด๋“์ด ์—†์Šต๋‹ˆ๋‹ค. ์‹ค์ œ API๊ฐ€ IN ์กฐ๊ฑด์œผ๋กœ ์—ฌ๋Ÿฌ ์ƒํƒœ๋ฅผ ๋™์‹œ์— ์กฐํšŒํ•˜๋Š” ๊ฒฝ์šฐ๋ฅผ ์ง€์›ํ•ด์•ผ ํ•˜๋ฏ€๋กœ ํ˜„์žฌ ์ธ๋ฑ์Šค๋ฅผ ์œ ์ง€ํ•˜๊ธฐ๋กœ ๊ฒฐ์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.

@Table(name = "reservation", indexes = {
    // status ์ธ๋ฑ์Šค ๋ฏธ์ถ”๊ฐ€ ๊ฒฐ์ •
    // ์ด์œ : IN ์กฐ๊ฑด ์„ฑ๋Šฅ ์ €ํ•˜, ์ƒํƒœ ์ „์ด ๋นˆ๋ฒˆ, 0.04ms ์ ˆ๊ฐ์œผ๋กœ ์ •๋‹นํ™” ๋ถˆ๊ฐ€
    @Index(name = "idx_reservation_customer_date_time",
           columnList = "customer_id, reservation_date DESC, reservation_time DESC"),
    @Index(name = "idx_reservation_business_date_time",
           columnList = "business_id, reservation_date DESC, reservation_time DESC")
})

DESC ๋ช…์‹œ: ORDER BY reservation_date DESC์™€ ์ธ๋ฑ์Šค ๋ฐฉํ–ฅ์ด ์ผ์น˜ํ•˜๋ฉด Index Scan Backward๋กœ ์—ญ์ˆœ ์Šค์บ”๋งŒ์œผ๋กœ ์ •๋ ฌ์ด ์™„๋ฃŒ๋ฉ๋‹ˆ๋‹ค. ๋ณ„๋„ Sort ๋…ธ๋“œ๊ฐ€ ์ƒ๊ธฐ์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๋น…์Šค์ผ€์ผ(100๋งŒ๊ฑด) ๊ฒ€์ฆ

-- ๊ณ ๊ฐ ์˜ˆ์•ฝ ๋ชฉ๋ก: WHERE customer_id = ? ORDER BY reservation_date DESC (100๋งŒ๊ฑด)

Sort  (cost=389.87..390.12 rows=100 width=182) (actual time=0.308..0.314 rows=100 loops=1)
  Sort Key: reservation_date DESC, reservation_time DESC
  Sort Method: quicksort  Memory: 41kB
  Buffers: shared hit=103
  ->  Bitmap Heap Scan on reservation
        Recheck Cond: (customer_id = '...')
        Heap Blocks: exact=100
        Buffers: shared hit=103
        ->  Bitmap Index Scan on idx_reservation_customer_date_time
              Index Cond: (customer_id = '...')
              Buffers: shared hit=3
Execution Time: 0.336 ms

Bitmap Scan + Sort ์กฐํ•ฉ์ด ๋‚˜์™”์Šต๋‹ˆ๋‹ค. ์†Œ๋Ÿ‰ ์ดˆ์•ˆ์—์„œ๋Š” Index Scan Backward๋กœ Sort๊ฐ€ ์—†์—ˆ๋Š”๋ฐ, 100๋งŒ๊ฑด ๊ทœ๋ชจ์—์„œ Planner๊ฐ€ Bitmap์œผ๋กœ ์ „ํ™˜ํ•˜๋ฉด์„œ Sort๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

Sort๊ฐ€ ๋ฐœ์ƒํ–ˆ์Œ์—๋„ 0.336ms์ž…๋‹ˆ๋‹ค. Bitmap Scan์ด ํ—ค๋“œ์˜ heap ์ ‘๊ทผ์„ ์ตœ์†Œํ™”ํ•œ ๋•๋ถ„์— ์ „์ฒด ๋น„์šฉ์ด ๋‚ฎ๊ฒŒ ์œ ์ง€๋ฉ๋‹ˆ๋‹ค. Sort ๋ฉ”๋ชจ๋ฆฌ๋„ 41kB๋กœ ์ž‘์Šต๋‹ˆ๋‹ค.

๋ฒ”์œ„ ์กฐ๊ฑด ์ฟผ๋ฆฌ์—์„œ๋Š” Index Scan์ด ์„ ํƒ๋ฉ๋‹ˆ๋‹ค.

-- ์ตœ๊ทผ 30์ผ ์˜ˆ์•ฝ: WHERE customer_id = ? AND reservation_date >= CURRENT_DATE - 30

Limit  (cost=0.43..16.49 rows=3 width=21) (actual time=0.014..0.024 rows=10 loops=1)
  Buffers: shared hit=13
  ->  Index Scan using idx_reservation_customer_date_time on reservation
        Index Cond: ((customer_id = '...') AND (reservation_date >= CURRENT_DATE - 30))
        Buffers: shared hit=13
Execution Time: 0.038 ms

๋‚ ์งœ ๋ฒ”์œ„๋ฅผ ์ถ”๊ฐ€ํ•˜๋ฉด ์ธ๋ฑ์Šค ํƒ์ƒ‰ ๊ตฌ๊ฐ„ ์ž์ฒด๊ฐ€ ์ค„์–ด Index Scan์ด ์„ ํƒ๋˜๊ณ  0.038ms๊ฐ€ ๋ฉ๋‹ˆ๋‹ค. ์—…์ฒด์ธก PENDING ์˜ˆ์•ฝ ์กฐํšŒ(๋น…์Šค์ผ€์ผ)๋„ ํ™•์ธํ–ˆ์Šต๋‹ˆ๋‹ค.

-- ์—…์ฒด PENDING ์˜ˆ์•ฝ ๋ชฉ๋ก: WHERE business_id = ? AND status = 'PENDING'

Bitmap Heap Scan on reservation
  Recheck Cond: (business_id = '...')
  Filter: ((status)::text = 'PENDING'::text)
  Heap Blocks: exact=2500
  Buffers: shared hit=2506
  ->  Bitmap Index Scan on idx_reservation_business_date_time
        Index Cond: (business_id = '...')
        Buffers: shared hit=6
Execution Time: 5.646 ms

status๋Š” ์†Œ๋Ÿ‰๊ณผ ๋™์ผํ•˜๊ฒŒ Filter๋กœ ์ฒ˜๋ฆฌ๋ฉ๋‹ˆ๋‹ค. 100๋งŒ๊ฑด ๊ทœ๋ชจ์—์„œ๋„ Bitmap Index Scan + Filter ๊ตฌ์กฐ๊ฐ€ ์œ ์ง€๋˜๋ฉฐ, 5.646ms๋Š” ์ด ์กฐ๊ฑด(์—…์ฒด์˜ ๋ชจ๋“  ์˜ˆ์•ฝ ์ค‘ PENDING ํ•„ํ„ฐ)์—์„œ ํ•ฉ๋ฆฌ์ ์ธ ์ˆ˜์น˜์ž…๋‹ˆ๋‹ค.


6. ์ปฌ๋Ÿผ ์ˆœ์„œ ๊ฒฐ์ • ์›์น™ ์š”์•ฝ

์œ„ ๊ณผ์ •์—์„œ ํ™•์ธํ•œ ์›์น™์ž…๋‹ˆ๋‹ค.

1์ˆœ์œ„: ๋“ฑํ˜ธ(=) ์กฐ๊ฑด ์ปฌ๋Ÿผ  โ†’  ํƒ์ƒ‰ ์‹œ์ž‘์ , Leading ์ปฌ๋Ÿผ
2์ˆœ์œ„: ๋ฒ”์œ„(BETWEEN/>=) ์กฐ๊ฑด โ†’  1์ˆœ์œ„๋กœ ์ขํžŒ ๊ตฌ๊ฐ„์—์„œ ๋ฒ”์œ„ ํ•„ํ„ฐ
3์ˆœ์œ„: ORDER BY ์ปฌ๋Ÿผ      โ†’  ์ธ๋ฑ์Šค ์ˆœ์„œ์™€ ์ผ์น˜ํ•˜๋ฉด Sort ๋…ธ๋“œ ์ œ๊ฑฐ

โ€ป Leading ์ปฌ๋Ÿผ ์—†์ด๋Š” ์ธ๋ฑ์Šค ์‚ฌ์šฉ ๋ถˆ๊ฐ€
โ€ป Bitmap Scan ์„ ํƒ ์‹œ Sort ๋…ธ๋“œ๋Š” ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Œ

7. SQL Explain ์‹œ์Šคํ…œ

์ธ๋ฑ์Šค๋ฅผ ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์„ค๊ณ„ํ•˜๋ ค๋ฉด ์‹ค์ œ ์‹คํ–‰ ๊ณ„ํš์„ ๋ฐ˜๋ณต์ ์œผ๋กœ ์ธก์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค. ๋ฌธ์ œ๋Š” ์ธก์ • ๊ณผ์ • ์ž์ฒด์˜ ๋งˆ์ฐฐ์ด ๋†’์œผ๋ฉด ์ธก์ •์„ ๊ฑด๋„ˆ๋›ฐ๊ฒŒ ๋œ๋‹ค๋Š” ๊ฒƒ์ž…๋‹ˆ๋‹ค. EXPLAIN ANALYZE ๋Œ€์ƒ ์ฟผ๋ฆฌ๋ฅผ ๋งค๋ฒˆ ์†์œผ๋กœ ๋ถ™์—ฌ๋„ฃ๊ณ , ๋กœ๊ทธ์—์„œ ์ง์ ‘ ์ฐพ์•„ ์ •๋ฆฌํ•˜๋Š” ์ž‘์—…์ด ๋ฐ˜๋ณต๋ ์ˆ˜๋ก ์ธก์ • ๋นˆ๋„๊ฐ€ ์ค„์–ด๋“ญ๋‹ˆ๋‹ค.

์ด ๋งˆ์ฐฐ์„ ์ค„์ด๊ธฐ ์œ„ํ•ด "์ธก์ • โ†’ ์ถ”์ถœ โ†’ ๋ถ„์„" ์„ธ ๋‹จ๊ณ„๋ฅผ ๋„๊ตฌ๋กœ ์ž๋™ํ™”ํ–ˆ์Šต๋‹ˆ๋‹ค.

7-1. ๋„๊ตฌ ๊ตฌ์„ฑ

scripts/query-logging/
โ”œโ”€โ”€ explain-analyze.ps1   โ† P6Spy ๋กœ๊ทธ์—์„œ SQL ์ž๋™ ์ถ”์ถœ
โ”œโ”€โ”€ config.json           โ† ์ถ”์ถœ ๊ทœ์น™ ์„ค์ • (์ฝ”๋“œ ์ˆ˜์ • ์—†์ด ๋ณ€๊ฒฝ ๊ฐ€๋Šฅ)
โ”œโ”€โ”€ extracted-queries.txt โ† ์ถ”์ถœ ๊ฒฐ๊ณผ (EXPLAIN ANALYZE ๋ฐ”๋กœ ์‹คํ–‰ ๊ฐ€๋Šฅ)
โ””โ”€โ”€ Readme.md

์„ธ ๋‹จ๊ณ„ ์›Œํฌํ”Œ๋กœ์šฐ:

โ‘  ์ธก์ •: Integration Test ์‹คํ–‰
   ./gradlew test --tests "*IntegrationTest"
   โ†’ P6Spy๊ฐ€ ์‹คํ–‰๋œ SQL ์ „์ฒด๋ฅผ logs/dev/application.log์— ๊ธฐ๋ก

โ‘ก ์ถ”์ถœ: PowerShell ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰
   cd scripts/query-logging
   .\explain-analyze.ps1
   โ†’ application.log์—์„œ SQL๋งŒ ํŒŒ์‹ฑ โ†’ extracted-queries.txt ์ƒ์„ฑ

โ‘ข ๋ถ„์„: IntelliJ / DBeaver์—์„œ EXPLAIN ANALYZE ์‹คํ–‰
   extracted-queries.txt์˜ ๊ฐ ์ฟผ๋ฆฌ ์•ž์— EXPLAIN ANALYZE ๋ถ™์—ฌ ์‹คํ–‰
   โ†’ Query Explain Report Template์œผ๋กœ Claude์—๊ฒŒ ๋ถ„์„ ์š”์ฒญ

7-2. config.json ๊ธฐ๋ฐ˜ ์„ค์ • ๋ถ„๋ฆฌ

{
  "extraction": {
    "sqlPatterns": [
      { "name": "SELECT", "regex": "SELECT\\s+.*?FROM", "enabled": true },
      { "name": "INSERT", "regex": "INSERT\\s+INTO",    "enabled": true }
    ],
    "cleanupRules": [
      { "description": "P6Spy prefix ์ œ๊ฑฐ",
        "pattern": "^.*?\\|\\s*statement\\s*\\|", "replace": "" },
      { "description": "Hibernate prefix ์ œ๊ฑฐ",
        "pattern": "^.*?Hibernate:\\s*",           "replace": "" }
    ]
  },
  "processing": {
    "removeDuplicates": true
  },
  "output": {
    "format": {
      "includeQueryNumber": true,
      "includeQueryType": true
    }
  }
}

์Šคํฌ๋ฆฝํŠธ ์ฝ”๋“œ๋ฅผ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๊ณ  config๋งŒ ์ˆ˜์ •ํ•ด SQL ํŒจํ„ด ํƒ์ง€ ๊ทœ์น™ ์ถ”๊ฐ€, ์ค‘๋ณต ์ œ๊ฑฐ on/off, ์ถœ๋ ฅ ํ˜•์‹ ๋ณ€๊ฒฝ์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค. ์ƒˆ๋กœ์šด ๋กœ๊ทธ ํฌ๋งท์ด ์ถ”๊ฐ€๋์„ ๋•Œ๋„ cleanupRules์— ํ•œ ์ค„ ์ถ”๊ฐ€๋กœ ๋Œ€์‘ํ•ฉ๋‹ˆ๋‹ค.

7-3. Query Explain Report Template

EXPLAIN ANALYZE ๊ฒฐ๊ณผ๋ฅผ Claude์—๊ฒŒ ๋ถ„์„์‹œํ‚ค๋Š” ํ”„๋กฌํ”„ํŠธ ํ…œํ”Œ๋ฆฟ์„ Query_Explain_Report_Template.md๋กœ ๊ด€๋ฆฌํ•ฉ๋‹ˆ๋‹ค.

ํ•ต์‹ฌ ์›์น™:
- EXPLAIN ๊ฒฐ๊ณผ ๊ฐ ์ค„๋งˆ๋‹ค ๋ฐ”๋กœ ์•„๋ž˜์— ํ•ด์„ (์žฅํ™ฉํ•œ ํ‘œ ๊ธˆ์ง€)
- ๋ฌธ์ œ์  3~5๊ฐœ๋งŒ ๋ช…ํ™•ํžˆ ์‹๋ณ„
- ๊ฐœ์„ ์•ˆ์€ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์ฝ”๋“œ(@Index ์–ด๋…ธํ…Œ์ด์…˜)๋กœ ์ œ์‹œ

์‹ค์ œ ์‚ฌ์šฉ ์˜ˆ์‹œ:

[์‚ฌ์šฉ์ž] EXPLAIN ANALYZE ๊ฒฐ๊ณผ๋ฅผ ๋ถ™์—ฌ๋„ฃ๊ณ  ํ…œํ”Œ๋ฆฟ ์š”์ฒญ
  โ†’  Seq Scan ๋ฐœ์ƒ, Rows Removed ๋น„์œจ, Sort ๋…ธ๋“œ ๋ฐœ์ƒ ์—ฌ๋ถ€ ์‹๋ณ„
  โ†’  ์ธ๋ฑ์Šค ์ปฌ๋Ÿผ ์ˆœ์„œ ์ œ์•ˆ + @Index ์ฝ”๋“œ ์ œ์‹œ
  โ†’  ์˜ˆ์ƒ Before/After ์‹œ๊ฐ„ ๋น„๊ต ํ…Œ์ด๋ธ”

[์‚ฌ์šฉ์ž] ์ธ๋ฑ์Šค ์ ์šฉ ํ›„ ์žฌ์ธก์ • ๊ฒฐ๊ณผ๋ฅผ ๋‹ค์‹œ ๋ถ™์—ฌ๋„ฃ๊ธฐ
  โ†’  ๊ฐœ์„  ํ™•์ธ ๋˜๋Š” ์ถ”๊ฐ€ ํŠœ๋‹ ํฌ์ธํŠธ ๋„์ถœ

์ด ๋ฌธ์„œ 3~5์ ˆ์˜ ์ธ๋ฑ์Šค ์„ค๊ณ„ ๊ณผ์ • โ€” Seq Scan ๋ฐœ๊ฒฌ, ์†Œ๋Ÿ‰ ๊ฒ€์ฆ, ๋น…์Šค์ผ€์ผ ๊ฒ€์ฆ, ํ”Œ๋žœ ๋ถˆ์•ˆ์ •์„ฑ ๋ถ„์„ โ€” ์€ ๋ชจ๋‘ ์ด ์›Œํฌํ”Œ๋กœ์šฐ๋ฅผ ๋ฐ˜๋ณต ์‹คํ–‰ํ•œ ๊ฒฐ๊ณผ์ž…๋‹ˆ๋‹ค.

Clone this wiki locally