본문 바로가기
Devkor

[고대로] 운영 시간 관리

by 진진리 2024. 8. 17.
728x90

Devkor에서 '고대로'라는 학교 안내 어플을 개발하는 활동을 하고 있다.

학교의 건물, 강의실, 편의시설 등의 운영시간을 관리할 필요성이 생겨 해당 기능을 맡아 구현하였다.

 

프로젝트를 시작하면서 학교 시설을 건물, 강의실, 편의시설로 구별하였다.

초반에 구상한 내용인데, 지금 생각해보면 강의실과 편의시설을 따로 구분할 필요성이 없지 않았을까라는 생각이 들기도 한다.

 

아무튼 건물과 강의실, 건물과 편의시설은 일대다 연관관계를 맺고 있으며 강의실과 편의시설은 건물과 독립된 운영시간을 가지고 있다.

가장 처음에는 각 시설에 operatingTime을 "00:00-00:00"과 같은 형식으로 저장하여 해당 String을 가지고 운영 여부 true/false를 판단하면 되지 않을까?라고 생각했다.

 

문제 상황

그러나 운영시간이 평일/주말, 학기중/방학, 공휴일에 따라 변동이 있고 심지어는 둘째, 넷째 주 토요일에 운영하거나 중간에 쉬는 시간이 있는 경우가 있고, 특히 식당은 아침/점심/저녁의 특정 시간대에만 운영하기 때문에 전체 시설의 운영 시간을 일관적으로 관리하기 어려웠다.

 

ERD 구상

그래서 다음과 같이 ERD를 구상하였다.

운영 조건과 건물/편의시설/강의실이 다대일, 운영 시간과 운영 조건이 다대일이다.

 

건물/강의실/편의시설은 String인 운영 시간(OperatingTime), boolean인 운영 여부(isOperating)를 별도로 가진다.

운영 조건은 건물/강의실/편의시설 중 하나와 연관관계를 가진다.

 

운영 조건에서 dayOfWeek는 평일/토/일로 구분되며 그 외 속성들로 공휴일, 방학, 짝수주 등에 따라 달라지는 운영 시간을 고려한다.

 

처음에는 공휴일/방학/짝수주 여부 등을 enum이나 boolean 값으로 사용했는데 그렇게 하면 DB에 넣어야 하는 데이터의 양이 들어나서 힘들어졌다.

그래서 nullable하게 하여 null인 경우 해당 필드가 운영 시간에 영향을 미치지 않는다, 즉 상관없다고 생각하여 로직을 구성하였다.

예를 들어 평일과 토요일의 운영 시간만 나와있는 경우 방학, 공휴일, 짝수주 등은 운영 시간에 영향을 미치지 않기 때문에 null로 두었다.

 

운영 시간과 운영 조건을 분리한 이유는 중간에 쉬는 시간이 있거나 하루 중 여러 번 나눠서 운영하는 경우를 고려하기 위함이다.

예를 들어 9~21시 운영 중 12~13시 쉬는 시간을 가진다면 운영 시간은 9~12, 13~21로 나누어 저장된다.

 

공휴일 관리

