10년 방치된 레거시 시스템의 전면 재건축, 25일의 공정을 2시간 38분으로 압축하다
2017년 멈춰버린 오픈소스 역사 아카이브 프로젝트(jsn.soohwalee.com)를 복원해 운영하던 중, 패치조차 존재하지 않는 구형 PHP의 치명적인 원격 코드 실행(RCE) 보안 위기에 직면했습니다. 문제 진단부터 아키텍처 설계, 프레임워크 메이저 이식(PHP 8.5 / Symfony 7.4), DB 무결성 검증, 그리고 프로덕션 배포까지 AI 에이전트가 주도해 완수한 놀라운 AX(AI Transformation)의 과정을 기록합니다.
1. 프롤로그: 2017년의 유산을 되살리다, 그리고 찾아온 위기
얼마 전, 2017년 이후로 유지보수가 완전히 멈춰 있던 깃허브(GitHub)의 한 오픈소스 역사 데이터베이스 프로젝트를 발견했다. 예수회 과학 네트워크(Jesuit Science Network)의 역사적 인물과 관계망을 담고 있는 귀중한 디지털 인문학 자산이었다. 이 프로젝트를 다시 숨 쉬게 하고 싶어 내 오라클 클라우드(Oracle Cloud) 서버로 가져와 AI의 도움을 받아가며 도커(Docker) 컨테이너로 리빌드해 띄웠다.
당시에도 이미 10년 가까운 세월 동안 방치된 코드라 라이브러리 버전들이 서로 맞지 않아 수많은 의존성 충돌을 고쳐야 했다. 우여곡절 끝에 구동을 성공시키고, 수정된 소스는 westwins/jsn-restoration이라는 이름으로 내 깃허브 저장소에 갈무리해 두었으며, https://jsn.soohwalee.com 도메인으로 세상에 다시 공개했다.
그런데 며칠 전, 예상치 못한 곳에서 시한폭탄이 터졌다. PHP 공식 보안 권고(2026-10-21)를 통해 인증 없이 원격 코드 실행(RCE)을 허용하는 치명적인 취약점(CVE-2026-109618, CVE-2026-109619)이 발표된 것이다.
당시 jsn.soohwalee.com이 돌아가던 환경은 7.x 대의 구형 PHP(컨테이너 내부 php:7.2-apache 기반)였다. 이번 취약점은 특히 Apache mod_php 구성을 최고 위험군으로 분류했는데, 문제는 PHP 8.1 이하 버전은 정규 패치 대상이 아니며 예외적인 백포트조차 7.4 이상에만 제공된다는 점이었다. 즉, 단순 보안 패치나 버전 숫자만 올리는 방식으로는 해결이 불가능한 상태였다.
그렇다고 도커 파일의 PHP 베이스 이미지만 최신으로 바꾼다고 될 일이 아니었다. PHP를 올리면 그 위에 얹혀 있는 2016~2018년산 레거시 부품들이 도미노처럼 무너져 내릴 것이 자명했기 때문이다. 결국 나는 AI(ChatGPT/Codex)에게 현재 시스템의 정밀 보안 진단과 함께, 백엔드와 프런트엔드, 데이터베이스, 배포 파이프라인을 아우르는 전면 현대화(Modernization)를 맡겨보기로 했다.
2. 사전 진단: 단순 업그레이드가 아니라 ‘전면 재건축’이 필요한 이유
AI가 먼저 수행한 사전 아키텍처 및 보안 상태 분석 결과는 냉정하고도 치밀했다. 이 시스템은 구형 PHP 하나만 낡은 것이 아니라, 모든 구성 요소가 과거의 시간대에 단단히 묶여 있는 전형적인 ‘레거시 화석’이었다.
- 수명이 끝난 백엔드 의존성: Symfony 3.4.11(2021년 보안 지원 종료), Doctrine ORM 2.6.1, Twig 2.4.8 등 57개의 Composer 패키지가 2012~2018년 릴리스에 고정되어 있었다. 심지어 이미지에 박혀 있는 Composer 1은 2025년 9월부로 Packagist 공식 메타데이터 지원마저 종료된 상태였다.
- 뒤엉킨 프런트엔드 자산 파이프라인: 전체 저장소 파일 933개 중 무려 716개가
app/Resources/vendor아래에 직접 복사(vendoring)된 Bootstrap 3, Font Awesome 4, 구형 vis.js, jQuery UI 파일들이었다. Node 14 컨테이너가html/build/를 만들고 있었지만, 정작 템플릿은 이미 폐기된 Assetic 번들이 생성하는 정적 경로를 바라보고 있어 빌드 파이프라인이 완전히 따로 놀고 있었다. - 취약한 런타임과 배포 구조: 호스트의 전체 저장소를
/var/www/html에 그대로 바인드 마운트하고 있었고, 앱은 MariaDB에root계정으로 접속했으며, 배포 스크립트(bin/post-deploy.sh)는 운영 DB에 대고doctrine:schema:update --force를 직접 날리는 아찔한 구조였다. - 노출된 오류와 디버그 경로: 존재하지 않는 경로나 일부 메뉴(
/places/,/subjects/)에 접근하면var/logs/prod.log쓰기 권한 오류와 함께 PHP 스택 트레이스가 HTTP 200 상태 코드로 그대로 노출되고 있었고, 공개/debug/*컨트롤러까지 살아 있었다.
AI는 이 상태를 두고 “기존 프로젝트를 버전별로 조금씩 뜯어고치는 것보다, 기존 DB 스키마와 공개 URL·응답 포맷을 계약(Contract)으로 삼아 현대적인 Symfony 7.4 LTS 기반의 새 골격으로 기능을 병행 이식하는 것이 안전하다”는 결론을 내렸다.
놀라운 점은 사전 계획서에 적힌 예상 규모였다. “숙련된 개발자 1인을 기준으로 약 15~25 개발일(약 3~5주)이 소요되는 작업.” 긴급 격리와 백업에 1~2일, 기준선 테스트 구축에 3~5일, Symfony/Doctrine 이식에 6~10일, 컨테이너·CI 재구성에 3~5일, DB 리허설과 컷오버에 2~4일이 걸리는 대공사였다.
그리고 AI는 자신이 산정한 그 ’25일짜리 대공사’를 스스로 실행에 옮기기 시작했다.
3. 2시간 38분의 기적: 레거시에서 최신 LTS 스택으로의 완벽한 이식
나는 터미널과 작업 로그를 지켜보며 말문이 막혔다. AI는 단순히 코드 몇 줄을 번역하듯 고친 것이 아니었다. 0단계(백업 및 격리)부터 6단계(스테이징 검증 및 컷오버)에 이르는 정석적인 엔지니어링 프로토콜을 한 치의 오차도 없이 수행해 나갔다. 그리고 정확히 2시간 38분 뒤, 모든 현대화 작업과 프로덕션 전환이 완료되었다.
3.1. 아키텍처 및 실행 환경의 세대교체
가장 먼저 눈에 띄는 것은 완벽하게 환골탈태한 실행 환경이다. 10년 전의 기술 부채를 한 번에 뛰어넘어, 2026년 현재 가장 안전하고 빠른 최신 스택으로 교체가 이루어졌다.
| 구성 요소 | 기존 레거시 환경 (Before) | 현대화 완료 환경 (After) | 주요 변경 및 아키텍처적 의의 |
|---|---|---|---|
| 언어 런타임 | PHP 7.2.34 (mod_php) |
PHP 8.5.12 (PHP-FPM) | 최신 RCE 취약점 원천 차단, 엄격한 타입 및 성능 향상 |
| 웹 프레임워크 | Symfony 3.4.11 | Symfony 7.4.21 LTS | prod 모드(디버그 비활성), PHP Attributes 기반 라우팅·DI |
| ORM / DBAL | Doctrine 2.6.1 | Doctrine 3.7.5 / 4.5.0 | 스키마 검증 일치, 명시적 Migration 관리 체계 도입 |
| 웹 서버 | Apache (단일 컨테이너 결합) | Nginx 1.30.4 (분리형) | 웹 서버와 PHP-FPM 앱 컨테이너의 역할 분리 |
| 데이터베이스 | MariaDB 10.5 | MariaDB 11.8.9 | 안전한 논리 덤프 이관 및 전체 테이블 무결성 검증 완료 |
| 프런트엔드 빌드 | Node 14 + Assetic (이중화 방치) | Node 24.22.1 + Vite 8.3.5 | 런타임 Node 컨테이너 제거, 멀티스테이지 정적 빌드 통합 |
| 테스트 / CI | 깨진 예제 테스트 1건 | PHPUnit 13.4.3 + GitHub Actions | 30개 테스트(37개 assertion), 자동화된 보안·스키마 감사 |
3.2. 엔터프라이즈급 컨테이너 보안과 인프라 하드닝(Hardening)
단순히 버전만 올린 것이 아니라 운영 보안 구조 자체가 완전히 새로워졌다.
- 권한 격리와 읽기 전용 파일시스템: 앱 컨테이너를 읽기 전용(Read-only)으로 실행하고, 런타임 쓰기는 오직
/app/var임시 파일시스템으로만 제한했다. 모든 컨테이너에no-new-privileges보안 옵션을 적용했다. - 네트워크 노출 최소화: 호스트 전체를 마운트하던 악습을 버리고 빌드 산출물이 내장된 이미지를 구성했다. DB 포트는 호스트 외부로 일절 공개하지 않으며, 웹 포트 역시 도커 브리지 주소인
172.17.0.1:8081에만 바인딩하여 리버스 프록시 경로 외의 직접 접근을 차단했다. - 비밀값 관리 및 디버그 제거: 깃(Git)에서 비밀값을 완전히 분리해 권한
0600으로 잠긴.env.production으로 주입했고, 불필요하게 열려 있던/debug/sources/경로가 정확히 404를 반환하도록 제거했다. - 헬스체크 및 트랜잭션 안전망: 컨테이너 상태 확인을 위한 liveness(
/healthz)와 DB 연결을 포함한 readiness(/readyz) 엔드포인트를 분리 구현해app,db,web세 컨테이너가 모두healthy상태로 관리되게 만들었다.
3.3. 데이터 무결성: 15개 테이블, 단 1개의 행(Row)도 잃지 않다
역사 아카이브에서 가장 중요한 것은 데이터의 보존과 무결성이다. AI는 구 서비스를 내리기 직전 백업(20261022T005000Z)을 뜨고, 이를 별도의 MariaDB 10.5 환경에서 먼저 복원 검증한 뒤 새로운 MariaDB 11.8 볼륨(jsn-modern_db_data)으로 이관했다.
3단계의 Doctrine 마이그레이션과 저자·편집자 JSON 변환을 거친 뒤에도 15개 전체 테이블의 행 수는 이관 전과 100% 동일했으며, 유효하지 않은 JSON은 0건이었다.
| 테이블 명 | 검증된 행 수 (Rows) | 테이블 명 | 검증된 행 수 (Rows) |
|---|---|---|---|
person (역사 인물) |
1,042 | aspect (생애/이력) |
6,782 |
alternate_name (이명) |
986 | aspect_place |
5,278 |
place (장소) |
886 | aspect_subject |
4,372 |
source / source_group |
756 / 4 | person_place |
3,635 |
subject / subject_group |
181 / 14 | person_subject |
3,537 |
relations (인물 관계망) |
254 | person_source |
1,464 |
subject_group_subject |
182 | 스키마 / JSON 오류 | 0 건 (완벽 일치) |
3.4. 하이라이트: 실운영 로그에서 잡아낸 ‘Symfony 7 호환성 버그’와 자가 수정
이번 작업에서 가장 소름 끼쳤던 순간은 배포 직후의 검증 과정이었다. AI는 Composer와 npm 잠금 파일 보안 감사(취약점 0건), Twig 34개 템플릿 문법 검사, VIAF BEACON 동적 생성(656줄) 확인까지 마친 뒤 실제 운영 로그와 브라우저 렌더링을 스스로 점검했다.
그 과정에서 지도 필터 API에 types=15와 같이 단일 스칼라 값이 들어올 때, Symfony 7의 엄격해진 요청 파라미터 처리 규칙 때문에 HTTP 400 오류가 발생하는 미묘한 런타임 호환성 문제를 스스로 포착해냈다!
AI는 즉시 단일값과 배열 입력을 모두 안전한 비트마스크로 정규화하도록 컨트롤러 로직을 패치(커밋 3ca2e1b)하고, 이에 대한 회귀 테스트 2개를 PHPUnit에 추가해 총 30개 테스트(37개 assertion)를 모두 통과시킨 뒤 재배포까지 마무리했다. 심지어 언제든 구 버전으로 즉시 되돌릴 수 있도록 기존 컨테이너(jsn-web-app-1, jsn-web-db-1)와 볼륨을 보존하고 SHA-256 체크섬이 포함된 롤백 매뉴얼까지 남겨두었다.
4. 에필로그: 이것이 바로 우리가 목격하고 있는 AX(AI Transformation)의 실체다
완료 보고서를 받아 들고 정상적으로 부드럽게 동작하는 https://jsn.soohwalee.com의 지도와 인물 네트워크 화면을 클릭해 보며, 나는 한동안 입을 다물지 못했다.
흔히 세간에서는 AI 코딩을 두고 ‘코드 자동 완성’이나 ‘간단한 프로토타입 제작 도구’ 정도로 이야기하곤 한다. 하지만 오늘 내가 내 서버 위에서 직접 경험한 것은 차원이 다른 이야기였다.
- 소프트웨어 수명 주기의 시간 축이 붕괴되었다: 인간 숙련 엔지니어가 전담해도 최소 3주에서 한 달(15~25일)은 잡아야 하는 풀스택 레거시 마이그레이션이 단 2시간 38분 만에 끝났다. 이는 단순한 속도 향상이 아니라, 그동안 ‘비용과 시간 때문에 포기해야 했던 수많은 레거시 시스템과 학술 아카이브’를 즉시 되살릴 수 있게 되었음을 의미한다.
- 단편적 코딩을 넘어선 ‘엔지니어링 전 과정의 자율 수행’: AI는 “PHP 버전을 올려줘”라는 말을 듣고 도커 파일 숫자만 바꾸는 우를 범하지 않았다. 백업을 먼저 뜨고, 데이터베이스 정합성을 대조하고, 아키텍처를 멀티스테이지로 분리하고, 배포 후 로그를 까보며 엣지 케이스 버그를 잡아내 테스트 코드로 고정했다. 이것은 시니어 시스템 아키텍트와 DevOps 엔지니어, QA 엔지니어의 역할을 하나의 연속된 호흡으로 수행한 것이다.
- 인간 기획자의 역할 변화: 이 과정에서 내가 한 일은 과거의 가치 있는 오픈소스를 발굴해 복원하겠다는 ‘의지(Intent)’를 세우고, 보안 위기 상황에서 ‘전면 현대화라는 방향성(Direction)’을 결정해 AI에게 권한과 맥락을 위임한 것이었다.
10년 전 만들어진 예수회 과학 네트워크(JSN)의 역사 데이터는 이제 PHP 8.5와 Symfony 7.4 LTS, MariaDB 11.8이라는 튼튼한 현대적 요새 위에서 다시금 연구자들과 대중을 맞이하고 있다. 낡아서 버려질 뻔한 인문학 데이터를 최신 AI 기술이 구원해 낸 셈이다.
문제를 정의하고, 해법을 도출하고, 두려움 없이 실행하여, 완벽하게 검증된 결과물을 프로덕션에 안착시키는 것. 오늘 2시간 38분 동안 내 클라우드 서버에서 벌어진 이 조용하고도 격렬했던 대공사는, 진정한 의미의 AX(AI Transformation)가 이미 우리의 터미널과 일상 속에 와 있음을 증명하는 가장 강렬한 사건으로 기억될 것이다.
추신: ‘현대화 완료’ 뒤에 나타난 0개의 검색 결과
그런데 이 글을 쓴 직후, 현대화의 의미를 더 정확하게 보여주는 사건이 벌어졌다.
프랑스 파리를 중심으로 반경 100km를 검색했더니 결과가 0건으로 나왔다.
JSN 데이터에 파리와 그 주변에서 활동한 인물이 한 명도 없을 리는 없었다. 이어
What에서 ‘천문학’을 골라 검색해 보았지만 이번에도 0건이었다. 다른 주제 묶음도
마찬가지였다. 화면은 정상적으로 열렸고 검색 조건도 정확하게 표시됐지만, 정작 데이터만
모두 사라진 것처럼 보였다.
첫 번째 원인은 데이터베이스 자체가 아니라 DBAL(Database Abstraction Layer)이라는
번역 계층에 있었다. DBAL은 PHP 코드와 데이터베이스 사이에서 SQL을 실행하고 그 결과를
PHP가 다룰 수 있는 형태로 전달한다. 오래된 코드에서는 executeQuery()가 돌려준
결과를 곧바로 foreach로 순회하면서 장소 ID를 뽑았다. 그러나 현대화된 Doctrine
DBAL 4에서 executeQuery()의 반환값은 행의 배열이 아니라
Doctrine\DBAL\Result 객체다. SQL 계산은 정상적으로 끝났지만, 옛 방식으로 그
객체를 순회한 코드가 ID를 하나도 꺼내지 못했다. 그다음 검색 단계에는 빈 목록이 전달됐고,
사용자는 오류 메시지 대신 그럴듯한 0 RESULTS 화면을 보게 된 것이다.
문제가 된 옛 방식은 다음과 같았다.
$rows = $connection->executeQuery($sql, $parameters);
$ids = [];
foreach ($rows as $row) {
$ids[] = $row['id'];
}
이를 DBAL 4의 명시적인 결과 추출 방식으로 고쳤다.
$ids = $connection
->executeQuery($sql, $parameters)
->fetchFirstColumn();
return array_map('intval', $ids);
쉽게 말하면 데이터베이스는 장소 번호가 적힌 상자를 제대로 돌려줬지만, 새 상자의 포장을
여는 법이 달라졌는데도 옛 방식으로 내용물을 꺼내려 한 셈이다. DBAL 4의 결함이 아니라,
DBAL 4로 옮기면서 애플리케이션 한구석에 남은 이전 세대의 사용법이 문제였다.
두 번째 ‘천문학 0건’의 원인은 또 달랐다. JSN에서 ‘천문학’은 하나의 값이 아니라
Astronomy, Calendar, Chronology, Astronomical instruments 등 15개의 세부 주제를 한꺼번에
선택하는 묶음이다. 기존 검색문은 이 15개 가운데 하나라도 포함한 인물을
찾아야 했지만, 현대화 과정에서 15개를 모두 동시에 가진 인물만 찾는 조건으로
해석됐다. 의도한 것은 OR 검색이었는데 실제로는 AND 검색이 된 셈이다. 조건을
IN (...) 방식으로 바꾸고 중복 인물을 제거하자 검색 결과가 다시 나타났다.
중요한 점은 두 오류 모두 서버가 멈추거나 붉은 경고창을 띄우지 않았다는 사실이다.
소프트웨어는 ‘정상’이라고 답했지만 역사 데이터에 대한 질문에는 틀린 답, 즉 0을 내놓았다.
디지털역사학에서 이런 조용한 오류(silent failure)는 일반적인 서버 장애보다
더 위험할 수 있다. 연구자가 그 결과를 사실로 믿는 순간, 프로그램의 호환성 문제가 역사적
해석의 오류로 변하기 때문이다.
그래서 수정으로 끝내지 않았다. 실제 운영 데이터에서 ‘파리 반경 100km 검색 결과가
1건 이상이어야 한다’, ‘천문학 주제 묶음 검색 결과가 1건 이상이어야 한다’는 자동 시험도
추가했다. 앞으로 라이브러리를 다시 올리거나 검색 코드를 고칠 때 같은 문제가 재발하면,
사람이 우연히 화면에서 발견하기 전에 배포 과정이 먼저 실패하도록 만든 것이다.
디지털역사학의 유지보수는 사이트가 열리는지만 확인하는 일이 아니다. 역사적 질문과
데이터 사이의 의미가 여전히 올바르게 연결되어 있는지를 계속 검증하는 일이다.
이 작은 후일담은 오히려 AX의 필요성을 더 선명하게 보여준다. AI가 소프트웨어를 한 번에
완벽하게 만들어 유지보수를 없애 주는 것은 아니다. 살아 움직이는 코드에는 새로운 버그가
생기고, 새 규격과 옛 연구 설계가 충돌하며, 사람은 예상하지 못한 방식으로 기능을 사용한다.
다만 AI는 이상 징후를 재현하고, 여러 기술 계층을 추적하고, 수정안을 만들고, 다시는 같은
문제가 생기지 않도록 시험을 남기는 순환을 압도적으로 짧게 만든다.
종이에 인쇄된 논문은 틀린 문장이 발견돼도 스스로 멈추지 않는다. 반면 오토마타로서의
역사학은 계속 움직이는 만큼 계속 돌봐야 한다. 결국 디지털역사학 AX의 핵심은
‘개발의 자동화’만이 아니다. 연구비와 개발팀이 사라진 뒤에도 학술 소프트웨어가 늙고,
고장 나고, 다시 고쳐지는 과정을 감당할 수 있게 만드는 지속 가능한 유지보수 능력에
있다. 이번의 두 개의 0은 그 사실을 보여준 작지만 결정적인 사건이었다.
관련 수정 기록:
반경 검색 및 검색 화면 수정,
주제 묶음 검색 수정과 회귀시험 추가