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 Injection 18% 예약/상담 폼 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.3 AES-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) 본인만 본인만 X X
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는 의료기관의 보안 요구사항을 이해하고, 개인정보보호법과 의료법을 준수하는 안전한 웹사이트를 설계합니다. 보안 감사나 의료 웹사이트 구축에 대한 상담이 필요하시면 연락해 주세요.