콘텐츠로 이동

병원 예약 SaaS 개발기 Part 1: 병원 예약은 CRUD로 끝나지 않는다

서로 다른 시간표와 의료 자원을 가진 세 병원 작업대를 하나의 SaaS 허브에 연결하는 작은 로봇 작업자들
같은 SaaS를 쓰더라도 병원마다 시간표와 의사, 진료, 장비는 다릅니다. 예약은 병원마다 다른 조건을 모두 만족해야 유효합니다.

병원 예약 서비스를 처음 설계하면 화면부터 떠올리기 쉽습니다. 달력에서 날짜를 고르고, 진료 항목을 고르고, 원하는 의사를 선택하고, 저장 버튼을 누릅니다. 데이터베이스에는 appointments 행 하나가 추가됩니다. 여기까지만 보면 전형적인 CRUD입니다.

하지만 병원 직원에게 물어보면 확인할 조건이 곧바로 늘어납니다.

  • 그 병원은 그날 문을 여는가?
  • 점심시간이나 임시휴진과 겹치지 않는가?
  • 선택한 의사가 그 진료를 할 수 있고 실제로 근무하는가?
  • 진료에 필요한 장비가 있으며 점검 중은 아닌가?
  • 같은 시간에 받을 수 있는 환자 수를 넘지 않는가?
  • 이미 확정되거나 진료가 시작된 예약을 자동으로 옮겨도 되는가?

이 조건을 확인하지 않으면 INSERT는 성공해도 실제로 받을 수 없는 예약이 생깁니다. 이번 시리즈는 clinic-appointment를 만들면서 이 간극을 요구사항 → 설계와 계획 → 구현 → 리뷰 → 추가 요구사항의 순서로 좁혀 간 과정을 따라갑니다. Part 1에서는 전체 구조부터 살펴봅니다.

요구사항 문서에는 세 종류의 사용자가 등장합니다. 환자는 가능한 시간을 조회하고 예약합니다. 직원은 예약을 확정하고 체크인과 진료 완료를 처리합니다. 관리자는 휴진과 장비 사용불가를 등록하고 영향을 받은 예약을 재배정합니다.

세 사람이 보는 화면은 다르지만 같은 예약을 다룹니다. 환자에게는 “오후 2시 예약”이고, 직원에게는 REQUESTED에서 CONFIRMED로 바꿔야 할 업무입니다. 관리자에게는 휴진이나 장비 고장이 생겼을 때 그대로 둘지 다른 시간으로 옮길지 판단해야 할 일정입니다. 따라서 예약 모델에는 날짜와 환자 이름 외에도 상태와 운영에 필요한 정보가 들어가야 합니다.

현재 Appointments 테이블은 clinicId, doctorId, treatmentTypeId, 선택적인 equipmentId, 날짜와 시작·종료 시각, 상태를 함께 저장합니다.

object Appointments : LongIdTable("scheduling_appointments") {
// 참조 삭제 정책과 이 글의 논점과 무관한 열은 생략
val clinicId = reference("clinic_id", Clinics)
val doctorId = reference("doctor_id", Doctors)
val treatmentTypeId = reference("treatment_type_id", TreatmentTypes)
val equipmentId = optReference("equipment_id", Equipments)
val appointmentDate = date("appointment_date")
val startTime = time("start_time")
val endTime = time("end_time")
val status = appointmentState("status")
}

외래 키가 많다는 얘기가 아닙니다. 예약 하나가 유효하려면 여러 업무 규칙을 모두 만족해야 한다는 뜻입니다. Appointments.kt의 인덱스도 이 특성을 보여줍니다. 의사·날짜 조합은 예약 중복 검사와 가능한 시간 조회에, 병원·날짜·상태 조합은 활성 예약 조회와 일괄 상태 변경에, 장비·날짜 조합은 장비 사용량 검사에 쓰입니다.

SaaS의 공통 기능과 병원별 업무 규칙을 나눈다

섹션 제목: “SaaS의 공통 기능과 병원별 업무 규칙을 나눈다”

clinic-appointment는 여러 병원이 사용하는 SaaS를 전제로 합니다. 최상위에는 테넌트별 데이터를 나누는 기준인 TenantGroup이 있고, 그 아래에 여러 Clinic이 속합니다. 하지만 “같은 테넌트에 속한다”와 “같은 시간표를 쓴다”는 뜻은 다릅니다.

