본문 바로가기

개발일지

홍익 병원 예약 시스템 개발 후기 -2

버그 수정 과정

 예약을 취소,완료한 경우 새로운 Doctor가 생성되는 문제가 존재했다. 그래서 그냥 새로운 DoctorData라는 필드를 Reserve 엔티티에 추가하여 데이터만 유지하도록 하겠다고 지난 포스트에서 말했다. 근데 아무리 생각해도 그럴거면 엔티티를 사용하는 이유가 없다는 생각이 들었다.

 

 그래서 좀 고민을 해봤는데 애초에 이러한 문제가 발생하는 근본적인 이유를 찾았다. 컨트롤러에서는 DTO를 사용하는데 DTO는 엔티티와의 연결되어 있으면 안된다.(애초에 엔티티를 사용하지 않기위해서 필요한 정보만을 담아두는 곳이 DTO이기 때문). 아래는 예약 취소를 위해 내가 기존에 구현 로직이다.

@GetMapping("/reserve/history")
public String getReserveHistory(@ModelAttribute("reserveForm") ReserveForm form, Model model, HttpSession session) {

    User loggedInUser = (User) session.getAttribute("loggedInUser");
    if (loggedInUser == null) {
        model.addAttribute("form", new UserForm());
        model.addAttribute("needToSignIn", true);
        return "loginForm";
    }
    User user = userService.findUser(loggedInUser.getId());
	
    // **** 이놈이 문제 **** //
    List<ReserveDto> reserveDtos = new ArrayList<>();
    List<Reserve> reserves = user.getReserves();

    for (Reserve reserve : reserves) {
        if (reserve.getReserveStatus() == form.getStatus()) {
            reserveDtos.add(new ReserveDto(reserve));
        }
    }

    model.addAttribute("reserves", reserveDtos);

    log.info("reserve history");

    return "reserve/reserveHistory";
}

@PostMapping("/reserve/history/{reserveId}/cancel")
public String cancelReserve(@PathVariable("reserveId") Long reserveId) {

    reserveService.cancel(reserveId);
    return "redirect:/reserve/history";
}

지금 보면 내가 만든 로직에서 상태가 맞는 reserve들을 reserveDto에 담아서 이를 html에 넘기고 있다. 근데 ReserveDto 내부를 까봤더니

package project.hongik_hospital.domain;

import lombok.Data;
import project.hongik_hospital.domain.reserve.Reserve;

import java.time.LocalDateTime;

@Data
public class ReserveDto {  //다른 엔티티들을 참조하는 잘못된 설계
    private Long id;
    private User user
    private Doctor doctor
    private Department department;
    private Hospital hospital;
    private LocalDateTime reserveDate;
    private String treatmentDate;
    private ReserveStatus reserveStatus;

    public ReserveDto(Reserve reserve) {
        id = reserve.getId();
        user = reserve.getUser();
        doctor = reserve.getDoctor();
        department = reserve.getDepartment();
        hospital = reserve.getHospital();
        reserveDate = reserve.getReserveDate();
        reserveStatus = reserve.getReserveStatus();

        TreatmentDate treatment = reserve.getTreatmentDate();

        String month = Integer.toString(treatment.getMonth());
        String date = Integer.toString(treatment.getDate());
        String hour = Integer.toString(treatment.getHour());
        String minute = Integer.toString(treatment.getMinute());

        if (treatment.getMonth() < 10) {
            month = "0" + month;
        }
        if (treatment.getDate() < 10) {
            date = "0" + date;
        }
        if (treatment.getHour() < 10) {
            hour = "0" + hour;
        }
        if (treatment.getMinute() < 10) {
            minute = "0" + minute;
        }

        treatmentDate = month + "-" + date + "-" + hour + ":" + minute;


    }
}

Dto 자체가 완전히 다른 엔티티들을 참조하고 있었다. 이 때문에 지금까지 많은 문제들이 발생했던 것이다.

"DTO는 엔티티를 참조해서는 절대로 안된다!!!"

 

그래서 html에 넘길 필요한 필드들만 남겨서 다시 설계를 했다. 내가 ReserveDto를 사용하는 경우는 reserve의 status를 "RESERVE"에서 "CANCEL"이나"COMPLETE"로 변경하는 경우밖에 없으니 애초에 ReserveDto에 저많은 정보를 다 가지고 있을 필요도 없었다.

ReserveDto가 필요한 정보는 User, Doctor, Department, TreatmentDate, ReserveDate, Reserve의 금액인 fee에 대한 데이터가 전부이다. 불필요한 Hospital에 대한 필드는 추가하지 않겠다.

