홀리스 크릭 가족 치과 · 수리 보고서

홀리스 크릭 사이트에서 결함 14개를 찾아 고치고 증명했습니다

사례 연구 · 가상의 치과 · 2026-11-07 UTC 측정

휴대폰에서는 예약 버튼이 화면 밖에 있었고 홈페이지가 이미지만 4.6 MB를 내려받았습니다(지금은 58 KB). 예약 양식은 감사 인사만 하고 메일을 보내지 않았으며, 보험 안내 페이지는 오류 화면뿐이었습니다. 아래 증거물마다 무엇이 잘못됐는지, 고친 커밋, 증명한 테스트, 직접 다시 재는 명령이 있습니다.

A 모바일과 속도

증거물 A-1 휴대폰에서 예약 버튼이 화면 밖에 있었습니다

390px 휴대폰에서 페이지가 옆으로 밀리고 “진료 예약 요청” 버튼이 오른쪽 가장자리 밖에 있어서, 환자가 옆으로 밀어 봐야만 찾을 수 있었습니다.

증거물 A-1: 측정 항목
측정 항목수리 전수리 후
390px에서 페이지 폭1180px390px
예약 버튼 오른쪽 끝542px370px

근본 원인 2019년 차일드 테마 규칙이 헤더 폭을 1180px로 고정하고, 메뉴 줄바꿈을 막고, 작은 화면용 메뉴 버튼을 숨겼습니다.

수리 덮어쓴 규칙을 지워 부모 테마의 유동 헤더가 900px 아래에서 메뉴를 접고 버튼을 한 줄에 따로 두게 했습니다.

증거물 A-2 휴대폰이 원본 크기 이미지를 내려받았습니다

홈페이지가 모든 휴대폰에 원본 업로드(3600px 대표 이미지, 3000px 대기실, 1600px 인물 사진)를 고를 크기도, 지연 로딩도 없이 보냈습니다.

증거물 A-2: 측정 항목
측정 항목수리 전수리 후
첫 로딩 이미지 용량(390px)4.6 MB58 KB
srcset 없는 이미지60
홈페이지 전체 용량(Lighthouse)4.7 MB146 KB
최대 콘텐츠 표시(LCP), 데스크톱4.0 s382 ms

Lighthouse: 3회 실행 중앙값

근본 원인 차일드 테마 홈 템플릿과 의료진 목록이 원본을 가리키는 맨 <img src> 태그를 출력했습니다. srcset, sizes, width/height, loading="lazy"가 없었습니다.

수리 각 자리 크기에 맞춘 wp_get_attachment_image()로 되돌렸습니다. 대표 이미지는 fetchpriority="high"로 먼저, 나머지는 지연 로딩합니다.

증거물 A-3 스크립트와 스타일이 첫 화면 표시를 막았습니다

화면에 무엇이든 나타나기 전에 브라우저가 스타일시트를 줄줄이 받고, jQuery와 jQuery UI 위젯 네 개, 이제는 없는 마크업용 슬라이더까지 실행해야 했습니다.

증거물 A-3: 측정 항목
측정 항목수리 전수리 후
<head>의 렌더링 차단 파일115
<head>의 동기 스크립트70
첫 콘텐츠 표시(FCP), 모바일1.7 s901 ms
Lighthouse 성능 점수, 데스크톱79100
Lighthouse 성능 점수, 모바일99100

Lighthouse: 3회 실행 중앙값

근본 원인 차일드 스타일시트가 부모를 @import로 불러 순차 다운로드가 생겼고, 폰트 여섯 굵기가 font-display 없이 로드됐으며, 슬라이더와 의존 스크립트가 head에 들어 있었습니다.

수리 차일드 스타일시트를 부모 의존성으로 등록하고, 폰트는 세 굵기에 swap을 쓰고, 슬라이더와 jQuery UI를 지웠습니다. 메뉴 스크립트는 defer로 늦췄습니다.

증거물 A-4 확대, 포커스, 대비, 레이블이 망가져 있었습니다

