not-a-gardener 개발기 4) JPA Projection, 물주기 계산로직

메밀·2023년 6월 12일
0

not-a-gardener

목록 보기
4/13
post-thumbnail

not_a_gardener_plant

1. 문제 상황!

1) not-a-gardener의 메인 기능

not-a-gardener의 메인인 Plant, Garden 기능을 소개하자면 다음과 같다.

  • 관수 주기 계산 (ex. 오늘 물 줄 날이에요, 물이 말랐을 수 있으니 흙을 확인해보세요, 물 줄 날짜가 지났어요!)
  • 물을 줘야하는 날이라면, 맹물을 줘야하는지 비료를 줘야하는지
  • 물 준지 며칠이 지났는지

즉, 계산할 게 많다!

2) 문제 상황!

계산 과정에서 필요한 값을 DB에서 불러와야 하는데(보유한 비료 목록과 각 비료의 마지막 시비 날짜),
이는 Entity가 아니다.

고로 이번 포스트에서는 JPA를 사용해 Entity가 아닌 값을 조회하는 방법과 관주/시비 주기 계산 로직을 중점으로 다룬다.

2. 백엔드

1) 플로우

서비스를 기준으로 DB에 다녀오는 Dao/Repository,
계산 로직을 담당하는 GardenResponseProvider/각종 Util 클래스를 분리하여 진행했다.

2) GardenDto: 무엇을 계산해서 돌려줄 것인가?

	@Getter
    @AllArgsConstructor
    @NoArgsConstructor
    @ToString
    public static class Response {
    	// 식물 정보 (DB에서 바로 나옴)
        private PlantDto.Response plant; 
        // 서버단에서 계산해서 돌려줘야할 데이터들
        private Detail gardenDetail;
    }
    
    @AllArgsConstructor
    @Builder
    @Getter
    @ToString
    public static class Detail {
        // 마지막 관수
        private WateringDto.Response latestWateringDate;

        // 이하 계산해서 넣는 정보
        private String anniversary; // 키운지 며칠 지났는지
        private int wateringDDay;

        // 물주기 정보
        private int wateringCode;

        // 비료 주기 정보
        ChemicalCode chemicalCode;
    }
    
    //WateringDto.Response
    @AllArgsConstructor
    @Builder
    @Getter
    public static class Response {
        private Long id;
        private String plantName;
        private String chemicalName;
        private LocalDate wateringDate;
        private Message msg;
    }
    
    // PlantDto.Response
    @AllArgsConstructor
    @Builder
    @Getter
    @ToString
    public static class Response{
        private Long id;
        private String name;
        private String species;
        private int recentWateringPeriod;
        private int earlyWateringPeriod;
        private String medium;
        private Long placeId;
        private String placeName;
        private LocalDate createDate;
        private LocalDate birthday;
        private LocalDate postponeDate;
        private LocalDate conditionDate;
    }

기존에 사용하고 있던 기본 Response용 DTO를 재활용하여 만든 DTO라 복잡해보일 수 있다. GardenDto를 도식화하면 다음과 같다.


GardenDto = Plant + Detail
PlantDetail
PlantDtoWateringDto
기타 정보(ex. nn일째 반려중)관수 코드, 시비 코드


3) 로직

클래스하는 일
GardenResponseProviderGardenUtil등으로 DB에서 받아온 데이터를 넘기고, Response용 DTO 조합
GardenUtilGardenResponseProvider에서 넘겨준 데이터로 각종 계산을 수행하고, 계산된 값을 돌려줌

4) GardenUtil

식물에 관한 각종 계산 로직이 들어있는 클래스다.

public GardenDto.Detail getGardenDetail(Plant plant, List<ChemicalUsage> latestChemicalUsages) {
        // ##### 1. nn일째 반려중
        String anniversary = getAnniversary(plant.getBirthday());

        // 물주기 기록이 없으면
        if (plant.getWaterings() == null || plant.getWaterings().size() == 0) {
            // 물주기 정보가 부족해요
            return GardenDto.Detail.from(null, anniversary, -1, WateringCode.NO_RECORD.getCode(), null);
        }

        // 가장 최근 물주기 불러오기
        WateringDto.Response latestWatering = WateringDto.Response.from(plant.getWaterings().get(0));

        // ##### 2. 비료든 물이든 뭐라도 준지 며칠이나 지났는지 계산
        int wateringDDay = getWateringDDay(plant.getRecentWateringPeriod(), plant.getWaterings().get(0).getWateringDate());
        
        // ##### 3. 관수 코드 계산
        // 이 식물은 목이 말라요, 흙이 말랐는지 확인해보세요 ... 등의 watering code를 계산
        int wateringCode = getWateringCode(plant.getRecentWateringPeriod(), wateringDDay);

		// ##### 4. 시비 코드 계산
        // chemicalCode: 물을 줄 식물에 대해서 맹물을 줄지 비료/약품 희석액을 줄지 알려주는 용도
        // 어떤 비료를 줘야하는지 알려준다
        GardenDto.ChemicalCode chemicalCode = getChemicalCode(latestChemicalUsages);

        return GardenDto.Detail.from(latestWatering, anniversary, wateringDDay, wateringCode, chemicalCode);
    }
    
    
    // # 1. nn일째 반려중
    public String getAnniversary(LocalDate birthday) {
        if(birthday == null){
            return "";
        }

        LocalDate today = LocalDate.now();

        // 생일이면
        if ((today.getMonth() == birthday.getMonth()) && (today.getDayOfMonth() == birthday.getDayOfMonth())) {
            return "생일 축하해요";
        }

        return Duration.between(birthday.atStartOfDay(), today.atStartOfDay()).toDays() + "일 째 반려중";
    }

