CodeIgniter 4로 멀티보드 만들기 #1 — 왜 게시판마다 테이블을 만들지 않는가
지난 강좌에서 우리는 CodeIgniter 4로 블로그를 하나 만들었습니다. 글을 쓰고, 검색하고, 이미지를 올리고, 마지막에는 운영 환경으로 배포까지 했습니다. 그 블로그는 지금 이 글이 올라와 있는 바로 그 블로그입니다.
이번에는 조금 다른 것을 만듭니다. 블로그 하나가 아니라, 게시판을 만들어 내는 것을 만듭니다.
관리자 화면에서 "공지사항"이라고 이름을 넣고 저장하면 /board/notice가 생기고, "행사게시판"을 만들면서 '지역'과 '행사일'이라는 입력 항목을 추가하면 그 게시판의 글쓰기 폼에만 그 두 칸이 생기는 — 그런 물건입니다. 코드를 한 줄도 더 짜지 않고요.
이런 걸 흔히 멀티보드라고 부릅니다. 국내에서는 그누보드나 킴스보드 같은 솔루션이 오래전부터 해오던 일이고, 그래서 "그거 그냥 게시판 테이블 여러 개 만드는 거 아니야?"라고 생각하기 쉽습니다. 실제로 그게 가장 흔한 답이었습니다.
이번 강좌는 거기서 갈라집니다. 그리고 그 갈라지는 지점이 이 강좌 전체의 성격을 결정하기 때문에, 첫 회차는 코드 없이 이 이야기만 하려고 합니다.
1. 문제는 '입력 항목이 다르다'는 것
게시판을 여러 개 만드는 것 자체는 어렵지 않습니다. 글에 board_id 하나 붙이면 끝입니다. 진짜 문제는 그다음입니다.
- 공지사항에는 제목과 본문만 있으면 됩니다.
- 행사게시판에는 지역과 행사일이 필요합니다.
- 채용게시판에는 경력 구분과 마감일이 필요합니다.
- Q&A에는 답변 완료 여부가 필요합니다.
게시판마다 담아야 할 데이터가 다릅니다. 그리고 이 목록은 우리가 미리 다 알 수 없습니다. 이 플랫폼을 쓰는 사람이 내일 어떤 게시판을 만들지 우리는 모르니까요.
"미리 알 수 없는 모양의 데이터를 어디에 담을 것인가." 이 한 문장이 멀티보드 설계의 전부입니다. 여기서 길이 셋으로 갈립니다.
2. 첫 번째 길 — 게시판마다 테이블을 만든다
가장 직관적인 답입니다. 게시판을 만들 때 CREATE TABLE을 실행해서 board_notice, board_event 같은 테이블을 그때그때 찍어냅니다. 국내 게시판 솔루션들이 오래 써온 방식이고, 잘 돌아갑니다.
장점이 분명합니다. 게시판마다 컬럼을 마음대로 둘 수 있고, 테이블이 물리적으로 나뉘어 있으니 글이 아무리 쌓여도 서로 영향을 주지 않습니다. 하나가 망가져도 다른 게시판은 멀쩡합니다.
그런데 이 방식은 우리가 지난 강좌 내내 써온 도구 하나를 정면으로 부숩니다. 마이그레이션입니다.
마이그레이션은 "이 프로젝트의 DB 구조는 이 파일들의 총합이다"라는 약속입니다. 새 사람이 프로젝트를 받아서 php spark migrate를 돌리면 내 컴퓨터와 똑같은 구조가 만들어지는, 그 약속 하나로 굴러갑니다. 그런데 실행 중에 테이블이 생겨나기 시작하면 그 약속이 깨집니다. 마이그레이션 파일을 다 돌려도 운영 서버와 같은 구조가 나오지 않습니다. 운영 DB에만 존재하는 테이블 서른 개를, 아무도 코드로 설명할 수 없게 됩니다.
부수적인 문제도 줄줄이 따라옵니다. 모델이 테이블 이름을 런타임에 받아야 하고, "전체 게시판에서 검색"은 UNION 서른 개가 되고, 게시판마다 컬럼이 다르니 그 UNION조차 쉽지 않습니다.
이 방식이 틀렸다는 말은 아닙니다. 게시판 하나에 글이 수백만 건씩 쌓이는 규모라면 오히려 이쪽이 정답입니다. 다만 학습용 강좌에서 가르치기에는 위험한 습관이고, 우리가 만들려는 규모에서는 얻는 것보다 잃는 게 많습니다.
3. 두 번째 길 — 테이블 하나에 전부 넣는다
반대편 끝입니다. 게시판이 몇 개든 글은 전부 posts 테이블 하나에 넣고, board_id로 구분합니다. 게시판의 설정(이름, 주소, 페이지당 글 수, 권한)은 boards 테이블에 따로 둡니다.
앞의 문제가 전부 사라집니다. 마이그레이션은 정상적으로 동작하고, 모델은 하나면 되고, 통합 검색은 WHERE 절 하나로 끝납니다. 프레임워크와도 궁합이 좋습니다.
대신 처음 던졌던 질문 앞에서 멈춰 섭니다. 행사게시판의 '지역'은 어디에 넣습니까?
posts에 region 컬럼을 추가하면, 그 컬럼은 공지사항 글 만 건에도 전부 NULL로 따라붙습니다. 채용게시판이 생기면 career_type이 붙고, Q&A가 생기면 is_answered가 붙습니다. 게시판 하나 만들 때마다 개발자가 마이그레이션을 짜야 한다면, 그건 이미 플랫폼이 아닙니다.
그래서 많은 프로젝트가 결국 extra1, extra2, extra3 같은 컬럼을 만듭니다. 그리고 몇 달 뒤에는 아무도 extra3에 뭐가 들어 있는지 모릅니다.
4. 세 번째 길 — 하나에 넣되, 여지를 남긴다
우리가 갈 길입니다. 두 번째 길 위에 JSON 컬럼을 하나 얹습니다.
구조는 두 조각입니다.
게시판이 "어떤 항목을 받을지" 정의하는 곳 — boards.field_schema
[
{
"key": "region",
"label": "지역",
"type": "select",
"options": ["서울", "부산", "대구"],
"required": true,
"searchable": true
},
{
"key": "event_date",
"label": "행사일",
"type": "date",
"required": false
}
]
글이 "그 항목의 값"을 담는 곳 — posts.extra
{ "region": "부산", "event_date": "2026-09-01" }
이 두 조각이 있으면 나머지는 따라옵니다. 글쓰기 폼은 field_schema를 읽어서 스스로 그려지고, 검증 규칙도 거기서 만들어지고, 목록에 어떤 칸을 보여줄지도 거기서 결정됩니다. 관리자가 항목을 하나 추가하면 그 게시판의 폼에 칸이 하나 늘어납니다. 우리가 짜야 할 코드는 없습니다.
extra1, extra2와 다른 점은 이름이 있다는 것입니다. extra를 열어보면 {"region": "부산"}이라고 적혀 있습니다. 몇 달 뒤에 봐도 읽힙니다.
공짜는 아닙니다
이 길에도 대가가 있고, 미리 말해두는 편이 정직합니다.
JSON 컬럼은 그냥 두면 인덱스가 걸리지 않습니다. "부산에서 열리는 행사만 보여줘"를 JSON_EXTRACT로 검색하면 그 게시판 글을 전부 훑습니다. 글이 몇 건 없을 때는 모르지만, 쌓이면 느려집니다.
해법은 있습니다. 생성 컬럼(generated column) 을 만들어서 JSON 안의 값을 꺼내 별도 컬럼처럼 쓰고, 거기에 인덱스를 겁니다. 이건 21편에서 정면으로 다룹니다. 미리 맛만 보자면 이렇게 갈립니다.
-- 생성 컬럼으로 검색 → 인덱스를 탑니다
WHERE board_id = 2 AND extra_region = '부산'
-- JSON 함수로 직접 검색 → 게시판 전체를 훑습니다
WHERE board_id = 2 AND JSON_UNQUOTE(JSON_EXTRACT(extra, '$.region')) = '부산'
같은 결과를 내는 두 줄인데, EXPLAIN을 찍어보면 완전히 다른 일이 벌어집니다. 이걸 눈으로 확인하는 회차가 이 강좌에서 가장 재미있는 대목이 될 거라고 생각합니다.
5. 세 길을 나란히 놓고
| 게시판별 테이블 | 단일 테이블 | 단일 테이블 + JSON | |
|---|---|---|---|
| 커스텀 항목 | 자유롭다 | 불가능하다 | 가능하다 |
| 마이그레이션 | 무너진다 | 정상이다 | 정상이다 |
| 통합 검색 | UNION 지옥 | 쉽다 | 쉽다 |
| 커스텀 항목 검색 | 빠르다 | — | 생성 컬럼이 필요하다 |
| 대용량 | 유리하다 | 불리해진다 | 불리해진다 |
| 배우는 것 | 런타임 DDL | 기본기 | 동적 스키마 설계 |
맨 아랫줄이 이 강좌가 세 번째 길을 고른 진짜 이유입니다.
지난 강좌가 "프레임워크를 쓸 줄 아는 것"에 관한 이야기였다면, 이번 강좌는 "쓰는 사람이 늘려 나갈 수 있는 구조를 짜는 것" 에 관한 이야기입니다. 설정으로 동작이 바뀌는 시스템, 데이터가 폼을 그리는 구조, 스키마 없는 데이터에 인덱스를 거는 방법. 게시판은 그걸 배우기 위한 소재일 뿐입니다.
6. 완성했을 때의 모습
37편을 마치면 손에 남는 것입니다.
방문자가 보는 것
/board/{slug}— 목록. 검색, 카테고리 필터, 공지 상단 고정/board/{slug}/{id}— 상세. 첨부파일, 댓글과 대댓글, 좋아요- 게시판 성격에 따라 달라지는 세 가지 스킨 — 일반형, 갤러리형, FAQ형
- 회원가입과 로그인, 게시판별 권한
관리자가 보는 것
- 게시판 만들기. 주소, 이름, 스킨, 권한, 기능 토글
- 커스텀 항목 만들기. 텍스트, 숫자, 선택, 체크박스, 날짜, URL
- 게시물과 댓글 관리, 회원 관리, 신고 처리
7. 이 강좌가 다루지 않는 것
강좌를 시작하기 전에 선을 그어 두겠습니다. 아래는 다루지 않습니다.
- 소셜 로그인, 이메일 인증
- 에디터 직접 구현 — 외부 라이브러리를 씁니다
- 실시간 알림, WebSocket
- 다국어
- REST API — 별도 시리즈로 다룰 생각입니다
전부 게시판에 붙일 만한 기능이지만, 하나씩 넣다 보면 정작 이 강좌의 주제인 구조 설계가 묻힙니다. 궁금한 것이 있으면 댓글로 남겨주세요. 반응이 많으면 별도 회차로 만들겠습니다.
8. 준비물
지난 강좌를 따라오셨다면 대부분 갖춰져 있습니다.
- PHP 8.2 이상 —
intl,mbstring확장 필요 - Composer
- MySQL 8.0 이상 권장. MariaDB 10.6 이상도 됩니다
- CodeIgniter 4의 MVC, 라우팅, 마이그레이션, 모델을 다뤄본 경험
DB에 대해 한 가지 짚고 갑니다. 지난 강좌에서는 운영 DB로 SQLite를 썼지만, 이번에는 SQLite를 쓰지 않습니다. JSON 컬럼과 생성 컬럼 인덱스가 이 강좌의 핵심인데 그 전략이 MySQL/MariaDB 기준이기 때문입니다.
MySQL과 MariaDB 사이에도 차이가 있습니다. 진행에는 문제가 없지만 26편(검색)에서 결과가 갈리는 지점이 하나 있어서, 그 회차에서 양쪽을 나눠 다루겠습니다. 지금 고르실 수 있다면 MySQL 8.0을 권합니다.
코드는 회차별로 태그가 붙어 있어서 원하는 시점부터 시작할 수 있습니다.
git clone https://github.com/nambak/ci4board.git
cd ci4board
git checkout ep12 # 12편 시점의 코드
마무리
이번 편에서는 코드를 한 줄도 쓰지 않았습니다. 대신 앞으로 36편 동안 되돌릴 수 없는 결정을 하나 했습니다.
이런 결정을 나중으로 미루면, 스무 번째 회차쯤에서 "아, 이 구조로는 안 되겠는데" 하는 순간이 옵니다. 그때는 이미 발행한 글과 태그를 전부 손대야 합니다. 그래서 첫 회차를 통째로 여기에 썼습니다.
다음 회차에서는 프로젝트 세팅과 이 강좌의 진행 규칙을 다룹니다. CodeIgniter 4를 설치하고, DB를 연결하고, 앞으로 회차마다 코드를 어떻게 남길지 정합니다.
댓글 0
아직 댓글이 없습니다.