환자가 화면을 확대할 수 없었고, 키보드 사용자는 현재 위치를 볼 수 없었으며, 메뉴는 흰 바탕에 옅은 회색이었고, 양식 칸에는 레이블이 없었습니다.

증거물 A-4: 측정 항목
측정 항목수리 전수리 후
5개 페이지 axe 위반(WCAG 2.2 AA)430
확대 차단예아니요
Lighthouse 접근성 점수, 모바일84100

Lighthouse: 3회 실행 중앙값

근본 원인 오래된 header.php 사본이 확대를 막고 건너뛰기 링크를 뺐고, 차일드 CSS가 포커스 표시를 지우고 메뉴를 회색으로 만들었으며, 양식이 레이블 대신 placeholder를 썼습니다.

수리 낡은 헤더 사본과 대비·포커스 덮어쓰기를 지우고 모든 칸에 레이블을 되돌렸습니다. axe-core로 WCAG 2.2 AA 기준을 확인했습니다.

B PHP 8 오류

증거물 B-1 PHP 경고문이 모든 페이지 위에 찍혔습니다

모든 페이지가 서버 경로가 드러나는 “Deprecated:” 문구로 시작했고, 같은 내용이 누구나 받을 수 있는 /wp-content/debug.log에 기록됐습니다.

증거물 B-1: 측정 항목
측정 항목수리 전수리 후
화면의 PHP 경고, 8개 페이지230
크롤링 중 기록된 폐기 경고890
/wp-content/debug.logHTTP 200HTTP 404

근본 원인 차일드 테마의 PHP 7 코드(필수 앞의 선택 매개변수, ${} 문자열, 동적 속성, trim·strlen에 null 전달)를 PHP 8이 폐기 경고로 알렸고, 호스팅 이전 뒤에도 오류 표시가 켜져 있었습니다.

수리 죽은 코드를 지우고, 화면 표시를 끄고, 웹 루트 밖 /var/www/logs에 기록하며, 마이그레이션이 예전 공개 로그를 지웁니다.

증거물 B-2 의료진 페이지가 사람마다 PHP 경고를 냈습니다

“의료진 소개”를 열 때마다 의료진 한 명당 “Trying to access array offset on false” 경고가 하나씩 기록됐습니다.

증거물 B-2: 측정 항목
측정 항목수리 전수리 후
크롤링 중 기록된 경고80
크롤링 중 PHP 로그 전체1000

근본 원인 목록 코드가 오래전에 지운 옵션을 배열처럼 읽었습니다. PHP 7은 조용했지만 PHP 8은 경고합니다.

수리 없는 옵션은 빈 설정으로 다루게 했고, 홈페이지는 빈 예약 시간이 있을 때만 다음 시간을 보여 줍니다.

증거물 B-3 보험 안내 페이지가 흰 화면이었습니다

“보험과 결제”를 열면 HTTP 500과 함께 “There has been a critical error on this website.”만 보였습니다.

증거물 B-3: 측정 항목
측정 항목수리 전수리 후
보험 페이지 상태HTTP 500HTTP 200
환자에게 보이는 보험 수07

근본 원인 보험 플러그인이 페이지를 그리기 전에 PHP 8.0에서 삭제된 create_function()으로 목록을 정렬했습니다.

수리 클로저로 정렬하고, 렌더링 전 훅을 없앴습니다.

C 데이터베이스

증거물 C-1 의료진 목록이 사람마다, 항목마다 쿼리를 날렸습니다

“의료진 소개”를 그릴 때 의료진마다 글, 직함, 사진, 언어, 진료 항목 하나하나에 쿼리가 들었고, 사람이 늘수록 늘었습니다.

증거물 C-1: 측정 항목
측정 항목수리 전수리 후
의료진 페이지 쿼리 수10934
홈페이지 쿼리 수8044

근본 원인 WordPress 캐시를 거치지 않고 사람마다 SQL을 직접 날리는 2016년 반복문(N+1)이었습니다.

수리 메타와 분류 캐시를 미리 채운 WP_Query 한 번, 사진 한 번, 진료 항목 쿼리 한 번으로 바꿨습니다.

