not-a-gardener 개발기 4. 식물 리스트와 물주기 스케줄

메밀·2023년 1월 3일
0

not-a-gardener 개발기

목록 보기
4/6

1. 서론

로그인을 마치면 유저의 모든 식물 리스트와 함께 개별 식물의 물 줄 날짜를 페이지에 띄운다.
가장 만들고 싶던 기능이다!!!

not-a-gardener

기본 흐름은 다음과 같다.

유저가 로그인한다
→ Spring Security가 token을 확인하고, (유효하다면) 헤더에 담긴 값을 사용해 유저의 식물 entity 리스트를 구해온다
→ Plant Entity의 정보를 이용해 PlantDto 리스트를 만든다
→ React는 받은 json을 화면에 표시한다



2. Spring

1) GardenController

@Slf4j
@RestController
public class GardenController {
    @Autowired
    private GardenService gardenService;

    // garden 메인 페이지의 데이터 받아오기
    @GetMapping("/garden")
    public List<PlantDto> gardenMain(@AuthenticationPrincipal User user){
        // @AuthenticationPrincipal: UserDetailsService에서 리턴한 객체를 파라미터로 직접 받아 사용할 수 있도록 하는 어노테이션
        // -> 이 어노테이션을 사용하면 헤더에 첨부된 access token에서 로그인한 사용자의 정보를 받아와 사용할 수 있다.

        return gardenService.getPlantList(user.getUsername());
    }
}

로그인 ID를 통해 유저의 식물 리스트를 구해온다.


@AuthenticationPrincipal

Spring Security는 한번 인증된 사용자 정보를 SecurityContextHolder 내부의 SecurityContext에 Authentication 객체로 저장한다. 이를 참조하기 위해 사용하는 어노테이션이다.



2) GardenService

@Slf4j
@Service
public class GardenServiceImpl implements GardenService {

    // 비료 주기는 일단 내가 하던대로 5일에 맞춰놓았다.
    // TODO 비료 주기 커스터마이징 기능
    private final int FERTILIZING_SCHEDULE = 5;
    
    @Autowired
    private PlantDao plantDao;

    @Autowired
    private WateringDao wateringDao;

    @Override
    public List<PlantDto> getPlantList(String id) {
        List<PlantDto> plantList = new ArrayList<>();

		// user의 id로 DB에 저장된 식물 리스트를 불러온 뒤, DTO로 변환한다.
        // 이 과정에서 아래 calculateCode 메소드를 사용하여 추가적인 정보를 덧붙인다.
        for(Plant p : plantDao.getPlantListByUsername(id)){
            PlantDto plantDto = new PlantDto(p);
            calculateCode(plantDto);

            plantList.add(plantDto);
        }

        return plantList;
    }

    @Override
    public void calculateCode(PlantDto plantDto){
        int recentWateringPeriod = plantDto.getAverageWateringPeriod();
        LocalDate latestWateringDay = wateringDao.getLatestWateringDayByPlantNo(plantDto.getPlantNo());
        LocalDate latestFertilizedDay = wateringDao.getLatestFertilizedDayByPlantNo(plantDto.getPlantNo());
        LocalDate today = LocalDate.now();

        // 둘 중에 더 큰 날짜를 반환
        // true면 latestFertilizedDay가 더 큰 날짜
        LocalDate lastDrinkingDay = (latestFertilizedDay.isAfter(latestWateringDay)) ? latestFertilizedDay : latestWateringDay;

        // log.debug("today: " + today);
        // log.debug("lastDrinkingDay: " + lastDrinkingDay);
        // log.debug("Period.between(today, lastDrinkingDay): " + Period.between(today, lastDrinkingDay));

        // 비료든 물이든 뭐라도 준지 며칠이나 지났는지 계산
        // 관엽이라 한달 넘어갈 일은 없으므로 일만 계산
        int period = Period.between(lastDrinkingDay, today).getDays();

        // 비료준지 얼마나 지났는지 계산
        int fertilizingSchedule = Period.between(latestFertilizedDay, today).getDays();
        // log.debug("fertilizingSchedule: " + fertilizingSchedule);

        // 비료준 지 5일이 지났는지 확인하고 해당하는 코드 반환
        int fertilizingCode = (fertilizingSchedule - FERTILIZING_SCHEDULE >= 0) ? 1 : 0;

        // log.debug("recentWateringPeriod: " + recentWateringPeriod);
        // log.debug("period: " + period);

        // 0이면 물 줄 날짜
        // 1이면 물 주기 하루 전이니까 체크해보기
        int wateringCode = recentWateringPeriod - period;

        // 음수가 나오면 물주기를 놓친 것이므로
        if(wateringCode < 0){
            wateringCode = 2;
            fertilizingCode = 0;
        } else if (wateringCode > 2){
            // 3 이상이면... 딱히 할 일 없는 식물
            wateringCode = 3;
        }

        plantDto.setWateringCode(wateringCode);
        plantDto.setFertilizingCode(fertilizingCode);

        log.debug("after calculate: " + plantDto);
    }
}

