Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

연구기관 웹사이트 PHP 버전 개발기 (1) — 조직도 관리와 관리자 편집 기능 본문

외주 개발일지

연구기관 웹사이트 PHP 버전 개발기 (1) — 조직도 관리와 관리자 편집 기능

HM소프트 2026. 6. 14. 23:09
반응형

운영 중인 레거시 PHP 사이트를 형상관리에 올리며 PDO 싱글톤과 EMULATE_PREPARES false로 SQL 인젝션을 구조적으로 차단하고, 평면 organization_units 테이블을 sort_order 기준 트리로 세운다. mb_str_split과 HTML 이스케이프 순서가 뒤바뀌어 팀명이 깨지던 버그, page_contents 재사용으로 스키마 변경 없이 푸터를 관리자 편집화한 과정을 다룬다.

운영 중인 PHP 사이트를 형상관리에 올리다

앞서 다른 시리즈에서 한 연구기관 웹사이트를 모던 스택으로 다시 만든 과정을 기록했는데, 같은 기관에는 PHP로 만들어진 버전의 사이트가 따로 하나 더 있다. 운영 서버에서 실제로 돌아가고 있는 코드라, 첫 작업은 기능 추가가 아니라 운영 중인 코드를 통째로 저장소에 올리는 일이었다.

레거시 PHP 프로젝트에서 흔한 풍경이다. 코드는 FTP로 서버에 직접 올라가고, 버전 기록은 없고, "지금 서버에 있는 게 최신"인 상태. 여기서 뭔가 고치려면 먼저 현재 상태를 박제해야 한다. 초기 커밋으로 전체 코드를 올리고 나서야 비로소 "무엇이 바뀌었는지"를 추적할 수 있게 됐다. 실제로 작업 중간에도 운영 반영 완료분 동기화라는 커밋이 한 번 끼어 있는데, 운영 서버에 먼저 들어간 수정을 저장소가 뒤따라가는 — 레거시 운영의 현실이 그대로 드러나는 흔적이다.

이번 글에서는 그 위에서 진행한 작업을 순서대로 다룬다. 조직도 데이터 구조와 출력 버그, 관리자 편집 기능, 그리고 보안·동선 개선까지. 화면 한두 개를 고치는 것처럼 보였지만, 실제로는 데이터 모델과 출력 계층을 함께 들여다봐야 하는 일이었다.

시스템을 한눈에 — 라우팅과 계층

먼저 이 사이트가 어떻게 생겼는지부터 짚자. 프레임워크 없이 짠 전형적인 PHP 사이트지만, 나름의 계층이 잡혀 있었다. 단일 진입점(router.php)이 요청을 받아 페이지 또는 API로 분기하고, 공통 기능은 includes/ 아래 함수들로 모여 있다.

클래스도 ORM도 없다. 대신 getDB(), dbQuery(), getOrgTree(), getPageContent() 같은 전역 함수의 얇은 층이 DB와 화면 사이를 잇는다. 레거시를 개선할 때는 이 구조를 존중하는 게 낫다. 갑자기 프레임워크를 끼얹는 순간 운영 중인 모든 화면이 위험해지기 때문이다. 그래서 이번 작업의 원칙은 "구조는 그대로 두고, 같은 패턴 안에서 고친다"였다.

얇은 DB 레이어부터 확인

가장 먼저 본 건 DB 접근 계층이다. 모든 쿼리가 여기를 지나가므로, 여기가 어떻게 생겼는지가 곧 이 사이트의 안정성을 좌우한다.

작지만 중요한 결정들이 들어 있다. static $pdo로 요청당 연결을 한 번만 맺고(싱글톤), ATTR_ERRMODE => EXCEPTION으로 DB 오류를 조용한 false 대신 예외로 터지게 했다. 무엇보다 ATTR_EMULATE_PREPARES => false — 진짜 prepared statement를 쓰게 해서, 모든 쿼리가 dbQuery($sql, $params) 형태로 파라미터 바인딩을 강제한다. 즉 SQL 인젝션을 구조적으로 막는 통로가 이 한 줄에 걸려 있다. 레거시에서 가장 무서운 게 문자열 이어붙인 쿼리인데, 이 사이트는 다행히 이 래퍼로 통일돼 있었다.

조직도 — 평면 테이블을 트리로 세우기