증거물 C-2 모든 요청이 죽은 캐시 수 메가바이트를 불러왔습니다

WordPress가 어떤 페이지든 시작하기 전에, 2023년에 닫힌 소셜 피드의 캐시 응답을 읽고 풀어야 했습니다.

증거물 C-2: 측정 항목
측정 항목수리 전수리 후
요청마다 불러오는 autoload 옵션3.5 MB22 KB
홈페이지 서버 처리 시간170 ms43 ms

근본 원인 피드 플러그인이 응답마다 autoload “yes” 옵션을 새로 만들고 한 번도 정리하지 않았습니다.

수리 죽은 플러그인을 지우고, 수리 마이그레이션이 그 옵션을 지웁니다(deploy.sh가 실행하는 wp hollis migrate).

증거물 C-3 다음 빈 예약 시간을 2019년 이후 모든 시간을 읽어 찾았습니다

홈페이지의 “다음 예약 가능 시간”이 볼 때마다 수만 개의 지난 예약 행을 훑고 정렬했습니다.

증거물 C-3: 측정 항목
측정 항목수리 전수리 후
한 번 찾을 때 읽는 행(ANALYZE)69,8641
쿼리 시간, 7회 중앙값83 ms0.12 ms
사용한 인덱스meta_keystatus_slot

근본 원인 예약 시간이 글 메타로 저장돼 meta_value로 찾았는데, 여기에는 쓸 수 있는 인덱스가 없습니다.

수리 (status, slot_at) 인덱스가 있는 예약 테이블을 만들고 마이그레이션으로 행을 옮겼습니다. 이제 인덱스 한 칸만 읽습니다.

D 보안과 메일

증거물 D-1 보안 헤더가 없었습니다

아무 사이트나 치과 페이지를 프레임에 넣을 수 있었고, 브라우저가 콘텐츠 형식을 추측할 수 있었으며, 스크립트 출처를 제한하는 것이 없었습니다.

증거물 D-1: 측정 항목
측정 항목수리 전수리 후
홈페이지 보안 헤더5개 중 05개 중 5
PHP 버전 노출PHP/8.3.36없음

근본 원인 사이트의 어떤 코드도 보내지 않았고, WordPress도 기본으로 보내지 않습니다.

수리 보안 강화 mu-plugin이 CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy와 HTTPS에서 HSTS를 보내고, X-Powered-By를 뺍니다.

증거물 D-2 누구나 로그인 이름을 알아낼 수 있었습니다

/?author=1이 관리자 로그인 이름으로 넘어갔고 /wp-json/wp/v2/users가 모든 작성자를 보여 줘, 비밀번호만 맞히면 되는 상태였습니다.

증거물 D-2: 측정 항목
측정 항목수리 전수리 후
익명 방문자가 얻는 로그인 이름20
/wp-json/wp/v2/usersHTTP 200HTTP 404

근본 원인 WordPress 기본값: 작성자 보관함, 공개 REST 사용자 경로, 사용자 사이트맵.

수리 방문자에게 작성자 쿼리는 404이고, 사용자 경로는 로그인한 사람에게만 있으며, 사용자 사이트맵과 oEmbed 작성자 정보를 없앴습니다.

증거물 D-3 XML-RPC가 열려 있었습니다

xmlrpc.php가 전체 메서드 목록을 응답했고, 그중 system.multicall은 요청 한 번에 비밀번호 수백 개를 시도할 수 있습니다.

증거물 D-3: 측정 항목
측정 항목수리 전수리 후
xmlrpc.php 응답HTTP 200HTTP 403
제공되는 메서드 수800

근본 원인 WordPress 기본값이며, 사이트에서 XML-RPC를 쓰는 곳은 없습니다.

수리 xmlrpc.php 요청은 어떤 메서드보다 먼저 403을 받고, pingback 헤더와 RSD 링크도 없앴습니다.

증거물 D-4 양식이 감사 인사만 하고 아무것도 보내지 않았습니다

진료 예약을 요청한 환자는 감사 인사를 받았지만, 접수처에는 요청이 전혀 오지 않았습니다.

