DevLog

병원 예약 시스템 Part 2 - 예약 비즈니스 로직 구현과 프론트엔드 연동

기마니 2025. 12. 9. 01:41

지난 글에서는 Oracle DB 설계와 JPA 엔티티 매핑을 통해 데이터의 구조 부분을 정리했다. 이번에는 실제 사용자가 예약을 요청했을 때 DB에 저장하고 처리하는 비즈니스 로직과 API를 구현하고 이를 프론트엔드와 연동하는 과정을 정리할 거다.

 

학원 다닐 때 TypeScript 찍먹해봤는데(사실 찍먹도 아님 그냥 냄새만 맡은 정도임) 이번 기회에 더 공부해보면 좋을 것 같아서 JavaScript 대신 TypeScript 써봄(하지만 굉장히 후회했다죠,.,, 왜냐고요? IntelliJ Community 버전에서는 타입스크립트 지원이 안된다죠,,,, 나는 그냥 아니 편하다며?! 타입스크립트 편하다며!!! 하나도 안편한데 왜 씀?? 이러면서 꾸역꾸역 해냄,,, 어쨌든 해냄)

 

 


 

Service 계층

컨트롤러가 요청을 받으면 실제 일은 Service가 처리한다. 단순히 repository.save()만 호출하는 것이 아니라, 트랜잭션 관리유효성 검증이 이곳에서 이루어진다.

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true) // 1. 기본적으로 읽기 전용으로 설정하여 성능 최적화
public class ReservationService {

    private final ReservationRepository reservationRepository;
    private final UserRepository userRepository;
    private final DoctorRepository doctorRepository;

    // 진료 예약 생성
    @Transactional // 2. 데이터 변경(Insert)이 일어나는 곳엔 트랜잭션 필수 적용
    public Long createReservation(Long userId, Long doctorId, LocalDateTime reservationTime) {

        // 3. 엔티티 조회 (데이터 존재 여부 확인)
        User user = userRepository.findById(userId)
                .orElseThrow(() -> new NoSuchElementException("환자를 찾을 수 없습니다."));

        Doctor doctor = doctorRepository.findById(doctorId)
                .orElseThrow(() -> new NoSuchElementException("의사를 찾을 수 없습니다."));

        // 4. [핵심] 중복 예약 검증
        validateDuplicateReservation(doctorId, reservationTime);

        // 5. 예약 생성 및 저장
        Reservation reservation = Reservation.createReservation(user, doctor, reservationTime);
        reservationRepository.save(reservation);
        
        return reservation.getId();
    }

    // 중복 예약 방지 메서드
    private void validateDuplicateReservation(Long doctorId, LocalDateTime reservationTime) {
        // 해당 의사의 해당 시간에 '확정(CONFIRMED)'되거나 '대기(PENDING)'중인 예약이 있는지 확인
        List<Reservation> duplicates = reservationRepository
                .findByDoctorIdAndReservationDatetimeAndStatusIn(
                        doctorId, 
                        reservationTime, 
                        List.of("CONFIRMED", "PENDING")
                );

        if (!duplicates.isEmpty()) {
            throw new IllegalStateException("이미 예약된 시간입니다.");
        }
    }
}

 

@RequiredArgsConstructor: 롬복(Lombok) 라이브러리 기능. final이 붙은 필드에 대한 생성자를 자동으로 생성해줌. 이 어노테이션을 쓰면 코드가 간결해지고 새로운 의존성이 추가되어도 코드 수정 안해도 됨. 의존성 주입을 받을 필드는 반드시 private final로 선언해야 롬복이 인식함.

 

@Transactional(readOnly = true): 클래스 레벨에 적용하여 모든 메서드를 기본적으로 읽기 전용으로 설정. 이렇게 하면 JPA가 변경 감지를 수행하지 않아 메모리를 절약하고 조회 성능이 향상된다.

 

@Transactional (메서드 레벨): createReservation 메서드는 데이터를 저장(Insert)해야 하므로, 읽기 전용을 덮어쓰고 쓰기 가능한 트랜잭션을 별도로 적용했다. 이를 통해 데이터의 정합성을 보장한다.

 

.orElseThrow(...): findById로 조회했을 때 데이터가 없으면 null을 반환하는 대신, 명확하게 예외를 던지도록 처리. 이렇게 하면 에러 발생 원인을 파악하기 훨씬 쉽다.

 

validateDuplicateReservation(...): save()를 호출하기 전에 같은 의사 + 같은 시간에 이미 확정 되거나 대기 중인 예약이 있는지 먼저 확인. 만약 중복된 예약이 있다면 IllegalStateException을 발생시켜 저장을 막는다.

