Skip to content

[3주차] 예약 API 구현 및 분산락 적용#83

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

[3주차] 예약 API 구현 및 분산락 적용#83
DongHyunKIM-Hi merged 42 commits intohanghae-skillup:zhdiddlfrom
zhdiddl:feature/week3

Conversation

@zhdiddl
Copy link

@zhdiddl zhdiddl commented Jan 27, 2025

제목(title)

주차와 함께 변경 사항을 요약하여 구성해 주세요.

  • 예약 API에 잠금 메커니즘 적용 후 성능 테스트 수행

작업 내용

이번 PR에서 진행된 주요 변경 사항을 기술해 주세요.
코드 구조, 핵심 로직 등에 대해 설명해 주시면 좋습니다. (이미지 첨부 가능)
ex: ConcurrentOrderService에 동시 주문 요청 처리 기능 추가

  • 예약 API 구현
  • 비관락 적용
  • 테이블 디자인 리팩토링 후 낙관락 적용
  • AOP 기반 분산락 적용 후 성능 테스트 결과 README 추가
  • 함수형 기반 분산락 적용 후 성능 테스트 결과 README 추가

발생했던 문제와 해결 과정을 남겨 주세요.

ex) 문제 1 - 다수의 사용자가 동시에 같은 리소스를 업데이트할 때 재고 수량이 음수로 내려가는 데이터 불일치 문제 발생
해결 방법 1 - Redis SET 명령어에 NX(Not Exists)와 PX(Expire Time) 옵션을 활용해 락을 설정했습니다. 이유는 ~

  • 낙관락 적용 단계에서 Version을 사용한 충돌 제어를 기존 테이블 구조에 적용할 수 없는 상황이라 테이블 구조 자체를 변경하는 과정에 시간을 많이 사용했습니다. 낙관락을 위해 구조 자체를 바꾼 것이 옳은 선택인지, 현재 구조가 괜찮은지 판단이 어려운 것 같습니다.

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

과제를 해결하며 특히 어려웠던 점이나 고민되었던 지점이 있다면 남겨주세요. 이 부분이 잘 반영된 것인지 궁금합니다.

  • 동시성 테스트 시 OptimisticLockingFailureException이 발생하며 409 응답이 나와야 한다고 알고 있는데, 테스트 결과에서 409가 나오지 않아 락이 올바르게 적용된 상태인지 확인이 어려웠습니다. 구현에서 어떤 부분을 개선해야 할지 궁금합니다.
  • 함수형 분산락 적용시 retry 로직을 적용하고 나서 성능 테스트를 했을 때 평균 응답 시간이 1.5배 증가하는 것으로 확인했습니다. 이런 결과는 어떻게 최적화가 가능한지 궁금합니다.
  • k6 성능 테스트 작성 시 짧은 시간에 같은 좌석에 대해 요청이 몰리는 시나리오를 작성했는데, 이러한 방향으로 테스트를 하는 게 맞는지 궁금합니다.

