SpaceStory 1 - 공간 예약 서비스 개발하기

junto·2024년 2월 24일
0

spring

목록 보기
19/30
post-thumbnail

실제로 취업하고 싶은 도메인을 선택해라

  • 이번 주 월요일부터 2주간 간단한 게시판 CRUD를 구현하는 1차 프로젝트를 진행했다. 처음에는 견주끼리 산책할 수 있는 게시판을 구현하려고 했으나 코치님은 실제 내가 취업하고 싶은 도메인을 선택해야 한다고 했다. 어떤 도메인이든 상관 없으나 개발자 공부하기 이전에 부동산 중개업을 했기 때문에 자연스레 부동산 도메인을 선택하게 되었다.

왜 공간 예약 서비스인가?

  • 프로젝트를 할 때 어떤 것을 목표로 할지 정하는 건 가슴이 동동거리는 일이다. 간단하게 시장 조사를 해본 결과 기존 부동산 대표 플랫폼들은 대체로 실적이 악화하고 있었다. 그중에도 성장하고 있는 부동산 플랫폼 기업을 찾아본 결과 단순히 중개 관련 정보를 제공하는 것에서 끝나지 않고 컨설팅, 인테리어 등을 곁들인 All-In-One 서비스를 해준다거나 기존에 활발하지 않았던 공유 서비스를 특화한 기업이 성장하고 있었다.
  • 처음에는 아직 시장에 없는 참신한 서비스를 하고 싶었지만, 서비스를 특화할수록 이게 과연 수요가 있을까? 하는 걱정이 앞섰다. 창업하는 게 아니기 때문에 고민은 그쯤 해두고 어떤 부동산 기업이든 신사업으로 부동산 공유 개념을 적용할 수 있으므로 공간대여 서비스로 하기로 결정했다.

MVP와 핵심 기능

  • MVP(최소 가치 제품, Minimum Viable Product)란 제품이나 서비스를 출시할 때 필요한 최소한의 기능을 갖춘 제품을 말한다. 이는 사용자에게 가장 큰 가치를 제공하는 핵심 기능에 초점을 맞추는 것이다.
  • 공간 예약 서비스 핵심 기능
    • 사용자에게 공간 타입마다 예약할 수 있는 공간을 보여준다. (READ)
    • 사용자는 자신의 예약 목록을 조회한다. (READ)
    • 사용자는 공간을 예약한다. (CREATE)
    • 사용자는 공간 예약(예약 날짜, 예약 시간)을 수정한다. (UPDATE)
    • 사용자가 공간 예약을 취소한다. (DELETE)
  • 주의사항
    • 부동산마다 여러 공간을 가질 수 있다.
    • 공간은 하나의 타입을 가지며, 하나의 타입은 여러 세부 타입을 가질 수 있다.
- 분류
    - 목적에 따른 분류
        - 친목
            - 파티룸, 공유주방, 카페
        - 행사
            - 공연장, 컨퍼런스, 전시회
        - 교육
            - 스터디룸, 강의실, 세미나실, 회의실
        - 예술
            - 댄스연습실, 보컬연습실, 악기연습실, 그림연습실, 공예실
        - 운동
            - 배드민턴장, 풋살장, 테니스장
        - 촬영
            - 촬영스튜디오, 라이브방송

엔티티 관계를 단순화해라

  • 엔티티는 핵심 기능을 작성하면서 쉽게 추가되거나 변경되는 사항이 많기 때문에 최대한 빨리 기능 구현을 시도했다. jpa를 사용해서 ddl-auto: update로 intelij가 그려주는 erd는 다음과 같다.
  • 객체지향 설계 관점에서 보면 핵심 기능 객체 간의 협력 관계에서 생각해야 한다. 협력이란 다른 관점에서 보면 서로 필요한 메시지를 전달하고 응답하는 과정에서 의존 관계가 형성되고 불필요한 의존 관계는 결합도를 증가시켜 변화에 대응하기 어려운 구조를 가지게 된다. 내가 착각한 건 협력 관계를 정의하기 위해선 반드시 도메인간 양방향 연관관계가 있어야 한다고 생각했다. 불필요한 연관관계를 제거하기 위해 다음을 고려하였다.
    • 특정 기능을 구현할 때 요청 파라미터 또는 요청 메시지 바디에 제공되는 경우를 고려해서 불필요한 관계를 제거하도록 한다.
    • 밀접한 관계가 아니고 서비스 로직에서 필요한 기능을 구현할 수 있다면 관계를 설정하지 않고 구현해 본다. 관계가 필요하다면 그때 추가해도 늦지 않는다.
  • 위를 고려하며 테스트 코드를 작성하면서 복잡성을 줄일 수 있었다.
  • 추후에 알게된 사실인데, 단방향 일대다 연관관계는 성능 문제(N+1)가 있어서 일반적으로 잘 사용하지 않고 양방향 일대다 관계를 사용한다고 한다.

