본문 바로가기

개발일지

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

2주차까지 병원 예약 시스템을 완성하여 마무리 지었다.

 

 전체적인 구조는 일반 사용자의 입장에서 구현한 웹사이트로써, 개인 회원이 병원에 예약을 하는 경우 환자의 예약 내역이 데이터베이스로 옮겨지는 구조로 사이트를 구현하였다.

2주차까지 구현한 내용은 단지 일반 사용자의 입장에서 구현한 사이트이었기에, 별도의 운영자 페이지의 필요성을 느끼게 되었다.

 기존의 코드를 재활용하여 구현이 가능하다 판단해서 컨트롤러를 추가하는 것과 로그인한 아이디의 일반유저, 운영자 구분을 위한 로직 이외에는 별도로 백엔드 단을 구현하지는 않았다.

public enum AccountType {  // 일반회원, 운영자 구분 관리용
    USER, ADMIN
}
public class User{
    //... 생략

    // 일반 유저와 운영자 구분을 위해서 User에 추가함.
    @Enumerated(value = STRING)
    private AccountType accountType;
}

운영자 페이지는 처음 구현해 두었던 홈 화면에서 운영자 계정으로 로그인 하는 경우 자동으로 이동되도록 구현했다.

@PostMapping("/user/login")
public String beforeSignIn(@Valid UserForm form, BindingResult result, HttpSession session, Model model) {
    // Check if the login credentials are valid
    Optional<User> findUser = userService.findUser(form.getLogin_id(), form.getLogin_pw());

    if (findUser.isEmpty()) {  // 등록된 회원이 없는 경우
        result.rejectValue("login_id", "invalid", "Invalid login credentials");
        model.addAttribute("form", new UserForm());
        model.addAttribute("isSignInFail", true); // 등록된 회원이 아닌 경우 경고 발생
        return "loginForm";
    }

    User user = findUser.get();
    // Set the 'loggedIn' attribute to true and store the user's information
    model.addAttribute("user", user);
    model.addAttribute("loggedIn", true);

    // 사용자 정보를 세션에 저장
    session.setAttribute("loggedInUser", user);

    if (user.getAccountType() == ADMIN) {
        // 운영자 전용 페이지로 이동
        return "redirect:/admin";
    }

    log.info("Logged in: " + user.getName());
    return "redirect:/"; // Redirect to the home page
}

운영자 페이지에서 다루는 서비스들은 다음과 같다.

1) 고객 정보 수정

운영자의 경우 DB상에 저장되어 있는 회원가입된 모든 유저의 개인 정보를 확인 가능하며, 이들 정보를 수정 가능하다. 단, 아이디를 수정하는 경우 테이블상 아이디가 곂치는 데이터가 생길 가능성이 존재하여 유저의 아이디는 수정이 불가하다.

Edit 버튼을 누르면 회원 정보 수정 페이지로 이동한다. 회원 정보 수정 페이지는 기존의 구현해 두었기에 간단히 구현했다.

 

2) 진료과 수정

기존에는 진료과 정보를 수정할 수 있는 기능을 구현하지 않았기에 진료과의 정보를 수정할 수 있는 기능을 추가할 예정이다.

해당 기능에는 진료과 이름 및 전화번호 변경, 해당 과에 속한 의사들의 소속 변경 및 삭제 등이 포함될 예정이다.

  • 8/25 추가) 진료과 추가 기능을 구현하였고, 로그인 관련 버그를 수정했다.

진료과 추가시 기존 진료과와 이름과 전화번호가 겹치지 않는다면 새로운 진료과를 생성할 수 있도록 페이지를 구현했다. 만일 겹치는 부서가 있다면 부서 생성 페이지가 리다이렉트 되며 사진과 같이 경고가 발생한다.

 

/**departmentController에 구현한 진료과 추가 로직**/

@GetMapping("/admin/edit/department/add")
public String addDepartment(Model model) {

    model.addAttribute("form", new DepartmentForm());

    log.info("add department");
    return "department/addDepartment";
}

