uncategorized

기자재 대여 예약 기능약 랩실 공간 대여 예약 기능으로 확장하기

문제 상황

기리기리 프로젝트에서 기자재 대여 반납 기능을 중점적으로 개발하다가 광운대학교 미디어커뮤니케이션 랩실에서는 기자재 뿐만 아니라 랩실도 대여 반납을 할 수 있어야 한다는 피드백을 받았다.

문제는 랩실과 기자재 로직이 상당 부분 비슷하지만 랩실의 대여 로직이 기자재 대여 로직과 세세한 부분에서 다르다.

그러면 문제 상황을 여러 가지로 나눠서 해결해본다.

  1. 엔티티 객체를 추상화하기
  2. 대여 로직을 추상화해서 재활용하기

개선 방법

엔티티 객체 추상화하기

기리기리 프로젝트에서는 Spring Data JPA를 사용한다. 기존에 Equipment라는 이름으로 기자재 엔티티를 관리한다. 이제 랩실을 추가로 관리해야 한다. 랩실도 기자재처럼 대여가 가능해야 한다.
기자재와 랩실의 공통점을 먼저 생각해본다. 논리적인 개념으로는 기리기리 서비스의 관점에서 기자재와 랩실은 모두 자산이다. 그래서 다음과 같이 추상 클래스를 구현해본다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Entity
@Getter
@Setter
@Table(name = "asset")
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
public abstract class RentableAsset {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(nullable = false)
private Long id;

@Column(nullable = false, unique = true)
private String name;

@Column(nullable = false)
private Integer totalQuantity;

@Column(nullable = false)
private Integer rentableQuantity;

@Column(nullable = false)
private Integer maxRentalDays;
private LocalDate deletedAt;

protected RentableAsset() {
}

private RentableAsset(final Long id, final String name, final Integer totalQuantity,
final Integer rentableQuantity, final Integer maxRentalDays, final LocalDate deletedAt) {
// 생략
}

protected RentableAsset(Long id, String name, Integer totalQuantity, Integer rentableQuantity,
Integer maxRentalDays) {
this(id, name, totalQuantity, rentableQuantity, maxRentalDays, null);
}

@Override
public abstract Integer getRemainQuantity(int reservedCount);
}

RentableAsset은 대여 대상 자산을 의미한다. 랩실과 기자재가 공통적으로 수행해야 할 로직을 모으고 있다. 특히 getRemainQuantity메서드는 추상 메서드로 랩실과 기자재가 서로 다르게 적용될 수 있다.
데이터베이스의 다형성 전략은 단일 테이블에 모으는 전략을 취했다. 조회 시 불필요한 조인 비용을 줄일 수 있고, 우리 서비스의 데이터 특성 상 기자재가 자산의 대부분이고 랩실은 데이터가 적기 때문에 하나의 테이블로 모으는 게 유리할 것이라 판단했다. 그리고 대여 예약 엔티티가 id를 통해 간접 참조하는데 단일 테이블 전략은 id의 유일성을 관리하기 쉽다. 만약 여러 테이블을 사용하면 ID generator와 같이 id의 유일성을 여러 테이블을 걸쳐서 고민해야 해서 번거롭다.