테스트 코드

@ExtendWith(MockitoExtension.class)
public class ReservationServiceTest {

    @InjectMocks
    private ReservationService reservationService;

    @Mock
    private UserRepository userRepository;

    @Mock
    private SpaceRepository spaceRepository;

    @Mock
    private ReservationRepository reservationRepository;

    @Mock
    private HostRepository hostRepository;

    private User user1, user2;
    private Host host;
    private RealEstate realEstate;
    private Space space;

    @BeforeEach
    void setUp() {
        user1 = new User("user1", "user1@gmail.com", "nickname1", 100_000L);
        user2 = new User("user1", "user1@gmail.com", "nickname1", 0L);
        host = new Host("host1", 0L);
        Address address1 = new Address("서울특별시 성동구 아차산로 17길 48", "서울특별시 성동구 성수동2가 280 성수 SK V1 CENTER 1", "서울특별시", "성동구", "성수동");
        realEstate = new RealEstate(address1, 2, false, true, host);

        HashSet<DetailedType> detailedTypes = new HashSet<>();
        detailedTypes.add(DetailedType.lectureRoom);
        detailedTypes.add(DetailedType.meetingRoom);
        space = new Space(SpaceType.EVENT, "소규모 회의, 업무 공간", LocalTime.of(9, 0), LocalTime.of(22, 0), 30000, 55, 10, detailedTypes, realEstate);
    }

    @DisplayName("사용자가 공간을 예약 시 예약된 공간을 반환한다")
    @Test
    void Reserve() {
        //given
        Long userId = user1.getId();
        Long spaceId = space.getId();
        LocalDate reservationDate = LocalDate.of(2024, 3, 3);
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of( 11, 0);
        long usageTime = Duration.between(start, end).toHours();
        long usageFee = space.getHourlyRate() * usageTime;
        SpaceReservation expected = new SpaceReservation(userId, reservationDate, start, end, usageFee, true, space);

        when(userRepository.findById(userId)).thenReturn(Optional.of(user1));
        when(spaceRepository.findById(spaceId)).thenReturn(Optional.of(space));
        when(reservationRepository.findBySpaceIdAndReservationDateAndIsReservedTrue(eq(spaceId), any(LocalDate.class))).thenReturn(Collections.emptyList());
        when(reservationRepository.save(any(SpaceReservation.class))).thenReturn(expected);
        when(hostRepository.save(space.getRealEstate().getHost())).thenReturn(host);

        //when
        SpaceReservation spaceReservation = reservationService.reserve(userId, spaceId, reservationDate, start, end);

        //then
        assertThat(spaceReservation).isNotNull();
        assertThat(spaceReservation).isEqualTo(expected);
        verify(reservationRepository).save(any(SpaceReservation.class));
        assertThat(user1.getPoint()).isEqualTo(40_000L);
        assertThat(host.getPoint()).isEqualTo(60_000L);
    }

    @DisplayName("[실패] 공간 예약 시 최소 시간은 1시간 이상이어야 한다.")
    @Test
    void Reserve_WithLessThanOneHour() {
        //given
        Long userId = user1.getId();
        Long spaceId = space.getId();
        LocalDate reservationDate = LocalDate.of(2024, 3, 3);
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of(9, 0);

        when(userRepository.findById(userId)).thenReturn(Optional.of(user1));
        when(spaceRepository.findById(spaceId)).thenReturn(Optional.of(space));

        //when

        //then
        assertThatThrownBy(() -> reservationService.reserve(userId, spaceId, reservationDate, start, end))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("space reservations require a minimum of 1 hour.");
    }

    @DisplayName("[실패] 공간 예약 시 이미 예약된 공간은 예약할 수 없다. 9~12시 예약이 있을 경우 7~10시 예약은 실패한다.")
    @Test
    void Reserve_WithOverlappedReservationTime_1() {
        Long userId = user1.getId();
        LocalDate reservationDate = LocalDate.of(2024, 3, 3);
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of( 12, 0);
        long usageTime = Duration.between(start, end).toHours();
        long usageFee = space.getHourlyRate() * usageTime;
        SpaceReservation reservation = new SpaceReservation(userId, reservationDate, start, end, usageFee, true, space);
        List<SpaceReservation> validReservations = new ArrayList<>();
        validReservations.add(reservation);

        Long reqUserId = user2.getId();
        Long reqSpaceId = space.getId();
        LocalTime reqStart = LocalTime.of( 7, 0);
        LocalTime reqEnd = LocalTime.of(10, 0);

        when(userRepository.findById(reqUserId)).thenReturn(Optional.of(user2));
        when(spaceRepository.findById(reqSpaceId)).thenReturn(Optional.of(space));
        when(reservationRepository.findBySpaceIdAndReservationDateAndIsReservedTrue(eq(reqSpaceId), any(LocalDate.class))).thenReturn(validReservations);

        //when

        //then
        assertThatThrownBy(() -> reservationService.reserve(reqUserId, reqSpaceId, reservationDate, reqStart, reqEnd))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("space is already reserved.");
    }

