not-a-gardener 개발기 6. 식물 CRUD, 물주기 계산 로직 수정, JPA @OneToMany, @ManyToOne 양방향 매핑

메밀·2023년 2월 7일
0

not-a-gardener 개발기

목록 보기
6/6
post-thumbnail

1. 최소 기능 개발 완료

최소 기능이라고 생각했던 로그인/회원가입, 식물 CRUD, 물/비료주기 및 관수 알림 기능을 완성했다.
본의 아니게 포스팅이 늦어져 조금 예전의 기록이다.


2. JPA @OneToMany, @ManyToOne 양방향 매핑

1) 매핑에 대한 오해

Plant(식물 정보)와 Watering(물주기 정보)은 일대다 관계를 맺고 있다.

화면 구성 상, Plant는 Watering을 알고 있어야 한다. 그러나 Watering은 Plant를 알고 있을 필요가 없다고 판단하여, Plant에 @OneToMany wateringList를 사용하여 일대다 단방향 매핑을 했다.

기본적으로 관계형 DB의 FK/JOIN은 양방향 매핑이지만, Entity의 연관관계는 그와 다르다는 피상적인 이해에 머무르고 있었기 때문이다.

연관관계의 주인에 대한 내용은 여기로!


2) 연관관계의 주인과 @OneToMany 단방향 매핑의 단점

@OneToMany 단방향 매핑의 가장 큰 문제는 엔티티가 관리하는 외래키가 다른 테이블에 있다는 것이다.
이에 기존 단방향 매핑을 양방향 매핑으로 변경했다.

코드는 간략하게만 옮긴다.

public class Plant{
	/* 생략 */
    
	@OneToMany(fetch=FetchType.LAZY, mappedBy="plant")
    @OrderBy("watering_date desc")
    private List<Watering> wateringList = new ArrayList<>();
}

public class Watering{
	/* 생략 */
    
	@ManyToOne
    @JoinColumn(name="plant_no")
    private Plant plant;
}

Plant - Watering의 일대다 관계에서 연관관계의 주인은 Watering이다.
주인이 아닌 쪽(Plant, wateringList)는 mappedBy 속성을 사용하여 연관관계의 주인을 표시한다.

이로써 Watering은 자신의 테이블에 있는 FK를 관리하게 되고,
Plant는 Watering을 읽기만 한다.



3. 식물 CRUD

1) PlantController

@RequestMapping("/garden/plant")
@RestController
@Slf4j
public class PlantController {
    @Autowired
    private PlantService plantService;

    @GetMapping("/{plantNo}")
    public PlantDto getOnePlant(@PathVariable("plantNo") int plantNo){
        return plantService.getOnePlant(plantNo);
    }

    @PostMapping("")
    public void addPlant(@AuthenticationPrincipal User user, @RequestBody PlantRequestDto plantRequestDto){
        plantRequestDto.setUsername(user.getUsername());
        plantService.addPlant(plantRequestDto);
    }

    @PutMapping("/{plantNo}")
    public void modifyPlant(@PathVariable("plantNo") int plantNo, @RequestBody PlantRequestDto plantRequestDto, @AuthenticationPrincipal User user){
        plantRequestDto.setUsername(user.getUsername());
        plantService.modifyPlant(plantRequestDto);
    }

    @DeleteMapping("/{plantNo}")
    public void deletePlant(@PathVariable("plantNo") int plantNo){
        log.debug("delete plant -> plantNo: " + plantNo);

        plantService.deletePlantByPlantNo(plantNo);
    }
}

@PathVariable

URL 경로에 변수를 넣어 데이터를 받아오는 방식
주로 Rest API에서 사용한다.
공백값이 넘어오는 곳에 사용하지 않도록 주의
.이 포함되어 있을 시 . 이후의 값만 넘어오므로 주의! ex) aaa.bb

RequestParam과 비교

http://127.0.0.1/users?userId={$userId}
-> RequestParam 방식

http://127.0.0.1/users/{userId}
-> PathVariable 방식



2) PlantService

@Service
@Slf4j
public class PlantServiceImpl implements PlantService {
    @Autowired
    private PlantDao plantDao;

    @Override
    public PlantDto getOnePlant(int plantNo) {
        Plant plant = plantDao.getPlantOne(plantNo);

        // DTO로 변환
        PlantDto plantDto = new PlantDto(plant);

        List<WaterDto> waterDtoList = new ArrayList<>();

        for(Watering w : plant.getWateringList()){
            waterDtoList.add(new WaterDto(w));
        }

        plantDto.setWaterDtoList(waterDtoList);
        
        return plantDto;
    }

    @Override
    public void addPlant(PlantRequestDto plantRequestDto) {
        plantDao.savePlant(plantRequestDto.toEntity());
    }

    @Override
    public void modifyPlant(PlantRequestDto plantRequestDto) {
        Plant plant = plantDao.getPlantOne(plantRequestDto.getPlantNo())
                .update(plantRequestDto.getPlantName(),
                        plantRequestDto.getPlantSpecies(),
                        plantRequestDto.getAverageWateringPeriod());
        plantDao.savePlant(plant);
    }

    @Override
    public void deletePlantByPlantNo(int plantNo) {
        plantDao.deletePlantByPlantNo(plantNo);
    }
}



3) PlantDao, PlantRepository

package com.buckwheat.garden.dao.impl;

import java.util.List;

@Slf4j
@Service
public class PlantDaoImpl implements PlantDao {

    @Autowired
    private PlantRepository plantRepository;

    // 유저의 전체 식물리스트를 반환
    @Override
    public List<Plant> getPlantListByUsername(String username) {
        return plantRepository.findByMember_Username(username);
    }