각 병원은 예약을 몇 분 간격으로 받을지, 어느 지역의 시간을 기준으로 운영할지, 동시에 환자를 몇 명까지 받을지, 공휴일에도 문을 열지를 따로 정합니다. 여기서 예약 간격은 진료 한 건에 걸리는 시간이 아닙니다. 진료 시간은 진료 유형별로 따로 정합니다. 병원별 업무시간과 휴식시간, 임시휴진, 의사의 근무·부재 일정, 진료 유형, 장비와 사용불가 일정도 따로 관리합니다. 그래서 같은 날짜와 진료 요청도 A 병원에서는 가능하고 B 병원에서는 불가능할 수 있습니다.

TenantGroup 아래 서로 다른 달력, 의사, 진료와 장비 정책을 가진 세 병원이 병원별 예약 판단으로 연결되는 어두운 배경의 구조도
README의 아키텍처와 모듈 그림을 출발점으로 삼고, 현재 소스 코드와 다시 대조해 어두운 배경의 블로그용 구조도로 재구성했습니다.

이 그림은 서로 다른 두 경계를 나눠 보여줍니다.

TenantGroup → Clinic은 소유권과 격리의 경계입니다. 다른 테넌트의 병원 데이터를 읽거나 바꾸지 못하게 하는 문제입니다. 반면 Clinic → Appointment는 예약 가능 여부를 판단하는 경계입니다. 해당 병원의 시간표와 자원으로 그 예약이 가능한지 계산합니다. 두 문제는 모두 풀어야 하지만 하나로 섞으면 안 됩니다. 병원별 정책을 정확히 계산해도 테넌트 검증을 빼먹으면 보안 결함이고, 테넌트를 완벽히 격리해도 휴진 시간에 예약을 받으면 업무 결함입니다.

현재 소스에서는 TenantGroups.kt가 병원 데이터가 어느 테넌트에 속하는지 정의합니다. Clinics.kt에는 tenantGroupId와 병원별 운영 기준이 들어 있습니다. 병원마다 다른 업무시간·의사·장비를 예약 가능 시간 계산에 어떻게 반영하는지는 Part 3에서, 테넌트 접근 경계는 Part 7에서 더 자세히 다룹니다.

요구사항을 모듈 경계로 옮기기

섹션 제목: “요구사항을 모듈 경계로 옮기기”

기능이 늘어날 때 모든 코드를 하나의 Spring Boot 모듈에 몰아넣으면 시작은 빠릅니다. 대신 어떤 기능이 다른 기능 없이도 동작해야 하는지 알기 어려워집니다. clinic-appointment는 각 모듈의 책임을 다음처럼 나눴습니다.

모듈책임경계를 나눈 이유
appointment-core도메인 모델, 저장소, 상태 머신, 실시간 예약 가능 시간 계산병원 예약 규칙을 API나 배포 방식과 분리합니다.
appointment-api테넌트 범위 REST API, 인증, 요청 검증외부 계약과 도메인 실행을 연결합니다.
appointment-solverTimefold를 이용한 대량 예약 최적화빠른 단건 조회와 전역 최적화의 실행 비용과 목적이 다릅니다.
appointment-event예약 도메인 이벤트와 이벤트 기록상태 변화의 후속 작업을 핵심 트랜잭션과 느슨하게 연결합니다.
appointment-notification알림과 고가용성 발송API가 없어도 독립 프로세스로 실행할 수 있게 합니다.
프런트엔드직원이 예약과 기준 정보를 관리하는 UINode/Angular 빌드와 Kotlin 빌드의 경계를 유지합니다.

특히 architecture ADR-4SlotCalculationServiceSolverService를 합치지 않기로 결정했습니다. 환자가 예약 가능 시간을 한 건 조회할 때는 빠르게 응답해야 하고, 관리자가 여러 예약을 재배치할 때는 개별 응답 속도보다 전체 조합의 품질이 중요하기 때문입니다. 비슷한 이름의 “스케줄링”이어도 호출 시점과 탐색 범위가 다르면 경계도 달라집니다.

