구버전 앱을 멈추지 않고 레거시 PHP API 정리하기

API 폴더 8개, PHP 파일 102개, 깊이가 다른 include 네 가지, 강제 업데이트할 수 없는 앱. 내부 리라이트와 응답 비교로 /api/v1 아래로 옮긴 과정.

모바일 앱이 붙어 있으면 서버 정리가 훨씬 어려워집니다. 웹 페이지는 언제든 주소를 바꿀 수 있지만, 2년 전에 설치된 앱은 빌드될 때 박힌 경로를 오늘도 그대로 호출합니다. 그리고 모든 사용자에게 업데이트를 강제할 방법은 없습니다.

저희 공유 호스팅의 문서 루트에는 /Lotto/json/, /Takarakuji/json/, /MegaMillions/json/ 같은 API 폴더 8개가 늘어서 있었습니다. 폴더마다 DB 접속 정보 사본을 하나씩 갖고 Flutter 앱 8개에 응답하고 있었습니다. 목표는 이것을 /api/v1/<앱>/ 한 갈래로 모으고 접속 정보를 웹 공개 영역 밖으로 빼는 것입니다. 그러면서도 어떤 버전의 앱에서 오는 옛 URL이든 계속 동작해야 했습니다. 아래는 2026년 11월 8일의 작업 기록과 그때 실측한 수치입니다.

0단계: "동작한다"의 기준부터 적는다

아무것도 옮기기 전에 현재 앱 코드를 읽고, 앱이 호출하는 주소를 모두 YAML에 적었습니다. 모두 95건입니다. 끝 슬래시 없는 디렉토리형 URL, JSON 본문을 보내는 POST, 일본어 쿼리 값(priDay=今日) 같은 까다로운 것도 포함했습니다.

작은 스크립트로 각 주소를 리다이렉트를 따라가지 않고 요청한 뒤, 상태 코드, Content-Type, Location, 본문 해시, JSON 키 구조를 저장했습니다.

200    28143  GET /starUnse/json/list.php?priDay=今日
301      795  GET /Lotto/json/latest
200     2579  GET /Lotto/json/grid.php?limit=30
301      795  GET /Takarakuji/json/lotto6/detail/section1
301      795  POST /Takarakuji/json/lotto6
...
saved: data/api_baseline/20261108-220202

저장 직후 같은 대상과 바로 비교해 차이 0건을 확인했으니, 비교 결과는 믿을 만합니다. AI 예상이나 인기 목록처럼 매번 본문이 바뀌는 응답은 상태 코드, 타입, JSON 구조만 비교합니다.

이 기준값을 만드는 단계에서 이미 변경 전부터 있던 문제가 두 가지 드러났습니다.

  • 32건의 호출이 먼저 301을 거치고 있었습니다. 앱은 /Lotto/json/latest를 부르는데, latest가 디렉토리라서 서버는 301 → /Lotto/json/latest/를 돌려줍니다. GET이라 결과는 결국 오지만 매번 왕복이 한 번 더 듭니다.
  • POST 하나는 아예 동작하지 않고 있었습니다. POST /Takarakuji/json/lotto6은 301을 받고, 그 다음은 디렉토리 목록 금지로 403입니다. Dart의 HttpClient는 GET과 HEAD만 리다이렉트를 따라가므로, 앱에서는 그냥 오류였습니다. 아무도 몰랐습니다.

1단계: 깊이가 다른 include를 부트스트랩 하나로

엔드포인트마다 파일 위치에 따라 깊이가 다른 상대 경로로 DB 설정을 불러오고 있었습니다.

include 줄 파일 수
require_once('../lib/AppConfig.php'); 37
require_once('../../lib/AppConfig.php'); 4
require_once('../../../lib/AppConfig.php'); 19
require_once('../../../../lib/AppConfig.php'); 41
require_once(__DIR__ . '/../lib/AppConfig.php'); 1

폴더를 옮기면 이 경로들이 전부 깨집니다. 서버의 AppConfig.php 10개를 해시로 비교해 보니 모두 같은 파일이어서, 하나로 모으기로 했습니다. 모은 파일은 웹 공개 영역 밖 ~/tlabo_private/에 두고, 각 엔드포인트는 부트스트랩 파일 하나만 불러오게 했습니다.

