요약: CodeIgniter 4 기업 사이트 템플릿에 애드센스 게재를 전제로 한 블로그형 테마 4종(ilgan·cobalt·quiet·devnotes)을 추가하고, 모든 테마의 head에 자동광고 코드를 넣는 설정을 만들었다. 핵심은
ThemeView의 3단 폴백이다. 테마는 달라지는 파일만 갖고, 광고 유닛·메뉴·사이드바는 default 테마를 공유한다.
배경: 테마를 4개 더 만들어야 했다
AiCreo는 1인 웹에이전시용 CodeIgniter 4 사이트 템플릿이다. 이 위에서 블로그를 운영하며 구글 애드센스를 붙이게 되었고, 광고 게재에 맞는 레이아웃이 필요했다. 이미 blog 2단 테마가 있었지만 성격이 다른 블로그를 위해 4종을 한 번에 추가했다.
| 테마 | 성격 | 레이아웃 |
|---|---|---|
| ilgan | 신문 지면형 | 상단 가로 메뉴 + 본문 + 우측 300px 광고 사이드바 |
| cobalt | 매거진 카드형 | 카드 그리드, 인피드 광고가 카드 한 칸 |
| quiet | 가독성 우선 미니멀 목록 | 고정 헤더 + 목록 + 우측 사이드바 |
| devnotes | 다크, 좌측 고정 메뉴 | 좌측 레일 + 본문 + 우측 광고 컬럼 |
문제는 파일 수였다. 테마마다 레이아웃·홈·광고 슬롯·메뉴·사이드바·푸터를 따로 두면 4종에 같은 코드가 4번 들어간다. 이걸 막아 주는 것이 테마 시스템의 폴백 순서다.
ThemeView: 3단 폴백
CI4 기본 렌더러 대신 ThemeView(app/Libraries/ThemeView.php)를 공유 렌더러로 쓴다. render()가 뷰 이름을 받으면 다음 순서로 파일을 찾는다.
themes/{활성 테마}/{view}(활성 테마가 default가 아닐 때만)themes/default/{view}{view}원래 경로 (관리자 뷰, 콘텐츠 뷰)
public function render(string $view, ?array $options = null, ?bool $saveData = null): string
{
// 이미 테마 경로로 들어온 경우 그대로 처리 (무한 루프 방지)
if (str_starts_with($view, 'themes/')) {
return parent::render($view, $options, $saveData);
}
$base = rtrim($this->viewPath, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$theme = $this->activeTheme();
// 1순위: 활성 테마 (default가 아닐 때만)
if ($theme !== 'default') {
$candidate = "themes/{$theme}/{$view}";
if (is_file($base . str_replace('/', DIRECTORY_SEPARATOR, $candidate) . '.php')) {
return parent::render($candidate, $options, $saveData);
}
}
// 2순위: default 테마
$candidate = "themes/default/{$view}";
if (is_file($base . str_replace('/', DIRECTORY_SEPARATOR, $candidate) . '.php')) {
return parent::render($candidate, $options, $saveData);
}
// 3순위: 원래 경로 (admin 뷰, 콘텐츠 뷰 등)
return parent::render($view, $options, $saveData);
}
활성 테마 이름은 settings.active_theme에서 읽고, 요청 안에서는 한 번만 조회해 보관한다. 설정 맵 자체가 캐시되므로 추가 쿼리는 없다.

themes/로 시작하는 이름은 폴백 없이 바로 렌더한다. 폴백이 themes/default/...를 만들어 다시 render()를 부르는 구조라서, 이 가드가 없으면 재귀가 끝나지 않는다.
4종이 실제로 덮어쓰는 파일
새 테마 4종은 각각 이 정도만 가진다.
themes/ilgan/
├── DESIGN.md # 테마 디자인 메모
├── layouts/main.php # 전체 틀
├── pages/home.php # 홈
└── components/ad_slot.php # 광고 슬롯 연결 (4줄)
ad_slot.php가 특히 짧다. 기존 호출부가 components/ad_slot을 부르므로, 이 테마에서는 그것을 공용 광고 유닛으로 연결하기만 한다.
<?php
// 게시글 본문 상·하단 등 기존 호출부(components/ad_slot)를 이 테마의 광고 슬롯 마크업으로 연결한다.
?>
<?= view('themes/default/components/ad_unit', ['ads' => $ads ?? [], 'slot' => $slot ?? 'post']) ?>
여기서 themes/default/...처럼 themes/로 시작하는 경로를 직접 지정하면 폴백을 타지 않는다. 의도적으로 "default의 이 파일을 쓴다"고 못 박는 용도다.
공용으로 만든 컴포넌트는 default 쪽에 있다.
ad_unit.php: 광고 슬롯 마크업blog_sidebar.php: 카테고리·사이드바 광고menu_items.php: 메뉴site_footer_info.php: 푸터 정보
덕분에 광고 정책을 바꿀 때 고칠 곳이 한 군데다.
광고 슬롯 규칙
공용 ad_unit.php가 4종 모두의 광고를 그린다. 규칙은 이렇다.
- "광고" 라벨을 항상 표시한다. 광고와 콘텐츠를 구분하기 위해서다.
- 슬롯별 최소 높이를 CSS로 예약한다. 광고가 늦게 떠도 본문이 밀리지 않게(CLS 방지).
- 등록된 광고가 없으면 아무것도 그리지 않는다.
- 광고는 메뉴·버튼·링크와 떨어진 곳에만 둔다. 실수 클릭을 막기 위해서다.
위치는 홈 상단, 홈 목록 중간(home_feed), 홈 하단, 게시글 본문 상·하단, 사이드바(sidebar)다. 이 중 home_feed와 sidebar는 이번에 마이그레이션으로 추가했다.
로그인·글쓰기·수정 화면에는 사이드바 광고를 싣지 않는다. 게시자 콘텐츠가 없는 화면에 광고를 두면 애드센스 정책에 걸릴 수 있기 때문이다. BaseController가 한 번만 판단해서 뷰에 넘긴다.
// 사이드바 광고 — 블로그형 테마의 콘텐츠 페이지에만. 로그인·글쓰기·수정 같은
// 게시자 콘텐츠가 없는 화면에는 광고를 싣지 않는다(애드센스 정책).
$adSafe = ! $isAdmin && preg_match('#^auth(/|$)|(^|/)(write|edit|password|delete)(/|$)#', uri_string()) !== 1;
$sidebarAds = ($isBlogTheme && $adSafe) ? (new AdModel())->getActiveByPosition('sidebar') : [];
"블로그형 테마" 판별은 한 곳에서
블로그형 테마에서만 홈에 최신 글 목록을 만들고 카테고리를 조회한다. 이 판별이 컨트롤러 여러 곳에 흩어지면 테마를 추가할 때마다 빠뜨린다. 그래서 ThemeView에 목록과 판별 메서드를 두었다.
public const BLOG_THEMES = ['blog', 'ilgan', 'cobalt', 'quiet', 'devnotes'];
public static function isBlogTheme(string $theme): bool
{
return in_array($theme, self::BLOG_THEMES, true);
}
BaseController, HomeController, 게시판 목록 뷰가 모두 이 메서드를 부른다. 다섯 번째 블로그형 테마를 만든다면 상수에 이름을 추가하면 된다.
자동광고: head에 한 줄 넣기
애드센스 자동광고는 사이트 모든 페이지의 <head>에 스크립트 한 줄을 넣으면 시작된다. 테마가 6개(default, blog, 신규 4종)이므로 각 레이아웃에 코드를 하드코딩하면 안 된다. 코드가 바뀔 때마다 배포해야 하기 때문이다.
그래서 SEO 설정 탭에 head_code 항목을 추가했다.
- 마이그레이션이
settings에seo그룹의head_code키를 넣는다. 이미 있으면 건너뛰므로 여러 번 돌려도 안전하다. SeoHelper::headScript()가 값을trim해서 그대로 돌려준다. 비어 있으면 빈 문자열이다.- 6개 테마의
layouts/main.php가<head>에서echo $seo->headScript();를 호출한다. Google Analytics 스크립트 바로 뒤다.
/**
* 관리자가 등록한 <head> 삽입 코드(애드센스 자동광고 등). 관리자 입력이므로 그대로 출력한다.
*/
public function headScript(): string
{
return trim((string) ($this->settings['head_code'] ?? ''));
}

관리자 화면에는 이 칸에 무엇을 붙여넣는지 설명을 적어 두었다. 발급받은 adsbygoogle.js?client=ca-pub-… 스크립트 한 줄이다. 테스트는 두 가지다. 값이 없으면 빈 문자열이고, 값이 있으면 앞뒤 공백만 제거되어 원문 그대로 나온다.
이스케이프하지 않는 이유와 위험
headScript()는 값을 이스케이프하지 않는다. 이스케이프하면 <script> 태그가 글자로 보이기 때문이다. 대신 이 값을 넣을 수 있는 사람이 관리자뿐이라는 전제 위에 있다. 광고 슬롯 스니펫($ad['code'])도 같은 이유로 그대로 출력한다. 이 두 곳은 코드에 "관리자 입력이므로 그대로 출력한다"라고 주석을 남겼다. 관리자 계정이 뚫리면 전 페이지에 스크립트가 실릴 수 있으므로 관리자 인증을 느슨하게 두면 안 된다.
배운 점
- 테마를 "전체 복사"가 아니라 "덮어쓰기"로 설계하면 파일이 줄고, 공통 정책(광고 라벨·CLS·정책 위반 화면 제외)을 한 곳에서 관리한다.
- 폴백 구조에서는 경로를 명시해 폴백을 건너뛰는 방법(
themes/default/...)과 재귀 가드를 함께 알아 둬야 한다. - 테마별 분기 조건은 상수와 메서드 하나로 모은다. 흩어진
in_array하나가 새 테마의 홈을 깨뜨린다. - 자주 바뀌는 외부 스니펫은 코드가 아니라 설정에 둔다. 배포 없이 바꿀 수 있다.
- 모바일에서는 헤더·중복 제목·상단 광고 위치에서 문제가 나왔다. 테마를 추가한 직후 4종의 모바일 헤더, 제목 중복, 광고 위치를 한 번 더 손봤다.

댓글 0개