증거물 D-4: 측정 항목
측정 항목수리 전수리 후
메일함에 도착한 요청1건 중 0건1건 중 1건
“감사합니다” 표시예예

근본 원인 SMTP 플러그인이 아직 예전 호스팅의 메일 서버를 가리켰고, 양식은 발송 실패를 무시했으며 실패 기록도 남기지 않았습니다.

수리 메일 서버 주소를 사이트 설정에서 읽고(이 데모에서는 Mailpit) 실패를 기록하며, 양식은 메일이 넘어갔을 때만 감사 인사를 합니다.

작업 절차

실제 운영 사이트에서 하듯 작업했습니다. 사본을 뜨고, 스테이징에서 테스트를 통과하기 전에는 운영 사이트를 건드리지 않았습니다.

  1. scripts/clone-to-staging.sh 운영을 스테이징으로 복제: DB 내보내기와 가져오기, 직렬화 데이터까지 URL 치환, 업로드 파일 복사, 검색엔진 차단.
  2. scripts/backup.sh production 무엇이든 건드리기 전에 운영 백업: DB, 업로드, 코드를 시각별로, SHA-256 체크섬과 함께.
  3. scripts/measure.sh 진단과 측정: PHP 오류 로그, 페이지별 쿼리 수, Lighthouse, axe, 응답 헤더, 메일 수집.
  4. site/ → staging 스테이징에서 수리: 결함 하나에 커밋 하나, 각각 증명 테스트와 함께.
  5. phpunit · playwright 테스트: PHPUnit 41개 중 41개, Playwright 34개 중 34개(두 사이트, 양방향), 운영 스크립트 테스트 25개 중 25개 통과 통과.
  6. scripts/deploy.sh 스테이징을 운영에 배포: 배포 전 백업, 점검 모드, 코드 교체, 수리 마이그레이션, 스모크 점검. 점검에 실패하면 되돌립니다.
  7. scripts/restore.sh 복원 검증: 지운 페이지와 이미지가 백업에서 돌아오고, 손상된 백업은 아무것도 바꾸기 전에 거부됩니다.

빈 볼륨에서 전체 실행: 1회차 · PHPUnit 41/41 · Playwright 30/30 · ops 25/25; 2회차 · PHPUnit 41/41 · Playwright 30/30 · ops 25/25; 3회차 · PHPUnit 41/41 · Playwright 34/34 · ops 25/25

Lighthouse, 홈페이지, 3회 실행 중앙값

Lighthouse
항목모바일 전모바일 후데스크톱 전데스크톱 후
성능9910079100
접근성8410084100
LCP1.69 s0.90 s4.01 s0.38 s
FCP1.66 s0.90 s0.40 s0.25 s
CLS0.0000.0000.0000.000
전체 용량4.7 MB146 KB4.7 MB146 KB

Lighthouse 13.6.0, Docker 안 Chromium, 2026-11-07. 모바일 점수는 수리 전에도 높았습니다. 좁은 화면에서는 가장 큰 요소가 대표 이미지가 아니라 글자이기 때문입니다(수리 전 사이트에서는 페이지 맨 위에 찍힌 PHP 경고문). 차이는 용량과 데스크톱 LCP에서 드러납니다.

추적 색인

모든 커밋과 테스트를 그것이 답하는 증거물로 되짚습니다. 커밋을 누르면 근거 페이지(메시지, 변경 내용, 증명 테스트)가 열리고, 커밋 메시지에도 “Exhibit:” 줄로 같은 연결이 있습니다.