<?php
// public_html/api/v1/_bootstrap.php
define('TLABO_PRIVATE', dirname(__DIR__, 5) . '/tlabo_private');
require_once TLABO_PRIVATE . '/AppConfig.php';
require_once TLABO_PRIVATE . '/clsMaria.php';
// 어떤 파일이 응답했는지 증명용. 본문은 바꾸지 않으며 앱은 무시한다
if (!headers_sent()) { header('X-Tlabo-Api: v1'); }

변환 스크립트는 폴더마다 json/을 통째로 복사합니다. 현재 앱이 쓰는 파일만이 아니라 구버전이 부를지도 모르는 파일까지 남기기 위해서입니다. 그다음 require_once 두 줄만 아래 한 줄로 바꿉니다.

require_once($_SERVER['DOCUMENT_ROOT'] . '/api/v1/_bootstrap.php');

lib/를 참조하는 require/include가 하나라도 남으면 멈추고, 모든 파일에 php -l을 돌립니다. 첫 실행은 HorseRace/json/api.php에서 멈췄는데, 원인은 include 문이 아니라 lib/AppConfig.php를 언급한 주석이었습니다. 그래서 검사 대상을 include 문으로 좁혔습니다.

2단계: 앱이 쓰기 전에 새 경로부터 시험한다

새 트리는 옛 폴더 옆에 올리기만 했고, 앱은 여전히 옛 폴더를 씁니다. 비교 도구는 경로 앞부분을 바꿔 요청할 수 있어서, 앱마다 새 위치의 엔드포인트를 불러 처음 저장한 기준값과 비교했습니다.

python scripts/api_snapshot.py compare --app kr_lottery --map /Lotto/json=/api/v1/lotto

8개 앱 모두 차이 0건이었습니다.

3단계: 새 파일이 "있을 때만" 리라이트한다

루트 .htaccess는 옛 접두어를 새 트리로 연결하되, 대상 파일이 실제로 있을 때만 연결합니다. 없으면 규칙이 걸리지 않고, 지금까지처럼 옛 폴더가 응답합니다. 원래 깨져 있던 POST도 그대로라서, 이번 변경으로 그 동작이 달라지지 않습니다.

# kr_lottery
RewriteCond %{DOCUMENT_ROOT}/api/v1/lotto/$1/index.php -f
RewriteRule ^Lotto/json/(.*?)/?$ api/v1/lotto/$1/index.php [L,QSA]
RewriteCond %{DOCUMENT_ROOT}/api/v1/lotto/$1 -f
RewriteRule ^Lotto/json/(.+)$ api/v1/lotto/$1 [L,QSA]

디렉토리형 요청을 바로 index.php로 보내면서 /Lotto/json/latest는 301 없이 곧바로 200을 주게 됐습니다. 운영 루트에 넣기 전에 같은 서버의 일회용 하위 폴더에서 규칙을 시험했고, 거기서 디렉토리형 경로로 보낸 POST가 리라이트를 거쳐 200을 받았습니다. Dart가 리다이렉트로는 처리하지 못하던 바로 그 경우입니다.

4단계: 전환하고 증명한다

첫 번째 앱은 단독으로 전환했습니다. 진짜 SQL 인젝션이 있던 앱입니다. list.php가 $_GET['priDay']를 SQL에 그대로 이어 붙이고 있었습니다. 수정에는 prepared statement가 아니라 mysqli_real_escape_string을 썼습니다. mysqlnd 환경에서 prepared statement는 정수 컬럼을 PHP 정수로 돌려주기 때문에, json_encode 출력이 "12"에서 12로 바뀝니다. 구버전 앱이 견디지 못할 수 있는 계약 변경입니다. 이스케이프는 출력을 바이트 단위로 그대로 유지합니다.

Hostinger의 WAF가 전형적인 공격 문자열(' OR '1'='1)을 PHP에 닿기 전에 "Forbidden"으로 막았기 때문에, 작은따옴표 하나로 시험했습니다.

