웹 개발

병원 온라인 예약 시스템 구축 가이드: React + Next.js 실전

Jihoon Park|웹 개발자
2025-09-05
18분 소요
#예약 시스템#React#Next.js#병원 웹#API 설계
병원 온라인 예약 시스템 구축 가이드: React + Next.js 실전

병원 온라인 예약 시스템은 단순한 달력 UI가 아닙니다. 의사별 진료 시간대, 시술 유형에 따른 소요시간 차이, 환자 개인정보 보호, 노쇼 방지 등 의료 도메인 특유의 복잡한 요구사항을 충족해야 합니다.

이 글에서는 Next.js + Prisma + PostgreSQL 스택으로 실제 운영 가능한 병원 예약 시스템을 설계하고 구현하는 과정을 단계별로 안내합니다.

예약 시스템 아키텍처 / Booking System Architecture Client React / Next.js User Interface HTTP API Routes Next.js Backend Business Logic ORM Prisma ORM Type-safe Query Builder SQL PostgreSQL Database Data Store Admin Dashboard Management UI Kakao Biz Notifications Messaging API Redis Caching Session Store
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 설계만으로도 상당 부분 방지할 수 있습니다.

효과적인 노쇼 방지 전략

  1. 단계적 리마인더: 3일 전 + 1일 전 + 당일 3단계 알림
  2. 간편 취소/변경: 링크 클릭 한 번으로 취소 또는 일정 변경
  3. 대기 리스트: 취소 발생 시 대기자에게 자동 알림
  4. 예약금 시스템: 고가 시술의 경우 예약금 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는 의료 도메인의 특수성을 이해하고, 개인정보보호법과 의료법을 준수하는 예약 시스템을 설계합니다. 병원 디지털 전환을 고민하고 계시다면 언제든 문의해 주세요.

    관련 글