calculateCode()에 대한 부가설명

calculateCode() 메소드는, 물을 줘야할지 여부(wateringCode)와 비료 시비 여부(fertilizingCode)를 숫자 코드로 만든 뒤, DTO에 담아 전달한다.

해당 코드를 계산하기 위한 기본 변수는 다음과 같다.

  • 최근 평균 물주기(recentWateringPeriod)
  • 가장 최근 물 준 날짜(latestWateringDay)
  • 가장 최근 비료를 준 날짜(latestFertilizedDay)
  • 현재 시각(now)
  • 물을 줬든 액체 비료를 줬든 흙은 젖었을 것이므로, latestWateringDay와 latestFertilizedDay 중 최신 날짜를 lastDrinkingDay 변수에 담는다.

fertilizingCode

오늘 날짜에서 가장 최근 비료를 준 날을 뺀다.

나는 평균적으로 액체 비료 주기를 일주일 미만으로 잡으므로, 이 값이 0 이상이면 1을(비료를 줘야한다는 뜻), 음수면 0을 반환한다.


wateringCode

period는 물을 준 지 며칠이 됐는지를 알려준다.

(DB에 저장해놓은) 이 식물의 최근 평균 물주기(averageWateringPeriod)에서 period를 빼서 wateringCode를 구한다.

오늘이 10일이고, 이 식물의 averageWateringPeriod가 4일 간격이라고 가정하자.

이해를 돕기 위해 잠시 x라는 변수를 사용하겠다.

int x = averageWateringPeriod - period;

  • x가 음수인 경우
    - ex) 물 준 지 5일 지남
    • 유저는 물 줄 날짜를 놓친 것이다.
    • 이럴 때 비료를 주는 건 위험하므로 fertilizingCode를 0으로 바꾼다. 맹물을 줘야한다.
  • x가 0인 경우
    - ex) 물 준 지 4일 지남

    • 오늘은 높은 확률로 물을 줘야하는 날이다.
  • x가 1인 경우
    - ex) 물 준 지 3일 지남
    - 식물의 성장 혹은 환경/계절 변화로 물주기가 앞당겨지는 경우가 많다.

    • 흙을 파서 말랐는지 확인해보아야 한다.
  • x가 2이상인 경우
    - 그냥... 관상하면 된다.



3) WateringDao

@Service
public class WateringDaoImpl implements WateringDao {
    @Autowired
    private WateringRepository wateringRepository;

    @Override
    public LocalDate getLatestWateringDayByPlantNo(int plantNo){
        return wateringRepository.findLatestWateringDayByPlantNo(plantNo);
    }

    @Override
    public LocalDate getLatestFertilizedDayByPlantNo(int plantNo) {
        return wateringRepository.findLatestFertilizedDayByPlantNo(plantNo);
    }
}

특별한 건 없고 그저 JpaRepository를 extend한 인터페이스에 간단한 네이밍 메소드를 사용하였다.



4) WateringRepository

@Repository
public interface WateringRepository extends JpaRepository<Watering, Integer> {
    @Query(value = "SELECT MAX(watering_date)"
            + " FROM watering w "
            + " WHERE w.plant_no = :plantNo"
            + " AND w.fertilized = 'Y'", nativeQuery = true)
    LocalDate findLatestFertilizedDayByPlantNo(@Param("plantNo") int plantNo);

    @Query(value = "SELECT MAX(watering_date)"
            + " FROM watering w "
            + " WHERE w.plant_no = :plantNo"
            + " AND w.fertilized IS NULL", nativeQuery = true)
    LocalDate findLatestWateringDayByPlantNo(@Param("plantNo") int plantNo);
}

