의료 웹사이트 보안과 개인정보보호 구현 가이드
의료 웹사이트는 민감한 건강정보를 다루므로 일반 웹사이트보다 높은 보안 수준이 요구됩니다. 개인정보보호법부터 OWASP Top 10 대응까지 실전 보안 구현 가이드.

읽기 전에 핵심만
- 수집부터 삭제까지 개인정보 흐름을 그린 뒤 각 단계의 접근 권한과 보호 조치를 점검합니다.
- HTTPS만으로 끝내지 말고 인증, 세션, 저장 데이터, API 권한과 보안 로그를 함께 검토합니다.
- 보안 설정은 문서에만 두지 말고 배포 전 자동 검사와 정기적인 운영 점검으로 확인합니다.
2024년 한국인터넷진흥원(KISA) 보고서에 따르면, 의료기관 대상 사이버 공격이 전년 대비 41% 증가했습니다. 의료 데이터는 다크웹에서 금융 데이터보다 10배 이상 높은 가격에 거래되며, 한번 유출되면 되돌릴 수 없습니다.
이 글에서는 의료 웹사이트 개발 시 반드시 구현해야 할 보안 조치를 코드 레벨에서 구체적으로 다룹니다.
1. 의료 웹사이트의 보안 위협 현황
주요 공격 유형
| 공격 유형 | 비율 | 주요 대상 | 피해 사례 |
|---|---|---|---|
| 랜섬웨어 | 34% | 환자 DB, 진료 기록 | 서울 A성형외과 3일 마비 |
| 피싱/사회공학 | 28% | 직원 계정 탈취 | 환자 연락처 2만건 유출 |
| SQL Injection | 18% | 예약/상담 폼 | DB 전체 유출 |
| API 취약점 | 12% | 모바일 앱 API | 인증 우회 |
| 기타 | 8% | DDoS, XSS 등 | 서비스 중단 |
2. 개인정보보호법 핵심 요구사항
의료 웹사이트에서 수집하는 정보는 크게 일반 개인정보와 민감정보(건강정보)로 나뉩니다.
정보 유형별 보호 수준
| 정보 유형 | 예시 | 수집 동의 | 암호화 | 보관 기한 |
|---|---|---|---|---|
| 일반 개인정보 | 이름, 전화번호, 이메일 | 필수 | 전송 시 암호화 | 목적 달성 후 즉시 파기 |
| 민감정보 | 건강상태, 시술이력, 증상 | 별도 동의 필수 | 저장+전송 암호화 | 진료기록: 5년 (의료법) |
| 고유식별정보 | 주민등록번호, 여권번호 | 법적 근거 필요 | AES-256 이상 | 최소화 원칙 |
동의서 폼 구현
'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 보안 헤더 설정
// 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;4. 데이터 암호화 전략
전송 중 암호화 vs 저장 중 암호화
| 구분 | 전송 중 (In Transit) | 저장 중 (At Rest) |
|---|---|---|
| 방법 | HTTPS/TLS 1.3 | AES-256-GCM |
| 대상 | 모든 HTTP 통신 | DB 민감 필드 |
| 구현 | 인증서 + 서버 설정 | 애플리케이션 레벨 |
| 키 관리 | 인증 기관(CA) | 환경 변수 + KMS |
필드 레벨 암호화 구현
// 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 미들웨어 인증
// 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 방어
// 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 방어
// 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 방어
// 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. 접근 로그와 감사 추적
개인정보보호법은 개인정보 접근에 대한 로그 기록 유지 의무를 부과합니다.
// 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. 보안 감사 체크리스트
마무리
의료 웹사이트 보안은 기술적 조치만으로 완성되지 않습니다. 개인정보보호법 준수, 조직 내 보안 교육, 정기적 보안 점검 3가지가 함께 이루어져야 합니다.
POEMA는 의료기관의 보안 요구사항을 이해하고, 개인정보보호법과 의료법을 준수하는 안전한 웹사이트를 설계합니다. 보안 감사나 의료 웹사이트 구축에 대한 상담이 필요하시면 연락해 주세요.
개인정보 흐름과 웹 보안 항목을 함께 점검하려면 POEMORA 문의하기를 이용해 주세요.
자주 묻는 질문
의료 웹사이트 보안 점검은 어디서 시작해야 하나요?
먼저 어떤 개인정보가 어느 화면과 API에서 수집되고 저장, 조회, 전송, 삭제되는지 데이터 흐름을 작성하세요. 그다음 각 단계의 접근 권한, 암호화, 로그와 삭제 절차를 실제 구현과 대조합니다.
HTTPS를 적용하면 개인정보 보호가 충분한가요?
HTTPS는 전송 구간 보호의 한 요소일 뿐입니다. 서버의 접근 통제, 안전한 인증과 세션 관리, 저장 데이터 보호, 보안 업데이트와 로그 점검도 별도로 설계하고 검증해야 합니다.
보안 로그에 개인정보를 남겨도 되나요?
문제 분석에 꼭 필요한 항목만 기록하고, 비밀번호나 인증 비밀값과 불필요한 민감정보는 로그에서 제외하세요. 로그 접근 권한, 보관 기간과 삭제 절차도 운영 정책에 포함해야 합니다.


