웹 개발

의료 웹사이트 보안과 개인정보보호 구현 가이드

Jihoon Park|웹 개발자
2025-10-20
14분 소요
#보안#개인정보보호#의료법#HTTPS#데이터 암호화
의료 웹사이트 보안과 개인정보보호 구현 가이드

2024년 한국인터넷진흥원(KISA) 보고서에 따르면, 의료기관 대상 사이버 공격이 전년 대비 41% 증가했습니다. 의료 데이터는 다크웹에서 금융 데이터보다 10배 이상 높은 가격에 거래되며, 한번 유출되면 되돌릴 수 없습니다.

이 글에서는 의료 웹사이트 개발 시 반드시 구현해야 할 보안 조치를 코드 레벨에서 구체적으로 다룹니다.

보안 레이어 다이어그램 / Security Layers Network Security • HTTPS/TLS Encryption • Web Application Firewall (WAF) • DDoS Protection • IP Whitelisting • Rate Limiting Application Security • Authentication (OAuth, JWT) • Role-Based Access Control (RBAC) • Input Validation & Sanitization • CSRF/XSS Protection Data Security • End-to-End Encryption • Automated Backups • Access Logs
Defense-in-depth approach with multiple security layers

1. 의료 웹사이트의 보안 위협 현황

주요 공격 유형

공격 유형비율주요 대상피해 사례
랜섬웨어34%환자 DB, 진료 기록서울 A성형외과 3일 마비
피싱/사회공학28%직원 계정 탈취환자 연락처 2만건 유출
SQL Injection18%예약/상담 폼DB 전체 유출
API 취약점12%모바일 앱 API인증 우회
기타8%DDoS, XSS 등서비스 중단
⚠️ 과태료 현황

개인정보보호법 위반 시 매출액의 3% 이하 과징금 또는 최대 5억원 과태료가 부과될 수 있습니다. 의료기관의 경우 추가로 행정처분(업무정지)까지 가능합니다.


2. 개인정보보호법 핵심 요구사항

의료 웹사이트에서 수집하는 정보는 크게 일반 개인정보민감정보(건강정보)로 나뉩니다.

정보 유형별 보호 수준

정보 유형예시수집 동의암호화보관 기한
일반 개인정보이름, 전화번호, 이메일필수전송 시 암호화목적 달성 후 즉시 파기
민감정보건강상태, 시술이력, 증상별도 동의 필수저장+전송 암호화진료기록: 5년 (의료법)
고유식별정보주민등록번호, 여권번호법적 근거 필요AES-256 이상최소화 원칙

동의서 폼 구현

tsx
'use client';
import { useState } from 'react';

interface ConsentFormProps {
  onConsent: (consents: ConsentData) => void;
}

interface ConsentData {
  personalInfo: boolean;
  sensitiveInfo: boolean;
  marketing: boolean;
  thirdParty: boolean;
  timestamp: string;
  ipAddress: string;
}

