| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- React
- typescript
- REST API
- 보안
- 온디바이스AI
- 배포
- javascript
- FastAPI
- electron
- 백엔드
- github actions
- 크롤링
- 인증
- 메일자동화
- contextBridge
- php
- 상태관리
- Vite
- Kotlin
- playwright
- 프론트엔드
- python
- 소셜로그인
- Android
- next.js
- sqlite
- 도메인설계
- 마이그레이션
- JWT
- 아키텍처
- Today
- Total
HM소프트
교육 플랫폼 개발기 (1) — WordPress 위에 LMS를 얹고 강사 권한 경계를 긋다 본문

WordPress를 LMS로 길들이며 강의 데이터를 postmeta 대신 전용 테이블에 정규화한다. 캡 버전 가산식 동기화, map_meta_cap으로 강사 프로필 체인 소유권을 판정해 자기 강의만 편집하게 긋는 경계, 옵션 키 가드 자가 회복 마이그레이션과 중복 라우트의 조용한 실패까지 다룬다.
시리즈를 시작하며
한 산업군(가맹·소상공인 교육) 종사자를 위한 온라인 교육 플랫폼(LMS)을 만들었다. 강사가 강의를 만들고, 관리자가 승인하고, 수강생이 VOD로 학습하고, AI 챗봇이 법령·교안 근거로 답하고, 결제·영수증·멘토링까지 한 바퀴를 도는 종합 플랫폼이다. 토대는 WordPress, 그 위에 도메인 기능을 여러 개의 플러그인으로 쪼개 올렸다.
네 편으로 나눠 기록한다. 1편은 LMS의 뼈대 — WordPress를 어떻게 LMS로 길들였고, "강사가 자기 강의만 건드리게" 하는 권한 경계를 어디에 그었는지. 회사명·대표자·브랜드는 전부 빼고 구현만 다룬다.

왜 WordPress 위에서 시작했나
LMS를 맨바닥부터 짜는 대신 WordPress를 토대로 삼은 이유는 단순하다. 회원·인증·세션·관리자 화면·역할(role)·콘텐츠 타입이 이미 검증된 형태로 들어 있기 때문이다. LMS에서 가장 지루하면서도 사고가 잦은 영역이 바로 그 부분이다. 거기에 시간을 쓰는 대신, 강의·VOD·결제·챗봇 같은 도메인 고유 기능에 집중하고 싶었다.
대신 원칙을 하나 세웠다. "WordPress의 것은 WordPress 방식으로, 우리 것은 우리 테이블로." 강의는 커스텀 포스트 타입(CPT) hm_course로 두되, 목록 정렬·평점·수강수처럼 자주 조회·집계되는 데이터는 wp_postmeta에 흩뿌리지 않고 wp_hm_courses 같은 전용 테이블에 정규화해 담았다. postmeta는 키-값 한 줄이 한 행이라, 가격·레벨·강사로 필터링하고 정렬하는 순간 끔찍한 JOIN 지옥이 된다. 도메인 데이터는 처음부터 컬럼이 있는 테이블에 두는 게 정답이었다.
세 역할과 권한의 단일 소스
플랫폼에는 세 역할이 있다 — 관리자, 강사, 수강생. WordPress의 역할/권한(capability) 시스템을 그대로 활용하되, 우리 도메인 캡(hm_manage_own_courses, hm_upload_vod 등)을 추가로 정의했다. 여기서 레거시 WordPress 플러그인이 흔히 겪는 함정을 먼저 막아두고 싶었다. add_role()은 역할이 이미 존재하면 아무 일도 안 한다. 즉 플러그인을 처음 활성화한 사이트에는 캡이 들어가지만, 그 뒤에 코드로 캡을 추가하면 기존 사이트에는 영영 반영되지 않는다.
그래서 캡 정의에 버전을 붙이고, init 때 저장된 버전이 낮으면 누락 캡만 가산식으로 보강하게 했다.

sync_caps()가 가산식(additive)이라는 점이 핵심이다. 역할 캡을 통째로 다시 쓰지 않고 "없는 것만 더한다." 이 플랫폼은 강의(core)·계약서(contracts)·챗봇 같은 여러 플러그인이 각자 같은 역할에 캡을 추가하기 때문에, 한 플러그인이 역할을 통째로 재정의하면 다른 플러그인이 넣은 캡이 날아간다. "내 것만 더하고 남의 것은 두는" 규율이 다중 플러그인 구조에서 권한의 일관성을 지켰다.
"자기 강의만 편집" — 경계를 어디에 긋는가
LMS 권한 버그의 90%는 한 문장으로 요약된다. *"강사 A가 강사 B의 강의를 못 건드리게, 그러나 자기 강의는 편집되게."* 처음엔 강사 대시보드에서 편집 버튼 자체가 동작하지 않았다. 강사 역할에 edit_published_posts가 없어 발행된 자기 강의도 못 고쳤기 때문이다. 그렇다고 edit_others_posts까지 주면 남의 강의도 편집할 수 있게 된다.
해법은 WordPress의 map_meta_cap 필터였다. "이 글을 편집할 수 있나?"라는 질문이 들어올 때마다 실제 캡으로 변환되는 지점에 끼어들어, 강의 소유권을 직접 판정한다.

