코어를 건드리지않고 확장하는 CodeIgniter 4 플러그인 구조와 핵심 설계 포인트
시리즈 제품 개발기 현재 위치 19 / 19
- 엑셀 간편장부를 웹 ERP로 옮기기 (AICassa)
- 혼자 쓰는 맛집 앱 Pagus 만들기
- 감으로 잡던 견적을 코드로: CNC 목공과 AILign 개발 이야기
- AIPicto: 도연님의 그림책을 위한 온라인 구독 플랫폼
- PHP 개발자, TypeScript와 R3F로 3D 가구 배치 앱 만들기
- 녹화형 AI 화상면접 솔루션 기획 노트 — AILego
- AIvance 제품 이름은 왜 라틴어일까: Cassa에서 Cella까지
- 1인 개발자가 11개 SaaS를 운영하는 비밀: 공통 골격과 MSA 분할 전략
- "난이도가 높아서 오래 걸리는 거야?" — AIFid 개발 기록으로 따져 본 소요 시간
- 개별로는 통과하는데 전체에서만 죽는다 — AIFid 테스트 스위트가 composer test를 멈춘 날들
- 1인 개발자가 클로드코드로 SaaS 11개 만든 기록
- 도면을 AI에게 읽히려다 AI를 뺀 이야기
- 팬리스 우분투로 DC 모터를 인터넷에서 제어하기
- 저장소 아키텍처를 archify로 그려 README에 링크한 날
- Apache 403 해결과 php spark 로 슈퍼관리자 자동 발급하기
- UX와 보안을 동시에 잡는 그림책 게스트 열람 방안
- 혼자 하는 유지보수, 단가와 한도를 어떻게 정할까
- SEO + GEO 동시 적용: 정적 페이지에서 AI 검색 노출까지 한 방법
- 코어를 건드리지않고 확장하는 CodeIgniter 4 플러그인 구조와 핵심 설계 포인트
시리즈 블로그 기능 추가 현재 위치 14 / 14
- 블로그 기능을 하나씩 직접 만들기 ① 시리즈 내비게이션
- 블로그 글 목차(TOC)를 JS 70줄로 직접 만들기
- 블로그 기능을 하나씩 직접 만들기 3 — CodeIgniter 4로 RSS 피드 만들기
- 글마다 다른 OG 썸네일을 GD로 자동 생성하기 (CodeIgniter 4)
- 스캐너 IP 자동 차단: 404 3회 일배치와 즉시 차단, 오차단 방어까지
- 워드프레스 이관 후 깨진 이미지, 태그째 정리하기 (CodeIgniter 4)
- Groq로 글 태그·시리즈 자동 추천: 환각 필터와 호출 제한
- CodeIgniter 4 테마 폴백으로 애드센스 자동 삽입하기
- 정규식 기반 마크다운 렌더러 구현기와 보안 고찰
- 블로그 개선 10개를 이슈로 쪼개 병렬로 만들며 배운 것
- Impeccable 스킬 적용한 게시글 상세 화면 구조 개선
- CMS 에디터가 내 CSS를 지웠다 — 소개 페이지 삽질기
- 하루 만에 구현한 9가지 블로그 운영 기능과 설계 기준
- 코어를 건드리지않고 확장하는 CodeIgniter 4 플러그인 구조와 핵심 설계 포인트
요약: CodeIgniter 4 기반 사이트 템플릿(AICreo)에 기능을 ZIP 하나로 넣고 뺄 수 있는 플러그인 구조를 8단계 PR로 나눠 만들었다. 오토로더가 만들어지는 부팅 시점엔 DB가 없다는 문제를 활성 목록 캐시 파일로 풀었고, 라우트 우선순위·마이그레이션 롤백·ZIP 설치 보안에서 마주친 함정을 정리했다. 위젯 슬롯과 날씨 위젯 샘플까지 올려 구조가 실제로 도는 것을 확인했다.
왜 플러그인인가
AICreo는 1인 웹에이전시용 CodeIgniter 4 기업 사이트 템플릿이다. 게시판, 문의 폼, 관리자 화면이 이미 있는데, 고객마다 요구가 달라 기능을 코어에 계속 얹으면 코어가 커지고 고객별로 갈라진다.
목표는 하나였다. 코어는 건드리지 않고 기능을 독립 모듈 단위로 추가·제거한다. 참고 구현은 같은 AICreo를 복사해 먼저 플러그인 인프라를 올려 둔 AILign 저장소였고, 그 설계를 AICreo 본체에 이식하는 방식으로 진행했다.
범위는 단계로 나눴다.
- 1단계 인프라: 스캔·등록·활성화·제거, 부팅 로딩, ZIP 설치, 관리자 화면
- 2단계 Entitlement: 회원별 보유 권한, 만료된 구독은 읽기 전용
- 3단계 과금: 이식 대상 아님
핵심 설계 결정 4가지
1. App\Plugins가 아니라 Plugins\{Name} 네임스페이스
CI4 마이그레이션 러너는 네임스페이스 단위로 동작한다. 플러그인을 App\ 아래에 두면 코어 마이그레이션과 한 덩어리가 되어 플러그인별 실행·롤백이 안 된다. 별도 네임스페이스여야 플러그인 하나만 마이그레이션할 수 있다.
php spark migrate -n "Plugins\Memo"
디렉터리는 app/Plugins/{PascalCase}/이고, 이름은 plugin.json의 key(snake_case)를 PascalCase로 바꾼 값이어야 한다(ai_quote → AiQuote). 어긋나면 매니페스트 검증에서 떨어진다.
2. 부팅 시점엔 DB가 없다 — 활성 목록을 파일로 투영
가장 먼저 부딪힌 문제다. Config\Autoload는 오토로더가 만들어지기 전에 인스턴스화된다. 그런데 어떤 플러그인이 활성인지 알아야 PSR-4 경로를 등록할 수 있다. 이 시점엔 DB도 서비스도 없다.
그래서 진실과 투영을 나눴다.
- 운영 상태의 진실: DB
plugins테이블 - 부팅용 투영:
writable/plugins/active.php(return ['quote' => 'Quote'];)

