첨부파일 외부 저장소 이동 (bsplus_external_storage)

Rhymix 애드온 lua_external_file을 참고만 하고 모듈로 전면 재설계 — 첨부파일을 로컬 마운트/S3 호환 스토리지/FTP로 자동 이동시켜 서버 용량을 절감

현재 버전 v1.7.5 2026-07-22 ~ 2026-07-23 GPLv2
이틀 동안 v1.0.0 첫 출시부터 v1.7.5까지 총 16번의 버전업이 있었습니다. 처음엔 "저장소 하나 골라서 첨부파일을 그쪽으로 옮기는" 단순한 구조였지만, 실제 운영(Cloudflare R2, 로컬 FileZilla FTP)으로 테스트하면서 발견된 버그를 고치고, "로컬만 쓰던 사람도 S3도 같이 써보고 싶다", "다른 사용자는 계정을 여러 개 쓸 수도 있겠다" 같은 요청을 거치며 로컬+S3+FTP를 동시에 등록하고 우선순위로 자동 전환/failover하는 저장소 체인, 게시판별 저장소 지정, 여기저기 흩어진 파일을 한 곳으로 모으는 통합 도구까지 갖춘 모듈로 성장했습니다.

변화 타임라인

v1.0.02026-07-22 · 첫 출시
모듈로 전면 재설계
저장소 4종(사용 안 함/로컬·마운트/S3/FTP), 업로드 직후 자동 이동, 다운로드·정적 서빙 양쪽에 권한 체크 적용. 이 시점엔 저장소를 하나만 고를 수 있었음.
v1.0.12026-07-22 · 버그 수정
"활성 설정 기준" → "파일별 실제 위치 기준"으로 전환
저장소 종류를 바꾸면 이미 이동해둔 파일들의 삭제/다운로드/이미지 표시가 깨지던 근본 문제 수정. 이후 모든 기능이 이 원칙(파일마다 실제 저장 위치를 기록하고 그걸 기준으로 동작) 위에서 만들어짐.
v1.1.02026-07-22 · 신규 기능
로컬 다중 경로 + 용량 자동 전환
로컬/마운트 경로를 여러 개 등록, 여유 공간이 부족하면 자동으로 다음 경로 사용. 용량 게이지, "여유 공간 확보량(GB)" 설정, # 접두어로 즉시 일시중지 기능 추가.
v1.2.02026-07-22 · 신규 기능
로컬 + S3 + FTP 동시 사용 (우선순위 체인)
단일 선택 라디오 → 1~3순위 드롭다운으로 전면 개편. "1순위=로컬, 2순위=S3, 3순위=FTP"처럼 자유롭게 조합 가능해짐. 로컬은 여유공간 사전 체크, S3/FTP는 업로드 시도 후 실패하면 자동으로 다음 순위로.
v1.2.12026-07-23 · 실전 버그 수정
Cloudflare R2 실연동 테스트로 발견된 버그 3건
설정 저장이 조용히 막히던 문제(댓글로 재현 확인), 게시글 본문 이미지가 S3 이동 후 깨지던 문제, "공개 URL" 옵션이 실제론 작동 안 하던 문제 — 전부 실제 계정으로 테스트하며 발견하고 수정.
v1.3.02026-07-23 · 신규 기능
FTP 공개 URL 옵션 + 보안 경고
FTP도 S3처럼 "PHP 스트리밍" / "공개 URL" 선택 가능. 단, 공개 URL은 권한 체크를 완전히 우회하므로 관리자 화면에 경고 문구 명시.
v1.4.02026-07-23 · 신규 기능
게시판별 저장소 지정
특정 게시판만 사이트 전체 설정과 다른 저장소를 쓰도록 지정하는 3번째 관리 탭 "저장소 지정" 추가.
v1.5.02026-07-23 · 신규 기능
데이터 이전 및 통합
여기저기 흩어진 파일을 목표 저장소 하나로 모으는 4번째 탭. 다운로드→업로드 왕복 작업이라 트래픽 경고 문구 포함, 한 번에 최대 30건 처리.
v1.6.02026-07-23 · 신규 기능
S3/FTP 계정 3개까지 등록
"다른 이용자는 Cloudflare R2 + Google Cloud를 동시에 쓰고 싶을 수도 있겠다"는 논의 끝에, S3/FTP를 각각 3개 계정까지 등록 가능하도록 확장. 1번 계정은 기존 필드 그대로 사용해 하위호환 100%.
v1.7.02026-07-23 · UX 개선
탭 배지 + 현재 탭 표시 + 우선순위 7단계
처리 필요 항목 수를 탭에 배지로 표시, 현재 탭 강조, 저장소 종류가 7개로 늘어난 만큼 우선순위도 7단계까지 지정 가능하도록 확장.
v1.7.12026-07-23 · 개선
미이동 파일 개수 표시
"데이터 이전 및 통합" 현황 표에 "기본 홈페이지 디렉토리(미이동)" 열 추가 — 지금 화면 아래 스크린샷 참고.
v1.7.22026-07-23 · 보안 수정
최종 점검 중 발견 — 삭제된 게시글의 원격 삭제 실패 파일이 계속 서빙되던 문제
게시글/댓글을 삭제하면 코어가 files 테이블 행을 즉시 지우지만, 원격(S3/FTP) 삭제가 실패해 delete_failed로 매핑이 남아있는 파일은 권한 체크 로직이 "레코드 없음 = 확인 불필요 = 허용"으로 잘못 판단해 삭제된 게시글의 첨부파일이 URL만 알면 계속 열람 가능했음. files 행이 없으면 항상 차단하도록 수정. 같은 점검에서 배지/안내문 카운트가 1000건을 넘으면 실제보다 적게 표시되던 부정확도 함께 수정.
v1.7.32026-07-23 · 재검토 중 발견
데이터 이전 및 통합이 "여유 공간 확보량" 예약분을 무시하던 문제
새 파일 자동 저장은 여유 공간 확보량(GB) 예약분을 항상 지키는데, "데이터 이전 및 통합"으로 로컬 목표를 고른 경우엔 이 확인 없이 그냥 밀어넣고 있었음 — 반복 실행하면 백업용으로 남겨두려던 공간까지 채울 수 있었음. 파일을 올리기 직전에 여유 공간을 확인해서, 예약분 밑으로 내려가면 그 시점에서 중단하고 안내하도록 수정.
v1.7.42026-07-23 · 전체 파일 재검토
트리거 예외 처리 강화 + 경로 처리 정확도 개선
PHP 파일 12개·쿼리 XML 16개 전부를 다시 읽으며 진행한 재검토. 4개 트리거가 catch(\Exception)로만 감싸여 있어 PHP의 Error/TypeError 계열은 놓치고 있던 것을 catch(\Throwable)로 방어 범위 확장. 점검 중 v1.7.2에서 고친 시나리오가 getMapByOriginalPath 쿼리의 status='migrated' 필터 덕에 애초에 그 경로로는 도달하지 않는다는 것도 재확인(그래도 "트리거가 끝까지 못 돌아 상태 갱신이 누락된" 케이스에는 여전히 유효한 방어). buildRemotePath()ltrim() 마스크 사용 방식도 더 정확한 방식으로 교체(현재 데이터로는 문제 된 적 없는 사전 예방적 수정).
v1.7.52026-07-23 · 외부 검토 반영
LocalDriver 경로 검증의 고전적인 prefix 매치 우회 패턴 보강
외부 검토에서 지적받은 사항. streamFile()/downloadToLocal()strpos($real, $realStorage) !== 0로만 경로 포함 여부를 확인하고 있어, $realStorage/data/storage일 때 /data/storage_evil/x 같은 형제 디렉터리도 prefix가 겹쳐 통과할 수 있는 구조였음. 현재는 $remotePath가 사용자 직접 입력이 아니라 매핑 테이블 값이라 실사용 위험은 낮지만, 향후 재사용 대비 사전 방어로 구분자 포함 비교 방식으로 교체. CLI로 정상 접근/traversal 차단 양쪽 다 직접 검증.

