diff --git a/README.md b/README.md index c562809b..7595835b 100644 --- a/README.md +++ b/README.md @@ -1,192 +1,820 @@ # spring-cgv-23rd -## 1️⃣ 캐시 도입하기 +## 1️⃣ 트랜잭션 전파 속성 더 찾아보기 -### 캐시란? +`REQUIRED`, `REQUIRES_NEW` 말고도 Spring에는 전파 속성이 더 있음. +공식 문서 기준으로 전파 옵션은 총 7개고, 수업에서 안 다룬 나머지 5개는 아래처럼 보면 됨. -- 자주 조회되지만 자주 바뀌지 않는 데이터를 잠깐 저장해두고 다음 요청에서는 원본 저장소(DB)까지 다시 가지 않도록 도와주는 장치 -- 목적: `응답 시간 단축`, `DB 부하 감소`, `같은 조회 반복 방지` -- 모든 데이터를 캐시하면 좋은 건 아님 -- 자주 바뀌는 데이터까지 무리하게 캐시하면 오래된 값을 내려주는 문제가 생길 수 있음 +### `SUPPORTS` -### CGV 클론 코딩에 캐시를 넣은 이유 +: 이미 트랜잭션이 있으면 거기에 같이 참여하고, 없으면 그냥 트랜잭션 없이 실행 -- 현재 CGV 프로젝트는 조회 API와 인증 요청이 반복적으로 많이 들어올 수 있는 구조였음 -- `GET /api/screenings`, `GET /api/screenings/{screeningId}/seats`, `GET /api/store/menus` 같은 read 경로는 계속 호출될 가능성이 높다고 봤음 -- 좌석 조회 쪽은 예약 상태뿐 아니라 좌석 배치 정보까지 같이 읽고 있어서 정적인 데이터와 동적인 데이터를 나눠서 보는 게 필요했음 -- JWT 인증이 필요한 API에서는 요청마다 사용자 정보를 다시 조회하는 비용이 있었고, 이 부분도 캐시 후보라고 판단했음 +- 조회성 로직에서 "있으면 같이 타고 가고, 없어도 굳이 새로 만들진 않음" 정도로 쓰기 좋음 +- 대신 호출 위치에 따라 동작이 달라져서, 핵심 쓰기 로직에 쓰면 오히려 헷갈릴 수 있음 -### 어떤 캐시를 선택했는지 +### `MANDATORY` -- `Spring Cache + Caffeine` -- 이유 - - 현재 프로젝트는 단일 Spring Boot 애플리케이션 중심 구조였음 - - Redis 같은 외부 캐시를 따로 붙이기보다 애플리케이션 내부 메모리 캐시가 더 단순했음 - - Caffeine은 Spring Cache와 붙이기 쉬웠고, TTL이나 최대 크기 설정도 비교적 간단했음 +: 반드시 기존 트랜잭션이 있어야 함. 없으면 바로 예외 발생 -### 어떤 캐싱 전략을 썼는지 +- 상위 서비스가 연 트랜잭션 안에서만 돌아야 하는 내부 메서드에 사용 +- "이 메서드는 단독 실행하면 안 됨"을 코드로 강하게 표현하는 느낌에 가까움 -- 기본 전략은 `Cache-Aside`로 잡았음 - - 조회 시 캐시에 값이 있으면 캐시를 그대로 사용 - - 없으면 DB를 조회한 뒤 캐시에 저장하는 방식으로 구현했음 -- 쓰기 요청이 발생해서 데이터가 바뀌는 경우에는 `cache update`보다 `cache evict`가 안전하다고 봤음 -- 매점 메뉴처럼 구매 이후 재고가 달라지는 데이터는 write 시점에 캐시를 비우고, 다음 조회 때 다시 채우도록 했음 +### `NOT_SUPPORTED` -### 어디에 캐시를 적용했는지 +: 트랜잭션 없이 실행. 바깥에 트랜잭션이 있으면 잠깐 멈춰두고 이 메서드만 비트랜잭션으로 동작 -| 대상 | 캐시 이름 | 전략 | TTL | 이유 | -| --- | --- | --- | --- | --- | -| `GET /api/screenings` | `screenings` | Cache-Aside | 3분 | 상영 목록은 조회가 자주 일어나고 매 요청마다 DB를 다시 읽지 않아도 될 가능성이 높다고 봤음 | -| 좌석 배치 정적 정보 (`SeatTemplate`) | `seatTemplates` | Cache-Aside | 30분 | 좌석 배치는 거의 바뀌지 않지만 좌석 조회 API에서 반복적으로 사용되기 때문이었음 | -| `GET /api/store/menus?cinemaId=...` | `storeMenus` | Cache-Aside + Evict on Write | 60초 | 메뉴 조회는 반복되지만 구매가 발생하면 재고가 바뀌기 때문에 eviction이 필요했음 | -| JWT 인증 시 `UserDetails` 조회 | `authUserDetails` | Cache-Aside | 10분 | 보호 API 요청마다 같은 사용자 정보를 반복 조회하는 비용을 줄이기 위해 넣었음 | +- 오래 걸리는 외부 API 호출, 파일 처리, 단순 조회처럼 굳이 트랜잭션으로 묶을 필요 없는 작업에 고려할 수 있음 +- 대신 여기서 실패한 내용을 바깥 트랜잭션 롤백으로 같이 되돌리는 건 기대하면 안 됨 -### 왜 좌석 상태 전체는 캐시하지 않았는지 +### `NEVER` -- 처음에는 `GET /api/screenings/{screeningId}/seats` 전체 응답을 캐시하는 것도 고려했음 -- 그런데 이 응답에는 `reserved` 여부가 포함되어 있었음 - - 예매 생성, 결제 완료, 취소, 만료에 따라 값이 자주 바뀜 - - 이 상태까지 통째로 캐시하면 실제로는 이미 예약된 좌석이 비어 있는 것처럼 보이거나, 반대로 이미 풀린 좌석이 계속 예약된 것처럼 보일 위험이 있었음 -- 그래서 `SeatTemplate` 같은 정적 데이터만 캐시하고, 실제 예약 상태는 매번 DB에서 계산하도록 했음 -- 정리하면 `정적 데이터는 캐시`, `동적 예약 상태는 실시간 조회`로 나눴음 +: 트랜잭션 없이만 실행. 그런데 이미 트랜잭션이 열려 있으면 예외 발생 -### 실제 구현 내용 +- `NOT_SUPPORTED`보다 더 강하게 제약을 거는 옵션 +- "이 작업은 트랜잭션 안에서 돌면 안 됨"을 분명하게 막고 싶을 때 사용 +- 실무에서 엄청 자주 보이진 않고, 의도를 강하게 드러낼 때 쓰는 편 -- `build.gradle`에 `spring-boot-starter-cache`, `caffeine` 의존성을 추가했음 -- `CacheConfig`에서 캐시별 TTL, 최대 크기, stats 기록 여부를 설정했음 -- `ScreeningQueryService.getScreenings()`에 상영 목록 캐시를 적용했음 -- `SeatTemplateCacheService`를 따로 두고 좌석 배치 정보를 스냅샷 형태로 캐시했음 -- `StoreQueryService.getStoreMenus()`는 DTO 응답 기준으로 캐시했음 -- `StorePurchaseService.purchase()`가 성공하면 해당 영화관 메뉴 캐시를 비우도록 했음 -- `CustomUserDetailsService.loadUserByUsername()`에도 캐시를 붙여서 JWT 인증 필터 경로에서 같은 사용자 조회가 반복되지 않게 했음 +### `NESTED` -### 기대한 효과 +: 기존 트랜잭션 안에서 `savepoint`를 잡고, 안쪽 작업만 부분 롤백할 수 있음. 바깥 트랜잭션이 없으면 `REQUIRED`처럼 동작 -- `GET /api/screenings` - - 반복 조회 시 DB 접근 횟수를 줄일 수 있게 되었음 -- `GET /api/store/menus` - - 자주 열리는 화면에서 응답 속도와 DB 부하 측면에서 이점이 생기도록 했음 -- `GET /api/screenings/{screeningId}/seats` - - 좌석 상태 전체를 캐시하지 않으면서도 정적인 좌석 배치 조회 비용은 줄일 수 있게 했음 +- `REQUIRES_NEW`처럼 물리 트랜잭션을 아예 분리하는 방식은 아님 +- 안쪽 작업이 실패하면 그 지점까지만 되돌리고, 바깥 작업은 계속 진행할 수 있음 +- 하지만 바깥 트랜잭션이 마지막에 롤백되면 안쪽에서 성공했던 작업도 결국 같이 롤백됨 +- `savepoint` 지원이 필요해서 환경 따라 바로 못 쓰는 경우도 있음 -### 검증 +### 헷갈리기 쉬운 차이 -- `./gradlew test` 통과 -- `ScreeningQueryServiceTest` - - 상영 목록 캐시와 좌석 배치 캐시가 채워지는지 확인했음 -- `StoreQueryServiceTest` - - 메뉴 캐시가 채워지고, 구매 후에는 eviction 되는지 확인했음 -- `CustomUserDetailsServiceTest` - - 인증용 사용자 조회가 첫 조회 이후 캐시에서 재사용되는지 확인했음 +#### `REQUIRES_NEW` vs `NESTED` -## 2️⃣ 로그 리팩토링 하기 +- `REQUIRES_NEW` + - 아예 새 물리 트랜잭션을 만듦 + - 안쪽 커밋/롤백이 바깥 트랜잭션과 분리됨 +- `NESTED` + - 같은 물리 트랜잭션 안에서 `savepoint`만 나눔 + - 부분 롤백은 되지만, 최종 결과는 바깥 트랜잭션에 영향을 받음 -- 운영 관점에서 요청 흐름을 한 번에 따라가기 위해서 -- 예매 / 결제 / 매점 구매처럼 장애가 나면 바로 확인해야 하는 구간은 있음 - - 하지만 어떤 요청이 왜 실패했는지 맥락이 충분하지 않음 +### 한 번에 정리 -### 이번에 잡은 방향 +| 속성 | 기존 트랜잭션이 있으면 | 없으면 | +| --- | --- | --- | +| `SUPPORTS` | 기존 트랜잭션 참여 | 그냥 실행 (트랜잭션 없음) | +| `MANDATORY` | 기존 트랜잭션 참여 | 예외 발생 | +| `NOT_SUPPORTED` | 기존 트랜잭션 일시 중단 | 그냥 실행 (트랜잭션 없음) | +| `NEVER` | 예외 발생 | 그냥 실행 (트랜잭션 없음) | +| `NESTED` | `savepoint` 기반 중첩 처리 | `REQUIRED`처럼 새 트랜잭션 시작 | + +### 정리 + +- 왜 `REQUIRED`가 기본값인지 좀 이해됐음. 대부분 서비스 로직은 "있으면 합류, 없으면 시작"이 제일 자연스러움 +- 나머지 전파 옵션들은 편한 기능이라기보다, "이 메서드는 어떤 경계 안에서 돌아야 하냐"를 코드로 드러내는 용도에 더 가까웠음 +- 특히 `NESTED`는 이름만 보면 `REQUIRES_NEW`랑 비슷해 보이는데, 실제 동작은 꽤 다름 + +## 2️⃣ 현재 CGV 서비스의 트랜잭션 분석 + +이번에는 "어디에 `@Transactional`이 붙어 있는지"만 본 게 아니라, +그 경계가 지금 서비스 흐름이랑 잘 맞는지도 같이 봤음. + +### 지금 구조는 어떻게 되어 있나 + +#### 조회 전용 서비스 + +- `MovieService` +- `CinemaService` +- `ScreeningQueryService` +- `SeatTemplateCacheService` +- `StoreQueryService` + +위 서비스들은 클래스 레벨에서 `@Transactional(readOnly = true)`를 사용하고 있었음. +조회가 목적이 분명해서 방향 자체는 괜찮았음. + +#### 쓰기 중심 서비스 + +- `AuthService` +- `ReservationService` +- `PaymentService` +- `StorePurchaseService` +- `MovieLikeService` +- `CinemaLikeService` + +여기는 생성, 수정, 삭제가 들어가니까 트랜잭션을 여는 방향이 자연스러웠음. + +### 괜찮았던 점 + +#### 1. 조회 서비스에 `readOnly = true`를 붙여둔 건 적절했음 + +- 상영 조회, 좌석 조회, 매점 메뉴 조회처럼 read 비중이 큰 서비스는 `readOnly = true`가 잘 맞음 +- 의미도 분명하고, JPA 입장에서도 "여긴 수정 의도 없음"을 알려주는 셈이라 관리가 깔끔했음 + +#### 2. 예매 / 결제 / 좌석 처리 흐름을 한 트랜잭션으로 묶은 건 맞는 방향이었음 + +예를 들면 `ReservationService.createReservation()`에서는 + +1. 예매 생성 +2. 좌석 점유 저장 +3. 결제 로그 생성 + +이 세 단계가 같이 묶여 있음. + +- 이건 한 단계라도 실패하면 전부 롤백되어야 하는 흐름이라서 트랜잭션 경계가 적절했음 +- `cancelReservation()`, `confirmPayment()`도 비슷하게 예매 상태와 결제 상태를 같이 바꾸고 있어서 분리하는 것보다 지금처럼 한 번에 묶는 쪽이 더 안전했음 + +#### 3. 동시성 이슈가 있는 곳은 락이 같이 들어가 있었음 + +- 예매 생성에서는 `Screening` 조회 시 `PESSIMISTIC_WRITE` 락 사용 +- 매점 구매에서는 `CinemaMenuStock` 조회 시 `PESSIMISTIC_WRITE` 락 사용 +- 좌석은 `reservation_seat` 유니크 제약까지 같이 걸려 있음 + +그래서 "트랜잭션만 열어둔 상태"가 아니라, 실제 충돌 가능성이 있는 자원에 대해서는 방어가 들어가 있었음. +이건 꽤 괜찮았음. + +### 아쉬웠던 점 + +#### 1. `AuthService.login()`이 `readOnly = true`인데 실제로는 쓰기를 하고 있었음 + +`login()`은 이름만 보면 조회처럼 보일 수 있는데, 실제로는 아래 작업을 같이 함. + +- 사용자 조회 +- 비밀번호 검증 +- access token 발급 +- refresh token 생성 +- refresh token 저장 또는 갱신 + +즉, 마지막 단계에서 DB write가 발생함. +그런데 메서드에는 `@Transactional(readOnly = true)`가 붙어 있었음. + +이건 두 가지 면에서 애매했음. + +- 의미상 read 메서드가 아님 +- `readOnly`는 "쓰기 금지"가 아니라 최적화 힌트에 가까워서, 이런 식으로 써두면 나중에 보면 더 헷갈림 + +그래서 이 부분은 `@Transactional`로 바꾸는 게 맞다고 판단했고, 이번에 실제로 그렇게 수정했음. + +#### 2. 조회 API까지 write 트랜잭션으로 열리고 있었음 + +`ReservationService`는 클래스에 `@Transactional(readOnly = true)`가 붙어 있는데, + +- `getReservations()` +- `getReservation()` + +이 두 메서드는 다시 `@Transactional`로 열고 있었음. + +이유를 따라가 보니, 조회 전에 `expireOverdueReservations()`를 호출하고 있었기 때문이었음. + +즉 지금 구조는 + +1. 조회 요청이 들어옴 +2. 만료 예매 일괄 정리 실행 +3. 결제 만료 처리 +4. 좌석 점유 삭제 +5. 그 다음에야 조회 + +이렇게 되어 있었음. + +문제는 조회 하나 하려고 들어왔는데, 그 안에서 전혀 관계없는 만료 배치 작업까지 같이 태우고 있다는 점이었음. +이러면 read API도 write transaction이 되고, 트랜잭션 범위도 필요 이상으로 커짐. + +#### 3. 만료 처리 범위가 너무 넓었음 + +`expireOverdueReservations()`는 이름 그대로 "만료된 예약 전체"를 한 번에 훑는 메서드임. + +그런데 이 메서드가 + +- `createReservation()` +- `getReservations()` +- `getReservation()` +- `cancelReservation()` +- `confirmPayment()` + +이런 여러 요청의 시작점마다 호출되고 있었음. + +이 방식의 아쉬운 점은, + +- 어떤 사용자가 좌석 2개 예약하려고 들어와도 +- 그 요청이 자기 예매만 처리하는 게 아니라 +- 시스템 전체의 만료 예약 정리까지 같이 하게 된다는 점임 + +정합성 자체는 맞출 수 있어도, 요청 하나가 불필요하게 무거워짐. + +#### 4. 만료 로직을 같은 서비스 안에 둔 것도 나중에 확장할 때 아쉬움 + +지금은 `ReservationService` 안에서 `expireOverdueReservations()`를 직접 호출하고 있음. + +이 구조에서는 나중에 + +- 만료 처리는 별도 트랜잭션으로 돌리고 싶다거나 +- `REQUIRES_NEW`를 써서 분리하고 싶다거나 +- 배치성 정리 로직만 따로 관리하고 싶다거나 + +해도 바로 적용하기가 애매함. + +같은 클래스 내부 호출은 스프링 프록시를 타지 않아서, 전파 옵션을 바꿔도 기대한 대로 안 먹을 수 있기 때문임. + +### 왜 이런 구조가 들어갔을까? + +조금 이해가 가는 부분도 있었음. + +현재 예매 좌석은 `reservation_seat(screening_id, seat_template_id)` 유니크 제약으로 막고 있음. +그래서 만료된 예약의 좌석 점유 레코드가 남아 있으면, 시간이 지났더라도 새 예매 insert가 막힐 수 있음. + +즉 "만료됐으면 좌석 점유를 실제로 지워야 한다"는 요구가 있어서, 예매 전에 만료 정리를 넣어둔 것 같았음. + +의도는 맞는데, 문제는 그걸 "전체 만료 예약 일괄 정리"로 풀어버렸다는 점이었음. + +### 개선 방향 + +#### 1. 로그인은 readOnly를 빼는 게 맞음 + +- `AuthService.login()` + - `@Transactional(readOnly = true)` -> `@Transactional` +- 이번에 이 부분은 실제로 수정했음 + +#### 2. 만료 처리는 조회 로직과 분리하는 게 좋겠음 + +내가 보기엔 방향은 아래 쪽이 더 나았음. + +- 스케줄러는 전체 만료 예약 정리 전용으로 사용 +- 사용자 요청에서는 "지금 요청에 필요한 범위만" 정리 +- 조회 메서드는 다시 `readOnly = true` 성격으로 돌려놓기 + +예를 들면 -1. 모든 요청에 `requestId`를 붙여서 요청 단위 추적이 가능하게 했음 -2. 운영 로그와 감사성 로그를 분리했음 -3. 중요한 도메인 이벤트는 JSON 형태로 남기게 했음 -4. 로그 리팩토링과 함께 Prometheus + Grafana로 볼 수 있는 지표도 같이 추가했음 +- 예매 생성 전에는 "해당 상영관 / 해당 좌석 관련 만료 예약만" 정리 +- 상세 조회 전에는 전체 배치를 돌리지 말고, 상태 계산만 하거나 필요한 예약만 확인 -### 어떻게 적용했는지 +이런 식이 더 가벼움. -#### 1. Request 단위 추적 +#### 3. 만료 로직은 별도 서비스로 빼는 편이 낫겠음 -- `RequestLoggingFilter`에서 요청마다 `X-Request-Id`를 생성하거나 기존 헤더를 그대로 사용하게 했음 -- `MDC`에 `requestId`, `method`, `path`를 넣어서 같은 요청에서 발생한 로그를 묶어 볼 수 있게 했음 -- 요청 종료 시점에는 `status`, `latencyMs`, `userId`, `clientIp`까지 같이 남기도록 했음 +예를 들면 + +- `ReservationService` + - 사용자 요청 처리 전용 +- `ReservationExpirationService` + - 만료 정리 전용 + +이렇게 나누면 역할이 분명해지고, 필요하면 나중에 `REQUIRES_NEW` 같은 전파 옵션도 붙이기 쉬워짐. + +### 정리 + +- 전체적으로 봤을 때 "트랜잭션을 아예 엉뚱하게 걸어둔 프로젝트"는 아니었음 +- 조회 서비스는 조회답게, 쓰기 서비스는 쓰기답게 나눠둔 편이었고 +- 예매 / 결제 / 좌석 / 재고처럼 중요한 변경 로직은 트랜잭션으로 잘 묶여 있었음 + +다만 아쉬웠던 건 두 군데였음. + +- `login()`이 readOnly였는데 실제로는 write를 하고 있던 점 +- 예약 관련 요청마다 전역 만료 정리 로직을 같이 태우면서 트랜잭션 범위를 너무 넓혀둔 점 + +정리하면, 지금 구조는 "큰 방향은 맞는데, 경계가 조금 넓은 부분이 있다" 정도로 보는 게 제일 맞아 보였음. + +## 3️⃣ 인덱스 종류 더 찾아보기 + +수업에서는 보통 "PK 인덱스", "일반 인덱스", "WHERE 빨라진다" 정도부터 시작하는데, +실제로는 인덱스도 꽤 여러 종류가 있고, 각각 잘 맞는 상황이 다름. + +특히 `커버링 인덱스`는 별도 문법이 있는 인덱스 종류라기보다, +"이 쿼리에 필요한 데이터를 테이블까지 다시 안 가고 인덱스만 보고 끝낼 수 있는 상태"에 가까움. + +### 먼저 큰 그림 + +MySQL 기준으로 보면 + +- 우리가 평소 가장 많이 쓰는 `PRIMARY KEY`, `UNIQUE`, `INDEX`는 대부분 `B-Tree` 기반 +- `SPATIAL`은 공간 데이터용이라 `R-Tree` 계열 +- `FULLTEXT`는 글 검색용 +- `HASH` 인덱스는 보통 `MEMORY` 엔진에서나 이야기하고, 일반적인 InnoDB 서비스에서는 거의 `B-Tree`를 기본으로 생각하면 됨 + +즉 실무에서 제일 많이 보는 건 사실상 `B-Tree 인덱스들`이고, +그 위에서 `단일`, `복합`, `유니크`, `커버링` 같은 식으로 나눠서 이해하는 편이 편했음. + +### `클러스터드 인덱스` + +: InnoDB에서 테이블 데이터 자체가 이 인덱스 순서에 맞춰 저장되는 인덱스 + +- 보통 `PRIMARY KEY`가 클러스터드 인덱스가 됨 +- 그래서 PK 조회가 빠른 편이고, PK 설계가 테이블 구조에 미치는 영향도 큼 +- PK가 너무 길거나 무거우면 세컨더리 인덱스도 같이 비대해질 수 있음 + +쉽게 말하면 + +- 클러스터드 인덱스 = 책 본문 자체가 목차 순서대로 정리된 느낌 + +### `세컨더리 인덱스` + +: PK 말고 추가로 만드는 일반 인덱스 + +- `member_id`, `reserved_at`, `status` 같은 컬럼에 거는 인덱스가 여기에 해당 +- InnoDB에서는 세컨더리 인덱스에 PK 값도 같이 들어감 +- 그래서 조건은 세컨더리 인덱스로 빨리 찾고, 최종 row 데이터는 PK 기준으로 다시 찾아가는 경우가 많음 + +즉 + +- 인덱스만 보고 끝나면 빠름 +- 인덱스로 후보 찾고 테이블 본문까지 다시 가면 그만큼 비용이 더 듦 + +### `유니크 인덱스` + +: 중복을 허용하지 않는 인덱스 + +- 속도용이기도 하지만, 사실 "중복 금지 규칙"을 DB 차원에서 보장하는 역할이 더 큼 +- 예를 들면 `email`, `payment_id`, `(screening_id, seat_template_id)` 같은 곳에 잘 어울림 +- 애플리케이션에서 중복 검사만 믿는 것보다 DB에서 한 번 더 막아주는 게 안전함 + +주의할 점은 + +- 조회는 편해질 수 있어도 +- `INSERT`, `UPDATE` 때 중복 체크 비용이 같이 들어감 + +그래도 정합성이 더 중요한 컬럼에는 거의 필수에 가까움. + +### `단일 컬럼 인덱스` + +: 컬럼 하나만 기준으로 만든 가장 기본적인 인덱스 + +예시 + +```sql +CREATE INDEX idx_reservations_member_id +ON reservations(member_id); +``` + +- `WHERE member_id = ?` 같이 한 컬럼 조건이 자주 나올 때 단순하고 효과적임 +- 대신 실무 쿼리는 조건이 하나로 끝나지 않는 경우가 많아서, 이것만으로 부족한 경우도 많음 + +### `복합 인덱스` / `다중 컬럼 인덱스` + +: 여러 컬럼을 묶어서 만든 인덱스 예시 -```text -2026-05-16 21:40:12.123 INFO [c4d5a40f-1e0f-4c73-9d28-0f7d7c5f3a2b] [http-nio-8080-exec-1] ... - event=request_complete method=POST path=/api/reservations status=200 latencyMs=41 userId=1 clientIp=127.0.0.1 +```sql +CREATE INDEX idx_reservations_member_reserved_at +ON reservations(member_id, reserved_at); ``` -#### 2. 운영 로그 / 감사 로그 분리 +- `WHERE + ORDER BY`가 같이 나오는 쿼리에서 특히 강력함 +- 예를 들면 "내 예매 내역 최신순 10건" 같은 조회에 잘 맞음 +- 각각 따로 인덱스 두 개를 거는 것보다, 자주 같이 쓰는 컬럼을 한 번에 묶는 쪽이 더 좋을 때가 많음 + +여기서 중요한 건 `컬럼 순서`임. + +- `(member_id, reserved_at)` 인덱스가 있으면 + - `member_id` + - `(member_id, reserved_at)` + 순서로는 잘 탐 +- 반대로 `reserved_at`만 조건에 쓰면 기대만큼 못 쓰는 경우가 있음 + +이게 흔히 말하는 `leftmost prefix` 규칙임. -- 일반 운영 로그는 `logs/application.log`에 남기도록 했음 -- 감사성 이벤트 로그는 `logs/audit/audit.log`에 따로 남기도록 했음 -- 운영 로그는 사람이 빨리 읽기 좋은 형식으로 두고, 감사 로그는 JSON 한 줄 구조로 남기게 했음 -- 이렇게 나누면 장애 대응용 로그와 이벤트 추적용 로그를 따로 보기 쉬웠음 +그래서 복합 인덱스는 + +- "무슨 컬럼을 묶을까?"도 중요하고 +- "그 컬럼을 어떤 순서로 둘까?"가 더 중요했음 + +### `커버링 인덱스` + +: 쿼리에 필요한 컬럼이 인덱스 안에 다 들어 있어서, 테이블 본문을 다시 읽지 않아도 되는 경우 + +이건 진짜 많이 헷갈리는데, + +- `CREATE COVERING INDEX ...` 같은 문법이 따로 있는 건 아님 +- 같은 인덱스라도 어떤 쿼리에서는 covering이고, 어떤 쿼리에서는 아닐 수 있음 + +예를 들면 아래 인덱스가 있다고 해보자. + +```sql +CREATE INDEX idx_reservations_member_reserved_at_price +ON reservations(member_id, reserved_at, total_price); +``` + +그리고 쿼리가 + +```sql +SELECT member_id, reserved_at, total_price +FROM reservations +WHERE member_id = 1 +ORDER BY reserved_at DESC +LIMIT 10; +``` + +처럼 필요한 컬럼만 읽는다면, 이 쿼리는 인덱스만 보고 끝날 가능성이 있음. + +왜 좋냐면 + +- 테이블 row까지 다시 안 가도 돼서 I/O가 줄고 +- 대량 조회에서 꽤 체감될 수 있음 + +대신 + +- `SELECT *`를 쓰면 커버링 인덱스 장점을 못 살리는 경우가 많음 +- 그래서 커버링 인덱스는 보통 "인덱스를 잘 만들자"와 "필요한 컬럼만 조회하자"가 같이 붙어 다님 + +### `접두사(prefix) 인덱스` + +: 문자열 컬럼 전체가 아니라 앞부분 일부만 인덱스로 만드는 방식 예시 -```json -{"timestamp":"2026-05-16T21:40:12.140","level":"INFO","event":"reservation_created","requestId":"c4d5a40f-1e0f-4c73-9d28-0f7d7c5f3a2b","method":"POST","path":"/api/reservations","userId":1,"screeningId":3,"reservationId":15,"paymentId":"pay-123","seatCount":2} +```sql +CREATE INDEX idx_movies_title_prefix +ON movies(title(20)); ``` -#### 3. 어떤 지점에 로그를 넣었는지 +- 긴 문자열 컬럼 전체를 인덱싱하면 공간을 많이 먹을 수 있어서 앞부분만 따는 방식 +- 인덱스 크기를 줄이는 데는 도움이 됨 +- 대신 앞 20글자가 같은 데이터가 많으면 구분력이 떨어질 수 있음 -| 위치 | 남긴 로그 | 이유 | -| --- | --- | --- | -| `RequestLoggingFilter` | 모든 요청의 성공/실패, 응답 시간, 상태 코드 | 요청 단위 흐름을 가장 바깥에서 공통으로 보기 위해 | -| `GlobalExceptionHandler` | `request_error`, `request_validation_error`, `request_unhandled_error` | 예외가 어디서 얼마나 발생하는지 바로 보기 위해 | -| `AuthService` | 회원가입 / 로그인 / 토큰 재발급 / 로그아웃 성공·실패 | 인증 실패율과 보안성 이벤트를 확인하기 위해 | -| `ReservationService` | 예매 생성 / 결제 확정 / 취소 / 만료 배치 | 핵심 도메인인 좌석 선점 흐름을 추적하기 위해 | -| `PaymentService` | 결제 시작 / 완료 / 취소 / 만료 | 결제 단계별 실패 지점을 분리해서 보기 위해 | -| `StorePurchaseService` | 매점 구매 성공·실패 | 재고 부족, 메뉴 없음 같은 실패 상황을 구분하기 위해 | +즉 + +- 저장 공간 절약에는 좋지만 +- 너무 짧게 잡으면 성능이 기대만큼 안 나올 수 있음 + +`TEXT`, `BLOB` 계열은 이런 prefix 길이를 지정해야 하는 경우도 있음. + +### `내림차순(Descending) 인덱스` + +: `DESC` 방향까지 고려해서 저장한 인덱스 + +- 최신순 조회가 많은 서비스에서 자주 생각해볼 수 있음 +- 예를 들면 `ORDER BY reserved_at DESC`, `ORDER BY created_at DESC` +- 예전에는 역순 스캔으로도 어느 정도 처리했지만, 내림차순 인덱스를 두면 더 효율적인 경우가 있음 -### 어떤 로그 전략을 썼는지 +특히 복합 인덱스에서 -- 모든 메서드에 무조건 상세 로그를 넣기보다 의미 있는 이벤트 위주로 남기려고 했음 -- Controller 진입/종료는 필터에서 공통 처리했음 -- Service에서는 실제로 운영에서 궁금한 도메인 이벤트만 남겼음 -- 성공 로그는 `INFO`, 처리 가능한 문제는 `WARN`, 예상하지 못한 예외는 `ERROR`로 나눴음 -- 로그 때문에 본 기능이 깨지면 안 된다고 생각 - - 로그 payload는 null-safe 하게 처리 +- 어떤 컬럼은 오름차순 +- 어떤 컬럼은 내림차순 -### 추가한 지표 +처럼 섞여 있을 때 의미가 더 커짐. -| 지표 | 의미 | 어디에 사용 | +### `FULLTEXT` 인덱스 + +: 문장이나 단어 검색을 위한 인덱스 + +- 일반 `B-Tree` 인덱스와 목적이 다름 +- `title LIKE '%avengers%'` 같은 검색을 B-Tree 하나로 예쁘게 해결하는 데는 한계가 있음 +- 이런 건 전문 검색용 인덱스가 더 잘 맞음 + +예를 들면 + +- 영화 제목 검색 +- 리뷰 검색 +- 줄거리 키워드 검색 + +같은 데서 떠올릴 수 있음. + +다만 + +- 정확히 같은 값 찾기 +- 범위 조회 +- 정렬 최적화 + +같은 데 쓰는 일반 인덱스와는 성격이 다름. + +### `SPATIAL` 인덱스 + +: 좌표, 위치, 영역 같은 공간 데이터용 인덱스 + +- 영화관 위치를 단순 주소 문자열로 저장하는 정도면 보통 안 씀 +- 대신 좌표 기반으로 "내 주변 영화관 찾기", "특정 영역 안 지점 찾기" 같은 기능이 있으면 얘기가 달라짐 +- 일반 서비스 CRUD에서는 자주 보이진 않지만, 지도 기능이 붙으면 갑자기 중요해질 수 있음 + +즉 지금 우리 CGV 프로젝트에서는 당장 우선순위가 높진 않지만, +"지오 기능이 들어오면 이런 인덱스가 따로 있구나" 정도는 알아두면 좋겠다고 느꼈음. + +### `함수 인덱스` / `생성 컬럼 인덱스` + +: 원본 컬럼 자체가 아니라, 계산된 값이나 표현식 기준으로 만드는 인덱스 + +이건 왜 필요하냐면, 우리가 흔히 + +```sql +WHERE DATE(reserved_at) = '2026-05-17' +``` + +같이 컬럼에 함수를 씌워버리면 일반 인덱스를 잘 못 타는 경우가 많기 때문임. + +이럴 때는 보통 + +- 쿼리를 범위 조건으로 바꾸거나 +- 생성 컬럼을 만들고 거기에 인덱스를 걸거나 +- 표현식 인덱스를 활용하는 식으로 접근함 + +특히 JSON 안쪽 값, 가공된 문자열, 계산된 날짜 키 같은 걸 자주 조회하면 생각해볼 만함. + +### `Invisible Index` + +: 실제로는 존재하지만, 옵티마이저가 사용하지 않게 숨겨둔 인덱스 + +- "이 인덱스 진짜 필요한가?" 확인할 때 유용함 +- 바로 drop 해버리면 위험할 수 있으니까, 잠깐 invisible로 바꿔서 영향도를 보는 식 +- 운영에서 인덱스 정리할 때 꽤 실용적인 기능임 + +즉 + +- 삭제 전 시험판 느낌 +- 인덱스 A/B 테스트 느낌 + +으로 이해하면 편함. + +### 한 번에 정리 + +| 종류 | 한 줄 설명 | 언제 떠올리면 좋은지 | | --- | --- | --- | -| `http.server.requests` | API 처리량, 상태 코드별 요청 수, 응답 시간 | 전체 트래픽 / 지연 / 에러율 확인 | -| `cgv.auth.events` | 로그인, 회원가입, refresh, logout 성공·실패 수 | 인증 실패율 확인 | -| `cgv.reservation.events` | 예매 생성 / 확정 / 취소 / 만료 이벤트 수 | 예매 흐름 이상 징후 확인 | -| `cgv.payment.events` | 결제 시작 / 완료 / 취소 / 만료 이벤트 수 | 결제 단계별 장애 확인 | -| `cgv.store.purchase.events` | 매점 구매 성공·실패 수 | 재고 관련 오류나 구매 실패 모니터링 | -| `cgv.reservation.expired.count` | 결제 대기 만료 건수 | 좌석 홀드 만료가 비정상적으로 늘어나는지 확인 | - -### Grafana 대시보드에 추가한 패널 - -- `API Throughput` - URI / Method 기준 요청 처리량을 봄 -- `API P95 Latency` - 어느 API가 느려지는지 빠르게 보기 위해 넣었음 -- `API Error Rate` - 4xx / 5xx 비율이 어디서 올라가는지 보기 위해 넣었음 -- `Auth Events` - 로그인 / refresh 실패가 갑자기 치솟는지 보기 위해 넣었음 -- `Reservation Events` - 예매 생성, 취소, 만료 흐름을 시간대별로 보기 위해 넣었음 -- `Store Purchase Events` - 매점 구매 성공/실패 추이를 보기 위해 넣었음 - -### 왜 이 지표들이 유용할까? - -- 예매 서비스는 `느려졌는지`, `실패가 늘었는지`, `어느 단계에서 터졌는지`를 빨리 알아야 함 -- 단순한 CPU / 메모리보다도 실제 사용자 요청에 가까운 지표를 먼저 보게 했음 -- `Reservation Events`, `Payment Events` - - 기능 장애를 서비스 흐름 기준으로 바로 파악할 수 있음 -- `requestId`: 로그에 같이 남음 - - Grafana에서 이상 징후를 보고 다시 로그 파일로 내려가서 원인 추적하기도 쉬움 - -### 실행 방법 - -```bash -docker compose -f docker-compose.observability.yml up -d -``` - -3. Prometheus: `http://localhost:9090` -4. Grafana: `http://localhost:3000` -5. 애플리케이션 메트릭은 `http://localhost:8080/actuator/prometheus` 에서 수집 -6. 대시보드는 provisioning 파일로 자동 등록되게 구성 - -### 해보면서 느낀 점 - -- 로그는 많이 찍는 것보다 맥락 있게 남기는 게 훨씬 중요 -- `requestId` 하나만 붙여도 장애 추적 난이도가 꽤 많이 내려갔음 -- 도메인 이벤트를 로그와 지표 둘 다로 남기니까 "무슨 일이 있었는지","얼마나 자주 있었는지"를 같이 볼 수 있음 +| 클러스터드 인덱스 | 테이블 본문 자체가 이 인덱스 기준으로 저장됨 | PK 설계할 때 | +| 세컨더리 인덱스 | PK 외에 추가로 만드는 일반 인덱스 | 대부분의 조회 튜닝 | +| 유니크 인덱스 | 중복 금지까지 같이 보장 | email, paymentId, 좌석 중복 방지 | +| 단일 컬럼 인덱스 | 컬럼 하나 기준 인덱스 | 조건이 단순할 때 | +| 복합 인덱스 | 여러 컬럼을 묶은 인덱스 | `WHERE + ORDER BY`, 다중 조건 | +| 커버링 인덱스 | 인덱스만 읽고 쿼리를 끝낼 수 있는 상태 | 필요한 컬럼만 조회하는 목록 API | +| 접두사 인덱스 | 문자열 앞부분만 인덱싱 | 긴 문자열 컬럼 | +| 내림차순 인덱스 | `DESC` 정렬까지 고려한 인덱스 | 최신순 조회가 많을 때 | +| FULLTEXT 인덱스 | 키워드/문장 검색용 | 제목, 리뷰, 줄거리 검색 | +| SPATIAL 인덱스 | 좌표/영역 검색용 | 지도, 위치 기반 기능 | +| 함수/생성 컬럼 인덱스 | 계산된 값 기준 인덱스 | 함수 조건, JSON 값 조회 | +| Invisible Index | 옵티마이저에서만 숨긴 인덱스 | 인덱스 제거 전 검증 | + +### 정리 + +- 인덱스는 그냥 "하나 걸면 빨라지는 것"보다, 쿼리 패턴에 맞춰 종류를 고르는 게 더 중요했음 +- 특히 실무에서는 `단일 인덱스`보다 `복합 인덱스`, 그리고 `커버링 인덱스` 개념이 훨씬 자주 체감될 것 같았음 +- 반대로 `FULLTEXT`, `SPATIAL`, `Invisible Index` 같은 건 당장 CRUD에서 많이 쓰이진 않아도, 상황이 바뀌면 갑자기 필요해지는 종류였음 +- 그리고 제일 중요한 건 "이론상 좋아 보이는 인덱스"보다, 실제로는 `EXPLAIN`, `EXPLAIN ANALYZE`로 확인해야 한다는 점이었음 + +## 4️⃣ 성능 최적화 해보기 + +이번에는 말로만 정리하지 않고, +실제로 `EXPLAIN ANALYZE`를 돌려서 전/후를 비교해봤음. + +현재 `cgv_db`는 데이터가 너무 적어서 성능 차이가 거의 안 보였고, +과제용으로는 [docs/sql/cgv_index_lab_setup.sql](C:\Users\jso25\OneDrive\Desktop\ceos\spring-cgv-23rd\docs\sql\cgv_index_lab_setup.sql)을 실행해서 별도 실습 스키마 `cgv_index_lab`을 만들었음. + +아래 이미지는 내가 실제로 실행한 `mysql` 콘솔 창 캡처이고, +원본 로그 텍스트는 [docs/performance-logs](C:\Users\jso25\OneDrive\Desktop\ceos\spring-cgv-23rd\docs\performance-logs)에 같이 남겨뒀음. + +실습 데이터 규모는 아래처럼 잡았음. + +| 테이블 | row 수 | +| --- | --- | +| `movies` | 200 | +| `screenings` | 50,000 | +| `reservations` | 100,000 | +| `store_orders` | 100,000 | + +이번에는 최소 3개 조건을 넘겨서, 총 4개 케이스를 확인했음. + +### 1. `WHERE` 조건 단일 인덱스 최적화 + +#### 상황 + +마이페이지에서 특정 회원의 예매 내역을 조회한다고 가정 + +```sql +SELECT * +FROM reservations +WHERE member_id = 1; +``` + +#### 전 + +- `Table scan on reservations` +- 100,000건 전체를 읽고 나서 `member_id = 1`인 150건만 필터링 +- 실제 실행 시간: `59.8ms` + +#### 적용 + +```sql +CREATE INDEX idx_reservations_member_id +ON reservations(member_id); +``` + +#### 후 + +- `Index lookup on reservations using idx_reservations_member_id` +- 조건에 맞는 150건만 바로 탐색 +- 실제 실행 시간: `0.66ms` + +#### 비교 + +- `59.8ms -> 0.66ms` +- 대략 `90배` 정도 빨라졌음 + +![Case 1](images/performance-real/case1_real_console.png) + +#### 느낀 점 + +- 이 케이스는 제일 정석적인 인덱스 효과였음 +- `WHERE` 조건이 명확하고, 결과 row 수도 적어서 단일 인덱스만으로도 충분히 큰 차이가 났음 + +--- + +### 2. `WHERE` 조건이 여러 개일 때, 아무 컬럼에나 인덱스 걸면 안 됨 + +#### 상황 + +관리자 페이지에서 최근 3일 이내 확정 예매를 조회한다고 가정 + +```sql +SELECT * +FROM reservations +WHERE status = 'CONFIRMED' + AND reserved_at >= DATE_SUB(NOW(), INTERVAL 3 DAY); +``` + +#### 전 + +- `Table scan on reservations` +- 100,000건 전체를 읽고 필터링 +- 실제 실행 시간: `70.5ms` + +#### 시도 1. `status` 인덱스 + +```sql +CREATE INDEX idx_reservations_status +ON reservations(status); +``` + +결과는 오히려 별로였음. + +- `status='CONFIRMED'`가 전체의 80%라서 너무 많이 걸림 +- 인덱스를 타긴 탔는데 80,000건을 읽어와야 했음 +- 실제 실행 시간: `197ms` + +즉 인덱스를 걸었는데도 더 느려질 수 있다는 걸 보여줬음. + +#### 시도 2. `reserved_at` 인덱스 + +```sql +CREATE INDEX idx_reservations_reserved_at +ON reservations(reserved_at); +``` + +이쪽이 훨씬 잘 맞았음. + +- 최근 3일 범위는 실제로 읽어야 할 row 수를 많이 줄여줌 +- 실제 실행 시간: `3.30ms` + +#### 비교 + +- 전체 스캔: `70.5ms` +- `status` 인덱스: `197ms` +- `reserved_at` 인덱스: `3.30ms` + +#### 왜 이렇게 됐을까? + +- `status`는 값 종류가 적어서 중복도가 높음 +- `reserved_at`은 최근 3일이라는 조건이 조회 범위를 훨씬 잘 줄여줌 + +즉 "조건이 여러 개면 많이 쓰는 컬럼에 무조건 인덱스"가 아니라, +"실제로 row 수를 많이 줄여주는 컬럼이 뭔지"를 봐야 했음. + +![Case 2](images/performance-real/case2_real_console.png) + +#### 느낀 점 + +- 이 케이스는 인덱스 자체보다 `선택도(selectivity)`가 중요하다는 걸 보여줬음 +- 낮은 카디널리티 컬럼 인덱스는 생각보다 별로일 수 있다는 걸 직접 확인했음 + +--- + +### 3. `ORDER BY` + `LIMIT` 정렬 최적화 + +#### 상황 + +관리자 페이지에서 매점 주문 내역을 결제 금액 높은 순으로 조회한다고 가정 + +```sql +SELECT * +FROM store_orders +ORDER BY total_price DESC +LIMIT 100; +``` + +#### 전 + +- `Table scan on store_orders` +- 100,000건을 다 읽고 정렬 +- `Sort: store_orders.total_price DESC` +- 실제 실행 시간: `63.8ms` + +#### 적용 + +```sql +CREATE INDEX idx_store_orders_total_price +ON store_orders(total_price); +``` + +#### 후 + +- `Index scan on store_orders using idx_store_orders_total_price (reverse)` +- 이미 정렬된 인덱스를 역순으로 읽으면서 100건만 가져옴 +- filesort가 사라짐 +- 실제 실행 시간: `0.728ms` + +#### 비교 + +- `63.8ms -> 0.728ms` +- 대략 `87배` 정도 빨라졌음 + +![Case 3](images/performance-real/case3_real_console.png) + +#### 느낀 점 + +- `ORDER BY`는 생각보다 비싼 작업이라는 걸 체감했음 +- 특히 `LIMIT`이 같이 있으면 정렬 컬럼 인덱스 효과가 엄청 크게 보였음 + +--- + +### 4. `WHERE + ORDER BY + 커버링 인덱스` 최적화 + +#### 상황 + +마이페이지에서 특정 회원의 예매 내역 10건을 최신순으로 조회한다고 가정 + +```sql +SELECT id, status, reserved_at, total_price +FROM reservations +WHERE member_id = 1 +ORDER BY reserved_at DESC +LIMIT 10; +``` + +#### 전 + +- `Table scan on reservations` +- 100,000건 전체를 읽고 +- `member_id = 1` 필터링 후 +- 다시 `reserved_at DESC` 정렬 +- 실제 실행 시간: `46.8ms` + +#### 시도 1. `member_id` 단일 인덱스 + +```sql +CREATE INDEX idx_reservations_member_id +ON reservations(member_id); +``` + +이것만으로도 많이 좋아졌음. + +- 150건만 찾은 뒤 정렬 +- 실제 실행 시간: `0.204ms` + +#### 시도 2. 복합 + 커버링 인덱스 + +```sql +CREATE INDEX idx_reservations_member_reserved_at_cover +ON reservations(member_id, reserved_at DESC, status, total_price); +``` + +이 쿼리는 `id`, `status`, `reserved_at`, `total_price`만 읽고 있음. +InnoDB 세컨더리 인덱스에는 PK 값도 같이 들어가니까, 이 경우 사실상 필요한 컬럼을 인덱스만으로 다 해결할 수 있었음. + +결과도 좋았음. + +- `Covering index lookup` +- 추가 정렬 없음 +- 테이블 본문 재조회도 없음 +- 실제 실행 시간: `0.0588ms` + +#### 비교 + +- 전체 스캔: `46.8ms` +- `member_id` 단일 인덱스: `0.204ms` +- 커버링 복합 인덱스: `0.0588ms` + +즉 + +- 단일 인덱스만으로도 엄청 좋아졌고 +- 복합 + 커버링으로 가면 거기서 한 번 더 줄어들었음 + +![Case 4](images/performance-real/case4_real_console.png) + +#### 느낀 점 + +- 이 케이스가 제일 "실서비스스럽다"는 느낌이었음 +- 목록 API에서 필요한 컬럼만 조회하고, 그 쿼리에 맞는 복합 인덱스를 설계하면 성능이 정말 잘 나옴 +- `커버링 인덱스`가 왜 실무에서 자주 언급되는지 감이 왔음 + +--- + +### 전체 정리 + +| 케이스 | 전 | 후 | 핵심 포인트 | +| --- | --- | --- | --- | +| `WHERE member_id = 1` | `59.8ms` | `0.66ms` | 단일 조건 인덱스 | +| `status + reserved_at` | `70.5ms` | `3.30ms` | 선택도 높은 컬럼 인덱스 | +| `ORDER BY total_price DESC LIMIT 100` | `63.8ms` | `0.728ms` | 정렬 컬럼 인덱스 | +| `WHERE member_id + ORDER BY reserved_at DESC LIMIT 10` | `46.8ms` | `0.0588ms` | 복합 + 커버링 인덱스 | + +### 최종 느낀 점 + +- 인덱스는 "있으면 좋다"가 아니라, 쿼리 패턴에 맞게 설계해야 효과가 컸음 +- 특히 `WHERE`, `ORDER BY`, `WHERE + ORDER BY`는 인덱스 전략이 꽤 다르게 먹혔음 +- 그리고 인덱스를 걸었다고 무조건 빨라지는 것도 아니었음 + - `status`처럼 중복도가 높은 컬럼은 오히려 별로일 수 있었음 +- 결국 제일 중요한 건 감으로 만드는 게 아니라 + - `EXPLAIN` + - `EXPLAIN ANALYZE` + - 전후 실제 시간 비교 + +이 세 가지를 같이 보는 거였음. diff --git a/docs/performance-logs/case1_where_index.txt b/docs/performance-logs/case1_where_index.txt new file mode 100644 index 00000000..8a7aa8e4 --- /dev/null +++ b/docs/performance-logs/case1_where_index.txt @@ -0,0 +1,24 @@ +Case 1. WHERE condition index + +-- Before +mysql> EXPLAIN ANALYZE SELECT * FROM reservations WHERE member_id = 1; ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| EXPLAIN | ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| -> Filter: (reservations.member_id = 1) (cost=10104 rows=9967) (actual time=0.328..50.8 rows=150 loops=1) + -> Table scan on reservations (cost=10104 rows=99673) (actual time=0.327..45.8 rows=100000 loops=1) + | ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + +-- Apply +mysql> CREATE INDEX idx_reservations_member_id ON reservations(member_id); + + +-- After +mysql> EXPLAIN ANALYZE SELECT * FROM reservations WHERE member_id = 1; ++---------------------------------------------------------------------------------------------------------------------------------------------------+ +| EXPLAIN | ++---------------------------------------------------------------------------------------------------------------------------------------------------+ +| -> Index lookup on reservations using idx_reservations_member_id (member_id=1) (cost=52.5 rows=150) (actual time=0.118..0.542 rows=150 loops=1) + | ++---------------------------------------------------------------------------------------------------------------------------------------------------+ diff --git a/docs/performance-logs/case2_selective_index.txt b/docs/performance-logs/case2_selective_index.txt new file mode 100644 index 00000000..a264f3aa --- /dev/null +++ b/docs/performance-logs/case2_selective_index.txt @@ -0,0 +1,47 @@ +Case 2. Selective index choice + +-- Reset +mysql> DROP INDEX idx_reservations_member_id ON reservations; + + +-- Before +mysql> EXPLAIN ANALYZE SELECT * FROM reservations WHERE status = 'CONFIRMED' AND reserved_at >= DATE_SUB(NOW(), INTERVAL 3 DAY); ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| EXPLAIN | ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| -> Filter: ((reservations.`status` = 'CONFIRMED') and (reservations.reserved_at >= ((now() - interval 3 day)))) (cost=9439 rows=3322) (actual time=0.353..75.3 rows=1427 loops=1) + -> Table scan on reservations (cost=9439 rows=99673) (actual time=0.342..51.8 rows=100000 loops=1) + | ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + +-- Apply bad index +mysql> CREATE INDEX idx_reservations_status ON reservations(status); + + +-- Status only result +mysql> EXPLAIN ANALYZE SELECT * FROM reservations WHERE status = 'CONFIRMED' AND reserved_at >= DATE_SUB(NOW(), INTERVAL 3 DAY); ++--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| EXPLAIN | ++--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| -> Filter: (reservations.reserved_at >= ((now() - interval 3 day))) (cost=2070 rows=16610) (actual time=1.5..187 rows=1427 loops=1) + -> Index lookup on reservations using idx_reservations_status (status='CONFIRMED') (cost=2070 rows=49836) (actual time=1.49..181 rows=80000 loops=1) + | ++--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + +-- Switch index +mysql> DROP INDEX idx_reservations_status ON reservations; + + +-- Apply good index +mysql> CREATE INDEX idx_reservations_reserved_at ON reservations(reserved_at); + + +-- Reserved_at result +mysql> EXPLAIN ANALYZE SELECT * FROM reservations WHERE status = 'CONFIRMED' AND reserved_at >= DATE_SUB(NOW(), INTERVAL 3 DAY); ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| EXPLAIN | ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| -> Filter: (reservations.`status` = 'CONFIRMED') (cost=788 rows=175) (actual time=0.443..3.54 rows=1427 loops=1) + -> Index range scan on reservations using idx_reservations_reserved_at over ('2026-05-20 22:13:40' <= reserved_at), with index condition: (reservations.reserved_at >= ((now() - interval 3 day))) (cost=788 rows=1750) (actual time=0.434..3.26 rows=1750 loops=1) + | ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ diff --git a/docs/performance-logs/case3_orderby_index.txt b/docs/performance-logs/case3_orderby_index.txt new file mode 100644 index 00000000..4c44495f --- /dev/null +++ b/docs/performance-logs/case3_orderby_index.txt @@ -0,0 +1,26 @@ +Case 3. ORDER BY optimization + +-- Before +mysql> EXPLAIN ANALYZE SELECT * FROM store_orders ORDER BY total_price DESC LIMIT 100; ++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| EXPLAIN | ++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| -> Limit: 100 row(s) (cost=10087 rows=100) (actual time=60..60 rows=100 loops=1) + -> Sort: store_orders.total_price DESC, limit input to 100 row(s) per chunk (cost=10087 rows=99828) (actual time=60..60 rows=100 loops=1) + -> Table scan on store_orders (cost=10087 rows=99828) (actual time=0.303..44.1 rows=100000 loops=1) + | ++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + +-- Apply +mysql> CREATE INDEX idx_store_orders_total_price ON store_orders(total_price); + + +-- After +mysql> EXPLAIN ANALYZE SELECT * FROM store_orders ORDER BY total_price DESC LIMIT 100; ++---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| EXPLAIN | ++---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| -> Limit: 100 row(s) (cost=0.114 rows=100) (actual time=1.16..1.51 rows=100 loops=1) + -> Index scan on store_orders using idx_store_orders_total_price (reverse) (cost=0.114 rows=100) (actual time=1.16..1.5 rows=100 loops=1) + | ++---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ diff --git a/docs/performance-logs/case4_covering_index.txt b/docs/performance-logs/case4_covering_index.txt new file mode 100644 index 00000000..b65c74ce --- /dev/null +++ b/docs/performance-logs/case4_covering_index.txt @@ -0,0 +1,50 @@ +Case 4. WHERE + ORDER BY + covering index + +-- Reset +mysql> DROP INDEX idx_reservations_reserved_at ON reservations; + + +-- Before +mysql> EXPLAIN ANALYZE SELECT id, status, reserved_at, total_price FROM reservations WHERE member_id = 1 ORDER BY reserved_at DESC LIMIT 10; ++-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| EXPLAIN | ++-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| -> Limit: 10 row(s) (cost=10104 rows=10) (actual time=44..44 rows=10 loops=1) + -> Sort: reservations.reserved_at DESC, limit input to 10 row(s) per chunk (cost=10104 rows=99673) (actual time=44..44 rows=10 loops=1) + -> Filter: (reservations.member_id = 1) (cost=10104 rows=99673) (actual time=0.255..43.9 rows=150 loops=1) + -> Table scan on reservations (cost=10104 rows=99673) (actual time=0.254..39.4 rows=100000 loops=1) + | ++-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + +-- Apply single index +mysql> CREATE INDEX idx_reservations_member_id ON reservations(member_id); + + +-- Single index result +mysql> EXPLAIN ANALYZE SELECT id, status, reserved_at, total_price FROM reservations WHERE member_id = 1 ORDER BY reserved_at DESC LIMIT 10; ++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| EXPLAIN | ++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| -> Limit: 10 row(s) (cost=52.5 rows=10) (actual time=0.314..0.315 rows=10 loops=1) + -> Sort: reservations.reserved_at DESC, limit input to 10 row(s) per chunk (cost=52.5 rows=150) (actual time=0.313..0.313 rows=10 loops=1) + -> Index lookup on reservations using idx_reservations_member_id (member_id=1) (cost=52.5 rows=150) (actual time=0.0815..0.272 rows=150 loops=1) + | ++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ + +-- Switch to covering index +mysql> DROP INDEX idx_reservations_member_id ON reservations; + + +-- Apply covering index +mysql> CREATE INDEX idx_reservations_member_reserved_at_cover ON reservations(member_id, reserved_at DESC, status, total_price); + + +-- Covering result +mysql> EXPLAIN ANALYZE SELECT id, status, reserved_at, total_price FROM reservations WHERE member_id = 1 ORDER BY reserved_at DESC LIMIT 10; ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| EXPLAIN | ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ +| -> Limit: 10 row(s) (cost=17.9 rows=10) (actual time=0.0457..0.0502 rows=10 loops=1) + -> Covering index lookup on reservations using idx_reservations_member_reserved_at_cover (member_id=1) (cost=17.9 rows=150) (actual time=0.0447..0.0485 rows=10 loops=1) + | ++----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ diff --git a/docs/performance-sql/case1_where_index.sql b/docs/performance-sql/case1_where_index.sql new file mode 100644 index 00000000..00a64ce5 --- /dev/null +++ b/docs/performance-sql/case1_where_index.sql @@ -0,0 +1,14 @@ +SELECT 'Before' AS stage; +EXPLAIN ANALYZE +SELECT * +FROM reservations +WHERE member_id = 1; + +CREATE INDEX idx_reservations_member_id +ON reservations(member_id); + +SELECT 'After' AS stage; +EXPLAIN ANALYZE +SELECT * +FROM reservations +WHERE member_id = 1; diff --git a/docs/performance-sql/case2_selective_index.sql b/docs/performance-sql/case2_selective_index.sql new file mode 100644 index 00000000..f9e29224 --- /dev/null +++ b/docs/performance-sql/case2_selective_index.sql @@ -0,0 +1,30 @@ +DROP INDEX idx_reservations_member_id ON reservations; + +SELECT 'Before' AS stage; +EXPLAIN ANALYZE +SELECT * +FROM reservations +WHERE status = 'CONFIRMED' + AND reserved_at >= DATE_SUB(NOW(), INTERVAL 3 DAY); + +CREATE INDEX idx_reservations_status +ON reservations(status); + +SELECT 'Status only result' AS stage; +EXPLAIN ANALYZE +SELECT * +FROM reservations +WHERE status = 'CONFIRMED' + AND reserved_at >= DATE_SUB(NOW(), INTERVAL 3 DAY); + +DROP INDEX idx_reservations_status ON reservations; + +CREATE INDEX idx_reservations_reserved_at +ON reservations(reserved_at); + +SELECT 'Reserved_at result' AS stage; +EXPLAIN ANALYZE +SELECT * +FROM reservations +WHERE status = 'CONFIRMED' + AND reserved_at >= DATE_SUB(NOW(), INTERVAL 3 DAY); diff --git a/docs/performance-sql/case3_orderby_index.sql b/docs/performance-sql/case3_orderby_index.sql new file mode 100644 index 00000000..e6fe4e23 --- /dev/null +++ b/docs/performance-sql/case3_orderby_index.sql @@ -0,0 +1,16 @@ +SELECT 'Before' AS stage; +EXPLAIN ANALYZE +SELECT * +FROM store_orders +ORDER BY total_price DESC +LIMIT 100; + +CREATE INDEX idx_store_orders_total_price +ON store_orders(total_price); + +SELECT 'After' AS stage; +EXPLAIN ANALYZE +SELECT * +FROM store_orders +ORDER BY total_price DESC +LIMIT 100; diff --git a/docs/performance-sql/case4_covering_index.sql b/docs/performance-sql/case4_covering_index.sql new file mode 100644 index 00000000..be4c4e45 --- /dev/null +++ b/docs/performance-sql/case4_covering_index.sql @@ -0,0 +1,33 @@ +DROP INDEX idx_reservations_reserved_at ON reservations; + +SELECT 'Before' AS stage; +EXPLAIN ANALYZE +SELECT id, status, reserved_at, total_price +FROM reservations +WHERE member_id = 1 +ORDER BY reserved_at DESC +LIMIT 10; + +CREATE INDEX idx_reservations_member_id +ON reservations(member_id); + +SELECT 'Single index result' AS stage; +EXPLAIN ANALYZE +SELECT id, status, reserved_at, total_price +FROM reservations +WHERE member_id = 1 +ORDER BY reserved_at DESC +LIMIT 10; + +DROP INDEX idx_reservations_member_id ON reservations; + +CREATE INDEX idx_reservations_member_reserved_at_cover +ON reservations(member_id, reserved_at DESC, status, total_price); + +SELECT 'Covering result' AS stage; +EXPLAIN ANALYZE +SELECT id, status, reserved_at, total_price +FROM reservations +WHERE member_id = 1 +ORDER BY reserved_at DESC +LIMIT 10; diff --git a/docs/sql/cgv_index_lab_setup.sql b/docs/sql/cgv_index_lab_setup.sql new file mode 100644 index 00000000..c2337673 --- /dev/null +++ b/docs/sql/cgv_index_lab_setup.sql @@ -0,0 +1,172 @@ +DROP DATABASE IF EXISTS cgv_index_lab; +CREATE DATABASE cgv_index_lab; +USE cgv_index_lab; + +CREATE TABLE digits ( + n INT PRIMARY KEY +); + +INSERT INTO digits (n) +VALUES (0), (1), (2), (3), (4), (5), (6), (7), (8), (9); + +CREATE TABLE seq_100k ( + n INT PRIMARY KEY +); + +INSERT INTO seq_100k (n) +SELECT + ones.n + + tens.n * 10 + + hundreds.n * 100 + + thousands.n * 1000 + + ten_thousands.n * 10000 + + 1 AS n +FROM digits ones +CROSS JOIN digits tens +CROSS JOIN digits hundreds +CROSS JOIN digits thousands +CROSS JOIN digits ten_thousands +WHERE + ones.n + + tens.n * 10 + + hundreds.n * 100 + + thousands.n * 1000 + + ten_thousands.n * 10000 + + 1 <= 100000; + +CREATE TABLE movies ( + id BIGINT PRIMARY KEY, + title VARCHAR(100) NOT NULL, + genre VARCHAR(30) NOT NULL, + running_time INT NOT NULL, + created_at DATETIME NOT NULL +); + +INSERT INTO movies (id, title, genre, running_time, created_at) +SELECT + n AS id, + CONCAT('Movie ', n) AS title, + CASE + WHEN MOD(n, 5) = 0 THEN 'ACTION' + WHEN MOD(n, 5) = 1 THEN 'DRAMA' + WHEN MOD(n, 5) = 2 THEN 'COMEDY' + WHEN MOD(n, 5) = 3 THEN 'THRILLER' + ELSE 'ANIMATION' + END AS genre, + 90 + MOD(n, 60) AS running_time, + NOW() - INTERVAL MOD(n, 365) DAY AS created_at +FROM seq_100k +WHERE n <= 200; + +CREATE TABLE screenings ( + id BIGINT PRIMARY KEY, + movie_id BIGINT NOT NULL, + theater_id BIGINT NOT NULL, + screen_name VARCHAR(50) NOT NULL, + start_time DATETIME NOT NULL, + remaining_seats INT NOT NULL, + created_at DATETIME NOT NULL +); + +INSERT INTO screenings ( + id, + movie_id, + theater_id, + screen_name, + start_time, + remaining_seats, + created_at +) +SELECT + n AS id, + 1 + MOD(n, 200) AS movie_id, + 1 + MOD(n, 30) AS theater_id, + CONCAT(1 + MOD(n, 10), '관') AS screen_name, + DATE_ADD( + DATE_ADD(CURDATE(), INTERVAL (MOD(n, 90) - 30) DAY), + INTERVAL (8 + MOD(n, 14)) HOUR + ) AS start_time, + MOD(n * 7, 180) AS remaining_seats, + NOW() - INTERVAL MOD(n, 180) DAY AS created_at +FROM seq_100k +WHERE n <= 50000; + +CREATE TABLE reservations ( + id BIGINT PRIMARY KEY, + member_id BIGINT NOT NULL, + screening_id BIGINT NOT NULL, + movie_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + reserved_at DATETIME NOT NULL, + total_price INT NOT NULL, + created_at DATETIME NOT NULL +); + +INSERT INTO reservations ( + id, + member_id, + screening_id, + movie_id, + status, + reserved_at, + total_price, + created_at +) +SELECT + n AS id, + CASE + WHEN n <= 150 THEN 1 + ELSE 2 + MOD(n, 4999) + END AS member_id, + 1 + MOD(n, 50000) AS screening_id, + 1 + MOD(1 + MOD(n, 50000), 200) AS movie_id, + CASE + WHEN MOD(n, 100) < 80 THEN 'CONFIRMED' + WHEN MOD(n, 100) < 95 THEN 'CANCELLED' + ELSE 'PENDING' + END AS status, + DATE_SUB(NOW(), INTERVAL MOD(n * 37, 180 * 24 * 60) MINUTE) AS reserved_at, + 12000 + MOD(n * 137, 60000) AS total_price, + DATE_SUB(NOW(), INTERVAL MOD(n * 37, 180 * 24 * 60) MINUTE) AS created_at +FROM seq_100k +WHERE n <= 100000; + +CREATE TABLE store_orders ( + id BIGINT PRIMARY KEY, + member_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + ordered_at DATETIME NOT NULL, + total_price INT NOT NULL, + created_at DATETIME NOT NULL +); + +INSERT INTO store_orders ( + id, + member_id, + status, + ordered_at, + total_price, + created_at +) +SELECT + n AS id, + CASE + WHEN n <= 200 THEN 1 + ELSE 2 + MOD(n, 4999) + END AS member_id, + CASE + WHEN MOD(n, 100) < 70 THEN 'PAID' + WHEN MOD(n, 100) < 85 THEN 'COMPLETED' + WHEN MOD(n, 100) < 95 THEN 'READY' + ELSE 'CANCELLED' + END AS status, + DATE_SUB(NOW(), INTERVAL MOD(n * 53, 180 * 24 * 60) MINUTE) AS ordered_at, + 3000 + MOD(n * 97, 50000) AS total_price, + DATE_SUB(NOW(), INTERVAL MOD(n * 53, 180 * 24 * 60) MINUTE) AS created_at +FROM seq_100k +WHERE n <= 100000; + +ANALYZE TABLE movies; +ANALYZE TABLE screenings; +ANALYZE TABLE reservations; +ANALYZE TABLE store_orders; diff --git a/images/performance-real/case1_real_console.png b/images/performance-real/case1_real_console.png new file mode 100644 index 00000000..dbdf81ae Binary files /dev/null and b/images/performance-real/case1_real_console.png differ diff --git a/images/performance-real/case2_real_console.png b/images/performance-real/case2_real_console.png new file mode 100644 index 00000000..02ee3b96 Binary files /dev/null and b/images/performance-real/case2_real_console.png differ diff --git a/images/performance-real/case3_real_console.png b/images/performance-real/case3_real_console.png new file mode 100644 index 00000000..722bfd70 Binary files /dev/null and b/images/performance-real/case3_real_console.png differ diff --git a/images/performance-real/case4_real_console.png b/images/performance-real/case4_real_console.png new file mode 100644 index 00000000..d6148b6d Binary files /dev/null and b/images/performance-real/case4_real_console.png differ diff --git a/scripts/capture_mysql_console_screenshot.ps1 b/scripts/capture_mysql_console_screenshot.ps1 new file mode 100644 index 00000000..9a79c3b9 --- /dev/null +++ b/scripts/capture_mysql_console_screenshot.ps1 @@ -0,0 +1,113 @@ +param( + [Parameter(Mandatory = $true)] + [string]$Title, + + [Parameter(Mandatory = $true)] + [string]$SqlFilePath, + + [Parameter(Mandatory = $true)] + [string]$OutputPath, + + [string]$DatabaseName = "cgv_index_lab", + [string]$MysqlPassword = "2562", + [int]$Cols = 180, + [int]$Lines = 42, + [int]$WaitSeconds = 4 +) + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +Add-Type @" +using System; +using System.Runtime.InteropServices; +public static class Win32 { + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint); + + [DllImport("user32.dll")] + public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect); + + public struct RECT { + public int Left; + public int Top; + public int Right; + public int Bottom; + } +} +"@ + +$mysqlExe = "C:\Program Files\MySQL\MySQL Server 8.0\bin\mysql.exe" +$sqlFile = (Resolve-Path $SqlFilePath).Path +$outputFile = [System.IO.Path]::GetFullPath($OutputPath) +$outputDir = Split-Path -Parent $outputFile +New-Item -ItemType Directory -Force -Path $outputDir | Out-Null + +$batchFile = Join-Path $env:TEMP ("capture_mysql_" + [guid]::NewGuid().ToString() + ".cmd") + +$batchContent = @" +@echo off +mode con: cols=$Cols lines=$Lines +title $Title +set MYSQL_PWD=$MysqlPassword +echo mysql^> source $sqlFile +cmd /c ""$mysqlExe" -E -uroot $DatabaseName < "$sqlFile"" +echo. +echo Press any key to close... +pause >nul +"@ + +Set-Content -LiteralPath $batchFile -Value $batchContent -Encoding ASCII + +$process = Start-Process cmd.exe -ArgumentList "/k", $batchFile -PassThru -WindowStyle Normal + +try { + Start-Sleep -Seconds $WaitSeconds + + $windowProcess = $null + for ($i = 0; $i -lt 15; $i++) { + $windowProcess = Get-Process | Where-Object { $_.MainWindowTitle -eq $Title } | Select-Object -First 1 + if ($null -ne $windowProcess) { + break + } + Start-Sleep -Milliseconds 500 + } + + if ($null -eq $windowProcess) { + throw "Could not find console window with title '$Title'." + } + + [Win32]::ShowWindow($windowProcess.MainWindowHandle, 5) | Out-Null + [Win32]::MoveWindow($windowProcess.MainWindowHandle, 20, 20, 1850, 980, $true) | Out-Null + [Win32]::SetForegroundWindow($windowProcess.MainWindowHandle) | Out-Null + + Start-Sleep -Milliseconds 700 + + $rect = New-Object Win32+RECT + [Win32]::GetWindowRect($windowProcess.MainWindowHandle, [ref]$rect) | Out-Null + + $width = $rect.Right - $rect.Left + $height = $rect.Bottom - $rect.Top + + $bitmap = New-Object System.Drawing.Bitmap $width, $height + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($rect.Left, $rect.Top, 0, 0, $bitmap.Size) + $bitmap.Save($outputFile, [System.Drawing.Imaging.ImageFormat]::Png) + $graphics.Dispose() + $bitmap.Dispose() +} +finally { + if ($null -ne $windowProcess) { + Stop-Process -Id $windowProcess.Id -Force -ErrorAction SilentlyContinue + } + elseif ($null -ne $process) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + } + + Remove-Item -LiteralPath $batchFile -Force -ErrorAction SilentlyContinue +} diff --git a/src/main/java/com/ceos23/spring_boot/cgv/service/auth/AuthService.java b/src/main/java/com/ceos23/spring_boot/cgv/service/auth/AuthService.java index a2a04dfc..e29088f3 100644 --- a/src/main/java/com/ceos23/spring_boot/cgv/service/auth/AuthService.java +++ b/src/main/java/com/ceos23/spring_boot/cgv/service/auth/AuthService.java @@ -78,7 +78,7 @@ public void signup(SignupRequest request) { } } - @Transactional(readOnly = true) + @Transactional public LoginResponse login(LoginRequest request) { long startTime = System.currentTimeMillis();