Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,21 @@ Reservation(예매)과 Seat(좌석)는 M:N 관계이므로 ReservedSeat 중간

## 성능측정

[성능측정보고서 링크](https://kind-artichoke-34f.notion.site/180a4bf15f1a805eadc6c3bbc20f74a9?pvs=4)
[성능측정보고서 링크](https://kind-artichoke-34f.notion.site/180a4bf15f1a805eadc6c3bbc20f74a9?pvs=4)

## 분산락 leaseTime 및 waitTime 설정 값과 이유

### 평균 소요 시간
- 예매 API의 평균 소요 시간: 570ms

### leaseTime 설정 값: 2초
- 설정 이유
- leaseTime은 트랜잭션 수행 중 락이 유지되는 시간입니다.
- 570ms는 평균 수행 시간이므로, **예외 상황(예: 네트워크 지연, 서버 부하 등)**을 고려하여 여유 시간을 추가해야 합니다.
- 일반적으로 평균 수행 시간의 2배 ~ 3배를 설정하는 것이 적당하므로, 2초로 설정했습니다.

### waitTime 설정 값: 2초
- 설정 이유:
- waitTime은 다른 요청이 락을 점유하고 있을 때, 현재 요청이 락 해제를 기다릴 최대 시간입니다.
- 평균 트랜잭션 수행 시간이 570ms이므로, 락이 해제될 때까지 기다릴 충분한 시간이라고 판단하여 2초로 설정했습니다.
- 이를 통해 대부분의 트랜잭션이 락 해제를 기다릴 여유를 가질 수 있습니다.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
@Retention(RetentionPolicy.RUNTIME)
public @interface DistributedLock {
String key();
long leaseTime() default 5;
long waitTime() default 5;
long leaseTime() default 2;
long waitTime() default 2;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.example.func;

import lombok.RequiredArgsConstructor;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;

@Component
@RequiredArgsConstructor
public class DistributedLockExecutor {

private final RedissonClient redissonClient;

public <T> T executeWithLock(List<String> lockKeys, long waitTime, long leaseTime, TimeUnit timeUnit, Supplier<T> action) {
List<RLock> locks = lockKeys.stream()
.map(redissonClient::getLock)
.toList();
boolean allLocked = false;

try {
allLocked = redissonClient.getMultiLock(locks.toArray(new RLock[0]))
.tryLock(waitTime, leaseTime, timeUnit);

if (!allLocked) {
throw new IllegalArgumentException("해당 좌석은 현재 다른 사용자가 예매를 진행하고 있습니다.");
}
Comment on lines +28 to +30

Choose a reason for hiding this comment

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

잠금에 대한 코드인데 좌석 예약에 대한 비지니스 로직이 들어가 있네요!?


return action.get();
} catch (InterruptedException e) {
throw new RuntimeException(e);

Choose a reason for hiding this comment

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

InterruptedException은 어느 상황에 발생할까요?
해당 상황이 언제 발생하고 어떻게 대응해야 하는지 생각해보셨으면 좋겠어요.

} finally {
if (allLocked) {
locks.forEach(lock -> {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
});
}
Comment on lines +36 to +42

Choose a reason for hiding this comment

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

쓴 자원은 꼭 반납해줘야 합니다👍 잘하셨어요

}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package com.example.reservation;

import com.example.aop.DistributedLock;
import com.example.entity.reservation.Reservation;
import com.example.entity.reservation.ReservedSeat;
import com.example.func.DistributedLockExecutor;
import com.example.message.MessageService;
import com.example.repository.reservation.ReservationRepository;
import com.example.reservation.request.ReservationServiceRequest;
Expand All @@ -14,6 +14,7 @@
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
Expand All @@ -22,20 +23,27 @@ public class ReservationService {
private final MessageService messageService;
private final ReservationValidate reservationValidate;
private final ReservationRepository reservationRepository;
private final DistributedLockExecutor lockExecutor;

// @DistributedLock(key = "reservation:screening:{#request.screeningId}:seat:{#request.seatIds}", leaseTime = 2, waitTime = 2)
@Transactional
@DistributedLock(key = "reservation:screening:{#request.screeningId}:seat:{#request.seatIds}", leaseTime = 10, waitTime = 5)
public ReservationServiceResponse reserve(ReservationServiceRequest request) {

Choose a reason for hiding this comment

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

예약 메소드에서 좌석이 붙어 있는지 혹은 최대 3개인지 등등의 검증이 이루어졌으면 좋겠습니다!
사용자의 요청은 100% 신뢰할 수 없으니깐요.


ReservationValidationResult validationResult = reservationValidate.validate(request);
List<String> lockKeys = request.getSeatIds().stream()
.map(seatId -> "reservation:screening:" + request.getScreeningId() + ":seat:" + seatId)

Choose a reason for hiding this comment

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

키 생성하는 로직을 따로 메서드로 관리하는건 어때요?

.toList();

return lockExecutor.executeWithLock(lockKeys, 2, 2, TimeUnit.SECONDS, () -> {
ReservationValidationResult validationResult = reservationValidate.validate(request);

Reservation reservation = createReservation(validationResult);
Reservation reservation = createReservation(validationResult);

reservationRepository.save(reservation);
reservationRepository.save(reservation);

messageService.send();
messageService.send();

return ReservationServiceResponse.of(reservation);
return ReservationServiceResponse.of(reservation);
Comment on lines +37 to +45

Choose a reason for hiding this comment

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

예약을 위해 redis에 특정 좌석이 예약이 되었는지 아닌지 저장하고 있는거 같아요! 관계형 데이터베이스에서 지원하는 낙관적 락을 사용하시는건 어때요? redis는 휘발성 데이터베이스이기 때문에 예약에 대한 정보를 저장하기에는 적합하지 않아 보입니다.

추가로, 형재님이 작성하신 코드의 흐름은 다음과 같아 보여요.

  1. 분산락을 통해 좌석 예약에 접근하는 사람은 한 사람이어야 함
  2. 예약이 되었는지 안되었는지 분산락을 통해 확인함
  3. 예약 성공

JPA에서 제공하는 @Version 어노테이션을 통한 낙관적 락을 사용해보시겠어요?

});
}

private Reservation createReservation(ReservationValidationResult validationResult) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.example.repository.reservation.ReservedSeatRepository;
import com.example.reservation.request.ReservationServiceRequest;
import lombok.RequiredArgsConstructor;
import org.redisson.api.RScript;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Component;

Expand Down Expand Up @@ -84,33 +85,30 @@ private Seats getSeats(List<Long> seatsIds) {
return new Seats(seatRepository.findAllById(seatsIds));
}

// private void validateAndReserveSeatsInRedis(Long screeningId, List<Long> seatIds) {
// String redisKey = "screening:" + screeningId + ":seats";
//
// // Lua 스크립트: 좌석 상태를 확인하고, 예약되지 않은 경우 예약 상태로 변경
// String luaScript =
// "for i, seatId in ipairs(ARGV) do " +
// " if redis.call('HGET', KEYS[1], seatId) == 'true' then " + // 이미 예약된 좌석 확인
// " return 0; " +
// " end; " +
// "end; " +
// "for i, seatId in ipairs(ARGV) do " +
// " redis.call('HSET', KEYS[1], seatId, 'true'); " + // 좌석 상태를 예약으로 변경
// "end; " +
// "return 1;";
//
// // Redisson의 Lua 실행 API 호출
// Long result = redissonClient.getScript().eval(
// RScript.Mode.READ_WRITE,
// luaScript,
// RScript.ReturnType.INTEGER,
// List.of(redisKey), // Redis 키
// seatIds.stream().map(String::valueOf).toArray() // ARGV: 좌석 ID 리스트
// );
//
// // Lua 스크립트 결과가 0이면 좌석 예약 실패 처리
// if (result == 0) {
// throw new IllegalArgumentException("이미 예매된 좌석입니다.");
// }
// }
private void validateAndReserveSeatsInRedis(Long screeningId, List<Long> seatIds) {
String redisKey = "screening:" + screeningId + ":seats";

String luaScript =
"for i, seatId in ipairs(ARGV) do " +
" if redis.call('HGET', KEYS[1], seatId) == 'true' then " +
" return 0; " +
" end; " +
"end; " +
"for i, seatId in ipairs(ARGV) do " +
" redis.call('HSET', KEYS[1], seatId, 'true'); " +
"end; " +
"return 1;";

Long result = redissonClient.getScript().eval(
RScript.Mode.READ_WRITE,
luaScript,
RScript.ReturnType.INTEGER,
List.of(redisKey),
seatIds.stream().map(String::valueOf).toArray()
);

if (result == 0) {
throw RESERVATION_EXIST_ERROR.exception();
}
}
}