package project.hongik_hospital.domain;

import lombok.Data;

import java.time.LocalDateTime;

@Data
public class ReserveDto {

    private Long id;

    // User
    private String userName;
    private int userAge;
    //Doctor
    private String doctorName;
    private int doctorCareer;
    //Department
    private String departmentName;
    //reserveDate
    private LocalDateTime reserveDate;
    //TreatmentDate
    private String treatmentDate;
    //ReserveStatus
    private ReserveStatus reserveStatus;
    //fee
    private int fee;

    public ReserveDto(Reserve reserve) {
        id = reserve.getId();

        userName = reserve.getUser().getName();
        userAge = reserve.getUser().getAge();

        try {
            doctorName = reserve.getDoctor().getName();
            doctorCareer = reserve.getDoctor().getCareer();
        } catch (NullPointerException e) {
            doctorName = null;
            doctorCareer = 0;
        }

        departmentName = reserve.getDepartment().getName();

        reserveDate = reserve.getReserveDate();
        reserveStatus = reserve.getReserveStatus();

        fee = reserve.getFee();

        TreatmentDate treatment = reserve.getTreatmentDate();

        String month = Integer.toString(treatment.getMonth());
        String date = Integer.toString(treatment.getDate());
        String hour = Integer.toString(treatment.getHour());
        String minute = Integer.toString(treatment.getMinute());

        if (treatment.getMonth() < 10) {
            month = "0" + month;
        }
        if (treatment.getDate() < 10) {
            date = "0" + date;
        }
        if (treatment.getHour() < 10) {
            hour = "0" + hour;
        }
        if (treatment.getMinute() < 10) {
            minute = "0" + minute;
        }

        treatmentDate = month + "-" + date + "-" + hour + ":" + minute;
    }
}

 보다시피 내부의 엔티티를 모두 제거하고 대신 엔티티 내부의 필드를 모두 새롭게 생성했다. 즉 엔티티와는 무관하나 데이터만 가지고 있도록 설계했다. 그리고 이렇게 설계를 하다보니 컨트롤러 계층도 수정이 필요했다. 상태 변경을 위해 새롭게 고친 컨트롤러 계층의 메서드는 다음과 같다.

@Controller
@Slf4j
@RequiredArgsConstructor
public class ReserveController {

    private final ReserveService reserveService;
    private final UserService userService;
    private final HospitalService hospitalService;
    private final DoctorService doctorService;
    private final DepartmentRepository departmentRepository;	
		
        //... 생략

    // 모든 reserve 정보가 여기에 저장됨
    List<ReserveDto> totalReserveDtos = new ArrayList<>();

    @GetMapping("/reserve/history")
    public String getReserveHistory(@ModelAttribute("reserveForm") ReserveForm form, Model model, HttpSession session) {

        User loggedInUser = (User) session.getAttribute("loggedInUser");
        if (loggedInUser == null) {
            model.addAttribute("form", new UserForm());
            model.addAttribute("needToSignIn", true);
            return "loginForm";
        }

        User user = userService.findUser(loggedInUser.getId());
        List<Reserve> reserves = user.getReserves();
        List<ReserveDto> reserveDtos = new ArrayList<>();

        //reserves에서 form에서 넘어온 status와 같은 reserve만 가져와 dto로 넘김
        for (Reserve reserve : reserves) {
            ReserveDto newReserveDto = new ReserveDto(reserve);

            // 특정 status의 reserve를 조회
            if (reserve.getReserveStatus() == form.getStatus()) {
                boolean isAlreadySaved = false;
                for (ReserveDto totalReserveDto : totalReserveDtos) {
                    //이미 totalReserveDtos에 저장되어 있는 상태
                    if (totalReserveDto.getId().compareTo(reserve.getId()) == 0) {
                        isAlreadySaved = true;
                        break;
                    }
                }
                // totalReserveDtos에 저장되지 않았다면(처음 생성한 예약) totalReserveDtos에 저장함
                if (!isAlreadySaved) {
                    totalReserveDtos.add(newReserveDto);
                }

                if (reserve.getDoctor() == null) {
                    // 만일 doctor 데이터가 없다면 기존 저장된 전체 ReserveDto 데이터에서
                    // 해당 reserve의 id와 동일한 ReserveDto 객체의 doctor 데이터를 가져와서 주입해줌
                    for (ReserveDto totalReserveDto : totalReserveDtos) {
                        if (totalReserveDto.getId().compareTo(reserve.getId()) == 0) {
                            // Doctor 정보 주입
                            newReserveDto.setDoctorName(totalReserveDto.getDoctorName());
                        }
                    }
                }

                reserveDtos.add(newReserveDto);
            }
        }

        model.addAttribute("reserves", reserveDtos);

        log.info("reserve history");

        return "reserve/reserveHistory";
    }