/api/v1/starunsejp/list.php?priDay=今日'   →  []  [200]

패치 전 파일이었다면 SQL 오류가 났을 입력이, 패치 후에는 이스케이프되어 빈 결과가 됩니다. 이렇게 반응하는 것은 패치된 파일뿐이라, 전환 뒤 옛 URL에 같은 요청을 보내 리라이트가 실제로 동작하는 것도 증명했습니다.

나머지 7개 앱은 23분 뒤 .htaccess 변경 한 번으로 함께 전환했습니다.

점검 결과
앱 직결 URL (광고 설정, ads.txt, app-ads.txt, API) 8/8
옛 경로 API 회귀 비교 (93개 엔드포인트 전체) 차이 0
전체 URL 점검 (스토어 URL, 리다이렉트, 페이지) 53/53

CDN에 속을 뻔했다

그런데 이후 증명 헤더 확인에서 URL 4개가 실패했습니다. X-Tlabo-Api 헤더가 없고 옛 301이 그대로 나왔습니다. 응답 헤더의 x-hcdn-cache-status: HIT가 원인을 알려 줬습니다. Hostinger CDN이 API 응답까지 캐시하고 있었고, 전환 전의 301도 그대로 들고 있었던 것입니다. 고유한 쿼리를 붙이자 4개 모두 서버에서 바로 200이 오고, 헤더가 있고, 리다이렉트도 없었습니다.

/Lotto/json/latest   cache: HIT  | bypass: HTTP/2 200 x-tlabo-api: v1 DYNAMIC

이건 이전 작업과 상관없이 원래부터 그랬던 상태입니다. 데이터를 갱신해도 캐시가 만료될 때까지 앱에 반영되지 않는 시간이 있다는 뜻이고, 그것만으로도 알아 둘 가치가 있습니다.

다음에도 할 것

  1. 호출되는 URL 목록은 서버 폴더가 아니라 앱 코드에서 만든다
  2. 기준 응답은 리다이렉트를 따라가지 않고, 리다이렉트 자체를 기록한다
  3. 상대 경로 include는 부트스트랩 하나로 바꾸고, 비밀 정보는 공개 영역 밖에 둔다
  4. 리라이트는 "새 파일이 있을 때만" 걸어서, 예상 못 한 경우는 옛 동작으로 돌아가게 한다
  5. 어떤 코드가 응답했는지 보여 주는 헤더를 달고, 검증은 항상 캐시 우회 쿼리를 붙여서 한다

옛 폴더는 구버전 앱만 부르는 무언가가 로그에 나타나는지 보기 위해 2주 동안 서버에 남겨 둡니다. 그 뒤 웹 공개 영역 밖으로 옮깁니다.

자주 묻는 질문

옛 API 경로를 새 경로로 301 리다이렉트하면 안 되나요?

모바일 HTTP 클라이언트는 POST 리다이렉트를 따라가지 않는 경우가 많습니다. Dart의 HttpClient는 GET과 HEAD만 자동으로 따라가므로, POST 엔드포인트가 301을 주면 앱에서는 오류가 됩니다. 내부 리라이트는 옛 URL 그대로 200을 줍니다.

새 파일이 응답하고 있다는 건 어떻게 증명하나요?

새 코드가 해가 없는 응답 헤더(이번에는 X-Tlabo-Api: v1)를 내보내게 하고, 옛 URL에 캐시 우회 쿼리를 붙여 확인합니다. 본문은 바이트 단위로 같아서 앱에는 영향이 없습니다.

SQL 인젝션 수정에 prepared statement를 쓰지 않은 이유는요?

mysqlnd 환경에서 prepared statement는 정수·실수를 숫자 타입으로 돌려주고, query()는 문자열로 돌려줍니다. 바꾸면 JSON의 숫자가 "12"에서 12로 바뀌어 구버전 앱이 읽지 못할 수 있었습니다. 이스케이프는 출력을 그대로 둔 채 취약점을 막습니다.

  • #PHP
  • #htaccess
  • #API
  • #모바일앱
  • #Flutter
  • #회귀테스트

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