    @DisplayName("[실패] 공간 예약 시 이미 예약된 공간은 예약할 수 없다. 9~12시 예약이 있을 경우 11~14시 예약은 실패한다.")
    @Test
    void Reserve_WithOverlappedReservationTime_2() {
        Long userId = user1.getId();
        LocalDate reservationDate = LocalDate.of(2024, 3, 3);
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of(12, 0);
        long usageTime = Duration.between(start, end).toHours();
        long usageFee = space.getHourlyRate() * usageTime;
        SpaceReservation reservation = new SpaceReservation(userId, reservationDate, start, end, usageFee, true, space);
        List<SpaceReservation> validReservations = new ArrayList<>();
        validReservations.add(reservation);

        Long reqUserId = user2.getId();
        Long reqSpaceId = space.getId();
        LocalTime reqStart = LocalTime.of(11, 0);
        LocalTime reqEnd = LocalTime.of(14, 0);

        when(userRepository.findById(reqUserId)).thenReturn(Optional.of(user2));
        when(spaceRepository.findById(reqSpaceId)).thenReturn(Optional.of(space));
        when(reservationRepository.findBySpaceIdAndReservationDateAndIsReservedTrue(eq(reqSpaceId), any(LocalDate.class))).thenReturn(validReservations);

        //when

        //then
        assertThatThrownBy(() -> reservationService.reserve(reqUserId, reqSpaceId, reservationDate, reqStart, reqEnd))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("space is already reserved.");
    }

    @DisplayName("[실패] 공간 예약 시 포인트가 부족하면 예약할 수 없다.")
    @Test
    void Reserve_WithNotEnoughPoint() {
        //given
        Long userId = user2.getId();
        Long spaceId = space.getId();
        LocalDate reservationDate = LocalDate.of(2024, 3, 3);
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of(11, 0);

        when(userRepository.findById(userId)).thenReturn(Optional.of(user2));
        when(spaceRepository.findById(spaceId)).thenReturn(Optional.of(space));

        //when

        //then
        assertThatThrownBy(() -> reservationService.reserve(userId, spaceId, reservationDate, start, end))
                .isInstanceOf(RuntimeException.class)
                .hasMessageContaining("user's points are insufficient");
    }

    @DisplayName("사용자가 특정 날짜에 예약할 수 있는 시간을 조회하다. 운영시간은 9~22시, 예약은 9~12시, 14~15시에 있다.")
    @Test
    void GetAvaliableReservation() {
        //given
        Long spaceId = space.getId();
        LocalDate reservationDate = LocalDate.of(2024, 3, 3);
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of(12, 0);
        long usageTime = Duration.between(start, end).toHours();
        long usageFee = space.getHourlyRate() * usageTime;
        SpaceReservation reservation = new SpaceReservation(user1.getId(), reservationDate, start, end, usageFee, true, space);

        LocalTime start2 = LocalTime.of(14, 0);
        LocalTime end2 = LocalTime.of(15, 0);
        long usageTime2 = Duration.between(start2, end2).toHours();
        long usageFee2 = space.getHourlyRate() * usageTime2;
        SpaceReservation reservation2 = new SpaceReservation(user1.getId(), reservationDate, start2, end2, usageFee2, true, space);

        List<SpaceReservation> validReservations = new ArrayList<>();
        validReservations.add(reservation);
        validReservations.add(reservation2);

        when(spaceRepository.findById(spaceId)).thenReturn(Optional.of(space));
        when(reservationRepository.findBySpaceIdAndReservationDateAndIsReservedTrue(spaceId, reservationDate)).thenReturn(validReservations);

        //when
        List<TimeSlot> availableReservation = reservationService.getAvailableReservation(spaceId, reservationDate);

        //then
        List<TimeSlot> expected = List.of(
                new TimeSlot(LocalTime.of(12, 0), LocalTime.of(13, 0)),
                new TimeSlot(LocalTime.of(13, 0), LocalTime.of(14, 0)),
                new TimeSlot(LocalTime.of(15, 0), LocalTime.of(16, 0)),
                new TimeSlot(LocalTime.of(16, 0), LocalTime.of(17, 0)),
                new TimeSlot(LocalTime.of(17, 0), LocalTime.of(18, 0)),
                new TimeSlot(LocalTime.of(18, 0), LocalTime.of(19, 0)),
                new TimeSlot(LocalTime.of(19, 0), LocalTime.of(20, 0)),
                new TimeSlot(LocalTime.of(20, 0), LocalTime.of(21, 0)),
                new TimeSlot(LocalTime.of(21, 0), LocalTime.of(22, 0))
                );
        assertThat(availableReservation).isNotNull();
        assertThat(availableReservation).usingRecursiveComparison().isEqualTo(expected);
    }