기관 사이트의 단골 메뉴인 조직도가 이번 작업의 중심이었다. 데이터는 organization_units 한 테이블에 평면적으로 들어 있고, 각 행은 unit_type(이사·본부·팀)과 parent_id, sort_order를 가진다. 화면에 그리려면 이 평면 데이터를 트리로 재구성해야 한다. 그 일을 하는 게 getOrgTree()다.

한 번의 쿼리로 전부 읽은 뒤 메모리에서 타입별로 가른다. 본부(DIVISION)마다 자기 idparent_id로 가진 팀들을 묶고, 본부와 팀 모두 sort_order로 정렬한다. 흐름으로 그리면 이렇다.

여기서 정렬 기준이 sort_order 하나로 통일돼 있다는 점이 중요하다. 첫 손질이 바로 이 정렬·간격 문제였기 때문이다. 부서 사이 간격이 지나치게 벌어져(Division 간격 56px) 조직 규모에 비해 화면이 휑했고, 팀 배치도 의도와 달랐다. 간격을 24px로 줄이고 본부 최대 폭을 좁히고, 팀 정렬을 가운데 정렬로 바꿔 빽빽하지도 헐겁지도 않은 밀도를 맞췄다. 이건 순수 레이아웃 작업이라 데이터엔 손대지 않았다 — 그런데 정렬을 손보고 나니 가려져 있던 진짜 버그가 드러났다.

팀명이 안 보이던 버그 — escape와 split의 순서

일부 본부에서 팀명이 깨지거나 아예 안 나오는 현상이 있었다. 조직도의 팀 박스는 폭이 좁은 세로 막대라, 팀 이름을 한 글자씩 세로로 쌓아 표시한다. 그래서 이름 문자열을 글자 단위로 쪼개는 mb_str_split을 쓴다. 문제는 쪼개는 순서였다.

언뜻 멀쩡해 보인다. 하지만 e()는 HTML 이스케이프 함수다. 팀명에 & 같은 문자가 하나라도 있으면 e()가 먼저 &로 바꿔 놓고, 그 결과를 mb_str_split글자 단위로 다시 쪼갠다. 그러면 &&, a, m, p, ; 다섯 칸으로 분해돼 세로줄에 쏟아진다. 이름 하나가 의미 없는 기호 줄로 변하고, 박스 높이 계산까지 어긋나 다른 팀명 출력에도 영향을 줬다. 고친 방향은 단순하지만 순서가 정반대다.

원본 문자열을 먼저 글자 단위로 쪼개고, 출력하는 순간에만 글자 하나하나를 이스케이프한다. 이러면 &는 온전한 한 글자로 쪼개진 뒤 &로 안전하게 출력된다. 보안(XSS 방지)도 그대로 지키면서 표시도 정상이 된다. 교훈은 분명하다. 이스케이프는 "출력 직전"에, 가공은 "원본"에서. 이 순서를 뒤집으면 인코딩된 문자열을 데이터처럼 다루게 되고, 멀티바이트·엔티티가 섞이는 순간 깨진다.

죽지 않고 원인을 말하게 — 관리자 오류 처리

같은 영역의 관리 화면(org_edit.php)에도 손볼 게 있었다. 조직 단위를 추가·수정·삭제하는 폼인데, 두 가지가 부족했다. 첫째, CSRF 토큰 검증이 실패해도 조용히 넘어가 사용자가 왜 저장이 안 되는지 알 수 없었다. 둘째, DB 작업이 예외를 던지면 화면이 그냥 하얗게 죽었다.

그래서 CSRF 실패 시 명확한 오류 메시지를 띄우고, add/edit/delete 각각을 try/catch로 감싸 DB 오류의 원인을 화면에 노출하도록 바꿨다. 운영자가 "안 돼요"가 아니라 "이런 오류가 떴어요"라고 말할 수 있게 하는 것 — 관리자 페이지에서 이 차이는 생각보다 크다. 디버깅 왕복이 한 번에 끝난다.

이 변경은 앞의 DB 레이어 설계와도 맞물린다. getDB()ERRMODE_EXCEPTION으로 예외를 던지도록 해 뒀기 때문에, 관리 화면에서 try/catch만 두르면 곧바로 의미 있는 오류 처리가 된다. 계층이 일관되면 위층의 개선이 쉬워진다.

푸터, 하드코딩에서 데이터로 — 스키마 변경 없이

다음은 사이트 하단 푸터다. 기관 전체명·영문명·저작권 문구·주소·연락처가 들어가는데, 기존에는 이게 다국어 번역 함수 t() 값으로 코드에 박혀 있었다. 전화번호 하나 바뀌어도 개발자가 코드를 고쳐 올려야 했다.