- 기존 코드는 검색 조건이 추가되면 인터페이스 변경이 필요함
- `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 작성
- `ReservationService`에 비관락 대신 낙관락 적용
- 변경된 DB 구조와 낙관락 메커니즘에 맞게 테스트를 수정
- 정상적으로 동시성이 제어되는 것을 확인
- 분산 락 적용을 위한 의존성 추가
- @distributedlock 애노테이션을 이용해 AOP 기반 분산 락 적용
- `screeningId` 및 `seatId`를 조합해 락 키를 설정해 충돌 방지
- 트랜잭션 실행 시간이 500ms ~ 750ms 으로 관찰됨
- 이를 기반으로 leaseTime(잠금 유지 시간)을 약 2배인 1초로 설정
- waitTime(대기 시간)은 leaseTime의 2배인 2초로 설정
- 성능 테스트 결과 README에 반영
- 직접 분산락을 호출해 특정 코드 블록에만 적용하는 방식으로 변경
- `saveReservationAndSeats` 메서드 실행 전에 락을 획득
- 재시도 로직 추가 후 테스트 시 응답 속도가 더 느려져 구현에서 제외
- 성능 테스트 시 응답 시간에서 약간 개선됨을 확인
@ann-mj
Copy link

ann-mj commented Jan 28, 2025

@zhdiddl 안녕하세요 진아님 ~ 3주차 열심히 구현해주셔서 감사합니다 ! 리뷰 진행하겠습니다 ~

// 함수형 분산 락을 특정 메서드에 적용
String lockKey = "seat_reservation:" + request.screeningId() + ":" + request.seatIds();
long waitTime = 2000;
long leaseTime = 1000;
Copy link

Choose a reason for hiding this comment

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

평균 작업시간(500ms) �기준 고려해서 2배로 설정하신 것 같은데, GC가 발생하는 상황이라던지 긴 시간 대기가 발생할 수 있어 3초~5초 정도로 하는건 어떨지 의견드려봅니다.

Copy link
Author

Choose a reason for hiding this comment

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

해당 부분 반영해 leaseTime을 5초로 수정했습니다! waitTime은 leaseTime의 2배로 잡으면 10초가 되어 다소 길어지는 데, leaseTime과 동일하게 5초로 설정하는 것도 괜찮을까요?

Copy link

Choose a reason for hiding this comment

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

leaseTime 이 5초이긴 해서 보다 여유롭게 7초 정도로 해둬도 좋을 것 같습니다.

}

@Override
public <T> T executeWithLock(String key, long waitTime, long leaseTime, Supplier<T> task) {
Copy link

Choose a reason for hiding this comment

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

적절하게 잘 구현해주셨습니다 👍


private final MessageServicePort messageServicePort;

@DistributedLock(key = "'seat_reservation:' + #request.screeningId + ':' + #request.seatIds.toString()")
Copy link

Choose a reason for hiding this comment

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

@Transacational 메서드로 선언되어 있는데, 분산락을 적용한경우
동시성 이슈가 발생할 수 있을 것 같습니다.

  1. 락 해제 시점과 트랜잭션 커밋 시점이 일치하지 않을 가능성이 있습니다.
  2. Thread A가 @DistributedLock을 통해 분산락을 획득하고, 트랜잭션을 시작합니다.
  3. Thread A가 트랜잭션 내에서 데이터베이스 작업을 수행합니다.
  4. 작업이 끝난 후, Thread A가 분산락을 해제합니다. (이 시점에서 트랜잭션은 아직 커밋되지 않았습니다.)
  5. Thread B가 동일한 키에 대해 분산락을 획득하고, 같은 좌석에 대해 작업을 시작합니다.
  6. Thread A의 트랜잭션이 아직 커밋되지 않은 상태라면, Thread B는 Thread A의 작업 결과를 보지 못하고 중복된 데이터 작업(예: 좌석 중복 예약)을 시도할 수 있습니다.

트랜잭션을 분산락 안으로 이동하면 좋을 것 같습니다 :)

Copy link
Author

Choose a reason for hiding this comment

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

락을 먼저 획득한 다음 트랜잭션이 시작되도록 설정해야 하는군요.
피드백 주신 부분에 대해 알아보며 많은 공부가 되었습니다. 감사합니다!

분산락을 함수로 적용한 경우 transactionTemplate를 사용해 분산락 내부에서 트랜잭션이 실행되도록 리팩토랭했습니다.
분산락을 AOP로 처리하는 경우에는 서비스 클래스대신 DistributedLockAspect 클래스 로직 안에서 적용한다고 보면 될까요?

Copy link

@ann-mj ann-mj Jan 31, 2025

Choose a reason for hiding this comment

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

Reservation reservation = saveReservationAndSeats(screening, member, requestedSeats);
                        return ReservationResponseDto.fromEntity(reservation);

넵! 요 로직을 메서드로 만들고, Transactional 로 감싸주면 좋을 것 같습니다.

@ann-mj
Copy link

ann-mj commented Jan 28, 2025

낙관락 적용 단계에서 Version을 사용한 충돌 제어를 기존 테이블 구조에 적용할 수 없는 상황이라 테이블 구조 자체를 변경하는 과정에 시간을 많이 사용했습니다. 낙관락을 위해 구조 자체를 바꾼 것이 옳은 선택인지, 현재 구조가 괜찮은지 판단이 어려운 것 같습니다.

구조적으로는 잘 구현해주신 것으로 보입니다. 낙관락을 제어해보기 위함이었다고 생각되구요.
추후 실무에서, 동시성 이슈가 발생할 수 있을만한 상황인데, 구조까지 변경해야 한다면? 보통 분산락 형태로 제어를 많이 하는 것 같습니다.

@ann-mj
Copy link

ann-mj commented Jan 28, 2025

동시성 테스트 시 OptimisticLockingFailureException이 발생하며 409 응답이 나와야 한다고 알고 있는데, 테스트 결과에서 409가 나오지 않아 락이 올바르게 적용된 상태인지 확인이 어려웠습니다. 구현에서 어떤 부분을 개선해야 할지 궁금합니다.

OptimisticLockingFailureException 에 대한 예외를 잡아서 별도의 409 코드로 처리하면 될 것 같습니다. 현재는 500에러가 뜨고 계신 것이지요?


@DisplayName("100개 스레드가 동일한 좌석을 동시에 예약할 때 중복 예약이 발생하지 않는다.")
@Test
void shouldPreventConcurrentSeatReservation() throws InterruptedException {
Copy link

Choose a reason for hiding this comment

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

테스트 코드가 잘 작성된 것 같습니다 👍

@ann-mj
Copy link

ann-mj commented Jan 28, 2025

함수형 분산락 적용시 retry 로직을 적용하고 나서 성능 테스트를 했을 때 평균 응답 시간이 1.5배 증가하는 것으로 확인했습니다. 이런 결과는 어떻게 최적화가 가능한지 궁금합니다.

  • retry 로직을 적용하신 이유가 있으셨을지 궁금합니다.

  • retry 로 인해서 응답시간이 증가한 이유는 공유 자원을 얻기 위한 대기 시간으로 인한 지연이기 때문에, retry 로직 자체를 제거하지 않는 이상은 최적화가 어려울 거라 생각합니다.

    • retry 로직을 제거할 수 없다면, retry 를 바로하는게 아니고 기다리는 시간을 지수적으로 늘려나가는 방법이 있습니다. (저도 좀 더 찾아봤습니다.. ㅎㅎ)
      • 처음엔 50ms 기다렸다가, 그 다음엔 100ms, 그 다음엔 200ms 를 기다리도록 하는 방법이요.
  • 소스 코드로만 본다면, retry 를 제거하고, 예외를 발생시키는게 좋은 방법 같습니다. (현재 적용해주신 로직처럼요)

  • 시스템 관점에서는 api 에 rate limit 등을 적용 시켜서 lock을 획득하지 못하는 상황이 발생할 가능성을 줄여주거나
    메세지 큐를 사용하는 방법이 있을 것 같습니다.

@ann-mj
Copy link

ann-mj commented Jan 28, 2025

k6 성능 테스트 작성 시 짧은 시간에 같은 좌석에 대해 요청이 몰리는 시나리오를 작성했는데, 이러한 방향으로 테스트를 하는 게 맞는지 궁금합니다.

  • 테스트 목적이 동시성 이슈에 대한 해결이 잘 되고, http duration 이 500ms 이하로 하겠다는 목표를 가지고 하신 것으로 보여서 적절한 것으로 보입니다.
  • 좀 더 현실적인 테스트를 해보고 싶으시다면, 같은 좌석이 아니라, 여러개의 seatIds 에 대한 케이스를 늘려서 해보는 것도 좋을 것 같습니다. (현재는 1개이지만, 10개 정도로)
    • 응답속도에 대한 큰 차이가 있을지 비교해보는 것을 추천드립니다.

private static final int MAX_SEATS_PER_SCREENING = 5;

public void validateReservationRequest(Long screeningId, Long memberId, List<Long> seatIds) {
if (Objects.isNull(screeningId)) {
Copy link

Choose a reason for hiding this comment

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

if(screeningId == null)
저는 보통 if 문 안에서는 Objects.isNull을 잘 사용하지는 않습니다.

image

이부분은 보통 stream 에서 주로 사용합니다.
ex) .filter(Objects::isNull) // null만 걸러냄

Copy link
Author

Choose a reason for hiding this comment

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

추가 메서드 호출이 불필요한 경우라고 봐야겠네요. 작은 부분도 더 신경 써야겠습니다. 감사합니다!

config.useSingleServer()
.setAddress("redis://localhost:6379")
.setConnectionPoolSize(10)
.setConnectionMinimumIdleSize(5);
Copy link

Choose a reason for hiding this comment

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

연결 타임아웃 설정, 재시도 횟수 등도 설정 할 수 있으니 나중에 필요하실 때 한번 찾아보시는 것을 추천드립니다 👍

Copy link
Author

Choose a reason for hiding this comment

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

해당 부분 리팩토링 적용했습니다 감사합니다!

@ann-mj
Copy link

ann-mj commented Jan 28, 2025

@zhdiddl

좋았던 점

  • 커밋메세지가 잘 분리되어 있고, readme 정리를 잘 해주셔서 파악하기가 수월했던 것 같습니다.
  • 통합테스트와 분산락에 대한 요구사항과 로깅까지 꼼꼼하게 구현해주셨습니다 👍
  • validation 로직도 전반적으로 잘 작성해주셨습니다.

아쉬운 점

  • MessageService send 부분이 빠져있긴 했습니다.

추가적인 의견

  • leaseTime 이 1초가 아니라 조금 더 여유롭게 했었어도 좋았을 것 같아요. (Stop the world 와 같은 긴 시간 대기해야하는 상황이 발생할 수 있기에)
  • transactional 어노테이션과 분산락을 함께 사용하실 때에는, 락의 범위 안에 트랜젝션 범위가 포함되도록 하신다면 더욱 좋을 것 같습니다.

- 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 추가
@DongHyunKIM-Hi DongHyunKIM-Hi merged commit 06aff06 into hanghae-skillup:zhdiddl Feb 6, 2025
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.

3 participants