| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 마이그레이션
- 상태관리
- 온디바이스AI
- 도메인설계
- REST API
- 아키텍처
- Android
- sqlite
- 소셜로그인
- github actions
- FastAPI
- playwright
- 보안
- 인증
- javascript
- Kotlin
- JWT
- React
- next.js
- contextBridge
- 백엔드
- python
- electron
- Vite
- 크롤링
- typescript
- php
- 배포
- 프론트엔드
- 메일자동화
- Today
- Total
HM소프트
브랜드 웹사이트 개발기 (1) — 서버 설정 없는 PHP + SQLite와 콘텐츠 관리 본문

PHP 8.1과 SQLite(PDO)로 클론하면 바로 도는 0-setup 브랜드 사이트를 만든다. 첫 요청이 곧 설치 과정이 되는 db() 함수, CREATE TABLE IF NOT EXISTS 스키마, 버전 플래그로 운영 DB까지 따라잡는 멱등 마이그레이션이 뼈대다. c() 헬퍼로 코드에서 문구를 분리해 관리자 폼이 스스로 그려지게 하고, DB-override 패턴으로 안전한 기본값과 유연한 변경을 통일한다.
시리즈를 시작하며
한 사회적기업의 브랜드 소개 웹사이트를 만들었다. 브랜드 스토리, 활동 소개, 제품 상세, 소식(news), 임팩트 통계를 보여주고, 운영자가 콘텐츠를 직접 고치는 사이트다. 스택은 단순하다. PHP 8.1 + SQLite(PDO), 프레임워크 없음, 빌드 도구 없음. php -S로 띄우면 그대로 돈다.
이 글을 쓰는 이유는 "PHP + SQLite로 만들었다"는 한 줄 요약 뒤에 생각보다 많은 설계 결정이 숨어 있었기 때문이다. DB 서버 없이도 안정적으로 돌게 만드는 것, 스키마를 코드로 관리하는 것, 운영자가 코드를 모르고도 문구·이미지를 바꾸게 하는 것 — 이 세 가지가 작은 사이트의 뼈대였다. 두 편으로 나눠 기록한다.
- (이 글) 서버 설정 없는 PHP + SQLite와 콘텐츠 관리 — 부트스트랩, 스키마 자동화, 멱등 마이그레이션, 콘텐츠 KV 시스템, DB-override 패턴.
- 기획 문서와 콘텐츠를 정확히 맞추기 — "문서가 지정한 것"과 "화면에 실제로 나온 것"의 정합성을 잡는 작업.
특정 기업명·브랜드·인물은 모두 일반화하고, 구조와 코드만 다룬다.
왜 PHP + SQLite인가 — "적정 기술"의 기준
브랜드 소개 사이트는 트래픽이 폭발하지 않는다. 콘텐츠 중심이고, 빠르게 띄워야 하고, 비개발자가 운영해야 한다. 이 조건을 그대로 기술 선택의 기준으로 삼았다.
- PHP 8.1+: 어디서나 도는 친숙한 런타임. 공유 호스팅이든 작은 VM이든 그대로 올라간다.
- SQLite(PDO): 파일 하나가 곧 데이터베이스다. MySQL/PostgreSQL 서버를 띄우고 계정을 만들고 포트를 여는 과정이 통째로 사라진다.
# 로컬·서버 구분 없이 동일하게 실행 — DB 서버 설치 불필요
php -S localhost:8000
# 첫 요청에서 sqlite 파일이 자동 생성되고, 스키마와 예시 데이터가 시드됨
핵심은 "클론 → 바로 실행"이 성립한다는 점이다. DB 설치도, 마이그레이션 명령도, 시드 스크립트 실행도 따로 없다. 첫 요청이 들어오는 순간 DB 파일이 만들어지고 테이블이 생기고 예시 데이터가 채워진다. 이 "0-setup"을 만드는 책임이 전부 db() 함수 한 곳에 모여 있는데, 그 설계가 1편의 출발점이다.
전체 구조를 먼저 그리면 이렇다. 진입 페이지마다 부트스트랩을 require하고, 부트스트랩이 설정·세션·헬퍼·DB를 순서대로 끌어온다.

부트스트랩 — 모든 진입점이 거치는 한 줄
레거시 PHP의 흔한 함정은 페이지마다 세션을 제각각 켜고, 설정을 중복 include하고, 어떤 페이지는 에러를 노출하고 어떤 페이지는 숨기는 것이다. 그래서 모든 진입 페이지가 똑같이 require하는 단일 부트스트랩을 두고, 거기서 환경을 한 번에 확정했다.

