병원 온라인 예약 시스템은 단순한 달력 UI가 아닙니다. 의사별 진료 시간대, 시술 유형에 따른 소요시간 차이, 환자 개인정보 보호, 노쇼 방지 등 의료 도메인 특유의 복잡한 요구사항을 충족해야 합니다.
이 글에서는 Next.js + Prisma + PostgreSQL 스택으로 실제 운영 가능한 병원 예약 시스템을 설계하고 구현하는 과정을 단계별로 안내합니다.
Full-stack booking system with external integrations
1. 의료 예약 시스템 요구사항 분석
일반 예약 서비스(식당, 미용실 등)와 의료 예약은 다음과 같은 핵심 차이가 있습니다.
| 요구사항 | 일반 예약 | 의료 예약 |
| 시간 단위 | 30분~1시간 | 시술별 가변 (15분~4시간) |
| 담당자 지정 | 선택 사항 | 필수 (의사 지정) |
| 개인정보 | 이름, 전화번호 | 이름, 생년월일, 연락처, 증상 |
| 보안 등급 | 일반 | 민감정보 (건강정보) |
| 동시 예약 방지 | 단순 충돌 체크 | 시술 시간 겹침 + 준비 시간 포함 |
| 알림 | 예약 확인 | 예약 확인 + 전일 리마인더 + 내원 안내 |
| 취소 정책 | 간단 | 복잡 (시술 종류별 차등) |
2. 데이터베이스 스키마 설계
Prisma ORM을 사용한 핵심 모델입니다.
prisma
model Doctor {
id String @id @default(cuid())
name String
specialty String // 전문 분야 (예: "코성형", "눈성형")
bio String?
imageUrl String?
slots TimeSlot[]
bookings Booking[]
createdAt DateTime @default(now())
}
model TimeSlot {
id String @id @default(cuid())
doctorId String
doctor Doctor @relation(fields: [doctorId], references: [id])
dayOfWeek Int // 0=일, 1=월, ..., 6=토
startTime String // "09:00"
endTime String // "18:00"
isActive Boolean @default(true)
}
model Booking {
id String @id @default(cuid())
doctorId String
doctor Doctor @relation(fields: [doctorId], references: [id])
patientName String
patientPhone String // 암호화 저장
patientEmail String?
procedureType String // 시술 종류
duration Int // 소요 시간 (분)
dateTime DateTime // 예약 일시
status BookingStatus @default(PENDING)
notes String? // 환자 메모 (증상 등)
reminderSent Boolean @default(false)
consentSigned Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([doctorId, dateTime])
@@index([status])
}
enum BookingStatus {
PENDING // 예약 요청
CONFIRMED // 확인됨
CANCELLED // 취소됨
COMPLETED // 진료 완료
NO_SHOW // 노쇼
}
💡 인덱스 설계
[doctorId, dateTime] 복합 인덱스가 핵심입니다. 특정 의사의 특정 날짜 예약 조회가 가장 빈번한 쿼리이므로, 이 인덱스만으로 대부분의 조회 성능 문제를 해결할 수 있습니다.
3. 가용 시간 조회 API
예약 가능한 시간대를 실시간으로 조회하는 API입니다. 동시 예약 방지를 위해 비관적 잠금(Pessimistic Locking) 패턴을 적용합니다.
tsx
// app/api/bookings/availability/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const doctorId = searchParams.get('doctorId');
const date = searchParams.get('date'); // "2025-09-15"
const procedureDuration = parseInt(searchParams.get('duration') || '60');
if (!doctorId || !date) {
return NextResponse.json({ error: 'Missing parameters' }, { status: 400 });
}
const targetDate = new Date(date);
const dayOfWeek = targetDate.getDay();
// 1. 의사의 해당 요일 근무 시간 조회
const timeSlot = await prisma.timeSlot.findFirst({
where: { doctorId, dayOfWeek, isActive: true },
});
if (!timeSlot) {
return NextResponse.json({ slots: [], message: '해당일 휴진' });
}
// 2. 해당 날짜의 기존 예약 조회
const startOfDay = new Date(date + 'T00:00:00');
const endOfDay = new Date(date + 'T23:59:59');
const existingBookings = await prisma.booking.findMany({
where: {
doctorId,
dateTime: { gte: startOfDay, lte: endOfDay },
status: { in: ['PENDING', 'CONFIRMED'] },
},
orderBy: { dateTime: 'asc' },
});
// 3. 가용 슬롯 계산 (30분 단위)
const available = generateAvailableSlots(
timeSlot.startTime,
timeSlot.endTime,
existingBookings,
procedureDuration
);
return NextResponse.json({ slots: available });
}
function generateAvailableSlots(
start: string, end: string,
bookings: Array<{ dateTime: Date; duration: number }>,
duration: number
): string[] {
const slots: string[] = [];
let [startH, startM] = start.split(':').map(Number);
const [endH, endM] = end.split(':').map(Number);
const endMinutes = endH * 60 + endM;
let current = startH * 60 + startM;
while (current + duration <= endMinutes) {
const slotStart = current;
const slotEnd = current + duration;
// 기존 예약과 충돌 체크 (준비시간 15분 포함)
const hasConflict = bookings.some((booking) => {
const bookingStart = booking.dateTime.getHours() * 60 + booking.dateTime.getMinutes();
const bookingEnd = bookingStart + booking.duration + 15; // 준비시간
return slotStart < bookingEnd && slotEnd > bookingStart;
});
if (!hasConflict) {
const h = Math.floor(current / 60).toString().padStart(2, '0');
const m = (current % 60).toString().padStart(2, '0');
slots.push(`${h}:${m}`);
}
current += 30; // 30분 단위
}
return slots;
}
4. 캘린더 UI 구현
환자가 직관적으로 날짜와 시간을 선택할 수 있는 캘린더 컴포넌트입니다.
tsx
'use client';
import { useState, useEffect } from 'react';
import { ChevronLeft, ChevronRight, Clock } from 'lucide-react';
interface BookingCalendarProps {
doctorId: string;
procedureDuration: number;
onSelect: (dateTime: string) => void;
}
export function BookingCalendar({ doctorId, procedureDuration, onSelect }: BookingCalendarProps) {
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [availableSlots, setAvailableSlots] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!selectedDate) return;
setLoading(true);
const dateStr = selectedDate.toISOString().split('T')[0];
fetch(`/api/bookings/availability?doctorId=${doctorId}&date=${dateStr}&duration=${procedureDuration}`)
.then(res => res.json())
.then(data => setAvailableSlots(data.slots || []))
.finally(() => setLoading(false));
}, [selectedDate, doctorId, procedureDuration]);
return (
<div className="bg-white rounded-2xl shadow-sm border p-6">
<h3 className="text-lg font-semibold mb-4">날짜 선택</h3>
{/* 달력 그리드 (간략화) */}
<div className="grid grid-cols-7 gap-1 mb-6">
{/* ... 달력 날짜 렌더링 ... */}
</div>
{/* 시간 슬롯 */}
{selectedDate && (
<div>
<h4 className="text-sm font-medium text-gray-500 mb-3 flex items-center gap-1">
<Clock className="w-4 h-4" />
예약 가능 시간
</h4>
{loading ? (
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-10 bg-gray-100 animate-pulse rounded-lg" />
))}
</div>
) : availableSlots.length > 0 ? (
<div className="grid grid-cols-4 gap-2">
{availableSlots.map((slot) => (
<button
key={slot}
onClick={() => onSelect(`${selectedDate.toISOString().split('T')[0]}T${slot}`)}
className="py-2 px-3 text-sm border rounded-lg hover:bg-accent hover:text-white hover:border-accent transition-colors"
>
{slot}
</button>
))}
</div>
) : (
<p className="text-sm text-gray-400 text-center py-4">
선택한 날짜에 예약 가능한 시간이 없습니다.
</p>
)}
</div>
)}
</div>
);
}
5. 알림 시스템 연동
카카오 알림톡 연동
한국에서 환자 알림은 카카오 알림톡이 가장 효과적입니다. 수신율 98%, 열람률 80% 이상으로 SMS보다 뛰어난 성과를 보입니다.
tsx
// lib/notifications/kakao.ts
interface AlimtalkParams {
templateCode: string;
recipientNo: string;
variables: Record<string, string>;
}
export async function sendAlimtalk({ templateCode, recipientNo, variables }: AlimtalkParams) {
const response = await fetch('https://alimtalk-api.kakao.com/v2/sender/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.KAKAO_BIZ_TOKEN}`,
},
body: JSON.stringify({
senderKey: process.env.KAKAO_SENDER_KEY,
templateCode,
recipientList: [{
recipientNo: recipientNo.replace(/-/g, ''),
templateParameter: variables,
}],
}),
});
return response.json();
}
// 예약 확인 알림 발송
export async function sendBookingConfirmation(booking: Booking) {
return sendAlimtalk({
templateCode: 'BOOKING_CONFIRM',
recipientNo: booking.patientPhone,
variables: {
patientName: booking.patientName,
doctorName: booking.doctor.name,
dateTime: formatDateTime(booking.dateTime),
procedure: booking.procedureType,
clinicAddress: CLINIC_ADDRESS,
},
});
}
알림 발송 시점
| 시점 | 알림 유형 | 채널 |
| 예약 즉시 | 예약 확인 | 알림톡 + 이메일 |
| 3일 전 | 내원 리마인더 | 알림톡 |
| 1일 전 | 최종 리마인더 + 준비사항 | 알림톡 |
| 당일 | 내원 안내 (주차, 위치) | 알림톡 |
| 시술 후 3일 | 사후 관리 안내 | 알림톡 |
💡 노쇼율 감소 효과
알림톡 리마인더 시스템 도입 후, 평균 노쇼율이 15%에서 4%로 73% 감소한 사례가 있습니다. 전일 리마인더에 "취소 시 여기를 눌러주세요" 링크를 포함하면 대기 환자에게 시간을 재배정할 수 있어 효율이 극대화됩니다.
6. 노쇼 방지 UX 패턴
노쇼는 병원 운영의 가장 큰 비효율 중 하나입니다. UX 설계만으로도 상당 부분 방지할 수 있습니다.
효과적인 노쇼 방지 전략
- 단계적 리마인더: 3일 전 + 1일 전 + 당일 3단계 알림
- 간편 취소/변경: 링크 클릭 한 번으로 취소 또는 일정 변경
- 대기 리스트: 취소 발생 시 대기자에게 자동 알림
- 예약금 시스템: 고가 시술의 경우 예약금 3~5만원 결제
tsx
// 리마인더 크론잡 (매일 오전 9시 실행)
// app/api/cron/reminders/route.ts
export async function GET() {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const bookings = await prisma.booking.findMany({
where: {
dateTime: {
gte: new Date(tomorrow.toDateString()),
lt: new Date(new Date(tomorrow).setDate(tomorrow.getDate() + 1)),
},
status: 'CONFIRMED',
reminderSent: false,
},
include: { doctor: true },
});
for (const booking of bookings) {
await sendAlimtalk({
templateCode: 'BOOKING_REMINDER',
recipientNo: booking.patientPhone,
variables: {
patientName: booking.patientName,
dateTime: formatDateTime(booking.dateTime),
doctorName: booking.doctor.name,
cancelLink: `${BASE_URL}/bookings/${booking.id}/cancel`,
},
});
await prisma.booking.update({
where: { id: booking.id },
data: { reminderSent: true },
});
}
return NextResponse.json({ sent: bookings.length });
}
7. 관리자 대시보드
병원 스태프가 예약을 효율적으로 관리할 수 있는 대시보드가 필수입니다.
대시보드 핵심 기능
| 기능 | 설명 | 우선순위 |
| 일간 예약 현황 | 타임라인 형태 의사별 예약 표시 | 필수 |
| 예약 상태 관리 | 확인/취소/노쇼/완료 상태 변경 | 필수 |
| 환자 검색 | 이름/전화번호 기반 빠른 검색 | 필수 |
| 통계 대시보드 | 월간 예약수, 노쇼율, 인기 시술 차트 | 권장 |
| 대기 리스트 | 취소 발생 시 자동 매칭 | 권장 |
8. 보안과 개인정보보호
의료 예약 시스템은 민감정보(건강정보)를 다루므로 일반 웹서비스보다 높은 보안 수준이 요구됩니다.
필수 보안 조치
tsx
// lib/encryption.ts
import crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex'); // 32바이트
export function encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag().toString('hex');
return `${iv.toString('hex')}:${authTag}:${encrypted}`;
}
export function decrypt(data: string): string {
const [ivHex, authTagHex, encrypted] = data.split(':');
const decipher = crypto.createDecipheriv(ALGORITHM, KEY, Buffer.from(ivHex, 'hex'));
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
⚠️ 개인정보보호법 필수 조치
환자 전화번호, 생년월일은 반드시 암호화 저장개인정보 수집 전 명시적 동의 획득 (체크박스)수집 목적 달성 후 지체없이 파기 (예: 예약 후 1년)개인정보 접근 로그 기록 유지 (최소 3년)
마무리
병원 온라인 예약 시스템은 기술적 복잡도와 규제 요구사항이 높은 프로젝트입니다. 하지만 올바르게 구축하면 환자 편의성 향상, 노쇼율 감소, 운영 효율화라는 삼중 효과를 얻을 수 있습니다.
POEMA는 의료 도메인의 특수성을 이해하고, 개인정보보호법과 의료법을 준수하는 예약 시스템을 설계합니다. 병원 디지털 전환을 고민하고 계시다면 언제든 문의해 주세요.