PHP2Shell 보안 패치를 하려고 시작했는데, 집에 있는 서버를 완전 정리하는 일이 되어 버렸습니다.
오래된 CentOS 7 서버에서 블로그를 옮기고, Jira와 Confluence를 옮기고, 마지막에는 리버스 프록시와 SSL 인증서까지 TrueNAS로 넘겼습니다. 이왕 옮기는 거 백업도 제대로 만들어 두기로 했고요. 그렇게 3시간여만에 모든 작업이 끝났습니다. 이번 글은 Claude Code와 함께 진행한 홈 서버 이전 기록입니다. 중간에 막혔던 부분과 해결한 방법, 옮기고 나서 얼마나 빨라졌는지까지 정리해 봅니다. 보안을 위해 IP, 개별 서비스 포트, 계정, 일부 서비스 이름과 버전 정보는 뺐습니다.
집에 서버가 두 대 있는데
하나는 HP의 Microserver Gen 8입니다. 오래되었죠. 14년 정도 된듯 합니다. 그리고 최근에 구매한 Minisforum NAS 서버입니다. 기존 서버는 가상머신 위에서 돌아가는 CentOS 7입니다. 2012년 세대 Xeon CPU에 메모리 10GB를 쓰고 있었습니다. 블로그, Jira, Confluence가 올라가 있고, 모든 도메인의 리버스 프록시와 SSL 인증서 발급까지 맡고 있었습니다.
새 서버는 TrueNAS SCALE을 설치한 NAS입니다. 최신 Ryzen CPU에 메모리 60GB, NVMe SSD를 쓰고 있습니다. 사진 관리, 파일 공유, 개인 프로젝트 앱들은 이미 여기에서 Docker 앱으로 돌리고 있었습니다.
CentOS 7은 2024년 6월에 지원이 끝났습니다. 옮겨야 한다는 건 알고 있었는데, 잘 돌아가니 계속 미루게 됩니다. 언젠가는 해야지 하다가 이번 PHP 보안 패치 때문에 더는 미룰 수 없게 되었습니다.
먼저 AI 도구들이 같은 기록을 보게 만들기
저는 VS Code에서 GitHub Copilot, Claude Code, Codex를 같이 씁니다. 문제는 도구를 바꾸면 앞에서 무슨 작업을 했는지 다시 설명해야 한다는 점입니다. 서버 설정은 계속 바뀌는데 각자 다른 상태를 알고 있으면 곤란하죠. 그래서 작업 전에 서버 관리용 저장소부터 만들었습니다. 어떤 도구를 쓰든 같은 문서를 읽고, 작업한 내용도 같은 곳에 남기도록 구성했습니다.
| 파일 | 용도 |
|---|---|
AGENTS.md | 모든 도구가 같이 쓰는 작업 규칙. Codex는 이 파일을 바로 읽습니다. |
CLAUDE.md, .github/copilot-instructions.md | 도구별 진입점. 규칙을 따로 적지 않고 AGENTS.md를 가리킵니다. |
memory/MEMORY.md | 현재 서버 상태만 적어 둡니다. 바뀌면 최신 내용으로 덮어씁니다. |
docs/worklog/YYYY-MM.md | 어떤 AI가 언제 무슨 작업을 했는지 순서대로 추가합니다. |
docs/hosts/, docs/services/, docs/runbooks/ | 서버와 서비스별 설정, 반복해서 쓸 작업 절차를 정리합니다. |
작업 전에는 MEMORY를 읽고, 작업이 끝나면 worklog에 기록하고, 상세 문서와 MEMORY를 갱신하도록 했습니다. 서버 두 대도 매번 길게 설명하기 귀찮아서 C서버, T서버라는 이름을 붙였습니다.
막상 해 보니 이걸 먼저 만든 게 도움이 많이 되었습니다. 어느 설정을 왜 바꿨는지 다시 찾을 수 있고, 도구를 바꿔도 이어서 작업할 수 있습니다. 이 글도 그때 남긴 기록을 보면서 쓰고 있습니다.
PHP 보안 패치, 그런데 CentOS 7은?
10월 21일에 PHP 긴급 보안 릴리스가 나왔습니다. 인증 없이 원격 코드를 실행할 수 있는 취약점 두 개였고, CVSS 점수는 9.2였습니다. 공개되기 전부터 공격 시도가 보고됐다고 합니다. 제가 운영하는 WordPress 사이트 두 개가 모두 영향 범위에 있었습니다.
참고자료: https://www.boho.or.kr/kr/bbs/view.do?bbsId=B0000133&pageIndex=1&nttId=72230&menuNo=205020
TrueNAS WordPress는 앱 업그레이드로 해결
NAS에 있던 WordPress는 TrueNAS 카탈로그 앱이라 비교적 간단했습니다. 다만 이미지만 다시 받으면 될 줄 알았는데 PHP 버전이 그대로였습니다.
알고 보니 카탈로그에서 이미지 태그와 digest를 고정해 두고 있었습니다. 같은 태그를 다시 받아서는 바뀌지 않고, 앱 업그레이드를 해야 수정된 PHP가 들어옵니다.
업그레이드할 이미지를 먼저 받아서 php -v로 패치 버전을 확인했습니다. 혹시 모르니 데이터셋 스냅샷도 찍어 두고 진행했습니다. 실제 앱 업그레이드는 15초 정도 걸렸습니다.
오래된 서버는 업데이트할 패키지가 없음
CentOS 7 쪽이 문제였습니다. PHP는 외부 저장소인 Remi에서 받아서 쓰고 있었는데, 이쪽도 CentOS 7 지원이 끝나서 새 패키지가 없었습니다.
일단 PHP에서 안내한 임시 완화 설정인 session.upload_progress.enabled = Off를 적용했습니다. 알려진 공격 경로 하나를 막는 조치이고, 완전한 해결은 아닙니다. 결국 사이트를 NAS로 옮겨야 했습니다.
이왕 확인하는 김에 쓰지 않는 사이트들도 찾아봤습니다. PHP 사이트가 네 개나 더 남아 있었고, DB 관리 도구도 있었습니다. 모두 백업한 뒤 정리했습니다.
그런데 삭제 직전에 하나를 발견했습니다. 다른 도메인의 인증서 갱신이 지우려던 사이트 폴더를 쓰고 있었습니다. 그냥 지웠으면 지금은 멀쩡하다가 두 달 뒤에 인증서가 만료될 뻔했습니다. 인증서 갱신 경로를 전용 폴더로 옮긴 다음 삭제했습니다.
안 쓰는 사이트라고 바로 지우면 안 되는 이유가 이런 데 있네요.
WordPress 이전 – 화면이 같으면 끝일까?
먼저 TrueNAS 카탈로그에서 WordPress 앱을 새로 만들고, DB 덤프와 wp-content를 옮겼습니다. 기존 사이트와 같은지 확인하고 나서 접속을 넘길 생각이었습니다.
첫 설치부터 권한 문제
처음에는 MariaDB 컨테이너가 뜨지 않았습니다. 카탈로그 앱에 권한을 설정하는 컨테이너가 있는데, 이게 호스트 경로(host path)의 권한까지 맞춰 주지는 않았습니다.
데이터셋 소유자를 WordPress와 MariaDB가 각각 사용하는 uid로 미리 지정하니 정상적으로 설치됐습니다. 앱에서 알아서 해 줄 거라고 생각했던 부분인데, 직접 맞춰 줘야 했습니다.
575개 페이지를 전부 비교해 보기
홈 화면 하나 열어 보고 끝내기는 아쉬웠습니다. 사이트맵에 있는 575개 URL을 기존 서버와 새 서버에서 각각 받아서 HTML을 비교했습니다.
- 553개는 완전히 같았습니다.
- 나머지 22개는 Akismet이 댓글 폼에 넣는 무작위 값만 달랐습니다.
비교하는 과정에서 따로 확인하고 해결한 문제도 있었습니다.
광고 스크립트가 안 나오는데?
새 서버에서만 Google Site Kit의 광고 관련 스크립트가 빠져 있었습니다. 원인은 wp-config.php에 있는 보안 키, SALT였습니다.
Site Kit는 Google 연결 토큰을 SALT로 암호화해서 저장합니다. 그런데 새 Docker 이미지에서는 SALT가 새로 만들어지니 기존 토큰을 풀 수 없었던 것입니다.
WordPress를 옮길 때는 SALT도 같이 옮겨야 합니다. 이걸 맞추면 기존 로그인 세션도 유지됩니다. DB와 파일만 옮기면 된다고 생각하기 쉬운데, 이런 설정도 같이 봐야 합니다.
썸네일이 다른 건 새 서버 문제일까?
이번에는 이미지가 달랐습니다. 그런데 확인해 보니 새 서버가 정상이고 기존 서버가 문제였습니다.
오래된 업로드 폴더가 root 소유로 되어 있어서 테마가 썸네일을 만들지 못하고 있었습니다. 그래서 매번 원본 이미지를 쓰고 있던 겁니다. 새 서버에서는 권한이 맞으니 원래 의도한 크기의 썸네일이 만들어졌습니다.
이전하다가 기존 서버의 문제를 고친 셈입니다. 이 부분은 뒤에 나오는 성능 차이에도 영향을 준 것으로 봅니다.
나만 새 서버를 보도록 설정
방문자들이 쓰는 사이트를 바로 바꾸기보다는, 먼저 제 PC에서만 새 사이트를 보도록 했습니다. 이걸 카나리 방식으로 검증했습니다.
기존 프록시에 관리 PC의 IP에서 들어오면 새 서버로 보내는 규칙을 넣었습니다. PC의 /etc/hosts는 기존 서버를 직접 가리키도록 했고요. 이렇게 하면 일반 방문자는 기존 사이트를 보고, 저는 같은 도메인과 정상 인증서로 새 사이트를 확인할 수 있습니다.
중간에 새 서버를 IP 주소로 직접 열어 봤더니 메뉴 아이콘이 전부 □로 나왔습니다. 또 뭘 잘못 옮겼나 싶었는데, CORS 문제였습니다. 페이지 안의 폰트 주소는 실제 도메인으로 되어 있으니 IP로 접속하면 브라우저가 다른 출처의 폰트를 막습니다. 도메인으로 확인해야 할 것을 IP로 확인해서 생긴 문제였습니다.
업로드 용량도 미리 맞춰 두기
새로 올린 공식 WordPress 이미지의 PHP 업로드 제한은 2MB였습니다. 기존에 쓰던 100MB로 맞추고, 설정 파일을 앱에 마운트했습니다. 앱을 업그레이드해도 유지되도록 구성한 겁니다.
확인이 끝난 뒤에는 프록시의 백엔드 주소만 바꿨습니다. WordPress 전환은 중단 시간 없이 끝났습니다.
그래서 얼마나 빨라졌을까?
같은 WordPress에 같은 데이터를 넣고 비교했습니다.
| 항목 | 기존 서버 | 새 서버 |
|---|---|---|
| PHP 단일 스레드 벤치마크 | 1.84초 | 0.76초 (2.4배) |
| 홈 화면 응답, 동시 접속 1 | 1,181ms | 146ms (8.1배) |
| 글 화면 응답, 동시 접속 1 | 293ms | 114ms (2.6배) |
| 홈 화면 처리량, 동시 접속 10 | 초당 3.9건 | 초당 33.9건 (8.6배) |
홈 화면은 1초 넘게 걸리던 게 0.15초 정도로 줄었습니다. 오래된 서버에서 옮긴 보람이 있습니다.
처음부터 이 수치가 나온 건 아닙니다. 동시 접속을 늘려 보니 생각보다 처리량이 안 나왔습니다. NAS 전체 CPU는 87%나 쉬고 있는데 앱은 CPU 사용률이 196%였습니다. 알고 보니 TrueNAS 앱에 CPU 2코어 제한이 걸려 있었습니다.
제한을 6코어로 올리니 처리량이 세 배 넘게 늘었습니다. 하드웨어가 좋아도 앱에 제한이 걸려 있으면 소용이 없습니다.
CPU 벤치마크는 2.4배인데 홈 화면은 왜 8배나 차이가 날까요? 앞에서 발견한 썸네일 문제에 가상 디스크와 스왑 영향까지 같이 줄어든 결과로 봅니다. 디스크 4K 동기 쓰기 성능도 약 22배 차이가 났습니다.
NAS로 옮겼으니 백업은 괜찮을까?
이전하면서 디스크 구성도 같이 확인했습니다. 그런데 모든 ZFS 풀이 디스크 한 개짜리였습니다. 이중화가 없었습니다. 정기 스냅샷도, 복제도, 외부 백업도 없었습니다.
NAS라는 이름만 믿고 있을 상황이 아니었습니다. 해당 디스크 하나가 고장 나면 그 안의 데이터가 날아가는 구조였으니까요. 옮기는 김에 백업부터 만들어 두기로 했습니다.
| 대상 | 백업 방식 | 보관 기간 |
|---|---|---|
| WordPress 2개 | 주간 DB 덤프 + 주간 ZFS 스냅샷 + 다른 물리 디스크의 풀로 복제 | 4주 |
| Jira·Confluence | 기존 제품 내장 백업 일정 유지 + 최신본만 남기는 정리 스크립트 + 주간 DB 덤프 + 스냅샷·복제 | 4주 |
| 리버스 프록시 설정 | 스냅샷·복제 | 4주 |
작업은 TrueNAS API로 등록했습니다. 웹 UI에서도 확인하고 관리할 수 있도록 했습니다. DB 덤프 스크립트는 덤프가 끝까지 완료됐는지 확인한 뒤에만 오래된 파일을 지우게 만들었습니다.
등록만 해 놓으면 제대로 되는지 모르니 바로 한 번씩 실행했습니다. 복제본의 파일 수가 원본과 같은지도 확인했습니다.
아직 클라우드 백업은 남아 있습니다. 다른 물리 디스크로 복제하는 것까지 했고, 다음에는 클라우드에도 한 벌 더 보관할 예정입니다. 3-2-1 백업에서 마지막 1이 남은 셈입니다.
Jira와 Confluence – 이번에는 라이선스가 문제
블로그를 옮겼으니 Jira와 Confluence도 옮기기로 했습니다. 둘 다 Atlassian Server 라이선스로 쓰던 제품입니다. Server 제품 지원은 2024년 2월에 끝났고, 유지보수를 연장할 수도 없습니다.
라이선스가 있다고 최신 버전까지 쓸 수 있는 게 아닙니다. 제 라이선스의 유지보수 만료일 이전에 빌드된 버전까지만 쓸 수 있습니다. 여기서 처음 계획이 바뀌었습니다.
Confluence는 권장 DB인 PostgreSQL을 계속 쓰면서 라이선스 범위 안의 마지막 버그 수정 버전으로 올릴 수 있었습니다. 반면 Jira는 허용되는 버전이 지원하는 DB가 모두 지원 종료 상태였습니다. 기존에는 공식 지원 대상이 아닌 MariaDB를 쓰고 있었는데, 이번에는 해당 Jira 버전이 공식 지원하는 MySQL 5.7로 옮겼습니다.
버전 하나 차이로 안 뜹니다
여기서 실수를 하나 했습니다. 보안 패치가 들어간 바로 다음 버전 정도는 쓸 수 있겠지 생각했는데, 출시일이 유지보수 만료일보다 2주 늦었습니다.
실제로 실행해 보니 Jira가 라이선스 오류로 기동을 거부했습니다. 2주 차이도 안 되는 건 안 됩니다.
다행히 업그레이드 작업까지 진행하지 않고 멈췄습니다. DB와 홈 디렉터리를 원본에서 다시 가져와 원래 버전으로 진행했습니다. 해당 취약점은 Atlassian이 안내한 우회 방법대로 프록시에서 관련 경로를 차단했습니다.
이전하면서 하나씩 맞춘 설정
| 막혔던 부분 | 처리한 방법 |
|---|---|
이번 Jira와 MySQL 5.7 조합에서 utf8mb4_bin 정렬 규칙을 맞춰야 했음 | 테이블 388개 변환. 이모지도 저장할 수 있게 됐습니다. |
기존 접속 옵션인 storage_engine이 MySQL 5.7에서 사라짐 | default_storage_engine으로 변경했습니다. |
| Confluence가 Jira 계정을 가져올 때 불필요한 HTTP 프록시를 거침 | 해당 설정을 제거하고 Jira 허용 주소에 Confluence 컨테이너를 추가했습니다. |
| Confluence 공동 편집 기능인 Synchrony가 별도 포트와 WebSocket을 사용 | 프록시에 경로를 따로 지정했습니다. |
| 시험용 서버에서도 알림 메일이 나가면 중복 발송될 수 있음 | JVM 옵션으로 메일 발송·수신을 꺼 두고 최종 전환할 때 해제했습니다. |
DB 수정은 손으로 반복하지 않고 스크립트로 만들었습니다. 시험 이전 때 한 번, 실제 전환 때 한 번 같은 작업을 해야 하기 때문입니다. 한 번 성공한 작업을 다시 손으로 하다가 틀리고 싶지는 않았습니다.
실제 전환은 4분 30초
WordPress 때와 같이 카나리로 확인한 다음, 기존 서버와 새 서버의 서비스를 모두 멈추고 마지막 데이터를 맞췄습니다. DB는 다시 가져오고, 홈 디렉터리는 rsync로 바뀐 부분만 보냈습니다.
실제 서비스 중단 시간은 4분 30초였습니다.
옮기고 나서 달라진 부분도 있습니다.
- 기존에 4천 번 넘게 반복되던 지원하지 않는 DB 경고가 사라졌습니다.
- Jira 기동 시간이 약 92초에서 48초로 줄었습니다.
- 기존 서버 메모리 사용량은 7.6GB에서 0.8GB로 내려갔습니다.
기존에는 Java가 메모리를 대부분 쓰고 스왑까지 사용하고 있었습니다. 서비스를 옮기고 나니 오래된 서버가 훨씬 한가해졌습니다.
리버스 프록시와 SSL까지 옮기기
여기까지 했는데도 외부 접속은 여전히 오래된 서버를 거치고 있었습니다. 리버스 프록시와 인증서 발급이 남아 있었기 때문입니다. 이것까지 옮겨야 오래된 서버를 정리할 수 있습니다.
Nginx Proxy Manager로 구성
TrueNAS 카탈로그에서 Nginx Proxy Manager(NPM)를 골랐습니다. 웹 화면으로 도메인별 프록시를 관리할 수 있고, Let’s Encrypt 인증서 발급과 자동 갱신도 해 줍니다.
기존 Apache에서 쓰던 취약 경로 차단, WebSocket, 대용량 업로드 허용, 스트리밍 응답용 버퍼링 해제 설정도 옮겼습니다.
그런데 NAS의 80, 443 포트는 TrueNAS 관리 화면이 쓰고 있었습니다. TrueNAS 설정을 바꾸는 대신 공유기에서 외부 80, 443을 NPM의 다른 내부 포트로 연결했습니다.
인증서부터 넣어 두고 접속을 넘기기
순서는 다음과 같이 진행했습니다.
- 기존 인증서를 NPM에 먼저 넣었습니다.
- 공유기 설정을 바꾸기 전에 도메인마다 새 프록시로 직접 접속해서 기존 사이트와 응답을 비교했습니다.
- 문제가 없는 것을 확인하고 공유기 포트포워딩을 바꿨습니다. 되돌릴 때도 이 설정을 바꾸면 됩니다.
- 그 뒤 NPM에서 Let’s Encrypt 인증서를 새로 발급받아 교체했습니다.
비교하다가 예전 설정도 하나 발견했습니다. Apache 설정에는 X-Frame-Options 헤더를 지우도록 적어 놨는데, 실제로는 적용되지 않고 있었습니다. 설정에 써 있다고 그대로 동작하는 건 아니었습니다. 새 프록시는 실제 운영 중인 응답을 기준으로 맞췄습니다.
화면 대신 API로 설정 남기기
도메인을 화면에서 하나씩 등록하지 않고, 도메인 목록 JSON과 NPM API 스크립트로 만들었습니다. 같은 스크립트를 다시 실행해도 결과가 같도록 구성했습니다. 다른 AI 도구로 작업할 때도 파일을 보면 설정을 알 수 있습니다.
이번에 사용한 NPM 2.16에서는 기존 방식과 다른 부분이 있었습니다.
- 인증서 발급 요청에 이메일을 넣는 항목이 없어졌습니다. NPM 로그인 계정의 이메일을 연락처로 사용합니다.
- Let’s Encrypt 인증서 이름을 따로 지정해도 도메인 목록으로 덮어씁니다.
두 번째 때문에 인증서를 두 번 발급했습니다. 스크립트가 이름으로 기존 인증서를 찾고 있었는데, 이름이 바뀌니 없는 것으로 판단한 겁니다. 도메인 묶음으로 비교하도록 수정했습니다.
덤으로 정리된 것들
인증서가 없어 HTTPS로 들어가면 엉뚱한 사이트가 열리던 apex 도메인, 즉 서브도메인이 없는 기본 도메인도 정상으로 맞췄습니다. 암호화 없이 노출되던 NAS 관리 화면과 사진 서비스에는 HTTPS를 붙였고, HTTP로 들어오면 HTTPS로 보내도록 했습니다.
등록하지 않은 이름으로 들어오는 요청도 거부하도록 했습니다. 예전에는 다른 사이트가 열리던 부분입니다. 하나씩 비교해 보니 오래된 설정에서 그냥 지나쳤던 것들이 보였습니다.
해 보고 나니
PHP 패치에서 시작해서 블로그, Jira, Confluence, 리버스 프록시, SSL 인증서까지 NAS로 옮겼습니다. 이미 NAS에 있던 블로그도 패치했고, WordPress 두 개와 나머지 서비스의 백업도 구성했습니다.
한꺼번에 옮기면 복잡할 것 같았는데, 서비스별로 시험하고 확인한 다음 접속을 넘기니 진행할 수 있었습니다. 중간에 권한도 틀리고, 라이선스도 잘못 판단하고, 인증서도 두 번 발급했지만요.
다음에 다시 한다면 미리 확인할 것들을 적어 둡니다.
- 라이선스와 지원 버전부터 확인하기. 설치가 되는 것과 실제로 쓸 수 있는 것은 다릅니다. 이번에는 버전 하나 차이로 막혔습니다.
- 홈 화면만 보고 끝내지 않기. 575개 URL을 비교하면서 광고 스크립트와 썸네일 문제까지 찾았습니다.
- 카나리로 먼저 확인하기. 관리 PC에서만 새 서버를 보게 하면 방문자에게 영향을 주지 않고 실제 도메인과 인증서로 시험할 수 있습니다.
- NAS에 넣었다고 백업이 끝난 건 아님. 디스크 구성, 스냅샷, 복제가 실제로 어떻게 되어 있는지 확인해야 합니다.
- 두 번 할 작업은 스크립트로 만들기. 시험 때 성공한 방법을 실제 전환에도 그대로 쓰는 게 편합니다.
- AI 도구를 바꿔도 기록은 이어지게 하기. 공통 규칙, 현재 상태, 작업 로그를 나눠 두니 다시 설명하는 일이 줄었습니다.
아직 남은 일은 있습니다. 클라우드에 백업을 한 벌 더 보관하고, 디스크 이중화도 검토해야 합니다. 오래된 서버는 바로 끄지 않고 비상용으로 남겨 뒀습니다. 한 달 정도 지켜본 다음 최종 퇴역을 검토할 생각입니다.
언젠가 옮겨야지 하던 서버를 드디어 옮겼습니다. PHP 패치 하나 하려다가 일이 커졌지만, 이번 기회에 밀려 있던 서버 정리까지 끝냈습니다. 이제 백업도 마저 챙겨 봐야겠습니다.
중요한건 이 모든 작업이 4시간 만에 Claude Code Pro의 기본 사용량으로 Opus 5.5 High에서 모두 완료되었다는 것인데, 예전에는 한달씩 걸리던 작업일텐데 정말 편해졌어요.