    @PostMapping("/reserve/history/{reserveId}/cancel")
    public String cancelReserve(@PathVariable("reserveId") Long reserveId) {

        reserveService.cancel(reserveId);
        return "redirect:/reserve/history";
    }

    @GetMapping("/admin/manage/history")
    public String manageReserve(@ModelAttribute("form") ReserveForm form, Model model) {
        List<Reserve> reserves = reserveService.findReserves();
        List<ReserveDto> reserveDtos = new ArrayList<>();

        //reserves에서 form에서 넘어온 status와 같은 reserve만 가져와 dto로 넘김
        for (Reserve reserve : reserves) {
            ReserveDto newReserveDto = new ReserveDto(reserve);

            // 특정 status의 reserve를 조회
            if (reserve.getReserveStatus() == form.getStatus()) {
                boolean isAlreadySaved = false;
                for (ReserveDto totalReserveDto : totalReserveDtos) {
                    //이미 totalReserveDtos에 저장되어 있는 상태
                    if (totalReserveDto.getId().compareTo(reserve.getId()) == 0) {
                        isAlreadySaved = true;
                        break;
                    }
                }
                // totalReserveDtos에 저장되지 않았다면(처음 생성한 예약) totalReserveDtos에 저장함
                if (!isAlreadySaved) {
                    totalReserveDtos.add(newReserveDto);
                }

                if (reserve.getDoctor() == null) {
                    // 만일 doctor 데이터가 없다면 기존 저장된 전체 ReserveDto 데이터에서
                    // 해당 reserve의 id와 동일한 ReserveDto 객체의 doctor 데이터를 가져와서 주입해줌
                    for (ReserveDto totalReserveDto : totalReserveDtos) {
                        if (totalReserveDto.getId().compareTo(reserve.getId()) == 0) {
                            // Doctor 정보 주입
                            newReserveDto.setDoctorName(totalReserveDto.getDoctorName());
                        }
                    }
                }

                reserveDtos.add(newReserveDto);
            }
        }

        model.addAttribute("reserves", reserveDtos);

        return "reserve/manageHistory";
    }

    @PostMapping("/admin/manage/history/{reserveId}")
    public String manageReserveComplete(@PathVariable("reserveId") Long reserveId, ReserveForm form) {

        reserveService.complete(reserveId, form.getFee());

        return "redirect:/admin/manage/history";
    }
}

 생각보다 고칠게 많아서 controller 계층 수정에 오랜시간이 걸렸던 것 같다. 위의 코드를 설명해보자면 기본적으로 기존에는 상태 변경시에도 Doctor의 데이터를 유지하기 위해서

