지난 글에서는 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 필드만 추가하면 되는 거 아닌가라는 생각을 하게 되었다. 효율적인 구조를 위해 눈물을 머금고 코드를 고치기로 결정했는데,,,,, 이 뒷부분은 다음 글에서,,,,
'DevLog' 카테고리의 다른 글
| 병원 예약 시스템 Part 3 - 데이터베이스 리팩토링과 API 연동 (0) | 2025.12.15 |
|---|---|
| 병원 예약 시스템 Part 1 - 프로젝트 세팅과 기술 선정 이유 (MyBatis vs JPA) (0) | 2025.12.08 |