커밋 → 증거물
커밋증거물
c4c8ced 헤더를 유동 폭으로 되돌려 390px 휴대폰에 예약 버튼이 들어오게 함 Fix A-1: fluid header so the booking button fits a 390px phoneA-1
31bc0c1 이미지를 자리 크기에 맞춰 srcset과 지연 로딩으로 제공 Fix A-2: serve images at the size of their slot, with srcset and lazy loadingA-2
d4e6502 렌더링 차단 스크립트, @import 연쇄, 쓰지 않는 폰트 굵기 제거 Fix A-3: remove render-blocking scripts, the @import chain and unused font weightsA-3
adfefb2 확대, 건너뛰기 링크, 대비, 포커스 표시, 양식 레이블 복구 Fix A-4: restore zoom, the skip link, contrast, focus outlines and form labelsA-4
141fe03 차일드 테마의 PHP 8 폐기 경고 수정, 오류를 페이지에 출력하지 않게 함 Fix B-1: PHP 8 deprecations in the child theme; stop printing errors into pagesB-1
d27aeda 의료진 옵션이나 다음 예약 시간이 없을 때의 PHP 8 경고 수정 Fix B-2: PHP 8 warnings when the team option or the next slot is missingB-2
031d47e 보험 페이지 흰 화면 수정(PHP 8에서 삭제된 create_function) Fix B-3: Insurance page white screen (create_function removed in PHP 8)B-3
3524955 의료진 목록 N+1 쿼리 수정, 데이터 종류마다 쿼리 한 번 Fix C-1: team list N+1 queries; one query per kind of dataC-1
3652956 죽은 피드 플러그인이 남긴 약 3.5 MB의 autoload 옵션 제거 Fix C-2: remove ~3.5 MB of autoloaded options left by a dead feed pluginC-2
854f62c 7만 개 메타 행을 훑는 대신 인덱스로 다음 빈 시간 찾기 Fix C-3: next open slot by index lookup instead of scanning 70k meta rowsC-3
bd639a1 보안 헤더 전송, 파일 편집기 비활성화 Fix D-1: send security headers; disable the file editorD-1
8df9c85 ?author=, REST, 사이트맵, oEmbed를 통한 사용자 이름 노출 차단 Fix D-2: stop user enumeration through ?author=, REST, sitemaps and oEmbedD-2
2d168c3 xmlrpc.php 닫기 Fix D-3: close xmlrpc.phpD-3
84d556e 예약 요청이 다시 메일함에 도착하고, 발송 실패는 화면에 알림 Fix D-4: appointment requests reach the inbox again; a failed send is shownD-4
d532fbd 후속: CSP에서 WordPress의 blob: 워커 허용 Fix D-1 follow-up: allow WordPress's blob: worker in the CSPD-1
fe60ced 후속: 예전 설정이 남긴 공개 debug.log 삭제 Fix B-1 follow-up: delete the public debug.log the old configuration left behindB-1
243f153 후속: 첫 화면 전에 메뉴를 접어 레이아웃 흔들림 제거 Fix A-1 follow-up: collapse the menu before the first paint (no layout shift)A-1
82b2c06 후속: 원본 비율을 지키는 대표 이미지 크기로 srcset이 비지 않게 함 Fix A-2 follow-up: hero size that keeps the upload's ratio, so srcset is not emptyA-2
테스트 → 증거물
테스트증거물
e2e/tests/a1-mobile-layout.spec.tsA-1
tests/phpunit/repair/ImagesTest.phpA-2
tests/phpunit/repair/RenderBlockingTest.phpA-3
e2e/tests/a4-accessibility.spec.tsA-4
tests/phpunit/repair/Php8DeprecationsTest.phpB-1
e2e/tests/b-php8.spec.tsB-1, B-3
tests/phpunit/repair/Php8WarningsTest.phpB-2
tests/phpunit/repair/InsuranceTableTest.phpB-3
tests/phpunit/repair/TeamQueriesTest.phpC-1
tests/phpunit/repair/AutoloadTest.phpC-2
tests/phpunit/repair/BookingSlotsTest.phpC-3
tests/phpunit/repair/SecurityHeadersTest.phpD-1
e2e/tests/d-security-mail.spec.tsD-1, D-2, D-3, D-4
tests/phpunit/repair/UserEnumerationTest.phpD-2
tests/phpunit/repair/XmlrpcTest.phpD-3
tests/phpunit/repair/MailTest.phpD-4

사이트와 저장소

두 버전이 같은 콘텐츠로 나란히 돌아갑니다. 어느 쪽 예약 양식이든 메일은 로컬 테스트 메일함으로만 가고, 실제 주소로는 가지 않습니다.

수리 후: 고친 사이트