export function ConsentForm({ onConsent }: ConsentFormProps) {
  const [consents, setConsents] = useState({
    personalInfo: false,
    sensitiveInfo: false,
    marketing: false,
    thirdParty: false,
  });

  const requiredConsents = consents.personalInfo && consents.sensitiveInfo;

  return (
    <div className="space-y-4 p-6 bg-gray-50 rounded-xl">
      <h3 className="font-semibold">개인정보 수집 및 이용 동의</h3>

      {/* 필수 동의: 개인정보 수집 */}
      <label className="flex items-start gap-3 p-4 bg-white rounded-lg border cursor-pointer">
        <input
          type="checkbox"
          checked={consents.personalInfo}
          onChange={(e) => setConsents(prev => ({ ...prev, personalInfo: e.target.checked }))}
          className="mt-1"
        />
        <div>
          <span className="font-medium">[필수] 개인정보 수집 및 이용 동의</span>
          <p className="text-sm text-gray-500 mt-1">
            수집 항목: 성명, 연락처, 이메일<br />
            수집 목적: 진료 상담 예약 및 안내<br />
            보유 기간: 예약일로부터 1년 (관련 법령에 따라 연장 가능)
          </p>
          <button className="text-xs text-accent underline mt-1">전문 보기</button>
        </div>
      </label>

      {/* 필수 동의: 민감정보 수집 */}
      <label className="flex items-start gap-3 p-4 bg-white rounded-lg border cursor-pointer">
        <input
          type="checkbox"
          checked={consents.sensitiveInfo}
          onChange={(e) => setConsents(prev => ({ ...prev, sensitiveInfo: e.target.checked }))}
          className="mt-1"
        />
        <div>
          <span className="font-medium">[필수] 민감정보(건강정보) 수집 동의</span>
          <p className="text-sm text-gray-500 mt-1">
            수집 항목: 관심 시술, 현재 건강상태, 알레르기 정보<br />
            수집 목적: 맞춤 시술 상담 제공<br />
            보유 기간: 진료기록 보관 의무에 따름 (의료법 제22조)
          </p>
        </div>
      </label>

      {/* 선택 동의: 마케팅 */}
      <label className="flex items-start gap-3 p-4 bg-white rounded-lg border cursor-pointer">
        <input
          type="checkbox"
          checked={consents.marketing}
          onChange={(e) => setConsents(prev => ({ ...prev, marketing: e.target.checked }))}
          className="mt-1"
        />
        <div>
          <span className="font-medium">[선택] 마케팅 정보 수신 동의</span>
          <p className="text-sm text-gray-500 mt-1">
            이벤트, 할인 정보, 시술 안내 등의 마케팅 정보를 받으실 수 있습니다.
          </p>
        </div>
      </label>

      <button
        onClick={() => onConsent({
          ...consents,
          timestamp: new Date().toISOString(),
          ipAddress: '', // 서버에서 수집
        })}
        disabled={!requiredConsents}
        className="w-full py-3 bg-accent text-white rounded-lg font-medium disabled:bg-gray-300 disabled:cursor-not-allowed"
      >
        동의하고 상담 신청하기
      </button>
    </div>
  );
}

3. HTTPS와 보안 헤더 구성

Next.js 보안 헤더 설정

tsx
// next.config.ts
const securityHeaders = [
  {
    key: 'Strict-Transport-Security',
    value: 'max-age=63072000; includeSubDomains; preload',
  },
  {
    key: 'Content-Security-Policy',
    value: [
      "default-src 'self'",
      "script-src 'self' 'unsafe-eval' 'unsafe-inline' https://t1.kakaocdn.net",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: blob: https:",
      "font-src 'self' https://fonts.gstatic.com",
      "connect-src 'self' https://kapi.kakao.com",
      "frame-ancestors 'none'",
    ].join('; '),
  },
  {
    key: 'X-Content-Type-Options',
    value: 'nosniff',
  },
  {
    key: 'X-Frame-Options',
    value: 'DENY',
  },
  {
    key: 'Referrer-Policy',
    value: 'strict-origin-when-cross-origin',
  },
  {
    key: 'Permissions-Policy',
    value: 'camera=(), microphone=(), geolocation=(self)',
  },
];

const nextConfig = {
  async headers() {
    return [
      {
        source: '/(.*)',
        headers: securityHeaders,
      },
    ];
  },
};

export default nextConfig;
💡 CSP 테스트 방법

CSP 헤더를 적용하기 전에 Content-Security-Policy-Report-Only 헤더로 먼저 테스트하세요. 리포트 모드에서는 차단 없이 위반 로그만 수집되므로 서비스 장애 없이 정책을 검증할 수 있습니다.


4. 데이터 암호화 전략

전송 중 암호화 vs 저장 중 암호화