알림도 마찬가지입니다. API는 알림 모듈에 직접 의존하지 않습니다. 예약 변경 이벤트를 발행하면 알림 모듈이 구독합니다. 실제 이메일이나 SMS가 아직 없더라도 예약 생성은 동작해야 합니다. 알림 스케줄러를 별도 프로세스로 배포할 수 있어야 한다는 요구가 이 경계를 만들었습니다.

기능만큼 문서의 역할도 설계했다

섹션 제목: “기능만큼 문서의 역할도 설계했다”

clinic-appointment는 기능뿐 아니라 문서마다 맡을 역할도 정했습니다. 2026년 3월의 Living Documentation 설계는 다음 역할을 나눴습니다.

자료답해야 하는 질문
루트와 모듈 README이 저장소는 무엇을 제공하며 어디서 시작하는가?
docs/requirements사용자가 기대하는 동작과 업무 규칙은 무엇인가?
설계 명세와 구현 계획어떤 선택지를 검토했고 무엇을 어떤 순서로 만들기로 했는가?
소스 코드와 테스트지금 실제로 동작하는 것은 무엇인가?
회고 문서구현과 리뷰에서 무엇을 놓쳤고 다음에는 무엇을 바꿔야 하는가?

이 구분은 개발 과정의 각 시점에서 무엇을 알고 어떤 결정을 했는지 보존합니다. 예를 들어 초기 요구사항 목록에는 멀티테넌시가 향후 과제로 남아 있지만 현재 소스 코드에는 TenantGroups와 테넌트 범위 검증 코드가 있습니다. 어떤 문서에는 도메인 엔티티가 16개라고 적혀 있고 더 최신 목록에는 17개가 나옵니다. 프런트엔드 버전 표기도 문서마다 차이가 있습니다.

이 차이를 최신 숫자 하나로 통일해 버리면 기능이 어떻게 발전했는지 알 수 없습니다. 반대로 오래된 문서를 현재 동작처럼 인용하면 틀린 설명이 됩니다. 그래서 이 시리즈에서는 다음 원칙을 사용합니다.

  1. 요구사항 문서는 당시의 업무 계약으로 읽습니다.
  2. 설계 명세와 구현 계획은 선택과 구현 순서를 설명하는 기록으로 읽습니다.
  3. 현재 동작은 소스 코드와 테스트로 다시 확인합니다.
  4. 차이가 생긴 이유는 리뷰와 회고 문서에서 찾고 다음 요구사항으로 연결합니다.

문서가 코드와 달라졌다는 사실 자체가 실패는 아닙니다. 차이를 발견하고도 방치하는 것이 문제입니다. Living documentation은 언제나 맞는 문서 한 장을 만드는 일이 아닙니다. 어떤 판단에 어떤 자료를 써야 하는지, 언제 소스와 다시 대조해야 하는지를 정하는 방식입니다.

구현하고 나면 다음 요구사항이 보인다

섹션 제목: “구현하고 나면 다음 요구사항이 보인다”

여기까지 살펴보면 처음에 세운 요구사항은 “예약 CRUD를 만든다”에서 다음과 같이 바뀝니다.

한 테넌트에 속한 각 병원의 운영 정책과 자원 제약 안에서, 허용된 상태 변화와 이력을 보존하며 예약을 생성·운영·재배정한다.

요구사항은 길어졌지만 설계할 경계는 오히려 선명해졌습니다. 병원과 자원을 모델링하고, 가능한 시간을 계산하며, 허용되지 않은 상태 변화를 막아야 합니다. 운영 중 제약이 바뀌면 기존 예약도 안전하게 다뤄야 합니다. 여러 예약을 동시에 옮길 때는 개별 예약만 봐서는 안 됩니다. 전체 일정을 최적화해야 하고, 변경 결과는 이벤트와 알림으로 이어집니다.

다음 편에서는 이 가운데 예약 상태를 계약으로 고정하는 방법을 다룹니다. PENDING, CONFIRMED, COMPLETED 같은 열거형 목록에 그치지 않고, 누가 어떤 이벤트로 상태를 바꿀 수 있는지, 변경 사유와 이력을 왜 함께 남겨야 하는지 살펴봅니다.

댓글

GitHub 계정으로 의견을 남기거나 reaction을 남길 수 있습니다.