하... 여기서 정말... 그냥 MyBatis 쓸 걸...하면서 후회했다...😭

식물 리스트를 띄우고 물주기 스케줄을 계산하기 위해선 plant 테이블의 값들과 watering 테이블의 데이터 두 개가 필요하다. MyBatis를 썼다면... 그냥 간단한 스칼라 쿼리로 한번에 가져올 수 있었을 것이다.

물론 JPA를 아직 잘 다루지 못해서 벌어진 일이었겠지만, JPA에서 MAX를 어떻게 사용하는지도 모르겠고... 스칼라쿼리는 못 쓴다고 그러고... 인라인뷰도 안된다 그랬다가 된다고 그랬다가.... 사실은 JOIN도... 원하는 column만 추출해내는 방법 모르고...

끙끙대느니 일단은 native query를 사용하기로 했다.

우선 plant 테이블에서 해당 유저의 식물을 모두 구해온 후, plant 테이블의 PK인 plant_no를 파라미터로 넘겨서 마지막으로 맹물을 준 날과 마지막으로 비료를 준 날을 구해오도록 하였다.



3. React - Garden 메인 페이지

const GardenMain = () => {
   const [plantList, setPlantList] = useState([{
      plantNo: ''
      , plantName: ''
      , plantSpecies: ''
      , averageWateringPeriod: ''
      , wateringCode: ''
      , fertilizedCode: ''
  }]);

  // 백엔드에서 식물 리스트를 받아온다
  useEffect(() => {
     axios.get("/garden", "")
        .then((res) => {
          // console.log(res.data);
          setPlantList(res.data);
          })
        .catch(error => console.log(error))
  }, [])

  return (
  <>
      <CRow>
       {plantList.map((plant, idx) => {
          const color = ["primary", "warning", "danger", "success"];
          let message = "";

           if(plant.wateringCode == 0){
              message = "이 식물은 목이 말라요!";

              if(plant.fertilizingCode == 0){
                message += "맹물을 주세요!";
              } else {
                message += "비료를 주세요!";
              }

          } else if(plant.wateringCode == 1) {
              message = "최근 물주기 하루 전입니다. 흙이 말랐는지 확인해보세요! 말랐다면 "

              if(plant.fertilizingCode == 0){
                message += "맹물을 주세요!";
              } else {
                message += "비료를 주세요!";
              }

          } else if(plant.wateringCode == 2) {
           	 message = "물 줄 날짜를 놓쳤어요! 비료 절대 안 됨!"
          } else if(plant.wateringCode == 3) {
           	 message = "놔두세요. 그냥 관상하세요.";
          }

         return (
            <CCol sm={6} lg={3}>
              <CWidgetStatsA
                className="mb-4"
                color={color[plant.wateringCode]}
                value={
                  <>
                    <span role="img" aria-label="herb">🌿 </span>
                        {plant.plantName}{' '}
                    <span role="img" aria-label="herb">🌿</span>

                    <div className="fs-6 fw-normal">
                      <div>{plant.plantSpecies}</div>
                      (이 식물의 평균 물주기는 {plant.averageWateringPeriod}일입니다.)
                    </div>
                  </>
                }
                title={message}
                action={
                  <CDropdown alignment="end">
                    <CDropdownToggle color="transparent" caret={false} className="p-0">
                      <CIcon icon={cilOptions} className="text-high-emphasis-inverse" />
                    </CDropdownToggle>
                    <CDropdownMenu>
                      <CDropdownItem>Action</CDropdownItem>
                      <CDropdownItem>Another action</CDropdownItem>
                      <CDropdownItem>Something else here...</CDropdownItem>
                      <CDropdownItem disabled>Disabled action</CDropdownItem>
                    </CDropdownMenu>
                  </CDropdown>
                }
              />
            </CCol>
          )
      })}
    </CRow>
  </>
  )
}

export default GardenMain

특별한 로직은 없다. 그냥 백엔드에서 식물 리스트를 받아온 다음, 반복문을 돌린다. 각 식물의 wateringCode와 fertilizingCode에 맞춰 카드 색깔과 메시지를 구한 뒤 화면에 보여준다.

0개의 댓글