이걸 관리자가 직접 편집할 수 있게 바꿨는데, 핵심은 "스키마를 건드리지 않았다"는 점이다. 이 사이트에는 이미 정적 페이지의 문구를 key-value로 저장하는 page_contents 테이블과 헬퍼(getPageContents, upsertPageContent)가 있었다. 푸터 설정도 그냥 footer라는 슬러그로 같은 테이블에 얹었다. 새 테이블도, 마이그레이션도 필요 없었다.

$fcv 클로저가 패턴의 전부다. DB에 값이 있고 비어 있지 않으면 그 값을, 아니면 기존 번역값을 쓴다. 덕분에 관리자가 아직 손대지 않은 항목은 예전과 똑같이 동작하고(무중단), 편집한 항목만 DB값으로 덮인다. 한 가지 더 신경 쓴 건 출처 단일화다. 주소·전화·이메일은 푸터 슬러그가 아니라 about_location 슬러그에서 읽는다. "찾아오시는 길" 페이지와 같은 데이터를 보게 해서, 주소가 두 군데서 따로 노는 일을 막았다. 레거시 개선에서 가성비가 가장 좋은 작업은 이렇게 코드에 박힌 운영 정보를 데이터로 빼내되, 출처를 하나로 모으는 일이라고 생각한다.

보안 한 걸음 — 비밀번호를 코드에서 들어내다

형상관리에 올리는 김에 위험한 것 하나를 정리했다. 기존 setup_db.php에는 DB 접속 비밀번호가 하드코딩돼 있었다. 저장소에 그대로 올라가면 이력에 영구히 박힌다. 그래서 실제 접속 정보가 든 config.php.gitignore로 저장소에서 제외하고, 빈 템플릿인 config.example.php를 대신 제공했다. setup_db.php도 비밀번호를 직접 쓰는 대신 config의 상수(DB_USER, DB_PASS)를 참조하도록 바꿨다.

거창한 보안 작업은 아니지만, 비밀 정보와 코드를 분리하는 이 작은 습관이 사고를 막는다. 레거시를 저장소로 옮기는 첫 순간이야말로 비밀 정보를 들어낼 마지막 기회에 가깝다 — 한 번 커밋되면 되돌리기 번거롭기 때문이다.

로그인 뒤의 한 걸음 — 기업메일 바로가기

마지막은 작은 동선 개선이다. 이 사이트의 회원(구성원)들은 로그인 후 기업메일을 쓰러 가는 일이 잦은데, 매번 메일 주소를 따로 쳐서 들어가고 있었다. 그래서 로그인한 일반 회원에게는 헤더에 기업메일 바로가기 버튼을, 관리자에게는 기존 관리자 페이지 버튼을 보여주도록 권한별로 분기했다. 메일 주소는 WEBMAIL_URL 상수로 빼고 헤더에 기본값 안전장치를 두어, 설정이 비어 있어도 화면이 깨지지 않게 했다.

기능 크기로 보면 버튼 하나지만, 누가 이 사이트를 매일 쓰는지 생각하면 우선순위가 달라진다. 외부 방문자는 가끔 오지만 구성원은 매일 드나든다. 매일 쓰는 사람의 클릭 수를 줄이는 것이 체감 만족도에 직결된다.

정리하며

  • 레거시 PHP 개선의 시작은 운영 코드를 저장소에 박제하는 것. 추적 가능해야 고칠 수 있고, 그 순간이 비밀 정보를 들어낼 적기다.
  • DB 레이어의 EMULATE_PREPARES => false + 파라미터 바인딩 래퍼가 SQL 인젝션을 구조적으로 차단한다. 위층 개선은 이 일관성 위에서 쉬워진다.
  • 조직도는 평면 테이블을 getOrgTree()로 트리화하고 sort_order 하나로 정렬을 통일했다.
  • 팀명 깨짐의 원인은 mb_str_split(e($name))이스케이프 후 분리였다. 가공은 원본에서, 이스케이프는 출력 직전으로 순서를 바로잡아 해결했다.
  • 푸터는 기존 page_contents 테이블을 재사용해 스키마 변경 없이 관리자 편집화했고, 연락처는 출처를 단일화했다.
  • 버튼 하나라도 매일 쓰는 사용자의 동선을 줄이면 체감 효과가 크다.
반응형