@PostMapping("/admin/edit/department/add")
public String addDepartmentToDB(@Valid DepartmentForm form, Model model, BindingResult result) {
    String name = form.getName();
    String phoneNumber = form.getPhoneNumber();

    // 이미 존재하는 부서인지 확인
    Optional<Department> findByName = departmentRepository.findByName(name);
    Optional<Department> findByPhoneNumber = departmentRepository.findByPhoneNumber(phoneNumber);
    if (findByName.isPresent() || findByPhoneNumber.isPresent()) {
        result.rejectValue("name", "invalid", "Department is already existed");
        result.rejectValue("phoneNumber", "invalid", "Department is already existed");


        model.addAttribute("isAlreadyExist", true);
        model.addAttribute("form", new DepartmentForm());
        return "department/addDepartment";
    }

    Department department = new Department(name, phoneNumber);

    // 병원과 의존관계 매핑
    Hospital hospital = hospitalService.findHospitals().get(0);
    hospital.addDepartment(department);

    departmentRepository.save(department);

    return "department/editDepartment";
}

 

  • 8/26 추가1) 서비스 계층에 더티체킹 기능을 통한 엔티티 업데이트 로직을 구현했다. 기존에는 컨트롤러에서 의존관계를 해제할 필요가 있는 경우, 엔티티를 조회하고 해당 엔티티가 준영속 상태에 있는 경우 변경 감지 기능으로는 DB에 내용을 수정하는 것이 불가하여 별도로 직접 update SQL을 작성해서 업데이트를 수행했었다. 그러나 이는 엔티티의 내용을 바꿀때마다 update 쿼리를 직접 날려줘야 한다는 점에서 스프링의 기능을 제대로 사용하지 못하는 문제가 있었고, 그렇기에 서비스 계층에 직접 업데이트를 위한 로직을 구현하였다. (다른 엔티티들도 업데이트 메서드를 구현했지만 Doctor의 서비스 계층을 예시로 들었다)
package project.hongik_hospital.service;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import project.hongik_hospital.domain.Department;
import project.hongik_hospital.domain.Doctor;
import project.hongik_hospital.repository.DoctorRepository;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional
public class DoctorService {

    private final DoctorRepository doctorRepository;

    // 생략

    /** 업데이트 로직 **/
    // 더티 체킹으로 업데이트 로직 구현
    public void updateDepartment(Long doctorId, Department department) {
        Doctor doctor = doctorRepository.findOne(doctorId);
        doctor.setDepartment(department);
    }
    public void update(Long doctorId, String name, int career) {
        Doctor doctor = doctorRepository.findOne(doctorId);
        doctor.setName(name);
        doctor.setCareer(career);
    }
    public void update(Long doctorId, String name, int career, Department department) {
        Doctor doctor = doctorRepository.findOne(doctorId);
        doctor.setName(name);
        doctor.setCareer(career);
        doctor.setDepartment(department);
    }
}

 

  • 8/26 추가2) 진료과 삭제 페이지와 진료과 정보 수정 페이지를 구현해서 진료과 수정 기능을 마무리 했다.

 

 

진료과 정보 수정 화면에서는 Edit 버튼을 누르면 해당 부서의 정보 수정 페이지로 이동하여 정보를 수정하도록 개발하였다.

  • 발견한 오류1) 진료과 정보 수정 페이지에 오류가 존재하는 것 같다. 개발한 코드대로라면 동일한 번호를 가지는 부서가 존재하는 경우 해당 번호로 수정을 할 수 없도록 로직을 구현하였는데, 같은 번호로 수정해도 정상적으로 수정이된다(?). 어디에 문제가 있는지를 모르겠어서 해결까지 시간이 좀 걸릴것 같다.
  • 해결1) 해결했다. 실수로 복붙하는 과정에서 findDepartmentByPhoneNumber()를 findDepartmentByName()에서 수정하지 않아서 동일한 전화번호를 가진 진료과를 조회하지 못했던 것이다.
@GetMapping("/admin/edit/department/{id}")
public String changeDepartmentInformationGet(@PathVariable Long id, Model model) {
    Department department = departmentService.findDepartment(id);
    model.addAttribute("department", department);
    model.addAttribute("form", new DepartmentForm());

    log.info("change Department information");
    return "department/changeDepartmentInfo";
}

@PostMapping("/admin/edit/department/{id}")
public String changeDepartmentInformationPost(@PathVariable Long id, @Valid DepartmentForm form, Model model, BindingResult result) {
    Department department = departmentService.findDepartment(id);

    String name = form.getName();
    String phoneNumber = form.getPhoneNumber();

    if (departmentService.findDepartmentByName(name).isPresent()) {

        result.rejectValue("name", "invalid", "Department is already existed");

        model.addAttribute("department", department);
        model.addAttribute("form", new DepartmentForm());
        model.addAttribute("isAlreadyExistName", true);

        log.info("change department information fail - name already exist");
        return "department/changeDepartmentInfo";

    }else if(departmentService.findDepartmentByName(name).isPresent()){
                    //findDepartmentByName(name)을 findDepartmentByPhoneNumber(phoneNumber)로 수정해야함
        result.rejectValue("phoneNumber", "invalid", "PhoneNumber is already used");

        model.addAttribute("department", department);
        model.addAttribute("form", new DepartmentForm());
        model.addAttribute("isAlreadyExistPhoneNumber", true);

        log.info("change department information fail - phone number already exist");
        return "department/changeDepartmentInfo";
    }  

    departmentService.update(id,name,phoneNumber);

    return "redirect:/admin/edit/department/information";
}

 

