스프링부트 토이프로젝트_영업 상태 표시하기1

네코·2023년 4월 3일
0

기록

목록 보기
1/1
post-thumbnail

학기 중 수업에서 진행했던 팀 프로젝트를 다시 만들어보고자 한다.
주요 기능은 다음과 같다.

  • 학교 주변 음식점들의 정보를 제공한다
  • 인증된 사용자는 리뷰를 작성할 수 있다
  • 인증된 사용자는 가격 인상, 폐업 등으로 인한 음식점 정보의 최신화를 요청할 수 있고 관리자는 이를 처리할 수 있다.

나를 포함한 3명에서 진행했던 팀프로젝트로 초기에 혼자 백엔드를 맡고 나머지 2명이 프론트를 담당하기로 했으나 후반에 어쩔 수 없이 프론트도 맡아서 하게 되었다.

테스트 코드도 작성하지 못했고 돌아가기에 급급한 코드를 짠 것이 아쉬워 다시 구현하면서

다양한 테스트 코드 작성
여러 예외 처리를 통한 완성도 추구
동일 기능을 구현할 수 있는 여러 방법을 비교하고 선택

등을 고려하고 공부하고자 한다.

프로젝트 세팅

스프링 부트 버전: 3.0.2
java17
초기 의존성

가장 먼저 음식점을 나타내는 Restaurant 도메인을 설계해보자
주요 책임은 다음과 같다.

  • 카카오 맵 상에서 음식점의 위치를 핀으로 표현하기 위해 위치 정보를 제공한다.
  • 음식점 이름과 간단한 설명을 제공한다.
  • 요일 별 운영 시간을 제공한다.
  • 대표 사진 1장을 제공한다.
  • 연락처를 제공한다.

구조는 다음과 같다

name,description 속성 추가

Restaurant 이름을 나타내는 Name VO(value object)를 정의했다.

개별 예외를 생성하였다.
후에 @RestControllerAdvice를 통해 전역적으로 예외 처리를 관리할 것을 염두했다.


동작 확인을 위한 간단한 테스트 코드를 작성하였다.
(@ParameterizedTest 이용 시 @DisplayName이 적용되지 않아 gradle이 아닌 intellij의 테스트 이용)

@ParameterizedTest를 통해 코드의 중복을 막고자 했다. @ValueSource를 통해 인자로 테스트 케이스를 전달하는 중에 null값에 대해서 constant expression이 아니기 때문에 에러가 발생했다. java language reference에서 constant expression에 대해 알 수 있었고 @NullSource를 통해 null값을 인자로 전달할 수 있었다.

결과

음식점 설명을 나타내는 Description value object도 위와 비슷하게 정의했다.

BusinessHours 클래스

현재 시각을 기준으로 사용자에게 음식점의 영업상태를 보여주는 기능을 구현하고자 했다.
프로젝트 당시에는 RunningTime이라는 @embedded 클래스를 정의하고 해당 클래스 필드로 LocalDateTime 타입의 오픈시간과 마감시간을 저장하였다. 이후 영업 중인지에 대한 검증 로직은 프론트엔드에서 구현하였다.

그러나 이러한 구현은 2가지 문제가 있다.

1. 다음날에 마감하는 케이스에 대해서 제대로 동작하지 못한다.

@Test
    public void fail_when_close_time_is_next_day() throws Exception {
        //given
        LocalDateTime now = LocalDateTime.now();        
        LocalDateTime open = LocalDateTime.of(now.getYear(), now.getMonth(), now.getDayOfMonth(), 17, 0);
        LocalDateTime close = LocalDateTime.of(now.getYear(), now.getMonth(), now.getDayOfMonth(), 1, 0);
        LocalDateTime testTime = LocalDateTime.of(now.getYear(), now.getMonth(), now.getDayOfMonth(), 18, 0);

        //when
        boolean isOpen = testTime.isAfter(open) && testTime.isBefore(close);

        //then
        assertThat(isOpen).isEqualTo(true);
    }
오후 5시에 오픈하여 익일 1시에 마감하는 경우에 대해 오후 6시 기준으로 영업 상태를 계산하였을 때 기대와 달리 false를 반환하였다.

2. 주말과 평일의 영업시간이 다른 경우를 포함할 수 없다.
학교 근처의 경우 주말에는 학생 수가 적어 일찍 마감하는 음식점이 많다. 기존 프로젝트에는 한 쌍의 영업시간만을 저장할 수 있기 때문에 다양한 영업 시간을 표현할 수가 없었다.

해결

초기 버전

BusinessHour