여기서 의도적으로 택한 건 소유권 기준을 post_author가 아니라 _hm_instructor_id 메타로 둔 것이다. WordPress의 기본 발상은 "글 작성자 = 소유자"지만, 실제 운영에서는 관리자가 강사 대신 강의를 만들어주거나, 강사 계정과 강사 프로필이 1:1이 아닐 수 있다. 그래서 강의는 instructors 테이블의 행을 가리키고, 그 행이 다시 WordPress user_id를 가리키는 한 단계를 두었다. 소유권 판정이 항상 이 체인을 따라가므로, 작성자가 누구든 "강사 프로필의 주인"만 편집할 수 있다.

REST API 쪽에도 같은 경계를 한 번 더 친다. 권한 콜백 can_manage_course()가 관리자면 통과, 아니면 강의의 instructor_id를 조회해 현재 로그인 사용자와 대조한다. UI에서 버튼을 숨기는 것과 별개로 서버에서 두 번째로 막는 것이 중요하다. 버튼 숨김은 권한이 아니라 안내일 뿐이다.
CPT와 커스텀 테이블의 동기화
강의 데이터가 CPT(wp_posts/wp_postmeta)와 커스텀 테이블(wp_hm_courses) 두 곳에 살다 보니, 둘을 어긋나지 않게 묶는 일이 필요했다. save_post_hm_course 훅에서 CPT 저장 시점마다 커스텀 테이블 행을 동기화한다.

이 동기화는 운영에서 두 번 물렸다. 첫째, 공용 호스팅에 배포할 때 시드 데이터가 커스텀 테이블만 채우고 wp_posts는 비워둔 채 올라가서 /courses/{id} URL이 전부 404가 났다. 그래서 활성화 이후 1회 실행되는 백필을 넣어, wp_hm_courses의 발행 강의 중 대응하는 CPT 글이 없는 행에 대해 글을 자동 생성하도록 했다. 둘째, 그 백필 도중 wp_insert_post가 다시 sync_course_meta 훅을 깨우면서 커스텀 테이블에 중복 행을 만들어 UPDATE가 충돌했다. 백필 동안에는 훅을 잠깐 떼었다가 finally에서 되돌리는 식으로 재진입을 차단했다.

이런 "한 번만 실행되는 자가 회복(self-healing) 마이그레이션"을 옵션 키 가드(get_option(...) === '1')로 여러 곳에 깔아둔 게 이 프로젝트의 일관된 패턴이다. 숫자 URL 리라이트 룰도 활성화 이후 추가됐는데 공용 호스팅에 안 박혀서, 같은 방식으로 옵션 키가 없으면 flush_rewrite_rules()를 1회 호출하게 했다. 운영 서버에 직접 들어가 WP-CLI를 돌릴 수 없는 환경에서는, 코드가 스스로 부족분을 메우게 만드는 게 가장 안전하다.
강사 대시보드 — 강의 생성과 발행 요청
강사가 프론트 대시보드에서 직접 강의를 만들 수 있어야 했다. 다만 강사가 곧장 발행(publish)하지는 못한다. 강의 생성은 항상 draft 상태로 만들고, 발행은 별도의 "발행 요청 → 관리자 승인" 워크플로를 거친다.
// 강의 생성은 draft 로 — 직접 발행 불가
public function create_course(WP_REST_Request $request) {
$title = sanitize_text_field($params['title'] ?? '');
if ($title === '') return HM_Helpers::json_error('missing_title', '강의 제목이 필요합니다.');
$inst = HM_DB::get_row('SELECT id FROM ' . HM_DB::table('instructors') . ' WHERE user_id = %d', [$uid]);
$post_id = wp_insert_post([
'post_type' => 'hm_course',
'post_title' => $title,
'post_status' => 'draft', // 발행 전 — 관리자/강사 편집 후 승인
'post_author' => $uid,
'meta_input' => ['_hm_instructor_id' => $inst ? (int) $inst['id'] : 0],
], true);
if (is_wp_error($post_id)) return $post_id;
// sync_course_meta 훅이 만든 courses 행을 조회해 돌려준다
$course = HM_DB::get_row('SELECT id FROM ' . HM_DB::table('courses') . ' WHERE post_id = %d', [$post_id]);
return ['course_id' => $course ? (int) $course['id'] : 0, 'post_id' => $post_id, 'status' => 'draft'];
}
강의 발행 승인 워크플로
강사가 강의를 다 만들면 "발행 요청"을 누른다. 이때 강의의 CPT 상태를 pending(검토 대기)으로 올리고, 관리자에게 알림을 보낸다. 관리자는 wp-admin에서 검토 후 발행한다. 멱등성도 챙겼다 — 이미 publish면 거부, 이미 pending이면 "이미 요청됨"으로 안내한다.

