Skip to content

[4주차] RateLimit 구현 및 Jacoco 커버리지 테스트#99

Merged
DongHyunKIM-Hi merged 57 commits intohanghae-skillup:zhdiddlfrom
zhdiddl:feature/week4
Feb 6, 2025
Merged

[4주차] RateLimit 구현 및 Jacoco 커버리지 테스트#99
DongHyunKIM-Hi merged 57 commits intohanghae-skillup:zhdiddlfrom
zhdiddl:feature/week4

Conversation

@zhdiddl
Copy link

@zhdiddl zhdiddl commented Feb 4, 2025

제목(title)

[4주차] RateLimit 구현 및 Jacoco 커버리지 테스트

작업 내용

  • Google Guava 사용해 Rate Limit 적용
  • Lua Script 사용해 Redis 기반 Rate Limit 로직으로 대체
  • 컨트롤러, 서비스, 리포지토리 테스트 작성
  • Jacoco 커버리지 테스트 후 결과 README에 추가

이번 주차에서 고민되었던 지점이나, 어려웠던 점을 알려 주세요.

아래의 문제를 해결하지 못했습니다... 😓

  • 문제: 작성한 테스트 중 ReservationJpaRepositoryTest가 실패
  • 가설 수립: RedisTemplate 빈을 주입하는 것에 실패한 것으로 판단
NoSuchBeanDefinitionException: No qualifying bean of type 'org.springframework.data.redis.core.RedisTemplate<java.lang.String, java.lang.String>' available
  • 시도한 내용:
  1. 테스트용 Redis 설정 클래스 작성
@TestConfiguration
public class RedisTestConfig {

    @Bean
    public RedisTemplate<String, String> redisTemplate() {
        RedisTemplate<String, String> template = Mockito.mock(RedisTemplate.class);
        when(template.opsForValue()).thenReturn(Mockito.mock(ValueOperations.class));
        return template;
    }
}
  1. JPA 테스트에 해당 빈을 주입하는 애노테이션 추가