3) 의사 정보 수정

 의사 정보 수정 기능의 경우 진료과 정보 수정 기능과 매우 유사한 형태로 개발 중이다. 따라서 기존 코드를 재활용하는 빈도가 높아 빠르게 마무리 가능할 것 같다. 차이점이라면 연관관계 해제시 관련된 엔티티의 차이로 인해 연관관계 해제 코드 정도만 약간 차이가 존재할 것 같다.

  • 발견한 오류1) 기존에 doctorService에서 Doctor 엔티티를 조회해오는 경우에 Department를 join fetch하여 조회해왔는데 이럴 경우에는 만일 doctor의 department가 존재하지 않는 경우에는 해당 칼럼을 조회해 오지 않는 문제가 발생한다
  • 해결1) join fetch를 left join으로 조회해서 연관된 department가 없다고 하더라도 doctor 자체는 조회해 올 수 있도록 수정했다.
  • 발견한 오류2) 기존에 예약을 취소하더라도 Reserve 엔티티와 Doctor 엔티티간 연관관계를 끊지 않아서 외래키 제약조건에 의해서 Doctor를 제거할 수 없었다.
  • 해결2) Reserve를 Cancel하는 경우 Doctor와 연관관계를 해제하여 Doctor를 제거하는데 있어 외래키 제약조건에서 벗어났다.

4) 예약 내역 관리

 예약 관리 시스템까지 개발을 모두 마쳤다. 예약 관리 시스템의 구조는 다음과 같다.

유저 페이지의 ‘예약하기’에서 예약을 하는 경우 새로운 Reserve 엔티티가 생성되고 해당 데이터는 DB에 저장이된다. 메인 페이지의 ‘예약확인’ 에서는 유저(일반유저, 운영자 모두)가 자신의 예약 내역을 확인하고 취소할 수 있다.

CANCEL 버튼을 누르게 되면 해당 예약의 상태는 CANCEL로 변경된다.

 이번에 새로 구현한 운영자 페이지의 예약 내역 관리 페이지도 이와 비슷하다. 기존에 운영자 페이지를 구현하기 이전에는 따로 예약의 상태를 COMPLETE로 변경할 수 없었다. 왜냐하면 일반 회원이 함부로 예약을 완료할 수는 없기 때문이다. 때문에 운영자 페이지의 “예약 내역 관리” 페이지에서는 예약 상태를 COMPLETE로 변경하는 기능을 구현했다. 전반적인 로직은 메인페이지의 예약 상태를 CANCEL로 변경하는 로직과 매우 유사하다. 단지 상태가 CANCEL에서 COMPLETE로 바뀐 차이정도가 전부이다.

 생각해보니 예약 완료가 된 상태라면 해당 진료에 대한 비용이 청구되야 하는데 비용을 표기하는 부분을 프론트에 구현을 안했었다;; 이는 추후 프로젝트 버그 수정하는 과정에서 추가 해야겠다.

 

암튼 reserve의 예약 상태를 COMPLETE로 변경하는 로직은 다음과 같다.

//reserveService.complete(reserveId, fee) 메서드

public void complete(Long reserveId, int fee) {
    Reserve reserve = reserveRepository.findOne(reserveId);
    TreatmentDate treatmentDate = reserve.getTreatmentDate();
    Doctor doctor = reserve.getDoctor();

    // doctor와 reserve간 연관관계 해제, 생김새만 똑같은 객체 new로 넣어줌(표시 용도)
    reserve.setDoctor(new Doctor(doctor.getName(), doctor.getCareer()));
    // doctor와 treatment간 연관관계 해제
    treatmentDate.setDoctor(null);

    // doctor의 treatments 컬렉션에서 treatment 제거
    doctor.cancelTreatment(treatmentDate);
    // treatmentDate를 다시 저장함으로써 doctor의 fk 제거를 DB에 반영
    treatmentDateRepository.save(treatmentDate);

    reserve.complete(fee);
}

 

  • 발견한 오류1) complete( )에서 아래의 로직을 보자