    @DisplayName("사용자가 자신의 예약 목록을 조회한다.")
    @Test
    void GetUserReservation() {
        //given
        Long userId = user1.getId();
        LocalDate reservationDate = LocalDate.of(2024, 3, 3);
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of(12, 0);
        long usageTime = Duration.between(start, end).toHours();
        long usageFee = space.getHourlyRate() * usageTime;
        SpaceReservation reservation = new SpaceReservation(userId, reservationDate, start, end, usageFee, true, space);

        LocalTime start2 = LocalTime.of(14, 0);
        LocalTime end2 = LocalTime.of(15, 0);
        long usageTime2 = Duration.between(start2, end2).toHours();
        long usageFee2 = space.getHourlyRate() * usageTime2;
        SpaceReservation reservation2 = new SpaceReservation(userId, reservationDate, start2, end2, usageFee2, true, space);

        List<SpaceReservation> expected = new ArrayList<>();
        expected.add(reservation);
        expected.add(reservation2);
        when(userRepository.findById(userId)).thenReturn(Optional.of(user1));
        when(reservationRepository.findByUserId(userId)).thenReturn(expected);

        //when
        List<SpaceReservation> userReservation = reservationService.getReservationsByUserId(userId);

        //then
        assertThat(userReservation).isNotNull();
        assertThat(userReservation).usingRecursiveComparison().isEqualTo(expected);
    }

    @DisplayName("사용자가 예약정보를 같은 날 다른 시간으로 변경한다. 9~12시 예약 -> 9~11시 예약으로 변경한다.")
    @Test
    void UpdateReservation() {
        //given
        Long userId = user1.getId();
        Long spaceId = space.getId();
        LocalDate reservationDate = LocalDate.of(2024, 3, 3);
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of(12, 0);
        long usageTime = Duration.between(start, end).toHours();
        long usageFee = space.getHourlyRate() * usageTime;
        SpaceReservation reservation = new SpaceReservation(userId, reservationDate, start, end, usageFee, true, space);
        List<SpaceReservation> reservations = new ArrayList<>();
        reservations.add(reservation);

        Long reservationId = reservation.getId();
        RequestUpdateReservation requestUpdateReservation = new RequestUpdateReservation(userId, spaceId, LocalDate.of(2024, 3, 3), LocalTime.of(9, 0), LocalTime.of(11, 0), true);
        LocalTime start2 = LocalTime.of(9, 0);
        LocalTime end2 = LocalTime.of(11, 0);
        long usageTime2 = Duration.between(start2, end2).toHours();
        long usageFee2 = space.getHourlyRate() * usageTime2;
        SpaceReservation expected = new SpaceReservation(userId, reservationDate, start2, end2, usageFee2, true, space);

        when(userRepository.findById(userId)).thenReturn(Optional.of(user1));
        when(spaceRepository.findById(spaceId)).thenReturn(Optional.of(space));
        when(reservationRepository.findById(reservation.getId())).thenReturn(Optional.of(reservation));
        when(reservationRepository.findBySpaceIdAndReservationDateAndIsReservedTrue(spaceId, reservationDate)).thenReturn(reservations);
        when(reservationRepository.save(any(SpaceReservation.class))).thenReturn(expected);

        //when
        SpaceReservation update = reservationService.update(userId, spaceId, reservationId, requestUpdateReservation);

        //then
        assertThat(update).isNotNull();
        assertThat(update).usingRecursiveComparison().isEqualTo(expected);
    }

    @DisplayName("예약 정보를 삭제를 삭제한다.")
    @Test
    void DeleteReservation_WithValidReservationId() {
        //given
        Long userId = user1.getId();
        LocalDate reservationDate = LocalDate.of(2024, 3, 3);
        LocalTime start = LocalTime.of(9, 0);
        LocalTime end = LocalTime.of(12, 0);
        long usageTime = Duration.between(start, end).toHours();
        long usageFee = space.getHourlyRate() * usageTime;
        SpaceReservation reservation = new SpaceReservation(userId, reservationDate, start, end, usageFee, true, space);
        Long reservationId = reservation.getId();
        when(reservationRepository.findById(reservationId)).thenReturn(Optional.of(reservation));

        //when
        reservationService.delete(reservationId);

        //then
        verify(reservationRepository).delete(reservation);
    }
}

profile
꾸준하게

0개의 댓글