치과 홈페이지 섹션 CMS · 병원별 독립 배포
관리자가 섹션을 골라 치과 홈페이지를 조립하는 CMS를 만들고, 병원마다 서버를 따로 두어 배포·운영한 외주 작업
- 관리자가 19개 섹션 타입, 50개 폼을 골라 조립하면 공개 홈페이지가 되는 CMS를 Next.js·NestJS·Prisma로 혼자 만들었습니다.
- 같은 코드를 병원마다 서버·DB·업로드 폴더를 따로 둔 4컨테이너로 배포하고, 배포 전 자동 백업과 복구·점검 스크립트를 붙였습니다.
- 운영 서버가 프레임워크 취약점으로 침해된 뒤 패치와 완화책을 적용하고, 전수 보안 점검에서 나온 결함을 고쳤습니다.
문제
여러 치과가 같은 홈페이지 코드를 쓰되, 데이터와 디자인, 관리자 계정은 병원마다 분리되어야 했다. 병원은 배너, 의료진, 진료안내, 논문, 게시판 같은 영역의 문구와 색, 크기를 직접 고치길 원했다. 수정 요청은 PC·모바일 화면 단위의 PDF로 계속 들어왔다.
운영 중에도 문제가 드러났다. 운영 서버 두 대가 프레임워크의 원격 코드 실행 취약점으로 채굴기에 감염됐다. 배포 직후 몇 초 동안 500·502가 났다. 상담 신청 암호화 키가 빠진 채 배포하면 사이트는 멀쩡해 보이는데 상담 접수만 실패했다.
해결
섹션 CMS. 페이지는 섹션 인스턴스의 목록이다. 19개 섹션 타입과 50개 폼을 카탈로그 한 곳에 정의하고, 관리자 페이지 빌더에서 섹션을 추가하고 순서와 내용을 편집한다. 헤더 메뉴는 메인 페이지 섹션의 라벨로 자동으로 만든다. 본문과 섹션 제목 편집기는 SmartEditor 2.0으로 바꾸고, 저장할 때 서버에서 HTML을 살균한다.
관리자. 페이지·섹션, 게시판·게시글, FAQ, 상담, 의료진, 장비, 진료과목, 전후 사진, 미디어, 회원, 사이트 설정, 리다이렉트, SEO, 통계 화면을 만들었다. 역할은 회원, 병원용, 내부용 세 단계다. 병원용 계정이 URL을 직접 입력해 내부 전용 화면에 들어가던 구멍은 웹 레이아웃 가드와 API 가드로 이중으로 막았다.
API와 데이터. NestJS API와 Prisma 모델 22개, 마이그레이션 54개로 구성했다. 업로드 이미지는 EXIF를 지우고 WebP와 썸네일을 만든다. 상담 신청의 이름·연락처·내용은 AES-256-GCM으로 암호화해 저장한다. 방문 통계는 자체 수집한다.
SEO·AEO. 페이지 메타데이터, JSON-LD, sitemap과 robots, llms.txt, IndexNow, AI 크롤러별 허용·차단을 관리자가 제어한다. 페이지별 SEO 진단, 구조화데이터 검증, 대표 Q&A(FAQPage), 리다이렉트와 404 관리 화면을 두었다.
병원별 독립 배포. 같은 코드를 병원마다 서버 한 대에 올린다. Caddy, web, api, PostgreSQL 네 컨테이너이고 Caddy만 외부에 연다. DB 볼륨, 업로드 폴더, 관리자 계정, 환경변수는 서버마다 따로 둔다. 배포 스크립트는 배포 직전에 DB 덤프와 업로드를 백업한다. Dockerfile에 seed가 들어 있거나 암호화 키가 비어 있으면 배포를 멈춘다. 복구 스크립트는 복구 전에 현재 상태를 한 번 더 백업한다. UI만 바꿀 때는 DB·API 컨테이너와 볼륨 마운트를 확인한 뒤 web만 교체하고 롤백 태그를 남긴다. api와 web에 healthcheck를 두고 준비가 끝난 뒤에 프록시가 뜨게 해 배포 직후 오류를 없앴다.
침해 대응 (2026-07-22). 운영 서버 두 대의 web 컨테이너에서 채굴기(XMRig)가 실행되는 것을 확인했다. Next.js의 원격 코드 실행 취약점(React2Shell, CVE-2025-55182·CVE-2025-66478)을 통해 /tmp에 파일을 내려받아 실행하는 패턴이었다. 같은 날 세 단계로 막았다.
- 완화 — web·api 컨테이너의
/tmp를 실행 불가 tmpfs(noexec)로 바꿔, 내려받은 파일이 실행되지 못하게 했다. - 근본 원인 제거 — Next.js 15.0.3 → 15.0.5, React 19 RC → 19.0.1 정식으로 올려 취약점을 패치했다.
- 추가 방어 — 서버의 fail2ban은 SSH만 막고 HTTP 로그인은 무방비였다. 로그인·가입·초기 설정 API에 IP당 분당 10회 제한을 걸고, 프록시 뒤에서도 실제 IP를 보도록 설정했다. 서버는 커널 업데이트 후 자동으로 재부팅되게 했다.
이후 서버 상태(CPU를 많이 쓰는 프로세스, 컨테이너, 열린 포트, 방화벽, fail2ban, SSH 로그)를 읽기 전용으로 점검하는 스크립트를 만들었다.
보안 강화. 계정 잠금, 감사 로그, CSP, 세션 만료 단축을 넣었다. 전수 점검에서는 비밀글 본문이 공개 목록 API로 새던 문제, 운영 환경의 API 문서 노출, 업로드 확장자로 인한 저장형 XSS, 응답 시간으로 가입 여부가 드러나던 문제 등 7건을 고쳤다. 서버 상태는 읽기 전용 점검 스크립트로 확인한다.
테스트. 권한 매트릭스 E2E, 카탈로그 동기화, 의존성 호환 Jest 테스트와 화면 회귀 테스트를 두었다. E2E는 폐기용 테스트 DB를 명시해야만 실행된다.
| 정한 것 | 대신 버린 것 | 이유 |
|---|---|---|
| 서버 구성병원마다 Lightsail 인스턴스 1대에 4컨테이너 | EC2와 RDS 분리 | 병원 사이트 트래픽 규모에서 분리는 과하다고 판단했다. 병원별 완전 격리가 목적이라 인스턴스와 병원을 1:1로 두는 편이 명확하다. |
| 리버스 프록시Caddy | nginx + certbot | Let's Encrypt 인증서 자동 발급·갱신 설정이 간결하고, IP 모드와 도메인 모드를 환경변수 한 줄로 바꿀 수 있다. |
| 여러 서버 배포스테이징을 먼저 배포하고 통과해야 운영으로 넘어가는 스크립트 | 서버마다 ssh로 따로 배포 | 체크리스트의 '스테이징 먼저' 규칙이 잘 지켜지지 않았다. 급하면 운영에 바로 배포하게 되어, 스테이징을 거치는 쪽을 가장 쉬운 길로 만들었다. |
| 관리자 통계 차트의존성 없는 SVG 차트 직접 구현 | recharts 같은 차트 라이브러리 | 필요한 차트가 막대 두 종류뿐이다. 서버 컴포넌트에서 그대로 렌더되고, 관리자 번들을 키우지 않는다. |
| 배포 백업 보관DB 덤프와 업로드 압축을 따로 세어 보관 | 배포 10회분을 한꺼번에 보관 | 배포가 잦은 날이면 백업이 반나절 만에 밀려 나가 나흘 전 덤프가 이미 없었다. DB 덤프와 업로드 압축은 크기가 1000배 차이 나서 같은 횟수로 묶을 이유가 없었다. |
결과
- 3
- 따로 배포해 운영 중인 병원 사이트
- 50
- 섹션 폼 (19개 섹션 타입)
- 7
- 8/31 보안 점검에서 고친 결함
- 2026-09-30 기준 병원 사이트 3곳이 각자 다른 서버에서 응답하고, API 상태 확인도 정상이다. 서버별로 어느 커밋이 배포되어 있는지는 저장소 기록만으로 단정하지 않는다.
- 배포 기록에는 배포 전후의 DB 행과 업로드 파일을 비교한 결과가 남아 있다. 9월 배포 한 건에서는 업로드 1,299개의 해시가 전후 일치했다.
- 8월에 접수한 수정 요청서 21건 중 18건은 완료, 2건은 재현 정보 대기, 1건(섹션 단위 페이지 넘김)은 미착수다. 완료한 18건 중 7건은 병원 측 화면 확인이 남아 있다.
- 의존성 취약점 정리와 실기기 화면 검수가 남아 있어 작업은 진행 중이다.
- 커밋 552개는 모두 본인 계정이고, 그중 538개에 Claude 공동 작성 표기가 있다.
고객사 실명, 도메인, 화면 캡처는 공개하지 않는다.
이 프로젝트가 궁금하시면 편하게 물어보세요.
연락하기