현재 화면 (v1.7.1 촬영 — v1.7.2는 화면 변화 없는 내부 로직 수정)

저장소 설정 화면
① 저장소 설정 — 우선순위 7단계, 로컬 저장소 현황(용량 게이지), S3/FTP 계정 3개씩 접이식 등록. 지금 실제로 로컬(1순위)·S3 계정1(2순위)·FTP 서버1(3순위)이 살아있는 상태.
저장소 지정 화면
② 저장소 지정 — 게시판별로 다른 저장소를 지정하는 화면. 아직 지정된 게시판은 없는 상태(전부 사이트 전체 설정을 따름).
데이터 이전 및 통합 화면
③ 데이터 이전 및 통합 — 저장소별 파일 개수 요약(로컬 3070건, S3 계정1 4건, FTP 서버1 2건, 미이동 0건)과 통합 실행 폼. 접이식 도움말도 함께 제공.

전체 패치리스트

v1.7.52026-07-23
외부 검토 반영: LocalDriver 경로 검증의 고전적인 prefix 매치 우회 패턴 보강
  • streamFile()/downloadToLocal()realpath() 결과가 저장소 경로 안에 있는지 strpos($real, $realStorage) !== 0로만 확인 — $realStorage/data/storage일 때 /data/storage_evil/x 같은 형제 디렉터리도 문자열 prefix가 겹쳐 통과하는 고전적인 우회 패턴이었음.
  • 현재 코드 흐름상 $remotePath는 사용자가 직접 넘기는 값이 아니라 매핑 테이블에 기록된 값이라 실제 악용 가능성은 낮으나, 향후 이 드라이버가 다른 입력 경로에 재사용될 경우를 대비해 strpos($real, $realStorage . DIRECTORY_SEPARATOR) !== 0 && $real !== $realStorage 방식으로 교체.
  • CLI 스크립트로 직접 검증: 정상 파일 접근은 그대로 동작, 형제 디렉터리를 노리는 traversal 시도는 새 로직에서 정확히 차단됨을 확인.