(원래 프로젝트 기획할 때

환자가 예약 신청(PENDING) ➡ 병원 관리자가 승인 ➡ 예약 확정(CONFIRMED) 으로 이어지는 승인 시스템을 구상했었음. 그래서 DB 설계 단계에서 status 컬럼 만들고 상태 값을 구분해뒀는데 프로젝트 진행하면서 일단 예약 기능에만 집중하는 게 좋을 것 같아서 승인 기능 구현은 보류함,,, 하지만? 혹시 몰라서 일단 남겨둠)

 

 


 

Controller 계층: API와 View의 분리

이번 프로젝트에서는 화면을 보여주는 역할과 데이터를 처리하는 역할을 분리했다.

View Controller (@Controller): HTML 템플릿(Thymeleaf)을 반환하여 사용자가 볼 화면을 렌더링함.

API Controller (@RestController): JSON 데이터를 반환하여 프론트엔드(Javascript)가 비동기로 통신할 수 있게 함.

 

화면 담당: ReservationFormController
@Controller
public class ReservationFormController {
    
    private final ReservationService reservationService;

    // 생성자 주입
    public ReservationFormController(ReservationService reservationService) {
        this.reservationService = reservationService;
    }

    // 예약 화면 진입 (GET)
    @GetMapping("/reservations/new")
    public String createForm(Model model) {
        // HTML 파일에 데이터를 넘겨주기 위해 빈 DTO 객체를 모델에 담아 보냄
        model.addAttribute("reservationForm", new ReservationRequestDto());
        return "reservations/createReservationForm"; // 템플릿 파일명 반환
    }
}

 

@Controller: 이 어노테이션이 붙으면 메서드의 반환값을 뷰 이름으로 인식한다. 스프링은 templates/ 폴더에서 해당 이름의 HTML 파일을 찾아 사용자에게 보여줌.

 

데이터 담당: ReservationController
@RestController // @Controller + @ResponseBody
@RequiredArgsConstructor
@RequestMapping("/api/v1/reservations")
public class ReservationApiController {

    private final ReservationService reservationService;

    // POST: 진료 예약 생성 요청 처리
    @PostMapping
    public ResponseEntity<Long> createReservation(
            @RequestParam("userId") Long userId,
            @RequestParam("doctorId") Long doctorId,
            @RequestParam("reservationTime") String reservationTimeStr
    ) {
        try {
            // 1. 문자열 시간을 LocalDateTime으로 파싱
            LocalDateTime reservationTime = LocalDateTime.parse(reservationTimeStr);

            // 2. 비즈니스 로직 호출 (Service)
            Long reservationId = reservationService.createReservation(
                    userId, doctorId, reservationTime);

            // 3. 결과(예약 ID)를 JSON 데이터로 반환 (화면 이동 X)
            return new ResponseEntity<>(reservationId, HttpStatus.CREATED);

        } catch (Exception e) {
            return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
        }
    }
}

 

 

@RestController: 메서드의 반환값을 뷰가 아닌 HTTP 응답 본문(JSON)으로 전송한다.

프론트엔드 연동: TypeScript(JS)의 fetch 함수가 이 주소(/api/v1/reservations)로 데이터를 보내고, 성공/실패 여부를 응답받는다.

 

이렇게 역할을 분리하면 나중에 프론트엔드 프레임워크를 React나 Vue로 바꾸더라도 API 컨트롤러는 그대로 재사용할 수 있다.

 

 


 

TypeScript 세팅

인텔리제이에서 TypeScript를 쓰려면 먼저 환경 세팅부터 해야 한다.
Node.js 설치: 터미널에서 node -v로 확인

 

만약 명령어를 찾을 수 없다고 나오면 Node.js 공식 홈페이지에서 설치해야 함

 

 

폴더 만들기: src 폴더의 형제로 frontend 폴더 만들기

reservation-system/
├── src/main/
│   ├── java/com/example/demo/
│   └── resources/
└── frontend/
    ├── src/
    │   └── reservation.ts   # TypeScript 소스
    └── dist/
        └── reservation.js   # 컴파일된 JavaScript

 

 

프로젝트 초기화: 터미널 열고 frontend 폴더로 이동해서 초기화 명령어 입력하면 프론트엔드 폴더에 package.json 파일이 생김

# 1. frontend 폴더로 이동
cd frontend

# 2. Node.js 프로젝트 초기화 (엔터 칠 필요 없이 기본값으로 자동 생성)
npm init -y

 

 

TypeScript 설치: package.json 파일 안에 "devDependencies": { "typescript": ... } 내용이 추가되고 node_modules 폴더가 생김

 

# (여전히 frontend 폴더 안에서 실행)
npm install -D typescript

 

 

설정 파일 만들기: TypeScript를 어떻게 자바스크립트로 변환할지 알려주는 설정 파일. frontend 폴더 안에 tsconfig.json 파일을 만들고 아래 내용을 복붙하면 됨

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "CommonJS",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist" // 컴파일된 JS 파일이 저장될 폴더
  },
  "include": ["src/**/*"] // 컴파일 대상 폴더
}

 

 

TypeScript 파일 작성:  frontend/src 폴더 안에 파일 만들면 됨