영업 시간 테이블을 추가하기로 했다. 먼저 가장 간단한 케이스에 대한 테스트를 작성하고 이를 통과하는 코드를 구현하였다.
@Test
    @DisplayName("당일 마감하고 현재 시각에 영업 중인 경우 true를 반환한다.")
    public void if_close_today_and_current_time_is_in_business_hour_then_true() throws Exception{
        //given
        LocalTime open = LocalTime.of(9, 0);
        LocalTime close = LocalTime.of(18, 0);
        LocalTime now = LocalTime.of(15, 0);

        //when
        BusinessHour businessHour = new BusinessHour(open, close);
        boolean isOpen = businessHour.isOpenAt(now);
        //then
        assertThat(isOpen).isEqualTo(true);
    }

이 테스트를 통과하기 위한 최소한의 로직을 작성하였다. 현재 테스트 케이스에 딱 맞게 값을 하드코딩해 통과를 확인한 후 다른 케이스를 만족하도록 구현하는 과정은 생략하였다.

public class BusinessHour {

    private LocalTime open;
    private LocalTime close;

    public BusinessHour(LocalTime open, LocalTime close) {
        this.open = open;
        this.close = close;
    }

    public boolean isOpenAt(LocalTime now) {
        return now.isAfter(open) && now.isBefore(close);
    }
}

이제 다음날 마감하는 경우를 생각해보자.

예를 들어 A 음식점의 월요일 영업 시간은 17시부터 익일 1시이다.
현재 시각이 월요일 18시인 경우와 화요일 오전 12시인 경우에 대해 영업 중임을 확인하는 테스트를 작성하고 이를 만족하도록 코드를 구현하였다.

    private static List<LocalTime> provideCurrentTimesWhenCloseTomorrow() {
        return List.of(
                LocalTime.of(1, 0),
                LocalTime.of(19, 0)
        );
    }

    @ParameterizedTest
    @MethodSource("provideCurrentTimeWhenCloseTomorrow")
    @DisplayName("익일 마감하고 현재 시각에 영업 중인 경우 true를 반환한다.")
    public void if_close_tomorrow_and_currentTime_is_in_businessHour_then_true(LocalTime now) throws Exception {
        //given
        LocalTime open = LocalTime.of(18, 0);
        LocalTime close = LocalTime.of(2, 0);

        //when
        BusinessHour businessHour = new BusinessHour(open, close);
        boolean isOpen = businessHour.isOpenAt(now);
        //then
        assertThat(isOpen).isEqualTo(true);
    }
public class BusinessHour {

    private LocalTime open;
    private LocalTime close;

    public BusinessHour(LocalTime open, LocalTime close) {
        this.open = open;
        this.close = close;
    }

    public boolean isOpenAt(LocalTime now) {
        if (isCloseAtTomorrow()) {
            return now.isAfter(open) || now.isBefore(close);
        }
        return now.isAfter(open) && now.isBefore(close);
    }

    private boolean isCloseAtTomorrow() {
        return open.isAfter(close);
    }
}

테스트 코드의 중복을 없애고자 @ParameterizedTest 활용해 작성했다. 이후 @MethodSource를 활용하여 영업 시간이 아닌 경우 false를 반환하는 케이스도 추가하였다.

당일 마감하는 경우에 현재 시각이 영업 시간 내에 있지 않을 경우 false를 반환하는지, 영업시간 경계에서의 값을 확인하는 테스트 케이스를 추가하였다. 경계에서 테스트가 실패했다. 이를 통과하도록 코드를 재작성했다.

BusinessHours

요일을 key로 갖고 앞서 구현한 BusinessHour를 value로 갖는 일급 컬렉션 클래스이다.
일급 컬렉션의 필요성/장점은 이 글에 잘 설명되어 있다. 개인적으로는 상태와 행위를 한 곳에서 관리할 수 있어 가독성과 유지 보수가 용이한 것이 큰 장점이라고 생각한다.
해당 클래스의 책임은 어제의 영업시간과 오늘의 영업시간을 기준으로 현재 시각에 영업 중인지를 반환하는 것이다.
테스트의 경우 어제 오늘의 각 각 당일마감, 익일마감하는 경우의 수로 4가지를 우선 고려하였다.

먼저 어제 오늘 모두 익일 마감인 경우에 대한 테스트를 작성했다.