v1.7.42026-07-23
전체 파일 재검토(쿼리 16개 전부 포함) — 트리거 예외 처리 강화 + 경로 처리 정확도 개선
  • 4개 트리거가 전부 catch(\Exception $e)로만 감싸여 있었음. PHP 7+의 TypeError/Error 계열은 \Exception이 아니라 \Throwable이라 이 catch에 걸리지 않아, 드라이버 쪽 Error 발생 시 트리거가 도중에 끊기고(삭제 트리거는 delete_failed 기록도 못 남긴 채) 요청 전체가 죽을 수 있었음. catch(\Throwable $e)로 전부 교체.
  • 점검 확인: v1.7.2의 delete_failed 시나리오는 getMapByOriginalPath 쿼리가 status='migrated'만 조회하도록 이미 필터링돼 있어 그 경로로는 도달하지 않음을 재확인 — 이번 예외 처리 강화와 결합했을 때 여전히 유효한 방어(트리거가 끝까지 못 돌아 상태 갱신이 누락되는 "유령 매핑" 케이스).
  • buildRemotePath()ltrim($str, './')는 "./" 리터럴이 아니라 문자 마스크로 동작해 과도하게 잘릴 여지가 있었음(현재 실제 경로 형식에서는 문제 된 적 없음) — substr() 기반 리터럴 접두어 제거로 교체.
  • PHP 파일 12개 전체, 쿼리 XML 16개 전체, 스키마 2개, 관리자 템플릿 4개, guest 접근 가능한 공개 스킨, lang 파일, module.xml 권한 테이블(액션 12개) 전부 재확인 — 추가 문제 없음.
v1.7.32026-07-23
수정: 데이터 이전 및 통합이 "여유 공간 확보량" 예약분을 무시하던 문제 (재검토 중 발견)
  • 새 파일이 자동으로 로컬 경로에 저장될 때는 pickLocalStorageRoot()가 "여유 공간 확보량(GB)"을 항상 지켜서 그 밑으로는 저장하지 않음. 그런데 "데이터 이전 및 통합"으로 로컬 경로를 목표로 고르면 이 확인 없이 그냥 밀어넣고 있었음 — 반복 실행 시 백업 등을 위해 남겨두려던 예약 공간까지 채울 수 있었음.
  • 파일 하나를 목표 로컬 경로에 올리기 직전에 여유 공간을 확인하도록 추가. 예약분 밑으로 내려가면 그 시점에서 중단하고 안내 메시지 표시.