5) 관수 코드 계산

// ##### 2. 비료든 물이든 뭐라도 준지 며칠이나 지났는지 계산
    public int getWateringDDay(int recentWateringPeriod, LocalDate lastDrinkingDay) {
        // 비료든 물이든 뭐라도 준지 며칠이나 지났는지 계산
        int period = (int) Duration.between(lastDrinkingDay.atStartOfDay(), LocalDate.now().atStartOfDay()).toDays();
        return recentWateringPeriod - period;
    }
    
// #### 3. 관수 코드 계산
    public int getWateringCode(int recentWateringPeriod, int wateringDDay) {
        if (recentWateringPeriod == wateringDDay) {
            // 오늘 물 줌
            return WateringCode.WATERED_TODAY.getCode();
        } else if (recentWateringPeriod == 0) {
            // 물주기 정보 부족
            return WateringCode.NO_RECORD.getCode();
        } else if (wateringDDay == 0) {
            // 물주기
            return WateringCode.THIRSTY.getCode();
        } else if (wateringDDay == 1) {
            // 물주기 하루 전
            // 체크하세요
            return WateringCode.CHECK.getCode();
        } else if (wateringDDay >= 2) { // 얘가 wateringCode == 4 보다 먼저 걸린다
            // 물주기까지 이틀 이상 남음
            // 놔두세요
            return WateringCode.LEAVE_HER_ALONE.getCode();
        } else {
            // 음수가 나왔으면 물주기 놓침
            // 며칠 늦었는지 알려줌
            return wateringDDay;
        }
    }
  • 해당 식물의 가장 마지막 물 준 날짜를 통해 물을 준지 며칠이나 지났는지 구한다. (비료를 줬든 맹물을 줬든 상관없다)
  • 위의 계산값을 오늘 알려줄 내용을 계산한다.

최근 7일마다 물을 줬던 식물이 있다고 가정하자.
  • 물을 준 지 7일 지남: 오늘 물 마실 날이에요
  • 물을 준 지 6일 지남: 흙이 마르지 않았는지 살펴보세요
  • 물을 준 지 1~5일 지남: 가만히 두세요
  • 오늘 물을 줌: 오늘 물을 줬어요
  • 물을 준 지 -3일 지남: '물 줄 날짜를 놓쳤어요'

6) 시비 코드 계산

물을 줘야할 식물의 경우, 오늘 맹물을 줘야할지 비료를 섞어 줘야할지도 계산해준다.

	// -1           0           1
    // 비료 사용 안함  맹물 주기      비료주기
    public GardenDto.ChemicalCode getChemicalCode(List<ChemicalUsage> latestChemicalUsages) {
        // index 필요
        // chemical list index에 맞춰 해당 chemical을 줘야하는지 말아야하는지 산출
        for (int i = 0; i < latestChemicalUsages.size(); i++) {
            ChemicalUsage latestFertilizingInfo = latestChemicalUsages.get(i);
            LocalDate latestFertilizedDate = latestFertilizingInfo.getLatestWateringDate();

            if (latestFertilizedDate == null) {
                // 해당 비료를 준 기록이 아예 없으면
                continue;
            }

            // 해당 비료를 준지 얼마나 지났는지 계산
            int period = (int) Duration.between(latestFertilizedDate.atStartOfDay(), LocalDate.now().atStartOfDay()).toDays();

            // 시비 날짜와 같거나 더 지났으면
            if (period >= (int) latestFertilizingInfo.getPeriod()) {
                return new GardenDto.ChemicalCode(latestFertilizingInfo.getChemicalId(), latestFertilizingInfo.getName());
            }
        }

        return null;
    }

이때, DB에서 엔티티가 아닌 값을 조회해와야 한다.

* JPA, DTO로 조회하기

@Query 어노테이션을 사용해여 nativeQuery 메소드를 만든다.

// ChemicalRepository

@Query(value = "select chemical_id chemicalId, period, name," +
            " (select MAX(watering_date) from watering w where w.plant_id = :plantId and w.chemical_id = c.chemical_id) latestWateringDate" +
            " from chemical c where c.gardener_id = :gardenerId" +
            " order by period desc", nativeQuery = true)
    List<ChemicalUsage> findLatestChemicalizedDayList(@Param("gardenerId") Long gardenerId, @Param("plantId") Long plantId);

chemicalId(PK), 시비 주기, 비료 이름, 그리고 스칼라 서브쿼리를 사용해 해당 비료의 마지막 시비 주기를 가져오는 간단한 SQL이다.

이때 리턴 값으로 사용한 ChemicalUsage는 다음과 같다.

public interface ChemicalUsage {
    Long getChemicalId();
    int getPeriod();
    String getName();
    LocalDate getLatestWateringDate();
}

getter만을 이용한 인터페이스를 구현해주면 된다.

3. 나가며

이번 기능을 구현하며 JPA가 자꾸 나를 괴롭혔다. (어떻게 하는지 모르겠음, 자꾸 안된다구 함)
하지만 그만큼 나도 JPA를 괴롭혔기 때문에 (안된다는데 계속 시킴) 쌤쌤으로 치기로 한다.

0개의 댓글