@ParameterizedTest
    @MethodSource("provideCurrentTimeAndOpenStatusWhenYesterdayCloseTomorrowAndTodayCloseTomorrow")
    @DisplayName("어제 오늘 모두 익일 마감일 때 주어진 시간에 운영 여부를 정상적으로 반환한다.")
    public void if_yesterday_close_tomorrow_and_today_close_tomorrow(LocalTime currentTime, boolean openStatus)
            throws Exception {
        //given
        BusinessHourCreateDto yesterdayBusinessHourDto = createBusinessHourDto(
                DayOfWeek.MONDAY,
                LocalTime.of(18, 0),
                LocalTime.of(2, 0));
        BusinessHourCreateDto todayBusinessHourDto = createBusinessHourDto(
                DayOfWeek.TUESDAY,
                LocalTime.of(19, 0),
                LocalTime.of(1, 0));

        BusinessHours businessHours = createBusinessHours(yesterdayBusinessHourDto, todayBusinessHourDto);

        //when
        DayOfWeek todayDayOfWeek = DayOfWeek.TUESDAY;
        boolean isOpen = businessHours.isOpenAt(todayDayOfWeek, currentTime);

        //then
        assertThat(isOpen).isEqualTo(openStatus);
    }

    private static Stream<Arguments> provideCurrentTimeAndOpenStatusWhenYesterdayCloseTomorrowAndTodayCloseTomorrow() {
        return Stream.of(
                //영업 중인 케이스
                Arguments.of(LocalTime.of(0, 0), true),
                Arguments.of(LocalTime.of(2, 0), true),
                Arguments.of(LocalTime.of(18, 0), true),
                Arguments.of(LocalTime.of(22, 0), true),
                //영업 중이 아닌 케이스
                Arguments.of(LocalTime.of(3, 0), false),
                Arguments.of(LocalTime.of(9, 0), false),
                Arguments.of(LocalTime.of(15, 0), false),
                Arguments.of(LocalTime.of(17, 0), false),
                Arguments.of(LocalTime.of(17, 30), false)
        );
    }

처음에는 테스트 메서드에서 BusinessHours를 생성하는 작업까지 모두 진행하였으나 메서드 크기도 커지고 다른 케이스에서 재사용할 수 있을 것이라 생각해 테스트 클래스 내 팩토리 메서드로 분리했다.

다음으로 어제는 익일 마감, 오늘은 당일 마감인 경우를 테스트해보자.

재설계

초기 버전에서 다시 설계하게된 이유는 2가지가 있다.

  1. 영업 시간에는 실제 서비스를 제공하는 시간과 휴게 시간이 포함되는 것이 보통이나 초기 버전에서는 이를 고려하지 못했다.
  2. 주어진 시간에 영업 여부를 확인하는 책임을 BusinessHour의 contains(LocalTime now)메서드를 호출하여 확인했었다. contains 메서드는 BusinessHour가 익일 마감인지 당일 마감인지를 확인하고 당일 마감일 경우에는 start < cur && cur < end, 익일마감인 경우 start < cur || cur < end와 같은 로직으로 확인했었다. 이렇게 작성할 경우 예외가 존재했다.
    다음과 같은 경우를 생각해보자 >>
    A음식점 월요일 영업시간 open: 17:00 ~ close: 01:00
    A음식점 화요일 영업시간 open: 18:00 ~ close: 01:00
    만약 화요일 17시 30분에 A음식점의 영업 상태를 알고 싶다고 하자. 익일 마감이 가능하기에 현재 시간의 영업 상태를 확인할 때 전날의 영업 상태를 확인해야한다. 월요일, 화요일의 BusinessHour에서 contains메서드를 통해 영업여부를 확인할 경우 월요일 영업시간이 반영되어 영업중으로 표시되는 문제가 있다.

contain메서드의 파라미터를 추가하여 분기처리를 하여 해결하는 방법도 있으나 보다 객체지향적으로 구현하고 싶어 BusinessHour를 추상클래스로 변경하고 contain 메서드에 템플릿 메서드 패턴을 적용하였다. 내부의 구체적인 구현은 TodayBusinessHour, YesterdayBusinessHour로 나눠 처리하였다.