v1.7.22026-07-23
보안 수정: 삭제된 게시글의 원격 삭제 실패 파일이 계속 서빙되던 문제 (최종 점검 중 발견)
  • 게시글/댓글이 삭제되면 코어가 files 테이블 행을 즉시 삭제(action="delete" 쿼리 직접 확인)하지만, 원격(S3/FTP) 삭제가 실패해 delete_failed 상태로 매핑이 남아있으면 정적 서빙 폴백의 isServeFilePermitted()가 "파일 레코드를 못 찾음 → 권한 체크 대상 아님 → 허용"으로 잘못 처리하고 있었음. 결과적으로 게시글이 삭제됐어도 원격 삭제만 실패한 첨부파일은 URL을 아는 사람에게 계속 노출되는 상태였음.
  • files 행이 없으면 이제 항상 차단하도록 수정 — 관리자가 매핑 목록에서 "삭제 재시도"로 원격 삭제를 완료하기 전까지는 아무에게도 서빙되지 않음.
  • 같은 점검에서 탭 배지·레거시 안내문의 건수가 최대 1000건까지만 세는 리스트 조회를 재사용하고 있어 1000건을 넘으면 실제보다 적게 표시되던 문제도 함께 발견 → count 전용 쿼리(getLegacyFileCount())로 교체.
v1.7.12026-07-23
개선: "데이터 이전 및 통합" 현황에 미이동 파일 개수 추가
  • 저장소별 파일 개수 요약 표에 "기본 홈페이지 디렉토리(미이동)" 열 추가 — 아직 모듈이 관리하지 않는(설치 이전 파일 + "이동 안 함" 게시판 지정) 파일 개수를 정확히 표시.
  • 배지용 카운트(최대 1000건 제한)와 별도로, count 쿼리 기반의 정확한 전체 건수를 반환하는 getLegacyFileCount() 추가.
v1.7.02026-07-23
관리자 화면 UX 개선: 탭 배지 + 현재 탭 표시 + 우선순위 7단계
  • "매핑 목록 / 레거시 정리" 탭에 처리 필요 항목 수(레거시+실패+삭제실패)를 빨간 배지로 표시.
  • 4개 관리자 탭 전부 현재 보고 있는 탭을 파란색 밑줄로 강조.
  • 저장소 우선순위를 3단계 → 7단계로 확장 (저장소 종류가 최대 7개로 늘어난 것에 맞춤). 기존 3단계 설정은 하위호환 유지.
v1.6.02026-07-23
신규 기능: S3/FTP 계정 3개까지 등록
  • S3 호환 스토리지, FTP 서버를 각각 3개까지 동시 등록 (예: 계정1=Cloudflare R2, 계정2=Google Cloud Storage, 계정3=Backblaze B2).
  • 우선순위/게시판 지정/데이터 통합 화면의 저장소 선택지가 "S3 계정 1/2/3", "FTP 서버 1/2/3"으로 세분화.
  • 1번 계정은 기존 필드 그대로 사용 — 하위호환 100%, 마이그레이션 불필요. 2/3번만 s3_2_*/s3_3_* 등 새 접두어 필드 사용.
  • 설정 화면에 계정별 접이식(<details>) 섹션 제공.
v1.5.02026-07-23
신규 기능: 데이터 이전 및 통합
  • 4번째 탭 "데이터 이전 및 통합" 추가 — 로컬/S3/FTP에 뒤섞인 파일을 목표 저장소 하나로 이전.
  • 동작 방식: 기존 위치에서 다운로드 → 목표 위치에 업로드 → 성공 확인 후에만 기존 위치에서 삭제 → 매핑 갱신.
  • 각 드라이버에 downloadToLocal() 추가 (S3=인증 GET, FTP=ftp_get(), 로컬=파일 복사).
  • 경고 문구: 다운로드+업로드 왕복으로 트래픽/시간 소모됨을 명시(AWS S3·Backblaze B2 과금 가능성, 홈서버 대역폭 점유 가능성). 한 번에 최대 30건.
  • 웹호스팅 사용자도 사용 가능(임시 폴더만 사용) — "로컬(지정 경로)" 목표만 홈서버 전용.
v1.4.02026-07-23
신규 기능: 게시판별 저장소 지정
  • 3번째 탭 "저장소 지정" 추가 — 게시판마다 이동 안 함/로컬/S3/FTP 중 하나를 개별 지정.
  • 지정된 게시판은 사이트 전체 우선순위 체인을 무시하고 지정된 저장소만 사용.
  • 새 테이블 bsplus_external_storage_board 추가 (관리자 화면에서 "설치" 버튼 클릭 필요했던 변경).
  • 레거시 마이그레이션 실행 시에도 게시판별 지정 반영.
  • 게시판 추가는 "아직 지정 안 된 게시판" 드롭다운에서 하나씩 선택하는 방식으로 단순화.