세부 규칙은 이렇다.
- 파일이 없거나 깨졌거나 배열이 아니면 빈 배열 = 전부 비활성. 부팅 실패보다 안전한 실패다.
- 쓸 때는 임시 파일에 쓰고
rename으로 원자적 교체한 뒤opcache_invalidate. - 로더(
active_plugins.php)는 클래스가 아니라 클로저를 반환하는 평범한 파일이다. 오토로더 이전에require해야 하기 때문이다. - 디렉터리명은
^[A-Z][A-Za-z0-9]*$만 허용한다. 캐시가 변조돼도../같은 경로 탈출이 안 되게 하려는 것이다.
코어 수정은 Config\Autoload 생성자에서 이 파일을 읽어 $this->psr4에 병합하는 부분 하나뿐이다.
3. 플러그인 라우트는 catch-all 직전에 명시적으로 수집
CI4는 모듈의 Config/Routes.php를 코어 라우트 뒤에 자동 수집한다. AICreo 코어 마지막 줄이 (:segment) catch-all(동적 페이지)이라, 자동 수집된 플러그인 라우트는 전부 동적 페이지 404로 떨어진다.
- 플러그인 라우트 파일 이름을
Config/Routes.php가 아니라 **플러그인 루트의Routes.php**로 둔다(자동 탐색 대상에서 빠진다). - 코어
Routes.php가 catch-all 직전에service('pluginManager')->loadRoutes($routes)를 호출한다. - 이 저장소엔 catch-all이 두 개다(다국어용
{locale}/(:segment)루프와(:segment)). 두 개 모두보다 위에 넣어야 한다.
Events.php도 같은 방식으로 app/Config/Events.php에서 loadEvents()로 수집한다. 활성일 때만 읽으므로 비활성 플러그인의 이벤트 구독은 존재하지 않는다.
4. 비활성화는 데이터 삭제가 아니다