별것 아닌 25줄처럼 보이지만, 여기서 두 가지를 강제한다. 첫째, 운영(APP_DEBUG=false)에서는 에러가 절대 화면에 노출되지 않는다. DB 접속 정보나 파일 경로가 에러 메시지로 새는 사고를 구조적으로 막는다. 둘째, 세션 쿠키에 httponly와 SameSite=Lax를 박았다. 관리자 로그인 세션이 XSS로 탈취되거나 외부 사이트에서 묻어 들어오는 표면을 줄인다. "보안은 페이지마다 챙기는 게 아니라 진입점 한 곳에서 끝낸다"는 원칙을 코드로 박은 셈이다.
0-setup의 심장 — db()와 자동 스키마 생성
db()는 이 사이트에서 가장 중요한 함수다. 요청당 연결을 한 번만 맺고(static 캐시), 데이터 디렉터리가 없으면 만들고, 연결 직후 스키마를 보장한다.

작지만 의도가 분명한 결정들이 들어 있다. ATTR_ERRMODE => EXCEPTION으로 DB 오류를 조용한 false가 아니라 예외로 터지게 했다. 레거시에서 가장 디버깅하기 힘든 건 "쿼리가 실패했는데 아무 일도 안 일어난" 상황인데, 예외 모드면 그런 침묵이 사라진다. FETCH_ASSOC를 기본으로 둬서 모든 조회 결과가 연관 배열로 일관되게 나오고, PRAGMA foreign_keys = ON으로 SQLite가 기본적으로 끄고 있는 외래키 제약을 켰다.
그리고 첫 요청이 곧 설치 과정이 된다. is_dir 체크로 데이터 폴더가 없으면 만들고, new PDO('sqlite:...')는 파일이 없으면 새로 만든다. 그 직후 migrate()가 테이블을 보장한다. 별도 설치 화면도, CLI 명령도 없이 "처음 접속한 순간"에 모든 게 준비된다. 이게 이 스택을 고른 이유 그 자체다.
스키마를 코드로 — CREATE TABLE IF NOT EXISTS
migrate()는 사이트가 쓰는 모든 테이블을 CREATE TABLE IF NOT EXISTS로 선언한다. 별도 마이그레이션 프레임워크 없이, 스키마 정의 자체가 멱등(idempotent) 하다. 몇 번을 호출해도 이미 있는 테이블은 건드리지 않는다.
$pdo->exec("
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'news',
body TEXT NOT NULL,
thumb TEXT, -- 업로드 파일명
published INTEGER NOT NULL DEFAULT 1,
published_at TEXT NOT NULL DEFAULT (date('now','localtime')),
created_at TEXT NOT NULL DEFAULT (datetime('now','localtime'))
)
");
$pdo->exec("
CREATE TABLE IF NOT EXISTS site_content (
skey TEXT PRIMARY KEY, -- 콘텐츠 키 (예: hero_line1)
val TEXT, -- 실제 값
label TEXT, -- 관리자 화면에 보일 한국어 라벨
grp TEXT, -- 관리자 화면 그룹(히어로/활동/연락처…)
kind TEXT NOT NULL DEFAULT 'text', -- text | textarea
sort INTEGER NOT NULL DEFAULT 0
)
");
테이블이 여러 개다(소식, 제품, 파트너, 연혁, 임팩트 카드/케이스, 프로세스 단계, 그리고 site_content). 공통점은 전부 운영자가 관리자 화면에서 만질 콘텐츠라는 것이다. 특히 site_content는 단순한 데이터 테이블이 아니라 "코드 안의 문구를 DB로 빼내는 장치" 다. skey(키)·val(값)뿐 아니라 label(라벨)·grp(그룹)·kind(입력 유형)까지 한 행에 들고 있는 게 포인트다. 데이터와 함께 "이 값을 관리자 화면 어디에, 어떤 입력칸으로 보여줄지"까지 같은 테이블이 알고 있다. 덕분에 새 문구를 추가하면 관리자 화면이 자동으로 그 입력칸을 그려낸다. (관리자 화면 코드는 다음 절에서 본다.)
멱등 마이그레이션 — 프레임워크 없이 스키마/데이터 진화시키기
여기서부터가 이 사이트의 진짜 엔지니어링이었다. 콘텐츠는 운영 중에 계속 바뀐다. 카피가 갈리고, 이미지가 사진에서 일러스트로 교체되고, 카드 제목 체계가 정리된다. 문제는 이미 운영 DB가 존재한다는 것이다. CREATE TABLE IF NOT EXISTS는 "테이블이 없을 때"만 시드를 넣으니, 한 번 시드된 뒤에는 기본값을 바꿔도 운영 DB에 반영되지 않는다.
ORM의 마이그레이션 명령도, 별도 버전 테이블 도구도 없다. 그래서 site_content에 버전 플래그 행을 직접 심는 방식으로 "한 번만 실행되는 데이터 변경"을 구현했다.

이 패턴의 본질은 "버전 플래그가 없으면 변경을 적용하고, 적용 직후 플래그를 박는다" 이다. 그러면 모든 요청에서 migrate()가 돌아도 각 변경은 정확히 한 번만 실행된다. 신규 환경(빈 DB)은 시드 기본값이 곧 최신값이고, 기존 환경은 버전 블록들이 차례로 적용되며 따라잡는다. 신규/기존이 결국 같은 상태로 수렴한다.
흐름으로 보면 이렇다.

실제로 이 사이트에는 v16부터 v23까지 여러 마이그레이션 블록이 쌓여 있다. 카피 전면 교체(v16), 공식 문서 기준 문구 정합화(v19), 사진→일러스트 이미지 교체(v20), 공개 연락처 갱신(v23) 같은 변경들이 전부 이 한 패턴으로 처리됐다. 한 가지 시행착오도 남아 있는데, 초기 버전은 UPDATE 방식이라 시드 INSERT와의 실행 순서에 따라 0행이 적용되는 레이스가 있었다. 다음 버전에서 INSERT OR REPLACE로 바꿔, 행이 있든 없든 무조건 확정되게 정정했다. 멱등성은 "여러 번 실행해도 같은 결과"만이 아니라 "행 존재 여부와 무관하게 같은 결과"까지 포함해야 안전하다는 걸 이때 배웠다.
추가로, 시드와 마이그레이션 모두 INSERT OR IGNORE(중복 키는 무시) / INSERT OR REPLACE(있으면 덮기) 같은 SQLite의 충돌 처리 구문을 적극적으로 썼다. 이 두 구문이 "매 요청마다 안전하게 돌려도 되는" 멱등 시드의 토대다.
콘텐츠 시스템 — c() 한 함수로 코드에서 문구를 분리
운영자가 개발자 없이 문구를 바꾸려면, 화면 코드가 문자열을 직접 들고 있으면 안 된다. 그래서 모든 편집 가능한 문구를 site_content에서 읽는 c() 헬퍼로 통일했다. 첫 호출에서 전체를 한 번 읽어 메모리에 캐시하고, 이후로는 그 캐시만 본다.

이 함수의 묘미는 두 번째 인자(기본값) 다. 화면 코드는 이렇게 쓴다.
<h1><?= e(c('item_coffee_h1', '매일 아침 찾아오는 건강한 활력 한 잔')) ?></h1>
<p><?= e(c('item_coffee_lead', '향긋한 커피 한 잔에 건강을 담았습니다…')) ?></p>
DB에 값이 있으면 그 값을, 없거나 비어 있으면 코드에 적힌 기본값을 쓴다. 그래서 DB가 비어 있어도(혹은 통째로 날아가도) 사이트는 멀쩡한 기본 문구로 렌더된다. 운영자가 편집한 항목만 DB값으로 덮이고, 손대지 않은 항목은 코드 기본값 그대로다. "기본은 코드, 변경은 DB"라는 원칙이 함수 한 줄의 폴백 로직에 그대로 들어 있다. 모든 출력은 e()(htmlspecialchars 래퍼)로 이스케이프해서, 운영자가 DB에 무엇을 넣든 XSS로 이어지지 않게 막았다.
관리자 화면이 스스로 그려진다 — 데이터 주도 폼
site_content가 label·grp·kind까지 들고 있는 덕에, 관리자 콘텐츠 편집 화면은 하드코딩된 폼이 아니라 데이터로부터 자동 생성된다. 그룹별로 묶어 출력하고, kind에 따라 입력칸 종류를 바꾸고, 키 이름이 _img/_bg로 끝나면 이미지 업로드 위젯으로 그린다.
function is_image_field(string $skey): bool {
return preg_match('/(_img|_bg)$/', $skey) === 1; // 키 이름으로 이미지 필드 판정
}
foreach ($groups as $grp => $fields) {
echo '<h2>' . e($grp) . '</h2>';
foreach ($fields as $f) {
if (is_image_field($f['skey'])) {
// 미리보기 + 경로 입력 + 파일 업로드 위젯
} elseif ($f['kind'] === 'textarea') {
// 여러 줄 입력칸
} else {
// 한 줄 입력칸
}
}
}
이 구조의 장점은 새 콘텐츠 키를 추가하는 비용이 거의 0 이라는 것이다. 시드 정의에 한 줄(['key', '기본값', '라벨', '그룹', 'text', 정렬])을 더하면, 화면에는 c('key', ...)로 쓰고, 관리자 폼에는 그 입력칸이 알아서 나타난다. 폼 HTML을 손으로 추가할 필요가 없다.
저장 로직도 같은 데이터 주도 방식이다. 폼이 제출되면 site_content의 모든 키를 돌며 POST값으로 업데이트한다. 이때 보안을 두 겹으로 챙겼다.

CSRF 토큰은 세션에 보관한 무작위 값을 폼 hidden 필드와 hash_equals()로 비교한다(타이밍 공격에 안전한 비교). 이미지 업로드는 파일 확장자를 믿지 않고 getimagesize()로 실제 바이트를 읽어 형식을 판정하고, 허용 목록(JPEG/PNG/WebP/GIF)에 없으면 거부한다. 저장 파일명에는 무작위 토큰을 붙여 경로 추측이나 기존 파일 덮어쓰기를 막았다. 프레임워크가 자동으로 챙겨주던 것들을 "직접" 챙겨야 하는 게 노-프레임워크 PHP의 숙제인데, 그만큼 무엇을 왜 막는지가 코드에 선명하게 드러난다.
DB-override — 기본은 코드, 변경만 DB로 덮기
콘텐츠 시스템의 사상이 가장 잘 드러나는 곳이 제품 이미지 처리다. 제품 카드의 이미지는 운영자가 직접 업로드할 수 있는데, 안 했을 수도 있다. 그래서 "운영자 지정값이 있으면 그걸, 없으면 카테고리·순번에 맞는 안전한 기본 이미지"를 쓰는 폴백을 만들었다.

이게 "DB-override" 패턴이다. 코드는 항상 보여줄 무언가를 보장하고, DB는 그 위에 선택적으로 덮어쓴다. 운영자가 손대지 않아도 화면은 깨지지 않고, 바꾸고 싶을 때만 DB(업로드)로 덮는다. c()의 기본값 폴백과 정확히 같은 사상이다 — 이미지든 문구든, "안전한 기본값 + 유연한 변경" 이라는 한 원칙을 사이트 전체가 공유한다.
여담으로, 시드 단계에서는 샘플 이미지 경로를 일부러 NULL로 비워 둔다(UPDATE products SET image=NULL WHERE image LIKE 'assets/img/%'). 시드된 더미 사진이 운영 화면에 그대로 나가는 걸 막고, 폴백 경로를 타도록 강제하기 위함이다. 이 한 줄도 매 요청 안전한(멱등) 방어선이다.
정리하며
- PHP 8.1 + SQLite(PDO) 로 "클론 → 바로 실행"이 되는 0-setup 사이트. 첫 요청이 곧 설치 과정이고, 그 책임을
db()한 곳에 모았다. - 단일 부트스트랩에서 에러 노출·세션 하드닝(
httponly·SameSite)을 한 번에 확정해, 보안을 진입점에서 끝냈다. - 스키마는
CREATE TABLE IF NOT EXISTS로 코드화하고, 데이터 변경은site_content에 버전 플래그를 심는 멱등 마이그레이션으로 운영 DB까지 안전하게 따라잡게 했다. (INSERT OR REPLACE로 레이스까지 정정.) c()한 함수로 코드에서 문구를 분리하고,label·grp·kind를 데이터에 담아 관리자 폼이 스스로 그려지게 했다. 새 콘텐츠 추가 비용이 거의 0.- 업로드는
getimagesize()화이트리스트 + 무작위 파일명, 저장은 CSRF +hash_equals()로 막았다. - DB-override 패턴(
product_img_style·c()의 폴백): 코드가 기본값을 보장하고 DB가 그 위에 덮는다 — 안전한 기본값과 유연한 변경을 한 사상으로 통일.
다음 2편에서는 이 사이트에서 의외로 큰 비중을 차지했던 작업 — "기획 문서가 지정한 콘텐츠와 화면에 실제로 나온 것을 정확히 일치시키기" 를 다룬다. 코드 결함이 아니라 "정합성"의 문제였고, 1편에서 만든 멱등 마이그레이션이 그 해법의 도구가 된다.
'외주 개발일지' 카테고리의 다른 글
| 모바일 축구 커리어 게임 개발기 (4) — AdMob 보상형 광고와 게임 재화 연동 (0) | 2026.06.23 |
|---|---|
| 브랜드 웹사이트 개발기 (2) — 기획 문서와 콘텐츠를 정확히 맞추기 (0) | 2026.06.23 |
| 급여 계산 자동화 도구 개발기 (1) — 4대보험·세금 계산 로직과 엣지케이스 (1) | 2026.06.23 |
| 시간제 시설 예약 시스템 개발기 (3) — 시간표 무중단 전환·일괄 차단·SEO·테스트 (0) | 2026.06.23 |
| 시간제 시설 예약 시스템 개발기 (2) — 알림톡 대체 발송과 단말 POS 실시간 연동 (0) | 2026.06.23 |