# frontend 폴더에서 실행
npx tsc

 

터미널에서 npx tsc 명령어 실행하면  frontend/src에 있는 .ts 파일이 자바스크립트로 변환되어 frontend/dist 폴더에 .js 파일이 생김. 이걸 복사해서 스프링부트의 src/main/resources/static/js 폴더에 넣으면 됨.

(사실 tsconfig.json 파일에서 outDir 경로를 스프링 폴더(src/main/resources/static/js)로 하면 여기에 바로 저장됨)

 

Frontend 구현(TypeScript) 

TypeScript는 JavaScript에 타입 시스템을 추가한 언어다.

우선 나는 코드를 작성하기 전에 먼저 데이터 구조를 Interface로 정의했다.

 

1. 타입 정의 (Interface): 백엔드와 주고 받을 데이터 타입 정의

// 진료과 데이터 타입
interface Department {
  id: number;           // 진료과 ID
  deptName: string;     // 진료과 이름
  description: string;  // 진료과 설명
}

// 의사 데이터 타입
interface Doctor {
  id: number;
  name: string;
  dayOff: string;       // 휴무일
  department: {         // 소속 진료과 (중첩 객체)
    id: number;
    deptName: string;
  };
}

.
.
.

 

중첩 객체: 객체 안에 또 다른 객체가 들어있는 구조 (API 한 번 호출로 필요한 정보를 가져올 수 있음)

 

Doctor 엔티티에 @ManyToOne 어노테이션을 사용: Doctor를 JSON으로 변환할 때 연관된 Department 정보도 자동으로 함께 포함시킴

 

@ManyToOne을 안 썼다면: Department 객체가 아니라 deptId만 저장   프론트엔드에서 진료과 정보를 보여주려면 추가로 API를 한 번 더 호출해야 함

 

 

2. 전역 상태 관리: 사용자가 선택한 값을 저장할 전역 변수 선언

// 선택된 값들을 저장 (null은 "아직 선택 안 함"을 의미)
let selectedDepartmentId: number | null = null;
let selectedDoctorId: number | null = null;
...

 

number | null 타입: 숫자 or null을 받을 수 있음. 아직 선택하지 않은 상태를 표현

 

let을 쓴 이유: 사용자가 선택을 변경할 때마다 값이 계속 바뀌어야 하기 때문(const는 재할당이 불가능하므로 사용 불가)

 

 

3. API 호출 함수: 백엔드와 통신하는 비동기 함수들 작성

async function fetchDepartments(): Promise<Department[]> {
  try {
    const response = await fetch('/api/v1/departments');
    
    if (!response.ok) {
      throw new Error('진료과 목록을 가져오는데 실패했습니다.');
    }
    
    const departments: Department[] = await response.json();
    return departments;
    
  } catch (error) {
    console.error('진료과 조회 오류:', error);
    return [];
  }
}

 

 

GET 요청으로 API 호출 → 응답 대기 → 성공 여부 확인  JSON 파싱  반환 (에러 시 빈 배열)

async/await: 비동기 처리를 동기 코드처럼 작성

fetch: .json() 메서드를 한 번 더 호출해야 함, 404/500 에러가 떠도 catch로 안 가고 then으로 옴(response.ok 체크해야 함)

(Axios 사용하면 자동으로 JSON 변환해주고 400,500 에러 때 catch로 넘어감)

이번 프로젝트는 로그인 기능 없고 그냥 단순 CRUD만 있는 프로젝트라서 fetch썼음

response.ok: HTTP 상태 코드 200번대(성공) 확인

response.json(): JSON을 JavaScript 객체로 변환

try-catch: 에러 발생 시 프로그램이 멈추지 않도록 처리

 

 

나머지 API 함수들(의사 목록, 예약 가능 시간 등)도 동일한 패턴으로 작성함. 차이점은 URL과 파라미터, 반환 타입정도?

 

 


 

기본적인 예약 생성 로직(Service, Controller)과 TypeScript, Thymeleaf 환경 구축 후 통합 테스트도 성공해서 마무리 하려고했는데 데이터베이스 구조를 검토하던 중 비효율적인 부분을 발견했다.

DOCTOR_SCHEDULE  테이블에 의사 한 명당 스케줄이 들어가 있는데 휴무가 아니라 근무일 데이터를 넣다보니 의사 1명 = 데이터 5개가 되었다. 생각해보니까 병원 운영 시간은 고정이고 의사별 휴무는 평일 1회니까 방식을 바꾸면 의사 1명 = 데이터 1개가 된다.

그러면? DOCTOR_SCHEDULE 테이블을 없애고 DOCTOR 테이블에 day_off 필드만 추가하면 되는 거 아닌가라는 생각을 하게 되었다. 효율적인 구조를 위해 눈물을 머금고 코드를 고치기로 결정했는데,,,,, 이 뒷부분은 다음 글에서,,,,