// 문제의 코드
reserve.setDoctor(new Doctor(doctor.getName(), doctor.getCareer()));

 예약이 완료된다면 해당 의사는 더 이상 예약에 의존관계가 존재해서는 안된다. 만일 예약이 취소, 완료된 상태에서도 의존관계가 끊어지지 않는다면 한번이라도 예약과 연관된 의사의 경우 DB상에서 외래키 제약조건에 의해서 제거할 수 없게 된다. 따라서 만일 예약이 취소, 완료된 경우 Reserve와 Doctor와의 연관관계를 해제하기 위해서 위와 같은 로직을 구현했다.

 사실 위의 로직을 구현한 당시에는 reserve와 연관된 Doctor 엔티티를 그냥 해당 Doctor와 모습만 똑같이 생긴 객체로 바꿔치기 해준다면 Doctor와의 연관관계는 해제가 됨과 동시에 Reserve는 예약되었던 Doctor의 내용은 그대로 갖고 있겠거니… 하고 구현했다.

 

 사실 처음에는 이렇게 구현하지도 않았었다.

// 처음에 구현한 코드
reserve.setDoctor(null);

 처음에는 그냥 연관관계 해제를 위해서 아예 그냥 Doctor를 null로 바꿔치기 했었다. 근데 이러면 Reserve와 Doctor와의 연관관계 해제는 가능하지만 더 이상 취소, 완료 상태의 예약내역에서 해당 Doctor의 정보를 볼수 없었다. 아예 연관된 Doctor가 사라졌기 때문이다.

 때문에 그냥 모습만 똑같은 객체를 생성해서

reserve.setDoctor(new 기존 연관된 Doctor엔티티와 정보만 같은 다른 Doctor 객체);

와 같은 코드를 작성했었던 것이다. 근데 이러면 문제가 존재한다.

기존 연관된 Doctor를 제거하고자 다른 Doctor 엔티티를 “생성”한다는 것이 그 문제이다. 이렇게 되면 DB 상에 처음 Doctor와 모습이 똑같은 필드가 하나 더 생성이 된다는 것이다.

 지금 보면 RESERVE 테이블의 DOCTOR_ID가 3에서 18로 변경이 됨과 동시에 18번 DOCTOR 필드도 새로이 생성되었음을 알 수 있다.

결론적으로 이 문제는 예약을 취소하거나 완료한 경우 새로운 의사가 계속해서 생성된다는 것이다.

(심지어 이렇게 새로 생성된 Doctor의 경우, 상태가 변경된 reserve와 연관관계가 계속 유지되기 때문에 테이블에서 삭제하는 것도 불가능하다.)

 

처음에 이 버그를 보았을 때 정말 어처구니가 없었다. 그래서 처음 시도한 것은 Reserve 클래스에서 Doctor 엔티티와 cascade 설정을 변경하는거였다.

@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Reserve {

    @Id @GeneratedValue
    @Column(name = "reserve_id")
    private Long id;

    
    @ManyToOne(fetch = LAZY, cascade = PERSIST) // cascade = PERSIST를 제거하려했다!
    @JoinColumn(name = "doctor_id")
    private Doctor doctor;

   //... 생략
}

처음에는 그럼 setDoctor로 넣어준 새로운 doctor를 DB상에 그냥 저장안하면 연관관계도 안생기니 되는거 아닌가?라는 마인드로 그냥 cascade 설정을 제거했었다.

근데 생각해보니 그렇게 되면 애초에 상태가 변경된 reserve는 DB 상에서 가져올 doctor가 없으니 애초에 의사 정보를 조회할 수 없게 된다. 또한 reserve가 persist되지 않은 엔티티를 조회하려고 시도하기 때문에 “org.hibernate.TransientPropertyValueException”에러가 발생한다.

// persist 되지 않은 엔티티를 조회하여 에러가 발생함
org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : project.hongik_hospital.domain.reserve.Reserve.doctor -> project.hongik_hospital.domain.Doctor

 

 그래서 생각을 바꿨다. Reserve가 Doctor 엔티티를 가지는 것이 아니라 새롭게 DoctorData라는 임베디드 타입 클래스를 만들어 Doctor의 데이터만 가지도록 구현하는 것이 그것이다. 이렇게 된다면 애초에 Reserve와 Doctor는 연관관계가 존재하지 않고, 만일 Reserve의 상태를 변경하는 경우에도 Doctor의 데이터는 계속 유지하는 것이 가능하다.

 

한가지 문제라면 이미 reserve와 doctor의 연관관계를 만들어서 프로그램을 완성시켜놔서 고쳐야 할 부분이 겁나 많다는 것이다. 시간이 좀 걸릴 것 같다….

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

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