구분전송 중 (In Transit)저장 중 (At Rest)
방법HTTPS/TLS 1.3AES-256-GCM
대상모든 HTTP 통신DB 민감 필드
구현인증서 + 서버 설정애플리케이션 레벨
키 관리인증 기관(CA)환경 변수 + KMS

필드 레벨 암호화 구현

tsx
// lib/crypto.ts
import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';

export class FieldEncryption {
  private key: Buffer;

  constructor() {
    const keyHex = process.env.ENCRYPTION_KEY;
    if (!keyHex || keyHex.length !== 64) {
      throw new Error('ENCRYPTION_KEY must be 32 bytes (64 hex chars)');
    }
    this.key = Buffer.from(keyHex, 'hex');
  }

  encrypt(plaintext: string): string {
    const iv = crypto.randomBytes(12); // GCM 권장 12바이트
    const cipher = crypto.createCipheriv(ALGORITHM, this.key, iv);
    const encrypted = Buffer.concat([
      cipher.update(plaintext, 'utf8'),
      cipher.final(),
    ]);
    const authTag = cipher.getAuthTag();
    // iv:authTag:ciphertext (모두 base64)
    return [
      iv.toString('base64'),
      authTag.toString('base64'),
      encrypted.toString('base64'),
    ].join('.');
  }

  decrypt(ciphertext: string): string {
    const [ivB64, tagB64, dataB64] = ciphertext.split('.');
    const iv = Buffer.from(ivB64, 'base64');
    const authTag = Buffer.from(tagB64, 'base64');
    const data = Buffer.from(dataB64, 'base64');

    const decipher = crypto.createDecipheriv(ALGORITHM, this.key, iv);
    decipher.setAuthTag(authTag);
    return Buffer.concat([
      decipher.update(data),
      decipher.final(),
    ]).toString('utf8');
  }
}

// 사용 예시
const enc = new FieldEncryption();
const encrypted = enc.encrypt('010-1234-5678');
const decrypted = enc.decrypt(encrypted); // '010-1234-5678'

5. 인증/인가 시스템

Next.js 미들웨어 인증

tsx
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyToken } from '@/lib/auth';

const PROTECTED_PATHS = ['/admin', '/api/admin'];
const PUBLIC_PATHS = ['/login', '/api/auth'];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 공개 경로는 통과
  if (PUBLIC_PATHS.some(p => pathname.startsWith(p))) {
    return NextResponse.next();
  }

  // 보호 경로 접근 시 토큰 검증
  if (PROTECTED_PATHS.some(p => pathname.startsWith(p))) {
    const token = request.cookies.get('auth-token')?.value;

    if (!token) {
      return NextResponse.redirect(new URL('/login', request.url));
    }

    try {
      const payload = await verifyToken(token);

      // RBAC: 관리자 경로는 admin/doctor 역할만
      if (pathname.startsWith('/admin') && !['admin', 'doctor'].includes(payload.role)) {
        return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
      }

      // 요청 헤더에 사용자 정보 추가
      const headers = new Headers(request.headers);
      headers.set('x-user-id', payload.userId);
      headers.set('x-user-role', payload.role);

      return NextResponse.next({ headers });
    } catch {
      return NextResponse.redirect(new URL('/login', request.url));
    }
  }

  return NextResponse.next();
}

RBAC (역할 기반 접근 제어)

역할환자 정보 조회예약 관리콘텐츠 편집시스템 설정
원장 (admin)전체전체전체전체
의료진 (doctor)담당 환자만본인 일정만시술 정보만X
스태프 (staff)연락처만전체공지사항X
환자 (patient)본인만본인만XX

6. OWASP Top 10 대응

SQL Injection 방어

tsx
// Prisma ORM 사용 시 자동 방어 (파라미터 바인딩)
const patient = await prisma.patient.findFirst({
  where: { phone: userInput }, // 안전: Prisma가 자동 이스케이프
});

