문제사항 제시
오버부킹 이슈를 해결하려고 PESSIMISTIC_WRITE 락을 분명 걸었는데 락 적용이 되지 않았다.
코드로 예시를 들어보겠다.
Customer 엔티티 코드이다.
@Entity
@Table(name = "customer")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
@Getter
public class CustomerEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String name;
private String email;
private String password;
private String tel;
@OneToMany(mappedBy = "customer", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ScheduleEntity> schedules = new ArrayList<>();
...
Schedule 엔티티 코드이다.
@Entity(name = "schedule")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@ToString(exclude = {"studySeat", "customer"})
@Where(clause = "state = 'RESERVED' && started_time >= NOW()")
public class ScheduleEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REFRESH)
@JoinColumn(name = "customer_id", referencedColumnName = "id")
private CustomerEntity customer;
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.REFRESH)
@JoinColumn(name = "study_seat_id", referencedColumnName = "id")
private StudySeatEntity studySeat;
@Column(name = "started_time")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime startedTime;
@Column(name = "end_time")
@DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime endTime;
@Enumerated(value = EnumType.STRING)
private ScheduleState state;
...
StudySeat 엔티티 코드이다.
@Entity
@Table(name = "study_seat")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
@Getter
public class StudySeatEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String seatNumber;
private boolean occupied;
@OneToMany(mappedBy = "studySeat", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ScheduleEntity> schedules = new ArrayList<>();
...
StudySeatService 코드이다.
public CommandReserveSeatResponse reserveSeat(final ReserveSeatRequest command, final long studySeatId) {
Customer customer = findByCustomerId(command.getCustomerId());
StudySeat studySeat = findByStudySeatId(studySeatId);
check(studySeat.isReservable(command.getStartedTime(), command.getEndTime())
, INVALID_SCHEDULE_RESERVATION_ALREADY_OCCUPIED)
;
studySeat.reserve(
customer, studySeat,
command.getStartedTime(), command.getEndTime()
);
studySeatRepository.save(studySeat);
return new CommandReserveSeatResponse(success, studySeatId);
}
Customer, StudySeat은 Schedule과 1:M 관계이다. DDD 구조상 StudySeat 과 Schedule로 하나의 aggregate로 묶여져 있고 StudySeat이 Schedule을 제어하는 역할을 한다.
그래서 이와 같은 파일 구조를 가진다.
그래서 결론은 StudySeat의 Repository에 findById에 Lock을 걸었는데 적용되지 않았다.
여러개의 쓰레드를 만들어서 한꺼번에 실행 시키는 테스트 코드를 실행시켰는데, 콘솔에 찍히는 query문을 여러번 눈을 씻고 찾아봐도 select for update가 계속 찍혔는데도 내가 원했던 결과가 나오지 않았다.
해결책 모색
생각 해보니 Service 코드를 보면 Customer 부터 찾아오는 것을 확인할 수 있었다.
public CommandReserveSeatResponse reserveSeat(final ReserveSeatRequest command, final long studySeatId) {
Customer customer = findByCustomerId(command.getCustomerId());
StudySeat studySeat = findByStudySeatId(studySeatId);
이 순서대로면 CustomerRepository에 락을 적용해야 원하는 결과가 나온다.
왜냐하면 Customer와 Schedule도 1:M으로 매핑되어 있기 때문에 Customer먼저 찾으면
이런꼴이 되기 때문에 isReservable 메소드(예약이 가능한지 검증하는 메소드)에 들어가면 schedule이 비어 있어서 모두 통과가 되는 것이다. 그러면 락이 걸리는 StudySeat 부터 검색하게 하면 해결이 된다.
적용
public CommandReserveSeatResponse reserveSeat(final ReserveSeatRequest command, final long studySeatId) {
StudySeat studySeat = findByStudySeatId(studySeatId);
Customer customer = findByCustomerId(command.getCustomerId());
결과
Customer를 먼저 찾아오게 되면 여러개 쓰레드 모두가 매핑된 Schedule이 비어있다고 인식된다. 그래서 락이 걸리는 StudySeat 객체 부터 찾아오게 하면서 이 문제를 해결했다. 락을 걸 때 순서 또한 중요하다고 느꼈다.