공휴일의 경우 공공데이터포털(https://www.data.go.kr/)의 오픈API인 '한국천문연구원_특일 정보'를 사용하여 공휴일 정보를 받아와 DB에 저장하였다.

 

스케줄러를 이용하여 매달 1일 자정마다 OpenAPI에 해당 연도와 월을 가지고 요청을 보내고 이번 달의 공휴일을 DB에 저장한다.

public void updateHolidays(int year, int month) throws URISyntaxException {
        // 요청 URL 만들기
        String uriString = String.format("%s?solYear=%d&solMonth=%02d&ServiceKey=%s&_type=json&numOfRows=30",
            endPoint, year, month, encodedKey);
        URI uri = new URI(uriString);

//        log.info("uri = " + uri);

        RequestEntity<Void> requestEntity = RequestEntity
            .get(uri)
            .header("Accept", "application/json")
            .build();

        ResponseEntity<String> responseEntity = restTemplate.exchange(requestEntity, String.class);

        log.info("responseEntity: {}", responseEntity);

        List<Holiday> holidayList = fromJSONtoItems(responseEntity.getBody()).stream().map(holidayResDto -> new Holiday(holidayResDto)).toList();

        holidayRepository.saveAll(holidayList);
    }

 

추가적으로 학교 개교기념일의 경우 5.5로 공휴일에 포함되기 때문에 따로 고려하지 않아도 된다.

 

운영 시간(String)  관리

스케줄러를 이용하여 매일 자정마다 요일, 공휴일, 방학, 짝수주 등을 판단한다.

 

매일 한 번씩 해당 조건에 해당하는 운영 조건을 조회하는데 이때 각 속성이 null인 경우를 잘 고려하여야 한다.

private List<OperatingCondition> findOperatingCondition(DayOfWeek dayOfWeek, boolean isHoliday, boolean isVacation, boolean evenWeek) {
        List<OperatingCondition> operatingConditionList 
            = operatingConditionRepository.findAllByDayOfWeekAndIsHolidayAndIsVacationOrNot(dayOfWeek, isHoliday, isVacation);

        if(dayOfWeek == DayOfWeek.SATURDAY) { // 토요일인 경우
            if(evenWeek) {
                operatingConditionList.stream().filter(operatingCondition ->
                    operatingCondition.getIsEvenWeek() == null || operatingCondition.getIsEvenWeek() == true);
            }
            else {
                operatingConditionList.stream().filter(operatingCondition ->
                    operatingCondition.getIsEvenWeek() == null || operatingCondition.getIsEvenWeek() == false);
            }
        }

        return operatingConditionList;
}

 

우선 dayOfWeek, isHoliday, isVacation에 해당하는 운영 조건을 조회한 후에 요일이 토요일인 경우에만 짝수주/홀수주 조건을 고려한다.

각 속성이 null인 경우는 항상 가져와야 하기 때문에 @Query를 통해 커스텀 쿼리 메서드를 만들어 사용하였다.

public interface OperatingConditionRepository extends JpaRepository<OperatingCondition, Long> {
    @Query("SELECT e FROM OperatingCondition e WHERE (e.dayOfWeek = :dayOfWeek OR e.dayOfWeek IS NULL) " +
    "AND (e.isHoliday = :isHoliday OR e.isHoliday IS NULL)" + "AND (e.isVacation = :isVacation OR e.isVacation IS NULL)")
    List<OperatingCondition> findAllByDayOfWeekAndIsHolidayAndIsVacationOrNot(@Param("dayOfWeek") DayOfWeek dayOfWeek, @Param("isHoliday") boolean isHoliday, @Param("isVacation") boolean isVacation);
}

 

 

각 운영 조건에 해당하는 운영 시간 중 가장 빠른 시각과 가장 늦은 시각을 골라 각 시설의 운영 시간에 "00:00-00:00"과 같은 형식으로 업데이트한다.

이때 추가적인 정보가 필요한 경우 "00:00-00:00 (수업에 따라 변동 가능)" 과 같이 뒤에 부연 설명을 덧붙이고 앞의 11글자만 수정하도록 하였다.

 

// . . .
for(OperatingCondition operatingCondition : operatingConditionList) {
            List<OperatingTime> operatingTimeList = operatingTimeRepository.findAllByOperatingCondition(operatingCondition);

            LocalTime endTime = LocalTime.of(0, 0);
            LocalTime startTime = LocalTime.of(23, 59);

            for(OperatingTime operatingTime : operatingTimeList) {
                LocalTime end = LocalTime.of(operatingTime.getEndHour(), operatingTime.getEndMinute());
                LocalTime start = LocalTime.of(operatingTime.getStartHour(), operatingTime.getStartMinute());
                if(endTime.isBefore(end)) endTime = end;
                if(startTime.isAfter(start)) startTime = start;
            }

            String newOperatingTime = formatTimeRange(startTime, endTime);

            if(operatingCondition.getBuilding() != null) {
                Building building = operatingCondition.getBuilding();
                building.setOperatingTime(newOperatingTime);
                buildingList.add(building);
            }
            else if(operatingCondition.getClassroom() != null) {
                Classroom classroom = operatingCondition.getClassroom();
                classroom.setOperatingTime(newOperatingTime);
                classroomList.add(classroom);
            }
            else if(operatingCondition.getFacility() != null) {
                Facility facility = operatingCondition.getFacility();
                facility.setOperatingTime(newOperatingTime);
                facilityList.add(facility);
            }
        }

        // 운영조건에 포함되지 못한 건물, 강의실, 편의시설
        List<Building> notOperatingBuildings = buildingRepository.findAllByIdNotIn(buildingList.stream().map(Building::getId).toList());
        for(Building building : notOperatingBuildings) {
            if(building.getId() != 0) building.setOperating(false);
        }

        if(classroomList.isEmpty()) notOperatingClassrooms = classroomRepository.findAll();
        else notOperatingClassrooms = classroomRepository.findAllByIdNotIn(classroomList.stream().map(Classroom::getId).toList());

        if(facilityList.isEmpty()) notOperatingFacilities = facilityRepository.findAll();
        else notOperatingFacilities = facilityRepository.findAllByIdNotIn(facilityList.stream().map(Facility::getId).toList());

// . . .

 

그리고 해당 일자에 맞는 운영 조건을 가진 건물, 강의실, 편의시설을 리스트에 저장한 후에 운영 조건을 가지지 않은 건물은 운영 여부를 false로 설정한다.

 

이후 운영 시간을 주기적으로 업데이트하는 데에 운영 조건과 운영 조건을 가지지 않는 강의실 및 편의시설 리스트가 계속 필요하기 때문에 static 변수로 설정하였다.

만약 classroomList가 비어있다면 findAllByIdNotIn(classroomList...)이 제대로 작동하지 않기 때문에 if문으로 나누었다.

 

운영 여부(boolean) 관리

운영 여부의 경우 각 시설의 운영 시간에 따라 주기적으로 업데이트되어야 해서 스케줄러가 30분에 한 번씩 작동하게끔 했다.

모든 시설의 운영 시간이 30분 단위로 끊어지지는 않지만 자주 작동할수록 서버에 부담이 생길 것 같아 30분으로 설정하였다.

 

스케줄러가 작동하면 운영 조건을 순회하면서 해당하는 운영 시간이 현재 시간을 포함하는지 확인한다.

포함한다면 해당 시설의 운영 여부를 true로, 아니라면 false로 업데이트한다.

운영 조건에 포함되지 않은 강의실, 편의시설의 경우 강의실과 편의시설이 있는 건물의 운영 여부를 따라가도록 한다.

 

// . . .
    for(OperatingCondition operCondition : operatingConditionList) {
            boolean isOperating = checkOperatingTime(operCondition, now);
            log.info("isOperating: {}", isOperating);

            if(operCondition.getBuilding() != null) {
                Building building = operCondition.getBuilding();
                building = entityManager.merge(building);
                log.info("building: {}", building.getName());
                if(building.isOperating() != isOperating) {
                    building.setOperating(isOperating);
                    changeNodeIsOperating(isOperating, building);
                }
            }
            
            // . . .
            
            // 운영 조건에 포함되지 않은 강의실, 편의시설
        for(Classroom classroom : notOperatingClassrooms) {
            classroom = entityManager.merge(classroom);
            classroom.setOperating(classroom.getBuilding().isOperating()); // 건물 운영여부를 따라감
        }

        for(Facility facility : notOperatingFacilities) {
            facility = entityManager.merge(facility);
            facility.setOperating(facility.getBuilding().isOperating()); // 건물 운영여부를 따라감
        }

 

 

건물의 운영 여부에 변동이 생긴 경우 건물의 가진 노드 중에 type이 출입구인 노드를 골라 운영 여부를 전환한다.

노드는 우리 어플의 주요 기능인 길찾기 기능을 위한 entity로, 길찾기에 현재 시간을 반영할 수 있도록 추후에 기능을 확대하기 위해서 isOperating 속성을 추가하였다.

 

결과

이런 식으로 화면에 표시된다!

 


 

혼자서 생각하고 구현한 내용이라 부족한 점도 많고... 계속 수정되겠지만

운영 시간 기능을 구현하고 더 효율적인 방법을 생각하는 과정이 재미있었기 때문에 기록해두려고 한다.

자세한 내용은 깃허브 참고!

https://github.com/DevKor-github/team-c-back

 

GitHub - DevKor-github/team-c-back: DevKor team c BE

DevKor team c BE. Contribute to DevKor-github/team-c-back development by creating an account on GitHub.

github.com

 

 

 

'Devkor' 카테고리의 다른 글

[고대로] static 변수와 영속성 컨텍스트  (0) 2024.08.17
AWS IAM 계정으로 배포하기 (1)  (0) 2024.06.27