// 주의: Raw Query 사용 시 반드시 파라미터 바인딩
const result = await prisma.$queryRaw`
  SELECT * FROM patients WHERE phone = ${userInput}
`;
// 절대 하지 말 것: prisma.$queryRawUnsafe(`SELECT * FROM patients WHERE phone = '${userInput}'`)

XSS 방어

tsx
// React는 기본적으로 JSX 내 값을 이스케이프
// 하지만 dangerouslySetInnerHTML 사용 시 주의
import DOMPurify from 'dompurify';

function SafeHTML({ content }: { content: string }) {
  const sanitized = DOMPurify.sanitize(content, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br'],
    ALLOWED_ATTR: [],
  });

  return <div dangerouslySetInnerHTML={{ __html: sanitized }} />;
}

CSRF 방어

tsx
// Next.js Server Actions는 CSRF 토큰 자동 처리
// API Routes 사용 시 수동 구현 필요

import crypto from 'crypto';

export function generateCSRFToken(): string {
  return crypto.randomBytes(32).toString('hex');
}

export function validateCSRFToken(token: string, sessionToken: string): boolean {
  return crypto.timingSafeEqual(
    Buffer.from(token),
    Buffer.from(sessionToken)
  );
}

7. 접근 로그와 감사 추적

개인정보보호법은 개인정보 접근에 대한 로그 기록 유지 의무를 부과합니다.

tsx
// lib/audit-log.ts
interface AuditEntry {
  userId: string;
  action: 'VIEW' | 'CREATE' | 'UPDATE' | 'DELETE' | 'EXPORT';
  resource: string;
  resourceId: string;
  details?: string;
  ipAddress: string;
  timestamp: Date;
}

export async function logAccess(entry: AuditEntry) {
  await prisma.auditLog.create({
    data: {
      ...entry,
      timestamp: new Date(),
    },
  });
}

// 사용 예시: 환자 정보 조회 시
const patient = await prisma.patient.findUnique({ where: { id: patientId } });
await logAccess({
  userId: currentUser.id,
  action: 'VIEW',
  resource: 'patient',
  resourceId: patientId,
  ipAddress: request.headers.get('x-forwarded-for') || 'unknown',
  timestamp: new Date(),
});

8. 보안 감사 체크리스트

✅ 의료 웹사이트 보안 체크리스트
  • [ ] HTTPS 적용 (TLS 1.3, HSTS 활성화)
  • [ ] 보안 헤더 설정 (CSP, X-Frame-Options, X-Content-Type-Options)
  • [ ] 민감 데이터 필드 레벨 암호화 (AES-256-GCM)
  • [ ] 비밀번호 안전한 해싱 (bcrypt 또는 Argon2)
  • [ ] JWT + Refresh Token 인증 구현
  • [ ] RBAC 역할 기반 접근 제어
  • [ ] 개인정보 수집 동의서 (필수/선택 분리)
  • [ ] 동의 이력 DB 저장 (타임스탬프, IP)
  • [ ] SQL Injection 방어 (ORM 파라미터 바인딩)
  • [ ] XSS 방어 (DOMPurify, CSP)
  • [ ] CSRF 방어 (Server Actions 또는 토큰)
  • [ ] Rate Limiting (API 엔드포인트)
  • [ ] 접근 로그/감사 추적 기록
  • [ ] 개인정보 보관 기한 관리 (자동 파기)
  • [ ] 정기 보안 점검 (분기별 취약점 스캔)
  • [ ] 백업 정책 (일간 자동 백업, 암호화 저장)

  • 마무리

    의료 웹사이트 보안은 기술적 조치만으로 완성되지 않습니다. 개인정보보호법 준수, 조직 내 보안 교육, 정기적 보안 점검 3가지가 함께 이루어져야 합니다.

    POEMA는 의료기관의 보안 요구사항을 이해하고, 개인정보보호법과 의료법을 준수하는 안전한 웹사이트를 설계합니다. 보안 감사나 의료 웹사이트 구축에 대한 상담이 필요하시면 연락해 주세요.

    관련 글