기존의 기자재 클래스는 RentableAsset을 상속받도록 구현했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Getter
@Entity
@Setter
@DiscriminatorValue("equipment")
public class Equipment extends RentableAsset {

@Enumerated(EnumType.STRING)
private Category category;

private String maker;

private String imgUrl;

private String description;

private String components;

private String purpose;

private String rentalPlace;

protected Equipment() {
}

@Builder
public Equipment(final Long id, final Category category, final String maker, final String name,
final String imgUrl, final String description,
final String components, final String purpose, final String rentalPlace,
final Integer totalQuantity, final Integer rentableQuantity, final Integer maxRentalDays) {
// 생략...
}

// 생략...

@Override
public Integer getRemainQuantity(int reservedCount) {
if (reservedCount > getRentableQuantity()) {
throw new EquipmentException("대여 가능 갯수가 대여 된 갯수보다 크면 안됩니다!");
}
return getRentableQuantity() - reservedCount;
}

기자재는 실제 기자재에 필요한 정보들을 가지고 있다. 기자재는 getRemainQuantity를 보면 대여 가능 갯수에서 예약할 갯수를 빼는 방식으로 구현되어 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Entity
@Getter
@Setter
@DiscriminatorValue("lab_room")
public class LabRoom extends RentableAsset {

private boolean isAvailable;
private Integer reservationCountPerDay;

@Lob
private String notice;

protected LabRoom() {
}

@Builder
private LabRoom(final Long id, final String name, final Integer totalQuantity, final Integer rentableQuantity,
final Integer maxRentalDays,
final boolean isAvailable, final Integer reservationCountPerDay, final String notice) {
//생략...
}

@Override
public Integer getRemainQuantity(final int reservedCount) {
if (reservedCount > getRentableQuantity()) {
throw new LabRoomException("대여 가능 갯수가 대여 된 갯수보다 크면 안됩니다!");
}
if (!this.isAvailable)
return 0;
return getRentableQuantity() - reservedCount;
}
}

새롭게 구현한 랩실이다. 랩실은 랩실 기능에 필요한 데이터를 가지고 있다. getRemainQuantity를 자신이 운영 가능한 상태인지 확인하여 운영이 안되고 있는 랩실은 남은 갯수를 0으로 반환하는 로직을 추가해줬다.

대여 로직을 추상화해서 재활용하기

기존의 기자재 대여 로직을 살펴보면 다음과 같다.

1
2
3
4
5
6
1. 사용자의 담은 기자재 정보를 가져온다.
2. 담은 기자재와 사용자 요청 데이터를 기반으로 예약 객체와 예약 상세 객체를 생성한다.
3. 사용자의 담은 기자재를 모두 제거한다.
4. 사용자 페널티 여부를 검증한다.
5. 각 예약 상세의 대여 신청 갯수가 실제로 대여 가능한지 검증한다.
6. 예약 객체들을 저장한다.

하지만 랩실 대여 로직을 살펴보면 다음과 같다.

1
2
3
4
5
6
1. 사용자 요청 데이를 통해 예약 객체와 예약 상세 객체를 생성한다.
2. 사용자 페널티 여부를 검증한다.
3. 각 예약 상세의 대여 신청 갯수가 실제로 대여 가능한지 검증한다.
4. 대여 신청자가 신청한 기간과 겹치는 랩실 대여 예약을 한 기록이 있는 지 검증한다.
5. 대여하려는 랩실이 대여 신청 기간동안 운영가능한지 검증한다.
6. 예약 객체들을 저장한다.

문제점

위 로직 그대로 구현하면 문제가 생긴다. 분명 페널티 검증, 예약 객체 저장 등 비슷한 형식을 가진 것으로 보인다. 만약 이 공통된 로직에서 변경이 생기면 기자재 로직 코드와 랩실 로직 모두 변경이 생긴다. (OCP 위반)
그리고 각 대여 로직이 복잡해져서 코드를 이해하기도 어려워진다.

공통 로직과 달라지는 로직의 분리

자 그러면 이 두 로직 간의 비슷한 부분과 달라지는 부분을 분리해보자.
먼저 랩실과 기자재 대여 로직에서 공통되는 부분을 본다.

1
2
3
4
1. 사용자의 요청 데이터를 사용해서 예약 객체와 예약 상세 객체를 생성한다.
2. 사용자 페널티 여부를 검증한다.
3. 각 예약 상세의 대여 신청 갯수가 실제로 대여 가능한지 검증한다.
4. 예약 객체들을 저장한다.

그렇다면 기자재와 랩실의 차이는 어디서 비롯되는가?

1
2
3
4
기자재는 대여 예약 객체를 생성할 때 담은 기자재 정보를 가져와서 만든다.
기자재는 대여 예약에 성공하면 담은 기자재를 모두 제거해야 한다.

랩실은 신청자가 예약 신청 기간 중 다른 랩실을 대여 예약한 기록이 있는지 검증한다.

즉 정리하면 대여 예약 객체를 생성하는 로직과 생성된 대여 예약 객체를 기반으로 검증하는 로직에서 약간의 차이가 있다. 차이가 있는 로직을 공통 로직에서 분리해서 공통 로직을 재활용할 수 있도록 해보자.

대여 예약 객체를 생성하는 코드 분리

먼저 분리되어야 하는 코드를 먼저 구현해본다. 대여 예약 객체를 생성하는 로직을 분리해본다.

기자재 대여 예약을 만드는 코드이다.
담은 기자재로부터 대여 예약 상세를 만드는 코드는 ReservationSpecMapper 인터페이스로 추상화되어 있다.

1
2
3
4
5
6
7
8
9
10
11
12
@Component
@RequiredArgsConstructor
public class EquipmentReservationCreator {
private final ReservationSpecMapper reservationSpecMapper;

public List<Reservation> create(final Long memberId,
final AddEquipmentReservationRequest addReservationRequest) {
final List<ReservationSpec> specs = reservationSpecMapper.map(memberId);
return mapToReservations(memberId, addReservationRequest, specs);
}
//생략..리.
}

랩실 대여 예약을 만드는 코드이다.
사용자의 입력값을 기반으로 대여 예약 객체를 만드는데 예약 기간동안 랩실이 운영가능 한 지 검증하는 로직도 존재한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
@RequiredArgsConstructor
public class LabRoomReservationCreator {
private final LabRoomService labRoomService;

public Reservation create(final Long memberId, final AddLabRoomReservationRequest addLabRoomReservationRequest) {
final LabRoom labRoom = labRoomService.getLabRoomByName(addLabRoomReservationRequest.labRoomName());
final RentalPeriod period = new RentalPeriod(addLabRoomReservationRequest.startDate(),
addLabRoomReservationRequest.endDate());
validateLabRoomForReserve(labRoom, period);
final RentalAmount amount = RentalAmount.ofPositive(addLabRoomReservationRequest.renterCount());
final ReservationSpec spec = mapToReservationSpec(labRoom, period, amount);
return mapToReservation(memberId, addLabRoomReservationRequest, spec);
}

private void validateLabRoomForReserve(final LabRoom labRoom, RentalPeriod period) {
labRoomService.validateDays(labRoom, period.getRentalDays());
if (!labRoom.isAvailable())
throw new LabRoomNotAvailableException();
}
// 생략...
}

생성된 대여 예약 객체를 검증하는 코드 분리

랩실의 경우 생성된 대여 예약을 검증해야 하는 로직이 추가로 필요하다. 반면 기자재는 아직 그런 검증은 없다. 이런 차이를 추상화하는 인터페이스를 추가해본다.

1
2
3
4
5
6
7
8
9
@FunctionalInterface
public interface ReserveValidator {
static ReserveValidator noExtraValidation() {
return reservation -> {
};
}

void validate(Reservation reservation);
}

공통 로직 구현

대여 예약하는 로직 중 공통되는 로직을 모은다. 그리고 추상화한 인터페이스를 통해 검증 객체를 주입받아서 적용한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Component
@RequiredArgsConstructor
public class ReserveTemplate {

private final ReservationRepository reservationRepository;
private final PenaltyChecker penaltyChecker;
private final RemainQuantityValidator remainQuantityValidator;

public void reserve(final Long memberId, final List<Reservation> reservations,
final ReserveValidator... reserveValidators) {
validatePenalty(memberId);
for (final Reservation reservation : reservations) {
validateValidators(reservation, reserveValidators);
validateAvailableCount(reservation);
}
reservationRepository.saveAll(reservations);
}

private void validatePenalty(final Long memberId) {
if (penaltyChecker.hasOngoingPenalty(memberId)) {
throw new ReservationException("페널티에 적용되는 기간에는 대여 예약을 할 수 없습니다.");
}
}

private void validateValidators(final Reservation reservation, final ReserveValidator[] reserveValidators) {
for (ReserveValidator reserveValidator : reserveValidators) {
reserveValidator.validate(reservation);
}
}

private void validateAvailableCount(final Reservation reservation) {
reservation.getReservationSpecs()
.forEach(spec -> remainQuantityValidator.validateAmount(spec.getRentable().getId(),
spec.getAmount().getAmount(), spec.getPeriod()));
}
}

기자재와 대여 로직 호출 부분(서비스) 구현

기자재 대여 예약 호출 부분

1
2
3
4
5
6
7
8
9
10
11
12
13
@Service
@Transactional
@RequiredArgsConstructor
public class EquipmentReserveService {

private final EquipmentReservationCreator equipmentReservationCreator;
private final ReserveTemplate reserveTemplate;

public void reserveEquipment(final Long memberId, final AddEquipmentReservationRequest addReservationRequest) {
final List<Reservation> reservations = equipmentReservationCreator.create(memberId, addReservationRequest);
reserveTemplate.reserve(memberId, reservations, ReserveValidator.noExtraValidation());
}
}

랩실 대여 예약 호출 부분

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
@Transactional
@RequiredArgsConstructor
public class LabRoomReserveService {

private final LabRoomReservationCreator labRoomReservationCreator;
private final ReserveTemplate reserveTemplate;
private final ReservationValidator reservationValidator;

public Long reserveLabRoom(final Long memberId, final AddLabRoomReservationRequest addLabRoomReservationRequest) {
final Reservation reservation = labRoomReservationCreator.create(memberId, addLabRoomReservationRequest);
reserveTemplate.reserve(memberId, List.of(reservation),
reservationValidator::validateAlreadyReservedSamePeriod);
return reservation.getId();
}
}

호출 부분을 보면 훨씬 간결하고 직관적으로 구현됐다. 공통 로직을 묶어서 공통 로직의 변화가 여러 코드의 변활르 일으키지 않도록 했다.

Share