reserve.setDoctor(new Doctor(doctor.getName(),doctor.getCarrer());

로 기존 Doctor 엔티티를 새로운 객체 엔티티로 바꿔치기 했었는데 이러다보니 새로운 Doctor가 계속해서 생성되는 문제가 발생했었다. 그렇기에 이번엔 그냥 아예 연관관계를 null로 제거했었다.

reserve.setDoctor(null);

 이러면 더 이상 새로운 엔티티가 생성되지는 않겠지만 해당 Reserve 엔티티의 경우 더 이상 기존 의사 정보를 알 수 없다는 단점이 존재했다.

 

 그래서 이번에는 저장된 모든 Reserve 객체를 ReserveDto로 변환한 후 저장하는 totalReserveDto 라는 리스트 객체를 선언했다. totalReserveDto에는 모든 Reserve 데이터를 저장해두기 때문에 만일 reserve.setDoctor(null);로 연관된 Doctor 정보가 제거되었다고 하더라도 totalReserveDto에서 조회해서 정보를 가져올 수 있도록 했다. 약간 일종의 아카이브 느낌(?)

 

 이때 무지성으로 totalReserveDto에 넣기만하면 무한정으로 리스트의 크기가 커질테니 그러지 않고 totalResreveDto에서 새로이 넣고자 하는 ResreveDto 객체가 새로 예약한 Reserve의 것인지 확인한 후 기존에 저장되지 않았던 새로운 것이라면 저장되고, 아니라면 저장되지 않도록 구현했다.

if (totalReserveDto.getId().compareTo(reserve.getId()) == 0) {
    isAlreadySaved = true;
    break;
}

		 ....
 
// totalReserveDtos에 저장되지 않았다면(처음 생성한 예약) totalReserveDtos에 저장함
if (!isAlreadySaved) {
    totalReserveDtos.add(newReserveDto);
}

 

또한 위에서 setDoctor(null)을 했기 때문에 조회한 reserve는 연관된 Doctor 엔티티가 존재하지 않을 가능성이 있다.
그렇기에 이럴 경우 totalReserveDto에서 해당 reserve에 해당하는 ReserveDto 객체를 찾아서, 의사 정보를 reserve에 주입했다.

if (reserve.getDoctor() == null) {
    // 만일 doctor 데이터가 없다면 기존 저장된 전체 ReserveDto 데이터에서
    // 해당 reserve의 id와 동일한 ReserveDto 객체의 doctor 데이터를 가져와서 주입해줌
    for (ReserveDto totalReserveDto : totalReserveDtos) {
        if (totalReserveDto.getId().compareTo(reserve.getId()) == 0) {
            // Doctor 정보 주입
            newReserveDto.setDoctorName(totalReserveDto.getDoctorName());
        }
    }
}

 

그 외의 로직은 기존과 동일하다. 요약하자면...

  1. DTO를 엔티티와 격리함
  2. 기존에 생성된 모든 Reserve의 내용이 저장된 별도의 리스트 생성.
  3. Reserve의 상태가 변경되는 경우 연관된 Doctor를 null로 변경하여 연관관계 제거
  4. 조회한 Reserve가 상태가 변경되어 Doctor 정보가 null 인 경우, 리스트에서 해당 reserve의 Doctor 데이터를 조회해서 주입해줌.

대강 이런식으로 구현했다. 실제로 이제는 버그도 없이 잘 작동한다.

 

정말 이 부분 고치는게 빡셌던 것 같다. 프로젝트 진행하면서 제일 오랜 시간과 고민을 했던 부분인 것 같다.

이 부분에서만 공부한 내용이

  1. 연관된 엔티티의 연관관계를 제거하지 않고 해당 엔티티를 제거하려는 경우 외래키 제약조건에 의해서 제거가 불가능할 수 도 있다.
  2. DTO는 무조건 엔티티와는 격리되어야 한다.

였던 것 같다. DELETE를 위해서 별도로 sql도 공부했다(근데 이건 쉬우니까).

 

프로젝트 개발 후기

 암튼 이런식으로 병원 예약 시스템은 완성하였다. 전반적인 엔티티 설계는 잘했던 것 같은데 아직 DTO와 컨트롤러, 연관관계에 대한 이해에 대해서 부족함을 많이 느꼈다. 그래도 강의만 봤을 때는 내가 뭘 모르는지 잘 몰랐는데, 플젝을 진행하니 내가 어떤 부분에서 부족한지 알 수 있었던 것 같다. 또한 웹사이트 하나를 만들더라도 생각보다 전혀 별거 아닌 것 같은데 디테일하게 신경쓸게 엄청 많다는 것도 배웠다. 경우의 수가 정말 많았다. 앱이나 프로그램 만드시는 분들은 대단하신 것 같다 정말.

 그리고 프론트엔드 몰라도 까짓거 대충하면 되겠지 하고 시작했는데 죽는줄 알았다. 어느날은 백엔드 설계보다 html 만드는게 더 오래 걸렸던 것 같다. 프론트엔드 개발자 분들께 존경의 박수를 보낸다. 그래도 하다보니 이젠 읽을 수는 있는듯?

 첫 프로젝트라서 걱정 많이 했는데 그냥 시간 갈아넣으면 구현할 수 있다는 것에서 나도 만들 수 있구나? 라는 객긴지 모를 자신감을 많이 얻은 것 같다. 그리고 만드는 게 생각보다 되게 재밌었다. 새벽까지 만들면서도 재밌어서 할 만했다.  백엔드 하길 잘한 것 같다~!

 앞으로는 DevOps도 조금 공부할까 한다. 백준도 좀 풀고, 플젝도 하나 더하고 싶다. 운동도 해야되고 동아리도 해야되고, 공부도하고 할게 너무 많다. 그래도 남들도 열심히 사니까 나도 열심히 해야겠다.

'개발일지' 카테고리의 다른 글

홍익 병원 예약 시스템 개발 후기  (2) 2023.08.31