BusinessHour의 기능은 다음과 같다.

  • isUnset(): start,end 시간이 설정되었는지 확인한다.
    휴게 시간은 없을 수 있음을 고려했다. 그러나 운영 시간은 반드시 존재해야한다. 이 조건에 대한 예외처리는 휴게시간, 운영시간을 갖는 상위의 BusinessSchedule에서 처리할 예정이다.
  • isIn(BusinessHour outer):
    운영 시간이 휴게 시간을 포함하는지 확인하고 예외 처리를 위한 메서드이다.
  • isWithinBusinessHour(LocalTime t): 앞서 다룬 contain메서드명이 변경된 것으로 기능은 동일하다.
  • convertToYesterdayBusinessHour(): TodayBusinessHour에서 YesterdayBusinessHour로 변환하는 메서드이다.
  public abstract class BusinessHour {
    private static final LocalTime UNSET = LocalTime.of(0, 0);

    protected final LocalTime start;
    protected final LocalTime end;
    protected final Boolean isStartAtTomorrow;

    protected BusinessHour(LocalTime start, LocalTime end, boolean isStartAtTomorrow) {
        checkNull(start, end);
        this.start = start;
        this.end = end;
        this.isStartAtTomorrow = isStartAtTomorrow;
    }

    private void checkNull(LocalTime start, LocalTime end) {
        if (start == null || end == null) {
            throw new RestaurantException(Code.INVALID_BUSINESS_HOUR);
        }
    }

    public boolean isUnset() {
        return this.start.equals(UNSET) && this.end.equals(UNSET);
    }

    public boolean isIn(BusinessHour outer) {
        if (isStartAndEndSameDayWith(outer)) {
            return outer.isAfterOrEqualToStart(this.start) && outer.isBeforeOrEqualToEnd(this.end);
        }
        if (isEndSameDayWith(outer)) {
            return outer.isBeforeOrEqualToEnd(this.end);
        }
        return outer.isAfterOrEqualToStart(this.start);
    }

    private boolean isStartAndEndSameDayWith(BusinessHour outer) {
        return isStartSameDayWith(outer) && isEndSameDayWith(outer);
    }

    private boolean isStartSameDayWith(BusinessHour outer) {
        return outer.isStartAtTomorrow() == this.isStartAtTomorrow();
    }

    private boolean isEndSameDayWith(BusinessHour outer) {
        return outer.isEndAtTomorrow() == this.isEndAtTomorrow();
    }

    public boolean isEndAtTomorrow() {
        return this.isStartAtTomorrow || this.start.isAfter(this.end);
    }

    public boolean isStartAtTomorrow() {
        return this.isStartAtTomorrow;
    }

    public boolean isAfterOrEqualToStart(LocalTime time) {
        return !time.isBefore(this.start);
    }

    protected boolean isBeforeOrEqualToEnd(LocalTime time) {
        return !time.isAfter(this.end);
    }

    public boolean isWithinBusinessHour(LocalTime now) {
        if (isEndAtTomorrow() && !isStartAtTomorrow) {
            return isWithinBusinessHourWhenEndTomorrow(now);
        }
        return isWithinBusinessHourWhenEndToday(now);
    }

    protected abstract boolean isWithinBusinessHourWhenEndToday(LocalTime now);

    protected abstract boolean isWithinBusinessHourWhenEndTomorrow(LocalTime now);

    public abstract BusinessHour convertToYesterdayBusinessHour();

    public LocalTime getEnd() {
        return this.end;
    }

    @Override
    public String toString() {
        return "BusinessHour{" +
                "start=" + this.start +
                ", end=" + this.end +
                '}';
    }
}

추가로 boolean타입의 isStartAtTomorrow 필드가 추가되었는데 운영 시간(runTime)은 다음날 시작할 수 없지만 휴게 시간은 운영 시간의 마감이 익일인 경우 익일에 시작해 종료될 수 있으므로 해당 필드를 통해 구분하였고 상위의 BusinessSchedule에서 관련된 예외를 처리했다.
이후 구현 클래스인 TodayBusinessHour를 구현하고 테스트 코드를 작성하였다.

  public class TodayBusinessHour extends BusinessHour {

    public TodayBusinessHour(LocalTime start, LocalTime end, boolean isStartAtTomorrow) {
        super(start, end, isStartAtTomorrow);
    }

    @Override
    protected boolean isWithinBusinessHourWhenEndTomorrow(LocalTime now) {
        return isAfterOrEqualToStart(now) || isBeforeOrEqualToEnd(now);
    }

    @Override
    protected boolean isWithinBusinessHourWhenEndToday(LocalTime now) {
        return isAfterOrEqualToStart(now) && isBeforeOrEqualToEnd(now);
    }

    @Override
    public BusinessHour convertToYesterdayBusinessHour() {
        return new YesterdayBusinessHour(start, end, isStartAtTomorrow);
    }
}

테스트 코드 작성 중에는 isIn()메서드를 테스트 할 때 다양한 케이스를 포함하고자 노력했다.
TodayBusinessHourTest에서 확인 할 수 있다.

이어서 다음으론 요일별 운영,휴게 시간을 나타내는 BusinessSchedule과 음식점의 영업 시간을 담당하는 BusinessShedules를 구현하겠다.

0개의 댓글