기리기리 프로젝트에서 기자재 대여 반납 기능을 중점적으로 개발하다가 광운대학교 미디어커뮤니케이션 랩실에서는 기자재 뿐만 아니라 랩실도 대여 반납을 할 수 있어야 한다는 피드백을 받았다.
문제는 랩실과 기자재 로직이 상당 부분 비슷하지만 랩실의 대여 로직이 기자재 대여 로직과 세세한 부분에서 다르다.
그러면 문제 상황을 여러 가지로 나눠서 해결해본다.
엔티티 객체를 추상화하기
대여 로직을 추상화해서 재활용하기
개선 방법
엔티티 객체 추상화하기
기리기리 프로젝트에서는 Spring Data JPA를 사용한다. 기존에 Equipment라는 이름으로 기자재 엔티티를 관리한다. 이제 랩실을 추가로 관리해야 한다. 랩실도 기자재처럼 대여가 가능해야 한다. 기자재와 랩실의 공통점을 먼저 생각해본다. 논리적인 개념으로는 기리기리 서비스의 관점에서 기자재와 랩실은 모두 자산이다. 그래서 다음과 같이 추상 클래스를 구현해본다.
privateRentableAsset(final Long id, final String name, final Integer totalQuantity, final Integer rentableQuantity, final Integer maxRentalDays, final LocalDate deletedAt) { // 생략 }
RentableAsset은 대여 대상 자산을 의미한다. 랩실과 기자재가 공통적으로 수행해야 할 로직을 모으고 있다. 특히 getRemainQuantity메서드는 추상 메서드로 랩실과 기자재가 서로 다르게 적용될 수 있다. 데이터베이스의 다형성 전략은 단일 테이블에 모으는 전략을 취했다. 조회 시 불필요한 조인 비용을 줄일 수 있고, 우리 서비스의 데이터 특성 상 기자재가 자산의 대부분이고 랩실은 데이터가 적기 때문에 하나의 테이블로 모으는 게 유리할 것이라 판단했다. 그리고 대여 예약 엔티티가 id를 통해 간접 참조하는데 단일 테이블 전략은 id의 유일성을 관리하기 쉽다. 만약 여러 테이블을 사용하면 ID generator와 같이 id의 유일성을 여러 테이블을 걸쳐서 고민해야 해서 번거롭다.
@Builder publicEquipment(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()) { thrownewEquipmentException("대여 가능 갯수가 대여 된 갯수보다 크면 안됩니다!"); } return getRentableQuantity() - reservedCount; }
기자재는 실제 기자재에 필요한 정보들을 가지고 있다. 기자재는 getRemainQuantity를 보면 대여 가능 갯수에서 예약할 갯수를 빼는 방식으로 구현되어 있다.
@Builder privateLabRoom(final Long id, final String name, final Integer totalQuantity, final Integer rentableQuantity, final Integer maxRentalDays, finalboolean isAvailable, final Integer reservationCountPerDay, final String notice) { //생략... }
@Override public Integer getRemainQuantity(finalint reservedCount) { if (reservedCount > getRentableQuantity()) { thrownewLabRoomException("대여 가능 갯수가 대여 된 갯수보다 크면 안됩니다!"); } if (!this.isAvailable) return0; 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 인터페이스로 추상화되어 있다.
publicvoidreserve(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); }
privatevoidvalidatePenalty(final Long memberId) { if (penaltyChecker.hasOngoingPenalty(memberId)) { thrownewReservationException("페널티에 적용되는 기간에는 대여 예약을 할 수 없습니다."); } }
privatevoidvalidateValidators(final Reservation reservation, final ReserveValidator[] reserveValidators) { for (ReserveValidator reserveValidator : reserveValidators) { reserveValidator.validate(reservation); } }
public Long reserveLabRoom(final Long memberId, final AddLabRoomReservationRequest addLabRoomReservationRequest) { finalReservationreservation= labRoomReservationCreator.create(memberId, addLabRoomReservationRequest); reserveTemplate.reserve(memberId, List.of(reservation), reservationValidator::validateAlreadyReservedSamePeriod); return reservation.getId(); } }
호출 부분을 보면 훨씬 간결하고 직관적으로 구현됐다. 공통 로직을 묶어서 공통 로직의 변화가 여러 코드의 변활르 일으키지 않도록 했다.