비활성화는 라우트·이벤트만 내리고 마이그레이션과 테이블 데이터는 그대로 둔다. 데이터가 사라지는 길은 제거 하나로 못 박았다. 파일이 사라진 플러그인은 missing으로 표시만 하고 레코드를 지우지 않는다. 가격·이력이 보존되고, 파일을 복구한 뒤 동기화하면 inactive로 돌아온다.
함정: 제거할 때 코어 테이블까지 지워질 수 있다
플러그인을 제거하면 마이그레이션을 롤백해야 한다. 그런데 MigrationRunner::regress()를 쓰면 안 된다. 내부에서 네임스페이스를 null로 되돌려 전체 배치를 훑기 때문에, 플러그인 하나를 지웠는데 코어 테이블이 전부 삭제된다.
대신 직접 롤백 목록을 만든다.
findNamespaceMigrations($ns)로 그 플러그인의 마이그레이션만 모은다.uid로 중복을 제거한다.krsort로 나중 버전부터 정렬한다.- 하나씩
force($path, $ns)로down()을 실행한다.
force()는 토글이라 같은 파일을 두 번 부르면 되돌린 것이 다시 실행된다. 중복 제거가 필수인 이유다. 활성화 쪽에도 비슷한 함정이 있다. 플러그인 네임스페이스를 런타임 오토로더에 등록한 뒤 마이그레이션을 돌리는데, 이미 등록돼 있으면 다시 등록하지 않아야 같은 마이그레이션이 두 번 조회되지 않는다. 그리고 "플러그인 제거가 코어 테이블을 남기는가"를 테스트로 고정했다.
ZIP 설치기: 검증 전에는 아무것도 쓰지 않는다
플러그인은 실행 코드를 담는다. 그래서 설치기는 원칙 하나로 짰다. 검증을 전부 통과하기 전에는 목적지에 아무것도 쓰지 않는다.
| 검사 | 내용 |
|---|---|
| 규모 | 엔트리 2000개 이하, 압축 해제 총량 50MB 이하 |
| Zip-slip | .., 선행 /, \, 드라이브 접두어(C:) 거부 |
| 확장자 | php json css js map md png jpg jpeg gif svg webp ico woff woff2 ttf 외 거부 |
| 매니페스트 | 최상위 plugin.json 필수 |
- 설치 디렉터리명은 ZIP 안의 폴더 구조가 아니라
key에서 유도한다. ZIP을 신뢰하지 않는다. - 설치 직후 상태는 항상
inactive다. 업로드가 곧 활성화는 아니다. - 이 기능은 서버에 임의 PHP를 올리는 기능이라 기본으로 꺼 두었다. 켜는 스위치는 관리자 화면(DB)이 아니라 서버
.env의plugins.allowUpload다. 관리자 계정이 탈취돼도 웹에서 스스로 켜서 코드를 올릴 수 없게 하려는 것이다.
플러그인 하나의 모양
app/Plugins/Memo/
├── plugin.json
├── Routes.php
├── Events.php
├── Controllers/ Models/ Views/ Libraries/
└── Database/Migrations/
public/plugins/memo/ # CSS·JS·이미지
매니페스트는 로드 시점에 전체 검증된다. 틀린 플러그인 하나만 건너뛰고 나머지는 정상 동작한다.
{
"key": "memo",
"name": "메모",
"version": "1.0.0",
"license_type": "onetime",
"menu": [
{ "title": "메모", "route": "memo", "icon": "bi-journal" }
]
}
라우트는 컨트롤러를 선행 \가 붙은 전체 네임스페이스로 쓴다.
// app/Plugins/Memo/Routes.php (플러그인 루트 — Config/Routes.php 가 아니다)
$routes->get('memo', '\Plugins\Memo\Controllers\MemoController::index');
$routes->group('admin/memo', ['filter' => 'auth:admin'], static function ($routes): void {
$routes->post('store', '\Plugins\Memo\Controllers\MemoController::store');
});
마이그레이션은 테이블 접두어(memo_notes)로 충돌을 막고, 시각 컬럼은 TIMESTAMP만 쓰며, down()을 반드시 구현한다. 제거할 때 실제로 실행되기 때문이다.
켜는 순서는 관리자 /admin/plugins에서 동기화 → 활성화다. 동기화하면 새 플러그인이 inactive로 목록에 뜨고, 활성화하면 마이그레이션이 돌고 라우트가 열린다.
플러그인끼리는 서로를 모른다
Pricing이 Quote 클래스를 use하는 순간 둘은 따로 팔 수 없는 하나가 된다. 그래서 플러그인 간 통신은 CI4 Events로만 한다.
- 이벤트명은
{plugin_key}.{과거형 동사}다(quote.accepted). 요청이 아니라 이미 일어난 사실을 알리는 것이다. - 페이로드는 snake_case 배열 하나, 식별자 위주다. 발행자는 키를 지우거나 의미를 바꾸지 않고 추가만 한다.
- 구독자는 놓쳐도 되는 후속 작업만 맡는다. 반드시 실행돼야 하면 발행자 책임이거나 큐다.
requires.plugins는 "없으면 동작하지 않음"일 때만 쓴다. 이벤트를 듣기만 하는 관계는 넣지 않는다.
위젯 슬롯과 날씨 샘플
플러그인이 공개 화면의 원하는 자리에 끼어들 수 있게 위젯 슬롯을 추가했다. 슬롯은 코어 뷰가 widget_slot('이름')을 부르는 자리다. 새 자리가 필요하면 그 뷰에 한 줄 넣으면 된다.
"widgets": [
{ "slot": "sidebar", "view": "Views/widgets/weather", "order": 20,
"class": "Plugins\\Weather\\Widgets\\WeatherWidget", "cache": 300 }
]
- 활성 플러그인의 위젯만 모으므로 비활성화하면 다음 요청부터 사라진다.
- 렌더는 위젯 단위 try/catch다. 예외가 나면 로그만 남기고 그 위젯만 비운다.
- 결과는 슬롯·뷰·로케일 기준으로 캐시한다(기본 300초). 사용자별로 달라지는 위젯은
cache: 0으로 둔다.
이 구조가 실제로 도는지 확인하려고 날씨 위젯 샘플 플러그인을 만들었다. 매니페스트에 settings[]를 두어 관리자가 지역을 입력하게 했고, 값은 plugins.settings(JSON)에 저장된다. 재스캔해도 저장값은 유지된다.
운영에서 놓치기 쉬운 것
writable/plugins/active.php는 git에 없다. 새 서버나 클린 배포 직후엔 전부 비활성이므로/admin/plugins에서 동기화 후 활성화해야 한다.- 이 파일은 서버별 상태다. 다중 서버면 서버마다 맞춰야 한다.
- 배포 스크립트의
php spark migrate --all은 비활성 플러그인 테이블까지 만든다. 무해하지만 알아 둘 것. - 플러그인 라우트도 방문 로그에 쌓인다. 주소에 토큰이 들어가는 경로는
VisitLogFilter제외 목록에 추가해야 하는데, 이건 코어 수정이라 가급적 쿼리나 세션으로 넘기는 설계를 택한다.
8개 PR로 나눈 이유
한 번에 올리지 않고 각 단계를 별도 PR로 쪼갰고, 단계마다 composer ci(코드 스타일, PHPStan level 6, PHPUnit)를 통과시켰다.
- 기반 클래스: 매니페스트·
LicenseType·MenuItem·예외 - 부팅 로더:
active_plugins.php·ActivePluginCache - 레지스트리:
plugins테이블·PluginModel·PluginManager - 코어 연결: Routes·Events·phpstan
- ZIP 설치기
- 관리자 화면
/admin/plugins - Sample 플러그인과 통합 테스트(스캔 → 활성화 → 마이그레이션 → 라우트 → 렌더 → 제거 전 경로)
- Entitlement: 사용자별 권한,
UserScopedModel,/my/plugins
7단계의 Sample 통합 테스트가 핵심이었다. 구조가 말로만이 아니라 전 경로로 고정되기 때문이다. 그 뒤에 개발 가이드 문서, 위젯 슬롯, 날씨 샘플을 얹었다.
배운 점
- 부팅 순서가 설계를 결정한다. "DB가 없는 시점에 무엇을 알아야 하는가"에서 투영 파일 구조가 나왔다. 진실은 DB에, 부팅에 필요한 만큼만 파일에 둔다.
- 프레임워크 기본 동작이 우리 가정과 어긋나는 지점을 먼저 찾는다. 라우트 자동 수집 순서와
regress()의 전체 롤백이 그랬다. 둘 다 설계 문서에 함정으로 먼저 적어 두고, 테스트로 고정했다. - 파괴적 경로는 하나로 모은다. 데이터가 사라지는 동작을 제거 하나로 한정하니, 운영에서 "비활성화로 충분한가"를 먼저 묻는 습관이 생긴다.
- 업로드 기능은 기본값을 끈다. 서버에 코드를 올리는 기능은 검증보다 먼저 "누가 켤 수 있는가"를
.env로 좁히는 게 안전하다.

댓글 1
10년도 더 전에 똑같은 구조를 만들었던 적이 있었다. 그때는 주소->오토로드->라이브러리를 통해 플러그인 구현까지 전부 직접 조사하고 벤치마킹해서 ci3로 만들었다. 지금은 ci4의 기본 구조만으로도 구현이 가능하다는게 격세지감을 느낀다.