v1.3.02026-07-23
신규 기능: FTP 공개 URL 옵션
  • FTP도 S3처럼 "PHP 스트리밍(기본)" / "공개 URL" 선택 가능 — FTP 루트가 동시에 웹 루트인 저가형 호스팅에서 서버 부담 절감 목적.
  • 보안 경고: 공개 URL은 권한 체크(isDownloadable(), 게시글/댓글 읽기 권한)를 완전히 우회함을 관리자 화면에 명시. 완전 공개 게시판에서만 권장.
v1.2.12026-07-23
중요 수정 (실제 R2 연동 테스트로 발견)
  • 설정 저장이 조용히 막히던 버그 — 우선순위에 넣었지만 세부 정보가 아직 부족한 저장소가 있으면 저장 자체를 막고 있었음. 이제 항상 저장되고 참고 메시지만 표시.
  • S3로 이동된 파일이 게시글 본문에서 깨지던 버그 — 다운로드 버튼 경로에만 있던 signed URL 리다이렉트를 정적 서빙 폴백(triggerCheckServeFile)에도 추가.
  • S3 "공개 URL" 옵션이 실제로는 동작하지 않던 문제 — S3Driver가 관련 설정을 아예 읽지 않고 있었음. 실제로 반영되도록 수정.
v1.2.02026-07-22
신규 기능: 로컬 + S3 + FTP 동시 사용 (우선순위 체인)
  • 단일 선택(라디오) → 1/2/3순위 드롭다운 3개로 전면 개편.
  • 로컬은 여유공간 사전 확인, S3/FTP는 업로드 시도 후 실패 시 자동으로 다음 순위 전환 (attemptChainUpload()).
  • 각 파일은 실제로 성공한 저장소를 그대로 기록 — 활성 설정이 아니라 시도 결과 기준.
  • 연결 테스트 버튼이 로컬/S3/FTP 섹션별로 독립 동작.
  • 예전 단일 storage_type 설정은 하위호환으로 1순위 자동 승격.
v1.1.02026-07-22
신규 기능: 로컬/마운트 다중 경로 (용량 부족 자동 대응)
  • 로컬/마운트 저장소를 여러 개(우선순위 순) 등록 가능. 기존 단일 local_path는 하위호환.
  • disk_free_space()로 여유 공간 확인 → 부족하면 자동으로 다음 순위 경로 사용. 전부 꽉 차면 failed 상태로 사전 감지.
  • 매핑 테이블에 storage_root 컬럼 추가 — 파일별 실제 저장 경로 기록.
  • 저장소 현황(용량 게이지, 70%↑ 주황·90%↑ 빨강) 추가.
  • # 접두어로 경로를 즉시 일시중지하는 기능 추가.
  • "여유 공간 확보량(GB)" 설정 추가(기본 5GB) — 백업 등을 위한 여유 공간 확보 목적.
v1.0.12026-07-22
중요 수정
  • 저장소 종류를 바꾸면 기존 파일이 깨지던 버그 — 트리거들이 "현재 활성 저장소" 기준으로 드라이버를 생성하고 있었음. StorageDriverFactory::create()에 저장소 종류를 명시적으로 넘기고, 각 트리거는 파일별 기록된 storage_type을 사용하도록 수정.
v1.0.02026-07-22
첫 출시 — 애드온을 참고만 하고 모듈로 완전히 새로 설계
  • 핵심 기능: 저장소 4종(사용 안 함/로컬·마운트/S3/FTP), 업로드 직후 자동 이동, 삭제 시 원격 동기화, 썸네일은 항상 로컬 보관.
  • 보안: 정적 서빙 폴백과 다운로드 버튼 양쪽에 isDownloadable() + 게시글/댓글 읽기 권한 체크, 경로 순회 방지.
  • 이번 버전 버그 수정: 404 유발하던 존재하지 않는 함수 호출, printNavigation() 미존재, foreach(range()) 템플릿 컴파일 오류, class="admin" 오용, 다운로드 버튼 원격 파일 미지원, 관리자 탭 미연결, FTP 확장 미설치 가드.
  • 알려진 제한: S3 다운로드 파일명 강제 불가, 다중 저장소 미지원(이후 v1.2.0에서 해소), WebDAV/SFTP 미지원, 한국어 언어팩만 존재.

지원 환경

PHP7.4 이상 (Rhymix 공식 최소 요구사항), 8.2 이상 권장. 상한선 없음.
Rhymix2.0.0 이상 권장. 실제 설치·테스트는 2.1.35에서 진행.
라이선스GPLv2 (Rhymix 코어 및 다른 BSplus 모듈과 동일)