    @Override
    public Plant savePlant(Plant plant) {
        return plantRepository.save(plant);
    }

    @Override
    public Plant getPlantOne(int plantNo) {
        return plantRepository.findById(plantNo).get();
    }

    @Override
    public void updateAverageWateringPeriod(int plantNo, int avgWateringPeriod) {
        Plant plant = plantRepository.findById(plantNo).get();
        plant.setAverageWateringPeriod(avgWateringPeriod);

        // plantNo 값이 있으므로 update가 실행된다.
        plantRepository.save(plant);
    }

    @Override
    public void deletePlantByPlantNo(int plantNo) {
        plantRepository.deleteById(plantNo);
    }
}

plantRepository.findByMember_Username(username)

FK로 엔티티를 조회하는 method 명명 규칙
findBy + <fk를 관리하는 entity의 필드명> + _ + <fk entity의 식별자 필드명>


save()의 insert와 update

JpaRepository의 save()를 호출하면 내부적으로 SimpleDataJpaRepository의 save()가 호출된다.
SimpleDataJpaRepository는 EntityInformation(엔티티의 정보를 갖고 있음)의 isNew 메소드에 엔티티를 넘겨 새롭게 생성된 Entity인지를 판단한다.

이렇게 Jpa는 신규 생성된 Entity인지 여부를 판단하여,
존재하던 Entity면 update(merge)를, 새로 생긴 엔티티면 insert를 실행한다.

ID 값이 있으면 update, 있으면 insert!



4. 물주기 계산 로직 수정

물, 비료 주기 정보가 없는 식물을 처리하기 위해 물주기 계산 로직을 수정하였다.

@Slf4j
@Service
public class GardenServiceImpl implements GardenService {
    // 비료 주기는 일단 내가 하던대로 5일에 맞춰놓았다.
    private final int FERTILIZING_SCHEDULE = 5;
    
    @Autowired
    private PlantDao plantDao;

    @Autowired
    private WateringDao wateringDao;

	// Entity를 DTO로 변환하며 계산 결과를 합쳐 반환하는 메소드
    @Override
    public List<PlantDto> getPlantList(String id) {
        List<PlantDto> plantList = new ArrayList<>();

        for(Plant p : plantDao.getPlantListByUsername(id)){
            PlantDto plantDto = new PlantDto(p);
            calculateCode(plantDto);

            plantList.add(plantDto);
        }

        return plantList;
    }

	// 마지막 관수 일자를 계산하는 메소드
    @Override
    public LocalDate getLastDrinkingDay(int plantNo){
        LocalDate latestWateringDay = wateringDao.getLatestWateringDayByPlantNo(plantNo);
        LocalDate latestFertilizedDay = wateringDao.getLatestFertilizedDayByPlantNo(plantNo);

        if(latestWateringDay == null && latestFertilizedDay == null){
            return null;
        } else if(latestWateringDay != null && latestFertilizedDay == null){
            return latestWateringDay;
        } else if(latestWateringDay == null) {
            // 이 입력은 아직 불가능
            return latestFertilizedDay;
        }

        // 물/비료 주기 정보가 둘 다 있을 시 둘 중에 더 큰 날짜를 반환
        // true면 latestFertilizedDay가 더 큰 날짜
        LocalDate lastDrinkingDay = (latestFertilizedDay.isAfter(latestWateringDay)) ? latestFertilizedDay : latestWateringDay;

        return lastDrinkingDay;
    }

    // 비료 주기 계산 메소드
    // 비료줘야 하면 1, 안 줘도 되면 0
    @Override
    public int getFertilizingCode(int plantNo){
        LocalDate latestFertilizedDay = wateringDao.getLatestFertilizedDayByPlantNo(plantNo);

        // 비료를 준 적이 없는 경우
        if(latestFertilizedDay == null){
            // 일단 맹물 주도록
            // TODO 첫 비료 스케줄 잡는 로직 추가
            return 0;
        }

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

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

	// 위의 메소드를 사용하여 물주기 일자를 계산하는 메소드
    @Override
    public void calculateCode(PlantDto plantDto){
        int recentWateringPeriod = plantDto.getAverageWateringPeriod();
        LocalDate lastDrinkingDay = getLastDrinkingDay(plantDto.getPlantNo());

        // 물을 준 적이 한 번도 없는 경우
        if(lastDrinkingDay == null){
            plantDto.setWateringCode(4);
            plantDto.setFertilizingCode(0);

            return;
        }

        // 비료 줄지 말지 여부 계산
        int fertilizingCode = getFertilizingCode(plantDto.getPlantNo());

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

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

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

        // 오늘 물 준 식물
        if(period == 0){
            wateringCode = 5;
        }

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

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



5. To be continued...

이렇게 not-a-gardener의 최소 기능 개발을 완료했다.
새로 접한 기술(JPA, React)들에 대한 기초적인 공부도 마쳤다.
이제 ERD를 다시 짜서 제대로 된 개발을 시작할 것이다.


일차적으로 완성한 ERD는 다음과 같다.

garden-erd
(ERD는 어떻게 예쁘게 배치하는 걸까..?)

우선 최소 기능에 살을 붙여 더 세밀한 관리와 기록이 가능하도록 할 것이다.

또한 종류별 비료에 따른 비료 시비 주기 커스터마이징, 해충 방제 알림, 분갈이 타이밍 알림 및 기록, 식물 상태에 따른 알림 세분화 및 기록, 식재별 관리 알림 등의 메인 기능과 올해의 식물 목표 정하기, 위시리스트와 가격 비교, 기온 변화에 따른 성장기 알람 등의 작은 기능을 추가할 것이다.

야호! 이제 봄이다!

0개의 댓글