승인 워크플로의 본질은 "강사의 요청이 관리자에게 확실히 닿는 것"이다. 그래서 알림은 메신저 알림톡을 1차로, 실패하면 이메일로 폴백한다. 이 폴백 패턴은 4편에서 다시 다룬다. 여기서 강조하고 싶은 건, 발행 권한을 코드로 막는 것만으로는 충분하지 않다는 점이다. 승인이라는 사람의 판단이 끼어들려면, 그 사람에게 닿는 알림 채널이 죽지 않아야 한다.
출시 직전, "각각은 되는데 합쳐서 안 되는" 버그들
기능이 많은 플랫폼의 출시 막바지는 늘 비슷하다. 단위로 보면 다 되는데, 전체 동선으로 묶으면 어딘가 막힌다. 출시를 막은 버그 여러 건을 한 번에 정리했는데, 그중 가장 골치 아팠던 건 같은 URL 경로의 REST 라우트를 두 군데서 중복 등록한 것이었다. 강의 리뷰 엔드포인트가 강의 컨트롤러와 커뮤니티 컨트롤러 양쪽에 등록돼 있었는데, WordPress의 dispatch는 한쪽만 호출한다. 그 결과 응답은 200인데 DB에는 insert가 0건인 조용한 실패(silent fail)가 났다. 한쪽 등록을 제거하고 커뮤니티 컨트롤러로 일원화해서 해결했다.

교훈은 분명했다. 200 응답은 "성공"이 아니라 "에러 없이 끝남"일 뿐이다. 결제·수강·리뷰처럼 부수효과(side effect)가 핵심인 동선은 응답 코드가 아니라 실제 DB 변화를 자동화 검증(QA 스크립트)으로 확인해야 한다. 그래서 강의 발행 승인, 회원 관리, VOD 매칭 같은 핵심 흐름을 헤드리스 브라우저로 끝까지 훑는 검증을 붙여, 응답은 멀쩡한데 데이터가 안 바뀌는 류의 버그를 걸러냈다.
정리하며
- LMS는 WordPress 토대 + 도메인 플러그인으로 올리되, 자주 조회·집계되는 강의 데이터는 postmeta가 아니라 전용 테이블에 정규화했다.
- 권한 캡은 버전을 붙여 가산식으로 동기화해, 다중 플러그인이 같은 역할에 캡을 더해도 서로 덮지 않게 했다.
- "강사는 자기 강의만"의 경계는
map_meta_cap에서post_author가 아닌 강사 프로필 체인으로 소유권을 판정해 그었고, REST 권한 콜백으로 한 번 더 막았다. - CPT ↔ 커스텀 테이블 동기화는 옵션 키 가드의 자가 회복 마이그레이션으로 배포 누락(404, 리라이트 미반영)을 코드가 스스로 메우게 했다.
- 발행은 draft → pending → 관리자 승인 워크플로로 강제하고, 알림은 닿을 때까지 폴백한다.
- 200 응답이 성공을 뜻하지 않는다 — 중복 라우트의 조용한 실패는 부수효과를 직접 검증해야 잡힌다.
다음 2편에서는 이 플랫폼의 차별점인 AI 챗봇 — 한국어 검색 정밀도와 법령 임포터를 다룬다.
'외주 개발일지' 카테고리의 다른 글
| 교육 플랫폼 개발기 (3) — 결제 서버 검증, 영수증 PDF, 계약서 AI 검토 (0) | 2026.06.23 |
|---|---|
| 교육 플랫폼 개발기 (2) — 한국어 RAG 챗봇과 법령 임포터의 검색 정밀도 (0) | 2026.06.21 |
| 웹·앱 공용 풀스택 시스템 개발기 (2) — 알림(alerts) 시스템 설계 (1) | 2026.06.21 |
| 웹·앱 공용 풀스택 시스템 개발기 (1) — 공용 백엔드 API 재설계 (0) | 2026.06.21 |
| 웹·앱 공용 풀스택 시스템 개발기 (3) — React Native 직원 앱과 운영 연동 (0) | 2026.06.21 |