[4주차] RateLimit 구현 및 Jacoco 커버리지 테스트#99
[4주차] RateLimit 구현 및 Jacoco 커버리지 테스트#99DongHyunKIM-Hi merged 57 commits intohanghae-skillup:zhdiddlfrom
Conversation
- 기존 코드는 검색 조건이 추가되면 인터페이스 변경이 필요함 - `MovieSearchCriteria` DTO를 생성해 OCP 원칙을 준수 - ModelAttribute 애노테이션으로 클라이언트 요청 값을 DTO 객체에 바인딩 - 추후 검색 조건 변경 시 DTO와 커스텀 리포지토리 구현체 코드만 수정하면 확장 가능
- 제목 검색 요청 값을 UTF-8 인코딩 기준으로 바이트 수 계산 - 스키마 상 150 바이트로 제한도니 설정과 일관된 검증을 수행
- 성능 검사를 위해 추가할 인덱스를 DDL에 작성 - `movie` 테이블에 검색 성능 향상을 위한 복합 인덱스 추가 - `screening` 테이블에서 `movie_id`를 조회할 때 풀 스캔을 방지하기 위한 인덱스 추가
- 여러 정렬 조건이 있을 경우 리스트에 모아 순서를 유지해 적용하도록 수정 - 잘못된 정렬 필드가 들어오는 것을 방지하기 위해 허용할 필드를 명시
- 현재 `title`과 `genre`로 검색 기능을 제공하는 점을 고려해 복합 인덱스 적용 - 수정된 인덱스로 실행 결과 확인 후 README에 내용 반영
- Jackson 미사용으로 불필요해진 설정을 application.yml에서 제거 - 캐싱 TTL 적용시 설정 시간을 20분으로 정하게 된 이유를 주석에 추가
- 잘못 첨부된 이미지 수정 - 보완된 설명을 추가
- `Member`: 회원 정보 관리 - `Reservation`: 회원이 생성한 예약 정보를 관리 - `SeatReservation`: 특정 예약에 대한 좌석 정보를 관리 - 좌석과 예약의 다대다 관계에서 `SeatReservation`이 중간테이블 역할을 수행
- `SeatRow`는 A부터 E까지 단일 문자로 표현되므로 타입을 Character로 개선 - 타입 변경으로 관련 메서드 코드 수정 - equals 메서드에서 Objects.equals 사용으로 null 안전성 확보 - Validation Layer에서 호출할 수 있도록 Getter 추가
- 예약 생성 과정에서 데이터 무결성 보장하기 위한 검증 로직 구현 - 회원 객체, 예약 요청 데이터, 좌석 선택 관련 유효성을 검증 - 검증 실패 시 CustomException을 던질 때 필요한 ErrorCode 추가
- 추가된 엔티티를 기반으로 저장소 포트 및 어댑터 구현 - 불필요한 주석 제거
- 예약 요청 및 응답 시 데이터를 전달할 DTO 생성
- 비즈니스 로직 상 비어 있는 리스트가 문제될 수 있는 것에 null 체크 - ErrorCode 및 메시지를 추가해 명확한 예외 메시지 제공
- 예약 서비스에서 Validation 서비스를 직접 참조하지 않도록 구조 개선
- Validation 포트를 사용해 예약 생성 서비스 구현
- 상속 및 구현 대상이 잘못 기재된 내용을 수정
- 코드 가독성을 위한 개행 추가 및 줄바꿈 수정
- `Member`, `Reservation`, `SeatReservation` DDL 추가
- 클라이언트 요청 값 검증 로직을 `validateReservationRequest` 메서드에 분리 - 컨트롤러에서 해당 메서드를 통해 기본 유효성을 체크하고 Early-Ex을 유도 - 서비스는 DB 데이터를 기반으로 비즈니스 로직과 데이터 일관성 검사 책임만 갖도록 개선
- CustomException이 개별 작성된 메시지를 반환할 수 있도록 수정 - 기본으로 작성된 예외 메시지로 일괄 처리되던 문제 해결
- data.sql에 회원 데이터 100개 삽입 쿼리 추가 - 좌석 예약 요청을 테스트할 .http 파일 생성
- 메시지 전송을 위한 포트와 구현체 클래스 생성 - FCM 연동 없이 로그 출력으로 대체 - Thread.sleep(500) 적용하여 메시지 발송 처리 시간 시뮬레이션 - 예약 완료 메시지에 사용자명, 좌석 번호, 영화 정보 포함 처리
- 상영 정보 및 회원 검증 로직은 현재 제공되는 기능에 사용되지 않아 삭제 처리
- 스레드 100개가 동일한 좌석을 예약 동시에 예약하는 테스트 작성 - 동시성 문제가 발생해 데이터 정합성이 유지되는지 확인하는 목적 - 테스트 코드에 필요한 관련 메서드를 추가 구현 - 현재는 락을 적용하지 않았기 때문에 테스트가 실패
- 사용하지 않는 fetch type 및 import문 제거
- `ReservationService`에서 좌석 예약 전 잠금 후 검증 로직 구현 - 비관적 락을 적용한 `existsByScreeningAndSeat` 메서드 사용 - 비관적 락은 동일한 트랜잭션 내에서만 유효하므로 `@Transactional` 추가 - 좌석 조회 시 즉시 잠금을 적용해 다른 트랜잭션에서 동시에 예약하지 못하도록 방지 - 여러 좌석 예약 요청 시 `validateSeatsExists`를 활용한 사전 검증 보완
- `Create`메서드는 핵심 흐름만 확인하고 단일 책임 원칙 준수 - `getScreening()`, `getMember()`: 정보 DB 조회 로직 - `validateReservationConstraints()`: 검증 로직을 통합 - `saveReservationAndSeats()`: 최종 예약 및 좌석 저장
- 좌석 예약 관리를 위해 `ScreeningSeat` 및 `ReservedSeat` 테이블 추가 - `ScreeningSeat`에 version 필드를 추가로 낙관락 적용 동시성 제어 - `ReservedSeat`으로 예약 정보와 좌석 정보를 연결 - 변경된 구조 README에 반영
- DB 변경에 따라 리포지토리 어댑터 및 포트 수정
- 추가된 테이블에 대한 DDL 작성
- 변경된 DB 구조와 낙관락 메커니즘에 맞게 테스트를 수정 - 정상적으로 동시성이 제어되는 것을 확인
- 분산 락 적용을 위한 의존성 추가 - @distributedlock 애노테이션을 이용해 AOP 기반 분산 락 적용 - `screeningId` 및 `seatId`를 조합해 락 키를 설정해 충돌 방지 - 트랜잭션 실행 시간이 500ms ~ 750ms 으로 관찰됨 - 이를 기반으로 leaseTime(잠금 유지 시간)을 약 2배인 1초로 설정 - waitTime(대기 시간)은 leaseTime의 2배인 2초로 설정 - 성능 테스트 결과 README에 반영
- 직접 분산락을 호출해 특정 코드 블록에만 적용하는 방식으로 변경 - `saveReservationAndSeats` 메서드 실행 전에 락을 획득 - 재시도 로직 추가 후 테스트 시 응답 속도가 더 느려져 구현에서 제외 - 성능 테스트 시 응답 시간에서 약간 개선됨을 확인
- GC 실행 시 STW로 락이 만료될 가능성을 고려해 leaseTime을 5초로 수정 - 트랜잭션 실행 시점을 락 내부로 이동해 락 종료 전에 트랜잭션이 먼저 커밋되도록 수정 - 낙관락 충돌 여부 확인을 위해 예약 생성 로직에 로그를 추가 - 좌석 상태 변경 시 트랜잭션이 종료 전에 버전 업데이트가 DB에 반영되도록 flush 호출 추가
- 저장소에 필요한 메서드를 남기고 사용하지 않는 메서드 제거 - 불필요한 import문 제거 - 코드 가독성을 위한 빈 줄 추가 및 메서드명 변경
- 낙관 락 충돌 예외 핸들러 추가해서 클라이언트에게 일관된 응답 반환 - CustomException 처리 시 필요한 에러코드 추가
- 락 획득 시도, 성공, 실패 로그를 추가해 모니터링 강화 - 예외 처리에 CustomException 적용해 일관된 예외 관리 구현 - 락 해제 시 예외가 발생할 경우 강제 해제를 실행해 데드락 상황 방지 - 롬복 애노테이션 사용으로 보일러플레이트 코드 제거
- if 문에서 Objects.isNull() 대신 == null 사용 - 불필요한 메서드 호출을 줄여 성능 최적화 - 코드 스타일을 일관되게 유지하여 가독성 향상
- Redis 명령 실행 타임아웃을 5초로 설정하여 지연 응답에 대한 예외 처리 - Redis 서버 연결 시 타임아웃을 5초로 설정하여 연결 지연을 방지 - 최대 3번의 재시도 및 재시도 간격을 1.5초로 설정하여 일시적인 연결 문제 해결
- 스프링 부트를 실행하는 모듈에 bootJar 생성 설정 - 캐시 관련 설정에 잘못된
- Redis를 캐시 저장소로 사용하기 위한 설정 - application.yml에 cache.type 추가
- Google Guava 사용을 위해 의존성 추가 - 조회 서비스에는 1분당 평균 50회 이하의 요청을 허용 - 조회 및 예약 API에 Rate Limiting 검증 후 CustomException 에러 처리 - Rate Limiting 서비스에 대한 단위 테스트 추가
- validation 관련 클래스를 application 모듈로 이동 - infrastructure 모듈 내 DB 패키지 재구성 - import 및 package 문 정정
- 기존 Google Guava RateLimiter를 제거하고 Redis 기반 Rate Limiter로 대체 - Redis Lua Script를 활용하여 요청 제한을 목적으로 카운터 방식으로 구현 - 인메모리 특성 상 Leaky Bucket보다 카운팅이 메모리 효율적이라고 판단 - Rate Limiter 서비스 클래스가 RateLimiterPort를 통해 Redis를 사용 - 컨트롤러에서 서비스 계층을 통해 Rate Limiting 적용 - 서비스 클래스에 대한 단위 테스트 추가 - 요청 제한 정책을 Redis에서 관리하여 분산 환경에서 일관된 Rate Limiting 가능
- 최초 요청시에만 TTL을 설정하도록 변경하여 Rate Limit 우회 방지
- 요청 횟수가 maxRequests와 같을 때도 차단되도록 조건 수정 (">"를 ">="로 변경)
- Rate Limiter 키에 Member ID를 추가하여 사용자 별 Rete Limiting 적용 - 기존에는 동일 영화 시간대에 모든 사용자가 제한되므로 구조 개선
- 클라이언트 IP를 올바르게 감지하도록 X-Forwarded-For 헤더 사용 - 영화 추가 시 DDL에 따라 영화 제목 길이 검증 추가
- 예약을 요청하는 Member ID를 키 값에 추가
- 테스트하는 클래스와 동일한 패키지 구조로 변경
- waitTime을 leaseTime보다 약간 길게 조정해 락 획득 기회를 증가
- 외부와 통합해 주요 흐름 위주로 테스트
- 각 서비스 클래스의 단위 테스트 작성 - 영화 객체 Mocking 진행 시 상영 시간이 null이 되지 않도록 ArrayList 기본 값 설정
- 예약 관련 리포지토리를 슬라이스 테스트 작성 - JPA 영속성 컨텍스트와 리포지토리 메서드가 정상 동작하는지 확인 - MySQL 실제 DB 환경에서 테스트하도록 설정 - 리포지토리가 Redis 캐싱을 사용하고 있어 Redis 설정을 빈으로 추가
- 리팩토링 과정에서 사용하지 않는 코드 제거
- 기존 테스트 코드에서 값 검증 방식을 존재 여부 검증 방식으로 변경 - reservedSeats 배열 여부 확인 추가 - 테스트 코드 가독성을 위해 @DisplayName 추가 - 불필요한 import문 제거 - RedisTestConfig 사용 시 JPA 테스트 외 모두 실패하는 문제가 발생해 제거
- 프로젝트 테스트 커버리지 분석을 위해 Jacoco 커버리지 결과를 README에 추가 - 모든 컨트롤러 커버리지 정보 포함 - 주요 서비스 및 리포지토리 클래스의 커버리지 정보 포함
| @JoinColumn(nullable = false) | ||
| private Seat seat; | ||
|
|
||
| @ManyToOne |
There was a problem hiding this comment.
querydsl을 사용하고 있는 상황이라면 엔티티 간의 Join을 위한 OneToMany, ManyToOne 같이 Join을 위한 어노테이션 사용을 지양하는 경향이 있습니다. 그 이유로는 JPA의 연관 관계를 사용하면 자동으로 생성되는 쿼리가 복잡해지고, 성능 최적화가 어려울 수 있기 떄문입니다. 반면 QueryDSL을 사용하면 필요한 필드만 선택적으로 조회하거나, 복잡한 조인 조건을 직접 제어할 수 있어서 더 유연하게 쿼리를 작성할 수 있습니다!
100 % 안좋다는 것은 아닌데 이러한 관점도 있으니 참고 해주시면 좋을 것 같습니다!
| protected SeatNumber() {} | ||
|
|
||
| private SeatNumber(String seatRow, int seatColumn) { | ||
| private SeatNumber(Character seatRow, int seatColumn) { |
There was a problem hiding this comment.
생성자 어노테이션을 사용하지 않은 이유가 특별하게 있는 것이 아니면 사용하는 것을 추천 드립니다. 유지보수 측면에서 혹은 다른 개발자가 누락할수있는 실수가 발생할수있기 때문에 자동으로 관리해주는 것이 좋다고 생각합니다
| reservation.getSeatReservations().add(seatReservation); // 좌석 정보를 예약 내역에 저장 | ||
| for (ScreeningSeat screeningSeat : requestedSeats) { | ||
| // 좌석에 Optimistic Lock 적용하면서 예약된 좌석인지 확인 | ||
| screeningSeat.reserve(); // 좌석을 예약 상태로 변경 |
There was a problem hiding this comment.
현재 도메인에서는 1개의 상품에 대해서 선착순으로 경쟁하는 상황이지만 다수의 상품에서 선착순으로 경쟁할 때는 ( ex) 선착순 특가 잔여 상품 3개 ) 락 획득 실패한 요청에 대해서도 재시도 처리를 넣어주어서 처리하는 방법도 많이 사용되고 있으니 참고해주시면 좋을 것 같아요. @retryable을 통해서 ObjectOptimisticLockingFailureException 예외 발생시 동작하도록 하는 식으로 말이죠 ㅎㅎ
좋으점
아래 링크를 참조해보면 좋을 것 같습니다.
개인적으로는 헥사고날 아키텍처 방향성에 맞게 잘 진행하고 있는 것 같아요. 그래도 자가 진단을 하고 싶다면 우선 현재 추가 및 변경된 내용중에서 도메인 레벨에서의 특이점이 있는지 먼저 확인해보면 좋습니다. 도메인 레벨은 앵간하면 초반에 추가되거나 컬럼이 추가 및 변경이 되거나 하지 않는 경우에 변경이 되었는지 그리고 그 주기가 빈번한지 먼저 보면 좋습니다. 그런데 현재 진행된 내용들을 살펴보니 도메인 레벨에서의 수정은 거의 일어나지 않았고 새로 추가된 도메인들도 기존의 도메인들과 별반 다르지 않고 유사한 형태로 추가되고 있어 특이점은 없어 보입니다. |
제목(title)
[4주차] RateLimit 구현 및 Jacoco 커버리지 테스트
작업 내용
이번 주차에서 고민되었던 지점이나, 어려웠던 점을 알려 주세요.
아래의 문제를 해결하지 못했습니다... 😓
ReservationJpaRepositoryTest가 실패@ActiveProfiles("test")애노테이션을 JPA 테스트에 추가리뷰 포인트