@DisplayName("[JPA 테스트] 예약 리포지토리")
@Import(RedisTestConfig.class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 실제 DB에서 테스트
@DataJpaTest
class ReservationJpaRepositoryTest {
...
  • 다른 시도: test 용 application 을 아래처럼 작성해서 @ActiveProfiles("test") 애노테이션을 JPA 테스트에 추가
spring:
  cache:
    type: none
  data:
    redis:
      host: localhost
      port: 0  # Redis 연결 방지
      timeout: 0
  • 결과: JPA 테스트만 성공하고 다른 테스트가 모두 실패
java.lang.IllegalStateException: ApplicationContext failure threshold (1) exceeded

리뷰 포인트

  • 위 문제의 원인을 잘 이해하지 못해 어떻게 해결할 수 있을지 궁금합니다.
  • Test Fixtures 적용하지 못한 상태입니다. 인프라 모듈과 애플리케이션 모듈에 테스트 코드가 분산되어 있고 각 테스트에서 필요한 객체가 다르다 보니 java-test-fixtures를 어떻게 적용해야 할지 잘 모르겠습니다.
  • 기능이 추가되면서 헥사고날 아키텍처 기반으로 설계한 초기 방향성과 멀어진 것 같은데 리팩토링이 필요하다면 어떤 부분을 중점적으로 고민해야 할지 궁금합니다.
  • (3주차 PR이 merge가 되지 않아 저번 주 커밋까지 포함된 것 같습니다. 이번 주 추가한 커밋은 cc1050d 부터 입니다)

- 기존 코드는 검색 조건이 추가되면 인터페이스 변경이 필요함
- `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

Choose a reason for hiding this comment

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

querydsl을 사용하고 있는 상황이라면 엔티티 간의 Join을 위한 OneToMany, ManyToOne 같이 Join을 위한 어노테이션 사용을 지양하는 경향이 있습니다. 그 이유로는 JPA의 연관 관계를 사용하면 자동으로 생성되는 쿼리가 복잡해지고, 성능 최적화가 어려울 수 있기 떄문입니다. 반면 QueryDSL을 사용하면 필요한 필드만 선택적으로 조회하거나, 복잡한 조인 조건을 직접 제어할 수 있어서 더 유연하게 쿼리를 작성할 수 있습니다!

100 % 안좋다는 것은 아닌데 이러한 관점도 있으니 참고 해주시면 좋을 것 같습니다!

protected SeatNumber() {}

private SeatNumber(String seatRow, int seatColumn) {
private SeatNumber(Character seatRow, int seatColumn) {

Choose a reason for hiding this comment

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

생성자 어노테이션을 사용하지 않은 이유가 특별하게 있는 것이 아니면 사용하는 것을 추천 드립니다. 유지보수 측면에서 혹은 다른 개발자가 누락할수있는 실수가 발생할수있기 때문에 자동으로 관리해주는 것이 좋다고 생각합니다

reservation.getSeatReservations().add(seatReservation); // 좌석 정보를 예약 내역에 저장
for (ScreeningSeat screeningSeat : requestedSeats) {
// 좌석에 Optimistic Lock 적용하면서 예약된 좌석인지 확인
screeningSeat.reserve(); // 좌석을 예약 상태로 변경

Choose a reason for hiding this comment

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

현재 도메인에서는 1개의 상품에 대해서 선착순으로 경쟁하는 상황이지만 다수의 상품에서 선착순으로 경쟁할 때는 ( ex) 선착순 특가 잔여 상품 3개 ) 락 획득 실패한 요청에 대해서도 재시도 처리를 넣어주어서 처리하는 방법도 많이 사용되고 있으니 참고해주시면 좋을 것 같아요. @retryable을 통해서 ObjectOptimisticLockingFailureException 예외 발생시 동작하도록 하는 식으로 말이죠 ㅎㅎ

@DongHyunKIM-Hi
Copy link

DongHyunKIM-Hi commented Feb 6, 2025

좋으점

  • 코드가 깔끔하고 어떤 생각으로 해당 코드를 작성했는지 명확하게 알 수 있어서 좋았습니다.
  • 다양한 방식으로 락을 시도하고 이를 비교한 내용이 있어서 좋았습니다.

위 문제의 원인을 잘 이해하지 못해 어떻게 해결할 수 있을지 궁금합니다.
해당 내용만으로는 어떻게 해결해야 할지 명확하게 판단이 잘 안가는 것 같습니다. 우선 현재 작성된 코드를 보니 redisconfig쪽에 RedisTemplate 관련된 설정이 없는 것으로봐서 일단 RedisTemplate<String, String>을 명시해주고 (필요 없을지언정 일단 어디가 문제인지 명확하게 하기 위해서) 그래도 문제가 발생하는지 에러 로그가 변경되는지 확인해볼 것 같습니다.
변경시 다른 에러로 변경되거나 해결된다? 그럼 문제가 더 추려지는 것이고
변경이 안된다? 그럼 redis 관련 빈 등록 문제가 아니니 다른 테스트 환경 문제를 볼것 같습니다. 혹은 @SpringBootTest로 돌려서 모든 설정을 로드 했을 경우에도 에러가 뜨는지 한번 더 확인하면서 문제를 좁혀 갈 것 같습니다.

Test Fixtures 적용하지 못한 상태입니다. 인프라 모듈과 애플리케이션 모듈에 테스트 코드가 분산되어 있고 각 테스트에서 필요한 객체가 다르다 보니 java-test-fixtures를 어떻게 적용해야 할지 잘 모르겠습니다.

아래 링크를 참조해보면 좋을 것 같습니다.
https://medium.com/@jojiapp/gradle-multi-module에서-testfixtures를-이용하여-테스트-코드-중복-줄이기-3a4737f574f
https://leeeeeyeon-dev.tistory.com/122
https://toss.tech/article/how-to-manage-test-dependency-in-gradle

기능이 추가되면서 헥사고날 아키텍처 기반으로 설계한 초기 방향성과 멀어진 것 같은데 리팩토링이 필요하다면 어떤 부분을 중점적으로 고민해야 할지 궁금합니다.

개인적으로는 헥사고날 아키텍처 방향성에 맞게 잘 진행하고 있는 것 같아요. 그래도 자가 진단을 하고 싶다면 우선 현재 추가 및 변경된 내용중에서 도메인 레벨에서의 특이점이 있는지 먼저 확인해보면 좋습니다. 도메인 레벨은 앵간하면 초반에 추가되거나 컬럼이 추가 및 변경이 되거나 하지 않는 경우에 변경이 되었는지 그리고 그 주기가 빈번한지 먼저 보면 좋습니다. 그런데 현재 진행된 내용들을 살펴보니 도메인 레벨에서의 수정은 거의 일어나지 않았고 새로 추가된 도메인들도 기존의 도메인들과 별반 다르지 않고 유사한 형태로 추가되고 있어 특이점은 없어 보입니다.
다만 validate 는 논의해볼만한 관점입니다. 이유는 현재 인프라 모듈에서 바로 도메인 모듈에 접근하여 검증을 하고 있습니다. 이건 외부 요청이 어플리케이션을 거치지 않고 바로 도메인 모듈에 접근하는데 해당 구조는 생각해볼만하고 개인적으로는 입력 데이터 및 프로세스 흐름 검증은 애플리케이션 계층에서 처리하는 것이 적절하다고 생각합니다!
이후 어플리케이션 모듈에서 비즈니스 로직이 수행이된 이후에 검증은 도메인 모듈에서 진행되는 것이 맞다고 보고 있습니다.

@DongHyunKIM-Hi DongHyunKIM-Hi merged commit 4d9afa5 into hanghae-skillup:zhdiddl Feb 6, 2025
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants