396 Commits
Author SHA1 Message Date
HeuJung 56836a2b66 Merge branch 'develop' 2026-08-24 17:55:37 +09:00
HeuJung 78bb192bb0 fix(docs): 신규 번들 확장이 README 인벤토리에서 누락되던 문제 수정 및 재발 방지
7.0.9 에 추가한 비즈뿌리오 플러그인과 그 일본어 언어팩이 README.md·README.ko.md
인벤토리 표 양쪽에서 빠진 채 릴리즈 직전까지 남아 있었다.

manifest·CHANGELOG·버전 동기는 기존 하네스가 잡지만 README 인벤토리 표를 보는
검사는 없었다. 누락되어도 오류가 나지 않고 릴리즈 절차도 그대로 통과하므로,
공개 저장소 첫 화면에서 그 확장만 없는 상태로 배포된다.

- 두 README 의 플러그인·언어팩 표에 누락 행 추가
- check-readme-bundled-inventory.cjs 신설 — 네 종류(_bundled 모듈/플러그인/
 템플릿/언어팩)를 두 README 와 대조. 학습용 샘플은 현행대로 제외
- Stop 훅에 등록해 세션 종료 전 자동 검출
- 검출력 확인: 수정 전 README 로 4건(플러그인 2 + 언어팩 2)을 red 로 잡는다
2026-08-24 17:49:33 +09:00
HeuJung 9ff363bd14 Merge branch 'develop' 2026-08-24 17:27:59 +09:00
정정홍andGitHub 7b26c4beec Merge pull request from gnuboard:HeuJung/issue597
HeuJung/issue597
2026-08-24 17:07:41 +09:00
HeuJung 28d6a7752c fix(core,message_bizppurio): 브랜치 검증 발견 결함 4건 — 스냅샷 회귀·조건부 skip·낡은 주석
출시 전 /validate-branch 통합 검증에서 발견한 결함을 처리한다.

- BindingShape 라우팅 diff 스냅샷이 중간 상태에서 구워져 HEAD 가 red 였다.
 발산 517건을 덤프해 신규 41건이 전부 객체·배열 리터럴 바인딩(구 정규식
 오판 계열)이고 유해 지점 스냅샷은 이미 green 임을 확인한 뒤 기록값을 맞춘다.
- 채널 잔재 정리 검증이 게시판 모듈 활성 환경에서만 실행돼 대부분의 실행에서
 미검증으로 남았다. 정리 로직이 소유자를 묻지 않는 실제 계약대로 모듈 소유
 정의를 테스트 안에서 구성해 무조건 실행되게 한다.
- activity_log 주석이 폐기된 설계(조회 전용 전환·콘솔 위임)를 서술하고 있어
 현행 사실로 정정한다. 사용자 문구는 바뀌지 않는다.
- 설정성 테이블의 상한 없는 조회 근거와 seeder 반환 키 타입을 코드에 남긴다.
2026-08-24 17:03:42 +09:00
HeuJung 0bccd867a7 chore(release): 7.0.9 출시 준비 — 출시일자 정렬·공개 배포물 정합 결함 정리
7.0.9 릴리즈 사전 점검에서 공개 배포물에 실릴 결함을 전수 정리한다.

출시일자: 이번 사이클에 버전이 오르거나 새로 추가된 대상 10건의 CHANGELOG
출시일을 실제 공개일로 정렬. 결제 3종은 직전 릴리즈 페이로드로 이미 나갔으므로
제외했다.

내부 역할 호칭: 공개 배포물 26파일 49곳을 중립 표현으로 치환. 이미 공개된
페이로드에는 실질 0건이라 이번 릴리즈가 첫 유입이었다.

최초 출시 규격: 신규 플러그인과 그 언어팩의 CHANGELOG 를 선례 규격으로
재구성. 작성 중 확인되지 않은 서술 2건은 코드 실측으로 교정했다.

번들 ja 언어팩: 번역 값에 남은 한글 40건 정정. 같은 용어가 세 갈래로 갈려
있던 표기도 함께 통일했다. 정정값의 근거는 저장소가 이미 갖고 있었다 —
테스트가 정답으로 단언하는 값과 배포물이 어긋난 상태였다.

재발 방지: 원인은 번역 생성기의 용어집 미등록이므로 출력만 고치면 다음
빌드에서 되살아난다. 용어집에 8건을 등록하고, 규정 3건과 잔존 검출 룰을
신설했다. 룰은 수정 전 배포물로 red 가 되는지를 픽스처로 고정해 판정기가
모집단에 닿는지를 증명한다.
2026-08-24 16:18:52 +09:00
HeuJung e7fa1b5535 feat(message_bizppurio): 알림 템플릿 편집 통합·검수 의견·관리탭 개선 및 언어팩 번역 보존 결함군 수정
비즈뿌리오 알림톡 UX 개편 후속:
- 코어/게시판/이커머스 알림 템플릿 [편집] 모달에 확장 채널 편집 영역
 (extension_point 2종 + hidden_template_editor 채널 메타)을 신설하고,
 알림톡 템플릿·문자(SMS)·수신자 규칙을 한 창에서 통합 저장하도록 개정
 (행 하단은 상태 요약 전용, 승인 여부 2단 배지)
- 검수 신청에 검수자 전달 의견(comment) 동봉 — kapi request 로만 전달,
 행에는 저장하지 않음 (FormRequest 신설 + API 문서 백필)
- 관리 탭 모바일 카드뷰·도구줄 코어 관례 통일, 사이트맵 알림 정의
 다국어 라벨 결측 수정 + 코어 정의 라벨 패리티 테스트 신설

언어팩 번역 소실·보존 결함군 (보완 실측 → 커밋 전 점검에서 연쇄 확정,
전부 실패 테스트 선행 후 수정):
- [기본값 복원]이 활성 언어팩 번역(ja)을 영구 소실 → 복원 기본값에
 시딩과 같은 주입기(SSoT)로 팩 로케일 병합 (notification·identity)
- 코어 알림/본인인증 시더의 언어팩 주입 전면 불능 → 주입기가 연관/리스트
 양형태 수용 + identity 시더의 config 복합 키 보존
- 실패한 언어팩 업데이트가 active 팩을 installed 로 방치 → 상태 복원 확장
- 병합·제거의 사용자 수정 보존 판정 사문(연관 전제 isset) → dot-path 판정
 교정, 팩 주입 저장의 user_overrides 오염 차단(시딩 바인딩), 템플릿
 2모델 translatableTrackableFields 선언(beta.4 설계 누락), 같은 프로세스
 활성화 시 시더 번역 필터 stale(캡처 인스턴스 캐시) 교정
- 7.0.9 업그레이드 스텝 신설 — 활성 팩 seed 재동기화로 기설치본의 소실
 번역 자동 복구 (멱등·운영자 수정 보존·팩별 실패 격리)
2026-08-24 14:51:37 +09:00
HeuJung 40f62e79aa docs(changelog): 게시판·이커머스 CHANGELOG 의 비즈뿌리오 기능 항목 제거
템플릿 작성·검수·발송 기능의 실체는 전부 플러그인(sirsoft-message_bizppurio)
디렉토리에 있고 두 모듈 화면에는 주입만 되므로, 그 기능 서술은 플러그인
CHANGELOG 가 정합 위치다. 모듈 자신의 변경(통합 탭 선언 지원)을 기록한
Changed 항목만 남긴다.
2026-08-22 23:05:31 +09:00
HeuJung e17731d8cc feat(message_bizppurio): 알림톡 템플릿 라이프사이클 개편 — DB 기반 작성·검수·승인
비즈뿌리오 알림톡 템플릿을 콘솔 위임(발송 전 실시간 조회) 방식에서 시스템 내
작성(draft) → 검수 신청(requested) → 승인(approved) 라이프사이클로 개편한다.
발송 판정의 SSoT 를 DB(bizppurio_templates)로 옮기고 발송 전 실시간 조회를 폐지.

- 알림↔템플릿 바인딩 모델(notification_bindings) 제거, 알림 1건당 템플릿 1행으로 대체
- 검수 신청: template_code 자체 채번(codeCheck 재시도) → kapi add/update → request
- 상태 동기화: 30분 스케줄러 + 수동 새로고침, 승인 전이 시 content 를 approved_content 로 동결
- SMS 본문 언어별 입력 복원(수신자 로케일 발송), SMS 단독 선택 시 알림톡 미발송
- 3면(코어/게시판/이커머스) '비즈뿌리오' 통합 탭 + 플러그인 '알림 템플릿 관리' 화면
- 중복 검수 신청은 서버 원자 선점(claimForInspection 조건부 UPDATE)으로 차단
- admin_basic 1.0.6 / board 1.0.5 / ecommerce 1.1.2: 채널 서브탭 통합 탭 선언(tab_channels) 지원
- 검증: 계획서 대비 5라운드 전수검증, §6.3 실측 매트릭스 Playwright 19 고정,
 PHPUnit 421 / Vitest 228 green, audit 0 error
2026-08-22 22:44:57 +09:00
HeuJung 6a8a537f10 feat(message_bizppurio): 임시 삭제된 비즈뿌리오 플러그인 복구 및 현행 규정 정합화
32afe9530 에서 게시판 반응 기능과 함께 임시 제거됐던 비즈뿌리오 메시징
플러그인 본체(137 파일)와 일본어 번들 언어팩(9 파일)을 삭제 직전 상태
(32afe9530~1)로 복구한다. 템플릿 신청·승인 개편의 기반 작업.

복구 범위
- plugins/_bundled/sirsoft-message_bizppurio 전체 + 번들 ja 언어팩
- 공유 파일의 비즈뿌리오 참조 복원: build-language-pack-ja.cjs 팩 정의,
 api-doc-unfilled-baseline.json(32건), docs/backend/api/README.md 표 행,
 audit coverage 노트·vite-sourcemap-env-gate 룰 주석, via 테스트 주석,
 ·AGENTS.md 확장 API 표(자동 재생성), 라우팅 패리티 스냅샷(+1)

삭제 이후 강화된 규정 2건 정합화
- TokenCheckController: 예외 원문을 메시지 키 자리에 전달하던 422 응답을
 키(token_check.failed) + errors.bizppurio_message 페이로드로 분리
 (GenericCatchStatusCodeContractTest 계약). 관리자 토스트는 errors 페이로드로
 상세 사유를 계속 표시하도록 레이아웃 동기 수정, lang ko/en/ja 키 추가
- AlimtalkTemplateController::index: base Request 주입 금지 룰에 따라
 AlimtalkTemplateListRequest FormRequest 신설 (형태 검증만 — kapi 위임 유지)

미복원(의도)
- 게시판·이커머스 CHANGELOG 의 알림톡 연결 문구 2줄은 연결 방식이 로
 재설계되므로 되살리지 않고, 완료 시점에 새 동작 기준으로 차기 버전에 기재

검증: TokenCheck 7 + AlimtalkController 10 + GenericCatch 계약 3 (PHPUnit),
플러그인 레이아웃 Vitest 137건, BindingShape 라우팅 패리티 8건 green.
audit 는 복구 전부터 baseline 처리된 API 문서 미채움 32건만 잔존.
2026-08-22 22:39:36 +09:00
정정홍andGitHub acdff53792 Merge pull request from gnuboard:HeuJung/issue610
feat(content): 본문 이미지 목록 썸네일·og:image 폴백 전 티어 구현
2026-08-22 22:26:42 +09:00
HeuJung 1ba67ca7cf feat(content): 본문 이미지 목록 썸네일·og:image 폴백 전 티어 구현
공개 이슈 https://github.com/gnuboard/g7/issues/22 — 본문에 삽입한 이미지가
카드/갤러리 목록 썸네일과 공유 미리보기(og:image)에 반영되지 않던 공백을
코어+3모듈+2템플릿에서 해소.

- 코어 7.0.9: HtmlImageExtractor(내부 이미지 한정, origin 정규화 SSoT 재사용)
 + seo.og_image_default 사이트 기본 공유 이미지 설정 + SeoMetaResolver 폴백 체인
- board 1.1.0 / ecommerce 1.2.0 / page 1.1.0: content_thumbnail_url 캐시 컬럼,
 saving 추출(html 모드 한정·첨부 우선·비밀글 게이트 유지),
 filter_content_thumbnail 훅, 기존 데이터 백필 업그레이드 스텝
- ecommerce 카테고리 og:image 생산자 신설, page og:description 死키 정정
- sirsoft-basic 1.1.2 og 바인딩 정정, admin_basic 1.0.7 SEO 탭 업로더(+ja 팩 2종)
- 검수 중 발견한 text 모드 오캐시 결함은 실패 테스트 선행 후 수정
2026-08-22 22:21:54 +09:00
HeuJung 14eb2e39e4 Merge branch 'develop' 2026-08-22 11:07:52 +09:00
HeuJung ca4834bbb9 fix(release): 7.0.8 사전점검 전수조사 — 릴리즈 차단 2건 포함 결함 7건 수정 및 출시일 정렬
- pay_nhnkcp 1.0.3 분리: 관리자 API 권한 게이트 변경분이 이미 배포된 1.0.2
 섹션에 소급 누적돼 있어 배포본과 어긋나고 업데이트 감지도 불가하던 것을
 manifest/package/composer/lock 동기와 함께 정정
- 공개 시나리오 매니페스트의 내부 경로 참조 제거 — 릴리즈 파일 누출 검사가
 error 로 발행을 차단하던 상태 해소 (내부 정적 검사 연계는 도구 비귀속 서술로 이관)
- 모듈/플러그인/템플릿 수동 설치에서 깨진 zip·manifest 누락 같은 사용자 입력
 오류가 500 으로 위장되던 422 계약 회귀 복원 — 서비스 경계에서 도메인 예외로
 승격해 상태코드와 실패 사유를 함께 보존 (회귀 테스트 동반)
- 본인인증 정책 검색 버튼이 입력 중인 검색어를 반영하지 않고, 초기화 후에도
 입력창에 이전 검색어가 남던 문제 수정 — 폼 자동바인딩 옵트아웃 + 명시 상태
 전환, E2E 회귀 스펙과 트러블슈팅 사례 동반
- 프록시 URL 검증 비문자열 분기의 :schemes 치환 누락 보정 (회귀 테스트 동반)
- 7.0.8 사이클 출시일자를 2026-08-22 로 정렬 (코어·엔진·확장 7종·언어팩 3종,
 버전·콘텐츠 무변경) 및 설치본 언어팩 동기
2026-08-22 10:16:08 +09:00
정정홍andGitHub df918de2a6 Merge pull request from gnuboard:HeuJung/issue605
fix(security): KVE-2026 신고 4건 조치 — 관리자 API 권한 게이트 + 금전 처리 경쟁 조건
2026-08-22 09:13:33 +09:00
HeuJung 494e581973 fix(security): KVE-2026 신고 4건 조치 — 관리자 API 권한 게이트 + 금전 처리 경쟁 조건
KISA 신고 4건(KVE-2026-1886/1887/1893/1894)을 검증하고, 미수정 2건과
동종 결함 전수 조사에서 나온 결함을 함께 조치했다.

권한 게이트 (KVE-2026-1893)

- NHN KCP 관리자 주문 연동 7경로가 admin 보유 여부만 보고 세부 권한을 보지 않아,
 업무 권한 없는 관리자가 주문번호·결제정보·수령인 연락처를 조회하고 에스크로
 배송등록까지 할 수 있었다. 조회는 orders.read, 등록은 orders.update, 설정성
 경로는 settings.read 로 게이트해 다른 PG 연동과 강도를 맞췄다.
- 마케팅 채널 저장은 코어 플러그인 설정과 같은 저장소를 덮어쓰는 우회 경로라
 core.plugins.update 를 부착했다.

첨부 해시 노출 (KVE-2026-1894 잔재)

- 비밀글의 썸네일 URL 이 목록·상세 응답에 그대로 실려 첨부 해시가 노출됐다.
 이미지 서빙은 이미 차단돼 있었으나 식별값 자체가 나갔다. 첨부 목록과 같은
 게이트를 써서 값만 가리고 필드는 유지한다.

경쟁 조건 (KVE-2026-1886 + 전수 조사)

- 쿠폰 차감을 조회 후 갱신에서 조건부 UPDATE 로 바꿔 1회 제한 쿠폰의 동시
 사용을 막고, 선점당한 주문은 409 로 되돌린다.
- 라이브 병렬 재현에서 그 롤백이 동작하지 않는 것을 확인했다. Action 훅 기본값이
 큐 래핑 + afterCommit 이라 금전 처리가 호출자 커밋 뒤에 실행되고 있었다.
 쿠폰·적립금 차감/복원 5개 구독에 sync 를 선언했고, 회귀 테스트는 손 등록이
 아니라 실제 등록 경로를 태워 고정했다.
- 동시 부분취소의 취소 누적 컬럼 lost update, 적립 lot 의 read-modify-write,
 주문옵션당 적립 lot 중복 생성을 각각 행 잠금·컬럼 연산·유니크 제약으로 막았다.
 기설치본의 중복 lot 은 인덱스 생성 전에 금액을 합산해 한 줄로 통합한다.

규정·문서

- sync 판정 기준을 hooks.md 에 명문화하고 /AGENTS.md Listener 표와
 coverage manifest 에 반영했다. 같은 증상을 다시 만났을 때의 진단 경로를
 트러블슈팅 사례로 남겼다.
2026-08-22 01:19:36 +09:00
정정홍andGitHub 510e498fec Merge pull request from gnuboard:HeuJung/issue604
fix(engine): 구형 Safari 부팅 실패 해소 및 배포 번들 브라우저 하한 회귀 방지
2026-08-21 21:07:33 +09:00
HeuJung 0db8f9c5fb fix(engine): 구형 Safari 부팅 실패 해소 및 배포 번들 브라우저 하한 회귀 방지
코어 엔진 번들의 정규식 lookbehind 2건이 Safari 16.4 미만에서 파싱 자체를
거부당해 iOS 15 대 전 기기에서 SPA 가 통째로 뜨지 않았다. 정규식 리터럴은
스크립트 파싱 단계에서 검증되므로 그 문법을 모르는 엔진은 파일을 한 줄도
실행하지 못하고, 번들이 async/defer 없는 동기 classic 스크립트라 폴백 경로도
없었다. 빌드 타깃(es2020)이 이를 막지 못한 이유는 ES 연도와 브라우저 지원
연도가 다르기 때문이며, 정규식 리터럴 문법은 다운레벨 자체가 불가능해
어떤 타깃 설정으로도 걸러지지 않는다.

- 두 정규식을 lookbehind 없는 형태로 교체(engine-v1.60.6). 선행 따옴표·구분자를
 소비한 뒤 캡처 그룹으로 분기하며, 식별자 인덱스가 match[1] → match[2] 로
 이동한다. 무작위 200,034 표본 퍼징에서 교체 전후 결과 불일치 0건.
 파싱 하한 실측 Safari 16.4 → 14.1 (제보자 기기 15.6 포함).

- 부팅 실패 안내를 사유별로 가른다. 번들을 받았는데 실행되지 않은 경우와
 끝내 받지 못한 경우는 사용자가 취할 행동이 정반대인데 같은 문구였다.
 사유는 추측이 아니라 window error 이벤트로 SyntaxError 를 관측해 판정한다
 (파싱 실패 시 script 는 error 가 아니라 load 를 발생시켜 element 이벤트로는
 구분 불가). 비호환 분기는 새로고침해도 낫지 않으므로 버튼을 렌더하지 않는다.
 인라인 부트스트랩은 ES5 를 유지해야 이 안내 자체가 구형에서 살아남는다.

- 재발 방지: 배포 JS 산출물의 브라우저 하한 초과 문법·API 를 정적 검사로 차단.
 부팅 임계 번들(코어 엔진·템플릿 컴포넌트)은 파싱에 실패하면 안내 화면조차
 렌더되지 않으므로 하한과 같은 버전을 요구하는 정규식 리터럴 문법도 금지한다.
 lookbehind 의 Safari 16.4 는 하한과 정확히 같아 하한 초과만 보는 검사로는
 영원히 잡히지 않았다 — 그 지점이 이번 사각이었다.

- 지원 브라우저 최소 버전(Chrome 111 / Safari 16.4 / Firefox 128)과, 하한 미만
 브라우저에서도 화면 표시와 기본 이용은 유지한다는 방침을 문서에 명시.

부수 수정: network-resilience E2E 4건이 자산 URL extensionless 모드에서 URL
glob 이 한 건도 매칭되지 않아 실패하고 있었다. 같은 파일의 컴포넌트 번들
테스트만 이중 모드를 알고 형제 케이스는 몰랐던 불균형이라 매처를 통일했다.
2026-08-21 20:55:57 +09:00
정정홍andGitHub 80d16c80bc Merge pull request from gnuboard:HeuJung/issue603
fix(extension): 확장 수명주기 캐시 무효화 순서 회귀 및 실패 사유 전달
2026-08-21 17:48:10 +09:00
HeuJung 03cbb99196 fix(extension): 확장 수명주기 캐시 무효화 순서 회귀 및 실패 사유 전달
route:cache 는 새 앱을 부팅해 라우트를 수집하고, 그 부팅의 확장 라우트 프로바이더는
DB 가 아니라 캐시된 활성 확장 목록을 읽는다. 그래서 rebuild 가
invalidate*StatusCache 보다 앞서면 방금 바뀐 상태가 빠진 채 라우트가 박제되고,
라우트 캐시에는 스캔 폴백이 없어 오류도 로그도 없이 404 가 된다. 무효화를 굽기 직전이
아니라 DB 상태 쓰기 직후로 올려, 같은 목록을 읽는 훅 매핑 캐시까지 함께 바로잡았다.
update 경로는 Updating 전이 직후에 비우면 그 창의 오토로드 갱신이 확장을 비활성으로
판정하므로, 상태 복원 직후에 비운 뒤 훅 캐시를 다시 굽는다.

플러그인 라우트 프로바이더에는 활성 게이트가 없어 비활성 플러그인의 API 가 계속
응답했다. 화면·메뉴만 사라지고 기능은 살아 있는 상태였다. 모듈과 같은 기준을 적용했다.

실패 사유가 하위 계층에서 버려져 관리자 화면에 :error 자리표시자가 그대로 노출되던
문제도 고쳤다. 반환 경로를 깨지 않도록 배열 키 reason 과 뒤에 붙인 선택적 out
파라미터로 사유를 실어 올리고, 확장이 수명주기 훅에서 사유를 남길 수 있는 통로를
추가했다. 설치 경로의 광역 RuntimeException catch 는 도메인 예외로 좁혀 원본 키와
파라미터를 응답에 싣는다 — 상태코드 422 는 유지해 사용자 계약을 함께 바꾸지 않는다.
언어팩 화면은 프로덕션에서 예외 원문을 싣지 않는 것이 확정된 계약이므로, 자리를
일반 문구로 채우는 대신 치환 자리 자체를 제거했다. 원문은 종전대로 errors 통로를
거쳐 디버그 모드에서 도달한다.
2026-08-21 17:09:30 +09:00
HeuJung 565f6c0117 chore(pay_kginicis): 7.0.7 배포 이후 변경분을 1.1.2 로 분리
CBT 연결 점검의 프록시 경유 수정과 코어 최소 버전 상향이 이미 배포된
1.1.1 섹션에 누적돼 있었다. 배포본과 내용이 어긋나지 않도록 1.1.2 로 분리한다.
2026-08-21 13:53:49 +09:00
HeuJung d8ca281464 chore(release): 코어 7.1.0 → 7.0.8 하향 및 7.0.7 이후 변경분 출시일 정렬
프록시 설정 추가분을 마이너(7.1.0)가 아닌 패치(7.0.8)로 내보낸다.
7.0.7 이후 변경된 코어·템플릿·플러그인·언어팩의 출시일을 2026-08-21 로 맞춘다.

>=7.1.0 을 요구하던 본인인증 플러그인의 코어 최소 버전을 >=7.0.8 로 내린다 —
그대로 두면 존재하지 않는 버전을 요구해 플러그인이 자동 비활성화된다.
앞선 작업에서 누락된 package-lock.json 버전(1.0.3)도 함께 맞춘다.
2026-08-21 13:53:42 +09:00
정정홍andGitHub 6d2677e7bd Merge pull request from gnuboard:HeuJung/issue600
feat(settings): 코어 아웃바운드 HTTP 프록시 설정 추가
2026-08-20 17:05:39 +09:00
HeuJung a7b7c64573 feat(settings): 코어 아웃바운드 HTTP 프록시 설정 추가
접속 IP 를 제한하는 결제사 API 를 로컬·스테이징에서 연동하려면 서버가
내보내는 요청의 출발지 IP 를 바꿔야 한다. 브라우저 프록시로는 바뀌지 않는
축이라 코어 환경설정으로 도입한다.

게이트는 디버그 모드이며 판정은 OutboundProxy 한 곳이 소유한다. 화면의
조건부 렌더링은 편의일 뿐이라 저장 API 직접 호출을 막지 못하므로, 실질
게이트를 판정 지점에 둔다. 주입·적용 지점은 결과만 소비한다.

적용은 Http::globalOptions 전역 옵션이라 확장의 Http:: 호출까지 함께
경유한다. 외부 연동 규약상 curl 핸들을 직접 다뤄야 하는 확장은
OutboundProxy::curlOptions 로 같은 프록시를 탄다 — KG이니시스 본인인증
승인 요청과 CBT 연결 점검을 이 통로로 편입했다. CBT 의 TCP 443 확인은
원시 소켓으로는 프록시를 태울 수 없어 curl CONNECT_ONLY 로 교체했다.

저장 전 연결 테스트는 저장값이 아니라 제출값을 검사하고, 그 프록시를
거쳤을 때 외부에 보이는 IP 를 함께 보고한다. 운영자가 결제사에 등록할
값이라 저장하고 나서 되짚지 않도록 했다. 적용과 같은 조립을 거치므로
확인한 구성과 저장 후 적용되는 구성이 어긋나지 않는다.
2026-08-20 17:01:27 +09:00
HeuJung 0e8b625436 Merge branch 'develop' 2026-08-19 09:20:00 +09:00
HeuJung 8fa97bc8bd docs(changelog): 7.0.7 릴리즈 공개 제보자 attribution 전수 검사 반영
- 이커머스 「삭제된 PG」 항목에 @Tuwasduliebst 제보 attribution 부착
 (제보 8/12 → 수정 8/14 — 같은 배치 제보 중 유일한 누락)
- 언어팩(ja) 5종의 공개 이슈 파생 번역 항목 11건에 원본 ko attribution 승계
 ( — core-ja/admin_basic-ja 기존 선례와 기준 통일)
- README 기여자 목록 변경 없음(신규 기여자 없음), ko↔ja 동기 검사 통과
2026-08-19 09:17:01 +09:00
정정홍andGitHub f0562a8553 Merge pull request from gnuboard:HeuJung/issue596
docs(changelog): 장바구니 선택 개수 수정에 공개 PR 기여 attribution 반영
2026-08-19 08:55:40 +09:00
HeuJung 5d58fb5c5b docs(changelog): 장바구니 선택 개수 수정에 공개 PR 기여 attribution 반영
공개 PR https://github.com/gnuboard/g7/pull/93 은 내부에서 이미 상위 호환으로
수정 완료된 결함과 중복이라 코드는 이식하지 않고, 지시에 따라 sirsoft-basic
1.1.1 CHANGELOG 의 해당 항목 attribution 에 수정 기여를 병기한다. 동일인의
제보+수정이므로 이슈 번호만 묶고 핸들은 1회 표기.
2026-08-18 23:12:00 +09:00
HeuJung 8bf40465df docs(release): 7.0.7 릴리즈 CHANGELOG 섹션 내 Changed 카테고리 중복 병합
병렬 검수 병합 과정에서 미릴리스 신규 버전 섹션 4곳(pay_kginicis 1.1.1,
pay_nhnkcp 1.0.2, pay_nicepayments 1.0.2, g7-core-ja 1.0.6)에 ### Changed
헤딩이 두 번 생긴 것을 하나로 병합. 불릿 내용은 무변경이며, 이미 공개
배포된 과거 버전 섹션의 동형 중복 12건은 소급 수정 금지 규정에 따라 유지.
2026-08-18 12:55:24 +09:00
HeuJung 6a756bdec1 docs(release): 7.0.7 출시 연기 — 릴리즈 CHANGELOG 27종 날짜 2026-08-18 → 2026-08-19 변경
코어·엔진·모듈 3종·플러그인 10종·템플릿 2종·언어팩 11종의 미공개 working
섹션 헤딩 날짜만 변경 (버전·콘텐츠 무변경).
2026-08-18 11:46:49 +09:00
HeuJung 07c459f0ff Merge branch 'develop' of https://github.com/gnuboard/dev-g7 into develop 2026-08-18 11:04:01 +09:00
HeuJung 932866da8f fix(ecommerce): 숨김 리뷰 이미지의 관리자 썸네일을 한시 서명 URL 로 복구
KVE-2026-1914 S-2 게이트가 숨김(HIDDEN) 리뷰 이미지를 무조건 404 차단해,
관리자 리뷰 화면의 <img> 썸네일(인증 헤더를 실을 수 없는 렌더 경로)이 깨졌다.
게시판·페이지 첨부와 동일 설계로, 게이트를 통과한 관리자 응답의 직렬화
시점에만 30분 한시 서명 download URL 을 발급하고 서빙 엔드포인트가 유효
서명을 허용한다. 무서명·변조·만료 요청 게이트와 전시중 리뷰의 무서명
공개 URL(직접 URL/CDN 포함)은 종전과 동일하다.

숨김 리뷰가 이미지와 함께 직렬화되는 경로는 관리자 응답뿐이므로(공개
목록은 VISIBLE 필터, 작성 직후는 이미지 미로드) 서명 발급이 곧 게이트
통과 자격의 위임이다.
2026-08-18 10:54:26 +09:00
HeuJung 04f1066e66 fix(release): 7.0.7 사전점검 실측 발견 결함 6건 보완 및 규정 반영
브라우저·SEO 실측(T1~T7 매트릭스 + 봇 렌더 스윕)에서 발견된 결함을 보완한다.

- 코어: 회원 일괄 상태변경 응답 메시지의 :count 플레이스홀더 미치환 수정
- 게시판·페이지: 비밀글·삭제글·미발행 첨부 썸네일이 KVE-2026-1914 게이트로
 깨진 것을 한시 서명 preview URL(게이트 통과 직렬화 발급 + 유효 서명 허용)로
 복구 — 무서명·변조·만료 요청 게이트는 종전과 동일
- 페이지: 발행/미발행 전환(after_publish)에도 SEO 캐시·사이트맵 갱신 구독
- 게시판: 게시판 디렉토리(전체 목록 + 내장 최근글)에 열람 권한 필터 적용
- 검색 화면: searchResults 데이터소스 auth_mode=optional 선언으로 로그인
 회원 검색 범위 복구 + data-sources.md 선언 의무 규정화(coverage 등록)
2026-08-18 10:38:50 +09:00
HeuJung 4b986be0b2 Merge: 7.0.7 병렬 검수 수정분 통합 — 구조분해·JSON-LD 중복 구현 단일화, 양측 고유 수정 보존
동일 결함 2건(표현식 배열 구조분해 회귀, JSON-LD 이스케이프)을 양 세션이 독립
구현해 충돌. 해소 기준:

- 평가기 구조분해: JS iterator 시맨틱 구현 채택(구 평가기 new Function 패리티가
 회귀 수정의 본래 의도). 관대(undefined) 바인딩 구현과 그 단언 1건은 폐기.
 단위 테스트는 양측 병합(구조분해 10케이스), E2E·시나리오 축은
 destructuring_param 단일 케이스로 통합. 무관 심볼의 @since 소급 변경은 원복.
- JSON-LD: JSON_HEX_TAG|JSON_HEX_AMP(상위집합) 채택, XSS 단위 테스트 중복 제거.
- 양측 고유 수정 각각 보존: composeTitleSuffix 접미사 조립(타 세션) /
 스케줄 확장 명령 웹 검증 폴백·SeoMetaResolverTest 설정 격리(본 세션).
- min.js 는 병합 소스로 core:build --production 재빌드.

검증: SafeExpressionEvaluator 120/120, SeoMetaResolverTest 68/68, 배포 번들
E2E 6/6. 실측 — 게시판 환경설정 권한 섹션 60컨트롤 렌더, 장바구니 담기 201,
봇 title 접미사 공백 복원·매달린 구분자 제거, 검색 XSS 봇/_escaped_fragment_
양 경로 이스케이프 확인.
2026-08-18 10:16:50 +09:00
HeuJung a1e1706ddc fix(release): 7.0.7 회귀 검수 발견 결함 3건 수정 — 표현식 배열 구조 분해·스케줄 확장 명령 웹 검증·JSON-LD 이스케이프
- 표현식 평가기가 화살표/함수 파라미터의 평탄한 배열 구조 분해(([k, v]) =>, 엘리전 홀)를
 지원하지 않아 배포 레이아웃 60여 파일(게시판 권한 computed·검증 오류 표시)이 평가 실패하던
 7.0.7 평가기 교체 회귀 수정 (engine-v1.60.5) + --production 재빌드.
- 스케줄 저장 검증이 HTTP 문맥에서 확장 커맨드를 해석하지 못하던 문제(확장 프로바이더의
 runningInConsole 등록 게이트로 Artisan 레지스트리 부재)를 프로바이더 $commands 선언 폴백으로
 수정 — 판정 기준(클래스 네임스페이스 + 실제 명령명 대조) 동일, fail-closed 유지.
- 봇 렌더 JSON-LD json_encode 에 JSON_HEX_TAG 추가 — 사용자 입력(검색어·게시글 제목)의
 </script> 조기 종료로 인한 저장형 XSS 차단, schema.org 데이터는 무손실.
- SeoMetaResolverTest 의 og_default_site_name 미고정 환경의존 실패를 setUp 고정으로 격리.
- 루트 CHANGELOG 7.0.7 Security/Fixed 각 1건(기출시 결함만), 시나리오 매니페스트 2건 갱신.
2026-08-18 10:00:01 +09:00
HeuJung 0bb2ab9b49 fix(seo,engine): 7.0.7 검증 발견 결함 수정 — 표현식 구조분해 회귀·타이틀 접미사 조립·JSON-LD 이스케이프
- SafeExpressionEvaluator 교체(engine-v1.60.0)가 구 평가기가 허용하던 화살표
 파라미터 배열 구조분해(([k, v]) / ([, vid]))를 누락해 해당 문법을 쓰는 액션
 params 가 미평가 원문으로 전송, 장바구니 담기/바로 구매가 전 상품 불능이던
 회귀를 수정 (engine-v1.60.5, production 재빌드 동반). 저장소 레이아웃 54파일
 104곳 사용 형태 전수를 단위/E2E 테스트로 잠금
- TrimStrings 가 타이틀 접미사의 선행 공백을 제거해 봇 렌더 제목이
 "제목| 사이트명" 으로 접착되고 빈 제목 화면은 "| 사이트명" 이 매달리던 기존
 결함 수정 — 조립 규칙을 composeTitleSuffix 단일 지점(SSoT)으로 정규화
- JSON-LD 직렬화에 JSON_HEX_TAG 미적용으로 검색어의 </script> 가 스크립트
 컨텍스트를 조기 종료해 실행 가능한 script 요소가 재생성되던 반사형 XSS(기존
 결함) 차단 — 태그 문자 이스케이프, 구조화 데이터 의미는 보존
2026-08-18 09:53:50 +09:00
HeuJung b3d0da9355 fix(release): 7.0.7 릴리즈 검증 발견 결함 전수 처리 및 출시일 8/18 정렬
7.0.7 페이로드 전수 검증에서 발견된 error 4건·warn 14건을 릴리즈 전에
처리했다. 미출시 분량의 자체 결함(회원 통합검색 500, 관리자 스코프 403 의
500 위장 등)이므로 페이로드에 합류시키고, 출시 대상 26개 CHANGELOG 의
버전 섹션 날짜를 2026-08-18 로 정렬했다.

- 통합검색 import 복원 + 이커머스 관리자 typed 403 catch 대칭(테스트 동반)
- admin 판정 스니핑 사설 사본 전수 수렴(BoardResource) + 조건 스캔 테스트
- 스케줄 인터프리터 변형 표기(확장자·버전 접미사) 정규화 차단
- ckeditor5 참조 판정 배치화·fail-safe, 결제 플러그인 진단 페이로드 대칭
- 템플릿 다크 대비·상태 Select value-제어 + --production 재빌드
- Upgrade_7_0_7 잔존 중복 색인 정리 스텝, API 문서·i18n(ko/en/ja) 동기
2026-08-18 08:12:55 +09:00
HeuJung ed985dd175 chore(release): 7.0.7 공개 릴리즈 사전점검 정비 — 누출 단서 주석 정리 + 태그 관례 정합
- 공개 릴리즈 누출 검사 error 3건 해소: 소스 주석의 내부 규정 파일명·내부 스크립트
 경로 참조를 공개 문서 참조/도구 비귀속 표현으로 교체
- warn 4건 해소: 테스트·주석의 내부 역할 호칭을 "운영 실측 제보" 로 정리
- 릴리즈 스크립트(코어·확장)의 버전 태그를 공개 저장소 기존 태그 관례(v 접두사 없음)에
 정합 — 접두사 혼재로 태그 시리즈가 갈라지는 것을 차단
2026-08-17 21:36:15 +09:00
정정홍andGitHub 3ee37e8399 Merge pull request from gnuboard:HeuJung/issue594
fix(security): 스케줄 셸 인터프리터 인라인 코드 우회 차단
2026-08-17 21:02:05 +09:00
HeuJung 2fb452aa44 fix(security): 스케줄 셸 인터프리터 인라인 코드 우회 차단
셸 스케줄 검증이 첫 토큰 basename 만 화이트리스트와 대조하고 뒤 인자를
검증하지 않아, 운영자가 bash 를 등록하면 `bash -c id` 로 임의 OS 명령이
실행됐다(KVE-2026-1653). 인터프리터는 범용 크론 용도로 등록을 허용하되,
스크립트 자리(첫 인자)에 형태 규칙을 적용해 인라인 코드·상대경로·트래버설·
artisan 우회·완전거부형(env/awk/make 등)을 저장·실행 양쪽에서 거부한다.
2026-08-17 20:59:42 +09:00
정정홍andGitHub 19424877e8 Merge pull request from gnuboard:HeuJung/issue573
feat(board,ecommerce,pay,verification,templates,core): 문의 답변 사슬·답글 삭제 정책·검증 계약 + 실패 사유 표면화 전수
2026-08-17 17:12:34 +09:00
HeuJung 3ebbda5d86 feat(board,ecommerce,pay,verification,templates,core): 문의 답변 사슬·답글 삭제 정책·검증 계약 + 실패 사유 표면화 전수
공개 (답변 중복 등록)/(문의 삭제 시 답변 잔존) 수정과 동형 결함 전수 해소.

- 문의 답변 단일성: 1차(피벗 is_answered) + 2차(게시판 리스너 duplicate 마커) 이중 방어,
 경합 경로에서도 사유(reply_already_exists 422)가 보존되도록 훅 반환 계약 확장
- 질문·답변·피벗 생명주기 대칭: 삭제/복원/영구삭제 훅 연동 + 고아 데이터 백필 업그레이드 스텝
- 게시판별 답글 삭제 정책(cascade/block) 신설, 정렬 화이트리스트·per_page 게이트
 (전량 조회 소비처 200/999 를 수용하는 상한 999 — 조였다가 관리자 화면 2곳 422 회귀 즉시 정정)
- 결제/본인인증 플러그인 검증 계약: FormRequest 15종 승격 + 컨트롤러 catch 정합.
 거래 조회 tid 상한은 저장 컬럼(varchar 100) 기준 — 해외 CBT TID(40자) 수용
- onError 토스트 고정 문구 전수 해소(67건/50파일/15확장) — 서버 실패 사유를 그대로 표면화,
 change-password 오변수(response→error) 교정
- 코어 플러그인 설정 검증 안내의 항목 표시명이 스키마 라벨(ko/en) 폴백에 덮여 요청 로케일
 (ja) 라벨을 잃던 회귀 정정 — 번역 등록이 있으면 그것을 우선 (7.0.7 미출시 기능 자체 결함)
- 사전 결함 정리(Icon 등가표 27·darkmode 2·validated 통일), API 문서·CHANGELOG·ja 팩 동기
- develop(60652fdc0) 리베이스: getTable 정비와 병합 — 양측 의도 보존 (충돌 18파일)
2026-08-17 17:04:44 +09:00
정정홍andGitHub 60652fdc07 Merge pull request from gnuboard:HeuJung/issue577
fix(security): 보안 제보 14건 + 유사 결함 전수조사 수정
2026-08-17 16:01:21 +09:00
HeuJung cf1d8ab52c fix(security): 보안 제보 14건 + 유사 결함 전수조사 수정
sir.kr 커뮤니티 제보(Xbuilder 님 외)와 전수조사 발굴분을 코어·게시판·
이커머스·페이지·ckeditor5 전반에 반영.

- 통합 검색 하이라이트: 코어 SearchHighlighter 로 원문 이스케이프 후 강조
 (저장형 XSS 차단, board/ecommerce/page 3중복제 통합)
- 공개 최신글·인기글·관리자 대시보드: 읽기권한 없는 게시판·블라인드·비활성
 게시판 글 필터. 비밀글은 기존 공개 정책대로 제목 노출 유지(본문만 보호)
- 게시판 관리 대시보드 권한 게이트, 이커머스 쿠폰 다운로드·리뷰 대상·주문옵션
 확정 스코프 강화, 취소/환불 항목 검증 회원/비회원 대칭
- 만료 토큰 거부(SetLocale), 회원 탈퇴·정보관리 실패 시 내부 오류 원문 마스킹
- 예외→응답 매핑 정비(generic catch 상태코드), ckeditor 업로드 권한 게이트 정리

정책 정합 정정: 착수 계획이 "비밀글 제목 노출=취약점"으로 전제해 공개 최신글·
대시보드에 넣었던 is_secret 필터는, 저장소가 확정한 "제목 공개, 본문만 보호"
정책(2026-01-02/02-04/06-18/06-19)과 배치되어 되돌림. 읽기권한·블라인드 필터는 유지.
Chrome MCP 실측(T1~T10) + 회귀 테스트로 확인.
2026-08-17 15:46:38 +09:00
정정홍andGitHub 2b5972437b Merge pull request from gnuboard:HeuJung/issue589
HeuJung/issue589
2026-08-17 15:18:07 +09:00
HeuJung febffb173e fix(core,admin-template): GC 스케줄 허용목록 동기 + 스토리지 잔존물 GC 신설 + 스케줄 모달 저장 회귀 수정
공개 후속 전수 조사의 실작업:
- attachments:prune-orphans 관리자 예약 허용목록 등재 + 가드 테스트 도출 규칙을
 런타임 validator 와 일치 (옵션 포함 키는 영원히 매칭되지 않는 죽은 항목)
- layout-previews:cleanup 을 gc-schedules 가드 구간으로 편입 (+onOneServer)
- storage:prune-leftovers 신설: 중단된 업데이트/설치의 _pending 스테이징·코어 임시·
 vendor 스테이징(3일)과 오래된 백업(30일, 최신 1개 상시 보존), 레거시 browser.log 회수
 — 산출물 누적 + 회수 경로 부재( 동형) 7건 해소
- 언어팩 설치 임시 디렉토리 정리를 finally 로 이동 (설치 성공 시 100% 누수)
- 브라우저 콘솔 로그를 daily(7일) 로테이션으로 전환 (단일 파일 989MB 실측)
- 검수 중 발견한 스케줄 등록/수정 모달 저장 불능 수정 (Form id 부재 + 커스텀
 Select/Toggle 값 미전송) + 동형 재발 차단 audit 룰 2종 신설
2026-08-17 15:17:20 +09:00
정정홍andGitHub db49e8b9b8 Merge pull request from gnuboard:HeuJung/issue588
fix(core): 공개 템플릿 config 캐시 무효화 누락과 확장 캐시 삭제 경로 동종 결함 정비
2026-08-17 13:26:13 +09:00
HeuJung e819db9d42 fix(core): 공개 템플릿 config 캐시 무효화 누락과 확장 캐시 삭제 경로 동종 결함 정비
공개 config.json 의 고정 캐시 키(template.config.{identifier})가 어떤 갱신
경로에서도 forget 되지 않아 template:update/cache-clear/비활성화/삭제 후에도
최대 1시간(TTL) 이전 manifest 로 응답되던 결함(공개 )을 수정한다.

- TemplateManager::clearTemplateCache 공개화 — config/components_manifest(두
 변종)/현재 버전 routes·language 키 능동 forget, updateTemplate cleanup 호출
- forgetLayoutCacheKeys 가 편집기용 .with_source_meta 변종까지 삭제
- ext.cache_version 쓰기를 incrementExtensionCacheVersion 단일 지점으로 통일
- module/plugin:cache-clear 의 유령 키 forget 을 실재 캐시(레이아웃 서빙/해석
 캐시, 상태 키, 미들웨어 인덱스, 버전 bump) 무효화로 교체
- 공개 routes/language/layout API 의 ?v 생략 폴백을 0 → 현재 버전으로 교정
2026-08-17 13:22:48 +09:00
정정홍andGitHub 09a700e4c7 Merge pull request from gnuboard:HeuJung/issue571
HeuJung/issue571
2026-08-17 03:18:10 +09:00
HeuJung 01526faac2 chore(core): 리베이스 후속 — LSP 일시 파일 ignore 추가 + 충돌 해소 검증 이력
- storage/framework/.gitignore 에 lsp-*.php 추가 — LSP/tinker 가 만드는
 일시 파일이 add -A 에 혼입되는 것을 차단 (추적 이력 없음 확인)
- develop 리베이스 충돌 4파일 해소 근거와 후속 검증(신규 룰 --all 전수 0건,
 병합 테스트 green, 활성 디렉토리 재동기) 기록
2026-08-17 03:11:07 +09:00
HeuJung 7f457a05b3 fix(core,board,page,ecommerce,basic,pay): FULLTEXT 게이트·검색 실패 표면화와 페이지네이션/접두사 결함 정비
- 공개 : MATCH 는 커버 인덱스가 있을 때만 조립 — 부재 시 LIKE 폴백 + 1회 경고,
 카테고리 검색 예외를 categories_failed/search_failed 로 표면화하고 basic 템플릿이 오류 안내 렌더
- 공개 동근원: paginate page 명시 전달 (언어팩 check-updates, 상품 문의 목록)
- 공개 동근원: raw SQL 접두사/별칭 하드코딩 정리 (board 시더, 7.0.6 업그레이드 스텝,
 결제 3플러그인 컨트롤러 51지점 모델 파생 전환)
- audit 룰 3종 신설 + repository-raw-hardcoded-table 컨트롤러 확대, 확장 TestCase
 오토로더 중복 선언 가드 16지점, ja 언어팩 동기
2026-08-17 03:02:26 +09:00
정정홍andGitHub 3ccfb24417 Merge pull request from gnuboard:HeuJung/issue575
feat(ckeditor5,board,ecommerce,page,core,admin_basic): 미참조 에디터 업로드 이미지 정리 신설 + 업로드 고아 동종 결함 7건 전수 해소
2026-08-17 02:51:54 +09:00
HeuJung cfc2caba1b feat(ckeditor5,board,ecommerce,page,core,admin_basic): 미참조 에디터 업로드 이미지 정리 신설 + 업로드 고아 동종 결함 7건 전수 해소
공개 (@Tuwasduliebst 제보) 대응.

- ckeditor5: 미참조 이미지 정리 엔진 신설 — 참조 판정(hash + 파일명 2토큰,
 filter_reference_sources 훅으로 확장 콘텐츠 등록) + 정리 커맨드(기본 꺼짐 옵트인,
 3중 게이트) + 업로드 관리 화면(권한 분리·참조 배지·개별/선택 삭제) + 기설치본
 설정 백필 upgrade step
- board: 임시 첨부 GC(상시) + 소프트삭제 첨부 영구 정리(옵트인) + 설정 UI,
 저장 payload 누락(F4)·빈 temp 디렉토리 잔존(F5) 수정
- ecommerce: 임시 상품 이미지 GC + 카테고리 삭제 시 이미지 파일 미삭제(S4) 수정
- page: 임시 첨부 GC
- 코어: 고아 첨부 정리 커맨드(옵트인, site_logo 보호) + 사이트 로고 교체 시
 구 로고 재편입 차단(S6) + DB 백업 미구현 501 정직화(B3) + 플러그인 관리자
 메뉴 설치·업데이트 자동 동기화(F1·F7)
- 동반: 플러그인 설정 검증 오류 필드명 라벨화(F3), Toggle 노드 type 정정
 수평 전개(marketing 1.0.2 bump·gdpr·ecommerce), ja 언어팩 4종, API 문서,
 hooks.md, 시나리오 매니페스트 8종, Playwright E2E 4종
2026-08-17 02:44:54 +09:00
정정홍andGitHub 1d895a6428 Merge pull request from gnuboard:HeuJung/issue568
fix(security): KVE-2026-1914/1915/1919 remediation 전건
2026-08-17 01:49:14 +09:00
HeuJung 71abdeaf60 fix(security): KVE-2026-1914/1915/1919 remediation 전건
위임 관리자(부관리자)가 권한·역할·표현식·비밀 콘텐츠 경계를 우회하던
결함군을 계층 대칭성 원칙으로 전건 차단한다. 약한 경로가 정상 응답을
내보내는 것이 유일한 증상이라, 게이트를 생산 지점 한 곳(SSoT)에 두고
같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유하도록 맞췄다.

- 등급 상한(rank ceiling): 슈퍼관리자 보호·역할/사용자 역할 배정(추가·제거
 대칭)·일괄 상태변경·순서변경을 상세 경로와 동일 강도로 재적용. 가드는
 DB 쓰기에 선행하여 거부 시 상태 불변.
- 레이아웃 표현식: new Function/with 실행을 AST 화이트리스트 평가기로 교체.
 비-문자열 computed 키 정규화(normalizeKey)·Object facade(리플렉션 static
 제거)·legacy 접근자 차단. 저장측 검증·정적 검사와 3계층 동형.
- secret 게이트: 비밀글의 댓글·첨부·문의 독립 경로 재적용, hash 파일서빙
 소유권·비밀·발행 상태 검사 통일.
- 신뢰 스크립트 호스트: 확장 선언 기반 + same-origin 브라우저 정규화를
 런타임·저장측·정적검사 3층 동형화.
- 회귀 감지: 단위·Feature·E2E·시나리오 매니페스트 전축 + audit 룰 4종 신설.
2026-08-17 01:45:09 +09:00
정정홍andGitHub cd84b63d2b Merge pull request from gnuboard:HeuJung/issue574
fix(core,board,ecommerce,page,pay): 예외→응답 매핑 정비와 저장 배송지 승계 결함 수정
2026-08-16 00:56:44 +09:00
HeuJung b16c5c38a5 fix(core,board,ecommerce,page,pay): 예외→응답 매핑 정비와 저장 배송지 승계 결함 수정
generic catch 가 4xx 를 돌려주면 인프라 장애가 입력 오류로 위장돼 장애 인지가
늦어지고, 이미 번역된 예외 메시지를 응답의 메시지 키 자리에 넘기면 키 해석에
실패해 원문이 그대로 화면에 나간다. 두 형태를 코어와 전 번들 확장에서 함께
정리하고, 판정기를 한 확장이 아닌 코어 모집단에 두어 확장 밖 동형 결함도
red 가 되게 했다 — 그 사각에 실제로 15건이 있었다.

주소록에서 해외 배송지를 고르면 국내 6필드만 옮겨 담아 해외 주소가 통째로
사라지던 결함도 함께 고쳤다. 저장은 200 으로 성공 처리돼 주문 상세를 다시
열기 전에는 드러나지 않았다.
2026-08-16 00:53:21 +09:00
정정홍andGitHub b33559e462 Merge pull request from gnuboard:HeuJung/issue570
fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합
2026-08-14 17:29:38 +09:00
HeuJung d970117149 fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합
확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다.
그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다.
예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다.

- 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용.
 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라
 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다.
- 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리.
 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다.
- 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의
 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다.
- 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을
 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지
 알 수 없었다.

시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects
검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다.
그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
2026-08-14 17:28:49 +09:00
정정홍andGitHub d7ef20668a Merge pull request from gnuboard:HeuJung/issue564
docs(core,scripts): 요구사항 선언과 실제 검증 범위 정합화 + 내부 하네스 3건
2026-08-14 11:55:01 +09:00
HeuJung 7e6f41cc53 docs(core,scripts): 요구사항 선언과 실제 검증 범위 정합화 + 내부 하네스 3건
요구사항 문서가 "지원한다"고 선언한 항목과 실제로 검증되는 범위가 어긋나 있었다.
지원 브라우저 3종 "최신 2개 버전" 선언에 비해 자동 테스트는 Chromium 단일이었고,
같은 형태의 불일치가 문서 전반에 있었다 — 디스크 700MB↔게이트 500MB, 확장 33개
필수 선언↔게이트 14개, HTTPS "필수"↔비차단 경고.

선언을 낮추는 대신 검증 수준을 명시하는 방향으로 정리했다. 항목마다 "설치 프로그램이
자동 검사" 와 "운영자 확인 필요" 를 구분하는 절(§9)을 두어, 이슈가 지적한 질문
("지원 선언이 어느 수준의 검증을 의미하는가")을 문서 전반으로 일반화했다.

조사 중 드러난 내부 하네스 결함 3건도 함께 고쳤다.
- 테스트 스코프 훅이 npm run test:e2e:smoke 를 오차단 — 필터가 package.json 안에
 있어 명령 문자열에서 보이지 않았다. 회귀 케이스 3건 red 확인 후 수정
- 브라우저 테스트 안내 문서가 없어진 스크립트·디렉토리를 가리키고 있었다
- E2E 동반 강제 룰의 확장 config 경로 패턴이 실제 경로와 달라, 확장 8개의 config
 변경이 인정되지 않았다

E2E 브라우저 확대는 하지 않는다 — 도입 조건만 문서에 못박았다.
2026-08-14 11:46:55 +09:00
정정홍andGitHub 071719e570 Merge pull request from gnuboard:HeuJung/issue572
fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
2026-08-14 11:19:34 +09:00
HeuJung 4e1ec6e001 fix(core,admin_basic,board,ecommerce,page,gdpr,verification): 설정 미러·자동 정리·탈퇴 원자성·일괄 선택 범위 정비
공개이슈 ··· 및 그 전수 해소분.

- 설정 저장·복원·초기화가 상주 프로세스 미러를 갱신하도록 단일 소유자화
- 만료 데이터 자동 정리 예약 12종 신설, 파기는 기본키 배치로 분할
- 회원 탈퇴를 전 경로 원자화하고 관리자 상태변경 경로를 정식 탈퇴로 통일
- 일괄 처리 목록의 선택을 화면에 보이는 행으로 한정(selectionScope)
- 비밀번호 정책 문구를 도메인 중립 라벨로 정정
- 정적 검사 2종·검증 스킬 설계 정합성 축 신설
2026-08-14 11:06:25 +09:00
정정홍andGitHub 49c155509e Merge pull request from gnuboard:HeuJung/issue562
fix(core,admin_basic,basic): 공개 자산 CDN 교차 출처 요청의 토큰 노출·CORS 실패 수정 + S3 접속 필드 노출 조건 확장
2026-08-14 10:33:50 +09:00
HeuJung c341fe28a4 fix(core,admin_basic,basic): 공개 자산 CDN 교차 출처 요청의 토큰 노출·CORS 실패 수정 + S3 접속 필드 노출 조건 확장
공개 (S3 드라이버) × (공개 자산 CDN) 병합 결과를 실 AWS S3 로 교차 실측하다
발견한 결함과, 그 과정에서 드러난 설정 화면의 사각을 함께 처리한다.

## 교차 출처 공개 자산 요청

공개 자산 디스크를 켜면 첨부·이미지의 download_url 이 외부 origin 절대 URL 이 되는데,
API 클라이언트가 URL 출처를 보지 않고 Authorization 을 붙이고 있었다. 그 결과
① 세션 토큰이 제3자 origin 으로 전송되고(응답을 허용하는 CDN 에서는 접근 로그에 남는다)
② 안전목록 밖 헤더라 preflight 가 발생해 CORS 미설정 버킷에서는 이미지가 통째로 실패했다.
상점 화면은 <img> 라 무증상이고 관리자 화면에서만 발현해 발견이 늦었다.

- 코어: 동일 출처 요청에만 Authorization 첨부 (상대 경로는 항상 동일 출처로 간주)
- 템플릿 2종: 교차 출처 URL 은 인증 XHR 없이 그대로 사용 (썸네일·갤러리·다운로드 3지점씩)

로컬 public 디스크나 허용적 CORS 를 주는 오브젝트 스토리지에서는 재현되지 않는다 —
외부 origin 과 CORS 미제공이 겹쳐야 드러나므로 회귀 테스트는 두 조건을 모두 고정한다.

## S3 접속 필드 노출 조건

파일 스토리지는 로컬로 두고 공개 자산만 S3 로 보내는 구성에서 접속 정보를 넣을 화면이
없었다(도움말이 보이지 않는 필드를 가리켰다). 두 축 중 하나라도 s3 면 블록을 노출하고,
연결 테스트 게이트의 적용 대상도 같은 조건으로 넓힌다. 재검증 트리거는 S3 접속 설정
변경으로 한정한다 — 트리거까지 넓히면 "파일 스토리지=s3 에서 공개 자산만 변경" 하는
기존 흐름이 저장 불가가 된다.
2026-08-14 10:32:24 +09:00
정정홍andGitHub a5a7caf8ba Merge pull request from gnuboard:HeuJung/issue562
feat(core,ecommerce,ckeditor5,board,admin_basic): 공개 자산 직접 URL(CDN) 서빙 옵트인
2026-08-14 08:09:02 +09:00
HeuJung 057e002ad1 feat(core,ecommerce,ckeditor5,board,admin_basic): 공개 자산 직접 URL(CDN) 서빙 옵트인
StorageInterface::url 의 public 하드코딩을 완화하고 core.storage.filter_url 훅을
신설해, 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)의 직접 URL 서빙을 옵트인으로
열었다. 미설정 시 기존 스트리밍 100% 보존.

디스크 결정은 확장 개별 설정 > 코어 전역 > 미설정 순이며, 서빙·삭제·이동은 행에 기록된
disk 를 따라 전환 전후 자산이 혼재해도 각자 실제 저장 위치를 향한다. 고아 disk(공급
플러그인 비활성화)는 스트리밍으로 폴백해 500 이 되지 않는다.

공개 이슈 (@lyg-kaban 건의) 대응.
2026-08-14 08:05:54 +09:00
정정홍andGitHub 37fad55333 Merge pull request from gnuboard:HeuJung/issue563
fix(core,admin_basic): S3 스토리지 드라이버 실동작 결함 수정 및 S3 호환 스토리지 연결 지원
2026-08-13 15:16:05 +09:00
HeuJung ea58c606a2 fix(core,admin_basic): S3 스토리지 드라이버 실동작 결함 수정 및 S3 호환 스토리지 연결 지원
공개 제보 — 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이
동작하지 않던 결함의 전면 수정.

- S3 어댑터(league/flysystem-aws-s3-v3)·predis 를 코어 기본 의존성으로 포함
 — 어댑터 부재 즉사, phpredis 확장 없는 서버의 redis 선택 전면 다운 차단
 (부트 시 확장 부재 감지 → predis 자동 폴백)
- storage_driver=s3 저장 시 코어 첨부 업로드 디스크를 s3 로 전환
 (ATTACHMENT_DISK env 명시가 항상 우선, 기존 행은 저장 당시 disk 로 서빙)
- 첨부·템플릿 레이아웃 첨부 서빙을 행 disk 를 따르는 스토리지 스트림으로 교체
 — 로컬 절대 경로 전제 fileResponse 는 S3 행에서 filemtime stat 500
 (streamedFileResponse: 행 메타 기반 ETag/304/Cache-Control)
- S3 호환 스토리지(R2/MinIO/NCP) 연결 지원: 엔드포인트 URL·path-style 설정
 신설, 리전 목록 선택 → 자유 입력 전환, 연결 테스트를 실제 저장 경로와
 동일 설정(endpoint/path-style)으로 정렬
- 사용 불능 드라이버(어댑터·PHP 확장 부재)의 저장/테스트 요청을 사유와 함께
 422 로 차단하는 서버 게이트 신설 (DriverRegistryService 능력 판정)
- 웹소켓 연결 테스트에 서버(백엔드 발송용) endpoint 검사 추가 — 클라이언트만
 검사해 테스트 성공 + 실제 발송 실패가 가능하던 비대칭 해소
- env 빈 값(`KEY=`) 함정 정규화: AWS_URL/AWS_ENDPOINT/ATTACHMENT_DISK 빈 문자열을
 미설정으로 취급 (config 정규화 + 예시 파일 주석 처리)
- 플러그인 드라이버 폴백의 log 카테고리 죽은 키(logging.default) 정정 및
 websocket 유령 설정 키 제거
- 실 AWS S3 종단 검증 완료 (설정 저장 → 업로드 S3 실저장 → 서빙 200/304)
2026-08-13 15:15:19 +09:00
정정홍andGitHub c128a6ea3a Merge pull request from gnuboard:HeuJung/issue551
fix(ecommerce): 배송비 구간 경계 포함 + 무게/부피 단위 환산
2026-08-12 23:49:06 +09:00
HeuJung 55bc9399e6 fix(ecommerce): 배송비 구간 경계 포함 + 무게/부피 단위 환산
구간별 배송비의 종료값이 구간에 포함되지 않아 경계값이 무료배송으로
새고, 상품 옵션의 g/cm³ 저장값을 정책의 kg/L 로 환산하지 않아 금액이
1000배로 청구되던 문제를 수정한다.

- 구간 매칭을 종료값 오름차순 사다리로 교체 — 시작값은 표시 전용이
 되어 기존 저장 데이터의 두 형태 모두에서 정확히 계산된다
- 단위 환산을 배송비 계산 한 지점으로 모으고, 부피무게와 실무게를
 같은 kg 단위로 비교하도록 교정
- 계산이 조용히 0원이 되는 설정(구간 미등록·단위값/기준금액 누락·
 중간 구간 무제한·계산 API 폴백 0원)을 저장 시점에 차단하고,
 런타임 잔여 경로에는 경고 로그를 남긴다
- 연속성 규칙을 정책 타입별로 분리해 소수 구간(2.5kg) 저장을 허용
- 상품 옵션 무게/부피 응답 왕복, 주문 총 무게/부피 실값 기록
2026-08-12 23:43:08 +09:00
정정홍andGitHub 9131ee6bb7 Merge pull request from gnuboard:HeuJung/issue557
fix(ecommerce,board): 쿠폰 중복사용 라디오 boolean 문자열화로 저장이 422 실패하던 결함 수정
2026-08-12 16:24:29 +09:00
HeuJung d675c8ac9f fix(ecommerce,board): 쿠폰 중복사용 라디오 boolean 문자열화로 저장이 422 실패하던 결함 수정
공개 제보(gnuboard/g7 @lyg-kaban): 관리자 쿠폰 폼의 「타 쿠폰과 중복 사용」
라디오를 클릭하면 자동바인딩이 DOM 문자열 "true"/"false" 를 폼 상태에 그대로
저장해 서버 boolean 규칙에서 422 로 저장이 실패했다. 표시 계층은 느슨 비교로
정상이라 저장 시점에야 드러나는 결함.

- 레이아웃: is_combinable 라디오 autoBinding 해제 + String 표시 바인딩
 + change 액션 === 'true' boolean 캐스팅
- 서버: Store/UpdateCouponRequest prepareForValidation 에서 문자열 표기 수용
 boolean 정규화 (해석 불가값은 유지해 boolean 규칙이 422 처리)
- 게시판 is_secret 동일 잠재 위험 서버 방어 + User Store/UpdatePostRequest 의
 Board 직접 조회 4곳을 Repository 경유로 교체 (board 1.0.4)
- 회귀: PHPUnit 11건(RED 선행) + 레이아웃 쓰기경로 타입 프로브 + E2E 왕복 spec
 + 시나리오 매니페스트(cases=32)
- 재발 차단: ·AGENTS.md 금지 패턴 + audit 룰
 layout-boolean-option-autobinding 신설(전수 0건) + 형제 룰 4종
 extensions/** glob 누락 보강 + 트러블슈팅 사례 8 + 회귀 테스트
2026-08-12 16:22:33 +09:00
정정홍andGitHub 7c10f45aaf Merge pull request from gnuboard:HeuJung/issue555
HeuJung/issue555
2026-08-12 15:24:30 +09:00
HeuJung b1b5bfa417 feat(core,ecommerce,ckeditor5): 이미지 업로드 필터 훅 대칭화
공개 제보(리뷰 이미지에 filter_upload_file 부재)를 계기로 전 이미지 업로드
경로의 훅 트라이어드를 대칭화한다. 확장이 업로드 파일을 저장 전에 가로채
압축/변환할 수 있도록 하기 위함.

- 리뷰 이미지·CKEditor5 이미지 업로드에 filter_upload_file 필터 훅 추가
- 레이아웃 편집기 첨부 업로드에 훅 트라이어드(before/filter/after) 신설
- ckeditor5 1.0.2 버전 동기(manifest/package/composer/lock), 각 CHANGELOG 기재,
 README 기여자 등재, hooks.md 업로드 트라이어드 및 필터 반환 계약 문서화
- 시나리오 매니페스트(upload-filter-hook-parity) + 3계층 훅 발화 테스트 추가
2026-08-12 15:23:50 +09:00
정정홍andGitHub 709fbff278 Merge pull request from gnuboard:HeuJung/issue554
fix(admin_basic,core,ecommerce,plugins,basic): stale dist 전수 재빌드 + 소스맵 게이트 audit 룰 신설
2026-08-12 14:35:13 +09:00
HeuJung 0bcd42ac04 fix(admin_basic,core,ecommerce,plugins,basic): stale dist 전수 재빌드 + 소스맵 게이트 audit 룰 신설
공개 — admin_basic 1.0.4 가 이전 소스로 빌드된 stale dist 를 릴리즈에
실어 주문 상세 합계행에 표현식 원문이 노출되던 회귀를 1.0.5 재빌드로 재수록.

- admin_basic 1.0.5: --production 재빌드 + 서빙 번들 회귀 E2E(red→green) + 시나리오 매니페스트
- 전수 점검: sourcemap 잔존 9확장+코어 3번들 --production 재빌드, untracked .map 3개 삭제
 (+1 bump: gdpr 1.0.3 / tosspayments 1.0.2 / verification_kginicis 1.0.3 /
 verification_nhnkcp 1.0.1 / basic 1.1.1 — 미공개 워킹 5건은 CHANGELOG 누적)
- 리터럴 대조 전수: 기능 drift 는 admin_basic 단독 확정(타 대상 차집합 0), 릴리즈 패리티 7/7 일치
- board/page 사문 build 스크립트 제거 (vite.config 부재 — module:build --all 실패 요인)
- audit 룰 신설: extension-dist-sourcemap-reference(error),
 extension-dist-source-literal-sync(warn) + coverage/룰 테스트/규정 문서 정정
2026-08-12 14:31:26 +09:00
정정홍andGitHub 2de257491f Merge pull request from gnuboard:HeuJung/issue550
HeuJung/issue550
2026-08-12 10:55:22 +09:00
HeuJung b44f2befd7 fix(console): 콘솔 없는 실행에서 확인 프롬프트가 무한 대기하던 문제 수정
Symfony 는 posix_isatty 로 비대화 실행을 감지해 isInteractive 를 false 로
내리는데, Windows PHP 에는 posix 확장이 없어 그 분기가 실행되지 않는다. 그래서
--no-interaction 없이 CI·스케줄러·에이전트에서 부르면 isInteractive 가 true 로
남고 ask 가 오지 않을 응답을 무한히 기다린다. 프롬프트 출력마저 버퍼에 갇혀
예외도 로그도 없이 "커맨드가 느리다" 로만 관측된다.

posix 없이도 동작하는 stream_isatty 로 한 번 더 판정한다. 판단 재료가 없으면
질문하는 쪽으로 떨어진다 — 물어볼 수 있는데 안 묻는 것이 더 위험하기 때문이다.

이 트레이트를 공유하는 13개 커맨드의 기본값을 전수 확인했다. 파괴적 확인은
모두 false(중단)이고 true 는 search:index --repair 하나뿐이라, 확인 없이
파괴적 동작이 수행되는 경로는 생기지 않는다.

문서는 이미 "비TTY → default 즉시 반환" 을 계약으로 명시하고 있었다 —
구현이 그 계약을 Windows 에서 지키지 못한 것이다.
2026-08-12 10:53:04 +09:00
HeuJung 7765695339 fix(cart): 장바구니 전체 선택 개수 최초 로딩 미표시 수정
표시식이 selectedCount 를 fallback 없이 보간했다. 이 키는 체크박스 change
핸들러에서만 기록되므로 최초 마운트 구간에 undefined 이고, 엔진이 undefined 를
예외 없이 빈 문자열로 서식해 (/27) 로 보였다. 선택 상태의 SSoT 인 selectedItems
파생으로 교체해 주문 요약과 계산 방식을 일치시켰다.

같은 결함 클래스(초기값 없는 키를 옵셔널 없이 하위 접근) 28곳을 함께 방어하고,
3조건 동시 충족만 잡는 audit 룰로 codify 했다. 브라우저 실측(T9)에서 다운로드
이력 모달의 iteration 문자열 선언(매 렌더 TypeError)·미존재 아이콘명·정적 id
중복 3종을 추가로 발견해 정정했다.

공개 이슈: gnuboard/g7
2026-08-12 10:17:22 +09:00
정정홍andGitHub 95957bb167 Merge pull request from gnuboard:HeuJung/issue547
fix(core,ecommerce): Windows 확장 업데이트 파일 잠금 해소 + 통화 삭제 영속화
2026-08-12 10:12:31 +09:00
HeuJung c77ddee03b fix(core,ecommerce): Windows 확장 업데이트 파일 잠금 해소 + 통화 삭제 영속화
Windows 는 하위 트리에 열린 핸들이 하나라도 있으면 디렉토리 rename 을 막는다.
잠금 프로세스를 찾아 종료하던 기존 대응은 디렉토리 핸들을 감지하지 못했고
사용 중인 편집기를 예고 없이 죽였다. rename 이 막히면 파일 단위 연산으로
폴백해 어떤 프로세스도 건드리지 않고 교체를 끝낸다. 커밋 전 점검에서
pint.json 이 코어 업데이트 대상에 빠져 있던 것도 함께 등재했다.

이커머스는 기본 제공 통화 삭제가 저장 응답에서 즉시 부활했다. 항목 단위
보충 병합이 "소실" 과 "의도적 삭제" 를 구분하지 못한 탓이라, 삭제 의도를
서버가 도출해 저장본에 기록하고 병합이 그 기록을 존중하게 했다. 그 통화로
결제된 과거 주문의 표기가 흔들리지 않도록 소수 자릿수 해석도 스냅샷 우선으로
바꿨다 — 금액은 원래 스냅샷 환율을 써 안전했으나 자릿수만 현재 설정을 봤다.
2026-08-12 10:10:01 +09:00
HeuJung c09711f609 Merge branch 'develop' 2026-08-11 15:58:47 +09:00
정정홍andGitHub 5ae34002f3 Merge pull request from gnuboard:HeuJung/issue546
fix(readme): 영문 번들 언어팩 목록을 표로 교체 — 인라인 코드 나열 렌더링 깨짐
2026-08-11 15:57:29 +09:00
HeuJung 22175d42b0 fix(readme): 영문 번들 언어팩 목록을 표로 교체 — 인라인 코드 나열 렌더링 깨짐
한 문단에 인라인 코드 스팬 17개를 늘어놓은 형태가 렌더러에서 깨져 식별자가
사라지고 쉼표만 남았다. 한국어판과 같은 표로 바꾸면 이 형태 의존이 사라진다.

부수 효과로 표별 행 수가 한국어판과 완전히 일치하게 되어, 두 README 사이에
남아 있던 구조 차이가 해소된다.
2026-08-11 15:56:18 +09:00
정정홍andGitHub 284b38b16c Merge pull request from gnuboard:HeuJung/issue546
docs(readme): README 영문 기본화 + 한국어판 분리 + 커뮤니티 기여자 하네스
2026-08-11 15:51:28 +09:00
HeuJung 5ca8f636e6 docs(readme): README 영문 기본화 + 한국어판 분리 + 커뮤니티 기여자 하네스
글로벌 방문자에게 한국어 README 는 완전한 이탈 요인인 반면 한국 방문자에게
영문 README 는 한 클릭 불편이라, 비대칭 비용에 맞춰 영문을 기본값으로 둔다.
한국어판은 이력을 보존하도록 git mv 로 옮기고 본문은 그대로 둔다.

커뮤니티 기여자 목록은 공개 CHANGELOG 의 제보자 표기에서 도출한다. 손으로
옮겨 적으면 이슈가 쌓일수록 누락·중복이 생기므로 생성기와 판정기를 한
스크립트로 두고, 세션 종료 시 두 README 와 CHANGELOG 의 불일치를 검사한다.

README 가 두 벌이 되면서 버전 뱃지 축도 둘이 됐다. 한 축이 빠져도 나머지가
통과시켜 초록이 누락을 감추므로, 각 축이 독립적으로 red 를 내는지를
합성 저장소로 고정한다.
2026-08-11 15:50:34 +09:00
HeuJung 71ff784b90 Merge branch 'develop' 2026-08-11 12:17:47 +09:00
정정홍andGitHub a8d0d21316 Merge pull request from gnuboard:HeuJung/issue543
fix(pay_tosspayments): 사문화된 결제수단 표시 리스너 제거 + 저장설정 PG 고정 백필
2026-08-11 12:00:09 +09:00
HeuJung 84e37bfa8f fix(pay_tosspayments): 사문화된 결제수단 표시 리스너 제거 + 저장설정 PG 고정 백필
토스 결제수단의 PG 고정 선언( 전환)은 develop 의 선행 커밋이 이미 반영했다.
이 커밋은 그 전환에서 남은 뒷정리와, 같은 결함이 다시 나지 않게 하는 장치를 담는다.

AdjustEcommercePaymentMethodsLayoutListener 는 코어 레이아웃 표현식의 no-PG 리스트
리터럴을 정규식으로 재작성해 표시를 바꾸던 리스너인데, 에서 코어가 그 리터럴을
버리면서 매치 대상이 사라져 이미 사문화된 상태였다. 단위 테스트가 그 리터럴을 합성
레이아웃에 직접 넣어 검증한 탓에 계속 통과해 사문화가 드러나지 않았다. 리스너와 그
테스트, 시나리오 매니페스트의 관련 effects 를 함께 걷어낸다.

런타임 병합은 정의값으로 자가 치유되지만 저장된 주문설정 파일에는 pg_provider=null 이
그대로 남는다. 자기 접두사 수단만 정정하는 멱등 업그레이드 스텝을 추가했다.

능력 선언 규약이 어디에도 문서화돼 있지 않아 4개 PG 플러그인 중 토스만 어긋난 채
남았으므로, 규약을 명문화하고 미선언을 검출하는 audit 룰을 신설했다(수정 전 코드에
red 확인 후 전수 스캔 green).

1.0.0 이 공개 발행되었으므로 발행 섹션에 누적하지 않고 1.0.1 로 올렸다.
2026-08-11 09:20:08 +09:00
HeuJung 152e3bfde7 Merge branch 'develop' 2026-08-10 22:18:47 +09:00
HeuJung 24597c2310 fix(ckeditor5): 공개 발행된 1.0.0 에 누적된 변경을 1.0.1 로 분리
- 공개 1.0.0 CHANGELOG 섹션을 발행본 원문으로 복원 (공개 tag 1.0.0 파일 및 릴리즈 노트와 바이트 동일 확인)
- 1.0.0 이후 누적분(업로드 이미지 크기·품질 설정 적용, 코어 7.0.6 요구)을 [1.0.1] 신설 섹션으로 이관
- 버전 1.0.0 → 1.0.1 동기화 (plugin.json / components.json / package.json / package-lock.json / composer.json)
2026-08-10 22:02:07 +09:00
HeuJung 623d9b4f9e Merge branch 'develop' 2026-08-10 21:26:09 +09:00
HeuJung 28ca8c9cc5 chore(core,gdpr): 공개 릴리즈 누출 검사 잔여 warn 2건 제거
이전 정리(3f29f9fd1)에서 보류한 API 표면 파일 2건의 내부 역할 호칭을 중립
표현으로 정정한다. warn 은 릴리즈를 차단하지 않지만 공개본에는 그대로 남는다.

- 언어팩 번들 설치 요청 / GDPR 동의 상태 리소스 주석의 호칭을 중립 표현으로 교체
- 두 파일 모두 요청 파라미터·검증 규칙·응답 필드 구성이 그대로라 API 레퍼런스에
 갱신할 내용이 없다. 문서 동반 게이트는 사유를 명시한 인라인 면제로 해소했다
 (룰이 명시 허용하는 용법, 저장소 선례 3건)

부수: 스타일 정리 과정에서 GDPR 리소스의 기존 PHPDoc 정렬 1줄이 함께 교정됐다.

검증: 파일 본문 누출 검사 error 0 / warn 0(11,528 파일), audit --working 위반
0(룰 192), php -l 통과, 코어 버전 정합·확장 버전 제약 경고 0.
2026-08-10 21:25:12 +09:00
HeuJung 6c55d1b2ca Merge branch 'develop' 2026-08-10 21:05:00 +09:00
정정홍andGitHub 3b831c8523 Merge pull request from gnuboard:HeuJung/issue543
HeuJung/issue543
2026-08-10 20:30:33 +09:00
HeuJung 4f074d19ef chore(core,lang-packs): 7.0.6 출시 버전 정합 교정 및 언어팩 검사 사각 보강
번들 언어팩은 코어 release 페이로드에 실려서만 공개되므로 그 팩의 공개 latest 는
직전 release 에 실린 버전이다. 그런데 버전 검사기가 lang-packs 를 대상에서 빼고 있어
이 축이 미검사로 남았고, 변경마다 bump 한 결과 공개된 적 없는 중간 버전이 최대 7단계까지
배포본에 쌓였다. 8개 팩의 버전을 규정상 한 단계로 되돌리고 그 사이 항목을 한 섹션에 병합했다.
항목 손실이 없음과 기공개 구간이 바뀌지 않음을 각각 실측했다.

7.0.6 과 함께 공개될 30건(코어·확장·언어팩)의 출시일을 2026-08-10 으로 맞췄다.
이미 공개된 버전의 날짜는 기록이므로 그대로 두었다.

재발을 막기 위해 검사기에 언어팩 축을 추가하고(기준선 origin/release) 회귀 테스트를 뒀다.
규정 문구의 "코어 검증으로 대체" 는 실제로 미검사를 뜻했으므로 기준선을 명시하도록 고쳤다.

7.0.6 에서 처음 번들되는 NHN KCP 휴대폰 본인확인 플러그인과 그 언어팩을 README 기본 제공
확장 표에 등재하고, 함께 어긋나 있던 번들 언어팩 개수 표기를 실제 수(16종)로 맞췄다.
2026-08-10 20:25:30 +09:00
HeuJung 3f29f9fd1e chore(core,board,ecommerce,page,gdpr): 7.0.6 공개 릴리즈 누출 검사 차단 요인 제거
prepare-release.sh 의 파일 본문 누출 검사가 error 10건으로 릴리즈를 차단하고 있었다.
공개 저장소는 를 제외하므로, 배포되는 파일이 그 경로나 를 참조하면
공개본에서 깨진 참조가 된다.

- 시나리오 매니페스트 4건: test_files 의 내부 하네스 테스트 경로 제거
- Playwright spec 3건: 룰 파일 경로/@see 포인터를 자기완결 서술로 교체
 (@see 2건은 이미 stale — 가리키던 사례 번호가 실제와 달랐다)
- 소스/테스트 주석 3건: 파일명 노출 제거

내부 역할 호칭이 공개본에 영구 노출되는 warn 23건 중 21건도 함께 중립 표현으로
정정했다. API 표면 파일 2건은 주석만 고쳐도 문서 커버리지 게이트에 영구 면제가
필요해져 보류한다 — warn 은 릴리즈를 차단하지 않는다.

부수로 드러난 기존 결함 둘을 같은 세션에서 처리:
- gdpr Playwright config 에 locale 고정 누락 — 첫 로드가 en-US 로 나가 화면이 영어로
 굳으면 한국어 문구를 단언하는 spec 이 전부 실패한다
- 언어팩 시나리오의 정적 검사 계층 전용 효과 2건을 effects 에서 rules_layer_coverage
 로 이관 — 런타임 테스트로 성립하지 않는 항목이라 그 칸이 정합 위치다

검증: 누출 검사 error 0, audit --working 위반 0(룰 192), 시나리오 매니페스트 8건
errors 0, gdpr Vitest 148 green, PHP lint/JSON/YAML 정상.
버전 정합은 코어 working 7.0.6 / 공개 latest 7.0.5 = new-bump 로 릴리즈 가능 상태.
2026-08-10 17:46:17 +09:00
HeuJung 2ba4a760fb test(ecommerce,pay_kginicis,pay_nhnkcp,pay_nicepayments): 브랜드 배지 회귀 테스트 전 플러그인 확대 + 시나리오 마커 정합
브랜치 통합 검증에서 정적 룰이 잡지 못한 결함 4건을 해소한다.

replaceState 후킹은 4개 결제 플러그인에 동일하게 적용됐는데 단위 회귀 테스트는
tosspayments 에만 있었다. 나머지 3개는 기존 injector 테스트가 있는데도 변경이
반영되지 않아, 앞으로 한 플러그인만 고쳐도 회귀를 잡을 수단이 없었다.

브랜드 마크 E2E 의 준비 게이트가 pg-locked-badge 만 세고 있었다. 그 배지는
pg_locked 인 수단에만 렌더되므로 PG 고정 수단이 없는 구성에서는 화면이 정상
렌더돼도 0 이 되어 타임아웃으로 오판 실패한다. 스펙이 선언한 "설치 구성을
하드코딩하지 않는다" 규율과 어긋나는 숨은 결합이라, PG 열의 상호배타 4분기
합계로 바꿔 구성과 무관하게 비고아 행 수와 일치시킨다. 읽히지 않던 badges
헬퍼도 함께 걷어낸다.

brand_mark 축과 두 효과가 매니페스트에 없어 마커가 가리킬 대상이 없었다.
축·효과·제외 규칙을 추가하며, 마커 파서가 쉼표로만 축을 분리한다는 사실을
확인해(× 는 첫 축만 잡힌다) 해당 spec 을 쉼표 형식으로 정정했다. 매니페스트를
건드리자 test_files 에 선언됐으나 마킹이 0건이던 파일 2건도 함께 드러나 마킹했다.
2026-08-10 17:17:53 +09:00
HeuJung 1b0be674aa fix(tosspayments,pay_nicepayments,pay_nhnkcp,pay_kginicis): 토스 전환 누락 보완 + 탭 복귀 브랜드 배지 재주입
토스페이먼츠가 "확장 결제수단 1급화" 전환에서 통째로 빠져 있었다. 토스 결제수단은
그 전환 3일 전에 들어왔고, 전환 커밋은 nicepayments·nhnkcp·kginicis 만 건드렸다.
 가 발견한 두 증상(브랜드 심벌 미노출 / PG 자유 선택)의 원인이 이 하나다.

토스페이먼츠
- 간편결제 5종에 brand_mark 를 선언한다. 같은 브랜드는 어느 PG 를 통하든 같은 배지여야
 하므로 문자·색을 다른 PG 플러그인과 동일 값으로 맞췄다. 카드·가상계좌·계좌이체·휴대폰은
 브랜드가 아니라 결제 형태이므로 키를 두지 않는다.
- defaults 에 pg_provider·pg_locked·needs_pg·refund_method 를 선언한다. 기존에는
 pg_provider 가 null 이라 관리자 화면이 "PG 고정" 배지 대신 선택지가 하나도 없는 PG
 셀렉트를 그렸고(토스 PG 는 자기 결제수단 id 를 supported_methods 로 선언하지 않는다),
 서버 쪽으로도 PG 없는 결제수단으로 오인될 수 있었다.
- 관리자 브랜드 injector 를 신설한다. 관리자 레이아웃은 아이콘 열을 _cached_icon 하나로만
 그리고 _cached_brand_mark 를 읽는 노드가 없어, 각 PG 플러그인이 자기 행의 아이콘을
 배지로 치환하는 구조인데 토스에만 그 파일이 없었다.

4개 PG 플러그인 공통
- 환경설정 탭 전환은 pushState 가 아니라 replaceState 로 URL 만 바꾼다(탭 왕복 1회에
 push 0 / replace 2 실측). pushState·popstate 만 후킹하던 injector 는 다른 탭에 갔다
 돌아오면 행은 다시 렌더되는데 배지만 사라진 채 남았다. replaceState 후킹을 추가한다.

브라우저 실측(Chrome MCP 7대 축)으로 전 항목 확인했다. 탭 복귀 재주입은 수정 전
toss 0 / nicepay 0 / kginicis 0 → 수정 후 5 / 8 / 4 로 복원되는 것을 계측했다.
2026-08-10 17:17:53 +09:00
HeuJung 30ad72a3a1 fix(core,ecommerce,tosspayments): 간편결제 선택값 유실·기본통화 중복·고아 결제수단 노출 수정
엔진
- dot notation setState(target:"_local.xxx")가 canonical _global._local 을 동기화하지
 않아, 화면에는 선택 표시가 되는데 확장 커스텀 핸들러의 getLocal 에는 값이 보이지
 않던 비대칭을 해소했다(engine-v1.58.2). 예외도 경고도 없이 값만 비어 발견이 늦다.
 동기화 기준을 전체 스냅샷이 아니라 live _global._local + 변경 키로 잡아, 부분 상태가
 base 가 되어 다른 키를 잃거나 앞선 setLocal 을 되돌리는 기존 회귀 경로를 피했다.

토스페이먼츠
- 간편결제 자체창 직행 지정을 top-level easyPay 로 넘겨 SDK 가 통째로 무시하던 것을
 card.flowMode DIRECT + card.easyPay 정본 계약으로 교정했다.

이커머스
- defaults 에서 보충된 통화가 옛 is_default 를 들여와 기본 통화가 둘이 되면서 환율이
 1.0 으로 박제돼 PG 청구액이 1/100 로 나갔다. 병합 직후 재동기화하고, 표시 계층의
 기본 통화 해석을 default_currency 우선으로 통일해 환산 계층과 일치시켰다.
- 공급 확장이 더 이상 제공하지 않는 고아 결제수단이 주문서에 노출되던 것을 공개 API
 단일 지점에서 차단했다. 관리자 응답은 운영자가 확인·삭제해야 하므로 그대로 둔다.
- 관리자 주문설정에서 Select 에 준 폭 클래스가 컴포넌트 기본 w-full 에 밀려 죽는
 구조라, 폭 책임을 셀렉트에서 열 컨테이너로 옮겼다.
- 고아 행에서 마일리지 차감 시점 열만 가드가 빠져 노출되던 사전 결함을 함께 고쳤다.
 열을 열거하는 대신 조상 체인의 가드 유무를 스캔해, 이후 추가되는 열도 자동으로
 검사 대상이 되도록 회귀 테스트를 구성했다.

개발 도구
- dev-dashboard 인스톨러 초기화에 환경설정/첨부파일 삭제 토글을 추가했다. 컨테이너
 디렉토리를 보존해 재생성 시 소유권이 바뀌지 않게 하고, 두 옵션이 같은 경로를 다뤄도
 서로 간섭하지 않도록 분리했다.

규정
- 저장값과 확장 훅 카탈로그가 병합되는 설정에서 공개 응답이 카탈로그 소속까지 판정해야
 한다는 규칙을 문서화하고, 정적 판정이 불가한 근거와 함께 coverage 에 등록했다.
2026-08-10 17:17:51 +09:00
HeuJung d3a62a7e04 chore(board,basic,core,admin_basic): 게시판 반응(추천/비추천) 기능 및 비즈뿌리오 메시징 플러그인 제거
게시판 게시글 반응 기능과 비즈뿌리오(카카오 알림톡·문자) 메시징 플러그인을 제거한다.

게시판 반응
- 마이그레이션 5종(반응 유형·반응·게시판 사용여부·활성 유형·게시글 카운트), 시더,
 모델·저장소·서비스·컨트롤러·검증 규칙·예외·API 리소스 제거
- 관리자 게시판 폼/환경설정의 반응 사용 토글과 유형 선택 블록, 반응 유형 데이터소스 제거
- 사용자 게시글 상세의 반응 버튼 영역 제거 (반응 도입 이전 상태로 복원)
- 반응 관련 다국어 키(ko/en/ja), API 문서, 시나리오 매니페스트, 단위·기능·E2E 테스트 제거

비즈뿌리오 메시징 플러그인
- 플러그인 본체와 일본어 번들 언어팩 제거
- 코어 문서·빌드 스크립트·검사 baseline, 게시판·이커머스 CHANGELOG 에 남아 있던 참조 정리

존치 항목
- 게시판·이커머스 알림 설정 행 하단의 확장 슬롯(notification_definition_row_footer)은
 코어 알림 설정 화면과 동일한 범용 확장점이라 유지한다
- 사용자 템플릿의 PostReactions 컴포넌트는 반응 기능 도입 이전부터 존재하던 자산이라 유지한다
- 태그 입력 드롭다운이 하단 고정 버튼에 가려지던 수정은 반응 기능과 무관해 유지한다

부수 정정
- 라우팅 판정 diff 스냅샷 총량을 실측값으로 갱신한다. 기록값 463 은 제거 이전 시점에도
 실제(471)와 어긋나 있었고, 이번 제거 후 실측은 469 다.
2026-08-10 16:28:03 +09:00
정정홍andGitHub f70c41c9ee Merge pull request from gnuboard:HeuJung/issue539
fix(core,ecommerce,board,basic,pay_kginicis,pay_nhnkcp,pay_nicepayments): 7.0.6 회귀 3건·잠복 결함 3건 해소 + 통화 하드코딩 전수 제거
2026-08-10 09:09:42 +09:00
HeuJung e21bff678f fix(core,ecommerce,board,basic,pay_kginicis,pay_nhnkcp,pay_nicepayments): 7.0.6 회귀 3건·잠복 결함 3건 해소 + 통화 하드코딩 전수 제거
7.0.6 사이클에서 유입된 회귀 3건을 출시 전에 닫는다. 추가옵션 저장소의
반환 타입 선언이 실제 반환(중첩 맵)과 어긋나 추가옵션 있는 상품의 담기·
바로구매가 죽었고, 검색 엔진이 결과 소비 시점에 SELECT 를 재작성해 정렬이
참조하던 스코어 별칭을 지웠으며, 미들웨어 targets 가 늘어난 라우트를
따라가지 못해 비회원 현금영수증이 전면 불능이었다. 셋 다 정적 검사의
사각지대였으므로 룰 2건과 계약 테스트를 함께 세워 재발을 막는다.

같은 유형의 잠복 결함 3건(빈 반환의 타입 불일치 2건, 없는 메서드 호출 1건)도
호출 경로가 생기는 순간 fatal 이므로 함께 닫았다.

브라우저 검수 중 드러난 선행 결함 5건을 이어서 수정한다. 엔진이 event 키로
선언한 액션을 React prop 으로 연결하지 않아 오류 없이 조용히 무반응이었고,
연결한 뒤에는 커스텀 컴포넌트의 이벤트 payload 가 유실됐다. 비회원 주문상세
응답에 현금영수증 키가 없어 발급 결과에 도달할 수 없었으며, 입금확인과
현금영수증 발급이 base 통화 금액을 써서 결제 통화가 다른 주문에서 각각
저장 실패와 잘못된 증빙을 냈다.

마지막으로 통화 단위를 코드·문구에서 걷어내 설정으로 이관한다. 다국어
문구의 원/円, 프론트의 KRW 고정표와 리터럴 폴백 173건, 서버 포맷의 통화
코드 리터럴이 대상이며, 기본 통화가 원이 아닌 상점에서 값은 맞고 단위만
틀린 금액이 예외 없이 나가던 문제였다. 통화 판정을 설정 단일 출처로 모으고
규칙 3건을 정적 검사로 고정했다.
2026-08-10 09:07:46 +09:00
eunhyeandGitHub 7f4e665827 Merge pull request from gnuboard/chym1217/issue525
feat(board): 게시글 반응(추천/비추천) 기능 구현
2026-08-07 18:06:56 +09:00
chym1217 46ff9c036b fix(board): 반응 처리 락 획득 시점 게시글 삭제 레이스를 404로 처리
스코프 검증 통과 직후·Post 락 획득 시점 사이에 게시글이 삭제되면
lockForUpdate 의 findOrFail 이 ModelNotFoundException 을 던지는데, 컨트롤러가
이 예외를 모르므로 일반 500 으로 새어나가 사용자가 "반응 처리에 실패했습니다"
라는 원인 불명 메시지만 보게 되던 문제를 수정. 이미 존재를 확인한 게시글이
사라진 것과 같은 의미이므로 PostNotFoundException 으로 변환해 404 로 일관 처리.
2026-08-07 18:03:04 +09:00
chym1217 442474817d fix(board,admin_basic): 반응 카운트 정합성·접근성 결함 수정 + 일괄적용 화면 UX 정리
빠른 반응 전환 시 캐시된 reaction_counts 가 실제 board_reactions 행 수와
어긋나던 결함을 Post 락 직렬화 + 실제 COUNT 재집계 방식으로 수정. 반응
버튼에 aria-pressed/aria-disabled 를 추가해 스크린리더가 상태를 인지하도록
보완. 신규 게시판 생성 폼의 반응 유형 체크박스 초기값 누락, 본인 글 반응
클릭 시 안내 토스트 부재도 함께 수정.

게시판 설정 일괄 적용 화면의 대상 게시판 선택을 카드형으로 개선하고
"반응 사용" 필드 번역 키 누락(ko/en/ja)을 수정. TagInput 컴포넌트가
드롭다운 메뉴 포탈에 react-select 기본 z-index(1)를 그대로 둬 sticky
하단 버튼바에 가려지던 결함을 styles.menuPortal 오버라이드로 수정.
2026-08-07 17:27:44 +09:00
chym1217 6490b339dd fix(board): 비밀글 반응 노출 결함 수정 + 반응 버튼 UX 개선 + E2E 결함 수정
- 열람 권한 없는 비밀글에 추천/비추천을 남길 수 있던 결함을 서버(ReactionService)와
 화면(반응 영역 미노출) 양쪽에서 차단. 신고 기능과 동일한 권한 판정 기준 재사용.
- 반응 버튼 UX를 다듬어 활성 상태는 색상으로만 표시하고, 클릭 순간에만 살짝 눌리는
 촉각 피드백(active:scale-95)을 추가.
- 이슈 명세 재검토 중 describe.skip 상태였던 반응 E2E spec 에서 결함 3건 발견·수정:
 대상 게시판/게시글이 실제로 반응 불가능한 대상이었던 점, 3단계(해제) 검증이 값
 우연 일치로 거짓 통과 가능했던 점을 고치고 실제 실행으로 통과 확인.
2026-08-07 17:13:45 +09:00
chym1217 6510ce9d9c chore(i18n): 게시판 반응 기능 일본어 번역 동기화
빌드 스크립트로 ja 언어팩 누락 키 증분 동기화.

- g7-module-sirsoft-board-ja: 반응 메시지·검증·활동 로그·관리자 설정 20키 번역 추가
- g7-template-sirsoft-basic-ja: 반응 버튼 로그인·실패 안내 2키 번역 추가
- 각 ja 팩 CHANGELOG 항목 누적 (미릴리스 working 섹션)
2026-08-07 17:06:52 +09:00
chym1217 facbd98b14 fix(board): 게시글 반응 개수 표시 결함 수정 + 반응 버튼 UX 개선
Chrome MCP 검수 중 발견한 결함 수정 및 요청 반영.

- 게시글 상세 응답의 reaction_counts 가 배열로 붕괴되어 개수가 0으로
 표시되던 문제 수정 (PostResource → stdClass 로 JSON 객체 보장) + 회귀 테스트
- 반응 버튼을 수정·삭제 버튼과 같은 줄에 배치
- 반응 UX 개선: 클릭 즉시 하이라이트 반영 + 개수는 응답값으로 갱신(전체
 재조회 제거) + 처리 중 로딩·중복클릭 차단 + 실패 시 롤백·안내
- board.md 상세 응답 필드 + basic CHANGELOG + 시나리오 매니페스트 갱신
2026-08-07 17:06:49 +09:00
chym1217 a4632eb47e test(board): 게시글 반응 시나리오 매니페스트·E2E·CHANGELOG
5단계 마무리 — 기능 동작 확인 후 마감.

- tests/scenarios/board-reaction.yaml: 단일 case 축 18조합 + effects 22개,
 각 테스트에 @scenario/@effects 마킹 (test-scenario-coverage 통과)
- board-reaction-flow.spec.ts: 등록→전환→해제 E2E (describe.skip, 토큰 환경 해제)
 + reactionToken fixture
- 부수 테스트: react 활동 로그 기록, 새 게시판 반응 기본값 시드(확정 13)
- board/basic CHANGELOG Added 에 반응 기능 기재 (working 버전 누적, +2 아님)

ja 언어팩 동기화는 ANTHROPIC_API_KEY( 환경) 필요 — 후속 진행.
2026-08-07 17:04:51 +09:00
chym1217 2ba0c2ba2d feat(board): 게시글 반응(추천/비추천) 프론트엔드 화면
4단계 화면 3면 + 백엔드 수용.

- 게시글 상세(show.json): 신고 버튼 위에 반응 영역 — use_reaction &&
 유형 존재 시 노출, 유형 iteration(아이콘+라벨+개수, 0도 노출), 본인 글 비활성화,
 같은 유형 재클릭=해제·다른 유형=전환, 비로그인 안내 후 로그인 유도
- 관리 설정 기본값 탭 + 개별 게시판 편집 폼: 반응 사용 토글 + 유형 체크박스
 (reactionTypes data_source iteration, active_reaction_types code 토글)
- FormRequest(Store/Update Board, Store/BulkApply Settings)에 use_reaction·
 active_reaction_types 화이트리스트/캐스트/시드 추가 (미추가 시 토글 미저장)
- BoardResource.icon_name(fa-* 토큰) — Icon 컴포넌트 접두사 중복 방지
- ko/en 다국어 + boards/settings API 문서 갱신
- Vitest 10건(템플릿 4 + 모듈 6) green, board 관리/설정 87건 회귀 green

ja 언어팩·시나리오 매니페스트·E2E·버전 bump·CHANGELOG 는 5단계.
2026-08-07 17:00:48 +09:00
chym1217 4060a91d28 feat(board): 게시글 반응(추천/비추천) 백엔드
DB 스키마 + 백엔드 로직 + API 레이어를 구현.

- board_reaction_types/board_reactions 테이블 신규, boards.use_reaction·
 active_reaction_types, board_posts.reaction_counts 컬럼 추가
- ReactionType(다국어 JSON+폴백)/Reaction 모델, Repository 2종(인터페이스 주입)
- ReactionService: 등록/전환/해제 통합, DB::transaction 원자 카운트,
 use_reaction·비활성유형·본인글·slug 스코프 차단, before/after_react 훅
- POST boards/{slug}/posts/{postId}/react(회원) + GET admin/reaction-types
- ReactPostRequest + AvailableReactionType Rule, PostResource/BoardResource 확장
- 반응 활동 로그(add/change/remove) + ko/en 다국어 + API 레퍼런스 문서
- 테스트 30건(Unit/Feature, 87 assertions) green, 마이그레이션 왕복 안전성 검증

프론트엔드 화면·ja 언어팩·CHANGELOG·버전 bump 는 후속 단계(4~5)에서 진행.
2026-08-07 17:00:41 +09:00
정정홍andGitHub 3d0f260653 Merge pull request from gnuboard:HeuJung/issue534
fix(ecommerce,board,tosspayments,pay_kginicis,pay_nhnkcp,pay_nicepayments,core,basic): 공개 이슈 잔여 결함 5건 해소 — 상점 주소 SSoT·게시판 계약/안내/상한/색인
2026-08-07 12:08:12 +09:00
HeuJung f9c4c025cb fix(ecommerce,board,tosspayments,pay_kginicis,pay_nhnkcp,pay_nicepayments,core,basic): 공개 이슈 잔여 결함 5건 해소 — 상점 주소 SSoT·게시판 계약/안내/상한/색인
공개 이슈(gnuboard/g7) 17건 전수 대조에서 "진짜 누락"으로 판정된 5건을 해소한다.

 — 서버가 만들어 내보내는 상점 주소가 각 지점에서 `/shop` 리터럴이나
`"/{$routePath}/..."` 조립으로 흩어져 있었다. 기본값이 아닌 상점에서는 그 주소가
존재하지 않는 화면을 가리키는데, 서버는 문자열을 만들어 내려보냈을 뿐이라 예외도
404 로그도 남지 않는다 — 링크만 조용히 죽는다. `ShopPathResolver` 를 단일 해석
지점으로 두고 주문 완료 이동·비회원 조회 안내·알림 메일·통합검색·상품문의·SEO
캐시·사이트맵 전 지점을 경유시켰다. 같은 값에 종속된 PG 4종 리다이렉트는
`{shopBase}` 자리표시자로, 편집기 상태 scope 는 선택 세그먼트 토큰 `/*?`
(engine-v1.58.0)로 해소했다. 기설치본은 업그레이드 스텝이 백필한다.

 M-02 — 답변 깊이 상한이 config 는 1~10 인데 컬럼 comment 만 1~5 로 남아,
comment 에서 자동 생성되는 계약 문서 4곳이 실제보다 좁은 상한을 공개하고 있었다.

 L-01 — 관리자 게시글 폼에 첨부 안내가 아예 없었고, 대신 사장된 고정 문구
("최대 5개 파일, 각 10MB")가 남아 있었다. 게시판마다 다른 실제 설정값을 렌더한다.

 — 목록 1페이지가 공지 전량을 상한 없이 읽던 것에 안전상한을 두고(구조 무변경,
limit 부가만), 조회수 정렬 목록 술어를 덮는 복합 색인을 신설했다.

브라우저 실측 검수에서 발견한 사전 결함 1건(사용자 게시판 목록이 URL 정렬
파라미터를 데이터소스로 전달하지 않던 문제)도 함께 고쳤다. 폴백을 빈 문자열로 둬
URL 에 정렬이 없을 때 게시판 설정을 덮어쓰지 않게 했다.
2026-08-07 12:04:13 +09:00
정정홍andGitHub 3715be5e51 Merge pull request from gnuboard:HeuJung/issue535
fix(core): 라우트 캐시 상태에서 DevTools 덤프·브라우저 로그 수집 복구
2026-08-07 10:16:25 +09:00
HeuJung 4744e7bf85 fix(core): 라우트 캐시 상태에서 DevTools 덤프·브라우저 로그 수집 복구
route:cache 가 걸리면 RouteServiceProvider 가 캐시 로드로 분기해 라우트 파일이
실행되지 않는다. routes/devtools.php 는 전역 함수 5개를 정의하고 핸들러가 호출했고,
boost 의 browser-logs 는 프로바이더 boot 등록분이라 setCompiledRoutes 교체로
폐기됐다. 7.0.6 의 RouteCacheHelper 가 12개 라이프사이클 지점에서 캐시를 자동
생성하면서, 확장을 한 번이라도 건드린 사이트에서 항상 재현됐다.

로직을 오토로드되는 App\Support\DevTools\* 로 옮기고 browser-logs URI 를 G7 가
직접 소유해 캐시에 함께 구워지게 했다. 게이트가 참조하던 App\Models\Setting 은
저장소에 없는 유물이라 ConfigRepositoryInterface 로 교체했다.

같은 부류의 재발은 정적 검사(route-file-local-function)로 차단하고, 재현이
in-process 로 불가능하므로 서브프로세스 하네스로 회귀를 고정했다.
2026-08-07 10:13:52 +09:00
정정홍andGitHub e80be1d924 Merge pull request from gnuboard:HeuJung/issue454
fix(ecommerce,tosspayments,pay_kginicis,core): 리베이스 접합부 payment_method string 계약 미전파 회귀 수정
2026-08-06 16:09:29 +09:00
HeuJung bf3d0d1df5 fix(ecommerce,tosspayments,pay_kginicis,core): 리베이스 접합부 payment_method string 계약 미전파 회귀 수정
develop 가 확장 결제수단 ID 허용을 위해 OrderPayment.payment_method 의
enum 캐스트를 제거했으나, 리베이스에서 텍스트 충돌이 없던 브랜치 소유 코드
4곳이 enum 전제 그대로 병합되어 string===enum(항상 false)·string 메서드 호출
(fatal)로 잠복했다. 부분취소/환불 재계산 fatal, 무통장 입금완료 현금영수증
자동발급 무동작, 토스 가상계좌 환불 수취계좌 미전송이 이에 해당하며, 전부
develop 기존 관용구(tryFrom/능력 메서드/->value)로 수렴해 해소.
IDV getStatus 의 리베이스 잔재 주석(코드와 모순)도 사실에 맞게 정정.

검증: ecommerce 235/235 · tosspayments 19/19 · pay_kginicis 5/5 green,
audit --working 0, 활성 반영(module/plugin:update --force) 완료.
2026-08-06 15:57:23 +09:00
HeuJung c9a73e7877 chore(core,ecommerce,tosspayments,pay_kginicis,message_bizppurio,admin_basic): 리베이스 후속 정합화 — 보류 테스트 정리·소스맵 게이트 강제
develop 리베이스 완주 후 보류 항목 처리.

- ApiDocPipelineTest 의 예시값 갱신 단언을 병합 계약(타입 동일 시 보존 —
 멱등)에 정합화. 예시값 최신성 단언과 멱등성 설계가 같은 상황에 정반대를
 요구하던 stale test 로, 병합에서 의도 채택한 쪽은 멱등성이다.
- 환경설정 보안 탭 체크박스 2개의 label prop 을 Label 래핑+형제 Span 으로
 교체. label prop 은 렌더되지 않아 설명 문구가 화면에 없었다.
- 확장 vite config 의 sourcemap: true 하드코딩이 --production 의
 G7_BUILD_SOURCEMAP=0 주입을 조용히 무시해 배포본 dist 에 .map 과 dangling
 참조가 남던 문제를 tosspayments·message_bizppurio 에서 정정하고 production
 재빌드. template-security.md 에 게이트 형태를 명시하고 audit 룰
 vite-sourcemap-env-gate(error) 를 신설해 재발을 차단한다.
- PHPUnit 12 대비: 접촉 테스트 파일의 docblock 메타데이터 6건 attribute 전환.
2026-08-06 15:00:23 +09:00
HeuJung 0207ae024e chore(ecommerce): 이커머스 번들 배포본 소스맵 참조 제거
리베이스가 이월한 issue454 의 pre-strip 번들에 남은 dangling
sourceMappingURL 참조를 프로덕션 빌드(module:build --production)로
재생성해 제거. 배포본 소스맵 차단 정책(코어 7.0.4)과 일치시킨다.
2026-08-06 14:19:15 +09:00
HeuJung f496477ba0 test(ecommerce): 체크아웃 결제수단 회귀 테스트 mock 을 selectedCorePaymentMethod 도입에 맞춰 정정
토스 주문서형 결제수단 도입으로 주문 body 표현식이 _computed.selectedPaymentMethod
→ selectedCorePaymentMethod (결제수단 id → core_payment_method 매핑) 로 바뀌었으나,
ecommerce 회귀 테스트의 context 헬퍼가 selectedPaymentMethod 만 세팅한 채 새 키를
누락해 payment_method 가 undefined 로 평가되던 stale test 를 정정. 레이아웃 로직은
정상이며 템플릿 측 테스트가 동일 동작을 이미 커버한다.
2026-08-06 14:16:42 +09:00
HeuJung 6c8381f164 fix(core,board,ecommerce,plugins): API 문서 미채움 마커 전수 채움 + 미해석 lang 키 정정 + IDV 조회 시도횟수 비노출
이슈 본연: API 레퍼런스의 미채움 마커 5종(실측 제외/TODO/필드 없음/대표 에러 없음)
1,194건을 코드에서 읽어 전수 채우고(0건), api:docgen 재생성이 사람이 채운 내용을
손실·열화시키던 멱등성 결함 4종(CRLF, 표 파이프 이스케이프, 에러 서술 보존, 중복
라우트명 키)을 근본 수정. --check 를 실측 제외와 드리프트가 구분되도록 재정의.
ParameterDescriber/ResourceFieldDescriber 에 leaf 폴백·SEO 스코프·공통 사전 확장.

파생 결함(문서 실측 중 발견): ResponseHelper 기본 메시지 키 6종이 존재하지 않는
messages.* 를 가리켜 응답 message 가 번역문 대신 키 문자열로 노출되던 문제를 common.*
으로 정정하고, 코어 7 + gdpr/marketing/pay_kginicis/verification/ecommerce 확장의
호출부와 누락 lang 키(게시판 10키 + 이커머스 category_images 4키 포함)를 ko/en/ja
전수 정정. 중복 그룹 키 2건은 기존 키로 호출부 통합.

보안: GET /api/identity/challenges/{id} 는 권한 가드 없는 공개 폴링 엔드포인트인데
Service::getStatus 가 attempts/max_attempts 를 응답에 담아 남은 시도 횟수를 추론할 수
있었다. 두 필드를 제거하고, 챌린지 화면(admin_basic/basic)의 서버 attempts 참조를
제거(남은 횟수 UI 는 query fallback + verify 실패 시 로컬 증가로 유지).

부수 정리(변경셋에 들어온 board 컨트롤러의 audit 사전 결함): FormRequest 4개 신설로
base Request 주입 제거, CommentController 의 Board 직접 호출을 BoardService 위임으로
전환, PHPDoc @return 보강. board 스위트에서 발견한 stale test 3건(seed SSoT 불일치,
admin 계정 user 역할 누락)도 같은 세션에서 정정.

버전 정렬(공개 release 기준): 코어 7.0.3→7.0.4, sirsoft-board 1.0.1→1.0.2,
verification_kginicis 1.0.0→1.0.1, admin_basic 1.0.1→1.0.4.
2026-08-06 14:16:40 +09:00
HeuJung b07d6c8953 fix(ecommerce,tosspayments,templates): 에스크로 표시 누락·결제수단 전환 시 입력 잔류 수정
- 에스크로 표시가 영구히 렌더되지 않던 문제: is_escrow 가 DB·모델·부분취소 차단
 로직에는 있으나 OrderPaymentResource 가 내보내지 않아, 이 값을 참조하는 주문
 상세(관리자·구매자)의 에스크로 행이 항상 false 로 평가됐다. Resource 에 노출한다.

- 결제수단을 바꿔도 이전 수단의 입력이 주문에 실리던 문제: 결제수단 버튼이
 _local.paymentMethod 만 바꾸고 슬롯이 소유한 확장 페이로드·환불계좌는 비우지
 않았다. 확장 슬롯과 환불계좌 블록은 결제수단 조건부로 언마운트되지만 _local
 값은 남으므로, 무통장에서 현금영수증·환불계좌를 입력한 뒤 카드로 전환하면 그
 값이 주문 생성 요청에 그대로 실렸다.
 프론트는 결제수단 전환 시 슬롯 소유 상태를 초기화하고, 서버는 환불계좌가 쓰이지
 않는 결제수단이면 저장하지 않도록 게이팅한다(현금영수증은 이미 dbank 게이트 보유).

- Modal 이 받지 않는 size prop 을 쓰던 4곳을 width 로 교체(무시되어 의도한 폭이
 나오지 않았다). 토스 에러 모달의 setState target 을 params 안으로 옮긴다.

- 레이아웃 편집기 팔레트에서 체크박스를 넣으면 라벨 없이 네모 칸만 놓이던 문제:
 Checkbox 는 label prop 을 렌더하지 않으므로 Label 래핑 + Span 텍스트로 바꾸고,
 Accordion 이 Label 을 받도록 중첩 스펙을 맞춘다.
2026-08-06 13:58:00 +09:00
HeuJung 38a5cbe550 fix(tosspayments,pay_kginicis): 플러그인 설정 검증이 큐로 나가 무동작하던 문제 수정
설정 저장을 차단해야 할 검증 리스너가 core.plugin_settings.before_save 를
구독하면서 sync 를 선언하지 않았다. Action 훅의 기본값은 큐 디스패치이고
실환경 큐 드라이버는 database 이므로, ValidationException 이 워커 안에서 죽고
PluginSettingsService::save 는 doAction 직후 저장을 그대로 진행했다.

결과적으로 범위 밖 가상계좌 유효시간, 허용값 밖 에스크로 설정, 빈 CBT 라이브
필수값이 아무 저항 없이 저장됐다. 리스너 docblock 이 스스로 약속한 422 응답도
나가지 않았다.

두 리스너에 sync 를 선언해 저장 트랜잭션 안에서 동기 실행되도록 했다.

회귀 가드는 훅 디스패치 경로를 실제로 통과시킨다. phpunit.xml 이 큐 드라이버를
sync 로 강제해 큐 경로도 즉시 실행되므로, Queue::fake 로 운영과 같은 비동기
상황을 만들어야 결함이 드러난다 — 리스너 메서드를 직접 호출하는 기존 테스트는
이 결함을 구조적으로 잡지 못한다.
2026-08-06 13:57:57 +09:00
HeuJung 403154167a fix(core): api:docgen 재생성이 문서를 손실·열화시키는 결함 수정
재생성이 사람이 채운 서술과 이전 실측 결과를 지우고, 코드가 바뀌지 않은
문서까지 매 실행마다 흔들던 결함 5건을 고쳤다.

- 중첩 객체 파라미터가 문서에서 통째로 누락되던 문제: 배열 요소(items.*.id)를
 상위로 대표시키려던 스킵 조건이 점(.) 포함 여부만 봐서, 와일드카드가 없는
 중첩 객체 필드(refund_bank.*, general.*, content.* 등 17개 엔드포인트 수백 개)
 까지 함께 버렸다. 와일드카드가 있을 때만 스킵하도록 좁혔다.
- 사람이 보강한 에러 응답 표가 자동 추론 초안에 덮여 사라지던 문제:
 상태코드 키 단위로 병합한다.
- 실측 실패가 이전에 관측해 둔 응답 예시를 지우던 문제: 실측 성패는 호출 시점
 데이터 유무에 좌우되므로(DELETE /checkout 은 대상이 없으면 404), 실패는
 "새로 관측하지 못했다"는 뜻이지 기존 관측이 무효라는 뜻이 아니다.
- 실측 예시값·응답 예시 JSON 이 DB 상태를 그대로 반영해 비멱등하던 문제:
 값이 아니라 스펙(필드 집합·타입)이 바뀐 경우에만 갱신한다. nullable 이
 null 로 관측된 것은 타입 변경이 아니므로 기존 값을 유지한다.
- 요청 예시 path 파라미터가 실측 성패에 따라 흔들리던 문제: placeholder 로 고정.

결과: 문서 63개 전수 대조에서 손실 0(서술·표 셀·응답 예시), 전 scope 연속
재생성 시 diff 0(멱등).
2026-08-06 13:54:35 +09:00
HeuJung 3f2674f1a7 fix(tosspayments,templates,core): 결제수단 체크박스 라벨 미표시·저장 실패 수정
Checkbox 는 label prop 을 렌더하지 않는다(부모 Label 이 표시 담당). 그 prop 에만
의존해 결제수단 9종의 라벨이 화면에서 전부 비어 있었다. Label 로 감싸고 텍스트를
형제 Span 으로 분리했다 — 라벨 클릭 토글과 접근성 이름도 함께 확보된다.

수정 중 저장 결함을 함께 발견했다. 폼 자동바인딩은 값이 boolean 일 때만 checked 로
붙고, null 이면 value 분기로 떨어져 빈 문자열을 전송한다. 서버가 그것을 null 로
저장하면 기본값(false)을 덮어 화면에서 다시 켤 수 없게 고착된다. initLocal 로는
막을 수 없어(shallow 는 form 객체를 교체하고, deep 은 source 의 null 을 그대로
반영) 레이아웃이 boolean 을 직접 보장하도록 했다 — autoBinding:false + checked 강제
+ change 액션이 $event.target.checked 를 기록.

같은 원인으로 편집기 팔레트도 라벨 없는 체크박스를 삽입하고 있어 함께 고쳤다.

회귀 방어: 레이아웃/팔레트 계약 테스트, 엔진 동작(isCheckedBinding·deepMergeState)
고정, 저장 페이로드가 boolean 인지 검사하는 E2E, audit 룰
layout-checkbox-label-and-boolean(label prop=error, 자동바인딩 의존=warn).
2026-08-06 13:27:45 +09:00
HeuJung faa79b0ae1 feat(tosspayments,pay_kginicis,ecommerce,core): 현금영수증 프로바이더 훅 축 + 토스 환불 정합성
현금영수증 발급사를 PG 결제사와 독립적으로 선택할 수 있도록 공용 훅 축
(registered_providers/issue/cancel)에 토스·KG 프로바이더를 등록한다. KG 결제로
받으면서 영수증만 토스로 발급하는 구성이 가능해진다. KG 전용 발행 컨트롤러·
라우트·관리자 패널은 이 축으로 이전하며 제거했다 — 이중 발행 경로를 남기지
않기 위해서다.

토스 환불은 네 가지를 바로잡았다. 전액취소를 부분취소로 보내던 문제(R1),
면세금액 미전달로 복합과세 상점의 부가세가 틀어지던 문제(R2), 가상계좌
환불계좌를 싣지 않아 취소가 실패하던 문제(R3), 에스크로 부분취소를 PG 호출
전에 차단하지 않던 문제(E4). 취소·발급 요청에는 멱등키를 부착해 네트워크
재시도가 국세청에 이중 신고되거나 이중 환불되는 것을 막는다.

코어 활동로그 가드가 \Exception 만 잡아 채널 해석 실패(\Error)를 놓치고 있었다.
결제 입금통보 같은 외부 콜백이 로그 문제만으로 500을 반환하던 문제를 \Throwable
로 수정한다. 활동로그는 부수 기록이므로 그것을 유발한 요청까지 죽여서는 안 된다.

토스 API 서비스는 베이스 \Exception 을 직접 던지고 있어 도메인 예외
(TossPaymentsApiException)를 신설했다. KG의 KgInicisApiException 선례와 동형이며
RuntimeException 상속이라 기존 소비자의 catch 는 그대로 동작한다.
2026-08-06 12:58:59 +09:00
HeuJung a9f85ff92b feat(tosspayments,ecommerce,core): 토스 주문서형 결제수단·가상계좌·에스크로 + API 문서 web 라우트 확장
토스페이먼츠 플러그인을 프로덕션 수준으로 완성한다 (계획서 S4).

주문서형 결제(9종 동적등록)
- 플러그인이 filter_available_payment_methods 로 결제수단을 주입하면 체크아웃에
 독립 항목으로 노출된다. 서버 전송 시에는 core_payment_method 로 번역해 보낸다 —
 코어 PaymentMethodEnum 은 toss_* 를 거부하므로 번역 없이는 주문 자체가 422 로 실패했다.
 번역 근거인 core_payment_method 가 설정 병합/스냅샷의 화이트리스트에서 탈락하던 것을
 provider-agnostic 하게 보존하도록 고쳤다 (토스 전용 분기 없음).

가상계좌·웹훅·에스크로
- 입금통보/결제상태 웹훅 2종 신설. secret 대조(hash_equals)·리플레이 멱등·CSRF 면제.
- 에스크로 3상태(off/on/buyer_choice). escrowProducts 를 가상계좌·계좌이체 양쪽
 SDK 페이로드에 싣는다 — 서버가 조립해도 SDK 로 전달되지 않으면 토스가 결제를 거부한다.
 E2E 가 SDK 경계를 직접 캡처해 이 계약의 나머지 절반을 잠근다.
- 관리자 설정 저장 시 입금기한·에스크로 값 범위를 서버에서 검증한다 (UI max 는 클라 힌트라
 API 직접 호출을 막지 못한다).

레이아웃 리스너 실행 순서
- KG 는 no-PG 리스트를 통짜 리터럴로 str_replace 하므로 토스가 먼저 append 하면 KG 의
 매치가 깨진다. HookManager 는 ksort 오름차순이므로 토스 priority 를 30 으로 두어
 "KG(20) 먼저" 를 플러그인 로드 순서와 무관한 불변식으로 고정했다.

코어 — API 문서 생성기가 확장 web 라우트도 수집
- PG 콜백·웹훅은 CSRF/세션 특성상 web.php 에 등록되지만 외부 시스템이 호출하는
 machine-facing 엔드포인트라 API 레퍼런스 대상이다. api/ prefix 만 수집하던 탓에
 영구 무문서였다. 확장 소유 web 라우트를 수집하되 관리자 화면(admin 컨텍스트)은 제외한다.
 코어 라우트 수집 결과는 291건으로 불변.

하네스 — 확장 테스트 베이스클래스 룰 신설
- coverage.json 의 test-extension-base-class 가 status:todo(룰 미구현)라 규정이 있어도
 검출되지 않았고, 실제로 이번 신설 테스트가 그 사각에 빠졌다. 룰을 구현해 봉인한다.
2026-08-06 12:46:48 +09:00
HeuJung 42eb21320b fix(ecommerce,core): 현금영수증 UI 보완 + 하네스 결함 6건 + 확장점 룰 사각 제거
현금영수증/환불계좌 기능(S3)의 감사 보완과, 그 과정에서 발견한 하네스 결함을
같은 세션에서 일괄 처리한다. 지시에 따라 무관 산출물(namuwiki 문서 초안 포함)도
단일 커밋으로 묶는다.

- 현금영수증 발급 이력 접기/펼치기(W-3) + 결제행별 독립 토글 + aria 상태
- vendor-bundle manifest 해시 재생성 — composer.json version bump 미반영분 정합
- 시나리오 매니페스트 거짓 경로 정정(A/B) + 미작성 부채 :test-files 정직 표기(C)
- layout-* 룰의 resources/extensions 검사 사각 제거 + 확장점 전용 root 스키마 분기
- validate-test-scenario.cjs: 맵형 test_files 크래시 + fallback 파서 covers 오검출 수정
- tosspayments 확장점 setState target 규약 위반 수정
2026-08-06 12:36:31 +09:00
HeuJung 58560eefb1 feat(ecommerce): 현금영수증 발급 API + 입금완료 자동발급 + 금액변동 연동
무통장입금 주문의 현금영수증 발급을 관리자·회원·비회원 세 경로에서 제공하고,
입금이 확인되면 자동 발급하며, 부분환불로 금액이 바뀌면 전액취소 후 재발급한다.

발급 프로바이더에 종속되지 않도록 발급/취소는 필터 훅으로 위임한다.
자동발급 리스너는 결제완료 전이(after_payment_complete)와 입금만 기록
(after_deposit_recorded) 두 경로를 모두 구독한다 — 후자가 없으면 관리자가
"입금만 기록"했을 때 자동발급이 통째로 누락된다.

금액 동기화(syncFromOrder)는 트랜잭션이 닫힌 뒤에 부른다. 재발급 실패 이력은
국세청 신고 누락을 막는 원장이라 롤백에 휩쓸리면 안 되고, 프로바이더 왕복 2회
동안 DB 잠금을 붙들 수 없기 때문이다. 환불은 이미 확정됐으므로 어떤 실패도
호출부로 전파하지 않고 FAILED 이력 + 로그로 남겨 관리자 수동 복구에 맡긴다.

식별번호는 재발급용으로만 암호화 보관하고 구매확정 시점에 폐기한다.
그 시점을 잡기 위해 order.after_purchase_confirmed 훅을 신설했다.

함께 고친 사전 결함:

- 취소 시 total_cash_equivalent_amount 가 재계산되지 않아 syncFromOrder 가
 "금액 변동 없음"으로 판정, 부분환불 후 재발급이 아예 동작하지 않았다.
 산정 규칙을 PaymentMethodEnum::resolveCashEquivalentAmount 로 SSoT 화해
 주문 생성 시점과 취소 재계산 시점이 같은 규칙을 쓰게 했다.

- 현금영수증 발급이 payment.receipt_url 을 덮어써 카드 매출전표 URL 이
 유실됐다. 한 컬럼이 두 의미를 갖던 문제로, 현금영수증 URL 은 이력 테이블이
 보관하도록 분리했다.

- OrderService::confirmOption 이 confirmed_at 을 무조건 now 로 덮어썼다
 (v0.16.1 부터 존재). 재확정 시 최초 확정 시점이 소실되고 구매확정 훅이
 중복 발화한다. OrderOptionService 와 대칭으로 멱등 가드를 넣었다.

- 코어 api:docgen 이 사람이 작성한 API 문서를 재생성 때마다 지웠다.
 @generated 블록 안의 표 셀·응답 본문이 대상이었고, extractGeneratedBlock
 이 앞의 `## ` 헤딩까지 거슬러 올라가 여러 엔드포인트를 한 덩어리로 반환하던
 문제도 함께 고쳤다. 실측 산출 응답 예시에 @probed 출처 마커를 붙여, 마커가
 없는 본문은 사람 작성분으로 보고 보존한다 — 실측 1회의 표본은 사람이 코드에서
 읽어낸 사실보다 좁으므로 실측은 기본값이지 덮어쓰기 권한이 아니다.

 ParameterDescriber 가 {order} 같은 path 파라미터를 "정렬 방향 asc/desc" 로
 설명하던 오염도 정정했다.
2026-08-06 12:17:24 +09:00
HeuJung eb724ed348 test(ecommerce): 통화 스냅샷 부재 테스트가 팩토리 기본값에 의존하던 문제 수정
test_mc_fields_null_without_currency_snapshot 은 "OrderFactory 가 currency_snapshot 을
비워 둔다"는 전제로 작성됐으나, 이후 팩토리에 기본 스냅샷이 추가되면서 전제가 무너져
클린 트리에서도 실패하고 있었다. 비즈니스 로직 회귀가 아니라 stale test 다.

팩토리 기본값에 의존하지 않고 currency_snapshot 을 명시적으로 null 로 주입해
레거시 주문(다통화 도입 이전 행)의 취소 경로를 재현한다. 전제가 다시 조용히
무너지지 않도록 assert 로 고정했다.

전액취소 경로의 스냅샷 가드를 무력화하면 이 테스트가 실패하는 것을 확인했다.
2026-08-06 11:50:52 +09:00
HeuJung cd82fe3fb4 feat(ecommerce): 현금영수증 발급 회차를 훅 페이로드로 제공
토스페이먼츠는 동일 orderId 로 재발급을 요청하면 중복으로 거부한다.
계획서 §B-8 은 "{order_number}-cr{seq}" 형태의 회차 접미를 요구하는데,
기존 발급 훅 페이로드에는 order_number 원본만 있어 리스너가 회차를 알 수 없었다.

회차는 코어가 센다. 프로바이더 리스너가 이력 테이블 구조를 알 필요가 없고,
향후 팝빌·바로빌 등 다른 발급사도 같은 숫자를 그대로 쓸 수 있다.
프로바이더 종속 문자열 조립은 리스너 내부에만 둔다.

실패한 발급도 회차를 소모한다 — 프로바이더에 요청이 도달한 뒤 응답만 실패한
경우 같은 식별자를 재사용하면 중복 거부되기 때문이다. 취소 이력은 소모하지 않는다.
2026-08-06 11:48:13 +09:00
HeuJung af1da5fe0b feat(ecommerce): 현금영수증 프로바이더 훅 축 + 이력 테이블 + 현금성 금액 + 배송비 과세 정책
현금영수증 발급 프로바이더를 PG 와 독립적으로 선택할 수 있도록 훅 축을 신설한다.
KG이니시스로 결제받으면서 현금영수증만 토스페이먼츠로 발급하는 구성이 가능하다.

발급/취소 원장(ecommerce_order_cash_receipts)이 국세청 신고 근거를 보관한다.
금액이 바뀌면 syncFromOrder 가 주문의 현재 상태에서 발급액을 재계산해
전체취소 → 전액 재발급한다. 부분취소 API 를 쓰지 않으므로 전액취소만 지원하는
벤더도 동일 인터페이스로 수용되고, 증가/감소 방향을 구분하지 않아 향후 반품·교환
배송비 청구가 도입되어도 수정 없이 동작한다.

배송비는 그동안 과세/면세 분류에서 아예 빠져 있었다. 배송비는 단계 3 에서 계산되고
할인은 단계 4 에서 적용되므로 상품 분류 시점(단계 2-b)에는 존재하지 않는다.
따라서 할인 후 배송비가 확정된 Summary 집계 직전에 3가지 정책 중 하나로 분류해
합산하고, 옵션별 과세액에는 배송비를 섞지 않는다.

재발급용 식별번호는 APP_KEY 기반으로 암호화 보관하고 구매확정 시 폐기한다.
이력에는 마스킹 값만 남기며 프로바이더 원응답의 민감 키도 가린다.
주민등록번호는 수집하지 않는다.

cash_receipt_type 은 Enum 캐스트하지 않는다 — 레거시 값(income_deduction)이 남아
있는 동안 Laravel 의 ::from 이 ValueError 를 던져 정규화해야 할 업그레이드 스텝
자신이 그 행을 읽지 못하게 되기 때문이다.
2026-08-06 11:48:12 +09:00
HeuJung 5b587d7119 fix(audit): api-doc-coverage 가 web 전용 확장에 문서 동반을 요구하던 오탐 수정
토스페이먼츠 복원 후 api-doc-coverage 가 warn 4건을 냈으나, 토스는 src/routes/api.php
가 없고 web.php 의 브라우저 리다이렉트 콜백 2건만 가진다. api:docgen 은 이 경우
"API 라우트가 없습니다" 로 응답하므로 문서 생성 자체가 불가능하다. 즉 충족할 수 없는
위반을 요구하고 있었다.

원인은 룰의 appliesTo 가 routes/*.php·Controllers/** 를 파일 경로로만 매칭하고 그
확장이 실제 api/ 표면을 갖는지 확인하지 않은 것이다. 확장의 api/ prefix 라우트는
RouteServiceProvider 가 api.php 를 로드해서만 등록되므로(web.php 는 web prefix),
api.php 존재 여부가 필요충분 판정자다. 번들 확장 14개 전수 대조에서 docs/api 보유와
api.php 존재가 14/14 일치함을 확인했다.

extensionHasApiSurface 게이트를 두어 api.php 가 없는 확장을 check 단계에서
면제한다. 확장명은 하드코딩하지 않으므로 api.php 가 추가되면 자동으로 검사 대상이
된다. repoRoot 미주입 시에는 판정이 불가하므로 기존대로 보수적으로 보고한다.

같은 오탐이 gnuboard7-hello_plugin 에도 있었고 함께 해소된다. api.php 를 가진
pay_kginicis 는 web.php 만 바뀌어도 여전히 문서 동반을 요구한다(면제 오적용 방지).

규정과 도구를 함께 갱신했다 — api-documentation.md 에 "문서 대상이 아닌 확장" 절을
신설하고, coverage.json 의 stale 한 note(verification_kginicis 를 미문서화 대상으로
기술)를 실제 상태로 정정했다.
2026-08-06 11:32:52 +09:00
HeuJung 26a349590e feat(plugins): 토스페이먼츠 번들 플러그인 복원
7.0.0 정식 출시 커밋에서 PG 3종이 함께 제거되었으나, 그중 토스페이먼츠만
번들로 되돌린다. 삭제 직전 트리를 기준으로 플러그인 40개 파일과 일본어
언어팩 6개 파일을 복원하고, 삭제에 연동되어 변경되었던 README 목록·언어팩
빌드 스크립트 엔트리·이커머스 편집기 샘플의 PG provider 를 함께 되살렸다.

복원된 코드는 삭제 시점에 멈춰 있어 현행 코드베이스와 어긋나는 지점이 있어
함께 정합화했다.

- PluginTestCase 가 활성 디렉토리 경로를 참조해 _bundled 단독 실행 시
 라우트가 등록되지 않던 문제를 자기 기준 경로로 교체
- SettingsServiceProvider 의 site_url override 로 app.url 이 오염되어
 assertRedirect 가 간헐 실패하던 문제를 APP_URL 명시 리셋으로 차단
- 환경설정 레이아웃의 시맨틱 클래스가 한 칸씩 밀려 순환 배치되어 2열 폼이
 무너지고 섹션 제목이 에러 색으로 표시되던 결함을 정정 (회귀 테스트 신설)
- 편집기 저장 시 silent drop 되던 미표준 root 키 제거, Icon 을 size prop 으로 전환
2026-08-06 11:29:47 +09:00
정정홍andGitHub 2b2856ed37 Merge pull request from gnuboard:HeuJung/issue519
HeuJung/issue519
2026-08-06 11:17:37 +09:00
HeuJung bd7b31feb8 fix(ecommerce): 상품 카드 별점이 리뷰 유무와 무관하게 0 으로 보이던 문제
최근 본 상품과 찜 목록만 리뷰 집계를 붙이지 않아, 리뷰가 달린 상품도 카드에 별 0 개로
표시됐다. 두 조회에 다른 목록과 같은 집계를 붙였다.

값이 비었는지로 판정하던 소비 측도 함께 고쳤다. 집계를 안 한 것과 세어보니 0건인 것이
같은 0 으로 뭉개져 있었다 — 이제 집계 컬럼이 붙었는지로 판정해, 세지 않은 조회에서는
항목을 생략하고 리뷰가 0건이면 종전대로 0 으로 표기한다. 관리자 상품 목록이 그 대상이라
API 문서에 어느 조회가 통계를 싣는지 명시했다.

소비처를 필드명으로만 훑어 처음에는 "화면에 안 보인다" 고 판정했는데 틀렸다. 레이아웃은
상품 객체를 통째로 카드에 넘기고 카드가 내부에서 별점을 그린다. 그 컴포넌트를 쓰는 화면의
공급 경로를 전수로 세어 찜 목록을 추가로 찾았다.

develop 리베이스 후속 검증 결과와 충돌 해소 근거도 함께 담는다.
2026-08-06 11:13:54 +09:00
HeuJung 7b859d4200 fix(core,basic,ecommerce): 쇼핑 화면 호출 통합 원복·검색 주소 승계·라우트 캐시 갱신 및 상한값 설정 저장 정합
합의되지 않은 구조 변경을 되돌리고, 계획서 전수조사에서 드러난 결함을 처리했다.

쇼핑 화면 호출 통합(F4)은 API 쿼리 튜닝이 아니라 화면 구조 변경이라 원복했다.
레이아웃 9개를 develop 상태로 복원하고 전용 엔드포인트·컨트롤러·문서·테스트를 걷어냈다.
되돌리면 함께 사라지는 비-F4 수정 둘(페이저 has_more_pages 판정 3곳, Icon 크기 클래스
23줄)은 재적용했다. 성능 개선분은 전부 잔존한다.

통합검색 화면 상태를 전역 상태에서 URL 쿼리로 옮겼다. 종전에는 탭·필터·정렬·페이지가
주소에 없어 새로고침·뒤로가기에서 초기화되고 결과를 공유할 수도 없었다. 화면은 정상으로
보이고 콘솔 에러도 없어 코드만으로는 드러나지 않는 형태였다.

확장이 라우트를 추가해도 라우트 캐시가 걸린 사이트에서는 그 주소가 404 가 되던 구멍을
막았다. 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 예외도 경고도 남지 않는다.
같은 문제를 이미 푼 ConfigCacheHelper 를 미러링해 갱신 지점을 한 곳으로 모았고,
코어 업데이트가 캐시를 비우기만 하고 되살리지 않던 문제도 함께 고쳤다.

상품·페이지 검색이 커서 판정에 페이지 번호를 넘기지 않아, 커서 없이 깊은 페이지를
지목한 딥링크가 조용히 1 페이지를 돌려주던 결함을 고쳤다. 호출 지점을 손으로 열거하는
대신 저장소를 훑는 패리티 테스트로 다음 도메인의 재발을 막는다.

환경설정 고급 탭의 목록 상한값이 저장되지 않던 결함을 고쳤다. 화면·검증·읽기는 모두
있었는데 저장 시 카테고리 분류표가 손으로 열거돼 있어 그 값만 버려지고 있었다. 분류표를
설정 정의에서 도출하도록 바꿔 다음 카테고리가 합류해도 같은 일이 생기지 않게 했다.
범위를 벗어난 값의 안내에 내부 식별자가 노출되던 것도 함께 정리했다.

언어팩 재설치가 활성 팩을 자기 자신과의 슬롯 충돌로 오인해 강등하던 회귀와, CLI 설치가
HTTP 권한 컨텍스트 없이 자동 활성화되는지를 고정하는 테스트를 함께 담았다.
2026-08-06 11:13:54 +09:00
HeuJung 0d1105abfc fix(basic,admin_basic): 상한 목록의 페이저 소비단 정합 및 화면 표기
저장소가 총 건수를 상한까지만 세게 되면서 마지막 페이지 번호가 없는 목록이
생겼는데, 화면은 그 값을 1 로 접거나 페이저 노출 조건으로만 쓰고 있었다. 앞은
"1페이지뿐" 으로 잘못 말하고 뒤는 페이저를 통째로 지워 뒤쪽 페이지로 갈 방법
자체를 없앤다. 둘 다 예외도 404 도 내지 않아 화면은 정상으로 보이고 기능만 없다.

Pagination 컴포넌트가 세 형태를 모두 이해하도록 넓혔다. 마지막 페이지를 알면
페이지 산술이 우선이고, 모를 때만 다음 페이지 존재 여부로 판정한다. 처음·마지막
버튼을 따로 켜고 끌 수 있게 나눠, 마지막 페이지를 모르는 목록에서는 그 버튼만
감춰지고 나머지 이동은 그대로 열려 있다. DataGrid 는 페이저를 그리는 두 경로
모두에 그 값을 전달한다 — 한쪽만 전달하면 그 화면만 첫 페이지에 갇힌다.

마이페이지 주문·마일리지·위시리스트·알림함, 게시판 목록, 상품 리뷰·문의, 내가 쓴
글, 관리자 설정 탭과 스케줄 이력의 소비 지점을 전건 옮겼다.

검색 화면은 총 건수를 정확히 세지 못한 경우 "N건 이상" 으로 표기하고 검색어를
좁히면 정확한 건수를 볼 수 있다고 안내한다. 탭 배지도 세지 못한 값을 정확한
숫자처럼 보여주지 않는다. 최신순은 첫 페이지부터 커서로 이동하며, 주소로 특정
페이지를 지목한 딥링크는 종전 동작을 유지한다.

쇼핑 첫 화면과 분류 화면을 단일 요청으로 재배선하고, 섹션 partial 이 부모가
선언한 이름을 읽도록 통일했다. 데이터소스 이름에 직결돼 있으면 다른 화면이
재사용할 때 폴백에 걸려 예외도 로그도 없이 섹션이 사라진다. 상품 상세의 "최근 본
상품" 이 계속 비어 있던 것은 존재하지 않는 라우트 파라미터를 읽고 있어서였다.

게시판 목록 세 스킨의 블라인드 아이콘 대비와 이미지 위 공통 색의 다크 모드 표기를
정리했다.
2026-08-06 11:13:53 +09:00
HeuJung 74a0a54016 fix(gdpr): 상한 목록에서 페이저가 접히던 자리 정합
동의 이력과 정책 버전 발행 이력의 페이저가 마지막 페이지 번호 하나에 의존하고
있었다. 총 건수를 끝까지 세지 못한 목록에서는 그 값이 없어, 페이저 노출 조건이
거짓이 되어 통째로 사라지거나 "다음" 이 첫 페이지부터 비활성이었다.

마지막 페이지를 아는 경우의 동작은 종전과 같고, 모르는 경우에만 다음 페이지
존재 여부로 판정한다. 표시용 마지막 페이지 번호는 지어내지 않는다.
2026-08-06 11:13:52 +09:00
HeuJung e619b21b2b perf(page): 검색 전량 적재 제거 및 상한 계약 소비
검색이 조건에 맞는 페이지 전체를 읽어 온 뒤 PHP 에서 화면 분량만 잘라 내고
있었다. 실제 page/per_page 를 저장소까지 하달해 필요한 분량만 조회한다.
구체 엔진 직접 호출도 해석기 경유로 옮겨 플러그인 검색엔진이 페이지 검색에
적용된다.

관리자 목록은 화면에 본문이 나오지 않는데도 항목마다 본문을 함께 읽고 있었다.
목록 조회에서 제외하고 총 건수에 상한 계약을 적용했다.

발행 페이지 목록과 통합검색의 페이지 탭 정렬에 필요한 색인을 더했다. 신규 설치는
마이그레이션이, 기존 사이트는 업그레이드 스텝이 적용한다.

관리자 목록의 페이저가 총 건수를 끝까지 세지 못한 경우 "1페이지뿐" 으로 접혀
뒤쪽 페이지에 도달할 수 없던 것을 고쳤다.
2026-08-06 11:13:52 +09:00
HeuJung 4c62dbe649 perf(ecommerce): 진열·집계·검색의 반복 실행 제거 및 상한 계약 소비
집계 — 판매량 정렬과 인기 상품이 ORDER BY 안의 상관 서브쿼리라 LIMIT 이 평가
범위를 막지 못했다. 전 상품을 평가한 뒤 정렬하던 것을 파생 테이블 조인으로 바꿨다.
리뷰 개수·평점도 상품마다 리뷰 표를 두 번씩 훑던 것을 한 번의 그룹 집계로 합쳤다.
대표 이미지는 이미 적재된 관계를 쓰지 않고 매번 관계 빌더로 조회해 상품당 2회씩
쿼리를 더 내고 있었다.

루프 안 쿼리 — 체크아웃의 항목별 옵션 조회, 게스트 장바구니 병합의 항목별 2쿼리,
주문 옵션의 항목별 find 를 일괄 조회로 옮겼다. 상품 대량 가격·재고 수정은 행마다
저장을 반복했는데, 두 번째 인자를 넘기던 오용 때문에 실제로는 바인딩이 어긋나
실행 자체가 실패하고 있었다. 이 두 메서드를 호출하는 테스트가 0건이라 전 스위트
green 상태로 통과하고 있었다.

카테고리 트리는 노드마다 집계 쿼리를 재귀 발행하던 것을 평면 1쿼리 + 조립으로
바꾸고 캐시했다. 거의 모든 쇼핑 화면이 읽는 값이라 분류·상품 변경 시 무효화한다.

검색 — 조건에 맞는 ID 전량을 PHP 로 적재한 뒤 무제한 whereIn 으로 다시 조회하던
경로를 없애고 키워드 술어를 페이지 쿼리에 직접 붙였다. 구체 엔진 직접 호출도
해석기 경유로 옮겨 플러그인 검색엔진이 상품·쿠폰·브랜드 검색에 적용된다.

쇼핑 첫 화면과 분류 화면은 각각 5요청·3요청을 한 번으로 합쳤다. 분류 화면이 함께
받던 분류 트리는 그 화면의 어떤 표현식도 읽지 않는 순수 낭비였다. 없는 분류
주소는 빈 화면 대신 404 로 처리해 검색엔진이 정상 페이지로 수집하지 않게 했다.

색인 — 전시 조건으로 좁힌 뒤 등록일·판매가로 정렬하는 목록과 주문 옵션 판매량
집계에 필요한 색인을 더했다. 신규 설치는 마이그레이션이, 기존 사이트는 업그레이드
스텝이 적용한다.

관리자 화면 — 총 건수가 잘린 목록에서 페이저가 접히던 자리와, 쿠폰 사용조건의
상품 검색이 추가 로드되지 않던 조건을 고쳤다. 같은 파일의 사전 결함(반복 렌더 안의
정적 id, Icon 박스 크기, 다크 모드 대비, 반복 변수 선언 오탈자)도 함께 처리했다.
2026-08-06 11:13:51 +09:00
HeuJung 41fe812f2c perf(board): 목록·검색·상세의 중복 실행 제거 및 상한 계약 소비
검색 — 이 코어에 추가한 BOOLEAN MODE 정제기가 게시판에는 도달하지 않아
원문 키워드가 그대로 바인딩되고 있었다. + - * " < > 를 넣으면 오류 화면이 떴다.
코어 정제기 경유로 옮겼고, 같은 술어를 count 와 get 으로 두 번 실행하던 것을
페이지네이터 한 번으로 합쳤다. 구체 엔진 직접 호출도 해석기 경유로 바꿔 플러그인
검색엔진이 게시글·신고 검색에 적용된다.

상세 — 같은 글을 3회, 같은 게시판을 5회 읽고 있었다. 조회한 인스턴스를 넘겨
글 1회·게시판 1회로 줄였고, 조회수 증가를 권한 확인 뒤로 옮겨 열람 권한이 없는
요청이 조회수를 올리지 않게 했다.

목록 — 답변 트리의 무제한 조회에 상한과 순환 가드를 두고, O(n²) 병합을 한 패스로
바꿨다. 댓글도 무제한 조회에 상한을 두되 잘렸다는 사실과 전체 건수를 함께 알린다.
조용히 잘리면 "댓글이 그만큼뿐" 으로 보인다. 원댓글 단위 페이지네이션을 더해
답글이 부모와 떨어지지 않게 했다.

관리자 화면 — 총 건수가 잘린 목록에서 페이저가 "1페이지뿐" 으로 접히던 자리를
고쳤다. 저장소가 상한을 붙인 목록인데 화면이 그 값을 1 로 채우고 있어, 기록이
상한을 넘으면 2페이지에 도달할 방법이 없었다.

같은 파일에서 드러난 사전 결함도 함께 처리했다 — 반복 렌더 안의 정적 HTML id,
Icon 의 박스 크기 지정, 단색 버튼의 다크 모드 대비.
2026-08-06 11:13:51 +09:00
HeuJung 2ae1972a3b perf(core): 대용량 목록 상한 총 건수·커서 계약 신설 및 요청당 반복 실행 비용 정리
공개 이슈 gnuboard/g7 이 지목한 병목은 "같은 일을 반복 실행한다" 축이다.
응답·행의 무게를 다룬 · 과 겹치지 않는다.

공통 계약 — 총 건수 상한과 페이지 이동 범위는 별개 결정이다. 묶으면 필요 없이
기능이 깎인다. 총 건수만 상한을 받고(파생 테이블 COUNT), "다음" 이동은
per_page + 1 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지
번호 하나뿐이며 그 사실은 last_page: null 이 알린다. 최신순처럼 실제 컬럼으로
정렬하는 목록은 커서로 전환해 깊이와 무관하게 일정 속도로 이동한다. 관련도순은
계산값 정렬이라 커서 키로 쓸 수 없어 offset 을 유지한다.

계약의 입구는 표준 paginate 와 같은 폭이어야 한다. 관계·쿼리 빌더를 받지 못하면
그 좁은 만큼이 그대로 운영 500 이 되고, 실제로 관리자 알림 목록에서 그렇게 터졌다.
응답 조립도 컬렉션마다 손으로 하면 형태가 늘어나는 순간 없는 값을 부르거나 새
필드를 흘리므로, 형태 판정을 paginationMeta 한 곳에 모았다. 표준 paginate
응답은 필드 단위로 이전과 동일하다.

요청당 반복 비용 — 훅 구독마다 남기던 로그 400줄, 요청당 스무 번 넘던 설정 파일
재읽기, 이미 캐시된 목록의 DB 재조회를 없앴다. 권한 판정은 요청 스코프 메모를 두어
화면 요소마다 나가던 조회를 한 번으로 줄였고, 크로스 요청 캐시는 두지 않아 권한
변경이 종전처럼 다음 요청에 반영된다.

검색 질의는 활성 엔진이 만든다. 저장소가 구체 엔진을 지목하면 플러그인이 등록한
엔진은 호출될 기회 자체를 잃고 오류 없이 다른 방식으로 동작한다. 해석기를 두어
활성 엔진에 위임하고, 전문검색이 없는 DBMS 의 부분일치 폴백도 드라이버명 하드코딩
대신 선언형 config 로 옮겼다.

총 건수가 잘린 목록에서 순번을 역산하면 0 과 음수가 나온다. 지어내지 않고 null 을
돌려주며, 그 원칙을 last_page 와 동일하게 적용했다.
2026-08-06 11:13:51 +09:00
eunhyeandGitHub 97ab99b7cd Merge pull request from gnuboard/chym1217/issue529
feat(message_bizppurio): 인증 토큰 캐시 정합성 및 연결 확인 기능 추가
2026-08-06 10:55:47 +09:00
chym1217 f9695df74a feat(message_bizppurio): 인증 토큰 캐시 정합성 및 연결 확인 기능 추가
설정 저장 시 토큰 캐시를 무효화하여 비밀번호 변경 후 최대 23시간 옛
자격증명이 재사용되던 문제를 해소하고, 저장된 계정/비밀번호를 그
자리에서 검증하는 "연결 확인" 버튼을 추가한다. 비밀번호 필드 라벨을
G7 로그인 비밀번호와 구분되도록 정정하고, 토큰 발급 실패 시 비즈뿌리오
원문 사유를 노출해 원인 추적을 돕는다.
2026-08-06 10:51:14 +09:00
정정홍andGitHub 1de67731b7 Merge pull request from gnuboard:HeuJung/issue518
HeuJung/issue518
2026-08-06 09:02:32 +09:00
HeuJung 4f6fced2db fix(admin_basic): 레이아웃 코드 편집 화면의 패널 폭·목록 갱신·버전 이력 도달 범위
리베이스 결과를 브라우저로 검수하다 같은 화면에서 셋을 확인했다. 셋 다 응답에도
콘솔에도 신호가 없고 화면에서만 드러나는 종류다.

- 두 카드 어디에도 폭 규약이 없어 flex 가 max-content 비율로 폭을 나눴다. 모듈
 파티션 레이아웃의 공백 없는 긴 파일명이 목록의 max-content 를 밀어 올려 편집기가
 절반 이하로 눌렸다(실측 목록 814 / 편집기 473 → 384 / 903). 이름은 자르지 않고
 접는다 — 잘라내면 어느 파티션인지 구분할 수 없다.
- 저장 뒤 상세만 다시 불러서, 방금 저장한 파일의 목록 행이 저장 전 설명·크기·수정일을
 그대로 달고 있었다. 목록 응답이 본문을 싣지 않게 된 뒤로 재조회가 수십 KB 라
 매 저장마다 부른다.
- 서버가 버전 목록에 기본 상한 100 을 도입했는데 이 화면에는 limit 도 '더 보기' 도
 없어 상한 밖 버전이 도달 불가였다(실측 114건 중 14건). 페이징이 위지윅 편집기에만
 들어가 상한 도입이 이 화면에서는 그대로 기능 축소가 됐다.

회귀는 레이아웃 구조(5건)와 브라우저(6건) 양쪽에서 잠근다. 브라우저 쪽은 수정을
되돌린 상태로 fail 을 먼저 확인했다.
2026-08-06 09:02:00 +09:00
HeuJung b5c0ec1178 fix(core,ecommerce,board,page,admin_basic,basic): 목록 응답의 하위 컬렉션 전량 직렬화 해소
관리자 상품목록을 한 페이지 여는 것만으로 그 페이지 모든 상품의 옵션이 응답에 실렸다.
같은 패턴을 저장소 전역에서 찾아 14개 목록 엔드포인트를 함께 정리했다.

근본 원인은 둘이다. Resource 가 whenLoaded 로 방어하는데 Repository 가 목록 쿼리에서
관계를 무조건 로드해 가드가 항상 참이 되는 가짜 가드, 그리고 toListArray 경량 표현을
정의해 두고도 컬렉션이 toArray 를 부르는 목록/상세 미분리다. 둘 다 응답만 보면
정상이라 오류도 경고도 없이 페이로드만 불어난다.

목록은 화면이 실제로 그리는 것만 싣는다. 개수·합계는 PHP 컬렉션 연산이 아니라 DB
집계로, 대표 1건이 필요한 곳은 관계 자체를 oldestOfMany 로 좁힌다. eager load 의
limit(1) 은 부모별이 아니라 배치 쿼리 전체에 걸려 첫 행만 값을 갖게 되므로 쓸 수 없다.

뺀 값에는 대체 경로를 먼저 만들었다. 상품 옵션은 행을 펼칠 때 배치로 불러오고(상품 수와
무관하게 쿼리 상수), 종전 동작이 필요한 호출자를 위해 ?with_options=1 등 opt-in 을 남겼다.
배송정책 국가설정과 리뷰 첨부 이미지는 소비처를 실측한 결과 화면이 실제로 그리고 있어
제거하지 않았다 — 그 소비 사실을 회귀 테스트로 고정했다.

재발 방지로 정적 검사 룰 4종과 규정 문서 항목을 함께 넣었다.
2026-08-05 21:20:05 +09:00
정정홍andGitHub f5b411d9a0 Merge pull request from gnuboard:HeuJung/issue527
fix(security): 예약 작업·언어팩·인스톨러의 차단목록을 허용목록으로 전환
2026-08-04 17:50:23 +09:00
HeuJung 4b6adf4788 fix(security): 예약 작업·언어팩·인스톨러의 차단목록을 허용목록으로 전환
KISA 신고포상제 접수 3건(KVE-2026-1677/1678/1679)의 공통 원인은 하나다 —
"위험한 것을 골라 막았고, 목록 밖으로 우회됐다". 세 지점 모두 판정을 뒤집는다.

예약 작업 Artisan (KVE-2026-1679)
차단 9개 / 허용 229개였던 구조를 허용목록으로 바꾸고 명령마다 허용 옵션을 선언한다.
검증 과정에서 보고서보다 짧은 경로를 추가로 발견했다 — 검증기는 preg_split 의 첫
토큰을 명령명으로 봤지만 실행부는 문자열째 넘겼고, parameters 가 비면 Laravel 이
StringInput 으로 재파싱한다. 따옴표 한 쌍이면 차단목록의 tinker 가 그대로 실행됐다
(마커 파일로 확인). 파서를 엄격 형태 하나로 고정하고 실행은 (명령명, 인자배열)로 넘겨
재해석 지점 자체를 없앴다.

언어팩 (KVE-2026-1678)
위험 함수 11개를 정규식으로 나열하던 검사를 토큰 상태기계로 교체해 "번역 배열만"
통과시킨다. 목록에 없는 함수도, 함수 이름을 아예 쓰지 않는 형태도 원리적으로 막힌다.
파일 형식·PHP 위치·심볼릭 링크도 화이트리스트로 뒤집었다. 활성 승격이 require 배선의
필수 조건이므로 설치와 활성화의 권한 경계를 네 설치 경로 전부에 같은 강도로 건다 —
경로별 면제는 곧 우회로가 되기 때문이다. 화면은 운영자의 활성화 권한을 보고
auto_activate 를 실을지 결정하므로 재설치 흐름은 그대로 동작한다.

인스톨러 (KVE-2026-1677)
실행 바이너리 자리에 인자가 들어가는 것을 형태 규칙으로 차단한다. escapeshellarg 는
"하나의 인자" 임만 보장할 뿐 그 인자가 실행 파일인지 스크립트인지는 보장하지 않는다.
판정을 의존성 0 공용 정책으로 모아 진단 API·설치 워커·저장 시점이 같은 규칙을 쓴다.
stat 은 호출하지 않는다 — 접근이 제한된 서버에서 정상 경로가 거부되던 회귀를
재발시키지 않기 위함이다.

검증: 백엔드 392건 + 프론트 7건 green(skip 1, symlink 미지원 환경). PoC 마커로 red 를
먼저 확보하고, 수정 후 stash 역회귀로 다시 red 가 되는 것까지 확인했다.
2026-08-04 17:48:05 +09:00
정정홍andGitHub 30867e0e0b Merge pull request from gnuboard:HeuJung/issue503
HeuJung/issue503
2026-08-04 10:42:20 +09:00
HeuJung 1ce434e2da fix(ecommerce): 비회원 주문 상세의 상품별 배송정책 표시 누락 해소
비회원 주문 조회 상세는 회원 상세와 같은 화면 구성(partial)을 쓰는데
비회원 응답에만 배송정책 스냅샷이 없어 그 줄만 조용히 비어 있었다.
오류도 빈 값 표기도 없이 항목 자체가 사라지므로 응답을 열어 보기 전에는
드러나지 않는다.

스냅샷 전체를 그대로 내보내면 원가·계산 근거까지 비회원에게 노출되므로
ShippingPolicySnapshot::forDisplay 로 화면이 쓰는 정책명·개별 배송비만
추려 내보낸다. 내부 계산 정보는 종전대로 제공하지 않는다.
2026-08-04 10:34:00 +09:00
HeuJung e426498fb3 fix(core): 레이아웃 코드 편집 화면 설명의 미해석 표기 노출 해소
파일 목록과 선택한 파일의 설명에 `$t:…` / `{{ … }}` 가 그대로 보였다.
설명 원문은 소유 템플릿의 다국어 키인데 응답 조립 시점에는 요청 템플릿의
사전만 열려 있어 다른 템플릿의 레이아웃을 편집하면 키를 찾지 못했다.
번역 실패가 예외 없이 원문 노출로 이어져 화면에만 나타났다.

목록·상세가 같은 규칙을 쓰도록 표시 해석을 App\Support\LayoutDescription 으로
모으고, 컨트롤러가 소유 템플릿 사전을 주입한다. 표현식이 섞인 설명과 번역을
찾지 못한 경우는 파일 이름으로 대체한다.

목록만 고치면 상세 헤더에 그대로 남는다 — 브라우저 실측에서 목록 수정 후에도
헤더가 새던 것을 확인해 상세 경로(LayoutResource)까지 함께 처리했다.
2026-08-04 10:21:00 +09:00
HeuJung 65378fdeb6 fix(engine): 반복 렌더 경로의 리터럴 단일 바인딩 판정 누락 해소
판정 통일(engine-v1.55.0)에서 리터럴 해석을 BindingShape 한 곳으로 모았으나
renderItemChildren 만 `hasPipes → isComplexExpression → 경로 탐색` 3분기를 직접
갈라 리터럴을 몰랐다. 그 결과 같은 `{{true}}` 가 조건 자리에서는 true,
목록 셀·카드 등 반복 렌더 prop 자리에서는 undefined 로 갈렸다 —
통일이 없애려던 비대칭이 이 지점에만 남아 있었다.

형태 판정을 resolveSingleBindingValue 에 위임해 다른 경로와 같은 규칙을 쓴다.
같은 지점이 빈 바인딩(`{{}}`)도 몰라 경로 탐색으로 보내던 것을 함께 봉인했다.

브라우저 실측(배포 번들의 G7Core.renderItemChildren 직접 호출)으로 발견했고,
저장소 레이아웃의 리터럴 단일 바인딩 60건은 전부 if/condition 자리라
화면 표시에는 변화가 없다.

SEO 패리티: 봇 측 ExpressionEvaluator 는 이미 리터럴을 해석하고 있었으므로
이 수정은 React 경로를 봇 경로에 맞춘 것이다 (파일 헤더에 면제 사유 기재).
2026-08-04 10:08:00 +09:00
HeuJung 60cccbf62d feat(ecommerce): 마일리지 적립 절사 기준 신설 및 주문 시점 스냅샷
적립 포인트 절사가 코드에 1점 버림으로 고정돼 있어 운영자가 조정할 수 없었다.
언어/통화 설정의 환율 절사를 따라가는 방안을 먼저 검토했으나, 그쪽은 외화 환산
분기에서만 적용되어 기본 통화 상점에는 따라갈 값 자체가 없다. 적립은 기본 통화
원장에 정수로 확정 기록되는 값이라 성격이 다르므로 마일리지 통화별 규칙에 신설했다.

- earn_rounding_unit(1/10/100) + earn_rounding_method(floor/round/ceil)
- 기본값은 종전 하드코딩과 정확히 같은 금액을 산출 — 기존 쇼핑몰 적립액 무변화
- 주문 계산·옵션 분할·부분취소 안분 전 경로에 동일 기준 적용
- 주문 시점 값을 스냅샷으로 보관 — 이후 기준을 바꿔도 과거 주문이 소급 변경되지 않는다
- 설정 UI·검증·다국어(ko/en/ja)·API 문서 동반, 기존 사이트는 업그레이드 스텝이 명시 기입

함께 처리: 마일리지 내역 목록 테스트가 첫 컬럼 field 를 `no` 로 단언해 실패하던 것을
현재 레이아웃 값(`number`)에 맞춰 정정했다. 이 브랜치와 무관한 stale 케이스다.
2026-08-04 09:56:00 +09:00
HeuJung 0ae3289c60 fix(ecommerce,basic): 배송정책 스냅샷 형태 정규화로 주문 상세 배송정보 누락 해소
마이페이지·비회원 주문 상세에서 상품별 배송정책명과 개별 배송비 줄이 아무 안내
없이 통째로 사라졌다. 주문에 보관하는 배송정책 스냅샷이 항목 목록과 배송지 메타를
한 배열에 섞어 담고 있어, PHP 배열이 non-sequential 이 되면서 json_encode 가 리스트가
아니라 객체로 직렬화했기 때문이다.

서버 소비자는 정수 키 판별로 관용 처리해 무사했고 배열을 전제한 화면만 죽었다.
한 컬럼에 두 의미를 담은 구조 자체를 items + address 로 분리했다.

이미 저장된 주문은 화면만 고쳐서는 낫지 않으므로 업그레이드 스텝으로 함께 정규화한다.
2026-08-04 09:39:00 +09:00
HeuJung 19789433df perf(core): 레이아웃 목록 응답에서 편집 대상 아닌 본문 제외
레이아웃 코드 편집 화면이 목록을 부를 때 모든 레이아웃의 본문(content)까지 함께
내려받고 있었다. 레이아웃이 많은 템플릿에서는 목록을 여는 것만으로 수십 MB 가
브라우저에 쌓여 화면이 크게 느려졌고, 디버그 모드에서는 탭이 메모리 부족으로
종료됐다. 목록 화면은 본문을 쓰지 않고 선택한 파일 하나만 편집한다.

목록 전용 Resource 를 두어 이름·설명·크기·수정일만 싣고, 본문은 상세 조회에서만
읽는다. 크기는 목록에서 표시하므로 저장소가 길이만 계산해 돌려준다.
2026-08-04 09:22:00 +09:00
HeuJung 953616552f fix(core,engine,ecommerce,admin_basic): 렌더 경로 판정·파서 단일화로 표현식 비대칭 일괄 해소
표현식 해석 로직이 엔진 전역에 복제되면서 복사본마다 기능 집합과 판정 기준이
갈라졌고, 같은 작성이 어느 렌더 경로를 타느냐에 따라 다른 결과를 냈다. 증상
대부분이 예외가 아니라 조용한 오답 또는 값 소실이라 재현 위치를 특정하기 어려웠다.

판정만 BindingShape 단일 출처로 모으고 실행은 지점별로 유지했다. 전면 통합은
회귀 위험이 크고, 갈라진 것은 실행이 아니라 판정이었기 때문이다.

- 파이프 평가를 문자열 보간에 위임하면서 생긴 비대칭 3건 해소 (중괄호 노출 /
 _computed stale 전파 / 원본 타입 소실)
- 파이프 분기 누락 8지점 보강 — 평가 지점 모집단을 스캔해 가드를 강제하는 회귀선 신설
- 단일 바인딩·복잡식 판정 14곳을 정본으로 치환, 빈 바인딩 봉인
- BINDING_PATTERN 정규식을 수동 스캐너로 교체, resolveObject 를 key 단위 예외 격리로 전환
- iteration + 항목 참조 if 가 목록 전체를 지우던 결함 해소, 반복 경로 기능 격차 정렬
- 템플릿 컴포넌트의 new Function 자체 평가기 제거 → G7Core.evaluateCondition 위임
- 컨텍스트에 식별자가 아닌 키가 하나라도 있으면 그 컨텍스트의 모든 식이 평가되지
 못하던 결함 수정
- SEO 봇 렌더러도 같은 규칙으로 정렬 — React 만 고치면 봇 화면에서만 목록이 빈다
- audit walker 가 레이아웃의 74%(partial 루트 444/598)로 내려가지 못하던 사각 해소,
 신규 룰 5종 등재
2026-08-04 09:05:00 +09:00
정정홍andGitHub ca4fd02e99 Merge pull request from gnuboard:HeuJung/issue506
HeuJung/issue506
2026-08-02 19:17:04 +09:00
HeuJung 6bde24bc75 fix(ecommerce): 숫자 설정 문자열 영속으로 인한 주문 생성 실패 해소
관리자 환경설정에서 미입금 자동취소 기한을 저장하면 HTML number 입력의
문자열이 그대로 영속되고, Carbon 3.13 의 strict 타입 경계에서 TypeError 가
발생해 무통장입금·가상계좌 주문이 전면 실패했다(공개 제보). 한 곳만 막으면
다른 저장 경로가 남으므로 저장·조회·소비·모델 네 계층에 각각 방어를 두고,
저장소 전역에서 같은 형태가 더 없음을 audit 룰로 확정했다.

같은 계획서 수행 중 드러난 인접 결함도 함께 해소했다.
- 쿠폰 수정 경로로 (발급일 기준, 일수 NULL) 조합이 저장되어 발급 즉시
 만료된 쿠폰이 조용히 나가던 문제 — 저장·발급 양쪽에서 차단
- 리뷰 작성 기한을 화면과 저장 판정이 서로 다른 기준으로 계산하던 문제
- 결제 플러그인이 PaymentMethodEnum 과 다른 어휘로 지원 결제수단을 선언해
 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제

엔진에서는 `_localInit` 적용 여부를 전역 해시로만 판정해 인스턴스별 저장소가
리셋되지 않던 결함(engine-v1.54.5)과, 제거 대상이 없어도 상태 갱신·캐시
무효화가 상시 발생하던 판정 결함(engine-v1.54.6)을 고쳤다.

Playwright MCP 정밀 점검(T1~T11)에서 설정이 빈 문자열로 남은 사이트의
주문서 입금기한 안내가 미치환 플레이스홀더를 노출하는 것을 실측해, 화면
폴백을 서버와 같은 규칙으로 맞췄다.
2026-08-02 18:35:28 +09:00
정정홍andGitHub ef2a77e0a0 Merge pull request from gnuboard:HeuJung/issue492
HeuJung/issue492
2026-08-02 16:48:52 +09:00
HeuJung 9025f6d1aa fix(core,ecommerce,gdpr,message_bizppurio,hello_module,basic,admin_basic): 리베이스 후속 정합 정리
develop 리베이스 3커밋이 각자 정상인데 합쳐지면서 어긋난 지점들을 정리한다.

- 발송 이력 목록이 뒤 페이지로 갈수록 느려지고 같은 시각 발송분이 중복
 노출되던 두 결함을 코어 지연 조인으로 함께 닫았다. 이 파일은 develop 쪽에서
 들어와 492 브랜치에서는 검출될 수 없었다.
- 정렬 허용 컬럼·지연 조인 호출 계약 테스트가 순수 소스 스캔인데도 앱을 부팅해
 케이스마다 DB 연결을 잡았다. 스캔 대상이 늘자 연결 상한(151)을 넘겨 실패했고,
 앱 의존이 없으므로 부팅을 뺐다.
- 확장 업데이트 모달 테스트는 stub 이 모든 체크박스에 같은 식별자를 붙여,
 검색 인덱스 재생성 체크박스가 추가되자 대상을 특정하지 못했다.
- 동의 이력 필터 어휘가 enum 전 케이스로 넓어진 것을 테스트 기대값에 반영하고,
 이력 페이지네이션 버튼의 액션 구조를 나머지와 같은 형태로 통일했다.
- 관리자 확장 화면의 존재 확인 가드가 추가된 뒤 갱신되지 않은 mock 을 맞췄다.

다크 모드 대비가 빠진 버튼 둘을 저장소 관례에 맞추고, 사용자 템플릿에서
그 클래스가 실제 CSS 로 생성되도록 safelist 를 함께 채웠다.
2026-08-02 16:42:52 +09:00
HeuJung b10e89bef0 fix(core,board,ecommerce,page,gdpr,ckeditor5,kginicis,admin_basic,basic): 목록 조회·검증 계층 정비 및 감사 지적 전건 처리
목록 조회 성능 축(깊은 OFFSET·정렬·색인)과 그 검증 계층에서 계획서 전수검수와 3회에 걸친
감사가 지적한 항목을 처리했다. 세부 경위는 의 각 회차 문서에 있다.

검증 계층 — 컨트롤러가 base Request 를 직접 주입받던 확장 13곳을 전용 FormRequest 로 옮겼다.
옮기면서 기존 동작 계약은 그대로 두었다: 상한 초과 limit 을 거부하지 않고 상한까지 반환하던
공개 API, per_page 를 범위로 조정하던 관리자 목록, 미지원 period 를 year 로 해석하던 인기글
목록 모두 종전과 같은 응답을 낸다. 상한/폴백을 rules 로 승격시키면 200 이던 응답이 422 가
되어 기존 링크가 깨지므로, 규칙은 타입만 닫고 클램프·폴백은 접근자가 맡는다. period 는
접근자가 닫힌 집합만 반환해 캐시 키 공간도 함께 닫힌다.

ckeditor5 이미지 업로드만 ResponseHelper 봉투를 쓰지 않는다. 응답을 파싱하는 주체가 CDN 으로
로드되는 상위 CKEditor5 43.3.1 의 SimpleUploadAdapter 라 규약을 바꿀 수 없어, 각 응답 지점에
사유를 명시한 면제를 부착하고 근거를 API 문서에 남겼다.

하네스 — 룰 5개의 대상 경로 패턴이 매처와 맞지 않아 번들 확장 컨트롤러가 검사 대상에서
통째로 빠져 있었다. 패턴을 고치자 확장 위반 17건이 드러나 전건 처리했다. severity 오타가
요약 집계 양쪽에 안 잡혀 "0 error" 로 보고되던 문제도 런너 사전 검증으로 막았다.

정렬 게이트 대조 하네스는 관계 정렬 변형만 쓰는 저장소를 탐지하지 못한 채 통과시키고 있었다.
탐지·제외·인자 파싱을 함께 고치고 단위 테스트를 신설했다.
2026-08-02 15:29:29 +09:00
HeuJung 5f4a733b3b feat(core,board,ecommerce,page): 성능 계측 인프라 4축 확장 및 계측 대상 선언 지점 신설
계측 축이 "목록 SELECT" 하나였고 프로파일 12종이 커맨드 파일에 하드코딩돼
있어 확장이 자기 대상을 추가할 수 없었다. 확장이 설치·제거되는 설치본마다
"실제로 존재하는 대상"이 달라지므로, 대상을 소유자가 선언하고 코어가 수집하도록
뒤집었다.

- 선언 지점 두 곳, 스키마 하나: 코어 config/benchmark.php,
 확장 getBenchmarkProfiles (모듈/플러그인). 수집은 method_exists 기반이라
 인터페이스를 직접 구현한 서드파티 확장이 깨지지 않는다.
- 축 4종(목록/화면/쓰기/배치)을 실행기로 분리하고 컨테이너 태그로 주입 —
 축이 늘 때 커맨드를 고치지 않는다.
- 화면 축은 HTTP 커널로 내부 요청을 처리한다. 라우트만 dispatch 하면 전역
 미들웨어를 건너뛰어 화면과 다른 것을 재게 된다. 인증은 프로파일이 선언한
 권한만 가진 임시 계정이 기본이며 --as 로 기존 계정을 지목할 수 있다.
- 화면/쓰기 축은 계측 계정 생성부터 처리까지 롤백되는 트랜잭션 안에서 실행해
 운영 DB 에도 잔여 데이터를 남기지 않는다. 배치 축은 내부 커밋·락 보유 때문에
 감싸지 않고 --allow-write 로만 통제한다.
- 잘못된 선언은 조용히 버리지 않고 사유를 경고로 남긴다. 버려진 선언이 곧
 계측 사각이 되기 때문이다. 전부 건너뛴 실행은 종료 코드 1 로 끝난다.
- 리포트는 환경 정보를 함께 적어 저장소 밖(storage/app/benchmarks)에 남긴다.
 측정값이 실행 머신에 종속되므로 저장소에 축적하면 비교 불가능한 수치가 섞인다.

작업 중 발견해 함께 고친 것:

- 이관 프로파일 3종의 soft_delete 선언이 실제 스키마와 어긋나 있었다
 (users/product_inquiries/pages 에 deleted_at 없음).
- 주문 목록이 상태 미지정 시 임시 주문 상태를 NOT IN 으로 제외하는데 선언에
 빠져 있었다. filters 가 등가 비교만 지원한 것이 근본 원인이라 연산자 형태를
 받도록 넓히고, 미허용 연산자는 측정 전에 거부한다.
- 댓글 프로파일은 지배적 술어가 상관 서브쿼리라 선언형으로 재현할 수 없어
 제거했다. 맨 테이블 스캔은 어느 화면도 내지 않는 수치가 된다.

g7:bench:pagination → g7:bench 로 개명(4축을 재므로). 이전 이름은 별칭으로 남겼다.

테스트 61건 통과, 라이브 18종 전건 측정.
2026-08-02 14:44:33 +09:00
HeuJung be9befe023 fix(core,board,ecommerce,page,gdpr,basic): 깊은 OFFSET 목록 조회 개선 및 raw/테이블명 전수 정리
목록이 뒤쪽 페이지로 갈수록 느려지는 원인은 건너뛰는 행의 넓은 컬럼까지 읽는 것이었다.
지연 조인(inner 는 키 컬럼만, outer 는 이번 페이지 행만 전체 컬럼)과 정렬 컬럼 해석을
코어 공통 트레이트로 두고 코어 8 + 확장 6 저장소에 적용했다.

감사 지적 처리:
- 그룹 쿼리의 총 건수 오산 — count 는 groupBy 를 벗기지 않아 첫 그룹의 행 수를
 총 건수로 돌려준다. getCountForPagination 으로 교정.
- audit 전수 스캔이 버퍼 한계로 한 번도 동작하지 않던 결함과, 확장 경로를 놓치던
 정규식 5건을 함께 고쳤다. 그동안 번들 확장의 위반이 보이지 않았다.

raw 20건을 빌더 표현으로 치환하고 하드코딩 테이블명 16건을 모델 유래로 바꿨다.
이 과정에서 마이페이지 「내가 댓글 단 글」 검색이 조인 별칭 충돌로 SQL 오류를 내던
결함이 해소됐다. 재발 차단용 룰 2종을 신설하고, 컬럼 프루닝 룰을 error 로 승격했다
(전역 위반 0건 — warning 으로 두면 "0 error" 판정을 통과하며 무음으로 재누적된다).

정렬 허용 목록은 FormRequest 게이트를 덮어야 한다. 좁으면 검증을 통과한 정렬이 422 도
로그도 없이 기본값으로 되돌아간다(스케줄 실행 이력의 종료시각·소요시간 정렬이 그랬다).
호출처를 스캔해 매핑 완전성까지 검사하는 계약 테스트로 전수 강제한다.

해외 배송지 결함도 함께 고쳤다 — 조회는 city/state/postal_code 로 내려주고 폼은
intl_* 로 제출하는 비대칭 때문에, 수정 시 입력칸이 비어 보이고 저장하면 기존 값이
유실됐다. 쿠폰 수정의 유효기간 미검증과 1인당 사용 제한 부분 수정 차단도 함께 수정.
2026-08-02 14:36:37 +09:00
정정홍andGitHub 95ffdf9eed Merge pull request from gnuboard:HeuJung/issue495
fix(core,engine): OFFSET 순회 중 필터 컬럼 변경으로 인한 처리 누락 수정
2026-07-31 22:19:37 +09:00
HeuJung 1eedbbdda1 fix(core,engine): OFFSET 순회 중 필터 컬럼 변경으로 인한 처리 누락 수정
Laravel chunk/each 는 OFFSET 기반이라, 콜백이 행의 필터 소속을 바꾸면
(필터 컬럼을 채우거나 매칭 행을 삭제하면) 다음 조회가 그만큼 앞으로 밀려
미처리 행을 조용히 건너뛴다. 250건/청크 100 이면 정확히 100건이 남는다.
코어·번들 확장 전수 조사로 확정한 6곳을 chunkById(키셋 순회)로 통일했다.

브라우저 검수(28축)에서 계획에 없던 결함 12건이 드러나 같은 사슬에서
수정했다. 이 중 layout_strategy=keep 무효화와 수정 감지 실패 시 "수정 없음"
오보는 사용자 작업 소실에 직결된다.

재발 방지로 audit 룰을 추가했고, 수정 6곳을 원복해 red 6건 / 복원 후 green
0건으로 판별력을 검증했다.
2026-07-31 21:50:45 +09:00
정정홍andGitHub 1866f0d566 Merge pull request from gnuboard:HeuJung/issue276
HeuJung/issue276
2026-07-31 08:12:50 +09:00
HeuJung ea9a14725b docs: 리베이스 후 확장 API 레퍼런스 인덱스 재생성
develop 이 추가한 확장 1건이 ·AGENTS.md 의 자동 생성 표에서 빠져 있었다.
리베이스 시 두 파일이 본문 기준으로 병합되면서 생성 섹션만 낡은 상태로 남은 것이라,
generate-docs-index.cjs 로 재생성해 12개 → 13개로 맞춘다.
2026-07-31 07:27:48 +09:00
HeuJung 0f0ef8fa8d test(e2e): 편집기 저장 spec 전용 시드 화면 분리 + 실패 10건 수정
레이아웃 편집기 spec 중 저장(PUT)까지 수행하는 것들이 제품 화면에 편집 결과를
영속시켜, 실행할 때마다 개발 사이트에 노드가 누적됐다(home 에 빈 표 7개,
관리자 대시보드에 빈 차트 5개). spec 안에 "추가한 노드 삭제 후 재저장" 원복을
넣어도 소용이 없었다 — 원복 후에도 레이아웃이 오염 시점과 정확히 같은 크기로
되돌아왔다. 원복에 기대는 대신 저장 대상 자체를 E2E 전용 시드 화면으로 분리한다.

시드 화면은 활성 템플릿 디렉토리에만 설치되므로 배포 원본과 릴리스 산출물은
변하지 않는다. globalSetup 이 매 실행 fixture 원본으로 덮어써 회차 간 누적이
성립하지 않으므로 저장 spec 에 원복 절차가 필요 없다. 설치는 레이아웃 파일과
라우트, DB 행 세 가지를 함께 다루되 시드 행만 건드린다 — 전체 재동기화를 쓰면
편집기로 저장한(파일에 없는) 변경이 실행할 때마다 사라지기 때문이다.

전수 실행에서 드러난 기존 실패도 함께 처리했다. 35건 중 27건은 절전·부하
아티팩트였고 나머지는 원인을 확정해 고쳤다. 템플릿 식별자 자리에 모듈 id 를
넣어 라우트 트리가 비어 있었고, "캔버스 텍스트가 길어진다"는 대리 지표가 렌더
여부와 무관했으며(정상 렌더에도 짧아진다), 팔레트 삽입 위치가 잠금 서브트리
안이면 선택이 조상으로 밀려 노드를 지목할 수 없었고, 상태 개수 단언이 상태
추가로 낡았고, if 게이트상 그 탭에 등록되지 않는 소스를 refetch 하고 있었고,
무통신 구간이 오지 않는 관리자 화면에 networkidle 을 기다렸고, 테스트 종료 후
도착한 route 콜백이 워커를 중단시켰고, 같은 설정 화면에 저장하는 테스트들이
병렬로 충돌했다.

측정 방법에서 반복해 틀린 지점들은 E2E 가이드에 표로 남겼다 — 가림은 가시성
판정으로 잡히지 않고, 길이 증감은 렌더 신호가 아니며, 총 개수 단언은 스펙이
늘면 깨지고, networkidle 은 실시간 연결이 있는 화면에서 오지 않는다.
2026-07-31 00:43:58 +09:00
HeuJung 6b3903553a fix(e2e,a11y): 확장 E2E 로케일 고정 + 관리자 탭·스위치 접근성 보완
확장 E2E config 의 로케일 미고정을 코어 포함 7개에서 해소했다. 미지정 시 첫 페이지
로드의 Accept-Language 가 en-US 로 나가고 엔진이 그 값을 localStorage 에 굳혀,
인증 후에도 세션 전체가 영어로 렌더돼 한국어 문구를 단언하는 spec 이 전멸한다.
같은 함정을 정적으로 막는 룰을 함께 등록했다.

관리자 공용 탭·스위치는 ARIA 와 키보드 처리가 없어 마우스 없이는 조작할 수 없었고
보조기기에 목록·선택 상태가 전달되지 않았다. WAI-ARIA 규약대로 보완하고, role 변경에
맞춰 낡은 셀렉터를 갱신했다(admin_basic E2E 회귀 1건 포함).

nhnkcp 설정 화면은 저장 후 정규화 결과가 입력칸에 반영되지 않았다. 계측 결과
refetch 와 initLocal 은 정상 동작하며 _local 도 갱신되지만 사용자가 편집한 입력칸만
값을 유지한다. 저장 응답으로 폼을 명시 재바인딩하고 그 기전을 트러블슈팅에 남겼다.

safelist 생성기가 빌드마다 main.css 에 빈 줄을 누적해 매 빌드가 소스를 변경된 것으로
만들던 문제도 함께 고쳤다.
2026-07-31 00:43:56 +09:00
HeuJung b127afcea7 fix(identity): 잔여 과제 전건 처리 + 로딩 표시 실렌더 확정
앞선 커밋이 CHANGELOG 에 주장한 "로딩 표시가 나오지 않던 문제 수정" 이 실제로
화면에 나타나는지 브라우저로 확정했다. 전달 경로부터 DOM 마운트·해제까지 측정했고,
지연·에러 응답에서도 영구 로딩이 남지 않음을 확인했다.

측정 과정에서 앞선 세션의 판단 하나가 틀렸음을 확인했다 — "오버레이 DOM 이 어느
화면에서도 관측되지 않는다" 는 관측 대상 노드를 잘못 잡은 것이었다. 스피너 방식
레이아웃은 다른 노드를 쓰며, 대조군에서도 안 보였던 이유가 이것이다.

미뤄 두었던 과제 4건을 함께 구현했다.

- 인증창을 그냥 닫고 이탈하면 그 인증 요청을 서버에도 종료로 표시한다.
 이전에는 브라우저만 알고 있어 인증 이력에 "보낸 뒤 소식 없는" 항목이 남았다
- 플러그인 설정 화면이 저장된 비밀값을 다시 내려받던 것을 가려서 표시한다.
 그 항목을 건드리지 않고 저장하면 저장된 값이 그대로 유지된다
- 언어팩 반영 누락 검사가 버전만 비교해 "버전 같고 내용 다름" 을 놓치던 사각을
 콘텐츠 비교로 닫았다
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 6개를 표준 형태로 통일했다.
 예전 이름도 함께 발행하므로 기존 확장은 수정 없이 동작한다

정적 검사 룰 2종의 오탐·사각을 함께 고쳤고, 개명으로 검사 대상에 편입된 파일의
사전 결함 8건을 해소했다. 검수용 데이터는 전량 정리했다.
2026-07-31 00:32:48 +09:00
HeuJung bd215bc586 feat(identity): NHN KCP 휴대폰 본인확인 플러그인 추가
메일 인증 대신 휴대폰 본인확인을 쓸 수 있게 하는 플러그인을 추가한다.
가입·비밀번호 재설정·성인인증 등 코어 IDV 가 강제하는 모든 지점에서 동작하며,
테스트 모드로 계약 없이 전 흐름을 확인할 수 있다.

구현·검수 과정에서 드러난 저장소 전역 결함을 함께 처리했다.

- 외래키 컬럼의 한국어 comment 가 방출되지 않던 문제 (소스 38건 교정 +
 기설치본 백필 업그레이드 스텝 7종). ->comment 를 ->constrained 뒤에
 체인하면 예외 없이 통과하면서 comment 가 조용히 사라진다
- 로케일 전환 후 확장 액션 핸들러가 소실되던 문제 (플러그인 3종에
 재등록 진입점 노출)
- 본인인증 상태 폴링 응답이 누적 시도 횟수를 함께 내보내던 문제
- 인증 안내 문구와 실제 진행 방식의 폭 경계 불일치 (768~1023px).
 엔진과 같은 값을 같은 방법으로 읽도록 교정
- transition_overlay_target 이 replace 없이 무효이던 관리자 목록 40곳.
 로딩 표시가 나오지 않아도 경고가 남지 않아 화면을 봐야만 알 수 있었다

두 인증 플러그인의 코어 최소 요구 버전을 7.0.6 으로 올린다. 운영 모드
자격증명 필수 검사가 7.0.6 의 설정 저장 검증 표면에 의존하며, 그보다 낮은
코어에서는 그 검사가 조용히 통과해 빈 자격증명으로 저장할 수 있었다.

재발 차단으로 정적 검사 룰 4종을 신설하고, 검사 도구가 미커밋 신규 확장을
"검사 파일 0" 으로 통과시키던 사각을 함께 막았다.
2026-07-31 00:32:46 +09:00
eunhyeandGitHub eca1232246 Merge pull request from gnuboard/chym1217/issue509
fix(gdpr): 이슈 검수 오류 4건 + 사후 보안 검토 6건 수정
2026-07-30 12:36:48 +09:00
chym1217 ac57df93bd fix(sirsoft-gdpr): HMAC 서명 만료시각 추가 + sequence handler 위치 정리
PR 전 마지막 재검토에서 남겨뒀던 두 항목을 처리한다.

- 게스트 세션 쿠키 HMAC 서명에 만료시각을 추가했다. 결제 플러그인
 (IssuesReceiptCookie)과 동일하게 값 형식을 {uuid}|{만료시각}|{서명}
 3단 구조로 바꾸고, 서명 자체가 만료시각도 함께 해시하므로 만료시각만
 위조해 유효기간을 늘리는 시도도 차단된다. 만료 기간은 쿠키 자체의
 브라우저 만료(1년)와 동일하게 맞췄다 — 결제 정보가 아닌 쿠키 선호도
 식별자라 짧게 잘라 잦은 재발급을 강제할 실익이 없다.
- 관리자 설정 화면의 정책 버전 이력 토글 버튼에서 sequence handler의
 actions 배열이 params 안에 잘못 중첩되어 있던 걸 발견해 바로잡았다.
 엔진(ActionDispatcher.handleSequence)이 두 위치를 모두 인식하는
 fallback을 갖고 있어 실제 동작은 정상이었지만, 정석 위치(top-level)로
 정리하고 실제 클릭 시 이력 표가 펼쳐지는지 검증하는 E2E를 추가했다.
2026-07-30 12:19:53 +09:00
chym1217 4d50fa2cde fix(sirsoft-gdpr): 관리자 설정 카드 제목 경고 색상 오용 수정
가 GDPR 관리자 설정 화면 스크린샷에서 카드 제목만 다른 확장과 달리
빨간색으로 표시되는 것을 발견해 지시했다.

「운영 주체」·「쿠키 동의 배너」·「자동 차단 정책」 카드 제목과 차단
도메인 안내 소제목이 에러/경고 전용 시맨틱(text-error-strong, red 계열)을
잘못 사용하고 있었다. 일반 카드 제목이므로 다른 정상 카드와 동일한
중립 톤(section-heading-md)으로 교체.

원래 develop 에 이미 있던 기존 결함이며 다른 확장(marketing,
pay_kginicis 등)에도 동일 패턴이 있으나, 이번엔 GDPR 플러그인 범위로
한정해 수정한다.

기존 Vitest 회귀 가드가 이 색상 버그를 "의도된 설계"로 잘못 확정해두고
있어 함께 뒤집었고, 실제 브라우저에서 색상을 검증하는 E2E 테스트도
추가했다(computed style RGB 값 검사).
2026-07-30 10:50:50 +09:00
chym1217 11d3cc6445 fix(sirsoft-gdpr): E2E 시드 커맨드 보안·멱등성 결함 2건 수정
PR 생성 직전 마지막 점검으로 변경분 전체를 라인 단위 재검토한 결과,
직전 커밋에서 신규 작성한 게스트→로그인 배너 재노출 E2E 시드 커맨드
(PlaywrightSeedGdprGuestLogin)에서 2건을 발견해 수정한다.

- 로그인 테스트 계정 비밀번호가 코드에 고정 문자열로 박혀 있어, 실수로
 프로덕션 DB에서 실행되면 알려진 비밀번호로 로그인 가능한 계정이
 남는 위험이 있었다. 실행마다 Str::random(32)로 무작위 생성하도록
 변경.
- 이메일/게스트 세션 ID를 매번 새로 발급해 재실행할 때마다 계정과
 동의 이력이 계속 누적됐다. 고정값으로 바꾸고 재실행 시 기존 데이터를
 먼저 삭제하도록 변경(sirsoft-page 모듈의 선삭제 패턴과 동일).
 2회 연속 실행 후 DB 조회로 누적되지 않음을 확인.

부수: cookie_banner.json 의 배너 재노출 조건에 "회원 로그인 후 로그아웃"
케이스가 회귀 테스트로 고정되어 있지 않아 추가 (로직 자체는 기존에도
정상 동작 확인됨 — 커버리지 보강).
2026-07-30 10:50:49 +09:00
chym1217 2a036704f4 fix(sirsoft-gdpr): 쿠키 동의 보안 결함 3건 수정 + 하위호환 고지
이슈 체크리스트와는 별개로, 처리 완료 후 지시로 진행한
sirsoft-gdpr 플러그인 전수 비판적 검토에서 발견한 보안·결함 3건을
수정한다.

- Admin 설정 저장 API 의 검증 우회 fallback(빈 요청 시 $request->all
 사용) 제거. UpdateAdminSettingsRequest 필드가 모두 nullable 이라
 검증을 거치지 않은 임의 키가 저장 로직까지 전달될 수 있었다.
- 게스트 세션 식별 쿠키(gdpr_session) 에 HMAC-SHA256 서명 적용.
 api 미들웨어 그룹에는 EncryptCookies 가 없어 쿠키가 평문으로 오가
 임의 UUID로 타인의 게스트 동의 상태를 조회/철회할 수 있었다.
 결제 플러그인들의 IssuesReceiptCookie 트레이트와 동일 컨벤션 채택.
 이 조치로 배포 이전 발급된 비회원 쿠키는 1회 초기화되며 이미
 배너를 닫았던 비회원은 다음 방문 시 배너를 다시 보게 된다(회원
 동의 이력은 영향 없음) — CHANGELOG 에 고지.
- 게스트로 쿠키 배너를 닫은 뒤 동의 이력 없는 다른 계정으로 로그인해도
 배너가 계속 숨겨져 있던 결함 수정. _global.gdprBannerDismissedFor 가
 "닫힘 여부"(boolean) 대신 "누가 닫았는지"(사용자 식별자)를 저장하도록
 변경해, 로그인으로 사용자가 바뀌면 다시 노출되게 한다. 실제 로그인
 폼 제출을 거쳐 재현하는 E2E spec 신규 작성(PlaywrightSeedGdprGuestLogin
 시드 커맨드 포함, sirsoft-page 의 PlaywrightSeedPage 패턴 재사용).

부수: 자동 차단 정책 안내에 보호 범위 한계 고지 추가, GDPR 플러그인
번들 로딩 우선순위 상향(다른 확장의 스크립트로부터 보호 범위 확대).
2026-07-30 10:50:49 +09:00
chym1217 192000cfce fix(sirsoft-gdpr): 쿠키 배너 노출 토글 안내 문구 및 필드 레이아웃 정정
banner_enabled(쿠키 배너 노출) 토글 안내가 "마이페이지 동의 관리 카드"까지
함께 켜지고 꺼지는 것처럼 서술되어 있었으나, 실제로는 카드가 이 토글과
무관하게 동의/철회 이력이 있는 회원에게 항상 노출된다(GDPR Art.7(3) 철회
대칭성 보장, mypage_privacy_tab.json 회귀 가드로 이미 고정된 설계).
문구만 실제 동작과 어긋나 있어 ko/en 양쪽 hint 및 관련 코드 주석을
바로잡았다.

문구 확인 과정에서 field_legal_entity_name / field_data_storage_location /
field_banner_enabled / field_banner_position 4개 필드가 텍스트 스타일
전용 클래스(section-heading-md, grid 미정의)를 컨테이너로 잘못 사용해
안내 문구가 길어지면 입력/토글 컨트롤이 아래 줄로 밀려나던 기존 UI
결함도 함께 발견해 수정했다. 같은 파일의 field_privacy_policy_slug가
이미 쓰던 올바른 grid-3col-responsive 패턴으로 통일.
2026-07-30 10:42:04 +09:00
chym1217 a0cc36a9db fix(sirsoft-gdpr): 정책 버전 본문 페이지 링크 404 수정
정책 버전 snapshot 모달의 본문 페이지 링크가 /{{slug}} 로 조합되어
페이지 모듈(sirsoft-page) URL 패턴 /page/{slug} 와 불일치해 404가
발생하던 문제를 수정. 쿠키 배너의 동일 링크와 조합 방식을 통일했다.

회귀 검증을 위해 GDPR 플러그인에 Playwright E2E 인프라를 최초 도입.
2026-07-30 10:42:04 +09:00
chym1217 d5b6f4b457 fix(sirsoft-gdpr): 마이페이지 쿠키 동의 철회 시 철회일 미표시 수정
철회 이력(status=revoked)이 있는 항목에 한해 status_at_formatted·
status_label 을 채워 마이페이지에 철회일이 표시되도록 한다. 거부·
미설정 상태는 기존대로 미표시 유지 ( 확인).

동의/철회 날짜 줄을 초록/빨강으로 구분해 시각적으로 대등하게
강조한다 (배지·관리자 GDPR 동의 이력 화면과 동일 톤).
2026-07-30 10:42:03 +09:00
정정홍andGitHub f1fcd48dc3 Merge pull request from gnuboard:HeuJung/issue493
HeuJung/issue493
2026-07-30 08:05:31 +09:00
HeuJung ffbbd9356a fix(basic): 사용자 화면 모달 닫기 버튼의 안내 라벨 미번역 수정
사용자 템플릿의 모달 닫기 버튼 접근성 라벨이 번역되지 않아 내부 식별자
(`common.close_modal`)가 그대로 노출됐다. 관리자 템플릿에는 키가 있는데
사용자 템플릿에만 빠져 있었고, 두 템플릿의 Modal 컴포넌트는 같은 키를 쓴다.

사용자 화면의 모든 모달이 영향을 받으며, 화면 낭독기 사용자는 버튼 용도 대신
내부 식별자를 듣게 된다. 눈으로 보는 검수로는 드러나지 않는 결함이라
접근성 트리를 읽는 과정에서 발견했다.

ko/en 키 추가 + 번들 일본어 언어팩 동기화(patch bump).

경위: 게시글 수정 화면에서 첨부를 빼면 실제로 삭제되는지 실측하던 중 발견했다.
첨부 삭제는 관리자·사용자 양쪽 모두 전용 즉시 삭제 경로로 이미 동작하며,
확인 모달이 "게시글 저장과 관계없이 되돌릴 수 없습니다" 로 그 의미를 안내한다.
2026-07-30 08:04:37 +09:00
HeuJung 7b56d5f23a feat(core,board,ecommerce,page,ckeditor5,kginicis): 업로드 이미지 축소·로그인 2단계 인증 도입 + 파생 결함 수정
인터뷰에서 방향만 정하고 남겨 뒀던 후속 5건을 구현하고, 커밋 전 종합 점검에서
발견한 결함 2건을 함께 고쳤다.

기능 도입:
- 업로드 이미지 축소 — `upload.image_max_width` / `image_max_height` / `image_quality`
 는 저장·검증만 되고 리사이즈 코드가 0건이었다. 비율 유지, 상한 이내면 재인코딩하지
 않음(불필요한 화질 손실 방지), 한계 미설정이면 무동작(기존 사이트 이미지 보호).
 PNG·GIF·WebP 투명도 보존. 실패해도 업로드는 계속되고 원본이 저장된다.
- 로그인 2단계 인증 — `security.two_factor_auth` 는 설정 항목만 있고 구현도 화면
 컨트롤도 없었다. 코어 IDV 인프라를 재사용해 purpose `login` 을 추가하고, 비밀번호
 확인 통과 후 challenge 만 발행한다(토큰 미발급 + logout). `POST /api/auth/login/two-factor`
 가 코드를 확인해야 토큰이 나가며, 로그인과 같은 요청 제한을 적용한다. challenge 의
 purpose 를 대조해 다른 흐름의 challenge 로 로그인하는 것을 막는다. 코드 발송이
 실패하면 실패로 알린다 — 2단계를 건너뛰고 로그인시키면 통제가 조용히 열린다.

결함 수정:
- 게시글 수정 시 사용자 경로가 `attachment_ids` 를 Service 로 넘기지 않아, 검증과
 권한은 통과하고 200 을 받는데 첨부만 조용히 연결되지 않았다. 관리자 경로는 넘기고
 있어 같은 요청이 화면에 따라 다르게 동작했다.
- 설정 화면 라디오가 `pointer-events-none` + Label click 으로만 상태를 바꿔, 키보드
 방향키(change) 조작 시 화면 표시와 저장될 값이 어긋났다. 동일 패턴 4개 레이아웃
 12개 라디오에 change 액션을 부여하고 params 를 click 과 동일하게 고정한다.
- 주문완료 화면이 결제수단 확인보다 영수증 조회를 앞세워, PG 를 거치지 않는 주문에서도
 매번 404 요청이 나갔다.
- 개별 게시판 폼의 숫자 항목에 경계값 바인딩이 없어 환경설정 화면과 안내가 달랐다.

커밋 전 점검에서 발견:
- 2단계 인증이 반쪽으로 들어가 있었다. enum 에 case 만 추가하고 코어 purpose
 레지스트리와 ko/en 라벨을 빠뜨려, 목적 목록 API 에서 통째로 빠지고 `hasPurpose`
 가 false 였으며 라벨 대신 i18n 키 원문이 반환됐다. 관리자가 이 목적의 메시지
 템플릿·정책을 만들 수 없는 상태였다. 세 곳을 enum 기준으로 전수 대조하는 회귀
 테스트를 함께 둔다.
- 업로드 이미지 축소가 적용되지 않은 8번째 서비스(레이아웃 편집기 첨부)가 있었다.
 열거형 커버리지 테스트는 내 조사 결과를 green 으로 고정할 뿐 전수를 증명하지
 못한다 — 같은 규약을 조건으로 표현한 audit 룰이 첫 실행에서 검출했다.

재발 방지:
- audit 룰 `upload-service-image-resize` (신규 업로드 서비스의 축소 누락 검출)
- 트러블슈팅 사례 20 (코어 설정이 일부 경로에만 적용되는 증상의 진단 절차)
- `sirsoft-ckeditor5` 의 코어 최소 요구 버전을 7.0.6 으로 상향 (신설 클래스 의존)

공개 이슈: gnuboard/g7
2026-07-30 07:57:06 +09:00
HeuJung d17ea28da5 fix(core,board,ecommerce,page,kginicis): 정책값 하드코딩·계약 분리 결함 일괄 수정
관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다.
설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자
리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다.

경계값 SSoT 도입:
- config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로
 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다.
- 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다.

적용되지 않던 정책을 실제로 적용:
- 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을
 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함)
- 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에
 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로
- 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책,
 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세

파생 결함:
- 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는
 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고,
 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다.
- 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자
 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로
 두고, 빈 목록 자체를 캐시하지 않는다.
- 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다.
- 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여).

재발 방지:
- audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프)
- 테스트 실행 범위 강제 훅(check-test-scope)
- 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착)

공개 이슈: gnuboard/g7
2026-07-30 07:52:24 +09:00
eunhyeandGitHub 5d140dfbe6 Merge pull request from gnuboard/chym1217/issue28
feat(message_bizppurio): 비즈뿌리오 SMS·카카오 알림톡 메시징 플러그인
2026-07-28 17:31:46 +09:00
chym1217 023ab4f099 chore(sirsoft-board,sirsoft-ecommerce,message_bizppurio): 코드 주석 내부 이슈번호 제거 + 시나리오 매니페스트 저장검증 effect 반영
PR 준비 비판적 재검토 중 발견한 잔여 항목 2건 정리.

- 게시판·이커머스 알림톡 연동 UI 테스트/레이아웃의 e2e:allow 주석·describe
 블록에 남아있던 작업 중 이슈번호 제거 (audit 정규식은 형식만 검사해
 이슈번호 잔존은 검출하지 못함)
- alimtalk-dispatch-binding.yaml 시나리오 매니페스트에 NotificationBindingService
 저장 시점 승인 재검증(assertSendable) effect 2건 추가 — 코드/테스트는
 이전 커밋에 있었으나 매니페스트만 누락
2026-07-28 16:56:13 +09:00
chym1217 0a6a8d6968 fix(message_bizppurio): 결과코드 5002 언어팩 누락 + 알림톡 템플릿 저장 승인검증 부재 수정
PR 준비 최종 검토 중 문제코드 처리 대조표를 재검증하며 발견한 결함 2건.

- ResultCodeResolver 가 5002(요청 과다)를 재시도 대상으로 분류하지만 ko/en/ja
 언어팩 어디에도 사유가 없어 코드값만 노출되던 결함 수정
- 알림톡 템플릿 연결 저장이 승인 상태(RDY/ACT) 재검증 없이 그대로 upsert되어,
 화면 드롭다운(승인 템플릿만 노출)을 우회한 API 직접 호출 시 미승인 템플릿도
 저장 가능했던 결함 수정 — bind 에 카카오 승인 목록 대조 추가, 조회 자체
 실패 시에도 안전측으로 저장 거부
2026-07-28 16:56:13 +09:00
chym1217 40d3ce9639 feat(sirsoft-board,sirsoft-ecommerce,message_bizppurio): 게시판·이커머스 알림 설정에 카카오 알림톡 연동 UI 추가
코어 알림 설정 화면에만 있던 비즈뿌리오 카카오 알림톡 템플릿 연결 UI(연결 버튼,
검수모드 배너, 안내박스)가 게시판·이커머스 알림 설정 화면에는 빠져 있었다.
Phase 6 재설계 시 "후속 작업"으로 남겨두고 실제 반영이 되지 않았던 부분이다.

- 게시판·이커머스 알림 정의 행에 코어와 동일한 notification_definition_row_footer
 확장 슬롯을 추가하고, 게시판 레이아웃에는 코어/이커머스와 동일한 채널 콘텐츠
 wrapper id를 신설해 구조를 맞췄다.
- 비즈뿌리오 플러그인에 게시판·이커머스 전용 overlay 확장 파일을 추가해 배너·
 안내박스·연결모달·데이터소스를 각 레이아웃에 주입한다. 백엔드는 notification_type을
 확장 구분 없는 문자열 키로 이미 3영역 공용 처리하고 있어 프론트 진입점만 보완했다.
- 회귀 테스트: 이커머스 화면에서 최초 구현 시 발견된 위치 버그(연결 버튼이 변수
 뱃지 아래가 아니라 토글/편집 버튼 옆으로 붙던 문제)를 fail 재현 후 수정,
 같은 회귀를 잡는 구조 검증 테스트를 게시판·이커머스 양쪽에 추가했다.
2026-07-28 12:40:22 +09:00
chym1217 49edacf5e0 docs(message_bizppurio): 운영자용 README 추가 + CHANGELOG 미배포 기준 정비
플러그인 README가 없어 webhook 등록, 검수/운영 계정, 알림톡 템플릿 역할 분리
등 운영자가 알아야 할 내용이 문서화되어 있지 않았다. 결제 플러그인(KG이니시스
등) README 관례를 따라 요구사항, 설치, 관리자 설정, webhook 등록, 발송 흐름,
결과 코드, 훅, API, 삭제 시 동작을 담은 README를 추가한다.

CHANGELOG는 아직 공개 저장소가 없는 미배포 상태이므로, 개발 중 쌓인 이력성
문구("예전에는 ~였지만 이제 ~")를 걷어내고 1.0.0이 처음 제공하는 기능 목록만
남기도록 정리한다. ja 번들 언어팩 CHANGELOG도 동일 기준으로 정리한다.
2026-07-28 12:40:20 +09:00
chym1217 f66f1a2f20 fix(message_bizppurio): 발송 실패 시 비즈뿌리오 응답의 실제 사유·결과코드를 이력에 기록
발송 API(/v3/message) 가 HTTP 오류로 실패하면 "메시지 발송 요청에 실패했습니다"
라는 고정 문구만 남고 비즈뿌리오가 준 실제 사유·코드를 버려, 발송 이력·로그로
원인을 알 수 없었다. 실패 응답 body 의 code·description 을 추출해 예외에 실어
발송 이력에 "발신 프로필 키가 유효하지 않음 (7103)"처럼 구체 사유가 남도록 한다.
body 가 없으면 기존 기본 문구로 폴백한다(결과코드 null).
2026-07-28 12:40:20 +09:00
chym1217 a706422401 fix(message_bizppurio): 발송 결과 코드 사유를 최신 공식 명세로 정합화
발송 결과 코드 사유 문구를 비즈뿌리오 최신 공식 명세
(bizppurio.github.io/response-codes)에 맞춰 정비한다. 인증·계정 계열
코드(3001·3004·3007·3009 등) 설명이 실제 의미와 달라 관리자가 실패 원인을
오해할 수 있던 것을 정정하고, 누락 코드(3003·3010 IP 오류·4431 수신거부·
7103 발신프로필 키 무효)를 추가한다. 재시도 분류에 5002(요청 과다)를 포함한다.
ko/en 및 ja 언어팩 사유를 함께 갱신한다.
2026-07-28 12:40:19 +09:00
chym1217 4b5ec6ebd6 fix(message_bizppurio): 잔액부족 알림 본문 변수 미치환 수정 + 후불 한도초과 대응
잔액부족 관리자 알림 본문의 {channel_label}·{result_code} 등이 실제 값으로
치환되지 않고 중괄호째 노출되던 결함을 수정한다. balance.low 훅 인자와 사이트
메타를 알림 data 로 채우는 extract_data 구독 리스너(BalanceLowNotificationDataListener)
를 추가해, 코어 알림 본문 치환이 성립하도록 한다.

후불 한도 초과(9071)를 잔액부족과 동일 분류에 포함해 관리자 알림이 발화되도록
하고, 채널군 라벨(문자/알림톡)·결과코드 사유를 ko/en 및 ja 언어팩에 추가한다.
2026-07-28 12:40:19 +09:00
chym1217 f49d939ec1 chore(message_bizppurio): A-2 브라우저 검수용 데모 시더 제거
DispatchResultDemoSeeder 는 코어 알림 발송 이력 화면의 비즈뿌리오 결과
컬럼·토글 노출을 눈으로 확인하기 위한 일회성 검수용 시더였다. 검수가
끝나 공개 릴리스에 데모 데이터 생성기가 포함되지 않도록 제거한다.
2026-07-28 12:40:19 +09:00
chym1217 a1b30fd218 docs(message_bizppurio): 삭제 시 채널 정리 CHANGELOG 기재 + 알림톡 연동 저장 방식 주석 정정
플러그인 삭제 시 문자·알림톡 채널이 정리되고 재설치 시 복원된다는
사용자 관점 항목을 CHANGELOG 에 추가한다.

알림톡 연동 저장 방식이 "코어 저장 훅 편승"에서 "행 하단 [연결/변경]
버튼 → 우리 연결 모달 → 우리 API 직접 저장"으로 확정됐으나 일부 주석이
옛 설명으로 남아 있어, 실제 구현과 일치하도록 3곳을 정정한다(로직 불변).
2026-07-28 12:40:19 +09:00
chym1217 f2c11d2edb fix(message_bizppurio): webhook 미들웨어 self-gate 전환 + 발송 시나리오 scale 면제
라우트 파일에서 직접 부착하던 webhook IP 화이트리스트를 plugin.php
getMiddleware self-gate 선언으로 전환한다(코어 확장 미들웨어 규정).
테스트 게이트 활성 헬퍼를 이식해 webhook 보안 테스트를 복구하고,
단건 발송 Job 시나리오에 scale 축 면제를 명시한다(볼륨 무관 상수 메모리).
webhook API 문서에 self-gate 부착 방식을 함께 보강한다.
2026-07-28 12:40:18 +09:00
chym1217 9dee9f5e35 feat(lang-pack): 비즈뿌리오 메시징 플러그인 일본어 번들 언어팩 추가
sirsoft-message_bizppurio 플러그인의 ja 언어팩을 신규 생성한다.
빌더 PACKAGES 테이블에 패키지를 등록하고, backend(messages·result_codes·
activity_log)·frontend·seed(notifications·permissions·manifest) 번역과
매니페스트·CHANGELOG를 함께 추가한다.
2026-07-28 12:40:18 +09:00
chym1217 8521428636 fix(message_bizppurio): 알림톡 템플릿 검색 결과 없음이 오류로 처리되던 문제 수정
카카오 kapi 결과코드 508("요청한 데이터가 없음")은 검색 결과 0건을 나타내는
정상 응답인데, 예외로 전파해 화면에 아무 반응 없이 이전 목록만 남는 문제가
있었다. 목록 조회에서만 508을 빈 결과로 흡수하도록 수정.
2026-07-28 12:40:17 +09:00
chym1217 e8784854aa fix(message_bizppurio): 발송 건너뜀이 코어 이력에 성공으로 오표시되던 문제 수정
SMS 템플릿 없음·알림톡 카카오 템플릿 미연결 등으로 발송 자체를 시도하지
못한 경우, 채널 드라이버가 조용히 return하면 코어가 예외 미발생을
성공으로 착각해 발송 이력에 "성공"으로 잘못 기록했다. 드라이버가
NotificationSendSkippedException을 던지도록 바꿔, 코어의 기존
실패 처리 경로(channel_send_failed 훅)를 그대로 태워 "실패"로 정확히
기록되게 한다. 코어 파일은 수정하지 않는다.

실패 사유 메시지의 알림 유형도 내부 코드값(welcome) 대신 사람이 읽는
이름(회원가입 환영)으로 표시하도록 함께 개선했다.
2026-07-28 12:40:17 +09:00
chym1217 efe1a81cb8 feat(message_bizppurio): 알림톡 실제 발송 내용을 발송 이력 화면에 별도 표시
코어 notification_logs.body는 알림톡의 경우 SMS 대체발송용 코어 템플릿
본문이라, 실제 카카오에 발송된 내용(승인 템플릿 전체)과 다르다. 코어
공용 파일은 다른 채널도 함께 쓰므로 직접 수정하지 않고, 결과 컬럼
오버레이 방식을 확장해 알림톡 채널일 때만 실제 발송 내용을 별도로
보여준다.
2026-07-28 12:40:16 +09:00
chym1217 e41f9c14c3 fix(message_bizppurio): SMS 대체발송 결과가 미요청 건에도 잘못 표시되던 문제 수정
비즈뿌리오가 대체발송 미요청 건에도 webhook TELRES/KAORES 값을 채워 보내
빈 문자열 검사만으로는 요청 여부를 구분할 수 없었다. 발송 시점에 우리가
실제로 대체발송을 요청했는지(request_payload의 resend 키 존재)를 판단
기준으로 바꿔, 미요청 건은 webhook 값과 무관하게 결과를 표시하지 않는다.
2026-07-28 12:40:16 +09:00
chym1217 4e77d25926 feat(message_bizppurio): 검수 모드 라벨 통일 + 발송 payload 저장 + 알림톡 URL 프로토콜 중복 방지
발송 이력 화면의 "테스트 모드" 표기를 "검수 모드"로 통일하고, 검수 모드 발송 건은
상태 라벨 대신 "검수" 라벨로 표시한다. 설정 미완료 상태에서도 검수 모드 안내가
함께 표시되도록 배너 노출 조건을 수정한다.

발송 요청 payload가 어디에도 저장되지 않아 실패 원인을 사후 재구성할 수 없던 문제를
해결한다. bizppurio_dispatches.request_payload 컬럼을 신설해 발송 시점(성공/실패
무관)에 실제 비즈뿌리오 전송 payload를 저장하되, 다른 컬럼과 중복되는 개인식별
정보(수신번호·치환된 본문 텍스트)는 제외하고 저장한다.

카카오 알림톡 버튼 URL과 완전한 URL 변수(action_url 등, 항상 프로토콜 포함)가
결합될 때 프로토콜이 중복(https://https://...)되어 잘못된 링크로 발송되던 문제를
방지한다.

플러그인 제거 후 재설치 시 sms·alimtalk 채널 템플릿이 재생성되지 않던 문제를
해결한다. 리스너 등록 이력 캐시가 재활성화 후에도 남아있어 재등록이 조용히
스킵되던 것으로, 코어 수정 없이 플러그인 내부에서 리플렉션으로 우회한다.
2026-07-28 12:40:15 +09:00
chym1217 a8a85ac0cf feat(message_bizppurio): 검수/운영 별도 계정 안내 + 사전 준비 UI 2단 카드화
비즈뿌리오는 검수/운영을 별도 계정으로 운영하도록 권장하나 본 플러그인은
계정을 1벌만 보관하므로, 관리자가 이를 인지하도록 검수 모드 카드에 안내를
상시 노출한다. 검수 모드 안내의 "(개발)" 표현도 제거했다.

또한 사전 준비 안내가 세로로 길어 보이는 문제를 개선해, 문자·카카오를
넓은 화면에서 2단 카드 + 채널 아이콘으로 정리했다(모바일 1단 유지).
2026-07-28 12:40:15 +09:00
chym1217 d4bb117b9b feat(message_bizppurio): 알림톡 카카오 템플릿 내용 발송 반영 + 연결 소실 감지
- 알림톡 발송이 카카오 승인 템플릿의 본문·버튼·요소를 실제로 채워 보내도록 구현(B안):
 카카오 상세조회 + template_code 단위 캐시(기본 60분, 수동 초기화·끄기 지원),
 카카오→발송 형식 변환 + 변수 치환
- 결과코드 재시도 분류 정정(알림톡 일시오류 7306/7307/7421/7437 추가)
- 발신프로필 응답 파싱 정정(data.success[])
- 연결한 카카오 템플릿 소실(삭제·차단·미승인) 시 알림 설정에 "사용 불가" 표시,
 조회 실패/승인 없음 구분 안내(결함 2·3)
2026-07-28 12:40:14 +09:00
chym1217 ca5c9a3438 fix(message_bizppurio): 알림톡 연결 모달에서 조회 실패와 '승인 템플릿 0건'을 구분
자격증명 오류 등으로 카카오 템플릿 조회가 실패하면 드롭다운이 비면서
'승인된 템플릿이 없습니다'로 떠서 실제 0건과 구분되지 않던 문제를 수정한다.
데이터소스 fallback 에 load_failed 마커를 실어, 드롭다운이 비었을 때
조회 실패(설정 확인 안내)와 진짜 0건(승인 템플릿 없음)을 문구로 분기한다.
에러 토스트 재도입 없이 모달 내 인라인 문구로만 구분한다.
2026-07-28 12:40:13 +09:00
chym1217 8aa395c3a1 fix(notification): 비활성 확장이 제공하던 알림 채널을 발송 대상에서 제외
비활성 상태의 확장이 등록했던 발송 수단(문자·알림톡 등)을 코어 via가
저장된 채널 설정만 보고 계속 발송·"건너뜀" 로깅하던 문제를 수정한다.
사용 가능한 채널 목록에 없는 채널은 로그 없이 조용히 제외하며,
알림 설정·템플릿은 그대로 유지되어 확장을 다시 켜면 복구된다.

아울러 발송이 타임아웃·연결실패로 최종 실패할 때 pending 으로 방치되던
이력을 failed 로 마감한다(webhook 이 먼저 확정한 건은 덮어쓰지 않음).
2026-07-28 12:40:13 +09:00
chym1217 cd11fc60ce feat(auth): 회원가입 시 휴대폰·전화번호 선택 입력 추가
마이페이지·관리자 회원 폼에는 이미 연락처 필드가 있으나 회원가입 폼에만
빠져 두 화면이 불일치했다. DB·모델·Resource는 기존 존재하므로 회원가입
경로에만 검증·저장·입력 UI를 연결한다. 연락처는 선택 항목이다.
2026-07-28 12:40:12 +09:00
chym1217 eb0c72c32b fix(message_bizppurio): 알림 채널 미저장 시 발송 차단 + 토글 저장·노출 정정
관리자가 SMS·알림톡 채널을 꺼도 발송이 계속되던 문제 수정.

- 원인: 채널 토글 저장이 map-only 라 미등록 확장 채널(sms/alimtalk)의
 on/off 가 저장되지 않았고, 백엔드는 미저장 채널을 활성으로 판정
- 프론트: 토글 저장을 upsert 로 전환(미저장 채널도 저장), 하단 채널 탭을
 source 기준으로 필터(확장 채널은 미저장/OFF 시 숨김) — 코어·게시판·이커머스
- 백엔드: 플러그인이 core.notification.channel_enabled 훅을 구독해
 확장 채널 미저장 시 OFF 로 게이트(코어 무수정, 코어 기본 채널은 하위호환 유지)
2026-07-28 12:36:29 +09:00
chym1217 92f36e86b1 feat(message_bizppurio): 알림톡 템플릿 화면을 조회 전용으로 전환
템플릿 등록·수정·삭제·검수·상태변경을 제거하고 목록·상태·내용 조회 +
알림 연결만 남긴다. 등록·관리는 비즈뿌리오 콘솔로 위임 — 콘솔이 이미
이미지메이커·AI생성·캐러셀을 잘 제공하고, 카카오 규격이 자주 바뀌어 등록
폼을 유지하면 규격을 영구히 쫓아가야 하는 유지보수 부채가 크기 때문이다.
이 플러그인의 고유 가치는 카카오 템플릿 ↔ G7 알림 연결(binding)이다.

주요 변경:
- 라우트/컨트롤러/서비스에서 변경(manage) 메서드 전부 제거, 조회만 유지
- dev_sample 임시 개발코드 + FormRequest 3종 + 이미지 업로드 경로 제거
- 레이아웃: 등록 폼·상태변경 모달 제거, 상세는 내용만+[닫기], 목록 관리열은 [내용]만
- 환경설정 사용 전 준비 안내 + 목록 상단 콘솔 위임 안내(배지 의미색) 추가
- 안내 배너 중복 해소: 목록 조회 실패 배너에 ready 게이트를 걸어 설정 안내와 배타 표시

동반 변경(직전 세션 잔여, lang 얽힘으로 같은 커밋):
- 연동 행 SMS 대체발송 배지(ON/OFF solid 색) 추가
2026-07-28 12:36:27 +09:00
chym1217 5e9d046812 fix(message_bizppurio): 설치 시 문자·알림톡 채널 템플릿 자동 시딩
PluginManager::activatePlugin 이 status=active 로 바꾸기 전에 activate 를
호출하므로, 그 시점엔 SeedChannelTemplatesListener 가 아직 등록되지 않아 재시딩이
sms·alimtalk 채널 template 을 붙이지 못했다(알림 설정 탭이 비어 토글도 미노출).

activate 가 재시딩 전에 해당 리스너를 명시적으로 선등록하도록 수정. register
는 동일 프로세스 내 중복 등록을 막으므로 멱등하며, 코어는 수정하지 않는다.

- plugin.php: activate 에 리스너 선등록 추가
- 회귀 테스트: activate 후 sms·alimtalk 템플릿 생성 검증 (fail→green)
- CHANGELOG: working(1.0.0) Added 항목에 설치 시 자동 시딩 문구 흡수
2026-07-28 12:36:27 +09:00
chym1217 11f1d79540 feat(message_bizppurio): 코어 알림 발송 이력 화면에 문자·알림톡 결과 주입
발송 결과(성공/실패·사유·잔액부족·SMS 대체발송)를 코어 "알림 발송 이력" 화면의 각 행에
표시한다. 코어 앱·코어 테이블 무수정 — 연결 표식은 bizppurio_dispatches.notification_log_id
에만 두고, 발송 사이클의 코어 after_log_sent/after_log_failed 훅으로 그 로그 id 를 연결한다.

- 결과 조회는 파라미터 없는 GET /dispatch-results/recent 로 최근 결과 맵을 받아 row.id 매칭
 (목록 로드 순서에 무관 — 빈 배열 전송·타이밍으로 결과가 비던 문제 해소).
- 코어 이력 datagrid 에 결과 컬럼을 layout_extensions 로 주입하고, 행 토글에도 상세 결과를
 얹는다. 메일·사이트내알림 탭에선 컬럼(헤더 포함)을 숨긴다. 다크모드 배지 색 보강.
- 브라우저 검수용 데모 시더(4상태·엣지 케이스 21건) + 전 계층 테스트(백엔드·Vitest 렌더)
 + 시나리오 매니페스트 + API 레퍼런스 동반.
2026-07-28 12:36:26 +09:00
chym1217 fc00e30fc4 fix(message_bizppurio): 알림톡 카카오 오류 사유 노출 + 탭 전환·목록 오류 표시
- 카카오 kapi HTTP 실패 시 응답 body의 message/code를 예외에 실어
 운영자에게 실제 사유(IP 차단·반려 등)를 노출 (기존 고정 문구로 뭉개지던 문제 해소)
- 설정 탭 전환을 replaceUrl→navigate(replace)로 교체
 (URL만 바뀌고 화면이 전환되지 않던 문제 해소)
- 템플릿 목록 조회 실패 시 카카오 사유를 빨간 배너로 표시
- report_url 데이터소스 if 조건으로 templates 탭 불필요 재조회 억제
2026-07-28 12:36:25 +09:00
chym1217 17a95bb556 feat(message_bizppurio): 알림톡 연동 행 슬롯·연결 모달 재설계
알림 설정 알림톡 탭에서 각 알림에 카카오 알림톡 템플릿을 연결하는 UI.
코어 편집 모달·저장 버튼은 건드리지 않는다(무오염).

- 각 알림 행 하단에 범용 확장 슬롯(notification_definition_row_footer) — 연결 상태 줄
 + [연결/변경] 버튼. 코어 편집 모달 내부 주입 불가(엔진 modals 미탐색) 회피.
- 우리 전용 연결 모달(modals 병합) — 승인 템플릿 드롭다운·SMS 대체 토글·변수 안내 +
 [취소][저장]. 저장은 우리 API(코어 저장 무관). openModal 을 승인 조회보다 먼저 실행.
- 상태 배너(readiness 미충족/테스트 모드, 문제 있을 때만) + 알림톡 탭 안내 박스.
- SMS 대체발송 안내(실패 시 이 알림 본문이 SMS로 발송) + 다크 모드 solid 색.
- 카카오 API 422 는 errorHandling.suppress 로 조용히 처리(전 탭 에러 토스트 제거).

Vitest 17 통과, audit 위반 0, Chrome MCP 다크·저장 검수 통과.
2026-07-28 12:36:25 +09:00
chym1217 af43a7c41d feat(message_bizppurio): 알림톡 연동 편집모달 전용칸 즉시저장 + 시딩 필터 훅 template 증강
Phase 6 재설계 — 코어 알림 설정 화면을 그대로 쓰고 알림톡 편집 시 전용 칸만 얹는다.

- 시딩 필터 훅(seed.*.notifications.translations) 편승으로 회원 알림에 sms·alimtalk
 template 증강 (코어·모듈 무수정, cleanupStaleTemplates 에도 유지)
- 코어 편집 모달에 overlay 로 전용 칸 append(연결 템플릿·SMS 대체) — 변경 즉시 저장,
 코어 저장 버튼과 무관 (코어 템플릿 무오염)
- 알림톡·SMS 탭 상태 배너(설정 미완료/테스트 모드)
- 1차 구현(우리 목록 통째 교체·게이트·우리 편집 모달) 폐기

전 계층 테스트 통과 (백엔드 170 / 프론트 63), audit 위반 0.
2026-07-28 12:36:24 +09:00
chym1217 73b05aa42f feat(message_bizppurio): 알림톡 템플릿 관리 화면 (설정 페이지 탭)
설정 페이지 알림톡 템플릿 탭에서 카카오 알림톡 템플릿을 등록·수정·삭제·검수·
상태변경한다. 진입은 코어 소유 설정 페이지 하나로 통일하고 getAdminMenus 는
폐기했다. 템플릿은 kapi 에 실시간 위임하며 DB 에 저장하지 않는다.

등록 폼은 계획서 §6-4 전체를 구현한다: 템플릿 코드(자동 생성/직접 입력),
카테고리 2단 드롭다운, 템플릿 유형(기본형/이미지형/강조표기형), 이미지형
파일 업로드, 본문 변수 클릭 삽입 + 알림별 변수 참고표, 선택 항목 6개
체크펼침(부가정보·미리보기·버튼·바로연결·대표링크·보안), 등록/등록 후
검수요청 2버튼.
2026-07-28 12:36:22 +09:00
chym1217 77df8c9a7c feat(message_bizppurio): 발송 결과 기록 + webhook 수신 + 결과코드
문자·알림톡 발송 결과를 DB에 기록하고, 비즈뿌리오 webhook(URL PUSH)으로
도달 여부를 갱신한다. 결과코드를 사유+분류로 해석하고, 잔액 부족 시
관리자에게 쿨다운(기본 1시간) 내 1회만 알린다.

발송 결과를 확인하는 화면은 플러그인 독립 페이지 대신 코어 알림 이력에
얹는 방식(후속)으로 이관하기로 하여, 이번에 만든 독립 이력 화면과
화면 전용 백엔드는 제거했다. webhook 결과 저장·수신·해석 뼈대는 유지한다.
2026-07-28 12:36:21 +09:00
chym1217 fd7e216639 feat(message_bizppurio): 설정 화면 KG이니시스식 개편 + 검수 모드 전환
- 설정 화면을 공용 클래스·2단 그리드·상단 안내 패널·필드 인라인 에러로 통일
- 연동 환경(dev/live)을 검수 모드 Toggle(is_test_mode)로 전환, 독립 카드로
 분리하고 운영 경고 박스를 API 연동 카드 상단에 배치
- 검수 모드를 끄면(운영) 발송 자격증명(아이디·비밀번호·발신번호)을
 required 로 강제하는 조건부 검증 필터 훅 추가
- 리포트 수신 주소를 설정 화면에서 확인·복사 (플러그인 라우트가
 config('app.url') 기준 절대 URL 반환, 코어 무수정)
- 명칭 "메시징→메시지 발송", 설명 "자동발송→발송", "(다우기술)" 제거
- 설정 저장 테스트가 실제 런타임 설정 파일을 오염시키던 결함 격리
2026-07-28 12:36:20 +09:00
chym1217 a3495d8dc6 feat(message_bizppurio): 문자·알림톡 채널 등록 + 자동발송
Phase 3 — 코어 알림 시스템에 sms·alimtalk 채널을 코어 무수정으로 등록하고,
가입·주문 등 이벤트 발생 시 문자를 자동발송한다. 실제 발송·재시도는 Phase 2
발송 Job 에 위임한다.

- filter_available_channels 훅으로 sms·alimtalk 채널 노출(비회원 발송 허용).
 channel_readiness 로 발신 정보 미설정 시 필요한 항목을 안내.
- 코어/게시판/이커머스 3영역의 채널 후보 훅을 구독해 자동발송 커버.
- sms 채널 드라이버를 ChannelManager 에 등록(코어 무수정) — 전화번호 해석
 (회원=프로필, 비회원=주문 연락처) → 본문 길이로 SMS/LMS 판별 → 발송 위임.
- 비회원 주문 전화번호는 이커머스 알림 데이터 훅을 구독해 채운다. 이커머스
 모듈은 무수정(자체 관행대로 배송지 연락처를 직접 읽음).

알림톡은 채널 등록·readiness 까지만. 실제 발송·템플릿 연동·탭 UI 는 Phase 5·6,
발송 이력 DB 는 Phase 4 로 이관.
2026-07-28 12:36:19 +09:00
chym1217 6549170bda feat(message_bizppurio): 비즈뿌리오 발송 엔진 API 클라이언트
발송 시스템(api.bizppurio.com)과 카카오 관리 시스템(kapi.ppurio.com)의
API 클라이언트를 인증 방식(토큰 vs bizId+apiKey)에 따라 분리 구현했다.
토큰 캐시(23h)·재발급, SMS/LMS EUC-KR byte 자동 판별, ShouldQueue 발송
Job(429·일시오류 재시도, 영구실패 즉시)을 포함한다. 실제 발송 없이
Http::fake 단위테스트 30건으로 검증했다.
2026-07-28 12:36:18 +09:00
chym1217 b48ac91fec feat(message_bizppurio): 비즈뿌리오 메시징 플러그인 골격 + 환경설정
Phase 1 — 플러그인 설치·활성화 시 권한·메뉴·환경설정 화면이 뜨는 기반 구축.
이후 발송엔진·채널·이력·템플릿 Phase 의 전제가 된다.

- 관리자 환경설정 화면(3섹션: API 연동/발송 설정/연동 안내) — 비즈뿌리오
 아이디·비밀번호·API 키·발신번호·알림톡 발신프로필 키·연동 환경(검수/운영).
 크리덴셜은 마스킹 + 프론트 미노출.
- 조회/관리 권한 2개 + 「메시지 발송 관리」 관리자 메뉴(알림톡 템플릿·발송 이력 하위).
- 잔액 부족 시 관리자 알림 정의(webhook 결과코드 감지는 Phase 4).
- 발송 관련 Enum 4종(채널/상태/출처/결과분류) — Phase 2~4 소비용 골격.

알림톡 채널 기본 body 시드는 채널 등록(Phase 3)·탭 연동(Phase 6)과 강결합이라
Phase 6 으로 이관(plugin.php 주석 명시).

테스트: PHPUnit 12건(설치·권한·메뉴·알림 정의·Enum) + Vitest 22건(환경설정 레이아웃).
설치→활성화 실검증으로 권한 4·메뉴 3·알림 1·설정 초기값 로드 확인.
2026-07-28 12:36:18 +09:00
eunhyeandGitHub d03c731876 Merge pull request from gnuboard/chym1217/issue430
feat(gdpr): 쿠키 배너 거부 기능 + 마이페이지 동의 관리 재설계
2026-07-27 16:30:02 +09:00
chym1217 84555ea8eb fix(lang-pack): g7-plugin-sirsoft-gdpr-ja 버전 bump 누락 수정
이슈 작업 중 일본어 언어팩에 신규 키 12건을 추가했으나 language-pack.json
version(1.0.0)을 patch bump 하지 않은 채 커밋됨 (규정: 키 추가/수정 → patch up).
1.0.1로 정정 + CHANGELOG 섹션 분리 + language-pack:update --force 로 설치본 반영.
2026-07-27 16:18:49 +09:00
chym1217 da031804ae fix(sirsoft-gdpr): 관리자 환경설정 카테고리 목록 id 중복 + 다크모드 배지 색상 누락 수정
- cookie_categories_rows / blocked_domains_taginputs iteration 컨테이너의 정적 id를
 index_var(categoryIdx)로 동적화 — 카테고리가 여러 개일 때 동일 HTML id가 중복 렌더되던
 W3C id 유일성 위반 수정 (audit layout-iteration-static-id)
- 카테고리 「필수」 배지 dark:bg-red-800 / text-danger-soft 클래스 사이 공백 누락으로
 다크 모드 배경·글자색이 미적용되던 오타 수정

이슈 작업 중 audit-branch 전수 검사에서 발견한 기존 결함 (이번 이슈 기능과 무관, )
2026-07-27 15:58:30 +09:00
chym1217 1c6217f90d feat(gdpr): 마이페이지 쿠키 동의 관리 버튼 재설계
- 토글 스위치 → 상태 배지 + 동의/철회 버튼으로 재설계
 (user 템플릿 미해석 클래스로 인한 스타일 회귀 + "켜짐/꺼짐" 표현 개선)
- 동의 시에도 확인 모달 경유, 라벨에 "쿠키" 접미사, "모두 동의" 바
- 동의한 항목만 동의일 표시 (상태는 라벨 옆 배지가 표현)
- 카테고리 설명을 코드 기본값에서 조회 — 관리자 설정 화면에 설명 편집 UI가
 없어 라벨만 저장 시 설명이 유실되던 문제 해결
- 철회/동의 모달 버튼을 목록 버튼과 톤 맞춰 정리 (취소=테두리형)
- cookie_banner 운영주체 라인 admin 전용 클래스 → 공통 클래스 치환
2026-07-27 15:58:29 +09:00
chym1217 ef13a1ddff feat(gdpr): 쿠키 거부 기능 + 마이페이지 동의 관리 토글 재설계
STEP 1~7 (거부 기능):
- 쿠키 배너에 「동의하지 않고 계속하기」 거부 버튼 추가 (intent=reject)
- is_rejected/rejected_at 컬럼 + ConsentAction Enum 3분기 (동의/철회/거부)
- 관리자 동의 이력에 [거부] 필터 + amber 배지

STEP A~F (토글 재설계):
- 필수 카테고리를 동의 축에서 완전 제외 (status/history 미저장) — ePrivacy Art.5(3) 면제
- 마이페이지 표→토글 전환: 필수=「항상 켜짐」 잠금, 선택=on/off 스위치, 동의일 제거, 「모두 켜기」 바
- 거부 토스트 문구 정정: consent.granted → rejected_saved (배너 + 컨트롤러 응답)

테스트: 백엔드 201/201, 프론트 288/288, 시나리오 커버리지 audit 통과
Chrome MCP 실측: 토글 grant/revoke/모두켜기 + 배너 거부 토스트 + 거부 직후 마이페이지 표기 확인
2026-07-27 15:58:28 +09:00
정정홍andGitHub 0dc174f2dc Merge pull request from gnuboard:HeuJung/issue496
feat(core,admin_basic): 번들 비-ko 언어팩 자동 프로비저닝 + 미설치·드리프트 발견성
2026-07-27 15:00:48 +09:00
HeuJung 3d8890b118 feat(core,admin_basic): 번들 비-ko 언어팩 자동 프로비저닝 + 미설치·드리프트 발견성
신규 설치/복구 시 비-ko/en 번들 언어팩(ja 등)이 자동으로 채워지지 않아
런타임에 오류 없이 조용히 ko 로 폴백하고, 운영자가 이를 알아채기 어려웠다.
두 결손을 함께 해소한다.

프로비저닝(SSoT):
- language-pack:provision 멱등 커맨드 신설. supported_locales ∖ {ko,en} 의
 미설치 번들 팩을 설치하고, 설치본 파일이 사라진 드리프트 팩(슬롯 점유로
 getUninstalledBundledPacks 가 못 잡는)도 복구 경로로 겸한다. 재실행 시 0건 수렴.

발견성:
- CLI language-pack:list 를 Service::list 경유로 전환해 미설치(uninstalled)와
 드리프트(active·파일 없음)를 함께 표면화.
- files_missing / bundled_source_available 파생 플래그를 Resource 에 노출.
- 관리자 언어팩 목록에 "파일 없음" 배지 + 원클릭 재설치(기존 install-bundled 모달
 재사용). 재설치 경로만 auto_activate 로 바인딩해 복구 후 active 를 유지한다.

회귀 방지:
- 정적 룰 lang-pack-supported-locale-has-bundle(선언 로케일 ↔ 번들 소스 부재) 신설,
 lang-pack-installed-version-drift 를 프로비저닝된 환경 한정으로 확장.
- 백엔드/레이아웃/E2E/시나리오 테스트 + 룰 fixture 추가.

부수 수정:
- 프로비저닝 작업 중 표면화된 ecommerce 언어팩 시더 테스트 격리결함 수정
 (modules 등록 행을 매 테스트가 직접 보장 — 프로세스당 1회 시딩 전제 붕괴 대응).
2026-07-27 14:58:48 +09:00
정정홍andGitHub 6b791679a6 Merge pull request from gnuboard:HeuJung/issue491
HeuJung/issue491
2026-07-27 10:10:07 +09:00
HeuJung 0dfb45951b fix(core,basic): 검색 봇 화면 렌더러 패리티 결손 일괄 수정
일반 화면(React)과 봇 화면(PHP)은 같은 레이아웃을 각각 렌더하는데 봇
화면 쪽 지원 범위가 뒤처져, 확장이 교체한 본문이 통째로 빈 채 나갔다
(공개 이슈 ). 같은 결손 계열을 전수 조사해 함께 닫는다.

- 확장 주입 props 를 렌더 진입 시점에 해석해 자손 전체에 상속
 → 게시글·답글 원글·댓글·페이지·상품 설명 5개 지점 복구
- 사용자 작성 HTML 정화기 신설 — isHtml 판정에 따라 이스케이프/정화.
 미정화 상태에서 봇 경로로 저장형 스크립트가 실제 실행됐다
- 노드 키 패리티: conditions / type:iterator / responsive 매칭 규칙 /
 children 문자열 / text 우선순위 / $t:defer / 레이아웃 초기 상태
- 표현식: 단일 바인딩 판정 교체, ||·&& 를 값 반환(JS 시맨틱)으로,
 비교식이 raw 경로에서 null 이 되던 문제
- 데이터소스: 엔드포인트 표현식 전면 해석 + 레이아웃 8곳 선언 보강
- 봇 페이지 캐시 1회 전량 무효화 업그레이드 스텝

지원 범위 SSoT 표를 문서에 고정하고, 한쪽만 고치는 것을 막는 검사 룰과
레이아웃 전수 순회 테스트로 잠갔다.
2026-07-27 10:03:06 +09:00
HeuJung b505ac7ba7 fix(core,board,ecommerce,page,gdpr): 목록 컨텍스트 왕복·엔진 렌더 파이프 결함 일괄 수정 + 공개문서 정리
세 갈래의 결함을 한 브랜치에서 정리한다.

## 목록 컨텍스트 왕복 시 URL 상태 소실 ( @jiwonpapa 님께서 제보해주셨습니다.)

목록에서 상세·형제 상세(이전/다음)·작성/수정 폼에 다녀오면 보고 있던
page/search/category/filters 가 사라지던 문제를 전 도메인에서 수정했다.

- 엔진(engine-v1.54.2): `mergeQuery: true` 만 적고 `query` 를 생략하면 병합이
 통째로 건너뛰어지던 함정을 교정 — `ActionDispatcher.handleNavigate`/`handleReplaceUrl`.
- 게시판·이커머스·페이지·회원·마이페이지·gdpr 등 9개 확장 레이아웃의 왕복 leg 전수
 적용(mergeQuery: true). 의도적 리셋(검색/필터 초기화·탭 전환·프리셋)은 면제 주석으로 구분.
- 무한스크롤 목록(브랜드·상품 공통정보·고시정보)의 새로고침이 URL 검색·정렬을 떨구던
 결함 수정.
- 재발 차단: audit 룰 `layout-list-context-navigate-merge-query`(목록 클러스터 자동 도출,
 page/필터 URL 신호 4종) + `layout-navigate-path-absolute`(navigate path 동작 키워드 금지).

## cellChildren 등 반복 렌더에서 단일 바인딩 파이프 미적용 ( @glitter-gim 님께서 제보해주셨습니다.)

목록 표의 각 칸에 넣은 날짜·숫자 서식(`{{row.x | datetime(...)}}`)이 빈 값이 되거나
서식 없는 원본으로 나오던 문제를, 표현식 판정 로직이 엔진 전역에 복제되며 갈라진
구조적 결함으로 진단하고 판정 경로를 단일화했다(engine-v1.54.3).

- `RenderHelpers`(renderItemChildren·evaluateIfCondition)·`ConditionEvaluator`·
 `DataBindingEngine.resolveObject`·`DynamicRenderer` props 5곳에 단일 바인딩 파이프 분기 추가.
- 계획: `g7-scalable-lobster.md`(렌더 경로 비대칭 결함 일괄 수정).

## 공개 문서 내부 도구 귀속 제거

release 에 포함되는 공개 문서(`docs/**`)에서 내부 audit 룰 ID 귀속 서술을
도구 비귀속 표현("정적 검사")으로 정리. 재발 차단 룰 `public-no-internal-audit-reference` 신설.

전 계층 테스트(PHPUnit·Vitest·Playwright)·회귀 테스트 동반, 버전/CHANGELOG/활성 디렉토리 동기 완료.
2026-07-26 15:17:56 +09:00
정정홍andGitHub b3efcb8366 Merge pull request from gnuboard:HeuJung/issue485
fix(core,board,ecommerce,page): 중첩 리소스 스코프·계층 무결성 결함 12건 일괄 수정
2026-07-24 16:19:35 +09:00
HeuJung abb40ad210 fix(core,board,ecommerce,page): 중첩 리소스 스코프·계층 무결성 결함 12건 일괄 수정
중첩 라우트의 상위 리소스 ID 를 조회 스코프에 반영하지 않아 A 의 경로로 B 의
하위 리소스를 읽거나 변경할 수 있었다. 방어 위치를 Repository where 절(SSoT)로
통일하고 컨트롤러 사후 비교에 의존하지 않게 했다. 같은 리소스의 형제 엔드포인트가
서로 다른 강도로 검증하던 부분도 맞췄다.

계층 리소스는 자기 자신·자손을 부모로 지정하는 순환을 검증 계층에서 차단하고,
검증을 우회하는 경로(시더/훅/오염 데이터)를 위해 path 재귀에 방문 ID 가드를 뒀다.
설정값이 정하는 깊이 상한을 Service 가 리터럴로 재클램프하던 부분을 제거해
게시판 설정이 실제로 적용되게 했다.

쓰기 경로는 validated 기준으로 좁혀 FormRequest 미정의 필드가 fillable 로
새지 않게 했다. 기존 오염 데이터는 업그레이드 스텝이 정리한다(삭제 없이 최상위로 이동).

재발 방지로 audit 룰 3종을 추가하고, 규정 문서와 트러블슈팅 사례를 함께 갱신했다.

공개 이슈: gnuboard/g7
2026-07-24 16:17:44 +09:00
정정홍andGitHub 9dda22f8e4 Merge pull request from gnuboard:HeuJung/issue486
HeuJung/issue486
2026-07-23 22:31:51 +09:00
HeuJung cd19344f48 fix(installer): 에셋 서빙 "확인 불가" 상태에 상황 상세 안내 추가
설치 2단계 서버 요구사항 화면의 "에셋 파일 서빙 방식" 항목이 "확인 불가"
한 단어만 표시돼 어떤 상황인지 알 수 없었다. "확인 불가" 는 확장자 있는/없는
프로브 요청이 모두 실패한 상태로, 에셋 방식 문제가 아니라 앱 미응답·프록시·CSP
등 다른 원인일 수 있다.

- asset_url_mode_detected_unavailable(ko/en): 두 프로브 모두 실패라는 상황,
 에셋 방식 문제가 아닐 가능성, 설치를 막지 않는다는 점, 설치 후 백지면
 확장자 미사용으로 전환하라는 조치까지 담아 보강. 이 키는 Step 3 감지 버튼
 상태(data-msg-unavailable)와 공유되어 양쪽 화면이 함께 개선된다.
- refreshAssetUrlModeCard: 경고 상태(확장자 미사용/확인 불가)에 설명 문구를
 카드 아래 fix-guide-hint 로 렌더. 짧은 라벨만으로는 상황을 알 수 없던 문제 해소.
2026-07-23 22:23:42 +09:00
HeuJung 9bcd97098b fix(core): 끝 슬래시가 붙은 경로(/admin/)가 라우트에 매칭되지 않던 문제
라우트 패턴은 끝 슬래시 없는 형태(*/admin 등)로만 정의되는데(routes.json
규약), Router.match 가 들어온 경로의 끝 슬래시를 정규화하지 않아 /admin/
(끝 슬래시)이 어떤 라우트에도 매칭되지 않고 404 로 떨어졌다. 특히 미인증
상태로 /admin/ 진입 시 대시보드 → 로그인 리다이렉트 흐름 대신 404 가 노출됐다.

match 진입에 normalizePathname 을 두어 루트(/)를 제외한 경로의 끝
슬래시(연속 포함)를 제거한 뒤 대조한다. match 는 라우팅 단일 진입점이라
모든 호출부가 함께 정상화되고, 다운스트림(auth back-URL, getAuthType)은
원본 pathname 을 그대로 써 의미 변화가 없다.

기존 릴리스부터 있던 라우터 견고성 결함이며 자산 URL 이중 모드 변경과는
무관하다(matchPattern 은 미변경, routes.json 은 81건 정상 로드 확인).
2026-07-23 21:34:44 +09:00
HeuJung 65f1c70ffe refactor(admin-template): 에셋 파일 서빙 방식을 지역화에서 전용 카드로 분리
에셋 파일 서빙 방식은 언어·시간대와 무관한 인프라(서버 라우팅) 설정이라
지역화 카드에 묶이는 것이 의미상 어긋난다. tab_content_general 하위에
전용 카드(card_asset_serving)를 신설해 필드·자동 감지 버튼·인라인 안내를
이동하고, 라벨/설명을 카드 헤더로 승격해 다른 카드와 형태를 맞췄다.

배치 회귀 가드: 레이아웃 구조 테스트 + E2E 로 전용 카드에 항목이 있고
지역화 카드에는 없음을 단언.
2026-07-23 21:13:39 +09:00
HeuJung 05e81c6017 fix(admin-template): 자산 URL 감지 UI 버튼 일관성·인라인 결과·문구 정비
관리자 환경설정 > 일반의 에셋 파일 서빙 방식 필드를 브라우저 검수 지적대로 다듬었다.

- "자동 감지" 버튼이 btn 베이스 클래스 없이 btn-secondary 만 갖고 있어 다른 버튼과
 달리 평문처럼 보였다. btn 을 더해 동일한 박스 스타일로 맞췄다.
- 감지 결과를 토스트로 띄우면 사라져 확인이 어려웠다. 핸들러가 결과를 로컬 폼
 상태로 기록하고, 필드 하단에 상태별 인라인 안내로 상시 노출하도록 바꿨다.
- 라벨을 "에셋 파일 서빙 방식" 으로 바꾸고(전 화면·전 로케일), 확장자 가로채기
 안내에 원인이 되는 nginx location ~* \.(js|css|...)$ 정적 최적화 블록을 담아
 인프라 담당자가 바로 인지할 수 있게 했다. ko/en/ja 및 인스톨러까지 일관 반영.

레이아웃 test util 은 _local 조건부 렌더를 시드하지 못하므로(엔진은 로컬상태
provider 로 if 를 평가) 인라인 안내의 렌더는 레이아웃 구조 테스트 + 핸들러 단위 +
E2E 세 계층으로 커버했다.
2026-07-23 15:10:28 +09:00
HeuJung 95008ee2af chore(core,admin-template): 리베이스 후 산출물 재빌드 + 코어 테스트 정합
develop 리베이스 중 빌드 산출물을 매 커밋 develop 베이스라인으로 넘겼으므로,
병합된 소스 기준으로 core:build + template:build 를 1회 재실행해 정합화했다.
iife 에 자산 URL 감지 핸들러가, main.css safelist 에 drift_alert amber 클래스가
develop 상위집합과 함께 담겼다.

코어 스위트에서 드러난 실패 4종을 처리했다(직전 세션이 admin 스위트만 돌려 누락):

- assetUrlRecovery(L6): 프로브 판정이 probeAssetUrlMode 로 추출됐는데 테스트가
 옛 detectAssetUrlMode 앵커를 봤다. 앵커 재조준(코드 정상, 테스트 stale).
- TranslationEngine: suffixed 헬퍼 일원화로 쿼리 파라미터 순서가 바뀌었다(서버엔
 무관). 순서 무관 단언으로 정정.
- sitemap sampleData 미커버: editor-spec 의 byDataSourceId 에 sitemap_status/
 sitemap_progress_ws 샘플 추가.
- SectionHeader/ThemeToggle 캡: audit 룰 SSoT 엔 이미 비대상 등록됐으나 테스트
 병행 allowlist 만 어긋나 있었다. allowlist 동기화.

앞 두 건은 훼손→red 로 가드 유효성을 확인했다. 순서상 테스트가 읽는 소스만
바뀌었고 런타임 동작 변화는 없다.
2026-07-23 13:52:09 +09:00
HeuJung d6e1f98a4f fix(core): 확장 오토로드 캐시 공집합 덮어쓰기로 인한 전역 500 + 소스맵 노출 차단 복원
오토로드 캐시(psr4)가 공집합으로 덮어써져 모든 모듈/플러그인 컨트롤러가
해석 불가능해지고 확장 화면 전체가 500 이 됐다. 이 매핑은 "DB 설치 목록 ×
디스크 활성 디렉토리" 교집합이라 DB 쪽만 일시적으로 비어도(테이블 부재·
설치 중간 실패·testing DB) 정상 매핑이 통째로 소실된다. 코어 라우트는 200
인데 확장 라우트만 전부 500 인 비대칭이 이 결함의 진단 단서다.

- 디스크에 확장 디렉토리가 남아 있는데 매핑이 0건이면 기록하지 않고 보존.
 정상적인 0건(신규 설치·마지막 확장 삭제)은 디스크도 함께 비어 통과한다
- generateAutoloadFile 에 testing 실 캐시 경로 쓰기 차단. updateComposerAutoload
 에만 있던 가드를 생성기 자체로 옮겨 어느 경로로 들어와도 막히게 했다
- extension:update-autoload 가 훅 캐시 재생성 전에 런타임 오토로드를 재등록.
 미재등록 시 깨진 매핑으로 훅 캐시가 modules/plugins 0건으로 생성되어
 확장 훅이 조용히 사라지고, 복구에 커맨드 2회 실행이 필요했다

소스맵 서빙은 에서 차단했으나 이번 브랜치에서 화이트리스트에 재추가되어
운영에서 원본 코드 전문(sourcesContent)이 노출될 수 있었다. 이 화이트리스트가
확장 에셋 서빙의 유일한 방어선이다. dev 빌드의 sourceMappingURL 404 를 피하면서
운영을 막도록 local 환경에서만 허용하도록 분기하고, 산출물을 production 으로
재빌드해 docroot 에 남아 있던 소스맵을 제거했다. --production 빌드는 소스맵을
생성하지 않을 뿐 기존 파일을 지우지 않는다.

CoreUpdateCommandSpawnFailureTest 는 in-process fallback 이 실제 업그레이드
스텝을 돌려 코어 역할·권한·메뉴를 커밋하는데 RefreshDatabase 가 없어, 이후
모든 테스트 클래스가 중복 키로 깨졌다(단독 실행 시 미재현).
2026-07-23 13:04:59 +09:00
HeuJung 3044119b37 chore(core): app/Support Pint 사전 드리프트 정리
이번 이슈에서 손대지 않은 파일인데 Pint 실행 시 워킹트리에 떠올라,
HEAD 사본을 꺼내 pint --test 로 대조했다. 5개 파일 모두 fail 이며
fixer 목록이 워킹트리 diff 와 일치 — 이전부터 누적된 미적용분이다.

기능 커밋에 섞으면 diff 의 의미가 흐려지므로 분리했다.

FakerShim 의 16진 표기 변경은 UUID v4 비트 마스킹 결과에 영향이 없고,
bootstrap.php 의 use 임포트 추출은 ::class 가 완전한 FQN 으로 해석되므로
class_alias 대상이 달라지지 않는다. 이 파일은 autoload.files 진입점이라
전역 fake 에 닿는 자리여서 따로 확인했다.
2026-07-23 11:39:59 +09:00
HeuJung 327c433250 fix(core): 번들 인스턴스 조회 시 클래스 재선언으로 프로세스가 죽던 문제
`_bundled`(또는 `_pending`) 확장의 상세 정보를 읽는 두 메서드가 파일을 가드 없이
require 했다. 같은 확장의 활성 디렉토리 사본이 이미 로드돼 있으면 두 파일은 경로가 달라
중복 제거가 통하지 않으므로 같은 클래스명을 두 번 선언하게 되고, 그 자리에서 죽는다.

 Cannot declare class Modules\Sirsoft\Board\Module, because the name is already in use

특히 위험한 것은 클래스 재선언이 Exception 이 아니라 Error 라는 점이다. 두 메서드 모두
try/catch 로 감싸여 있어 안전해 보이지만 실제로는 잡히지 않아 프로세스가 통째로 종료된다.
운영에서는 해당 요청이 죽고, 테스트에서는 요약 줄도 없이 스위트가 중단된다.

같은 파일의 다른 로드 지점들은 이미 "이미 선언돼 있으면 임시 클래스명으로 로드" 패턴을
갖고 있었고 이 두 곳만 빠져 있었다. 기존 패턴을 그대로 적용하고, 놓치던 Error 까지 잡도록
catch 범위를 넓혔다.

회귀 테스트는 처음에 실제 번들 확장으로 짰다가 폐기했다 — 테스트 부팅이 번들 쪽을 먼저
로드하므로 같은 경로를 다시 읽는 셈이라 위험한 조합이 만들어지지 않았고, 가드를 지워도
통과했다. 활성/번들 두 사본을 갖는 임시 확장 루트를 만들어 조합을 직접 재현하고, 어느 쪽
파일을 읽었는지까지 단언하도록 다시 작성했다. 가드를 제거하면 예측한 메시지 그대로 죽는다.

이 경로가 앞서 관측된 스위트 중단과 같은 사건인지는 단정하지 않는다. 메시지와 메커니즘은
일치하지만 그 중단은 이후 재현되지 않아 수정 전후 비교가 불가능했다.

검증: 신규 2건 + 확장 매니저 회귀 120건 green, 인스톨러·업그레이드 통합 581건 green.
2026-07-23 11:39:59 +09:00
HeuJung 7e569efe4c test(core): 개발 환경 파일을 건드리는 테스트 격리
테스트가 개발자의 `.env` 를 삭제한 사고 이후 부트스트랩에 복원 안전망을 넣었지만,
그것은 프로세스 종료 시점 복원이라 같은 실행 안에서 뒤 테스트가 훼손된 파일을 읽는 구간이
남아 있었다. 복원이 아니라 애초에 건드리지 않는 구조로 바꾼다.

앱 루트를 임시 디렉토리로 돌리면 `base_path` 를 쓰는 제품 코드는 한 줄도 바뀌지 않은 채
격리된 파일만 다룬다. 공용 트레이트로 묶어 세 클래스가 같은 방식을 쓰게 했다.

격리 자체를 증명하기 위해 안전망을 임시로 끄고 돌렸다. 안전망 없이도 `.env` 가 바이트
단위로 그대로였다 — "green 이니 됐다" 로는 안전망이 뒤에서 복원해 준 것과 구분되지 않는다.

이 과정에서 격리로는 막을 수 없는 유형을 하나 더 찾았다. 별도 프로세스를 띄워 업그레이드
커맨드를 실행하는 테스트가 자식 쪽에서 `.env` 의 버전 값을 덮어쓰고 있었다. 부모의 앱 루트
전환도, 테스트 부트스트랩의 안전망도 자식에는 닿지 않는다. 테스트 환경 지정은 읽는 파일만
바꿀 뿐 쓰기 대상은 언제나 프로젝트 루트의 `.env` 다. 그 테스트가 검증하는 것은 자식이 방금
쓴 클래스를 읽어들이는지 뿐이므로, 커맨드가 이미 제공하는 부수효과 차단 옵션을 붙였다.

같은 구조의 문제가 업그레이드 스텝 임시 파일에도 있었다 — 실제 `upgrades/` 에 쓰고 정리를
tearDown 에 의존하므로 중단되면 개발 체크아웃에 정체불명의 스텝이 남는다. 실제로 두 번
발견해 지웠고, 부트스트랩 셧다운 훅이 그 패턴을 쓸어내도록 했다.

검증: 세 클래스 개별 green, 안전망을 끈 통합 실행 28건 green + `.env` 불변,
자식 프로세스 테스트 green, 전 구간 `.env` 해시 불변, audit 위반 0.

알려진 잔여 문제: 인스톨러와 업그레이드 스위트를 한 프로세스로 합쳐 돌리면 모듈 클래스
중복 선언 fatal 로 중단된다. 각각 따로 돌리면 297 / 301 건 모두 통과하며, 이번 변경 이전에도
같은 증상이었다. 별도 조사가 필요해 이번 범위에서 다루지 않았다.
2026-07-23 11:39:59 +09:00
HeuJung 072b3f50d9 feat(core,admin-template): 자산 URL 방식 감지 지점 보강 + 테스트 격리 결함 수정
계획서 §검증 후반부를 다시 대조해 미구현 3건을 확인하고 모두 구현했다. 앞선 커밋에서
"미구현 1건" 으로 보고한 것은 §12 불변식 표만 파일로 대조하고 §검증 섹션의 표 두 개는
확인하지 않은 결과였다.

- 설치 2단계 요구사항 화면에 자산 URL 방식 항목 추가. 판정은 서버가 아니라 브라우저가
 한다 — 서버에서 자기 APP_URL 로 요청하면 loopback 이 vhost·프록시 체인을 우회해
 실제 방문자와 다른 답을 낸다. 2단계와 3단계가 같은 판정 함수를 쓰도록 추출했다
- 대시보드가 스스로 프로브를 던져 **저장된** 방식과 대조하고 어긋나면 안내한다.
 런타임 값이 아니라 저장값을 보는 이유는, 자가 복구가 이미 전환해 놓았을 때가 바로
 알려야 할 상황이기 때문이다 — 봇은 자바스크립트를 실행하지 않아 자가 복구가 닿지 않는다.
 알리기만 하고 저장하지 않으므로 클라이언트가 서버 설정을 뒤집지 않는다
- 레이아웃 렌더링 테스트를 두 모드로 추가. 핵심 단언은 두 모드의 DOM 이 완전히 동일하다는
 것이다 — 모드가 화면 구조로 새어나가면 확장자 없는 환경에서만 깨지고 그 환경은 개발 중
 거의 재현되지 않는다

함께 고친 테스트 격리 결함 4건 (이번 기능과 무관하나 같은 세션에서 발견):

- 인스톨러 공유 클래스 로드가 오토로드를 막고 있어, 앱 루트를 임시 디렉토리로 바꾸는
 테스트에서 없는 파일을 읽으려다 죽었다 (8건 fatal)
- 테스트용 번역 스텁이 실제 구현과의 정의 경쟁에서 이기면 주입된 번역을 무시했다.
 클래스 단독 실행은 통과하고 스위트 실행만 실패하던 원인
- 모듈 활성 목록은 "디스크 로드분 ∩ DB 활성분" 인데 부팅 시 테이블이 비어 디스크 스캔이
 일어나지 않아, 파일 캐시 잔재가 있을 때만 통과하던 순서 의존 테스트
- 라우트 프로바이더 가드 테스트가 Git 미추적 파일(.env) 존재에 의존해 신규 클론·CI 에서
 실패했다. 임시 앱 루트로 격리

또한 일부 테스트가 실제 개발 환경 파일을 삭제·재생성하고 자신의 정리 단계에서 되돌리는데,
그 테스트가 죽으면 되돌아오지 않아 복구 불가능한 유실이 발생한다. 실제로 이번 세션에서
겪었으므로 테스트 부트스트랩에 프로세스 수준 복원 안전망을 추가했다.

검증: 백엔드 344건 / 템플릿 2799건 / 레이아웃 7건 / 브라우저 9건 green, audit 위반 0.
2026-07-23 11:39:57 +09:00
HeuJung 15cd4452bf feat(core): 정적 최적화 서버 대응 자산 URL 이중 모드
nginx 의 정규식 location 은 프리픽스 location 보다 먼저 매칭되므로,
`location ~* \.(js|css|json)$` 블록이 있는 서버에서는 확장자 붙은 동적
엔드포인트가 `try_files ... /index.php` 폴백이 실행될 기회 없이 404 가 된다.
aaPanel/CyberPanel/Plesk 기본 템플릿에 들어있어 드물지 않으며, 관리자 화면조차
뜨지 않아 "서버 설정을 고치세요" 안내가 순환 참조가 된다.

관례를 깬 쪽이 G7 이므로 해소 책임도 G7 에 두고, 두 형태를 모두 서빙한 뒤
환경에 따라 택일한다. 확장자 형태는 영구 유지한다 — 제거하면 URL 을 하드코딩한
서드파티 확장이 깨진다.

- 라우트: dualSuffix / dualSuffixSegment / dualAsset 매크로로 23개 엔드포인트와
 프로브를 이중 등록. 확장자 형태를 먼저 등록한다 — 확장자 없는 쪽이 더 느슨한
 패턴이라 순서가 뒤집히면 `.json` 요청까지 삼킨다
- URL 생성: 서버 App\Support\AssetUrl, 프론트 core/support/assetUrl.ts 로 집약.
 기본 모드에서 생성 결과는 치환 이전과 문자열까지 동일하다 (쿼리 순서 포함 —
 순서가 바뀌면 의미는 같아도 HTTP 캐시 키가 갈린다)
- 자가 복구: 부트스트랩 자산 로드 실패 시 확장자 없는 형태로 1회 단방향 전환.
 역방향 금지 + 기존 재시도 예산 공유로 무한 왕복을 막는다
- 감지·전환: 인스톨러 프로브(설치 시 확정) / 관리자 환경설정 일반 탭 /
 g7:asset-url-mode. 판정은 상태코드가 아니라 매직 토큰 + Content-Type 으로 한다.
 상태코드만 보면 "404 대신 200 + 에러 HTML" 환경에서 영원히 오판한다
- 봇은 JavaScript 를 실행하지 않아 자가 복구가 닿지 않으므로, 모드 변경 시
 SEO 프리렌더 캐시를 비워 재생성시킨다

가드: audit 룰 2종(dynamic-route-static-extension / asset-url-builder-required),
Playwright 6건(정적 블록 가로채기 시뮬레이션), 루프 방지 불변식 L1~L9 전수 red 증명.

부수 정리: 양 Composer 에 중복돼 있던 확장 에셋 수집 123줄을 트레이트로 통합하고,
자산 확장자 화이트리스트 누락(.map)과 경로 정규화 우회 가능성을 함께 교정.

sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.
2026-07-23 11:34:32 +09:00
정정홍andGitHub 74df8a4b09 Merge pull request from gnuboard:HeuJung/issue483
fix(core,board,ecommerce): 조건부 검증 결함 패밀리 일괄 수정 및 실패 안내 노출
2026-07-23 11:09:36 +09:00
HeuJung 7f8fb6870f fix(core,board,ecommerce): 조건부 검증 결함 패밀리 일괄 수정 및 실패 안내 노출
여러 화면에서 조건부(FormRequest) 검증이 우회되거나 과도하게 제한되던 결함을 한 계열로 묶어 수정한다.

- 검증 규칙 확장 필터 이름 10개를 나머지 92개와 동일 규칙으로 통일
- 설명 길이를 바이트가 아닌 글자 수로 계산(한글 오검출 해소)
- 비활성 언어팩 번역 보존한 채 수정 허용
- 알림 템플릿 제목 nullable 존중, 미지원 발송 수단 저장 차단
- 일정/실행 이력 목록 조회 검증 정합
- 게시판 첨부 미사용 시 빈 확장자 통과, new_display_hours/최소 길이 0 허용, 일괄 적용 override_values 범위 검증(SSoT)
- 이커머스 클레임 사유 조회 검증, 용어 정정
- 원문 i18n 키 노출 다수 해소, 저장/삭제 실패 시 사유 화면 노출
- 아이콘 크기/다크모드 대비 회귀 수정

번들 확장(모듈3·플러그인6·템플릿2·언어팩5) 버전 new-bump 및 CHANGELOG 반영, ja 언어팩 동기.
2026-07-23 10:54:17 +09:00
정정홍andGitHub c001b30a87 Merge pull request from gnuboard:HeuJung/issue484
fix(admin-template, installer): OPcache 비활성 경고 경각심 강화 및 배치 개선
2026-07-20 16:35:54 +09:00
HeuJung a3b25b2756 fix(admin-template, installer): OPcache 비활성 경고 경각심 강화 및 배치 개선
브라우저 실측에서 경고가 경고로 읽히지 않는 문제가 드러났다. 기능은 정상
동작했으나 문구가 밋밋하고, 인스톨러 카드가 통과 항목과 같은 색이며, 관리자
정보 탭에서는 경고가 카드 사이에 끼어 있었다.

문구는 영향의 크기를 구체화했다 — 페이지를 열 때마다 모든 PHP 코드를 다시
해석하며 응답이 몇 배까지 느려진다는 점을 인스톨러와 관리자 양쪽에 같은 기준으로
반영했다.

인스톨러는 상태를 아이콘에만 반영하고 있어 목록을 훑을 때 문제 항목이 눈에
들어오지 않았다. 카드 자체에 상태 수식자를 부여했다. 경고만 고치면 필수 항목
실패 카드도 통과 카드와 똑같이 보이는 같은 결함이 남으므로 fail 도 함께 다뤘다.

관리자 정보 탭에서는 경고가 2단 카드 그리드의 세 번째 셀로 들어가 있었다.
탭 최상단으로 옮기고, 여백은 호출처가 아니라 자산이 책임지는 기존 컨벤션을
따라 alert 인접 규칙에 카드 그리드를 추가했다. 배치는 눈으로만 보면 회귀를
알 수 없으므로 좌표와 직계 자식 여부를 E2E 로 고정했다.
2026-07-20 16:15:24 +09:00
HeuJung cbf8ce1555 fix(admin-template): 사이트맵 상태 wait_for 누락 + stale 레이아웃 테스트 5건 교정
작업 중 전체 Vitest 스위트에서 사전 결함 6건을 발견해 같은 세션에서 종결한다.

SEO 탭의 sitemap_status 가 transition_overlay.wait_for 에 빠져 있어, 오버레이가
먼저 걷히며 진행상황 블록이 빈 상태로 잠깐 노출될 수 있었다. 구조가 동일한
policies 는 등록돼 있어 설계 의도가 아닌 누락으로 판정했다.

나머지 5건은 로직이 아니라 테스트가 낡은 것이었다. 사이드바 테스트는 프로필 UI
부재라는 의도 대신 컨테이너 id 부재로 단언해 이후 재도입된 크로스 이동 링크에
걸렸고, 대시보드 테스트는 iteration id 보간 도입 후에도 정확 일치로만 노드를
찾고 있었다. 각각 의도에 맞게 교정하고 반대 방향 회귀 가드를 보강했다.
2026-07-20 14:39:13 +09:00
HeuJung 062a88ca04 feat(core): DB 최고권한 계정 차단 + OPcache 상태 안내
설치 단계와 코어 부팅의 정책이 엇갈려 있었다. 인스톨러는 root 를 자유롭게
허용하면서, 설치 후 부팅은 같은 값을 보고 확장 로딩 전체를 조용히 스킵했다.
설치는 성공하지만 이후 깨진 화면만 뜨고 원인은 로그 한 줄에만 남았다.

판정을 프레임워크 의존성 0 인 공용 SSoT 로 모아 인스톨러와 코어가 같은 답을
내게 하고, 설치 단계에서 서버 2경로로 차단하며, 설치 후 변경은 전용 에러
페이지로 드러낸다. 가드를 boot 가 아닌 미들웨어에 두어 artisan 이 죽지 않게
해 복구 수단을 남겼다. admin 은 일반 권한 운영 계정 오차단을 막기 위해 차단
목록에서 제외했다.

OPcache 는 상태 조회 경로 자체가 없어 성능 저하를 인지할 방법이 없었다.
설치 단계 경고(비차단)와 관리자 정보 탭 표시를 같은 판정 SSoT 로 추가했다.
확장 로드 여부만으로는 부족해 opcache.enable 지시자까지 확인하며, ini_get 이
차단된 환경은 "확인 불가"로 폴백해 경고도 차단도 하지 않는다.
2026-07-20 14:17:49 +09:00
정정홍andGitHub 91ec5f2d1f Merge pull request from gnuboard:HeuJung/issue481
HeuJung/issue481
2026-07-19 23:29:11 +09:00
HeuJung 0cdb97862f fix(ecommerce): 장바구니 담기·바로구매 옵션 식별을 product_option_id 기반으로 전환
담기/바로구매가 메인 옵션을 로케일 값 조합(option_values)으로 전송하고 서버가
문자열 동등비교로 역매칭하던 방식은, 클라·서버 로케일 불일치나 관리자의 옵션 값
텍스트 수정 시 매칭 실패를 유발했다. 클라이언트가 이미 보유한 옵션 ID(optionId)를
전송하고, 서버는 요청 상품의 옵션 집합 내에서 ID로만 조회하도록 전환한다.
추가옵션(value_id/custom_text)은 이미 ID 기반이라 변경 없음.
2026-07-19 23:20:18 +09:00
HeuJung 8522245137 fix(ecommerce): 관리자 카테고리 이미지 다운로드 전체 메모리 적재 제거
관리자 카테고리 이미지 다운로드가 streamDownload 콜백 안에서
Storage::get 으로 파일 전체를 메모리에 적재한 뒤 echo 하던 안티패턴을,
공개 서빙과 동일한 CategoryImageService::download
(StorageInterface::response 스트리밍) 위임으로 교체한다.

서빙 계층은 이미 종단간 스트리밍이며 이 지점이 유일한 전체 적재 경로였다.
규정(storage-driver.md)에 서빙 안티패턴을 명시하고 audit coverage 에 등재한다.
2026-07-19 21:09:22 +09:00
HeuJung 707919719c chore(build): 번들 확장 소스맵 산출물 제거 (--production 재빌드)
결제 플러그인 3종(pay_kginicis/nhnkcp/nicepayments)과 sirsoft-basic 템플릿의
배포 JS 에 //# sourceMappingURL 주석과 .map 파일이 남아 있어, 공개 배포판에
원본 TS/TSX 전문(sourcesContent)이 노출될 수 있었다(NoSourcemapArtifactsTest 2건 실패).

각 확장을 --production(G7_BUILD_SOURCEMAP=0) 으로 재빌드해 소스맵 주석·.map 을
제거하고 {plugin|template}:update 로 활성 디렉토리에 반영. 기능 코드는 바이트 동일,
소스맵 주석만 제거된다. nhnkcp/nicepayments 의 components.json 파생 버전도 매니페스트
SSoT(1.0.0)로 재동기화. 매니페스트 버전은 무변경(동작 변화 없는 배포본 위생 수정).

NoSourcemapArtifactsTest 4/4 green.
2026-07-19 20:23:00 +09:00
HeuJung 8fda5d31e1 feat(seo): changefreq Enum 도입 + 사이트맵 사전결함 테스트 정정
계획서 위임 항목(Q6·Q7·Q8) 마감.

Q8: sitemaps.org 폐쇄 어휘를 App\Enums\SitemapChangeFreq 로 도입해
저장(SitemapIndexer)·렌더(SitemapXmlRenderer) 두 경계에서 normalize 하고,
생산 지점(생성기 정적 URL·3 contributor·4 리스너) 리터럴을 enum case 로 치환.
규격에 없는 changefreq 가 사이트맵에 그대로 출력되던 것을 차단(대소문자·공백 흡수).

Q6: Upgrade_7_0_0_beta_3Test 의 "동결 TARGETS === 살아있는 config" 단언은
동결 규정과 충돌하는 무효 불변식(config 는 에서 정당하게 진화)이므로
의도적 divergence 를 검증하도록 재작성.

Q7: 코어 20건 사전 실패를 격리 실측으로 재분류 — 6클러스터는 전 스위트
OOM/싱글톤 누수 오염(격리 시 통과), GenericNotificationViaTest 는 readiness
게이트를 mock 하지 않아 빈 settings 로 mail 이 항상 제외되던 stale test 를 정정.
2026-07-19 20:13:05 +09:00
HeuJung 124433148c feat(seo): 사이트맵 재생성 알림·폴링 폴백 + 코어 7.0.6 확정
이슈 후속(S7) 마감 + 릴리스 버전 확정.

기능(S7):
- 사이트맵 전체 재생성 완료/실패 시 실행 관리자에게 알림(수동 실행 한정).
 스케줄러·증분·봇 재생성은 알림 없음(triggered_by 부재 시 skip).
- 최종 실패 전용 알림 + 재생성 전용 큐 + WS 진행상황 payload 봉투 정정.
- Reverb OFF 폴링 폴백 경로 수정(실브라우저 폴링에서만 드러난 결함).

버전(D28 — 정정, D20 번복):
- 릴리스를 7.0.5→7.0.6(patch)로 확정. 7.1.0(minor)은 다른 브랜치에서 별도 진행.
- config/app.php + .env + README + INSTALL + CHANGELOG + 모듈 3 requires 를 7.0.6 로.
- D19 SEO 캐시 이행 업그레이드 스텝을 Upgrade_7_0_6 로 리네임.
 이유: 스텝 버전 게이팅이 version<=toVersion 이라, 7.1.0 이름이면
 7.0.5→7.0.6 업그레이드에서 이행이 실행되지 않는다.

검증: composer test-smoke OK(2·29) · 재생성 알림/업그레이드 이행/진행상황 테스트 green ·
version-sync/version-bump(new-bump)/extension-require green · audit 0 error.
2026-07-19 18:57:51 +09:00
HeuJung 3dd34104cc feat(seo): 사이트맵 대용량 하네스 + 코어 7.1.0 마감
S6(하네스 + 마감): 큐/생성기의 볼륨 붕괴 재발을 정적으로 강제하고,
S1~S5 사이트맵 개편의 공개 표면을 문서화하며 코어를 7.1.0 으로 확정한다.

하네스(주안점 5):
- test-scenario 매니페스트에 scale 축(n≥100000 + assert) 도입.
 내장 YAML fallback 파서 제약으로 block-array of flow objects 형태.
- 룰 2종: scenario-scale-axis-required(batch 매니페스트 scale 강제),
 job-generator-needs-scale-test(Jobs·*Generator 변경 시 scale 시나리오 요구).
 *Command 은 볼륨 비의존이라 제외 — tags:[batch] opt-in 으로 커버.
- tests/scenarios/sitemap-generation.yaml + @scale 마킹 3(Writer/Progress/ManagerMode).

문서: seo-system(증분 저장소·모드·진행상황·lazy contributor)·testing-guide(scale 절)·
broadcasting(sitemap 채널)·hooks(2훅)·database-guide(loc_hash)·cheatsheet.

버전: 코어 7.0.5→7.1.0(config+env+README+INSTALL+CHANGELOG) +
모듈 board/ecommerce/page requires.g7_version >=7.1.0.
2026-07-18 20:56:47 +09:00
HeuJung face022073 feat(seo): 사이트맵 재생성 진행상황 + 실시간/폴링 UI
관리자 "전체 재생성"을 큐(Full 모드)로 던지고 진행상황을 SEO 탭에서 확인한다.
Reverb ON 이면 방송 채널 구독으로 실시간 갱신, OFF 면 상태 API 폴링으로 폴백한다.

- SitemapProgress 진행상황 스토어(queued→running→writing→completed/failed,
 캐시 TTL 3600, 방송 스로틀) + SitemapManager.getStatus 확장(progress + realtime_enabled)
- GET /api/admin/seo/sitemap/status(core.settings.read) 신규
- 관리자 재생성을 항상 Full 모드로 큐잉 + 진행률 기록, Job 최종 실패 시 progress=failed 고정
- core.admin.seo.sitemap 방송 채널 인증(core.settings.read)
- SEO 탭 진행상황 UI(상태 배지/단계/누적 URL/실시간·폴링 안내) + 폴링 분기
- 재생성 중 누적 URL 표기(replaceAllForContributor 삽입 건수 반환 — count 쿼리 없이 스트림 누적)
- ja 언어팩 진행상황 UI 번역 동기화
2026-07-18 18:22:16 +09:00
HeuJung 1629684156 feat(seo): 사이트맵 증분 저장소 + 리소스 리스너 재배선
사이트맵을 리소스 단위로 영속화(sitemap_urls)해 진정한 증분 갱신과
도메인 재쿼리 없는 파일 재작성을 구현했다. 리소스 공개/비공개/삭제 시
해당 항목만 색인이 갱신되고, 관리자=full·스케줄러=auto·리소스 변경=증분으로 동작한다.

- sitemap_urls 마이그레이션 + SitemapUrl 모델. loc(2048) 를 unique 에 직접 넣으면
 InnoDB 키 길이를 초과하므로 loc_hash(sha256) 컬럼을 identity 로 사용
- SitemapUrlRepository(멱등 upsert / remove / 스트리밍) + SitemapIndexer
 (+ 제3자용 sitemap.index.collect_for_resource 필터 훅)
- 4 리스너 재배선: 공개→색인 / 비공개·삭제→색인 해제 + 재생성 잡 디바운스 디스패치.
 3 기여자가 resource_type/resource_id 를 함께 emit 해 전체·증분 identity 를 일치
- 재생성 모드를 SitemapGenerationMode Backed Enum 으로 통일(Command→Job→Manager)
- seo:generate-sitemap 에 --rebuild / --mode 옵션 추가
2026-07-18 16:19:49 +09:00
HeuJung 1a6fca0d6b feat(seo): 사이트맵 기여자 지연 스트리밍 + hreflang 계산 선형화
AbstractSitemapContributor 브리지 base 로 인터페이스를 바꾸지 않고
board/ecommerce/page 기여자를 getUrlsLazy 지연 스트리밍으로 전환해,
게시글이 수백만 건인 사이트에서도 URL 배열을 통째로 적재하지 않는다.
SitemapGenerator 는 capability 를 감지해 지연 경로를 쓰고, base 를
상속하지 않은 제3자 구현체는 기존 getUrls 경로로 그대로 동작한다.

hreflang alternate 집합을 base URL 당 한 번만 계산해 로케일별 <url>
에 재사용하도록 바꿔 계산 비용을 로케일 수의 제곱에서 선형으로 줄였다
(정상 구성 출력은 불변). 다국어 대체 링크 사용 여부 토글과 로케일 수
상한을 추가하고 설정/검증/관리자 UI/다국어(ko·en·ja) 표면을 동반했다.
2026-07-18 15:05:56 +09:00
HeuJung 1c99547bc1 feat(seo): 사이트맵 안전 서빙·잡 견고화·분할 설정 + 캐시 설정 정합
봇 요청 스레드의 동기 생성을 제거하고 디스크 스트리밍 서빙으로 전환했다.
신선도가 만료되면 생성 잡만 예약하고 기존 세트(stale) 또는 503+Retry-After 로
응답해, 대용량에서 봇 요청이 OOM/타임아웃을 유발하던 경로를 끊었다.

- 잡: ShouldBeUnique(고정 uniqueId)+uniqueFor+retryUntil, timeout 300→1800, backoff
- 컨트롤러: 인덱스 stale/503 분기 + 자식 라우트(.xml/.xml.gz 동일 액션)
- 스케줄 withoutOverlapping, 관리자 재생성 큐 이관
- 설정 4키(분할 기준·압축·stale 서빙·수집기 상한) + 고급탭 Sitemap 캐시 짝
- SitemapManager 응답 메시지 다국어화

설정 저장 테스트가 실제 설정 파일(사이트명/언어 포함)을 덮어쓰던 사전결함을
TestCase 전역 격리로 차단하고, 두 탭이 같은 다국어 키를 공유해 한쪽에 틀린
한도를 안내하던 사전결함을 키 분리로 해소했다.
2026-07-18 11:32:11 +09:00
HeuJung 21f666e4b5 feat(seo): 사이트맵 스트리밍 생성 + SEO 캐시 설정 정합화
대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의
1단계로, 전체 URL을 메모리에 적재하던 구조를 스트리밍 분할 생성으로 교체한다.

사이트맵 스트리밍 코어:
- SitemapWriter — 자식 파일 1개 분량만 버퍼링하고 임계 도달 시 flush.
 StorageInterface가 append를 제공하지 않아(put이 전체 덮어쓰기) 택한 구조이며,
 최대 메모리가 "자식 파일 1개 크기"로 유계가 된다. URL 수(50,000)와
 바이트(45MB) 두 임계로 분할해 sitemaps.org 프로토콜을 지킨다.
- 커밋은 _tmp 전량 기록 → live 재배치 → manifest 마지막 기록 순. manifest 존재가
 커밋 완료 신호다. 기존 manifest를 먼저 지우지 않아 스왑 중 봇에게 503이 나가지 않는다.
- SitemapFileStore — 읽기측 + 파일 경로 SSoT. SitemapXmlRenderer — escape 단일 출처.

데이터 접근 계층 정리:
- contributor 3종이 쿼리를 직접 조립하던 것을 Repository 위임으로 전환.
 MySQL 버퍼드 쿼리에서 cursor는 결과셋 전체를 적재하므로 lazyById를 쓴다.
- SeoCacheStatsService → SeoCacheStatRepository 신설 (집계 4쿼리 → 1쿼리).
- audit 룰 service-direct-data-access의 사각 2건 수정 — 확장 디렉토리 정규식이
 상대경로를 매칭하지 못해 *Contributor처럼 접미사 없는 클래스가 전부 미검출이었다.

SEO 캐시 설정 정합화:
- SEO 설정의 캐시 3키가 cache 카테고리 형제 키에 항상 져서 운영자 입력이 조용히
 무시되고 있었다(설정을 cache 카테고리로 이관하며 SEO 탭 옛 칸을 미제거).
 고급 탭을 기준값으로 두되 SEO 탭에 지정이 있으면 오버라이드하도록 SeoCacheSettings
 단일 출처로 정리. "지정 여부"는 null로 판정하므로 기본값을 비웠다.
- 기존 설치는 Upgrade_7_1_0이 이행 — 옛 기본값과 같으면 미설정으로 비우고, 다르면
 운영자 의도로 보아 보존한다.

코어 버전 bump와 루트 CHANGELOG는 계획서 결정(D20)에 따라 마감 단계에서 일괄 수행한다.
2026-07-17 19:59:40 +09:00
HeuJung fcaacad8d1 Merge branch 'develop' 2026-07-16 16:04:10 +09:00
HeuJung 8ca5e34a53 docs(readme): 나이스페이먼츠·NHN KCP 결제 플러그인 및 언어팩 번들 등재 반영
정식 1.0.0 번들에 편입된 결제 플러그인 2종과 대응 일본어 언어팩 2종을
기본 제공 확장 표에 추가하고, 번들 ja 언어팩 개수를 14종으로 갱신.
2026-07-16 16:03:52 +09:00
HeuJung d5065f1bc9 Merge branch 'develop' 2026-07-16 15:33:15 +09:00
HeuJung 8828c80b56 chore(changelog): 7.0.5 사이클 코어·확장·언어팩 출시일 2026-07-16 통일
코어 7.0.5 릴리즈와 함께 버전이 오른 확장·언어팩의 CHANGELOG 출시 날짜를 오늘(2026-07-16)로 맞춤.

- 코어 7.0.5, ecommerce 1.0.4, gdpr 1.0.1, pay_kginicis 1.0.1, basic 1.0.3 출시일 통일
- core-ja 1.0.4, ecommerce-ja 1.0.4 출시일 통일
- pay_kginicis-ja: [Unreleased] 항목을 1.0.1 로 릴리즈 (manifest version 1.0.0 → 1.0.1 bump)
- pay_nhnkcp-ja / pay_nicepayments-ja 1.0.1 누락 날짜 명시
2026-07-16 15:32:29 +09:00
HeuJung a45e233b97 Merge branch 'develop' 2026-07-16 15:11:11 +09:00
HeuJung 7b7a175c07 chore(payment): 나이스페이먼츠·NHN KCP 결제 플러그인 정식 1.0.0 승격
두 결제 플러그인을 1.0.0-beta.4 에서 정식 1.0.0 으로 올린다.
공개 release latest(beta.3) 대비 프리릴리즈 → 정식 승격으로
버전 bump 규정에 정합(new-bump). manifest/package/composer/lock
4개 파일 버전 동기화 + CHANGELOG [Unreleased] 를 1.0.0 으로 확정
(nicepayments 는 중복 Fixed 챕터 병합). g7_version(>=7.0.5) 은
코드가 7.0.5 도입 표면(미들웨어 self-gate·애플페이 iOS 컨텍스트)에
의존하므로 그대로 유지.
2026-07-16 15:00:49 +09:00
정정홍andGitHub a7340f21ca Merge pull request from gnuboard:HeuJung/issue479
HeuJung/issue479
2026-07-16 14:38:10 +09:00
HeuJung f157ed1cc9 fix(core-update): sudo 재생성 시 public/storage 링크 소유자가 root 로 바뀌는 문제
sudo 로 실행된 종료 시점 복구가 symlink 로 public/storage 를 재생성하면
소유권 인자가 없어 링크가 root:root 로 남아, 원래 앱 실행 유저 소유였던
링크의 소유권이 바뀌었다. 소유권 복원(restoreOwnership) 이후 재생성되어
복원 대상에서도 빠졌다.

StorageLinkHelper::createLink 가 symlink 성공 직후 부모 public/ 의
owner/group 을 lchown/lchgrp(링크 자체 대상 — chown 은 target 을 따라감)로
상속시켜, 이 프로젝트의 "신규 항목은 부모 소유권 상속" 컨벤션과 일치시킨다.
비-POSIX/함수 부재/권한 부족 시 무해하게 skip.
2026-07-16 14:28:34 +09:00
HeuJung 0a6ff23132 fix(core-update): --prune 시 public/storage symlink 삭제로 업로드 파일 404 되는 문제
--prune 은 릴리즈 소스에 없는 public/storage 런타임 symlink 를 orphan 으로
삭제해 업로드 파일이 404 되었다. 심층 방어 2층으로 차단한다.

- 층1(예방): FilePermissionHelper 에 preserveLinkPaths 화이트리스트 추가.
 applyUpdate 가 public 타깃에 ['storage'] 전달 → 매칭 orphan 링크/junction 만
 삭제 제외 (정밀 보호)
- 층2(복구): StorageLinkHelper::ensurePublicStorageLink 신규 정적 헬퍼.
 CoreUpdateCommand 종료 시점(정상+핸드오프) 호출로 부재/손상 링크 멱등 재생성
 (Windows junction 폴백). beta.5 migration 05 는 이 헬퍼로 위임(상위호환)
2026-07-16 12:42:54 +09:00
정정홍andGitHub 9251af5c6a Merge pull request from gnuboard:HeuJung/issue477
fix(seo): 봇 SEO HTML의 코어 app.css 404 참조 제거 (, )
2026-07-16 11:24:19 +09:00
HeuJung 7598b76498 fix(seo): 봇 SEO HTML의 코어 app.css 404 참조 제거 (, )
SeoRenderer.getCssPath가 구 Vite manifest 구조만 파싱해 존재하지 않는 고정 경로 /build/assets/app.css 를 반환, 봇 렌더링 HTML(seo.blade)에서 CSS 404가 발생했다. 해당 파일은 실제 사용자 화면에서 미참조하는 Laravel 기본 빌드 산출물이므로, 담당자 답변 방향대로 참조 자체와 getCssPath 메서드를 제거했다. 봇 스타일은 템플릿 CSS($stylesheets)가 그대로 담당한다.
2026-07-16 11:16:55 +09:00
HeuJung c0e0d82459 refactor(extension): 확장 미들웨어 선언적 self-gate 정규화
확장이 SP Kernel 미들웨어 그룹을 직접 조작하거나 라우트 파일에 미들웨어
FQCN 을 직접 부착하던 임시 방식을, 확장이 부착 대상(targets)을 명시 선언하고
코어가 요청 시점에 라우트명·URI 로 매칭해 실행하는 self-gate 로 정규화.
번들 7건(ecommerce/gdpr/pay 3종) 이전, audit 룰 3건·문서·버전 동기화 동반.

부수적으로 generate-skills.cjs 가 재생성 시 신의성실(부수의무) 섹션을 소실
시키던 회귀를 수정 — 스킬별 불릿을 스크립트 SSoT 로 편입해 재생성에도 보존.
2026-07-16 10:22:51 +09:00
HeuJung 8b9bfd0c91 refactor(ecommerce,pay): 간편결제 브랜드 마크 카탈로그 편입 + 애플페이 iOS 게이팅 이관
3사 PG 플러그인의 체크아웃 DOM 인젝터가 렌더 후 브랜드 마크를 주입하고
injector끼리 같은 버튼을 두고 경합하던 구조를 제거한다. 시각정보(색 배지/
SVG 로고)를 결제수단 카탈로그(_cached_brand_mark)로 편입해 코어 체크아웃
레이아웃의 신규 BrandMark 컴포넌트가 데이터 기반으로 직접 렌더한다.
reconcileKginicisPatchedDuplicates(중복 방어)도 함께 제거했다.

애플페이 iOS 게이팅도 injector 밖으로 이관: 이커머스 모듈 DetectDevice
미들웨어가 UA로 기기를 판정해 코어 core.frontend.filter_app_config 훅으로
appConfig.isIos를 주입하고(1차), 템플릿 부트스트랩이 iPadOS 데스크탑 UA를
클라 신호로 보정한다(2차). 코어는 훅만 제공하고 감지 로직은 모듈이 소유한다.

미들웨어는 Router::pushMiddlewareToGroup 이 아닌 HTTP Kernel::appendMiddlewareToGroup
으로 web 그룹에 등록한다(전자는 Router 레지스트리만 갱신해 SSR 셸 요청 파이프라인에
미반영 → isIos 항상 false). sirsoft-gdpr 선례와 동일.

코어 최소 요구 버전 7.0.5 상향(appConfig 필터 훅 도입).
2026-07-15 17:50:39 +09:00
HeuJung e896a47d4f feat(ecommerce,pay): 확장 결제수단(간편결제) 1급화
PG 플러그인이 등록하는 간편결제(네이버페이 등)를 코어가 정식 결제수단으로
인식하도록 결제수단 카탈로그를 SSoT 로 전환했다. 확장 결제수단이
pg_provider=null 로 등록돼 서버가 "PG 결제가 아닌 주문"으로 오인, 결제 실패
주문에 관리자 신규주문 알림이 발송되고 TempOrder 가 즉시 삭제돼 재결제가
막히던 두 결함을 하나의 원인(능력 해석을 enum 이 답하지 못함)으로 근본 해결.

- PaymentMethodResolver 신규: 능력(PG 필요/라벨/환불수단/PG 고정) 해석을
 카탈로그 SSoT 로 통일, enum case 없는 확장 ID 도 동일 판정
- 프론트 결제수단 위장(card) 제거, provider-agnostic 결제 진입(pg_payment_handler)
- 관리자 주문설정 결제수단 목록 PG 고정 3분기 UI (pg_locked/needs_pg)
- 저장된 pg_provider=null 자가치유(read-time merge) + 백필 업그레이드 스텝
- 사용자 주문완료 화면 즉시 결제완료 헤더를 입금대기 여집합으로 판정(basic 1.0.3)
- 전 계층 회귀 테스트: 훅 발화 스파이(관리자 알림 미발화/PG 환불 훅 진입),
 주문 응답 계약(requires_pg_payment/pg_payment_handler)+TempOrder 보존,
 관리자 3분기 렌더, 확장 E2E 인프라
2026-07-15 11:23:51 +09:00
HeuJung 10fe28a16d Revert "공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거"
This reverts commit f2ecaefa6b5315904d18fe94a062e04af3a023e8.
2026-07-14 17:46:37 +09:00
HeuJung 00ef91bda5 Merge branch 'develop' 2026-07-14 15:56:07 +09:00
HeuJung 8fcb79d60f docs(changelog): 공개 대상 아닌 나이스페이먼츠 플러그인 수정 항목 제거
7.0.4 에서 나이스페이먼츠 결제 플러그인을 번들에서 제외하므로,
해당 플러그인의 결함 수정 항목은 코어 릴리즈 노트에서 제거한다.
2026-07-14 15:54:46 +09:00
HeuJung 79b83626eb Merge branch 'develop' 2026-07-14 15:16:41 +09:00
HeuJung 29c25376bc 공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거 2026-07-14 15:09:24 +09:00
HeuJung 512869e407 docs(pay_nhnkcp): 공개 배포 대상 주석에서 내부 역할 호칭 제거
RestrictKcpIp 의 테스트 모드 설명 주석에 내부 역할 호칭이 남아 있어
공개 release 누출 검사에서 경고로 검출되었다. 의미가 동일한 일반 표현으로
정정해 공개 저장소 발행 시 내부 단서가 실리지 않도록 한다.
2026-07-14 15:05:04 +09:00
정정홍andGitHub ab4f05c1c0 Merge pull request from gnuboard:HeuJung/issue473
fix(security): 스케줄러 Shell/Artisan 명령 실행 하드닝
2026-07-14 14:55:57 +09:00
HeuJung ce8e55b390 fix(security): 스케줄러 Shell/Artisan 명령 실행 하드닝
미출시 예약 작업 기능에서 스케줄 권한만 위임받은 계정이 Shell/Artisan
명령으로 임의 코드를 실행할 수 있던 문제를 선제적으로 차단(KVE-2026-1567).
Shell 은 기본 차단 + 실행 파일 화이트리스트 + 셸 미경유 인자배열 실행,
Artisan 은 차단목록. 저장 시점(FormRequest)과 실행 시점(Service) 이중 검증.

아울러 공개 release 보다 앞선 미출시 버전들의 CHANGELOG 출시 날짜를
2026-07-14 로 통일.
2026-07-14 14:48:33 +09:00
정정홍andGitHub 4c73db2e91 Merge pull request from gnuboard:HeuJung/issue471
HeuJung/issue471
2026-07-14 13:50:08 +09:00
HeuJung 47a2e815aa test(security): 활성 확장 디렉토리 소스맵 잔존 회귀 가드 추가
브라우저 실측(제기자 관점 재검증) 중 발견: 기존 가드 3종이 모두 git 추적
파일만 검사해, 실제 서빙 대상인 활성 확장 디렉토리(gitignore 대상)를 통째로
놓치고 있었다. _bundled 를 --production 으로 재빌드해도 {type}:update 를
돌리지 않은 확장은 활성 디렉토리에 예전 .map 과 sourceMappingURL 을 그대로
안고 있었고(6건), 그 상태로 테스트는 전부 green 이었다.

- 활성 디렉토리(templates|modules|plugins)/{id}/dist/js/*.map 를 glob 로 전수 검사
- 배포본 포함 여부는 _bundled 원본 존재로 판정. DB 레코드로 판정하면 테스트 DB 에
 확장 행이 0건이라 전부 skip 되어 영구 green 이 된다(실제로 이 함정에 빠져
 .map 을 심어도 통과했다)
- 가드 실효성은 설치된 확장에 .map 을 심어 red 확인 후 제거해 green 복귀로 실증

서빙 계층이 .map 을 거부하므로 원본 유출로는 이어지지 않았으나(브라우저 실측:
모든 .map 요청이 non-JSON, 원본 TS 4종 전부 404), 배포본 위생상 잔존해서는 안 된다.
2026-07-14 13:13:42 +09:00
HeuJung 90f154ecc0 fix(security): 배포본 소스맵 노출 차단 + 관리자 설정 탭 지연로딩 + 동반 결함 수정
## 소스맵 노출 차단 (공개 이슈 )

배포본에 .map 이 포함돼 원본 TS/TSX 전문(sourcesContent, 한글 주석 포함)이
그대로 노출됐다. 생성·서빙·저장소 유입 3계층을 모두 막는다.

- 빌드 config 15곳(코어 3 + _bundled 12): sourcemap 하드코딩 제거.
 G7_BUILD_SOURCEMAP 판정식으로 교체 — 미설정(로컬 npm run build)이면 생성해
 개발 디버깅 경험을 보존하고, 0 이면 미생성.
- Build{Core,Module,Plugin,Template}Command: 죽어 있던 --production 플래그 복구.
 $productionMode 를 콘솔 문자열에만 쓰고 npm 프로세스에 env 를 넘기지 않아
 산출물이 개발 빌드와 동일했다. runNpmCommand 에 $env 를 추가해 주입
 (--watch 에는 미주입). Symfony Process 는 부모 환경에 병합하므로 PATH 유실 없음.
- Allowed{Module,Plugin}FileType / {Module,Plugin}Service::getMimeType:
 허용 확장자 map 제거 — 익명 200 서빙 차단(템플릿은 원래 미허용, 3종 정책 일치).
- ExtensionBundleService: CSS 번들의 /*# sourceMappingURL=... */ strip 추가.
 CSS 는 블록 주석이라 JS 의 //# 패턴으로는 검출되지 않던 구멍.
- 추적 .map 13개 삭제 + .gitignore 전역 *.map 규칙(경로 한정 시 새 확장에서 누락).

실측: 산출 JS 의 sourceMappingURL 13 → 0, 추적 .map 13 → 0,
확장 디렉토리 npm run build 는 여전히 map 생성(개발 경험 회귀 없음).

## 관리자 환경설정 탭 지연 로딩

코어/이커머스/게시판 환경설정 화면이 진입 즉시 전 탭의 데이터소스를 한꺼번에
호출했다. 각 데이터소스에 활성 탭 조건(if)을 걸어 해당 탭에서만 fetch 하도록
변경하고, 결제 플러그인 3종의 테스트모드 상태 조회도 주문설정 탭으로 한정.

## 동반 결함 수정 (무관 결함 — 재발 방지 목적으로 같은 세션 처리)

- 나이스페이먼츠: 체크아웃 캐시 프리페치가 라우트를 가리지 않아 플러그인 번들이
 로드되는 모든 페이지(홈/게시판/로그인)에서 주문서를 조회 → 비회원 매번 422.
 형제 플러그인(kginicis)의 기존 가드 패턴을 적용해 체크아웃 경로로 한정.
- admin smoke 8건: 대부분 구현이 아니라 테스트 결함이었다 — URL 오타(단수 board),
 존재하지 않는 권한키, 앱 로케일이 en 인데 한국어 라벨 로케이터, DataGrid 버튼을
 먼저 잡던 휴리스틱 로케이터. 레이아웃에는 sticky className / 안정 id 를 부여.
- PHPDoc ↔ Pint 충돌 3건: 설명 없는 @param 은 no_superfluous_phpdoc_tags 가
 삭제해 audit phpdoc-public-method 와 무한 충돌. 한국어 설명을 붙여 양쪽 충족
 (리스너 3종의 설명 누락 메서드도 전수 보강).
- Log::spy 500 3건: spy 가 Log::channel('activity') 를 null 로 만들고
 logActivity 가 null->info 로 \Error → 같은 줄 catch(\Exception) 은 못 잡아
 통과. 운영에선 채널이 항상 로거이므로 제품 무결 → 테스트에 activity 채널
 보존 헬퍼 추가.
- NhnKcpApiServiceTest 5건: 스텁이 Unix 바이너리만 만들어 Windows 분기
 (pp_cli_exe.exe)에서만 실패. #!/bin/sh 는 Windows 실행 불가 → 파일 내 기존
 컨벤션대로 skip 가드.

## 테스트

- 소스맵 범위 130 passed (신규: BuildCommandSourcemapEnv, NoSourcemapArtifacts,
 ExtensionBundleService CSS strip, Allowed*FileType 반전, 자산 서빙 거부)
- 결제 3플러그인 505 passed / 0 failed (skipped 9 = Windows 전용 CLI, 사유 명시)
- 나이스페이먼츠 프론트 38 passed (가드 제거 시 red 실증 후 복원)
- E2E: production-sourcemap-exposure.spec.ts 6건 + 시나리오 매니페스트

버전: sirsoft-admin_basic 1.0.2 → 1.0.3, sirsoft-board 1.0.1 → 1.0.2
(각 manifest/package/lock/composer 동기화). 코어 7.0.4 는 미출시 누적.
2026-07-14 12:50:23 +09:00
정정홍andGitHub 84ca40f0d8 Merge pull request from gnuboard:HeuJung/issue466
HeuJung/issue466
2026-07-14 09:20:09 +09:00
HeuJung c4beb116ad fix(security): outbound URL 내부주소·host 검증 전수 강화
서버가 외부 입력으로 목적지가 제어되는 outbound HTTP 요청을 보내는 지점을
전수 조사해, 내부망(사설 IP·루프백·링크로컬·메타데이터)과 host 위조를 차단한다.
KVE-2026-1546(이니시스 인증 URL 접두사 매칭 우회)과 동일 클래스의
지점(언어팩 URL 설치, 스케줄 URL 호출, 배송비 계산 API, GitHub URL 판정)을
코어 공용 유틸 App\Support\OutboundUrlValidator 로 수렴시켰다.

- 접두사 매칭을 host 완전일치로 교체 — userinfo(@) 위장과 접미사 확장 도메인
 우회 벡터 차단.
- 사내 서버 호출이 정당한 관리자 지점(스케줄·외부 API)은 신규 설정
 security.allow_internal_outbound_urls(기본 off)로 옵트인.
- 언어팩 URL 설치는 원격 코드 다운로드라 옵트인과 무관하게 내부 주소 항상 차단.
- 웹소켓 연결 테스트는 localhost·사설 IP 가 정상 구성이므로 구조적 위조만 차단.

각 지점에 red 재현 → 수정 → green 회귀 테스트 + 실브라우저 E2E 로 검증.
수기 API 문서가 자동 재생성으로 소실되는 사고를 막는 PreToolUse 훅을 함께 추가한다.
2026-07-14 09:17:00 +09:00
정정홍andGitHub f848086fd6 Merge pull request from gnuboard:HeuJung/issue465
HeuJung/issue465
2026-07-13 17:05:21 +09:00
HeuJung cc705c2f21 test(installer): 엔드포인트 조립 계층 회귀 가드 추가
escapeEnvValue 재선언 fatal 이 통과한 사각지대를 구조적으로 봉인한다.

인스톨러는 Laravel 오토로드 밖의 플레인 PHP require 체인이라, 엔드포인트가
"어떤 파일을 어떤 순서로 조립하는가" 가 곧 함수 선언 순서다. 기존 단위 테스트는
함수를 직접 호출하므로 이 조립 계층을 전혀 검증하지 못했고, 그래서 재선언
fatal(빈 응답 → 프론트 JSON 파싱 에러)이 그대로 통과했다.

추가: 엔드포인트 5종(install-process / state-management / finalize-env /
install-worker / index)의 실제 require 체인을 격리 프로세스로 재현하고,
그 엔드포인트가 호출하는 함수가 모두 정의되는지 + fatal 이 없는지 검증.

이로써 "Call to undefined function"(정의 파일 미포함) 과 "Cannot redeclare"
(가드 없는 중복 선언) 두 fatal 유형이 모두 차단된다 — 증상은 빈 응답으로
동일하지만 원인이 달라 각각 잡아야 한다.

가드 제거 시 9건 중 3건이 red 로 전환됨을 확인 (거짓 통과 아님).
2026-07-13 17:00:59 +09:00
HeuJung 1922003917 fix(installer): 설치 시작 즉시 빈 응답 — escapeEnvValue 재선언 fatal
직전 커밋이 install-process.php 에 installer-runtime.php require 를
functions.php 앞에 추가하면서 발생한 회귀.

installer-runtime.php 는 escapeEnvValue 를 function_exists 가드와 함께
polyfill 로 선언하는데, functions.php 는 같은 함수를 가드 없이 선언하고 있었다.
로드 순서가 뒤바뀌자 "Cannot redeclare escapeEnvValue" fatal 이 발생하고,
JSON 이 출력되기 전에 스크립트가 죽어 응답 본문이 비었다. 브라우저의
res.json 이 이를 "Unexpected end of JSON input" 으로 보고해 설치 시작
즉시 실패했다 (SSE / 폴링 양 모드 — 둘 다 install-process.php 를 경유).

수정:
- functions.php 의 escapeEnvValue 에 function_exists 가드 추가 (근본 방어 —
 require 순서만 되돌리면 다음 사람이 순서를 바꿀 때 재발한다)
- install-process.php 의 require 순서를 다른 엔드포인트와 동일하게 정렬

인스톨러 전역 전수 스캔 결과 중복 선언 함수는 escapeEnvValue 하나뿐이며
이제 양쪽 모두 가드를 갖는다.

회귀 가드: InstallerIncludeOrderTest — 격리 프로세스로 로드 순서를 바꿔가며
fatal 부재 검증 + 중복 선언 함수의 가드 보유를 정적 검사 (수정 전 3/4 fail 확인).
2026-07-13 17:00:59 +09:00
HeuJung 9be8faaea2 fix(installer): 설치 상태 파일에 관리자 비밀번호가 평문으로 잔존하는 문제
비밀을 state.json(0664, 실패 시 무기한 잔존)에 기록하지 않고
runtime.php(0600, finalize 시 삭제)로 채널을 일원화한다.

정상 완료 경로에서는 finalize 가 state.json 을 삭제하지만, finalize
미호출 / unlink 실패 / 설치 중단 시에는 평문이 영구히 남았고, finalize
가드가 재호출을 410 차단해 자가 회복도 불가능했다. 삭제라는 단일 경로에
의존해 평문을 보호하던 구조 자체를 제거한다.

SSE 워커는 세션에 접근할 수 없어 파일 경유가 불가피한데, 그 파일이 0664 의
state.json 이었던 것이 구조적 원인이다. 세션을 보유한 유일한 공통 시작점인
install-process.php 를 비밀 이송 지점으로 삼고, db_seed 가 소비한 즉시
제거한다. 레거시 / 실패 경로는 완료·중단·초기화·finalize 시점 redact 로 방어.

동반 수정:
- 세션 config 재병합으로 무력화되던 기존 DB 비밀번호 마스킹 복구
- db_seed 후 putenv 미해제로 모든 exec 자식에 평문이 ENV 상속되던 누출 차단
- runtime read 비밀번호 폴백이 write 값으로 잘못 대체되던 문제 ( 인접)

이미 설치된 사이트는 7.0.4 업그레이드 스텝이 잔존 파일을 정리한다.
완료 증거가 있으면 삭제하고, 설치 진행 중일 수 있으면 비밀만 제거한다.
2026-07-13 17:00:59 +09:00
eunhyeandGitHub ae447c9cc4 Merge pull request from gnuboard/chym1217/issue458
feat(verification_kginicis): 라이브 MID 자동완성 방지 + SRB 프리픽스 전환
2026-07-13 16:32:22 +09:00
chym1217 57ac2b0b3b feat(verification_kginicis): 라이브 MID 자동완성 방지 + SRB 프리픽스 전환
- 라이브 자격증명 입력칸 브라우저 자동완성 자동 채움 차단
 (live_mid: one-time-code / live_api_key: new-password —
 크롬이 autoComplete=off 를 무시함을 실측 후 강화)
- 라이브 가맹점 MID 프리픽스를 이니시스 정책 변경에 맞춰 SRA→SRB 전환.
 런타임 프리픽스를 InicisIdentityProvider::LIVE_MID_PREFIX 상수로 추출,
 표시 문자열(라벨·Span·힌트 ko/en/ja)은 값 교체.
- 설정 저장 테스트가 실제 스토리지 설정 파일을 오염시키던 결함 격리
 (PluginTestCase 에서 plugins 디스크를 테스트 전용 경로로 격리)
- 설정 화면 배너 아이콘 크기 시맨틱 정정 (w-N h-N → text-*, 정렬 회귀 해결)
- 프리픽스 상수 ↔ 표시 문자열 정합 audit 룰 추가 (이중관리 불일치 차단)
2026-07-13 15:18:10 +09:00
HeuJung cd3f6ad2b7 Revert "공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거"
This reverts commit bac963b011f7b180c0d64e4f050131b5f05b85c1.
2026-07-13 11:10:50 +09:00
HeuJung a690fdd2c4 Revert "공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 일본어 언어팩 번들 제거"
This reverts commit 2aead0879df6ab08ec58b9b3ab08c7cdaa50a935.
2026-07-13 11:10:50 +09:00
정정홍andGitHub 8cc40e0681 Merge pull request from gnuboard:HeuJung/issue463
fix(engine): 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이는 문제
2026-07-12 17:22:56 +09:00
HeuJung d85ff9971a test(engine): 감사 지적 3건 반영 — 컴포넌트 번들 E2E 커버 + 약한 단언 실질화
D-4 (실질 커버리지 공백):
- 코어 번들은 "브라우저가 abort 된 script 를 스스로 재요청한다" 는 실측 근거로
 상시-부재 폴백 테스트를 대체본으로 뒀으면서, 형제 경로인 템플릿 컴포넌트
 번들에는 그 대체본조차 없었다. 6경로 중 이 1경로만 E2E 무커버였다.
 상시-부재 → 폴백 안내 E2E 추가 (수정 전 blade 에서 red 확인).

B-9 (제목↔단언 불일치):
- 제목은 "렌더는 진행된다" 인데 단언은 executeInitActions 가 reject 하지
 않는다는 것뿐이라, 그 뒤 init 체인이 죽어도 통과했다. renderTemplate 직접
 단언은 라우터/레이아웃 로드를 거치는 별개 경로라 단위에서는 모킹을 시험하는
 꼴이 되므로, 실제 본체인 "초기화가 에러 화면 없이 완주한다" 로 단언을 바꾸고
 제목도 그에 맞췄다.

B-1 (spy 미사용):
- setRoutes spy 를 선언만 하고 쓰지 않아 "에러 화면이 안 떴다" 만 단언했다.
 라우트가 비어도 에러 화면은 안 뜬다. 재시도로 받아온 routes 가 실제로
 라우터에 반영됐음을 단언한다 (재시도 제거 시 red 확인).
2026-07-12 17:09:03 +09:00
HeuJung 793afb95dc fix(engine): 감사 지적 반영 — 언어팩 버전 bump + 회귀 가드 실질화
전수조사에서 지적된 5건을 코드로 재확인하고 조치했다.

차단성:
- ja 언어팩 1.0.3 → 1.0.4. 1.0.3 은 이미 배포된 버전이라, 같은 버전에 키를
 추가하면 기설치 사이트가 업데이트를 받지 못해 번역이 영영 도달하지 않는다.

회귀 가드 실질화 (green ≠ 회귀를 잡는다):
- 미등록 핸들러 억제 테스트가 동어반복이었다. 값을 손으로 대입하고 그 값을
 읽어, 억제를 삭제해도 통과했다. 실제 dispatch 후 표시 계층이 호출되지
 않음을 감시하도록 교체 (억제 제거 시 red 확인).
- script 번들의 "1건 취소 → 복구" E2E 는 수정 전에도 통과한다. 브라우저가
 abort 된 script 를 스스로 재요청하기 때문 (수정 전 blade 로 실측: 재시도
 훅 0개인데 hits=2, 렌더 정상). 거짓 안심이므로 제거하고, blade 가 고유하게
 보장하는 것 — 재시도/폴백 훅 설치 + 정상 경로 오발동 없음 — 을 단언하도록
 교체 (수정 전 red 확인).
- 재시도 횟수 단언을 하한(>=2)에서 정확값(==2)으로. 과잉 재시도 회귀를
 통과시키던 구멍을 막는다.

정확도:
- waitForHandlers 가 전역 실패 플래그로 판정해, 무관한 확장의 실패가 정상
 로드된 확장의 핸들러 대기까지 포기시켰다. 실패 키 단위로 정밀화한다.
2026-07-12 16:30:38 +09:00
HeuJung 68cbee0e83 fix(engine): 좁은 창에서 레이아웃 편집기가 찌그러지는 문제
레이아웃 편집기는 라우트 트리(280px 고정) + 디바이스 미리보기 캔버스 +
라벨 있는 툴바 버튼 10여 개가 나란히 놓이는 대화면 전용 도구다. 그런데
셸에 너비 하한이 없어 창을 좁히면 이들이 창 너비에 맞춰 압착됐고, 툴바가
flex row 라 각 항목 폭이 깎이면서 라벨이 글자 단위로 줄바꿈됐다("요 소 추 가").

반응형으로 재배치하는 대신 셸이 최소 너비(EDITOR_MIN_WIDTH=1280)를 유지하고
모자란 폭은 브라우저 가로 스크롤이 흡수하도록 했다. 편집기 UI 는 축약 대상이
아니며, 압착을 허용하면 캔버스 실측 폭이 뷰포트에 종속돼 디바이스 미리보기의
의미 자체가 사라진다.

툴바 압착 차단은 직접 자식 전체를 덮는 CSS 규칙으로 뒀다. 버튼마다 인라인
style 로 붙이면 자체 style 을 가진 하위 컴포넌트(템플릿 전환기·언어 전환기)와
이후 추가될 항목이 빠져 같은 결함이 재발한다.

jsdom 은 레이아웃 엔진이 없어 단위 테스트로는 "style 선언 존재" 까지만 잠긴다.
실제로 압착이 멈췄는지는 렌더 박스를 재는 브라우저에서만 증명되므로 Playwright
spec 을 함께 추가했다(수정 되돌림 시 red 확인). 아울러 이 파일의 e2e:allow 면제
주석은 사유("브라우저 가시 UI 무변경")가 더 이상 참이 아니라 제거했다.
2026-07-12 16:13:51 +09:00
HeuJung bfaf551d41 fix(engine): 페이지 로드 요청 1건의 일시 실패가 앱 전체를 죽이는 문제
모바일에서 새로고침을 연타하면 진행 중이던 요청이 취소되는데, 화면 구성에
필요한 요청 하나만 그렇게 끊겨도 전면 "초기화 실패" 로 끝났다. 재시도가 전혀
없었고, 로컬은 왕복이 수 ms 라 재현되지 않아 오래 남아 있었다.

fetch 는 4xx/5xx 로 reject 하지 않고 Response.ok=false 로 resolve 한다.
따라서 TypeError 는 "응답 자체가 없었다"(취소·커넥션 유실)는 뜻이며, 그 경우만
재시도해야 한다. HTTP 응답은 그대로 호출부에 넘겨 기존 상태코드 분기와 이중으로
겹치지 않게 했다.

- routes.json / components.json / 레이아웃 JSON: 네트워크 실패에만 지수 백오프 재시도
- 실패한 promise 가 레이아웃 캐시에 남아 영구 실패가 되던 문제 수정
- 문서 이탈(pagehide) 중에는 에러 화면을 그리지 않는다 (버려질 문서)
- 확장 에셋 로더의 실패 계약을 resolve → reject 로 (실패를 발생 지점에서 표면화)
- 번들 로드가 실패로 확정되면 오지 않을 핸들러를 5초간 기다리지 않는다
- 미등록 핸들러의 raw 내부 식별자가 토스트로 노출되던 것을 차단
- 번들이 끝내 부재해도 백지 대신 새로고침 버튼이 있는 안내 화면

정상 경로 성능은 종전과 같다. script 태그는 정적으로 유지해 프리로드 스캐너
이점을 보존하고, 실패했을 때만 onerror 에서 동적 재시도를 건다.
2026-07-12 15:39:07 +09:00
정정홍andGitHub 8018c525b8 Merge pull request from gnuboard:HeuJung/issue461
HeuJung/issue461
2026-07-11 22:00:42 +09:00
HeuJung 25c0ab46ec fix(vendor-bundle): 확장에 새 패키지 추가 시 번들 누락 수정
빌드가 composer install 의 입력(스테이징에 복사할 composer.json/lock)만 활성
디렉토리에서 가져오고 manifest 해시는 _bundled 기준으로 기록해, 두 판단이 서로
다른 파일을 보고 있었다.

개발자는 규정상 _bundled 에서만 작업하므로 새 패키지를 추가하면 _bundled 에만
반영되고 활성은 다음 update 전까지 구버전으로 남는다. 그 결과 구버전 lock 으로
설치한 zip 에 신버전 해시가 붙어, 무결성 검증은 통과하는데 새 패키지가 번들에서
누락되고 설치 후 런타임에 클래스 not found 로 터졌다.

설치 입력·해시 기준·의존성 판정·stale 판정 네 가지를 모두 resolveHashTarget
(출력 우선, 없으면 소스 폴백) 으로 통일. 코어는 source == output 이라 무영향.

실증: 이커머스에 psr/log 추가 → 빌드 2 packages(수정 전 1) → zip 에 vendor/psr/log
포함 → module:update → 활성 vendor 배포 + Psr\Log\LoggerInterface 런타임 로드 확인.
2026-07-11 19:50:06 +09:00
HeuJung 13e65131f4 fix(vendor-bundle): 코어 빌드 무한 행 해결 + 이커머스 알림 라벨 raw 노출 수정
이커머스 알림 정의 화면에서 order_pending_deposit·order_delivered 두 알림이
라벨 대신 raw 다국어 키로 표시되던 문제를 수정. settings.json 3로케일에 라벨
추가 + 렌더 회귀 테스트 + 브라우저 실측.

vendor-bundle 시스템 결함 5종 해결:
- (A/B) manifest 해시 기준을 활성이 아닌 출력(_bundled) 디렉토리로 통일해
 확장 업데이트가 composer_json_sha_mismatch 순환에 빠지던 문제 해소
- (C) 코어 빌드 시 composer autoload dump 의 async 손자 프로세스가 부모
 파이프 핸들을 상속·점유해 무한 대기하던 교착을, stdout/stderr 를 파이프
 아닌 파일 descriptor 로 받아 원천 차단 (파이프=행/파일=완주 A/B 실측으로 확정)
- (D) 빌드 실패 시 원본 번들 유실 방지 — 임시파일 성공 후 원자적 교체
- (E) Windows .bat 실행 시 bypass_shell 통일

코어 7.0.4 / 이커머스·언어팩 1.0.3 bump.
2026-07-11 07:00:34 +09:00
HeuJung a34a03eb40 Merge branch 'develop' 2026-07-10 21:12:41 +09:00
HeuJung b8ceb26cf3 fix(ecommerce): vendor-bundle 재빌드 — composer.json 해시 불일치로 모듈 업데이트 실패
7.0.3 로 업데이트하는 서버에서 module:update sirsoft-ecommerce 가
"번들 파일의 무결성 검증에 실패했습니다" 로 중단됐다.

앞선 커밋이 composer.json 의 version 을 1.0.1 → 1.0.2 로 올리면서
vendor-bundle:build 재실행을 빠뜨려, vendor-bundle.json 이 옛 해시
(8a46fadc…)를 그대로 들고 있었다. 실제 파일은 aa8f7406… 이다.
이 상태가 v7.0.3 태그에 그대로 실렸다.

로컬에서 재현되지 않은 것은 ModuleManager 의 isComposerUnchanged 가
staging 과 active 의 composer.json 을 비교해 같으면 vendor 설치를
통째로 건너뛰기 때문이다. 이미 1.0.2 가 깔린 환경은 검사 자체를 타지
않는다. 1.0.1 에서 올라오는 서버만 설치 경로로 들어가 검증에 걸린다.

재빌드로 manifest 해시를 실제 파일과 맞추고 g7_version 도 7.0.3 으로
갱신했다. zip 내용물은 432개 파일 중 vendor/composer/installed.php 의
자기 버전 표기(1.0.1 → 1.0.2) 한 곳만 달라지고 htmlpurifier 를 포함한
나머지 431개는 바이트 단위로 동일하다. 의존성 변화는 없다.

vendor-bundle:verify-all 로 코어와 전 번들 확장이 통과함을 확인했고,
서버와 같은 경로(--vendor-mode=bundled)로 module:update 를 실제 실행해
성공을 확인했다.
2026-07-10 21:10:57 +09:00
HeuJung feca0c7146 Merge branch 'develop' 2026-07-10 20:44:07 +09:00
HeuJung 1ec9692cac 공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 일본어 언어팩 번들 제거 2026-07-10 20:42:59 +09:00
HeuJung b65c782447 Merge branch 'develop' 2026-07-10 20:24:14 +09:00
HeuJung 2832db6ffd chore(api-docs, sirsoft-ecommerce): 공개 release 누출 검사 위반 정리
공개 release 빌드의 파일 본문 누출 검사에서 검출된 항목을 정리한다.

api-documentation.md 는 릴리즈에 포함되지 않는 내부 지침 파일명을 참조하고
있었다. 공개본 독자에게는 존재하지 않는 파일을 가리키므로 공개 진입점
문서(AGENTS.md)로 교체했다.

이커머스 배송국가 테스트 2종의 주석에서 내부 역할 호칭을 제거했다. 판단
근거 서술은 그대로 두고 호칭만 걷어냈으므로 어서션·실행 경로는 불변이다.

검증: check-public-release-leakage error 0 / warn 0,
PruneEmptyShippingCountryNameLocalesTest 10/10 통과.
2026-07-10 20:23:13 +09:00
HeuJung f432df9d9c Merge branch 'develop' 2026-07-10 20:06:10 +09:00
HeuJung edb4d13096 공개 버전 출시를 위한 나이스페이먼츠·NHN KCP 결제 플러그인 번들 제거 2026-07-10 20:05:23 +09:00
정정홍andGitHub abe3fe85d4 Merge pull request from gnuboard:HeuJung/issue459
HeuJung/issue459
2026-07-10 19:33:00 +09:00
HeuJung 809409f3cf fix(api-docs): 문서 샘플 언어팩이 전역 지원 로케일을 오염시키는 문제 수정
api:docgen --seed 가 생성하는 샘플 언어팩이 scope=core + status=active 로
만들어져 getActiveCoreLocales 승격 쿼리에 걸렸다. 그 결과 시스템 전역
supported_locales/translatable_locales 가 넓어지고, 그 아래에서 시드된
다국어 데이터에 실재하지 않는 로케일 키가 박혔다. 샘플을 비활성화하면
그 키가 TranslatableField 검증을 통과하지 못해 저장이 막힌다.

문서용 샘플은 조회 예시일 뿐 시스템 동작을 바꿀 자격이 없으므로,
승격 경로를 벗어난 조합(module 스코프 + installed 상태 + 실재 번들 로케일)
으로 생성하도록 바꾸고 audit 룰로 재발을 차단한다.

README 재생성 시 사람이 덧붙인 헤딩 섹션이 통째로 소실되던 사전 결함도
함께 수정했다(경계를 임의의 ## 이 아닌 확장 목차 헤딩으로 특정).
2026-07-10 19:27:27 +09:00
HeuJung 12968b76ca fix(core): 다중 progressive 데이터소스의 initLocal 유실 수정
_localInit 은 단일 슬롯이고 updateTemplateData 가 얕은 스프레드로 교체했다.
소비부(DynamicRenderer useEffect)는 React commit 이후에 실행되므로,
initLocal 을 가진 progressive 소스가 둘 이상이면 두 payload 가 같은 commit
사이에 도착할 때 먼저 온 것이 한 번도 관측되지 않고 사라졌다.

무조건 누적 병합은 refetchDataSource 시 소비가 끝난 payload 를 재적용해
사용자 폼 편집을 되돌린다. 그래서 아직 관측되지 않은 슬롯만 병합하고,
관측된 슬롯은 종전대로 교체한다. 소비 여부는 소비부가 기록한 슬롯 참조로
판정해 해시 계산식을 생산부에 복제하지 않는다.

레이아웃 전환으로 _local 을 리셋할 때 추적 레지스트리도 함께 비운다.

검증: CPU 6배 스로틀로 경합 창을 강제로 열어 수정 전 0/8 → 수정 후 8/8.
동반: template-engine.test.ts 의 vi.mock 이 공유 런타임 export 를 빠뜨려
스위트가 로드되지 않던 것과, 코어 spec 2개가 없는 fixture 를 import 해
playwright 수집이 실패하던 것을 함께 복구했다.
2026-07-10 19:27:27 +09:00
HeuJung 051ed1e528 fix(core, sirsoft-ecommerce, sirsoft-basic): 배송국가 다국어 표시·입력 지원
$localized 가 ActionDispatcher 경로에서 항상 ko 를 반환했다. 컨텍스트에
$locale 이 없다는 것은 "로케일이 ko" 라는 뜻이 아니라 "이 경로가 로케일을
넘기지 않는다" 는 뜻인데, context.$locale || 'ko' 로 하드코딩 폴백하고 있었다.
바로 아래 $t 는 engine-v1.38.2 에서 이미 앱 설정 회수 폴백을 갖췄으나
$localized 만 정의 순서상 그 폴백보다 위에 있어 혜택을 받지 못했다.
로케일/templateId 해석을 두 헬퍼 앞으로 끌어올려 공유한다 (engine-v1.52.1).

국가 추가 폼은 이름 입력칸이 ko/en 두 칸으로 고정돼 그 외 언어의 국가명을
넣을 수 없었다. 설치된 언어를 $locales 로 순회해 입력칸을 만들고, 추가 조건도
특정 로케일 대신 "어느 한 언어라도 채워졌는가" 로 바꾼다. 백엔드
StoreEcommerceSettingsRequest 도 name 을 array 로만 요구하므로 계약이 일치한다.

저장 시 입력하지 않은 로케일은 남기지 않는다. 기존 저장본에 박힌 빈 문자열은
업그레이드 스텝이 청소한다 — 부재 로케일은 서버 localize_catalog_field 가
읽기 시점에 언어팩으로 보강하므로, 빈 문자열은 그 보강을 막는 어중간한 상태였다.

동일 결함 재발을 막는 audit 룰(engine-context-locale-hardcoded-fallback)을
함께 추가한다. 수정 전 코드에서 1건 검출, 수정 후 0건을 확인했다.
2026-07-10 19:27:26 +09:00
HeuJung 0f84657a5c fix(sirsoft-ecommerce): 주문상세 상품 목록 숫자 열 우측 정렬 통일
상품 목록의 숫자 6개 열 중 `구매수량`·`적립예정` 만 가운데 정렬이라 나머지 네 열과
세로 기준선이 어긋나 보였다. 두 열은 값 아래 배지를 쌓기 위해 `flex flex-col` 을
쓰는데 교차축 정렬이 `items-center` 로 남아 있던 것이 원인. `items-end` 로 바꿔
배지 세로 배치를 유지한 채 `text-right` 열과 같은 우측 기준선에 맞췄다.

공유 컴포넌트인 DataGrid 는 8개 이상 레이아웃이 사용하므로 손대지 않았다.

배포 후 실제 DOM 실측으로 확인: Quantity·Points 는 alignItems=flex-end,
Price·Discount·Actual Price·Subtotal 은 textAlign=right, 비숫자 열(상품정보·상태·
배송)은 정렬 대상에서 제외.

회귀 spec 추가 — `items-center` 는 text-align 이 아니라 align-items 로 정렬하므로
textAlign 만 단언하면 두 셀을 영영 못 잡는다. 콘텐츠의 우측 여백을 재고, 절대
임계값 대신 같은 행 안에서 열끼리 여백이 서로 같은지를 단언한다. 헤더 텍스트로
열을 찾지 않는다(E2E 세션 로케일이 en).

함께 수정 — 모듈 E2E 스위트가 수집 단계에서 전체 실패하고 있었다. 모듈 config 는
--config 로만 실행되어 여태 아무도 돌린 적이 없었다.

- 미정의 fixture 3종 추가(userToken/customerToken/ordersReadToken). Playwright 는
 describe.skip 블록도 수집 시점에 fixture 파라미터를 해석한다.
- mp06 spec 주석에서 `OrderCalculation*/` 의 `*/` 가 블록 주석을 조기 종료시켜
 SyntaxError 가 나던 문제 수정.

검증: 모듈 E2E 2 passed/63 skipped/0 failed, Vitest 2382 passed/5 failed
(clean HEAD 와 동일 — 선행 결함, 무회귀), audit 0 error.
2026-07-10 19:27:26 +09:00
HeuJung 0ff4b674ac fix(sirsoft-basic, sirsoft-admin_basic): 모바일 드로어 선호설정 아코디언화 및 뷰포트 개선
모바일 헤더 우측 그룹이 420px 를 요구해 폰 뷰포트에서 통화 선택기가 잘렸다.
언어·통화·배송국가를 드로어로 옮겨 가로 칩으로 통일했으나, 이번엔 드로어가
세로로 길어져 정작 메뉴가 스크롤 밖으로 밀렸다.

언어와 통화·배송국가를 각각 독립 아코디언(기본 접힘)으로 바꾸고, 접힘 상태에서도
트리거에 현재 선택값을 요약 표기해 펼치지 않고 확인할 수 있게 했다. 언어는 템플릿이,
통화·배송국가는 이커머스 주입 조각이 각자 상태를 소유해 서로 간섭하지 않는다.

통화 패널은 새 플래그를 만들지 않고 데스크톱 드롭다운의 기존 토글을 재사용한다 —
portable 오버라이드에서 if 를 생략하면 엔진이 base if 를 상속하므로 자동으로 기본
접힘이 된다. 이 과정에서 모바일로 펼칠 때 전체 화면 배경 오버레이가 드로어 클릭을
가로채는 결함을 함께 차단했다.

그 밖에 모달 좌우 여백, 주문 취소·일괄변경 팝업의 세로 스택 전환, 은행 관리 입력칸
축소, 쇼핑몰 환경설정·쿠폰·엑셀 다운로드 화면의 가로 넘침을 함께 해소했다.
2026-07-10 19:27:25 +09:00
HeuJung e3c00addd8 fix(sirsoft-basic): 모바일 뷰포트 가로 오버플로·압착 해소 및 확장 조각 audit 사각지대 수정
390px 실측 결과 26개 라우트 중 24개에서 문서가 뷰포트를 11px 넘겼고,
320px 에서는 햄버거 버튼이 화면 밖으로 밀려 내비게이션 자체가 불가능했다.
원인은 예외 없이 헤더 우측의 언어(71px)+통화(91px) 선택기였다.

이를 모바일 드로어로 옮기고, 좁은 화면에서는 세로 목록 대신 가로 칩으로
나열하도록 했다. 통화·배송국가는 이커머스 모듈이 슬롯에 주입하는 조각이므로
모듈 쪽에도 responsive.portable 오버라이드를 넣었다. 템플릿의 드로어 자리는
overflow-y-auto 라 옛 absolute 드롭다운이 잘리므로, 템플릿의 이커머스 모듈
의존 하한을 >=1.0.2 로 올렸다.

게시글 상단 네비는 성격이 다른 회귀였다. 게시판 이름이 길수록 flex 형제인
목록·이전글·다음글 버튼이 좁아지는데(87→73→62px), document.scrollWidth 는
내내 뷰포트와 같아 오버플로 검사로는 잡히지 않았다. 넘치는 게 아니라 줄어드는
회귀다. shrink-0 으로 버튼 폭을 고정하고, 모바일에서만 게시판 이름을 자기 줄로
올렸다. 버튼 3개 고정폭 261px + 21자 제목 323px = 584px < 태블릿 콘텐츠 735px
이므로 portable 이 아니라 mobile 스코프로 한정했다.

커밋 전 점검에서 audit 결함 두 건이 드러나 함께 고쳤다.

첫째, layout-* 룰 28개 중 27개가 appliesTo 에 확장 주입 조각 경로를 빠뜨려
조각 32개가 정적 검사에서 통째로 빠져 있었다. 실제 위반 60건이 은닉돼 있었고
그중 45건은 error 다. 다만 glob 만 넓히면 layout-grammar 의 root 화이트리스트가
조각의 정당한 키(target_layout/injections/extension_point/mode/priority)를 전부
오탐해 90건의 error 를 새로 만든다. 조각은 레이아웃이 아니라 별도 스키마이므로
extension-fragment 스키마를 신설해 오탐을 없애는 동시에 조각 고유의 결함(대상 키
누락으로 인한 silent drop, target_layout ↔ extension_point 동시 정의, 무동작 조각)을
새로 검출하게 했다.

둘째, Icon 의 w-N h-N 을 size prop 으로 바꾸라는 규정이 틀렸다. Icon 은 <i> 글리프라
박스 크기가 곧 font-size 인데, size prop 은 Font Awesome fa-* 로 매핑되어 상대값
0.875em + line-height 붕괴를 동반한다. 실측하면 w-4 h-4(16×16)가 fa-sm 에서
12.25×0.88 이 된다. 등가는 text-base 하나뿐이다. 앞선 작업에서 size:"sm" 으로
치환했던 2건을 되돌리고, 규정과 룰 메시지를 실측 등가표로 교체했다.
2026-07-10 19:27:25 +09:00
thisgun 0200f1d043 fix(nicepayments): 가상계좌 close-report 상태 보존
가상계좌 발급 후 입금대기 상태인 주문에 PC 결제창 닫힘 보고가 뒤늦게 도착해도 주문/결제 상태를 실패로 덮지 않도록 READY 상태만 실패 처리 대상으로 제한한다.
2026-07-09 12:53:16 +09:00
thisgun 5f99b104c1 Merge branch 'develop' of github-gnuboard:gnuboard/dev-g7 into thisgun/pay_develop 2026-07-09 12:52:08 +09:00
HeuJung 5f3b904aab docs: API 레퍼런스 진입점 정비 + README 데모 링크 추가
최상위 README 의 "API 레퍼런스" 가 문서 작성 규정(api-documentation.md)을
가리켜, 개발자·AI 가 실제 엔드포인트 레퍼런스에 도달할 경로가 없었다.
코어 목차(docs/backend/api/README.md)도 어느 인덱스에서도 참조되지 않았고,
확장 API 문서 9종 역시 공개 진입점이 없었다.

코어 목차를 코어+확장 통합 진입 문서로 승격하고, 상단에 공통 규약(인증·
응답 봉투·페이지네이션·에러)을 실측 근거로 서술했다. 확장 목차는 파일 시스템
패턴 스캔으로 생성해 확장명 하드코딩을 두지 않았고, --scope 를 좁혀 실행해도
개요와 확장 표가 소실되지 않도록 보존 경로를 분리했다.

아울러 설치 없이 동작을 확인할 수 있도록 README 상단에 데모 사이트와
관리자 데모 링크를 추가했다.
2026-07-09 10:24:17 +09:00
정정홍andGitHub 45bf59472a Merge pull request from gnuboard:HeuJung/issue456
fix(core): installer_completed fast-path가 빈 DB 배포 시 부팅을 깨뜨리는 문제 수정 및 7.0.3 릴리스
2026-07-09 09:15:05 +09:00
thisgun 2493bf424a fix(nicepayments): 부분취소 후 잔액 취소 판정 보정
이미 부분취소된 거래의 남은 잔액을 취소할 때도 나이스페이에 부분취소 코드가 전달되도록 보정합니다.

회귀 테스트와 나이스페이 플러그인 CHANGELOG를 함께 추가합니다.
2026-07-08 23:24:20 +09:00
HeuJung c26b49a997 fix(core): installer_completed fast-path가 빈 DB 배포 시 부팅을 깨뜨리는 문제 수정 및 7.0.3 릴리스
INSTALLER_COMPLETED=true 가 적힌 .env 를 빈 DB 새 서버에 복사한 뒤
php artisan migrate 로 테이블을 만들기 전에 앱이 부팅되면, installer_completed
fast-path 가 테이블 존재를 잘못 전제하여 뒤따르는 쿼리가 table not found 로
부팅을 깨뜨렸다. 마이그레이션을 실행하려는 부팅에서 그 명령이 오히려 실패하는
닭-달걀 상태가 된다.

fast-path 자체(매 요청 information_schema 쿼리 제거 성능 최적화)는 유지하되,
이미 존재하던 참조 구현(LanguagePackServiceProvider::isRegistryReady)의 방어
패턴을 InstallerContext::isSchemaMutatingCommand 로 SSoT 화하여 나머지 5곳에
통일 적용했다.

- app/Support/InstallerContext.php: 마이그레이션 계열 콘솔 명령 판정 헬퍼
 (migrate/migrate:fresh/migrate:refresh/migrate:rollback/migrate:reset/db:wipe)
- Caches{Module,Plugin,Template}Status: fast-path 앞에 && 가드
- {Module,Plugin}RouteServiceProvider: hasTable 검증 진입 조건에 || 가드로
 무방비 pluck 도달 차단
- LanguagePackServiceProvider: 인라인 argv 검사를 헬퍼로 치환(migrate:refresh 보강)

부류 B(NotificationHookListener, IdentityPolicyRepository)는 fast-path 이후
쿼리가 이미 try/catch 로 보호되어 빈 DB 에서도 안전하므로 수정 제외.

최초 설치 흐름은 migrate 시점에 완료 플래그가 없어(false) 이번 가드의 영향권
밖이며 원래대로 hasTable 폴백을 탄다. 가드가 실제 발동하는 유일 케이스는
"플래그 true + 빈 DB" 뿐이고, 정상 운영 웹 요청은 fast-path 성능이 유지된다.

회귀 테스트: InstallerContextTest 8건 + InstallerCompletedGuardTest 5건 추가
(수정 전 5건 fail → 수정 후 green). 관련 스위트 355 passed.

7.0.3 출시 버전 bump: 코어 버전 SSoT 7곳 + CHANGELOG 동기화.
2026-07-08 22:01:53 +09:00
정정홍andGitHub 13aeb433cc Merge pull request from gnuboard:HeuJung/issue447
HeuJung/issue447
2026-07-08 20:01:18 +09:00
HeuJung 168f439be3 feat(api-docs): 확장 API 레퍼런스 발견성 — README 목차 규약 + 동적 스캔 인덱스
확장 API 문서가 인덱스·목차 어디에도 노출되지 않아 처음 온 개발자/AI 가
발견할 수 없던 간극을 해소했다. 코어에 확장명을 하드코딩하지 않고(동적 로딩 원칙),
파일 시스템 패턴 스캔 + 확장 소유 목차 규약으로 발견성을 확보한다.

- ApiDocScaffolder::readmeIndex — 각 대상의 docs/api/README.md 목차(도메인 파일·
 엔드포인트 수)를 @generated 블록으로 생성(멱등, 사람 개요 보존). 커맨드가 대상별
 README 를 방출(readmeFile 경로 헬퍼). 코어 + 9 확장 = 10개 목차 생성.
- generate-docs-index.cjs scanExtensionApiReadmes — {modules,plugins}/_bundled/*/
 docs/api/README.md 를 패턴 스캔(확장명 하드코딩 0)해 ·AGENTS.md 에
 "확장 API 레퍼런스" 표로 자동 편입. 확장 추가/삭제 시 재생성만으로 반영.
- api-documentation.md 에 README 목차 규약 + 발견 경로 명문화. coverage.json 에
 api-doc-readme-index(manual-only) 등록. 루트 README API 레퍼런스 링크 갱신.

테스트: readmeIndex 목차 생성·멱등 2건 + 스캐너 편입·하드코딩부재 2건(node --test).
2026-07-08 18:22:35 +09:00
HeuJung 56a78e1d55 feat(api-docs): 요청/응답 예시 블록 + 8종 결함 근본 수정 + audit error 승격
API 레퍼런스에 요청 예시(raw HTTP)·응답 예시(envelope 전문)를 표준 포맷에
편입하고, 전 대상(코어+전 모듈+전 플러그인)에 방출했다. 예시 결함 8종
(multipart·Host 마스킹·write 실측·path 치환·현실적 바디·query 반영·중복
제거·민감값 마스킹)을 스캐폴더/프로브 공용 수정으로 근본 해소했다.

추가로 검증에서 드러난 결함을 마감했다:
- 응답 예시 body 의 절대 URL(페이지네이터·콜백) 호스트를 placeholder 로
 마스킹(maskResponseHost) — 요청 Host 헤더만 마스킹되던 누락 보완, 확장
 39건 정정
- api:docgen 커맨드 통합 테스트 신설(라우트 수집·확장 라우팅·rules 추출·
 멱등 재생성·check drift)
- module-routing.md URL 스킴 오류 정정(/api/admin → /api/modules)
- README·cheatsheet·api-documentation 에 API 문서 커맨드/레퍼런스 진입점 반영

전 대상 3칸 완료로 api-doc-coverage 룰을 warn→error 승격.
2026-07-08 18:22:34 +09:00
HeuJung 70452745c4 feat(api-docs): API 레퍼런스 문서 전면화 — 추출 파이프라인·전 대상 문서화·audit 강제
코드에만 존재하던 REST API 계약(약 663 엔드포인트)을 코어/확장 책임별로
분리된 마크다운 레퍼런스로 전면 문서화한다. 674개 규모에서 수기 문서는
반드시 drift 하므로 "코드 추출 → 스캐폴딩 → 사람이 서술 채움 → 하네스가
커버리지 강제" 하이브리드로 구성했다.

추출 파이프라인 (app/Support/ApiDoc):
- ApiRouteInventory / FormRequestIntrospector / ApiEndpointProbe(실측 HTTP) /
 ResponseSchemaInferrer / ApiDocScaffolder / ColumnCommentResolver /
 ResourceFieldDescriber / ParameterDescriber
- api:docgen 커맨드(--scope/--seed/--check/--dry-run/--base-url/--user) +
 응답/파라미터 in-place 백필 커맨드 2종(재생성 없이 TODO 셀만 치환, 멱등)
- ApiDocSampleSeeder 계약 + 코어/확장 시더로 실측용 완전 샘플 멱등 생성

문서화 (실측 기반, GET read-only 실호출):
- 코어 291엔드포인트 35파일(docs/backend/api) + 규정 docs/backend/api-documentation.md
- 확장: ecommerce(231)·board(80)·page(17)·hello_module(2)·pay_kginicis(22)·
 gdpr(15)·ckeditor5(2)·marketing(2)·verification_kginicis(1)
- 표준 4구성(헤더·요청 파라미터·응답 필드·에러 표) + 엔드포인트 용도 서술
- 파라미터 용도·응답 필드 설명 셀 전수 채움(도메인 지식 수기)

하네스:
- audit 룰 api-doc-coverage — API 표면(라우트/컨트롤러/FormRequest/Resource)
 변경 시 대응 문서 미동반이면 차단. 전 대상 문서 완비로 error 승격
- file-rules 리마인더(컨트롤러/라우트 편집 시), coverage.json, dev-dashboard 카드
- docs/backend/routing.md 확장 공개 API URL 스킴 정정(/api/modules|plugins/{id})
- /AGENTS.md/docs-index 동기
2026-07-08 18:22:33 +09:00
thisgun 79eb091351 docs(pay_kginicis): CHANGELOG [Unreleased] 항목을 [1.0.0] 으로 정리
미출시 1.0.0(공개 latest=beta.6) 이므로 [Unreleased] 의 테스트모드 경고·설정 오류 안내 항목을 [1.0.0] Fixed 로 흡수한다.
2026-07-08 16:39:46 +09:00
thisgun 848b408b60 Merge branch 'develop' of github-gnuboard:gnuboard/dev-g7 into thisgun/pay_develop 2026-07-08 16:23:21 +09:00
HeuJung 49b21e788c Merge branch 'develop' 2026-07-08 14:51:37 +09:00
HeuJung dbbc343734 chore(github): blank issue 생성 허용
이슈 템플릿 선택 없이 빈 이슈를 자유롭게 생성할 수 있도록
blank_issues_enabled 를 활성화한다.
2026-07-08 14:50:46 +09:00
HeuJung cb2b481caf Merge branch 'develop' 2026-07-08 13:55:16 +09:00
HeuJung 487283b33a fix(core): core:update 증분 모드가 _bundled 확장 파일을 반영하도록 protected 오버라이드
targets 에 명시된 {modules,plugins,templates,lang-packs}/_bundled 가
상위 protected(modules 등)에 걸려 3-way apply 목록에서 제외되던 결함 수정.
targets 가 protected 보다 더 구체적이면 오버라이드해 통과시켜, 코어 업데이트로
번들 확장 갱신 파일(composer.json·vendor-bundle.json 등)이 정상 반영되도록 한다.
자동 발견 폴백의 방어 및 prune 롤백 경로는 불변.

아울러 이슈 템플릿의 사용법 문의 링크를 Discussions 에서 sir.kr Q&A 로 전환.
2026-07-08 13:54:18 +09:00
HeuJung 212ecb3577 Merge branch 'develop' 2026-07-08 12:45:19 +09:00
HeuJung 6acc914100 fix(ecommerce): 버전 bump 시 누락된 vendor-bundle 재생성 (무결성 검증 복구)
에서 이커머스 모듈 버전을 1.0.0 → 1.0.1 로 올리며 composer.json version
필드를 변경했으나 vendor-bundle 을 재생성하지 않아, 번들 기록 해시와 실제
composer.json SHA 가 불일치. VendorIntegrityChecker 가 module:update 를 차단.
module:vendor-bundle 로 재생성해 해시를 재동기화하고 무결성 검증을 복구.
2026-07-08 12:43:22 +09:00
HeuJung e4f5319dad Merge branch 'develop' 2026-07-08 12:26:44 +09:00
HeuJung 683ebdc396 chore(release): 7.0.2 공개 release 누출 검사 대상 주석·시나리오 내부 단서 제거
코어/번들 확장의 주석·테스트·시나리오 yaml 에 남은 내부 역할 호칭("")과
 파일명 참조를 제거해 공개 release 누출 검사(error 0/warn 0)를 통과.
기술적 의미는 그대로 유지하고 비공개 단서만 정리.
2026-07-08 12:26:00 +09:00
HeuJung 3ab879ea9a Merge branch 'develop' 2026-07-08 12:20:22 +09:00
HeuJung e7bb07f80e docs(changelog): 7.0.2 릴리스 대상 코어·확장·언어팩 CHANGELOG 날짜를 출시일(2026-07-08)로 통일
7.0.2 번들과 함께 버전이 상향되는 코어(7.0.2), 모듈(board/ecommerce/page 1.0.1),
템플릿(admin_basic/basic 1.0.1) 및 대응 번들 언어팩의 릴리스 날짜를 오늘로 정렬.
이니시스 결제(pay_kginicis 1.0.0)는 기존 출시일(07-01) 유지.
2026-07-08 11:53:40 +09:00
정정홍andGitHub 93f6bd5b71 Merge pull request from gnuboard:HeuJung/issue452
feat(core): core:update 기본을 코어 변경분만 적용(3-way skip)으로 전환
2026-07-08 10:21:41 +09:00
HeuJung d0943d6cb6 feat(core): core:update 기본을 코어 변경분만 적용(3-way skip)으로 전환
core:update Step 7 이 targets 전체를 무조건 재복사하고 orphan 을 삭제하여
사용자 .htaccess 커스텀 블록·_bundled 커스텀 확장이 소실되던 사고(공개 )를
차단한다. 백업(base)↔_pending(theirs) 3-way md5 비교로 코어가 실제 변경/추가한
파일만 산출해 적용하고, 코어 미변경 파일은 복사·권한·mtime 을 전부 스킵해 현재
디스크 상태(사용자 수정 포함)를 보존한다. 전체 덮어쓰기 + orphan 정리가 필요하면
--prune 으로 opt-in. 백업 부재(--no-backup) 시 base 가 없어 전체 덮어쓰기로 안전
회귀 + 안내를 출력한다.

기존 stale 정리 테스트는 새 기본(증분)에서 orphan 을 삭제하지 않으므로 prune
컨텍스트로 전환했고, 증분 3-way 판정·목록 적용·fallback 단위 테스트와 --prune
커맨드 표면 테스트를 추가했다.

번들 ja 언어팩은 코어 apply_mode 안내 키(g7-core-ja)와 함께, 같은 빌드에서
동기화된 관리자↔유저 크로스링크 라벨(board/ecommerce/admin_basic/basic)의 일본어
번역을 반영하고 각 패키지 버전·CHANGELOG 를 갱신했다.
2026-07-07 22:45:16 +09:00
thisgun 88116de788 fix(kginicis): 설정 저장 오류 안내 개선 2026-07-07 18:19:17 +09:00
thisgun c6cab1f668 fix(nicepayments): 주문설정 테스트결제 경고 표시 개선 2026-07-07 16:38:47 +09:00
thisgun f744b816b6 fix(nhnkcp): 주문설정 테스트결제 경고 표시 개선 2026-07-07 16:38:32 +09:00
thisgun b9355f051c fix(kginicis): 주문설정 테스트결제 경고 표시 개선 2026-07-07 16:38:15 +09:00
thisgun ff45a19b4b fix(payment): NHN KCP 부분취소 가능금액 전달값 수정 2026-07-07 14:11:57 +09:00
thisgun cebe352e46 fix(payment): NHN KCP close-report 경합 처리 보강 2026-07-07 14:11:16 +09:00
thisgun 48f877bdcd fix(payment): NHN KCP 관리자 결제 상태 표시 보강 2026-07-07 14:10:35 +09:00
정정홍andGitHub efc8a7ae6b Merge pull request from gnuboard:HeuJung/issue450
feat(board,ecommerce,template): 관리자↔유저 크로스 이동 링크 + 상품코드·게시판 슬러그 라우팅 전환
2026-07-07 10:42:23 +09:00
HeuJung 6505d85959 feat(board,ecommerce,template): 관리자↔유저 크로스 이동 링크 + 상품코드·게시판 슬러그 라우팅 전환
관리자·유저 화면 왕복 이동 수단을 추가하고, 상품/게시판 URL 식별자를
사람이 읽을 수 있는 값(상품코드/슬러그)으로 전환한다.

크로스 이동 링크 (전부 새 탭):
- 관리자 사이드바 "사이트 보기" + 모바일 드로어 상단 잘림 수정
- 관리자 게시물 조회 ↔ 유저 게시판, 유저 상품 상세 ↔ 관리자 상품 수정
- 유저 화면의 관리자 진입은 is_admin 이 아니라 세부 권한 플래그로 게이트
 (게시판 can_access_admin, 상품 can_update)

상품 라우팅 id → product_code:
- Product::resolveRouteBinding(code/id) + 공개 라우트 정규식 확장
- 모든 상품 링크(목록·최근본·검색·장바구니·주문내역·문의내역·관리자) 전환
- 상품 검색을 상품코드·번호 양쪽 조회 가능하게 확장(관리자+통합검색)

게시판 라우팅 board id → slug:
- Board::resolveRouteBinding(slug/id) + 관리자 라우트 slug 전환
- 생성 후 리다이렉트·토글·삭제·수정·add-to-menu·편집기 스코프 전수 정정
- BoardController::addToAdminMenu 를 Board 바인딩으로 정합

검증: 프론트 레이아웃 Vitest 36건 green, 백엔드 회귀 테스트 추가,
g7.dev 라이브 실측(생성/삭제/메뉴추가 slug, 문의내역·검색 상품코드 URL).
2026-07-07 10:40:22 +09:00
정정홍andGitHub 32b1b11930 Merge pull request from gnuboard:HeuJung/issue446
fix(installer): Read DB 미사용 시 Write DB만 참조하도록 수정 + GitHub 이슈 템플릿 신설
2026-07-06 17:18:04 +09:00
HeuJung cbc91117d7 fix(installer): Read DB 미사용 시 Write DB만 참조하도록 수정 + GitHub 이슈 템플릿 신설
설치 시 use_read_db=false 인데도 db_read_host 잔존값으로 read 커넥션을
조립해 5단계 마이그레이션이 실패하던 문제를 수정한다. "read 사용 여부"
판정이 세 지점에서 달랐던 것이 근본 원인으로, use_read_db 플래그를 단일
SSoT 로 통일한다.

- installer-runtime.php: read 블록을 use_read_db 게이트로. 미사용 시 runtime
 에 read 키 미생성 → 하류가 write 로 자동 fallback. mergeRuntimeIntoEnv 도
 read 부재 시 DB_READ_* 를 빈 값으로 기록(write 복사 제거).
- config/database.php: read env 를 Elvis(?:) fallback 으로 복원. 중첩
 env(A, env(B)) 는 DB_READ_* 가 빈 문자열일 때 fallback 이 무력화되므로,
 빈 값까지 흡수하는 ?: 를 사용(tinker 실측 확인). 설치 이후 운영 중
 DB_READ_* 공란 시에도 SELECT 가 write DB 로 정확히 향하게 한다.
- functions.php / request-handler.php: read 미사용 시 .env DB_READ_* 빈 값
 기록 + state.json 잔존 read 필드 제거(2단계 방어).

기존 설치 환경은 .env 에 DB_READ_* 가 이미 채워져 Elvis fallback 미발동 →
동작 불변이라 업그레이드 스텝 불필요(실측 확인).

무관 사전 실패 테스트(beta.7 stale-ENV 복구)는 코어 version 이 beta.7 이
아니면 skip 하도록 가드 헬퍼 추가(프로덕션 한시 가드 전제를 테스트에 반영).

자유형식 제보에서 진단 축(버전/활성 확장/커스텀 자산/환경)이 누락되기 쉬워
G7 전용 이슈 템플릿을 추가한다. bug_report / feature_request / upgrade_issue
+ config.yml(blank 비활성, Discussions·보안신고 링크).
2026-07-06 17:15:29 +09:00
정정홍andGitHub 94d2cd2c11 Merge pull request from gnuboard:HeuJung/issue445
HeuJung/issue445
2026-07-06 17:10:22 +09:00
HeuJung 157485003d fix(installer): 요구사항 검증의 디렉토리 권한 재귀 스캔 깊이 제한으로 2단계 지연 해소
인스톨러 2단계(설치 환경 확인)의 checkDirectoryPermissions 가 storage 를
재귀 스캔하는데, checkSubdirectoriesRecursive 가 깊이 제한 없이 무한 재귀하여
storage/framework/testing 하위에 누적된 수만 개 디렉토리를 전부 순회 → 응답이
8초 이상 지연되던 문제.

쓰기 권한 문제는 트리 상위 얕은 깊이에서 결정되므로(상위 쓰기 가능 시 하위 상속),
재귀에 maxDepth(기본 1) 제한을 추가. 기본값이 있어 호출부는 변경 불필요.
실측: checkDirectoryPermissions 8.4초 → 0.086초.
2026-07-06 17:09:42 +09:00
HeuJung 342486a20b fix(installer): Composer 설치 로그 인코딩으로 인한 진행 상태 응답 빈 본문 보완
Composer 실행 모드로 설치할 때 일부 Windows 환경에서 composer 출력의
invalid UTF-8 바이트가 정규화 없이 로그에 저장되어, 진행 상태 응답의
json_encode 가 false 를 반환 → 빈 본문(HTTP 200) → 프론트 파싱 실패로
진행 상황이 표시되지 않던 문제.

- 로그 저장(addLog)에서 mb_scrub 로 항상 유효 UTF-8 보장 (근본)
- composer 스트림 라인 정규화 (SSE/폴링 공통 경계)
- 폴링(state-management)·SSE(progress-emitter) 응답 json_encode 에
 JSON_INVALID_UTF8_SUBSTITUTE + false 가드 (양쪽 안전망)

무관 결함 동시 정리: 신규 격리 테스트가 트리거한 BASE_PATH 누수로
깨지던 DeleteDirectoryTest·InstallerWindowsCommandsTest 를 프로젝트 루트
절대경로 앵커로 격리.

공개 이슈 gnuboard/g7 (@kitrio 제보).
2026-07-06 17:09:41 +09:00
thisgunandCodex e7e2936ea0 fix(payment): KG 이니시스 close-report 동시성 보강
PC 승인 콜백 처리 중 close-report가 동시에 도착해 결제완료 주문의 옵션을 취소로 덮는 race를 막기 위해 같은 주문 락을 공유하고 최신 주문 상태를 다시 확인합니다.

Co-Authored-By: Codex <codex@openai.com>
2026-07-06 14:38:58 +09:00
thisgunandCodex 8429d8ada5 fix(payment): KG 이니시스 결제 콜백 안정성 보강
KG 이니시스 결제에서 통화 스냅샷 이상 시 서버 오류로 새지 않도록 검증 실패/재통보 가능한 응답으로 처리하고, 주문 단위 콜백 락으로 동시 완료 콜백의 중복 후속 처리를 줄입니다.

백엔드 테스트의 통화 스냅샷 fixture도 최신 계약에 맞춰 복구합니다.

Co-Authored-By: Codex <codex@openai.com>
2026-07-06 13:46:35 +09:00
thisgun d18aaab1fd fix(payment): 나이스페이 SignData 방어와 비회원 영수증 조회 보강 2026-07-06 12:26:56 +09:00
thisgun 646efeb91c fix(payment): NHN KCP 비회원 영수증 조회 지원 2026-07-06 12:26:39 +09:00
thisgun dee5a12ded fix(payment): NHN KCP 다통화 결제 금액 검증 예외 처리 보강 2026-07-06 10:54:32 +09:00
eunhyeandGitHub 5ee1d1f2b5 Merge pull request from gnuboard/chym1217/issue424
fix(page): 페이지 검수 이슈 일괄 수정
2026-07-06 09:54:05 +09:00
chym1217 9d7bccdfb9 chore(page): 코드·테스트·라우트 주석 정리 및 CHANGELOG 날짜 정정
코드/테스트 주석에 남아 있던 내부 이슈번호(-14, -15 등)를
규정에 따라 제거하고, 첨부 라우트 주석의 오해 소지 표현을 정정한다.
1.0.1 릴리스 날짜를 2026-07-06 으로 맞춘다.

- Listener/커맨드 주석, Feature/Unit/레이아웃/E2E 테스트 주석
- 시나리오 매니페스트 설명 (audit:allow 지시어는 보존)
- 첨부 라우트: admin 그룹의 오해성 "단일화" 주석 제거,
 공개 pages 그룹 각 라우트에 게시판과 동일한 방식·사유를 간결히 명시
 (미리보기=공개 이미지, 다운로드=발행 상태 조건부 접근 제어)
- CHANGELOG [1.0.1] 날짜를 2026-07-06 으로 정정
 (page 모듈 / basic·admin_basic 템플릿 / 대응 ja 언어팩 3종)
- 로직·동작 변경 없음
2026-07-06 09:37:07 +09:00
chym1217 155b95989a fix(page): 페이지 관리 화면 UI 정돈 및 모바일 검색·취소 이동 결함 수정
목록·상세·등록/수정 화면의 버튼·선택 상자·카드 여백을 시맨틱 클래스로 통일하고,
검수 중 발견한 동작 결함을 함께 수정한다.

- 모바일 검색이 저장소 불일치(_global 저장 ↔ _local 읽기)로 미작동하던 문제 수정
- 폼 취소 이동을 수정=상세 / 등록=목록으로 명시 (기존 navigateBack 안티패턴 제거)
- 검증 에러 박스 제목 색/굵기·하단 여백, 저장 버튼 시맨틱화(spinner 포함)
- 상세: 첨부 기본 열림 + 항목별 구분선, 다크모드 카드 대비 개선,
 메타 정보 2열 배치, 카드 헤더 통일(이중 여백·구분선 제거), 실패 토스트 실제 메시지 노출
- 언어 탭이 모바일에서 줄바꿈되도록 반응형 처리

레이아웃 렌더 회귀 테스트 113건 통과 + 취소 이동 E2E spec 추가.
2026-07-06 09:37:07 +09:00
chym1217 b98a536162 fix(page): 페이지 첨부 썸네일·다운로드 미노출 수정
썸네일(<img>)·다운로드(<a>)는 브라우저 직접 GET 이라 토큰을 실을 수 없는데
커밋 d48c684 가 첨부 URL 을 auth:sanctum admin 라우트로 바꿔 401 회귀가 났다.
게시판·이커머스 표준으로 정렬한다.

- 썸네일(preview): 공개 hash 서빙으로 단일화 (발행/미발행 무관 200)
- 다운로드: 공개 hash 라우트 + 권한 게이트(발행=누구나 / 미발행=pages.read 관리자만)
- 프론트 다운로드를 downloadAttachment 핸들러(토큰 fetch→blob)로 전환
- 다운로드 아이콘 arrow-down-tray(FA 미존재) → download
- 사용자 페이지에도 이미지 첨부 썸네일 추가 (관리자 상세와 일관)
- admin 첨부 preview/download 데드 라우트·메서드 제거
- iteration 안 정적 id 를 att_idx 동적 id 로 교정 (위생 정리)
2026-07-06 09:37:06 +09:00
chym1217 98989e3c2c fix(page): 첨부 업로드 검증 실패 시 사용자 안내 토스트 노출
- 페이지 폼 FileUploader 에 onUploadError→toast 연결 (용량/확장자 초과 안내)
- 사용자 템플릿(sirsoft-basic) 개수 초과 시 onUploadError 발화 추가 (silent 무시 방지)
- 개수 초과 문구를 이미지 전용에서 일반 파일 범용 공통 메시지로 통일 (ko/en/ja)
2026-07-06 09:37:06 +09:00
chym1217 2473ca7242 fix(page): 버전 복원 후 검색엔진 캐시가 즉시 갱신되도록 after_restore 훅 구독
버전 복원 시 발화되는 after_restore 훅을 SeoPageCacheListener가
구독하지 않아, 복원 후에도 검색봇에 복원 전 버전이 계속 노출되던
문제를 수정. 복원은 콘텐츠 변경의 일종이므로 수정/삭제와 동일한
onPageChange 무효화 정책을 재사용.
2026-07-06 09:37:05 +09:00
chym1217 ba2adcf748 fix(page): 미발행 페이지를 권한 관리자가 사용자 화면에서 미리보도록 수정
미발행 페이지는 관리자여도 사용자 화면(/page/{slug})에 접근할 수 없어
발행 전 미리보기가 불가능했다. 페이지 조회 권한(sirsoft-page.pages.read)
보유 관리자는 미발행 페이지를 사용자 화면에서 미리볼 수 있게 하고, 미리보기
화면에 안내 배너를 노출한다. 비로그인·일반 회원은 기존대로 404를 유지한다.

- getPublishedPageBySlug 에 미리보기 허용 플래그 추가 (권한 판정은 컨트롤러)
- 미발행 미리보기 시 첨부 URL 을 admin 컨텍스트로 전환해 첨부까지 미리보기
- page/show.json 에 미발행 미리보기 안내 배너 + ko/en/ja 다국어
- E2E 시드 커맨드 구현 + 커맨드 등록 + 사용자 화면 미리보기 spec
2026-07-06 09:37:05 +09:00
chym1217 8cf55ad88f fix(page): 버전 이력 변경내역을 다국어 라벨로 표시 + E2E 인프라 신설
- 페이지 상세 버전 이력의 "변경내역"이 title·content 등 영문 필드명으로
 노출되던 문제를 field_labels 다국어 매핑으로 정정 (ko/en/ja)
- sirsoft-page 모듈에 Playwright E2E 인프라를 이커머스와 동일 패턴으로 신설
 (config·fixture·seed stub + spec 3건, data-testid 보강 전까지 skip placeholder)
- 출시된 1.0.0 이후 변경이므로 module 1.0.1 bump (3곳+lock 동기화),
 ja 언어팩 1.0.1 bump
2026-07-06 09:37:04 +09:00
chym1217 4c4f90a091 fix(page): 페이지 목록 검색 범위·페이지당 기본값·긴 검색어 오류 정정
- 검색 조건 '전체'가 슬러그를 검색하도록 통합하고, 검색 대상을 제목·슬러그로
 한정(본문 제외 — UI '제목 또는 슬러그로 검색'과 정합). '제목' 선택 시 제목만 검색.
- 페이지당 표시 개수 기본값을 백엔드도 20으로 통일(화면 표시와 실제 조회 일치).
- 긴 검색어(공백 없는 한글 다수)가 FULLTEXT 토큰 한도를 넘겨 500을 유발하던 문제를
 검색어 100자 제한으로 422 처리. 검증 메시지 필드명을 '검색어'로 노출.
2026-07-06 09:37:03 +09:00
chym1217 bb654fb56c fix(page): 일괄 발행/미발행 시 활동 이력이 기록되도록 after_publish 훅 발화
페이지 목록 일괄 발행/미발행이 after_publish 훅을 발화하지 않아
활동로그에 발행/미발행이 기록되지 않던 문제를 수정한다.

- bulkChangePublishStatus 를 DB::transaction 내부에서 페이지별로
 before_publish -> update -> after_publish 발화하도록 재작성 (per-item 로그)
- 존재하지 않는 id 가 섞이면 전체 롤백 (all-or-nothing)
- 단일 mass UPDATE 전용 bulkUpdatePublished 제거 (dead code, 의존 확장 영향 없음)
- 일괄 발행 성공 응답 메시지의 :count 미치환 정정
 (success 의 4번째 인자 messageParams 로 치환 파라미터 전달)
- 회귀 테스트 추가 (훅 발화 횟수/published 인자/롤백/메시지 치환)
2026-07-06 09:37:02 +09:00
chym1217 0de58baf46 fix(page): 첨부 URL 컨텍스트·업로드 순서·등록폼 잔존·슬러그 미리보기·SEO 캐시 정정
- 관리자 첨부 다운로드/미리보기를 admin 라우트 URL로 분기 (미발행 페이지 첨부를 관리자가 열 수 있도록)
- 다중 업로드 시 올린 순서대로 저장 (이커머스 패턴: temp_key 연결 + form.attachments 동기화, 코어/공용 컴포넌트 무수정)
- 수정→등록 전환 시 이전 첨부·제목·슬러그·검증안내 잔존 차단
 (initialFiles를 _local.form.attachments에 바인딩 + init_actions에서 form null→기본값 초기화)
- 등록 폼 슬러그 미리보기 {{slug}} 미치환 수정 (빈 슬러그를 못 거르던 ?? → || fallback)
- 페이지 변경 시 상세 SEO 캐시 무효화 URL 패턴을 단수형(/page/{slug})으로 정정
2026-07-06 09:37:02 +09:00
chym1217 b03042b6d7 fix(page): 페이지·첨부 삭제를 물리 삭제로 전환해 슬러그 재사용 가능하도록 수정
페이지 삭제 후 동일 슬러그로 재생성 시 "이미 사용 중" 오류가 나던 문제 수정.
슬러그 중복 체크(SoftDeletes 스코프로 삭제 행 제외)와 생성 검증(Rule::unique로
삭제 행 포함)이 soft delete를 다르게 취급해 발생. Page/PageAttachment의
SoftDeletes를 제거하고 deleted_at 컬럼을 마이그레이션으로 정리해 두 경로를 정합화.
2026-07-06 09:37:01 +09:00
정정홍andGitHub d6baa1c299 Merge pull request from gnuboard:HeuJung/issue436
fix(ecommerce): 관리자·유저 구매확정 적립 정합성 및 마일리지 게이트 통일
2026-07-06 09:32:57 +09:00
HeuJung 4a90046d54 fix(ecommerce): 관리자·유저 구매확정 적립 정합성 및 마일리지 게이트 통일
관리자 주문옵션 구매확정 경로가 유저 셀프 확정과 부수효과가 어긋나던
문제를 바로잡고, 검수에서 드러난 적립 금액·기능 토글 결함을 함께 수정한다.

- confirmed_at 대칭화: 관리자 CONFIRMED 전이 시 옵션·주문 헤더 confirmed_at
 기록 + order-option.after_confirm 훅 발화(즉시적립·활동로그·리뷰기한 정상화)
- 나눠 확정 델타 적립: 멱등 기준을 "적립 건수"에서 "금액 델타"로 전환.
 분할·병합으로 목표 적립액이 늘어도 차액만큼 정확히 지급(기존 lot 증액,
 적립 내역 한 줄 유지). 병합 시 적립 거래를 생존 옵션으로 이전
- 마일리지 미사용 시 즉시적립도 미지급하도록 통일(지연적립 게이트와 일관)

관리자·회원·비회원 셀프확정 전 표면 및 지연적립 스케줄러 연계를
Chrome MCP로 실측 검증(전 시나리오 통과). 회귀 테스트 12건 추가.
2026-07-06 09:32:16 +09:00
정정홍andGitHub d6555c0e7f Merge pull request from gnuboard:HeuJung/issue440
HeuJung/issue440
2026-07-06 09:15:17 +09:00
HeuJung d466aae6eb Merge origin/HeuJung/issue440 into HeuJung/issue440
원격 커밋(f267f21, 버전 bump 이전 7.0.1)과 로컬 재작성본
(7.0.2 bump 반영)이 갈라져 발생한 충돌 해결.

충돌 해결 방침 — 로컬(7.0.2) 기준 채택:
- docs/extension/module-assets.md: 번들 gzip 압축 섹션 유지
- template-engine/CHANGELOG.md: 루트 [7.0.2] 참조 유지
- history/20260704_2200: 7.0.2 bump 경위 기록 유지
- CHANGELOG.md: 자동 병합이 번들 병합 항목을 [7.0.1]에 중복
 삽입한 것을 제거 (해당 항목은 [7.0.2]에만 존재)
2026-07-05 22:36:58 +09:00
HeuJung c819820573 perf(core): JSON 서빙 네트워크 오버헤드 최적화
실측(g7.dev) 결과 줄바꿈 제거·gzip 은 이미 적용된 상태였고,
남은 실질 개선 3종을 구현:

- 모든 API 응답에 JSON_UNESCAPED_UNICODE 적용 — 한글이 \uXXXX(6B)로
 팽창하던 것을 raw UTF-8(3B)로 직렬화 (lang pack gzip -9.1%)
- 공개 레이아웃 서빙에서 개발자용 comment/_comment 재귀 제거,
 편집 모드(with_source_meta)는 보존 (layout gzip -34.9%)
- 번들 JS/CSS(BinaryFileResponse) gzip 압축 사각지대 해소 —
 기존 미들웨어가 getContent==false 로 압축을 건너뛰던 문제 (-73~77%)
2026-07-05 22:31:04 +09:00
HeuJung a958443d69 feat(core): 모듈/플러그인 프론트엔드 에셋 서버측 번들 병합
활성 모듈/플러그인마다 프론트엔드 IIFE JS·CSS 가 개별 HTTP 요청으로 로드되어
확장 수에 비례해 요청이 늘어나는 오버헤드를, 종류별 1개 번들로 서버 병합해 줄인다.
각 확장 IIFE 자가등록 계약(핸들러/리스너/preblocker)은 priority 순 물리 병합으로
실행 순서가 보존되어 그대로 동작한다.

## 백엔드
- 신규 ExtensionBundleService — 정렬(uasort priority)·필터(global)·concat(\n;\n
 구분자로 ASI 경계 보호)·소스맵(prod strip / dev rewrite)·version-in-path 디스크
 캐시·확장별 fine-grained try/catch(한 확장 실패가 번들 전체를 붕괴시키지 않음)
- AbstractModule/AbstractPlugin::getBuiltAssetAbsolutePaths — _bundled 경로
 정확 해석용 절대경로 게터(getAssetFilePath 하드코딩 복제 회피)
- PublicModule/PluginController::serveBundle{Js,Css} + routes/api.php 4개
 (/api/{modules,plugins}/bundle.{js,css}?v={version}, fileResponse ETag/304 재사용)
- config/filesystems.php ext-bundles 디스크 + storage/.gitignore + 루트 .gitignore
- 신규 ext-bundles:cleanup 커맨드 + {module,plugin,template}:cache-clear 번들
 정리 통합 + dev-dashboard 버튼

## 프론트엔드 (engine-v1.52.0)
- TemplateApp.loadExtensionAssets — 개별 로딩 → bundleUrls 기반 모듈→플러그인
 순 번들 로딩. bundleUrls 부재 시 개별 로딩 폴백(회귀 안전)
- ModuleAssetLoader.loadBundle + parseBundleUrlsFromConfig + ExtensionBundleUrls
- TemplateComposer/UserTemplateComposer bundleUrls 뷰변수 + app/admin.blade.php
- core:build 재빌드 산출물(public/build/core/*)

## 버전
- 코어 7.0.1 → 7.0.2 bump. 7.0.1 이 이미 2026-07-03 공개 릴리스이므로 그 위 신규
 기능은 +1 bump(new-bump). config/app.php(SSoT) + .env{.example,.testing.example,
 .testing} + README 뱃지 + INSTALL 예시 + 루트 CHANGELOG 신규 [7.0.2] 섹션 동기화.
- 확장 g7_version 상향 안 함(>=7.0.0 유지) — 6개 프론트 에셋 확장이 신규 코어
 표면을 자기 코드에서 호출하지 않고 개별 서빙 라우트 존치+프론트 폴백으로 코어
 7.0.0 에서도 동작 → 실제 의존 없음( 결정).
- 부수 결함 수정: config/app.php 편집으로 활성화된 core-update-targets-coverage
 룰이 v1.51.0 에서 추가된 vite.config.{editor,devtools}.js 가 app.update.targets
 에 미등재됨을 검출 → 추가. 코어 업데이트 시 편집기/devtools 빌드 설정 갱신 누락
 방지(CHANGELOG [7.0.2] Fixed).

## 규정/audit/문서/테스트
- ("확장 번들 병합" 절+파일유형표+E2E경로) / AGENTS.md /
 docs/extension/module-assets.md / docs/cheatsheet.md
- coverage.json 4항목 + codified 룰 4종(url-same-origin, concat-separator,
 asset-path-getter, clears-template-caches-no-file-delete)
- 루트 CHANGELOG [7.0.2] Added/Fixed / 엔진 CHANGELOG engine-v1.52.0 Added
- Vitest 11(loadBundle + concat 자가등록) + PHPUnit 17(Service 12 + Feature 5)
 + Playwright spec + 시나리오 매니페스트
- 브라우저 실측(g7.dev): 홈/관리자 번들 각 1건·개별 iife 0건, gdpr 인터셉터
 정상, 활성화 토글 반영, 장바구니→체크아웃·게시판 글쓰기 정상. g7_2.dev 미적용
 대비 확장 요청 11~12→4건, DOMContentLoaded ~843~957→605~741ms(페이로드 무손실)

## 도구 보완
- pre-commit-review 스킬 에 엔진 경로 감지 게이트(5-pre + 보고 5e행) 신설 —
 매핑 표에 엔진 파일이 있으면 엔진 CHANGELOG 판정을 강제(이번 초기 누락 재발 방지)
2026-07-05 21:57:39 +09:00
HeuJung f267f21ea6 feat(core): 모듈/플러그인 프론트엔드 에셋 서버측 번들 병합
활성 모듈/플러그인마다 프론트엔드 IIFE JS·CSS 가 개별 HTTP 요청으로 로드되어
확장 수에 비례해 요청이 늘어나는 오버헤드를, 종류별 1개 번들로 서버 병합해 줄인다.
각 확장 IIFE 자가등록 계약(핸들러/리스너/preblocker)은 priority 순 물리 병합으로
실행 순서가 보존되어 그대로 동작한다.

## 백엔드
- 신규 ExtensionBundleService — 정렬(uasort priority)·필터(global)·concat(\n;\n
 구분자로 ASI 경계 보호)·소스맵(prod strip / dev rewrite)·version-in-path 디스크
 캐시·확장별 fine-grained try/catch(한 확장 실패가 번들 전체를 붕괴시키지 않음)
- AbstractModule/AbstractPlugin::getBuiltAssetAbsolutePaths — _bundled 경로
 정확 해석용 절대경로 게터(getAssetFilePath 하드코딩 복제 회피)
- PublicModule/PluginController::serveBundle{Js,Css} + routes/api.php 4개
 (/api/{modules,plugins}/bundle.{js,css}?v={version}, fileResponse ETag/304 재사용)
- config/filesystems.php ext-bundles 디스크 + storage/.gitignore + 루트 .gitignore
- 신규 ext-bundles:cleanup 커맨드 + {module,plugin,template}:cache-clear 번들
 정리 통합 + dev-dashboard 버튼

## 프론트엔드 (engine-v1.52.0)
- TemplateApp.loadExtensionAssets — 개별 로딩 → bundleUrls 기반 모듈→플러그인
 순 번들 로딩. bundleUrls 부재 시 개별 로딩 폴백(회귀 안전)
- ModuleAssetLoader.loadBundle + parseBundleUrlsFromConfig + ExtensionBundleUrls
- TemplateComposer/UserTemplateComposer bundleUrls 뷰변수 + app/admin.blade.php
- core:build 재빌드 산출물(public/build/core/*)

## 버전 동기화 판정 ( 결정)
코어 add-only(개별 서빙 라우트 존치 + 프론트 폴백). 6개 프론트 에셋 확장이 신규
코어 표면을 자기 코드에서 호출하지 않아 코어 7.0.0 에서도 동작 → 실제 의존 없음.
확장 g7_version 상향 안 함(>=7.0.0 유지). 코어 버전 무변경(7.0.1 accumulate).

## 규정/audit/문서/테스트
- ("확장 번들 병합" 절+파일유형표+E2E경로) / AGENTS.md /
 docs/extension/module-assets.md / docs/cheatsheet.md
- coverage.json 4항목 + codified 룰 4종(url-same-origin, concat-separator,
 asset-path-getter, clears-template-caches-no-file-delete)
- 루트 CHANGELOG [7.0.1] Added / 엔진 CHANGELOG engine-v1.52.0 Added
- Vitest 11(loadBundle + concat 자가등록) + PHPUnit 17(Service 12 + Feature 5)
 + Playwright spec + 시나리오 매니페스트
- 브라우저 실측(g7.dev): 홈/관리자 번들 각 1건·개별 iife 0건, gdpr 인터셉터
 정상, 활성화 토글 반영, 장바구니→체크아웃·게시판 글쓰기 정상. g7_2.dev 미적용
 대비 확장 요청 11~12→4건, DOMContentLoaded ~843~957→605~741ms(페이로드 무손실)

## 도구 보완
- pre-commit-review 스킬 에 엔진 경로 감지 게이트(5-pre + 보고 5e행) 신설 —
 매핑 표에 엔진 파일이 있으면 엔진 CHANGELOG 판정을 강제(이번 초기 누락 재발 방지)
2026-07-04 22:19:55 +09:00
thisgun c59819645a fix(payment): 나이스페이 인증 실패 재시도 상태 유지
인증 단계 실패는 PG 승인 전이므로 주문과 결제, 주문옵션을 취소 상태로 전환하지 않고 체크아웃에서 재시도할 수 있게 한다.
2026-07-04 12:48:53 +09:00
thisgun 1cdef4513a fix(payment): 나이스페이 다통화 결제 예외 처리 보강
환율 스냅샷이 누락되거나 0인 주문에서 SignData와 콜백 처리 중 통화 예외가 일반 승인 오류로 뭉개지지 않도록 실패 사유를 분리한다.
2026-07-04 11:56:59 +09:00
thisgun 5a9ad97327 fix(payment): 나이스페이 간편결제 표시 라벨 보강
나이스페이 간편결제 선택값을 콜백까지 보존하고 주문완료, 마이페이지, 관리자 주문목록/상세에서 실제 간편결제명을 표시한다.
2026-07-04 10:05:24 +09:00
thisgun b428734a06 fix(payment): NHN KCP 콜백 동시성 락 추가 2026-07-03 21:58:48 +09:00
thisgun 0e9bb39980 fix(payment): NHN KCP 간편결제 표시 라벨 보강 2026-07-03 19:54:51 +09:00
thisgun 2a9f521487 fix(payment): NHN KCP 간편결제 메타와 중복 콜백 처리 보강 2026-07-03 19:03:54 +09:00
thisgun 76f7300e8f test(payment): NHN KCP 결제완료 부작용 통합 테스트 추가
NHN KCP 콜백 성공 후 코어 주문 완료 흐름까지 실행해 재고, 쿠폰, 마일리지, 장바구니 정합성을 자동 검증합니다.
2026-07-03 18:18:24 +09:00
thisgun 022d629144 test(payment): NHN KCP 테스트 주문 통화 스냅샷 보정
NHN KCP 결제 콜백/재시도/창닫기/모바일 승인 테스트가 현재 주문 금액 계산 로직의 KRW 통화 스냅샷 계약을 따르도록 보정합니다.
2026-07-03 17:55:12 +09:00
thisgun f0d44989e1 Merge branch 'develop' of github-gnuboard:gnuboard/dev-g7 into thisgun/pay_develop 2026-07-03 15:10:45 +09:00
thisgun 9863ea9d3a fix(payment): KG이니시스 간편결제 설정 노출 복구
신규 설치 사이트에서도 이커머스 주문설정에서 KG 이니시스 국내 간편결제 수단을 켤 수 있도록 배포 원본의 등록 조건을 정리합니다.
2026-07-03 11:51:12 +09:00
thisgun 479d537dfb Merge branch 'develop' of github-gnuboard:gnuboard/dev-g7 into thisgun/pay_develop 2026-07-02 20:11:44 +09:00
thisgun 657f546efc chore(payment): NHN KCP와 NICE 결제 플러그인 개발 브랜치 분리
정식 develop에서는 기본 번들 정책에 따라 제거된 결제 플러그인을 별도 개발 브랜치에서 계속 유지보수할 수 있도록 복원한다.
2026-07-02 20:03:22 +09:00
4156 changed files with 692156 additions and 33263 deletions
+12 -3
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.1
APP_VERSION=7.0.9
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -22,7 +22,9 @@ BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
# 설치 직후 기본값은 운영 기준(error). 관리자 환경설정 > 고급에서 조정하며,
# 그 값이 이 항목보다 우선합니다. debug 로 두면 정상 동작 기록까지 매 요청 쌓입니다.
LOG_LEVEL=error
DB_CONNECTION=mysql
@@ -36,7 +38,9 @@ DB_WRITE_PASSWORD=
# Database Table Prefix (optional)
DB_PREFIX=g7_
# Read Database Connection (Optional - falls back to WRITE if not set)
# Read Database Connection (Optional)
# 비워두면 각 항목이 대응하는 DB_WRITE_* 값으로 대체됩니다 (config/database.php).
# Read 전용 DB(리플리카)를 쓸 때만 값을 채우세요.
DB_READ_HOST=
DB_READ_PORT=
DB_READ_DATABASE=
@@ -96,6 +100,11 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
# AWS_URL=
# AWS_ENDPOINT=
# ATTACHMENT_DISK=
VITE_APP_NAME="${APP_NAME}"
+7 -2
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.1
APP_VERSION=7.0.9
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -34,7 +34,7 @@ DB_WRITE_PASSWORD=
DB_PREFIX=g7_
# Read Database Connection (테스트에서는 WRITE와 동일하게 설정)
# database.php의 read 기본값이 WRITE와 다르므로, 명시 설정 필수
# 비워두면 config/database.php 가 WRITE 값으로 대체하나, 테스트 DB 명시를 위해 값을 채워 둔다.
DB_READ_HOST=127.0.0.1
DB_READ_PORT=3306
DB_READ_DATABASE=g7_testing
@@ -77,6 +77,11 @@ AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# 아래 3키는 사용할 때만 주석을 해제하세요 — 값 없는 `KEY=` 는 "미설정" 이 아니라
# 빈 문자열로 적용됩니다 (S3 공개 URL / S3 호환 엔드포인트 / 첨부 디스크 강제 지정)
# AWS_URL=
# AWS_ENDPOINT=
# ATTACHMENT_DISK=
VITE_APP_NAME="${APP_NAME}"
+71
View File
@@ -0,0 +1,71 @@
---
name: 🐛 버그 제보
about: 그누보드7(G7) 사용 중 발견한 버그를 제보합니다.
title: "[Bug] "
labels: bug
assignees: ''
---
## 증상 요약
<!-- 어떤 문제가 발생했는지 한두 문장으로 적어 주세요. -->
## 재현 절차
1.
2.
3.
## 기대한 동작
<!-- 원래 어떻게 동작해야 하는지 적어 주세요. -->
## 실제 동작
<!-- 실제로 어떻게 동작했는지 적어 주세요. 오류 메시지가 있다면 그대로 붙여 주세요. -->
## 환경 정보 (필수)
> 아래 정보가 없으면 원인 파악이 어렵습니다. 가능한 한 모두 채워 주세요.
- **코어 버전**: <!-- 예: 7.0.2 / config/app.php 의 APP_VERSION 또는 관리자 > 정보 화면 -->
- **업그레이드 이슈인 경우 — 업그레이드 전 버전 → 후 버전**: <!-- 예: 7.0.0-beta.7 → 7.0.2 -->
- **PHP 버전**: <!-- 예: 8.2.x / php -v -->
- **웹서버·실행 방식**: <!-- 예: Apache + mod_php / Nginx + PHP-FPM / 공유호스팅 -->
- **DB**: <!-- 예: MySQL 8.0 / MariaDB 10.x -->
- **OS**: <!-- 예: Ubuntu 24.04 / Windows 11 -->
## 활성 확장 목록 (해당 시)
<!--
확장(모듈/플러그인/템플릿) 관련 문제이거나 업그레이드 문제라면 아래 명령 결과를 붙여 주세요.
php artisan module:list
php artisan plugin:list
php artisan template:list
-->
```text
(여기에 붙여넣기)
```
## 커스텀 자산 여부 (해당 시)
> 업그레이드·파일 삭제·권한 문제라면 반드시 확인해 주세요.
- [ ] `modules/_bundled/`·`plugins/_bundled/`·`templates/_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다.
- [ ] `public/.htaccess` 에 직접 추가한 규칙이 있습니다.
- [ ] 그 외 코어 배포본에 없는 커스텀 파일을 직접 추가/수정했습니다.
<!-- 위 항목에 해당하면 어떤 경로에 무엇을 추가했는지 적어 주세요. -->
## 스크린샷·로그
<!--
- 브라우저 콘솔 에러 (F12 > Console)
- storage/logs/ 관련 로그
- 화면 스크린샷
-->
## 추가 정보
<!-- 원인 추정, 임시 우회 방법 등 참고될 내용이 있으면 적어 주세요. -->
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: 질문·사용법 문의 (sir.kr Q&A)
url: https://sir.kr/questions/create?tag=%EA%B7%B8%EB%88%84%EB%B3%B4%EB%93%9C7
about: 버그가 아닌 사용법 질문·사용법 문의는 sir.kr Q&A 를 이용해 주세요.
- name: 보안 취약점 신고
url: https://github.com/gnuboard/g7/security/advisories/new
about: 보안 취약점은 공개 이슈가 아닌 비공개 Security Advisory 로 신고해 주세요.
+26
View File
@@ -0,0 +1,26 @@
---
name: ✨ 기능 제안
about: 새로운 기능이나 개선 아이디어를 제안합니다.
title: "[Feature] "
labels: enhancement
assignees: ''
---
## 해결하려는 문제
<!-- 어떤 불편이나 한계 때문에 이 기능이 필요한지 적어 주세요. -->
## 제안하는 기능
<!-- 원하는 동작을 구체적으로 적어 주세요. -->
## 적용 범위 (해당 시)
- [ ] 코어
- [ ] 모듈 <!-- 어떤 모듈인지: -->
- [ ] 플러그인 <!-- 어떤 플러그인인지: -->
- [ ] 템플릿 <!-- 어떤 템플릿인지: -->
## 대안·참고 사항
<!-- 고려한 다른 방법, 타 CMS 사례, 참고 링크 등이 있으면 적어 주세요. -->
+51
View File
@@ -0,0 +1,51 @@
---
name: ⬆️ 업그레이드 문제
about: core:update / 확장 업데이트 과정에서 발생한 문제를 제보합니다.
title: "[Upgrade] "
labels: "bug, upgrade"
assignees: ''
---
## 업그레이드 경로 (필수)
- **업그레이드 전 버전**: <!-- 예: 7.0.0-beta.7 -->
- **업그레이드 후(목표) 버전**: <!-- 예: 7.0.2 -->
- **실행한 명령**: <!-- 예: php artisan core:update --source=/tmp/g7-7.0.2 -->
- **실행 계정/권한**: <!-- 예: www-data / sudo(root) / 일반 사용자 -->
> 참고: `core:update` 는 **현재 설치된(업그레이드 전) 버전의 코드**로 실행됩니다.
> 따라서 최신 버전에서 수정된 버그라도, 그 수정 이전 버전에서 올라오는 경우에는 재발할 수 있습니다.
> 업그레이드 전 반드시 전체 백업을 권장합니다.
## 증상
<!-- 업데이트 중/후 어떤 문제가 발생했는지 적어 주세요. 오류 메시지는 그대로 붙여 주세요. -->
## 확장·커스텀 자산 상태 (필수)
- [ ] `_bundled/` 아래에 직접 추가한 커스텀 확장이 있습니다. <!-- 경로: -->
- [ ] `public/.htaccess`·`public/storage` 등 커스텀 파일/링크가 있습니다.
- [ ] 업데이트 후 위 자산이 삭제/변경되었습니다.
<!-- 업데이트 전후 확장 목록을 붙여 주시면 도움이 됩니다:
php artisan module:list / plugin:list / template:list
-->
```text
(확장 목록 붙여넣기)
```
## 환경 정보
- **PHP 버전**:
- **웹서버·실행 방식**: <!-- Apache+mod_php / Nginx+PHP-FPM / 공유호스팅 등 -->
- **DB**:
- **OS**:
## 로그·스크린샷
<!--
- 콘솔 전체 출력 (업데이트 실행 로그)
- storage/logs/ 의 관련 로그
- 브라우저 콘솔 에러
-->
+12
View File
@@ -48,6 +48,11 @@ node_modules/
# Settings JSON files (환경별 설정)
/storage/app/settings/
# 성능 계측 리포트 (g7:bench --report) — 측정값이 실행 머신 사양에 종속되므로 저장소에 축적하지 않는다
/storage/app/benchmarks/
# 확장 프론트엔드 병합 번들 캐시 (version-in-path, 런타임 생성)
/storage/app/ext-bundles/
# Bootstrap cache (auto-generated)
/bootstrap/cache/*
@@ -133,3 +138,10 @@ playwright-report/
test-results/
# Playwright MCP 세션 아티팩트(스냅샷/콘솔 로그) — 커밋 대상 아님
.playwright-mcp/
# ===== 소스맵 =====
# 소스맵은 원본 코드 전문(sourcesContent)을 담고 있어 배포 대상이 아니다.
# 로컬 개발 빌드에서는 생성되지만(디버깅용) 저장소에는 유입되지 않는다.
# 배포용 빌드는 `--production` 이 G7_BUILD_SOURCEMAP=0 을 주입해 생성 자체를 막는다.
# 경로 한정 시 새 확장이 추가될 때 조용히 누락되므로 전역 규칙으로 둔다.
*.map
+394 -6
View File
@@ -6,15 +6,17 @@
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (31개)
### 백엔드 [backend/](docs/backend/) (34개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
| [activity-log.md](docs/backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel('activity... |
| [admin-settings-access.md](docs/backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → SettingsServicePr... |
| [api-documentation.md](docs/backend/api-documentation.md) | API 레퍼런스 문서 규정 (API Documentation) | 모든 API 엔드포인트는 레퍼런스 문서 필수 — 메서드/URI/파라미터/응답 필드 + 요청·응답 예시 ... |
| [api-resources.md](docs/backend/api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: BaseApiColl... |
| [authentication.md](docs/backend/authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
| [benchmark.md](docs/backend/benchmark.md) | 성능 계측 시스템 (Benchmark) | `g7:bench` 가 4축(list/screen/write/batch)을 잰다 — 계측 대상은 커맨드... |
| [broadcasting.md](docs/backend/broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
| [console-confirm.md](docs/backend/console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) | 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용 — Laravel... |
| [controllers.md](docs/backend/controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / PublicBaseCont... |
@@ -31,6 +33,7 @@
| [language-pack-service.md](docs/backend/language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) | LanguagePackService 가 install/activate/deactivate/uninsta... |
| [middleware.md](docs/backend/middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) |
| [pagination.md](docs/backend/pagination.md) | 대용량 목록 페이지네이션 (Pagination) | 총 건수만 상한을 받는다 — 상한 이하면 정확, 초과면 "이상"(total_relation=at_least) |
| [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
| [routing.md](docs/backend/routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
| [search-system.md](docs/backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ... |
@@ -113,10 +116,10 @@
| [module-assets.md](docs/extension/module-assets.md) | 모듈 프론트엔드 에셋 시스템 | module.json에 에셋 매니페스트 정의 (js, css, loading strategy) |
| [module-basics.md](docs/extension/module-basics.md) | 모듈 개발 기초 | 디렉토리: vendor-module (예: sirsoft-ecommerce) |
| [module-commands.md](docs/extension/module-commands.md) | 모듈 Artisan 커맨드 | 목록: php artisan module:list |
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /lang/{locale}/*.php → __('vendor-module::key') |
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /src/lang/{locale}/*.php → __('vendor-module::key') ... |
| [module-identity-settings.md](docs/extension/module-identity-settings.md) | 모듈/플러그인 본인인증(IDV) 설정 통합 가이드 | 정책/목적/메시지: module.php::getIdentity{Policies,Purposes,Mess... |
| [module-layouts.md](docs/extension/module-layouts.md) | 모듈 레이아웃 시스템 | 위치: modules/_bundled/vendor-module/resources/layouts/admi... |
| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/admin/[vendor-module]/... |
| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/modules/[vendor-module]/... |
| [module-settings.md](docs/extension/module-settings.md) | 모듈 환경설정 시스템 개발 가이드 | - |
| [permissions.md](docs/extension/permissions.md) | 권한 시스템 | 구조: User → Role → Permission (기능 레벨) |
| [plugin-development.md](docs/extension/plugin-development.md) | 플러그인 개발 가이드 | 디렉토리: plugins/vendor-plugin (예: sirsoft-payment) |
@@ -143,6 +146,37 @@
| [e2e-testing.md](docs/testing/e2e-testing.md) | 그누보드7 Playwright E2E 테스트 가이드 | - |
### API 레퍼런스 진입점
> 엔드포인트별 요청 파라미터·응답 필드·요청/응답 예시. 공통 규약(Bearer 토큰 인증, 응답 봉투, 페이지네이션, 401/403/422/428)은 진입점 문서 상단에 정리되어 있다.
| 대상 | 진입점 | 문서/엔드포인트 |
|------|--------|----------------|
| 코어 | [docs/backend/api/README.md](docs/backend/api/README.md) | 36 / 319 |
### 확장 API 레퍼런스 (14개 확장, 자동 스캔)
> 각 확장이 소유하는 API 문서 목차. `php artisan api:docgen` 이 생성하며, 이 표는 `{modules,plugins}/_bundled/*/docs/api/README.md` 를 패턴 스캔해 자동 편입된다(확장명 하드코딩 없음).
| 확장 | 유형 | API 문서 목차 | 문서/엔드포인트 |
|------|------|--------------|----------------|
| `gnuboard7-hello_module` | 모듈 | [docs/api/](modules/_bundled/gnuboard7-hello_module/docs/api/README.md) | 1 / 7 |
| `sirsoft-board` | 모듈 | [docs/api/](modules/_bundled/sirsoft-board/docs/api/README.md) | 10 / 80 |
| `sirsoft-ecommerce` | 모듈 | [docs/api/](modules/_bundled/sirsoft-ecommerce/docs/api/README.md) | 33 / 239 |
| `sirsoft-page` | 모듈 | [docs/api/](modules/_bundled/sirsoft-page/docs/api/README.md) | 2 / 17 |
| `sirsoft-ckeditor5` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-ckeditor5/docs/api/README.md) | 3 / 5 |
| `sirsoft-gdpr` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-gdpr/docs/api/README.md) | 4 / 15 |
| `sirsoft-marketing` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-marketing/docs/api/README.md) | 2 / 2 |
| `sirsoft-message_bizppurio` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-message_bizppurio/docs/api/README.md) | 6 / 21 |
| `sirsoft-pay_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_kginicis/docs/api/README.md) | 5 / 34 |
| `sirsoft-pay_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nhnkcp/docs/api/README.md) | 0 / 0 |
| `sirsoft-pay_nicepayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-pay_nicepayments/docs/api/README.md) | 0 / 0 |
| `sirsoft-tosspayments` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-tosspayments/docs/api/README.md) | 2 / 4 |
| `sirsoft-verification_kginicis` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_kginicis/docs/api/README.md) | 2 / 3 |
| `sirsoft-verification_nhnkcp` | 플러그인 | [docs/api/](plugins/_bundled/sirsoft-verification_nhnkcp/docs/api/README.md) | 1 / 1 |
<!-- AUTO-GENERATED-END: docs-quick-reference -->
---
@@ -161,7 +195,7 @@
### ① 코어 → 확장 동기화 (`requires.g7_version`)
- 트리거: 코어 공개 확장 표면(`app/Extension/Abstract*`, `HookManager`, `ExtensionManager`, `ModuleManager`, `PluginManager`, `TemplateManager`, `app/Contracts/Extension/**`, `app/Extension/Helpers/**`, `app/Seo/Contracts/**`, `app/ActivityLog/**` 공개 API, 루트 `CHANGELOG.md` Added/Changed/Removed) 수정
- 트리거: 코어 공개 확장 표면(`app/Extension/Abstract*`, `HookManager`, `ExtensionManager`, `ModuleManager`, `PluginManager`, `TemplateManager`, `app/Contracts/Extension/**`, `app/Extension/Helpers/**`, `app/Repositories/Concerns/**`, `app/Seo/Contracts/**`, `app/ActivityLog/**` 공개 API, 루트 `CHANGELOG.md` Added/Changed/Removed) 수정
- 조치: 영향 받는 번들 확장의 `g7_version` 상향 + 각 확장 CHANGELOG 에 변경 기재
### ② 확장 → 확장 동기화 (`dependencies.{modules|plugins}`)
@@ -192,6 +226,8 @@
| `handler: "nav"` | `handler: "navigate"` |
| `handler: "setLocalState"` | `handler: "setState"` + `target: "local"` |
| `navigate` + `replace: true` (URL만 변경 시) | `handler: "replaceUrl"` |
| `navigate` `params.path: "back"` (동작 키워드로 착각) | `handler: "navigateBack"` — path 는 주소로 해석되어 조용히 `/back` 으로 이동한다 |
| `navigate` `params.url` / `href` / `to` 로 목적지 전달 | `params.path` (또는 액션 `target`) — 엔진은 이 둘만 읽는다. 다른 이름은 무시되어 목적지가 `undefined` 가 되고, 예외도 404 도 없이 버튼만 동작하지 않는다 |
| apiCall `params.target` (params 내부) | `target` 은 액션 top-level. params 내부 위치 시 URL 미해석 |
| apiCall `params.onSuccess` / `params.onError` (params 내부) | 액션 top-level. params 내부면 무시됨 |
| `refetchDataSource` `params.id` | `params.dataSourceId` 사용 |
@@ -221,10 +257,26 @@
| 금지 | 올바른 사용 |
|------|------------|
| `Icon className="w-4 h-4"` | `Icon size="sm"` 또는 `className="text-sm"` |
| `Icon className="w-4 h-4"` | `className="text-base"` (아래 등가표) 또는 `size` prop |
| `Select valueKey/labelKey` | computed로 `{ value, label }` 변환 |
| Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) |
| `options={{options}}` | `options={{options ?? []}}` (fallback) |
| boolean 필드를 `RadioGroup`/`Select` 의 `name` 자동바인딩만으로 폼에 묶기 | `autoBinding: false` + `value: "{{String(_local.form?.필드 ?? 기본값)}}"` + `change` 액션 `"{{$event.target.value === 'true'}}"` 캐스팅. 자동바인딩 value 경로는 `e.target.value` 문자열을 그대로 저장해 서버 `boolean` 규칙에서 422 가 된다 (표시만 보면 정상이라 저장 시점에야 드러남) |
| `options` 지정 커스텀 `Select`(composite) 에 `defaultValue` | `value: "{{상태 ?? 기본값}}"` + `change` 액션 + 열기 지점 상태 시드 — 커스텀 Select 는 value-제어 전용이라 `defaultValue` 는 렌더되지 않고(빈 표시) 숨은 input 도 없어 값이 조용히 미전송된다 (options 없는 네이티브 렌더 경로만 defaultValue 유효) |
| 폼 밖 제출 버튼 `props.form: "X"` 만 선언 | 참조 대상 `Form` 에 `props.id: "X"` 동반 필수 — id 가 없으면 버튼이 어떤 폼에도 연결되지 않아 클릭이 무반응이 된다 (오류 없음) |
Icon 은 `<i>` 글리프라 박스 크기가 곧 `font-size` 다. `w-N h-N` 은 박스만 정하고 글리프는 부모 `font-size` 를 상속하므로 어긋난다. 기존 `w-N h-N` 을 옮길 때는 아래 등가표를 쓴다 (Chrome 실측).
| `w-N h-N` | px | 등가 `className` |
|---|---|---|
| `w-3 h-3` | 12 | `text-xs` |
| `w-3.5 h-3.5` | 14 | `text-sm` |
| `w-4 h-4` | 16 | `text-base` |
| `w-5 h-5` | 20 | `text-xl` |
| `w-6 h-6` | 24 | `text-2xl` |
| `w-12 h-12` | 48 | `text-5xl` |
`size` prop 은 Font Awesome `fa-*` 클래스로 매핑되며 등가가 아니다 — `size="sm"` → `fa-sm` → `font-size: 0.875em`(상대값) + `line-height` 붕괴로 16px 이 12.25×0.88px 이 된다. 새 아이콘에는 써도 되지만, 기존 `w-N h-N` 의 치환용으로는 쓰지 않는다.
### 상태 관리
@@ -259,6 +311,290 @@
| `AuthManager.updateConfig({ loginPath: '//evil.com/...' })` (protocol-relative) | `//` 시작 금지 (open redirect 방지) |
| 401 에러 페이지(`errors/401.json`)에서 직접 로그인 리다이렉트 구현 | 코어 `TemplateApp.showRouteError` 가드에 위임 (자동 처리) |
### 정적 확장자 라우트 / 자산 URL 생성
| 금지 | 올바른 사용 |
|------|------------|
| `Route::get('{id}/routes.json', ...)` (`.js`/`.css`/`.json`/`.map` 단일 등록) | `Route::dualSuffix('{id}/routes', 'json', ...)` — 확장자 형태 + 확장자 없는 형태 동시 등록 |
| `Route::get('bundle.js', ...)` (접미사가 종류를 구분해 제거 불가) | `Route::dualSuffixSegment('bundle', 'js', ...)` (`bundle.js` + `bundle/js`) |
| `Route::get('assets/{id}/{path}', ...)` (와일드카드 자산) | `Route::dualAsset('assets/{id}', ...)` (`.../{path}` + `?file=` 쿼리) |
| 서버에서 `'/api/templates/assets/'.$id.'/'.$path` 문자열 조립 | `App\Support\AssetUrl::templateAsset($id, $path)` |
| 프론트에서 `` `/api/templates/${id}/routes.json` `` 템플릿 리터럴 조립 | `resources/js/core/support/assetUrl.ts` 의 `suffixed()` / `templateAsset()` 등 |
정규식 location 은 프리픽스 location 보다 먼저 매칭되므로, 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 있는 서버에서는 확장자 붙은 동적 응답이 `try_files ... /index.php` 폴백 기회 없이 404 가 된다. 서버측 `AssetUrl` 과 프론트측 `assetUrl.ts` 는 동일 규칙을 공유하므로 한쪽만 바꾸면 그 자산만 404 가 된다. 상세: [routing.md](docs/backend/routing.md) "정적 확장자로 끝나는 동적 엔드포인트", [api/README.md](docs/backend/api/README.md) "자산 URL 이중 모드".
### 라우트 캐시 안전성
`route:cache` 가 걸리면 `RouteServiceProvider::boot()` 이 캐시 로드로 분기해 라우트 파일 자체가 실행되지 않는다. 클로저는 직렬화 형태로 복원되므로 문제가 아니다 (`routes/web.php` SPA catch-all 이 증거). 깨지는 것은 오토로드되지 않는 심볼 참조뿐이다.
| 금지 | 올바른 사용 |
|------|------------|
| 라우트 파일에 전역 함수 선언 + 핸들러가 호출 | 로직을 클래스(`app/Support/…`)로 옮기고 핸들러는 위임만 |
| 파일 스코프 변수를 핸들러가 `use` 없이 참조 | 클래스 상수 또는 `use ($var)` 로 클로저에 캡처 |
| 벤더/프로바이더가 `boot()` 에서 조건부 등록하는 라우트에 의존 | 그 URI 를 G7 라우트 파일이 직접 소유 |
전역 함수 위반은 `Call to undefined function` 500 인데 예외의 `file` 이 `laravel-serializable-closure://` 라 원인 파일이 스택에 드러나지 않는다. 프로바이더 등록분이 사라지는 이유는 별개다 — `Router::setCompiledRoutes()` 가 `booted` 콜백에서 라우트 컬렉션을 통째로 교체하므로 그보다 앞선 등록은 조건 충족 여부와 무관하게 폐기된다(프레임워크 자신의 `BroadcastManager::routes()` 는 `routesAreCached()` 가드를 갖지만 모든 패키지가 그렇지는 않다). 정적 검사가 라우트 파일의 전역 함수 선언을 차단한다. 상세: [routing.md](docs/backend/routing.md) "캐시 안전한 라우트 작성".
### 목록 컨텍스트 왕복 (list context round-trip)
페이지네이션 목록 화면과 그에 딸린 상세·형제 상세·작성/수정 폼·확인 모달은 하나의 목록 클러스터다. 이 클러스터 안에서의 이동은 URL 목록 상태(`page`/`search`/`category`/`filters[*]`/정렬/`per_page`)를 손실 없이 보존해야 한다.
| 금지 | 올바른 사용 |
|------|------------|
| 클러스터 내 navigate 에 `mergeQuery` 누락 | `"params": { "path": "…", "mergeQuery": true, "query": {} }` |
| 이전글/다음글 등 형제 상세 이동만 규약에서 누락 | 목록 진입 / 목록 복귀 / 형제 이동 / 폼 취소 / 삭제 후 복귀 전 leg 동일 적용 |
| 현재 값을 그대로 다시 넘기는 키 열거 (`{"del": "{{query.del ?? ''}}"}`) | `mergeQuery` 가 이미 전부 나른다 — 열거는 중복이자 누락 위험 |
| 덮어쓸 키만 남기지 않고 필터 키 전부 재열거 | 값을 바꿔야 하는 키만 남긴다 (페이지 되돌림은 `{"page": ""}`) |
| 새로고침 버튼에 `mergeQuery: false` | 새로고침은 보던 목록을 다시 부르는 것 — 병합 유지 |
| `mergeQuery` 를 표현식으로 분기 (`"{{cond}}"`) | boolean 리터럴 고정 — 분기마다 보존 여부가 갈리면 한쪽이 조용히 상태를 떨군다 |
| `"path": "/board/{slug}/write?parent_id={{id}}"` (인라인 쿼리스트링) | 인라인 쿼리는 병합 시 버려진다 → `query` 객체로 옮긴다 |
| `mergeQuery: true` + `query` 키 생략 | 의도를 드러내도록 `"query": {}` 를 함께 둔다 |
| `"query": []` (배열 리터럴) | `"query": {}` — 동작은 같아 조용히 통과하지만, 나중에 덮어쓸 키를 넣으면 그 값이 버려진다 |
| 목적지가 표현식이라 판정 불가한 이동을 무표시로 둠 (`"{{_global.shopBase}}/products"`) | 클러스터 내 이동이면 `mergeQuery: true`, 밖으로 나가는 이동이면 예외 주석으로 의도를 명시 |
| 의도적 리셋(검색·필터 초기화 / 탭 전환 / 프리셋 적용)에 `mergeQuery: true` | 리셋은 병합하지 않는다 — 병합하면 초기화 버튼이 아무 일도 하지 않는다 |
| 탭 전환(`onTabChange`)이나 겹치지 않는 다른 목록으로의 이동에 `mergeQuery: true` | 목록 정체성이 다르면 승계하지 않는다 — 남의 검색어·페이지가 얹혀 빈 화면이 열린다 |
| 면제 주석은 "병합하지 않는다" 인데 코드는 `mergeQuery: true` | 주석과 코드를 일치시킨다 (주석은 사실이 아니라 선언일 뿐) |
| 검색 실행·페이지 이동 액션에서 `query` 키를 비움 | 값을 바꾸는 액션은 그 값을 직접 넘긴다 (`{"page": "{{$args[0]}}"}`) — 병합만으로는 새 값이 전달되지 않는다 |
| `path` 없이 `query` 만 바꾸는 액션에 `mergeQuery` 누락 (탭 전환 `{"tab": …}`, 항목 선택 `{"id": …, "mode": "view"}`) | `path` 생략은 "현재 주소에 작용" 이라 목록 화면 자신이 대상 — `mergeQuery: true` 없으면 지금 걸린 목록 상태가 통째로 날아간다 |
의도적 리셋(검색 초기화 / 필터 초기화 / 탭 전환 / 프리셋 적용 / 다른 목록으로의 이동)은 예외다. 그 경우 액션 노드 `comment` 에 `audit:allow layout-list-context-navigate-merge-query <사유>` 를 남겨 의도를 코드에 기록한다. 상세: [actions-handlers-navigation.md "목록 컨텍스트 왕복 규약"](docs/frontend/actions-handlers-navigation.md)
### 일괄 처리 목록의 선택 범위
체크박스 선택은 화면 밖(전역/로컬 상태)에 저장된다. 그래서 검색·필터·페이지 이동으로 행이 목록에서 빠져도 그 행의 선택은 남는다. 그 상태에서 일괄 처리를 누르면 사용자가 보고 있지도, 체크하지도 않은 행이 대상이 된다. 확인 모달은 건수만 말하므로 실행 전에 알아챌 방법이 없고, 처리는 정상 성공하므로 실행 후에도 오류가 남지 않는다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 선택을 화면 밖 상태에 저장하는 DataGrid 에 `selectionScope` 미선언 | `"selectionScope": "page"`(일괄 처리 목록) 또는 `"free"`(선택 자체가 저장 대상인 폼)를 **명시** |
| 일괄 처리 버튼이 달린 목록에 `"free"` | `"page"` — 대상은 언제나 "화면에 보이고 체크된 행" |
| 여러 페이지에 걸쳐 고르는 폼 선택기에 `"page"` | `"free"` — 페이지를 넘기면 앞 페이지 선택이 사라져 기능이 깨진다 |
| `selectable` 이 꺼진 화면이라 안전하다고 간주 | 체크박스가 없으면 남은 선택이 **더** 안 보인다 — 범위 판정은 `selectable` 과 무관 |
| `"selectionScope": "{{조건}}"` 표현식 분기 | 리터럴 고정 — 분기마다 보존 여부가 갈리면 한쪽이 조용히 대상 밖 행을 싣는다 |
| 화면마다 검색·필터·페이지 액션에 선택 초기화 액션을 복제 | 컴포넌트가 단일 지점에서 정리 — 액션 복제는 한 곳만 빠져도 같은 결함이 남는다 |
정적 검사가 `onSelectionChange` 가 배선된 DataGrid 를 전수 검사해 미선언을 차단한다. 상세: [component-props.md DataGrid](docs/frontend/component-props.md)
### 중첩 리소스 스코프 / 계층 무결성
| 금지 | 올바른 사용 |
|------|------------|
| 중첩 라우트의 상위 리소스 ID 를 받아만 두고 조회에 미반영 | Repository where 절에 상위 스코프 반영(SSoT) + Service 가 상위 ID 전달 → 교차 접근 시 404 |
| `$request->except(...)` / `->all()` 결과를 Service 쓰기 메서드로 전달 | `$request->validated()` 기준 (FormRequest 미정의 필드가 `$fillable` 로 새는 것 차단) |
| 요청 배열 항목의 `Rule::exists` 에 상위 스코프 미부착 | `Rule::exists(Model::class,'id')->where('order_id', $order->id)` → 422 |
| 수정/순서변경 FormRequest 의 `parent_id` 에 `Rule::exists` 만 부착 | 자손 전체를 검사하는 순환 방지 Rule 부착 (자기참조만 막는 Rule 은 `A→B→A` 통과) |
| 같은 리소스의 두 엔드포인트가 서로 다른 검증 강도 | 부모 변경 경로 전부 동일 강도 — 약한 쪽이 우회로가 된다 |
| 설정값이 정하는 한계를 Service 에서 리터럴로 재클램프 | Service 는 계산만, 상한 검증은 Rule 단일 책임 (이중 클램프 시 깊이 제한이 통째로 무력화) |
| 계층 재귀(path/depth 재계산)에 방문 ID 가드 없음 | 방문 집합으로 유한 종료 — 검증 우회 경로/오염 데이터에서도 무한 루프 금지 |
> 상세: [validation.md "계층 리소스 순환 참조" / "배열 항목의 상위 스코프"](docs/backend/validation.md), [service-repository.md "중첩 리소스 스코프" / "설정 기반 한계값"](docs/backend/service-repository.md)
#### 보안 게이트 대칭성 (KVE-2026-1914/1915/1919)
접근 게이트와 권한 등급 상한은 한 경로에만 있으면 다른 경로가 조용한 우회로가 된다. 게이트는 생산 지점(부모 비밀 판정 · 소유권 판정 · 등급 판정) 한 곳을 SSoT 로 두고, 같은 데이터를 내보내는 소비 경로 전부가 그 게이트를 경유해야 한다.
| 금지 | 올바른 사용 |
|------|------------|
| 비밀/비공개 부모(게시글)의 비밀 게이트를 하위 리소스(댓글·첨부·문의) 독립 엔드포인트에서 재적용하지 않음 | 부모 비밀 판정을 하위 전 경로(훅·서비스·첨부 서빙·댓글 목록)에 재적용 — PostResource 한 곳만으로는 부족하다 (KVE-2026-1914) |
| hash 기반 file-serving(preview/download)이 소유권·비밀·발행 상태 검사 없이 서빙 | preview 와 download 가 동일 게이트 공유 — 미발행·비소유·비밀 첨부는 404 (KVE-2026-1914 A-3/S-1/S-2) |
| User/Role 의 쓰기·상태변경·권한부여 경로가 삭제 경로보다 약한 등급 가드 | 전 경로에 동일 등급-상한(rank ceiling)을 대칭 적용 — 정적 라우트(bulk)는 스코프 미들웨어가 우회되므로 서비스 계층에서 강제한다 (KVE-2026-1919) |
| 저장측 레이아웃 표현식 검증(SafeLayoutExpressions)을 문자열 endpoint 필드에만 부착 | 표현식이 실릴 수 있는 배열 트리 전체(`content`)에 부착 — 문자열 한정 부착은 `is_array` 가드로 무력화되어 no-op 이 된다 (KVE-2026-1915) |
| 배열 트리 순회용 규칙(`NoExternalUrls`)이 문자열 필드에도 부착돼 `is_array` 로 조용히 통과 | 규칙이 문자열 스칼라도 처리하거나, 그 자리에서 떼어낸다 — 부착만 해두고 통과시키는 상태가 최악이다 |
| 같은 저장 대상의 FormRequest 마다 부착 규칙이 다름 (편집기 경로만 누락) | Store·Update·Content·ExtensionContent 4경로 동일 강도 — 편집기 저장 경로가 가장 약하면 그 경로가 우회로다 |
| same-origin 을 `//` 접두·scheme·`/` 시작 **문자열 검사**로만 판정 | 브라우저 URL 파서와 동일 정규화(tab·LF·CR 제거 → 백슬래시를 슬래시로 → 선행 슬래시 런 접기) 후 판정 — `/\/evil.com/x.js` 는 문자열상 path 지만 브라우저는 외부 origin 으로 해석한다. 런타임·저장측·정적검사 3층이 같은 정규화를 공유한다 (KVE-2026-1915 B-2) |
| same-origin 판정만 정규화하고 **신뢰 호스트 추출(`hostOf`)은 원문**으로 판정 | 두 판정이 같은 `if` 안에서 이어지므로 정규화도 공유 — 어긋나면 `https://evil.com\@cdn.신뢰.com/x.js` 가 저장측에서만 신뢰 호스트로 보여 통과한다 |
| 정적 일괄 라우트(`bulk-*`)에 등급 상한만 적용하고 **스코프 축은 비움** | 라우트 모델이 없으면 미들웨어 스코프 검사가 스킵되므로 서비스가 상세 경로와 **같은 스코프 판정**(`PermissionHelper::filterByScope`)을 재적용 — 등급 축만 막으면 스코프 축이 우회로다 (KVE-2026-1919) |
| 권한 상한(ceiling) 검사를 DB 쓰기 **뒤**에 배치 | 가드 → 쓰기 순서 — 쓰기 뒤에 검사하면 거부된 요청이 고아 행·반영된 속성 변경을 남긴다. 회귀 테스트는 403 뿐 아니라 **상태 불변**까지 단언한다 |
| 같은 리소스를 쓰는 public 서비스 메서드 중 일부만 보호 가드 보유 | 형제 public 메서드 전부 동일 가드 — 서비스는 확장에 열려 있으므로 "현재 호출부가 없다" 는 방어가 아니다 |
| 라우트 파라미터가 Model 로 resolve 되지 않는 쓰기 경로를 미들웨어 스코프 검사에 맡김 | 서비스 계층에서 재적용 — 스킵 조건은 정적 경로(`bulk-*`·`reorder`)뿐 아니라 **파라미터명 불일치**(`{id}` + `int` 타입힌트)도 있고, 후자는 상세 경로까지 무가드다 |
| 순서 변경·일괄 작업의 스코프 거부를 "대상 일부 제외" 로 처리 | 순서·트리처럼 집합 전체가 하나의 값인 작업은 **전량 거부** — 일부만 반영하면 나머지와 어긋난 상태가 저장된다 |
| 가시성 판정을 호출부가 넘기는 옵트인 플래그(`$filters['is_public'] ?? false`)에 의존 | 열람자 신원 기반 fail-closed — 옵트인은 호출부가 빠뜨리면 조용히 열린다(읽기만 하고 쓰는 곳이 없는 사문 플래그가 실재했다) |
| 부모 상태로 판정하는 게이트를 `$x->parent && …` 로 작성 | 부모를 못 읽으면 차단 — 부모가 soft-delete 되면 조건이 성립하지 않아 통과한다 |
| 리소스 `abilityMap can_*` 을 연관/타 리소스 권한으로 게이팅 | 그 엔드포인트의 라우트 권한(SSoT)과 **같은 리소스 prefix** — 상승 방지는 게이트 이중화가 아니라 rank ceiling 이 담당한다 |
이 결함군은 예외도 오류도 남기지 않는다 — 약한 경로가 정상 응답을 내보내는 것이 유일한 증상이다. secret 게이트 재적용·hash 서빙 게이트·rank 대칭·URL 판정 3층 동형·정적 bulk 스코프 재적용·가드 선행·형제 메서드 가드 패리티·abilityMap prefix 정합은 의미 판정 영역이라 정적 검사가 일부만 덮으므로, 부모 변경·하위 서빙·등급 경로·URL 검증 지점을 건드릴 때 코드 리뷰에서 대칭성을 확인한다.
> 상세: [validation.md](docs/backend/validation.md), [service-repository.md](docs/backend/service-repository.md), [frontend/security.md](docs/frontend/security.md)
### 목록 응답의 하위 컬렉션
목록은 화면이 그 행에서 **실제로 그리는 것**만 싣는다. 행마다 하위 컬렉션을 통째로 직렬화하면 한 페이지를 여는 것만으로 수백~수천 행이 응답에 실린다 (공개 #76 — 상품 100건 × 옵션 20건).
| 금지 | 올바른 사용 |
|------|------------|
| `relationLoaded('x') ? $this->x : $this->whenLoaded('y')` (가짜 가드) | `whenLoaded('x', fn () => ...)` 하나만 — 로드 여부는 **Repository 가** 결정한다 |
| Resource 는 `whenLoaded` 로 방어하는데 Repository 가 목록에서 무조건 eager load | 목록의 `relations:` 는 목록이 **실제로 직렬화하는** 관계만 |
| 개수/합계를 PHP 컬렉션 연산으로 (`$this->options->where(...)->sum(...)`) | `withCount:` / `withSum`(`outerUsing:`) DB 집계 |
| `toListArray()` 를 정의해 두고 컬렉션이 `toArray()` 를 호출 | 컨트롤러/컬렉션이 목록 표현을 **명시 호출** |
| 목록 Resource 안에서 관계 재쿼리 (`$this->images()->first()`) | `relationLoaded` 분기로 로드된 컬렉션에서 고른다 |
| 집계 별칭 존재 여부를 `!== null` 로 판정 | `array_key_exists($alias, $model->getAttributes())` — SUM 은 0건에서 NULL 이라 값 검사로는 "집계 안 함" 과 구분되지 않는다 |
| 목록에서 뺀 값을 대체 경로 없이 제거 | 지연 로드 경로(배치 조회)를 먼저 만들고, 하위호환은 opt-in 파라미터(`?with_options=1`)로 |
착수 전 **소비처를 실측**한다. 화면이 그 값을 실제로 순회·렌더하면 제거는 기능 축소다 — 계획서에 "안 쓴다" 고 적혀 있어도 레이아웃 JSON 을 열어 확인한다.
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
### 저장값 + 확장 카탈로그 병합 설정의 공개 응답
설정 항목이 "운영자 저장값 + 확장이 훅으로 등록한 카탈로그" 의 병합으로 만들어지면, 저장값은 남아 있는데 카탈로그에서 항목이 사라지는 상태가 생긴다 — 그 확장을 삭제·비활성화했거나, 확장이 자기 기능 토글을 껐을 때다. 병합부는 이를 고아 항목으로 표시하지만 저장값의 `is_active` 는 참 그대로 남는다.
| 금지 | 올바른 사용 |
|------|------------|
| 공개 응답이 저장값 플래그(`is_active`)만 보고 항목을 내보냄 | 카탈로그 소속(`_orphaned`)을 함께 판정 — 공급 확장이 더 이상 제공하지 않는 항목은 공개 응답에서 제거 |
| 고아 항목을 관리자 응답에서도 제거 | 관리자 응답은 유지 — 운영자가 확인하고 지워야 할 대상이다 |
| 소비 화면(레이아웃 JSON)마다 필터를 넣어 차단 | 공개 API 단일 지점에서 차단 — 화면마다 복제하면 템플릿 하나만 빠져도 같은 결함이 남는다 |
| 같은 데이터를 내보내는 공개 엔드포인트가 서로 다른 게터를 사용 | 전 엔드포인트가 같은 공개 게터를 경유 — 한쪽이 raw `getSettings()` 를 쓰면 그 경로만 조용히 뚫린다 |
| 항목 제거 후 배열 인덱스를 그대로 둠 | `array_values()` 로 재정렬 — 비연속 키는 JSON 객체로 직렬화되어 화면 반복이 깨진다 |
이 결함은 예외도 경고도 로그도 남기지 않는다. 이미 제공 불가한 항목이 사용자 화면에서 선택 가능한 상태로 남아 있는 것이 유일한 증상이고, 관리자 화면은 고아 표시로 정상 차단하고 있어 양쪽을 나란히 보지 않으면 드러나지 않는다.
> 상세: [module-settings.md](docs/extension/module-settings.md) "카탈로그 병합 설정의 공개 응답"
### 목록 조회 컬럼 프루닝과 지연 조인
| 금지 | 올바른 사용 |
|------|------------|
| `->paginate($perPage)` / `->paginate($perPage, ['*'])` (컬럼 목록 미지정) | 목록이 실제로 쓰는 컬럼만 명시. 깊은 OFFSET 이 가능한 목록은 `PaginatesWithDeferredJoin` |
| 요청 값에서 온 정렬 컬럼을 그대로 `orderBy` 에 전달 | `ResolvesSortSpec` 으로 닫힌 집합 해석 (방향만 검사하는 `in_array` 는 보호가 아니다) |
| 지연 조인의 `$query` 에 미리 `orderBy`/`with`/`select` 적용 | 필터/where 만 적용해 넘기고, 정렬·관계·컬럼은 trait 인자로 전달 |
| 쿼리에 `with()` 만 하고 `relations:` 인자 생략 | 관계는 `relations:` 로 전달 — trait 이 inner 뿐 아니라 **outer 에서도** eager load 를 지우므로 관계가 조용히 사라진다 (예외·쿼리 오류 없이 응답에서 필드만 없어져 관계를 단언하지 않는 테스트는 전부 통과) |
| 목록 SELECT 에 `SUBSTRING(content, 1, N)` 을 두고 프루닝했다고 간주 | 오버플로 페이지 읽기가 그대로 발생 — 잘라내기는 outer(`$columns`)에서만 |
| 그룹 쿼리(`groupBy`)의 총 건수를 `count()` 로 계산 | `getCountForPagination()` (서브쿼리로 감싸 그룹 수를 센다) |
| raw SQL 안에 테이블명·별칭을 문자열로 조립 | 테이블명은 `(new Model)->getTable()`, 프리픽스는 `DB::getTablePrefix()`, 별칭은 빌더(`join($table.' as uc', …)`)가 만들게 |
| `whereRaw('1 = 0')` / `where($c, DB::raw("({$sub->toSql()})"))` + `mergeBindings` | `whereIn($key, [])` / `where($c, '=', $sub)` (빌더가 바인딩까지 처리) |
| 화면 정렬 셀렉트에 게이트가 모르는 컬럼을 넣기 | `화면 옵션 ⊆ FormRequest 게이트 ⊆ Repository 화이트리스트` — 어긋나면 422 후 직전 목록이 남아 정렬된 것처럼 보인다 |
| 분류값 필터의 허용 어휘를 화면·게이트·기록 지점에 각각 리터럴로 적기 | 어휘는 Enum 단일 출처에서 파생 — `화면 필터 옵션 = 라벨 키 = 실제 기록 어휘`. 부분집합이 되면 빠진 값으로 기록된 행이 어떤 필터 조합으로도 도달 불가하고, 라벨 키가 없는 값은 목록 셀에 원시 키 문자열로 노출된다 |
| 목록 응답이 `last_page > 1` 인데 화면에 페이저·총건수가 없음 | 페이지 이동 컨트롤과 총건수를 함께 노출 — 없으면 1페이지 밖 행이 조용히 잘리고, 잘렸다는 사실조차 화면에 나타나지 않는다 |
| 쿼리 파라미터 불리언에 `boolean` 규칙만 부착 | `prepareForValidation()` 으로 `"true"`/`"false"` 정규화 (쿼리는 문자열로 도착 — 화면이 그 형태로 보내면 목록 전체가 422). 단, 해석 불가한 값은 건드리지 말 것 |
| 같은 화면의 개수 배지와 그 배지가 여는 목록이 서로 다른 엔드포인트 | 배지 계산과 목록 조회는 같은 스코프의 같은 데이터소스에서 |
| 관계 테이블 컬럼 정렬을 `join`+`groupBy` 로 구현 | `SortsByRelatedColumn` 의 상관 서브쿼리 (1:N 조인은 원 행을 부풀려 총 건수·페이지 경계를 깨고, INNER 는 자식 없는 행을 지운다) |
| 관계 정렬을 넣고 인덱스는 그대로 | `(외래키, 정렬컬럼)` 복합 인덱스 마이그레이션 동반 (서브쿼리가 행마다 실행된다) |
| 페이지네이션 정렬을 비고유 컬럼(`created_at` 등)으로만 끝내기 | 정렬 마지막에 기본키를 덧붙여 전순서 보장 (동률 구간에서 인접 페이지가 같은 행을 중복 노출하고 다른 행을 누락한다) |
> 상세: [service-repository.md "목록 조회 컬럼 프루닝과 지연 조인" / "정렬 컬럼 화이트리스트" / "화면 정렬 옵션은 게이트의 부분집합이어야 한다" / "관계 테이블 컬럼 기준 정렬" / "허용되는 Raw 쿼리"](docs/backend/service-repository.md)
### 대용량 목록의 총 건수와 페이지 이동
총 건수 상한과 페이지 이동 범위는 **별개 결정**이다. 묶으면 필요 없이 기능이 깎인다. 총 건수만 상한을 받고, "다음" 이동은 `per_page + 1` 실측으로 끝까지 열어 둔다. 계산이 불가능해지는 것은 마지막 페이지 번호 하나뿐이다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 같은 술어를 `count()` 한 번, `get()` 한 번 실행 | `BoundedPaginator::paginate()` 한 번 (총 건수 + 페이지를 한 번에) |
| `paginate(PHP_INT_MAX)` 후 PHP `array_slice` | 실제 `page`/`per_page` 를 저장소까지 하달 |
| `forPage($page, $perPage + 1)` | offset 은 `per_page` 기준으로 따로 계산 (안 그러면 페이지가 깊어질수록 경계가 밀린다) |
| Scout `->keys()->all()` + 무제한 `whereIn` | 키워드 술어를 페이지 쿼리에 직접 밀어넣기 (`DatabaseFulltextEngine::whereFulltext`) |
| FULLTEXT 원문 키워드를 raw 로 바인딩 | 코어 sanitizer 경유 — `+` `-` `*` `"` 입력이 파싱 오류로 500 이 된다 |
| `whereDate` / `whereYear`+`whereMonth` | `FiltersByDateRange` 의 범위 조건 (컬럼에 함수를 씌우면 인덱스를 못 쓴다) |
| 총 건수를 모르는데 `last_page` 를 1 로 채움 | `null` 로 내보내 화면이 마지막 페이지 점프만 감추게 한다 |
| 상한값을 저장소·화면에 리터럴로 재기입 | `PaginationLimits` 단일 해석 + 확장은 `core.pagination.filter_*` 필터 훅으로만 조정 |
| 결과 크기가 데이터 증가에 비례하는데 상한 없는 `->get()` / `->pluck()` | 목록은 페이지네이션, 순회는 `chunkById`/`lazyById`, 몇 건이면 `limit` — 운영자 등록 수에 묶인 설정성 테이블만 예외이며 그 근거를 코드에 남긴다 |
| 배지·요약 건수를 `int` 하나로 돌려주기 | `BoundedPaginator::count()` 의 `BoundedCount` — 잘린 값과 정확한 값이 구분되지 않으면 잘린 10,000 이 "정확히 10,000 건" 으로 화면에 나간다 |
| 여러 카테고리 건수를 합치며 정확도는 버리기 | 하나라도 부정확하면 합계도 부정확. 단, 특정 탭만 볼 때는 그 카테고리의 정확도만 본다 |
| 정렬 마지막이 비고유 컬럼 | 기본키를 덧붙여 전순서 보장 (동률 구간에서 행이 겹치거나 샌다) |
| 관련도순(`_ft_score`)에 커서 적용 | 계산값은 WHERE 절 경계로 쓸 수 없다 — offset 유지 (`KeysetPaginator::supports` 가 판정) |
> 상세: [pagination.md](docs/backend/pagination.md)
### 검색 인덱스 재생성(리인덱싱)
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 재생성을 자동 트리거(마이그레이션 종료·확장 업데이트 완료 등)에 연결 | 운영자가 **명시적으로 선택**했을 때만 수행. 인덱스 잠금·전체 재색인 비용이 운영 중 사이트를 멈춘다 |
| 재생성 체크 상태를 전역에 남겨 다음 모달 진입에 이월 | 모달 진입 시드와 제출 후 초기화 **양쪽**에서 해제. 이월되면 운영자가 아무것도 누르지 않았는데 재생성이 수행되고, 서버 옵인 가드는 정상이라 HTTP 테스트로는 드러나지 않는다 |
| 모듈·플러그인 모달이 같은 전역 키 공유 | 면마다 별도 키 (한쪽 체크가 다른 쪽으로 전이 금지) |
| 응답 헬퍼가 `JsonResource::resolve()` 만 호출해 `additional()` 유실 | 부가 데이터를 응답 최상위에 병합 — 색인 누락은 오류 없이 "검색 0건" 으로만 나타나므로 응답 페이로드가 유일한 통로다 |
| 재생성 수행을 곧 복구로 간주 | `remaining` 은 **재생성 후 재점검** 결과 — "재생성했다" 와 "복구됐다" 를 구분해 보고 |
| 점검 커맨드의 비-0 종료를 "실행 실패" 로 표시 | 이상 발견 신호다. 종료 코드와 출력을 그대로 노출 |
| 특정 엔진(FULLTEXT) 전용으로 점검·재생성 구현 | `SearchIndexMaintainer` 계약 + `core.search.index_maintainers` 훅 |
| "점검 대상 0" 과 "점검 불가" 를 같은 문구로 보고 | 구분 보고 — 뭉뚱그리면 "인덱스가 다 정상" 으로 읽힌다 |
> 상세: [search-system.md](docs/backend/search-system.md)
### 검색 질의는 활성 엔진이 만든다
검색 엔진은 `core.search.engine_drivers` 훅으로 교체 가능하다. 그런데 그 교체가 실제로 먹는 것은 **활성 엔진을 거치는 경로뿐**이다. 저장소가 구체 엔진 클래스를 지목하면 등록된 엔진은 호출될 기회 자체를 잃고, 오류도 경고도 없이 그 사이트의 검색만 조용히 다른 방식으로 동작한다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| `DatabaseFulltextEngine::whereFulltext(...)` 등 구체 엔진 정적 호출 | `KeywordSearch::apply()` / `::applyAny()` (해석기가 활성 엔진에 위임) |
| `DB::getDriverName() === 'pgsql'` 처럼 드라이버명을 코드에 비교 | 선언형 `config('core.search.*')` + `core.search.like_operators` 필터 훅 |
| 매칭 ID 전량을 PHP 로 적재 후 `whereIn` (`search()->keys()->all()`) | 술어를 페이지 쿼리에 직접 부착 — ID 왕복도 목록 폭발도 없다 |
| 엔진에게 페이지 번호를 넘겨 한 페이지만 받기 | 페이지네이션은 DB 담당. 엔진은 **키 집합 상한**만 책임진다 (`KeywordSearchContext`) |
| 키 집합 상한을 총 건수 상한과 다른 값으로 두기 | 둘 다 `PaginationLimits::resultCap()` — 갈라지면 엔진이 돌려준 건수와 화면 총 건수의 근거가 달라진다 |
| 부분일치 폴백을 "전문검색 없을 때의 임시방편" 으로 취급 | 전문검색 미제공 DBMS 에서는 **그것이 정상 경로** — 와일드카드 escape + 대소문자 규칙을 갖춘다 |
| 확장이 `Model::search()` 를 쓰는 것을 금지로 오해 | Scout 경로는 그대로 유효하다. 새 계약은 대체가 아니라 **추가 통로** |
정적 검사는 이 저장소 안만 볼 수 있다 — 외부 엔진이 상한을 지키는지는 강제할 수 없으므로, 코어는 **값을 손에 쥐어 주는 것**까지 하고 그 값이 도달하는지를 계약 테스트가 고정한다.
> 상세: [search-system.md "키워드 술어는 활성 엔진이 만든다"](docs/backend/search-system.md)
### 통화 단위는 설정이 정한다
G7 은 **기본 통화**(상품·쿠폰·배송비 저장 기준), **표시 통화**(구매자가 고른 통화), **결제 통화**(PG 청구 통화)를 각각 따로 설정한다. 셋은 같을 수도, 모두 다를 수도 있다. 금액을 다루는 지점이 특정 통화를 전제하면 값은 맞고 **단위만 틀린** 금액이 나가며, 예외도 경고도 없다.
| 금지 | 올바른 사용 |
|--------|---------------|
| 다국어 문구에 `:amount원` / `:amount円` | 문구는 `:amount` 로 중립, 호출부가 `ecommerce_format_price($amount, $currency)` 로 포맷해 전달 |
| 레이아웃에서 `{{금액.toLocaleString()}}원` 조립 | 서버가 준 `*_formatted` / `multi_currency_*[통화].formatted` 를 그대로 출력 |
| `formatCurrencyPrice($price, 'KRW')` (통화 코드 리터럴) | `formatBaseCurrency()` / `formatOrderCurrency()` (설정·주문 스냅샷이 통화를 정한다) |
| `number_format($amount).'원'` | 같은 도메인의 통화 인지 헬퍼(`formatOrderChargeAmount()` 등) 경유 |
| `_global.preferredCurrency ?? 'KRW'` | `_global.preferredCurrency ?? _global.defaultCurrency` (둘 다 없으면 `*_formatted` 로 내려간다) |
| 통화표를 코드에 고정(기호·자릿수 5종 표 + 특정 통화 폴백) | 설정의 `symbol` / `decimal_places` 를 읽고, 미설정 시에만 폴백표 |
| `code === 'KRW' ? 0자리 : 2자리` 식 코드 분기 | `decimal_places` 로 판정 (운영자가 추가한 0자리 통화도 포함) |
| 통화 선택 입력의 기본값을 `"KRW"` 로 시드 | 설정의 `default_currency` — 마일리지처럼 **통화별 원장**을 쓰는 도메인은 표시가 아니라 **기록이 틀어진다** |
언어는 통화가 아니다. 한국어 문구에 `원`, 일본어에 `円` 을 박으면 기본 통화가 다른 상점에서 UI 언어가 통화를 결정하게 된다 — 영어 문구가 `:amount` 로 중립인 것이 정답이다.
주문·결제·환불 금액은 **거래 시점 통화로 동결**한다(`currency_snapshot.base_currency`). 운영자가 이후 기본 통화를 바꿔도 과거 주문의 표기는 불변이어야 한다.
동결 대상은 환율만이 아니다 — **소수 자릿수·절사 규칙·환산 분모(base_unit)까지 스냅샷이 SSoT** 다. 이 값들을 현재 설정에서 조회하면, 운영자가 그 통화를 삭제하는 순간 설정에서 사라져 폴백(자릿수 2)이 적용된다. 소수 0자리 통화의 과거 주문 표기가 `¥14,835` → `¥14,835.00` 으로 바뀌고, 3자리 이상으로 설정했던 통화는 표시 금액이 절사된다. 금액 계산은 스냅샷을 쓰는데 표기만 현재 설정을 따라가면 같은 화면 안에서 근거가 갈린다.
| ❌ 금지 | ✅ 올바른 사용 |
| --- | --- |
| 주문·환불 표시에서 `getDecimalPlaces($code)` 를 스냅샷 없이 호출 | `getDecimalPlaces($code, $currencySnapshot)` — 스냅샷이 있으면 그것이 우선 |
| 리소스가 주문 스냅샷을 자식에게 전파하지 않음 | `withOrderCurrency()` 를 전파하는 지점마다 `withCurrencySnapshot()` 도 함께 전파 |
| 상품·카탈로그 표시까지 스냅샷으로 고정 | 현재 판매가는 **현재 설정**이 정답 — 스냅샷 없이 호출한다 |
| 자릿수를 박제하지 않은 구형 스냅샷에서 예외/0 자리 강제 | 박제값이 없으면 현재 설정 폴백을 그대로 탄다 (하위호환) |
> 상세: [api-resources.md](docs/backend/api-resources.md), [service-repository.md](docs/backend/service-repository.md)
### 확장 결제수단은 자기 능력을 선언한다
코어 `PaymentMethodEnum` 은 확장 결제수단 ID(`kginicis_naverpay`, `toss_tosspay` 등)를 모른다. 그래서 능력(PG 필요 여부 / PG 고정 / 환불수단)은 **등록하는 확장이 카탈로그에 선언**하고, 관리자 화면과 서버는 그 선언만 읽는다. 미선언 시 안전 기본값(`needs_pg=true`, `pg_locked=false`, `pg_provider=null`)으로 떨어지는데, 그 조합은 "PG 가 필요한데 어느 PG 인지 모른다" 를 뜻해 화면과 실제 결제 경로가 어긋난다.
| 금지 | 올바른 사용 |
|--------|---------------|
| entry `defaults` 에 `pg_provider` 만 두고 능력 키 생략 | `needs_pg` 명시 선언 (PG 결제창을 거치는가) |
| 자기 PG 전용 수단인데 `pg_provider: null` | `pg_provider: '{자기 provider id}'` + `pg_locked: true` (PG 제공자 등록 리스너의 id 와 동일해야 배지가 이름을 찾는다) |
| 표시를 고치려고 코어 레이아웃 표현식을 정규식 치환 | 카탈로그 선언만 바꾼다 — 레이아웃이 `pg_locked`/`needs_pg` 로 직접 3분기한다 |
| 선언을 바꾸고 기설치본은 그대로 | 저장된 `order_settings.json` 을 정정하는 업그레이드 스텝 동반 (자기 접두사만, 멱등) |
레이아웃 치환 방식은 코어가 그 리터럴을 버리는 순간 조용히 사문화된다 — 합성 입력으로만 검증한 테스트는 계속 통과하므로 사문화가 드러나지 않는다. 정적 검사가 능력 선언 누락을 차단한다.
### 예외를 응답으로 바꾸는 자리
`catch (\Exception)` / `catch (\Throwable)` 는 **도메인 예외가 아닌 것**을 잡는 자리다. 여기서 4xx 를 돌려주면 인프라 장애·코드 결함이 "입력 오류" 로 위장되어 사용자는 고칠 수 없는 안내를 보고 운영자는 장애를 늦게 안다. 그리고 이미 번역된 `$e->getMessage()` 를 응답의 메시지 **키** 자리에 넘기면 키 해석에 실패해 원문(SQL 상태코드·경로 포함 가능)이 그대로 화면에 나간다. 둘 다 예외도 로그도 남기지 않는다.
| 금지 | 올바른 사용 |
|--------|---------------|
| generic catch 가 4xx 반환 | 5xx — 의도적 4xx 는 사유와 함께 판정 테스트의 상수에 선언 |
| generic catch 에서 상태코드 인자 생략 (`moduleError($mod,'key')`) | 상태코드 명시 — 생략 시 `ResponseHelper` 기본값 **400** 이 조용히 적용된다 |
| 도메인 예외를 typed 로 승격한 뒤에도 `catch (\RuntimeException)` 유지 | 승격한 예외로 좁힌다 — 도메인 예외의 **부모**를 잡으면 남는 것은 인프라 예외뿐인데 그것까지 4xx 가 된다 |
| `error($e->getMessage(), 422)` | `error($e->getMessageKey(), 422, null, $e->getMessageParams())` |
| 도메인 예외가 번역문만 들고 다님 | 생성자에 키+치환 파라미터 보관 → `getMessageKey()` / `getMessageParams()` |
| 서비스를 typed 예외로 승격하고 컨트롤러는 generic 만 유지 | 그 서비스 메서드를 호출하는 **컨트롤러 메서드 전수**에 typed catch 추가 (없으면 도메인 사유가 500 이 된다) |
| typed 예외 도입하면서 그 분기의 상태코드도 변경 | typed 는 **기존 상태코드 유지** — 예외 도입이 사용자 계약을 함께 바꾸면 회귀다 |
| 공개(비인증) 엔드포인트 응답에 예외 원문 포함 | 원문은 `Log::error` 로만 — 관리자 전용 면의 `errors` 페이로드는 진단 정보로 허용된다 |
| 원문을 직접 문자열로 조립해 노출 폭을 호출부가 정함 | 노출 폭은 `ResponseHelper` 가 정한다 — Throwable 을 넘기면 `app.debug` 에서만 펼쳐진다 |
| 치환 자리(`:error`)를 가진 키를 파라미터 없이 호출 | 넷째 인자 `messageParams` 로 채운다 — 비워 두면 번역기가 자리표시자를 **그대로 둔 문장**을 돌려줘 운영자 화면에 `:error` 가 노출된다 (실패했을 때만 드러나 정상 흐름 테스트로는 안 잡힌다) |
| 사유를 모른다고 치환 자리를 비워 두기 | 알 수 없으면 일반 문구(`errors.unknown_error`)로 채운다 |
| 원문을 싣지 않기로 한 문구에 `:error` 자리를 남겨 두기 | 그 키에서 **치환 자리 자체를 없앤다** — 자리를 남기면 나중에 예외 원문으로 채우는 회귀를 부른다 |
| 하위 계층이 `false`/`null` 만 돌려주고 실패 사유를 버림 | 사유를 반환 경로에 실어 올린다 (배열 키 `reason` 또는 **뒤에 붙인 선택적 out 파라미터**) — 기존 호출부를 깨지 않는다 |
| 확장 수명주기 훅이 사유 없이 `false` 반환 | `AbstractModule`/`AbstractPlugin` 의 `failWith(__('...'))` — 코어가 그 사유를 원인 자리에 싣는다 |
`message`(첫 인자)와 `errors`(셋째 인자)는 다른 통로다. **키 자리에 원문을 넘기는 것은 언제나 금지**지만, `errors` 페이로드의 원문은 금지 대상이 아니다 — `ResponseHelper::error` 가 문자열 `errors` 를 `500+` 비디버그에서만 차단하고 배열은 통과시키는 것은 `tests/Unit/Helpers/ResponseHelperTest.php` 가 고정한 의도다. 관리자에게 결제대행사·외부 시스템이 돌려준 사유를 감추면 조치 근거가 사라지고, 다국어 키는 유한해서 예상 못 한 실패를 담지 못한다. 판단 축은 "원문이냐 키냐" 가 아니라 **누구에게 / 무엇의 원문인가 / 어느 통로인가** 셋이다.
상세: [exceptions.md "예외 → 응답 매핑"](docs/backend/exceptions.md). `tests/Feature/Http/GenericCatchStatusCodeContractTest.php` 가 코어와 모든 번들 확장의 컨트롤러를 전수 스캔해 두 규칙을 고정한다. 판정기를 한 확장 안에 두면 그 확장 밖의 동형 결함이 검출되지 않는다. 치환 자리 축은 `tests/Feature/Http/ErrorMessageParamSubstitutionTest.php` 가 고정한다 — 호출부를 열거하지 않고 `->error(...)` 전수를 괄호 균형으로 잘라, 키를 실제로 번역해 `:error` 를 요구하는지 판정한다. 같은 판정기가 `new *OperationException(...)` 생성자 축도 덮는다(파라미터 배열이 넷째가 아니라 둘째 인자다). 이 축이 없으면 키를 들고 다니는 예외로 던지는 경로가 통째로 사각이 된다.
### Listener 데이터 접근
| 금지 | 올바른 사용 |
@@ -269,6 +605,8 @@
| Listener 생성자에 구체 Repository 직접 주입 | Repository Interface 주입 |
| Listener 에서 `request()` / `$_POST` 직접 접근 | Service 가 검증 후 도메인 객체로 전달 받기 |
| Filter 훅에 `'type' => 'filter'` 누락 | type 명시 필수 (반환값 무시 회귀 차단) |
| 실패 시 호출자 트랜잭션을 되돌려야 하는 Action 훅에 `'sync' => true` 누락 | 금전 이동(쿠폰 차감·복원, 적립금 차감·복원)은 `sync` 필수. 기본값은 큐 래핑 + `afterCommit` 이라 **커밋 뒤에** 실행되어, 예외를 던져도 롤백되지 않고 오류 응답만 나간 채 데이터가 남는다 (큐 드라이버가 `sync` 여도 동일) |
| 훅 회귀 테스트에서 리스너를 손으로 `addAction` 등록 | `HookListenerRegistrar::register()` 로 **실제 등록 경로**를 태운다 — 손으로 등록하면 큐 래핑을 건너뛰어 커밋 이후 실행 문제를 통과시킨다 |
| Listener 가 `HookListenerInterface` 미구현 (auto-discovery 대상) | implements + `getSubscribedHooks()` 정적 메서드 |
> 상세: [hooks.md "Listener 데이터 접근 규정"](docs/extension/hooks.md), [service-repository.md](docs/backend/service-repository.md)
@@ -666,9 +1004,15 @@ Controller → Request → Service → RepositoryInterface → Repository → Mo
절대 금지: DB CASCADE에 의존한 삭제 → Service에서 명시적 삭제 (훅/파일/로깅 보장)
절대 금지: 로케일 하드코딩 → config('app.supported_locales') 사용
필수: 마이그레이션 한국어 comment 필수, down() 구현 필수
필수: FK 컬럼의 ->comment() 는 ->constrained()/->references()/->on() 앞에 둔다 (뒤에 두면 comment 가 컬럼이 아닌 FK 정의에 부착되어 조용히 사라진다)
필수: 소스 교정만으로는 기설치본이 낫지 않는다 — 마이그레이션은 재실행되지 않으므로 업그레이드 스텝 백필을 함께 작성
필수: 필터가 걸린 쿼리를 순회하며 그 행을 update/delete 하면 chunkById() (키셋 순회)
절대 금지: 그 경우 chunk()/each()/lazy() 사용 — OFFSET 기반이라 처리된 행이 결과에서 이탈한 만큼 커서가 밀려 미처리 행을 조용히 건너뛴다 (250건/청크 100 → 100건 누락, 예외·로그 없음)
주의: ResponseHelper::success($messageKey, $data) — 메시지가 첫 번째 인수
```
갱신값이 항상 필터 소속을 유지해 안전한 경우(예: `whereNotNull` + 갱신값이 항상 non-null)만 예외이며, 그 근거를 코드 주석에 남긴다. 정적 검사가 이 패턴을 검출한다.
> 상세 규칙 (API 리소스, ServiceProvider, validation, 인증, 활동 로그 등): [docs/backend/](docs/backend/) 각 문서 참조
### 컨트롤러 계층
@@ -712,6 +1056,11 @@ BaseApiController (최상위)
필수: 확장 코드 변경 시 manifest 버전 업 (미변경 시 업데이트 감지 불가)
필수: 버전 업 시 CHANGELOG.md 기록 — Keep a Changelog 표준 (미기록 시 버전 업 불가)
필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지)
필수: ActionDispatcher 에 핸들러를 등록하는 확장은 재등록 진입점을 window 전역에 고정 이름으로 노출 — 모듈 window.__[Name].initModule, 플러그인 window.__[Name].initPlugin (미노출 시 로케일 전환 후 해당 확장 액션이 전부 무반응, 에러·토스트 없음). 진입점은 핸들러 재등록만 수행
필수: 확장 미들웨어는 getMiddleware() 로 부착 대상(targets) 명시 선언 (self-gate) — SP Kernel 미들웨어 그룹 직접 조작·라우트 파일 자기 미들웨어 FQCN 부착 금지, 무규율 전역 개입 금지
필수: 라우트 정의를 바꾸는 지점은 App\Support\RouteCacheHelper::rebuild() 로 라우트 캐시 갱신 — 확장 설치/활성화/비활성화/삭제/업데이트, 코어 업데이트·업그레이드 스텝. route:clear/route:cache 를 각 지점에 직접 흩어 놓지 않는다 (누락 발생, 비우기만 하면 재생성되지 않아 성능 이점 영구 소실). 훅 캐시와 달리 라우트 캐시에는 스캔 폴백이 없어 캐시에 없는 라우트는 예외·경고 없이 404. 파일 교체 중인 코어 업데이트는 중간에 clear(), 끝에서 rebuild(). 템플릿·모듈 설정은 서버 라우트 무관 (상세: docs/backend/routing.md "라우트 캐시")
필수: 확장 라우트는 활성 상태인 확장의 것만 등록한다 — 모듈·플러그인 두 라우트 프로바이더가 같은 기준을 쓴다. 게이트가 한쪽에만 있으면 그 비대칭은 오류가 아니라 "조용히 열린 경로" 로만 나타난다: 비활성화해도 화면·메뉴·에셋만 사라지고 API 는 계속 호출 가능하며, 컨트롤러가 정상 처리하므로 오류도 로그도 남지 않는다
필수: 그 rebuild() 는 확장 상태 캐시 무효화(invalidate*StatusCache()) 뒤에 온다 — route:cache 는 새 앱을 부팅해 라우트를 수집하는데 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록(TTL 기본 1일)을 읽으므로, 먼저 구우면 방금 바뀐 상태가 빠진 채 박제되고 자가 회복되지 않는다 (활성화 → 그 확장 API 전량 404 / 비활성화 → 끈 확장 API 가 계속 호출 가능 / 업데이트 → 404 + 훅 리스너 누락). 무효화는 굽기 직전이 아니라 DB 상태 쓰기 직후에 둔다 — 같은 목록을 읽는 굽기가 라우트 캐시 말고도 있다 (오토로드 갱신 안의 훅 매핑 캐시). update 경로만 예외: Updating 전이 직후에는 비우지 않고 (비우면 그 창의 오토로드 갱신이 그 확장을 비활성으로 판정해 훅 리스너를 떨군다) 상태 복원 직후에 비운 뒤 ExtensionManager::regenerateHookCache() 로 훅 캐시를 다시 굽는다. 훅 캐시 폴백은 파일 부재·손상에만 작동해 내용이 stale 한 경우는 조용히 통과한다
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
필수: 모든 확장 작업은 Artisan 커맨드로 수행
```
@@ -768,14 +1117,33 @@ public function createProduct(array $data): Product
| `*.json` (레이아웃만) | `{type}:update {id} --force` 실행 |
| `*.tsx`, `*.ts` + `*.json` | `{type}:build` + `{type}:update {id} --force` |
| `*.tsx`, `*.ts`만 | `{type}:build` + `{type}:update {id} --force` |
| `lang-packs/_bundled/**` (번들 언어팩 콘텐츠/버전) | `language-pack:update {id} --force` (빌드 불필요) |
```bash
# 확장 업데이트 (_bundled → 활성 반영)
php artisan template:update sirsoft-admin_basic --force
php artisan module:update sirsoft-ecommerce --force
php artisan plugin:update sirsoft-payment --force
php artisan language-pack:update g7-core-ja --force
```
번들 언어팩도 `_bundled` 는 배포 원본일 뿐이다. 설치본(`lang-packs/{id}/`)을 갱신하지 않으면 새로 추가한 번역 키가 런타임에 존재하지 않아 해당 로케일이 조용히 기준 로케일로 폴백한다.
### 배포 산출물의 브라우저 하한
선언 하한은 **Chrome 111 / Safari 16.4 / Firefox 128** 이다 ([requirements.md §7](docs/requirements.md)). 빌드 타깃(`target: 'es2020'`)은 이 하한을 강제하지 못한다 — **ES 연도와 브라우저 지원 연도가 다르기 때문**이다. ES2018 인 정규식 lookbehind 를 WebKit 은 Safari 16.4 에서야 구현했고, 타깃 검사는 그대로 통과시킨다.
정규식 **리터럴** 문법은 그중에서도 다운레벨이 원리상 불가능하다. 번들러는 lookbehind 를 `new RegExp(...)` 로 옮길 뿐이라 파싱 오류가 **런타임 오류로 이동**할 뿐 사라지지 않는다. 따라서 타깃 하향은 해법이 아니다.
| ❌ 금지 | ✅ 올바른 사용 |
|--------|---------------|
| 배포 JS 산출물에 선언 하한 **초과** 문법·API (`Object.groupBy`·`Promise.withResolvers`·`Array.fromAsync`·`RegExp.escape`·정규식 `v` 플래그) | 하한 이하 문법으로 작성 |
| **부팅 임계 번들**(`public/build/core/template-engine.min.js`, `templates/_bundled/*/dist/js/components.iife.js`)에 정규식 리터럴 전용 문법(lookbehind `(?<!` `(?<=`, `v` 플래그) — 하한과 같은 버전이어도 | 그 두 파일만은 하한 미만 브라우저에서도 **파싱**돼야 한다 |
| 빌드 타깃을 낮춰 해결 시도 | 소스에서 그 문법을 쓰지 않는다 |
| 바이트 길이 비교식 번들러 검출기로 판정 | 문법·API 표 기반 검사 (프린터 표기 차이가 오탐을 낸다) |
부팅 임계 번들 둘은 `async`/`defer` 없는 동기 classic 스크립트다. 파싱에 실패하면 **폴백 안내 화면조차 렌더되지 않아** 사용자에게는 백지 또는 거짓 진단만 남는다. 하한 미만 브라우저에 "지원 범위 밖" 안내를 띄우려면 이 두 파일은 파싱에 성공해야 하므로, 하한과 **정확히 같은** 버전을 요구하는 문법(lookbehind = Safari 16.4)도 금지한다 — 하한 초과만 보는 검사로는 영원히 잡히지 않는 지점이다. 정적 검사가 이 규칙을 강제한다.
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
@@ -788,6 +1156,18 @@ php artisan plugin:update sirsoft-payment --force
lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱글톤·React Context·DevTools 코어)을 재사용할 때는 재번들하지 않고 `window.G7Core.__runtime` 을 빌려 쓴다 — React/컨텍스트/싱글톤 인스턴스 동일성이 강제되기 때문(사본이 둘이면 "Invalid hook call"·컨텍스트 미매칭). 메인 번들이 `G7CoreGlobals` 에서 공유 대상을 `G7Core.__runtime` 에 노출하고, lazy 번들 vite config 는 React 4종(`react`/`react-dom`/`react-dom/client`/`react/jsx-runtime`)을 external→window 로, 코어 런타임 모듈을 `resolveId` 플러그인으로 `__runtime-shims/` 로 치환한다.
### 확장 번들 병합 (서버측 concat)
활성 모듈/플러그인의 프론트엔드 IIFE JS·CSS 는 타입별로 서버에서 하나의 번들로 병합해 서빙한다(`/api/{modules,plugins}/bundle.{js,css}?v={version}`). `ExtensionBundleService` 가 정렬·필터·concat·캐시를 전담하고, 프론트는 `window.G7Config.bundleUrls` 를 읽어 모듈 번들 → 플러그인 번들 순으로 로드한다. 병합 규율:
- priority 순서는 선언형 — 실행 순서는 오직 manifest `loading.priority` 오름차순(`uasort`). 특정 확장 이름을 지목하는 분기를 두지 않는다.
- IIFE 사이는 `\n;\n`(JS)/`\n`(CSS) 로 잇는다. 미사용 시 ASI 경계가 깨져 번들 전체 파싱 에러가 난다.
- 소스맵은 prod strip, dev 는 개별 에셋 서빙 절대 URL 로 rewrite. 개별 에셋 서빙 라우트(`*.map` 포함)는 존치한다.
- 번들 URL 은 반드시 same-origin(`/api/...`). 외부 origin/CDN·protocol-relative 는 gdpr preblocker 에 자기 차단된다.
- 확장 에셋 절대경로는 `getBuiltAssetAbsolutePaths()`(=`getModulePath()`/`getPluginPath()`) 만 쓴다. `base_path("modules"|"plugins")` 직접 조립은 `_bundled` 경로 오해석 → 빈 번들.
- concat 루프는 확장별 try/catch — 실패 확장만 skip 하고 나머지 병합을 지속한다.
- 번들 파일명에 확장 캐시 버전을 포함(`{type}.{version}.{js,css}`). 조합 변경 시 version bump → 새 파일명 → 자동 재생성. 구파일 GC 는 `ext-bundles:cleanup` + `{module,plugin,template}:cache-clear` 가 담당한다. prod 은 version-in-path 디스크 캐시, 비프로덕션은 매 요청 concat.
### 빌드 명령어 (Artisan)
```bash
@@ -816,6 +1196,8 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
```
> **빌드 원칙**: 기본값은 `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
`_bundled` 의 `dist/`(코어는 `public/build/core/`)는 Git 추적되는 배포 산출물이다 (`*.map` 만 ignore). src 변경 시 커밋 dist 를 `--production` 으로 동반 재빌드한다 — 신규 소스 리터럴이 dist 에 없으면 stale 빌드이며, 정적 검사가 이를 검출한다. 커밋 dist 에 `//# sourceMappingURL=` 참조를 남기지 않는다 — `.map` 은 배포본에 존재하지 않아 브라우저 개발자 도구에서 404 를 유발한다. 코어 3번들 재빌드는 `core:build --production` (`--full` 은 앱 번들 전용 — `public/build` 를 비워 코어 3번들을 지운다).
> 활성 디렉토리 반영은 `update` 커맨드로만 수행. `--watch` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
---
@@ -902,7 +1284,7 @@ php artisan migrate:rollback
| 수정 대상 파일 패턴 | 작업 전 필수 참조 |
| ------------------- | ------------------ |
| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md) |
| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md), [api-documentation.md](docs/backend/api-documentation.md) |
| `app/Services/**` | [service-repository.md](docs/backend/service-repository.md) |
| `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) |
| `app/Repositories/**` | [service-repository.md](docs/backend/service-repository.md) |
@@ -920,6 +1302,7 @@ php artisan migrate:rollback
| `lang/**` | [database-guide.md](docs/database-guide.md) (다국어 섹션) |
| `routes/**` | [routing.md](docs/backend/routing.md) |
| `app/Seo/**` | [seo-system.md](docs/backend/seo-system.md) |
| `app/Benchmark/**`, `config/benchmark.php` | [benchmark.md](docs/backend/benchmark.md) |
---
@@ -952,3 +1335,8 @@ php artisan migrate:rollback
- **ResolvesActivityLogType**: `app/ActivityLog/Traits/ResolvesActivityLogType.php`
- **ChangeDetector**: `app/ActivityLog/ChangeDetector.php`
- **CoreActivityLogListener**: `app/Listeners/CoreActivityLogListener.php`
- **BenchmarkProfileRegistry**: `app/Benchmark/BenchmarkProfileRegistry.php`
- **성능 계측 DTO**: `app/Benchmark/DTO/{BenchmarkProfile,BenchmarkRunOptions,BenchmarkResult}.php`
- **BenchmarkAxisRunner**: `app/Benchmark/Contracts/BenchmarkAxisRunner.php`
- **성능 계측 축 실행기**: `app/Benchmark/Axes/{List,Screen,Write,Batch}AxisRunner.php`
- **BenchmarkAxis**: `app/Enums/BenchmarkAxis.php`
+397 -2
View File
@@ -4,6 +4,403 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.9] - 2026-08-24
### Added
- 게시글·상품 설명·페이지처럼 에디터로 작성한 본문에서 첫 번째 내부 이미지를 찾아 목록 썸네일이나 공유 미리보기 이미지로 활용할 수 있는 공용 기능이 추가되었습니다. 게시판·쇼핑몰·페이지 확장이 이 기능을 사용합니다. (#22 @abc101 님께서 제보해주셨습니다.)
- 사이트 기본 공유 이미지(og:image)를 환경설정 > SEO 에서 지정할 수 있습니다. 화면별 공유 이미지가 없을 때 이 이미지가 사용되어, 사이트 주소를 공유했을 때 미리보기 카드에 이미지가 비어 보이는 일이 없어집니다.
### Fixed
- 알림 템플릿·본인인증 메시지에서 [기본값 복원]을 실행하면 언어팩이 제공한 번역(일본어 등)이 함께 지워지던 문제를 수정했습니다. 이제 복원 기본값에도 활성 언어팩의 번역이 포함되어, 복원 후에도 해당 언어 화면에 번역이 유지됩니다. 또한 이번 버전으로 업그레이드하면 이전 버전에서 이미 지워졌던 언어팩 번역이 활성 언어팩 기준으로 자동 복구됩니다.
- 설치·복구 과정의 데이터 동기화에서 코어 알림 정의와 본인인증 메시지에 언어팩 번역이 주입되지 않을 수 있던 문제를 수정했습니다.
- 언어팩 업데이트가 실패했을 때 사용 중이던 언어팩이 비활성 상태로 남아, 해당 언어의 화면·메일 문구가 안내 없이 기본 언어로 표시되던 문제를 수정했습니다. 이제 실패 시 이전 상태로 복원됩니다.
- 언어팩을 다시 활성화하거나 업데이트할 때 관리자가 직접 수정해 둔 알림·본인인증 템플릿의 해당 언어 문구가 언어팩 기본값으로 덮어써지던 문제를 수정했습니다. 이제 관리자가 수정한 언어만 보존되고, 수정하지 않은 언어는 언어팩 갱신을 그대로 따라갑니다.
- 언어팩 활성화 직후 같은 화면 흐름에서 일부 기본 데이터에 새 언어 번역이 반영되지 않던 문제를 수정했습니다.
## [7.0.8] - 2026-08-22
### Added
- 사이트가 외부로 보내는 요청을 지정한 프록시 서버를 거쳐 나가도록 설정할 수 있습니다. 결제사처럼 접속 IP 를 제한하는 외부 서비스를 연동할 때, 개발·스테이징 환경에서도 허용된 IP 로 요청을 보낼 수 있습니다. 환경설정 > 고급에서 디버그 모드를 켜면 프록시 주소 입력칸이 나타나며, 프록시를 거치지 않을 주소를 예외 목록으로 따로 지정할 수 있습니다. 디버그 모드를 끄면 저장된 주소가 남아 있어도 프록시는 적용되지 않습니다.
- 프록시 주소 옆의 「연결 테스트」로 저장하기 전에 연결 여부를 확인할 수 있습니다. 성공하면 그 프록시를 거쳤을 때 외부 서비스에 보이는 IP 주소를 함께 알려주므로, 결제사에 어떤 IP 를 등록해야 하는지 미리 확인할 수 있습니다.
- 확장 개발자용: 사이트 표준 HTTP 호출은 프록시 설정이 자동으로 적용됩니다. 외부 연동 규약상 별도 방식으로 통신해야 하는 확장은 코어가 제공하는 프록시 설정을 받아 같은 경로로 내보낼 수 있습니다.
### Changed
- 비활성화한 플러그인의 기능이 더 이상 동작하지 않습니다. 이전에는 플러그인을 꺼도 화면·메뉴·스크립트만 사라지고 그 플러그인의 기능 주소는 계속 응답해, 꺼진 결제수단으로 결제가 시도되는 등 "껐는데 아직 살아 있는" 상태가 남았습니다. 이제 모듈과 동일하게 활성화된 플러그인의 기능만 동작합니다. **결제·본인인증처럼 외부 서비스가 직접 호출하는 주소도 함께 닫히므로, 진행 중인 거래가 있을 때의 비활성화·업데이트는 처리가 끝난 뒤에 하시기 바랍니다.**
- 확장 개발자용: 모듈·플러그인의 설치·활성화·비활성화·제거 처리에서 실패 사유를 코어에 전달할 수 있습니다. `failWith()` 로 사유를 남기고 실패를 반환하면 관리자 화면의 실패 안내에 그 사유가 함께 표시됩니다. 사유를 남기지 않아도 종전처럼 동작합니다.
- 지원 브라우저 문서에 최소 버전(Chrome 111 / Safari 16.4 / Firefox 128)을 명시했습니다. 이보다 오래된 브라우저에서는 스타일이 일부 깨질 수 있으나 화면 표시와 기본 이용은 가능하도록 유지한다는 방침도 함께 밝혔습니다. (#121 @bigmsg 님께서 건의해주셨습니다.)
### Fixed
- 구형 iOS·macOS Safari(16.4 미만)에서 사이트가 전혀 표시되지 않던 문제를 수정했습니다. 화면 구성에 쓰이는 스크립트에 해당 브라우저가 해석하지 못하는 문법이 들어 있어, 스크립트 전체가 실행되지 못하고 사이트가 통째로 멈춰 있었습니다. (#121 @bigmsg 님께서 제보해주셨습니다.)
- 화면을 불러오지 못했을 때 원인과 무관하게 "네트워크 연결이 불안정할 수 있습니다"로 안내되던 문제를 수정했습니다. 브라우저가 지원 범위보다 오래되어 화면을 실행하지 못한 경우에는 그에 맞는 안내를 표시하며, 새로고침해도 해결되지 않는 상황이므로 새로고침 버튼도 표시하지 않습니다.
- 관리자 화면의 실패 안내에 원인이 들어갈 자리가 채워지지 않아 `:error` 라는 내부 표시가 그대로 보이던 문제를 수정했습니다. 모듈·플러그인·템플릿·언어팩 관리와 플러그인 설정 저장의 실패 안내 전반에서 발생했습니다. 이제 실패 원인을 알 수 있으면 그 원인이, 알 수 없으면 일반 안내 문구가 표시됩니다. 언어팩 관리처럼 원인을 표시하지 않기로 한 안내는 문구 자체를 정리했습니다.
- 모듈·플러그인을 활성화한 직후 그 확장의 화면과 기능이 "주소를 찾을 수 없음" 오류만 내던 문제를 수정했습니다. 활성화 시점에 사이트 내부 주소록이 활성화 이전 상태를 기준으로 다시 만들어져, 방금 켠 확장의 주소가 빠진 채로 굳어졌습니다. 시간이 지나도 스스로 복구되지 않았습니다. 같은 원인으로 비활성화한 확장의 기능이 계속 호출 가능하던 문제, 확장을 업데이트한 직후 그 확장의 기능과 다른 기능과의 연동 동작이 함께 누락되던 문제도 바로잡았습니다.
## [7.0.7] - 2026-08-19
### Security
- 부관리자(위임 관리자)가 슈퍼 관리자 계정을 함부로 손대지 못하도록 막았습니다. 슈퍼 관리자 보호는 삭제·탈퇴 경로에만 있었고 비밀번호 변경·상태 변경(차단/탈퇴)·계정 잠금 해제·일괄 상태 변경 경로에는 없어, 회원 관리 권한만 위임받은 계정이 슈퍼 관리자 계정을 무력화할 수 있었습니다. 이제 이 경로 전부에 같은 기준을 적용해, 슈퍼 관리자 계정은 슈퍼 관리자만 수정할 수 있습니다. 슈퍼 관리자 본인의 작업은 종전처럼 정상 동작합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1919)
- 부관리자가 역할 권한을 통해 자신보다 높은 권한을 획득하지 못하도록 막았습니다. 이전에는 역할에 권한을 부여할 때 부여하는 사람이 그 권한을 가졌는지 확인하지 않아, 권한 관리 권한만 위임받은 계정이 자신에게 없는 권한이나 더 넓은 범위의 권한을 역할에 실어 우회 상승할 수 있었습니다. 이제 자신이 보유한 권한을 자신의 범위 이내로만 부여할 수 있으며, `admin` 같은 시스템·확장 소유 역할의 권한·활성 상태 변경도 슈퍼 관리자로 제한됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1919)
- 위 상한을 사용자에게 역할을 붙이는 경로에도 동일하게 적용했습니다. 이전에는 사용자 생성·수정 화면에서 역할을 배정할 때 그 역할이 담은 권한을 배정자가 모두 가졌는지 확인하지 않아, `admin` 같은 고권한 역할을 통째로 붙여 우회 상승할 수 있었습니다. 이제 배정자가 자신의 권한 범위 안에서 전부 부여할 수 있는 역할만 붙일 수 있습니다. 같은 기준이 역할을 **떼는** 방향에도 적용되어, 자신이 부여할 수 없는 상위 역할을 다른 관리자에게서 박탈하는 것도 차단됩니다. 사용자가 이미 가진 역할을 유지하는 수정은 그대로 허용되고, 슈퍼 관리자의 역할 배정은 종전처럼 정상 동작합니다. (KVE-2026-1919)
- 레이아웃 편집기에 저장하는 표현식이 서버나 다른 사용자 브라우저에서 임의 코드로 실행될 수 없도록 표현식 평가 방식을 근본적으로 바꿨습니다. 이전에는 표현식을 실제 코드로 만들어 실행했기 때문에 특정한 우회 기법으로 편집 권한을 넘어선 동작이 가능했습니다. 이제 정해진 문법·함수만 해석하는 안전한 방식으로 평가하며, 위험한 표현식과 외부 주소의 스크립트 로드는 저장 단계에서도 거부합니다. 기존 레이아웃의 정상 표현식(조건·계산·목록 가공·경로 조립 등)은 그대로 동작하고, 위지윅 에디터·주소 검색처럼 정해진 외부 스크립트를 쓰는 확장은 각자 신뢰 출처를 선언해 정책 강화 이후에도 정상 동작합니다. 신뢰 출처는 모듈·플러그인·템플릿이 모두 자기 설정 파일에 선언할 수 있으며, 활성 상태인 확장의 선언만 반영됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1915)
- 레이아웃에 적는 주소가 "내 사이트 경로"인지 판정하는 방식을 브라우저의 실제 해석과 일치시켰습니다. 이전에는 주소 앞부분의 글자만 보고 판정했기 때문에, 슬래시 사이에 역슬래시나 보이지 않는 공백 문자를 끼워 넣은 주소가 내 사이트 경로처럼 통과한 뒤 브라우저에서는 외부 사이트 주소로 해석되어, 선언하지 않은 외부 스크립트가 실제로 불려 올 수 있었습니다. 이제 브라우저와 같은 기준으로 정규화한 뒤 판정하며, 저장 단계·화면 로드·정비 검사 세 곳이 같은 기준을 씁니다. 경로 중간에 역슬래시가 들어간 정상 주소는 종전처럼 그대로 동작합니다.
- 레이아웃 편집기로 저장할 때 외부 주소 차단이 적용되지 않던 문제를 수정했습니다. 레이아웃을 새로 만들거나 정보를 수정하는 경로에는 이 검사가 걸려 있었지만, 편집기가 실제로 사용하는 콘텐츠 저장 경로에는 빠져 있어 같은 내용도 어느 화면에서 저장하느냐에 따라 통과 여부가 달랐습니다. 함께, 주소 입력 칸 하나만 검사하도록 걸어 둔 설정이 실제로는 아무것도 검사하지 않고 지나가던 것도 바로잡았습니다. 이제 네 저장 경로가 모두 같은 강도로 검사합니다.
- 회원 일괄 상태 변경에서 담당 범위 제한이 적용되지 않던 문제를 수정했습니다. 회원 수정 권한은 "본인 계정만" 또는 "같은 역할 범위만" 으로 범위를 좁혀 위임할 수 있는데, 이 제한은 회원을 하나씩 여는 화면에서만 적용되고 목록에서 여러 명을 한 번에 처리하는 일괄 변경에는 적용되지 않았습니다. 그래서 범위를 좁혀 위임받은 관리자가 일괄 변경으로는 담당 밖 회원까지 차단·탈퇴 처리하고 그 회원들의 로그인 세션까지 끊을 수 있었습니다. 기본 제공 역할인 "매니저" 가 이 구성에 해당합니다. 이제 일괄 변경도 회원 상세와 같은 기준으로 대상마다 범위를 확인하며, 범위 밖 회원은 처리 대상에서 제외되고 처리 건수로 확인할 수 있습니다. 범위 제한 없이 위임받은 관리자의 일괄 작업은 종전처럼 정상 동작합니다. (KVE-2026-1919)
- 레이아웃에 적는 스크립트 주소가 확장이 선언한 신뢰 출처인지 판정할 때, 주소를 브라우저와 같은 기준으로 정규화하지 않던 문제를 수정했습니다. 신뢰 출처 이름을 주소 뒷부분에 끼워 넣고 그 앞에 역슬래시를 둔 주소가 저장 단계에서만 신뢰 출처로 보여 통과했습니다(화면 로드 단계는 차단하고 있었으므로 실제로 불려 오지는 않았습니다). 반대로 브라우저가 신뢰 출처로 읽는 형태를 저장 단계만 거부하는 경우도 있었습니다. 이제 주소가 "내 사이트 경로인가" 와 "신뢰 출처인가" 를 같은 기준으로 판정합니다.
- 역할 생성·수정이 권한 상한에 걸려 거부될 때 변경 일부가 남던 문제를 수정했습니다. 권한 확인이 저장 뒤에 있었기 때문에, 거부된 요청인데도 권한이 하나도 없는 빈 역할이 만들어지거나 역할 이름 변경만 반영된 상태가 남았습니다. 이제 저장 전에 확인해 거부 시 아무것도 변경되지 않습니다.
- 첨부파일 순서 변경과 메뉴 순서 변경에도 담당 범위 제한을 적용했습니다. 두 기능은 대상을 목록으로 한 번에 받는 방식이라 범위 확인이 걸리지 않았고, 그래서 "본인 것만" 으로 범위를 좁혀 위임받은 관리자가 다른 사람이 올린 첨부파일이나 만든 메뉴의 순서를 바꿀 수 있었습니다. 기본 제공 역할인 "매니저" 가 첨부파일에서 이 구성에 해당합니다. 순서는 목록 전체에 대한 하나의 값이라 일부만 반영하면 나머지와 어긋나므로, 범위 밖 대상이 하나라도 섞이면 요청 전체를 거부하고 아무것도 변경하지 않습니다. (KVE-2026-1919)
- 레이아웃 표현식에서 객체의 숨은 내부 구조에 접근하는 우회 경로를 막았습니다. 표현식 평가기는 위험한 이름으로의 직접 접근을 막고 있었지만, 모든 객체가 공통으로 가진 오래된 방식의 접근 함수는 그 검사를 거치지 않아 같은 곳에 닿을 수 있었습니다. 이 경로로 사이트 전체의 공통 동작을 바꾸거나 망가뜨릴 수 있었습니다(임의 코드 실행으로는 이어지지 않습니다). 이제 화면 로드·저장·정비 검사 세 곳이 모두 이 이름들을 거부하며, 기존 레이아웃이 쓰는 정상 표현식은 그대로 동작합니다. (KVE-2026-1915)
- 회원 탈퇴·회원 정보 관리 작업이 실패할 때, 응답 메시지에 데이터베이스 오류 원문 같은 내부 시스템 정보가 그대로 노출될 수 있던 문제를 수정했습니다. 이제 이런 경우 사용자에게는 일반 안내 문구만 표시하고, 원본 오류는 서버 로그에만 기록합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
- 만료된 인증 토큰이 사용자 언어(로케일) 판별에서 여전히 유효한 것으로 취급되던 문제를 수정했습니다. 이제 만료된 토큰은 비로그인과 동일하게 처리합니다. (sir.kr 커뮤니티의 Xbuilder 님께서 제보해주셨습니다.)
- 통합 검색이 로그인한 회원을 비회원으로 취급하던 문제를 수정했습니다. 검색 API 가 로그인 상태를 해석하지 않아, 회원 전용 게시판의 읽기 권한이 있어도 검색 결과와 게시판 필터 목록이 비회원 기준으로만 제한되었습니다. 이제 로그인한 회원은 자신이 열람할 수 있는 게시판 범위 그대로 검색됩니다. 비회원 검색은 종전과 동일합니다.
- 셸 예약 작업의 허용 실행 파일 목록에 `bash`·`python` 같은 인터프리터를 등록했을 때, 인터프리터에 인라인 명령(`bash -c ...` 형태)을 실어 임의 명령이 실행되던 문제를 막았습니다. 이제 인터프리터 뒤에는 절대경로 스크립트 파일만 지정할 수 있으며, 인라인 코드 실행·상대경로·경로 조작이 담긴 명령은 저장·실행 양쪽에서 거부됩니다. 스크립트 파일을 주기 실행하는 정상 사용(예: `python /경로/작업.py`)은 그대로 동작합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1653)
- 검색어·게시글 제목처럼 방문자가 입력한 값이 검색엔진용 구조화 데이터(JSON-LD)에 실릴 때, 스크립트 태그를 닫는 문자열을 끼워 넣으면 그 뒤의 내용이 방문자 브라우저에서 스크립트로 실행될 수 있던 문제를 수정했습니다. 검색엔진 봇에게 제공되는 화면이지만 특정 주소 파라미터로 일반 방문자에게도 같은 화면을 강제할 수 있어 실제 공격 경로가 됩니다. 이제 태그 문자를 이스케이프해 실행을 차단하며, 검색엔진이 읽는 구조화 데이터의 의미는 그대로 유지됩니다.
### Added
- 레이아웃 편집기 첨부 파일 업로드에 업로드 전/후 액션 훅과 파일 가공 필터 훅 제공 — 확장에서 다른 업로드 경로와 동일하게 개입할 수 있습니다.
- S3 호환 스토리지(Cloudflare R2, MinIO, 네이버 클라우드 등) 연결 지원 — 드라이버 설정에 엔드포인트 URL 과 Path-style 주소 옵션이 추가되어, AWS 가 아닌 S3 호환 스토리지도 관리자 화면 설정만으로 연결할 수 있습니다. S3 리전도 목록 선택 대신 자유 입력으로 바뀌어 새로 생기는 리전이나 R2 의 `auto` 값을 그대로 쓸 수 있습니다.
- 서버에서 동작할 수 없는 드라이버(필요한 라이브러리·PHP 확장이 없는 경우)를 저장하려 하면 사유와 함께 거부합니다 — 잘못된 드라이버 저장으로 사이트가 멈추는 사고를 저장 시점에 차단합니다.
- 완전 공개 자산(상품·카테고리·리뷰·에디터 이미지)을 S3+CDN 등 원격 디스크의 직접 URL 로 서빙할 수 있습니다. 환경설정 > 드라이버의 "공개 자산 스토리지"에서 디스크를 고르면 새로 올리는 이미지가 그 디스크에 저장되고 화면에는 CDN 주소가 실립니다 — 서버를 거치지 않아 트래픽 부담이 줄어듭니다. 사용 안 함(기본)이면 종전 방식 그대로이며, 디스크를 바꿔도 기존 이미지는 원래 위치에서 계속 정상 표시됩니다. 확장(쇼핑몰·에디터)별로 다른 디스크를 쓰도록 개별 설정할 수도 있습니다. (#100 @lyg-kaban 님께서 건의해주셨습니다.)
- 확장 개발자용: 스토리지 URL 계약이 public 외에도 URL 이 설정된 디스크(S3+CDN 등)에서 직접 주소를 반환하며, 생성 결과를 공급·수정·차단할 수 있는 `core.storage.filter_url` 필터 훅이 추가되었습니다. 공개 자산 디스크 선택지는 `core.settings.available_public_asset_drivers` 훅으로 플러그인이 확장할 수 있고, 확장이 이미지 같은 카테고리 단위로 다른 스토리지 디스크를 쓰도록 분리할 수 있습니다. 공개 자산 디스크 오버라이드 설정을 선언한 플러그인은 설정 조회·저장 응답에 디스크 선택지 목록이 함께 실리고 저장 시 선택지 검증도 함께 걸리므로, 설정 화면이 별도 권한의 코어 환경설정 API 를 조회하거나 플러그인이 검증을 따로 구현할 필요가 없습니다.
- 보존 기간이 지난 데이터의 자동 정리에 확장이 개입할 수 있는 훅을 제공합니다 — 활동 로그·알림 발송 이력·예약 작업 이력의 정리 전후에 발생하므로, 확장이 정리 대상을 외부에 따로 보관하는 등의 처리를 붙일 수 있습니다.
- 폼을 저장하지 않고 나가서 소유자 없이 남은 첨부파일을 자동으로 정리할 수 있습니다. 환경설정 > 업로드에서 자동 정리를 켜면(기본 꺼짐) 설정한 보존기간(기본 30일)이 지난 고아 첨부의 파일과 기록을 매일 삭제합니다. 사이트 로고처럼 현재 사용 중인 첨부와 확장이 관리하는 첨부는 대상에서 제외됩니다.
- 관리자가 회원 정보 수정 화면에서 상태를 '탈퇴'로 바꿔 저장하면 확인 창을 거치도록 했습니다. 이 처리는 회원의 이메일·이름·닉네임을 익명화하고 되돌릴 수 없으므로, 무엇이 처리되는지 먼저 알려 드립니다. 함께, 관리자가 상태만 바꿔 탈퇴시킨 경우에도 회원이 직접 탈퇴한 것과 동일한 처리(익명화·연계 데이터 정리)가 이뤄지도록 통일했습니다.
### Changed
- 공개 자산을 외부 주소(CDN)로 서빙할 때, 그 주소로 나가는 요청에는 로그인 토큰을 함께 보내지 않습니다 — 공개 자산은 인증이 필요 없고, 토큰이 외부 서비스로 전달되지 않아야 하기 때문입니다. 사이트 자신의 주소로 가는 요청은 종전과 동일합니다.
- 확장 개발자용: 확장 캐시 계약 인터페이스에 카테고리별 스토리지 접근이 추가되었습니다. 코어가 제공하는 확장 베이스 클래스·서비스 프로바이더를 상속하는 확장은 수정 없이 호환되지만, 이 계약을 직접 구현하는 확장은 새 메서드를 구현해야 합니다 (번들 확장은 모두 반영 완료 — 최소 요구 코어 버전 7.0.7).
- 예약 작업의 실행 시각이 환경설정의 시간대를 기준으로 해석되도록 바꿨습니다. 종전에는 세계표준시 기준이어서 '새벽 4시'로 등록해도 국내 기준 낮에 실행됐습니다. 이번 변경으로 새로 추가된 자동 정리 작업은 물론, 기존 사이트맵 자동 생성·GeoIP 갱신·확장이 제공하는 예약 작업, 관리자가 직접 등록한 예약 작업까지 모두 같은 기준을 따릅니다. **시각을 지정해 둔 예약은 실제 실행 시각이 이동하므로 확인이 필요합니다.**
- 시스템 요구사항 문서를 실제 검증 기준에 맞게 정비했습니다. 지원 브라우저 항목에 검증 방식을 명시하고(자동 테스트는 Chromium 기준이며, Firefox·Safari 는 프론트엔드 의존성의 호환 범위를 기준으로 지원합니다), PHP 확장을 필수와 기능별 선택으로 구분했으며, 설치 프로그램이 자동으로 검사하는 항목과 운영자가 직접 확인해야 하는 항목을 구분하는 절을 새로 두었습니다. "요구사항" 이라고 적힌 항목이 모두 설치 단계에서 검사되는 것은 아니라는 점이 이제 문서에 드러납니다. (#95 @glitter-gim 님께서 제보해주셨습니다.)
- 문서 사이에 어긋나 있던 수치와 표기를 정정했습니다 — 디스크 용량 기준(설치 검사 기준 500MB), PHP 확장 개수, 지원 운영체제에 Windows 개발 환경 추가, 데이터베이스 표기에 MariaDB 병기, 한국어 README 의 시스템 요구사항 항목 보완, 그리고 데이터베이스 접두어 길이 제한·collation 변경 가능 여부처럼 실제로는 동작하지만 문서에 없던 조건의 추가.
- 확장 개발자용: 배포본에 함께 들어가는 브라우저 테스트 안내 문서가 실제 디렉토리 구성 및 실행 스크립트와 어긋나 있던 부분을 정정했습니다.
### Fixed
- 데이터베이스 테이블 접두어를 기본값(`g7_`)이 아닌 값으로 설치한 사이트에서, 7.0.6 업그레이드의 활동 로그 색인 교체가 옛 색인을 지우지 못하고 남겨 두던 문제를 수정했습니다. 남은 옛 색인은 조회 이득 없이 기록 쓰기 비용만 늘리므로, 이번 업그레이드에서 자동으로 정리됩니다.
- FULLTEXT 인덱스가 없는 테이블을 검색하면 오류 대신 부분일치 검색으로 자동 전환되고, 그 사실이 기록으로 남습니다. 이전에는 오류가 화면에 드러나지 않아 "검색 결과 0건" 으로만 보였습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 통합검색에서 일부 항목의 검색이 실패하면 "검색 결과 없음" 이 아니라 오류 안내로 구분해 표시되도록, 항목별 실패 여부를 응답에 실어 내립니다. 실패 원인은 서버 기록에 상세히 남습니다. (#103 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 언어팩 업데이트 확인이 특정 조건(요청에 페이지 이동 파라미터가 실려 있는 경우)에서 아무 팩도 확인하지 않고 끝나던 문제를 수정했습니다. (#102 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 검색엔진 플러그인을 선택해 저장한 뒤 그 플러그인을 삭제하면 사이트 검색이 오류로 멈추던 문제를 수정했습니다. 이제 다른 드라이버 설정과 동일하게 기본 검색엔진으로 자동 복귀합니다.
- 설정을 항목 단위로 저장할 때 전체 저장과 다른 처리를 거치던 문제를 수정했습니다. 자산 주소 방식을 바꾸면 SEO 미리 생성 캐시가, 드라이버를 바꾸면 백그라운드 작업이 각각 갱신되지 않았습니다. SEO 설정도 항목 단위 저장에서 캐시가 정리되지 않아 검색엔진에 예전 정보가 남았습니다. (#114 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 모듈 환경설정(쇼핑몰·게시판)을 저장해도 SEO 미리 생성 캐시가 갱신되지 않던 문제를 수정했습니다. 함께, 모듈 설정 변경이 활동 로그에 기록됩니다.
- 설정을 항목 단위로 저장할 때 한 번 입력한 값을 빈 칸으로 되돌릴 수 없던 문제를 수정했습니다. 켜기/끄기 설정과 숫자 설정이 문자열로 저장되던 문제도 함께 고쳤으며, 해당 설정이 받을 수 없는 값은 저장 단계에서 안내와 함께 거부됩니다.
- 삭제된 본인인증 플러그인의 식별자가 정책 조회 응답과 공개 페이지에 그대로 노출되던 문제를 수정했습니다. 조회 응답은 실제 사용 가능한 인증 수단으로 해석해 내려주고, 공개 페이지에는 이 값을 싣지 않습니다.
- 관리자 화면 이동 시 템플릿 목록을 불필요하게 반복 검색하던 문제를 수정했습니다.
- 언어팩 관리(설치·활성화·삭제·업데이트·캐시 갱신)가 실패했을 때 응답에 내부 오류 원문이 그대로 실리던 문제를 수정했습니다. 설치 전 파일 검증 실패도 마찬가지로 안내 문구만 표시되며, 상세 내용은 디버그 모드에서만 확인할 수 있습니다.
- 관리자가 설정을 저장해도 백그라운드 작업(큐 워커 등)에는 이전 설정이 계속 적용되던 문제를 수정했습니다. 저장 즉시 실행 중인 프로세스에도 새 값이 반영됩니다. 설정 백업 복원과 플러그인 설정 초기화도 같은 문제가 있어 함께 수정했습니다. (#109 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 만료된 로그인 토큰 등 수명이 지난 데이터가 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 만료 토큰·비밀번호 재설정 기록·오래된 알림·실패한 작업 기록·SEO 통계·예약 작업 이력·본인인증 이력·활동 로그·알림 발송 이력·확장 화면 구성에 쓰인 병합 파일의 구버전이 매일 자동 정리됩니다. 기존에 쌓여 있던 보존 기간 초과 데이터는 첫 정리 때 한 번에 정리되며, 양이 많아도 나눠서 지우므로 정리 중에 사이트가 멈추지 않습니다. (#110 @Tuwasduliebst, #120 @glitter-gim 님께서 제보해주셨습니다.)
- 회원 탈퇴가 마지막 단계에서 실패하면 약관 동의 이력·프로필 이미지·로그인 세션만 사라지고 계정은 남는 문제를 수정했습니다. 이제 탈퇴는 전부 성공하거나 전부 취소되며, 같은 이메일로 재가입한 회원이 같은 날 다시 탈퇴해도 실패하지 않습니다. 관리자·운영자 계정의 탈퇴 시도는 서버 오류가 아니라 안내 문구로 거절됩니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 사이트 로고를 교체해도 이전 로고가 설정에 다시 실려, 교체할 때마다 파일과 기록이 함께 쌓이던 문제를 수정했습니다. 화면에서 뺀 로고는 저장이 완료된 뒤 실제로 삭제되며, 저장에 실패하면 파일도 그대로 남습니다.
- 관리자 API 의 데이터베이스 백업 요청이 항상 서버 오류로 끝나고, 그 응답에 내부 구현 정보가 그대로 실려 나가던 문제를 수정했습니다. 이 기능은 아직 제공하지 않으므로 "제공하지 않는 기능" 임을 알리는 응답으로 답합니다. 설정 파일 백업은 종전대로 사용할 수 있습니다.
- 플러그인이 선언한 관리자 메뉴가 설치·업데이트 시 자동으로 만들어지도록 수정했습니다. 종전에는 플러그인이 활성화 처리에서 직접 만든 경우에만 메뉴가 생겨서, 이미 사용 중인 사이트가 플러그인을 업데이트해 새 화면을 받아도 메뉴가 나타나지 않고 주소를 직접 입력해야만 닿을 수 있었습니다. 이제 모듈과 동일하게 처리됩니다.
- 회원가입 등 일반 화면의 비밀번호 오류 문구가 'SMTP 비밀번호'로 표시되던 문제를 수정했습니다. 같은 이유로 어긋나 있던 '기본 언어'·'기본 통화'·'사용자 ID 목록'·'알림 채널' 등의 문구도 화면에 맞게 정리했습니다. (#113 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 삭제·회원 정보 수정·회원가입·역할 삭제·비밀번호 재설정·첨부 파일 삭제·메뉴 삭제·예약 작업 삭제가 중간에 실패하면 일부만 처리된 상태로 남던 문제를 수정했습니다. 회원 정보 수정은 프로필 변경·로그인 세션 정리·역할 변경이 함께 처리되므로, 역할 변경에서 실패하면 이름만 바뀌고 로그인이 끊긴 상태로 남았습니다. 이제 각 작업은 전부 성공하거나 전부 취소되며, 파일 삭제는 데이터 정리가 확정된 뒤에 수행됩니다.
- 페이지 모듈의 첨부 파일 설정(개수·용량·형식)이 저장해도 반영되지 않던 문제를 수정했습니다.
- 회원 목록에서 여러 명을 골라 일괄 탈퇴할 때, 탈퇴할 수 없는 계정(관리자 등)이 섞여 있으면 그 건이 조용히 건너뛰어지던 문제를 수정했습니다. 이제 처리된 인원·처리하지 못한 인원과 그 사유가 응답에 함께 담깁니다. (#112 @Tuwasduliebst 님께서 제보해주셨습니다.)
- 회원 일괄 상태 변경의 결과 안내 문구에 처리 인원수가 채워지지 않고 자리표시 원문이 그대로 표시되던 문제를 수정했습니다.
- 파일 스토리지에서 S3 를 선택해 저장해도 실제 파일 저장이 동작하지 않던 문제를 수정했습니다. S3 구동에 필요한 스토리지 어댑터가 빠져 있었고, 연결 테스트는 통과해도 실제 저장 경로는 동작하지 않았습니다. 이제 어댑터가 기본 포함되고, 연결 테스트도 실제 연결 경로와 같은 설정(엔드포인트·Path-style 포함)을 사용합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
- 파일 스토리지를 S3 로 저장해도 첨부 파일 업로드는 계속 서버 로컬 디스크에 저장되던 문제를 수정했습니다. 이제 S3 저장 시 새 첨부 파일이 S3 로 저장되며, 기존 파일은 종전 위치에서 계속 서빙됩니다. 서버 환경변수(`ATTACHMENT_DISK`)로 저장 위치를 명시한 경우에는 그 값이 우선합니다. (#99 @lyg-kaban 님께서 제보해주셨습니다.)
- phpredis PHP 확장이 없는 서버에서 캐시·세션·큐 드라이버로 Redis 를 선택하면 사이트 전체가 열리지 않던 문제를 예방했습니다 — Redis 클라이언트 라이브러리(predis)가 기본 포함되어 확장 없이도 동작합니다.
- 웹소켓 연결 테스트가 브라우저 접속 주소만 검사해, 테스트는 성공해도 서버 발송(알림 등)은 실패할 수 있던 문제를 수정했습니다 — 이제 서버 발송용 주소도 함께 검사하고, 실패 시 어느 쪽 주소가 문제인지 구분해 알려줍니다.
- 드라이버 설정의 S3 URL 이 너무 길 때 표시되는 영어 안내 문구가 실제 허용 길이(500자)와 다르게 255자로 안내되던 문제를 수정했습니다.
- 확인 질문이 있는 Artisan 명령(확장 설치·삭제·업데이트, 코어 업데이트, 검색 인덱스 재생성 등)을 터미널이 아닌 곳에서 실행하면 아무 반응 없이 멈춰 있던 문제를 수정했습니다. 예약 작업·배포 스크립트·CI 처럼 사람이 답할 수 없는 환경에서는 질문이 화면에 나오지도 않은 채 응답을 기다렸기 때문에, 명령이 실패한 것도 아니고 진행 중인 것도 아닌 상태로 남아 "명령이 몹시 느리다" 로만 보였습니다. 이제 그런 환경에서는 질문을 건너뛰고 기본 동작을 따릅니다 — 삭제·업데이트처럼 되돌리기 어려운 명령의 기본 동작은 "중단" 이므로, 확인 없이 수행되는 일은 없습니다. 그대로 진행하려면 종전처럼 `--no-interaction` 이나 `--force` 를 붙이면 됩니다.
- Windows 에서 확장(모듈·플러그인·템플릿) 업데이트가 "디렉토리 이동 실패" 로 중단되던 문제를 해소했습니다. Windows 는 편집기·개발 도구·파일 감시 프로그램이 폴더를 보고 있기만 해도 폴더 이름 바꾸기를 차단하므로, 폴더를 통째로 이동하는 방식으로는 실패를 피할 수 없었습니다. 이제 이동이 차단되면 파일 단위 교체로 자동 전환해 어떤 프로그램도 종료하지 않고 업데이트를 완료합니다. 잠근 프로그램을 찾아 강제 종료하던 이전 동작은 제거했습니다 — 사용 중인 편집기나 개발 도구가 예고 없이 종료되는 부작용이 있었고, 폴더를 감시만 하는 프로그램은 찾아내지도 못했습니다. 백업 복원에도 같은 방식이 적용되며, 실패한 업데이트가 남긴 임시 폴더는 다음 업데이트 때 자동으로 정리됩니다.
- 빌드 산출물이 존재하지 않는 소스맵 파일을 참조해 브라우저 개발자 도구 사용 시 불필요한 404 요청이 발생하던 문제를 수정했습니다.
- 템플릿을 업데이트하거나 템플릿 캐시를 삭제해도 공개 템플릿 설정(config.json)이 최대 1시간 동안 이전 내용으로 응답되던 문제를 수정했습니다. 비활성화·삭제한 템플릿의 설정이 같은 시간 동안 계속 조회되던 문제와, `template:cache-clear` 명령이 실제로는 대부분의 캐시를 지우지 못하면서 삭제 완료로 보고하던 문제도 함께 고쳤습니다. 모듈·플러그인의 캐시 삭제 명령도 실재하는 캐시를 지우도록 정비했습니다. (#119 @glitter-gim 님께서 제보해주셨습니다.)
- 템플릿 업데이트로 컴포넌트 정의(components.json)가 바뀌어도 레이아웃 저장 검증이 최대 1시간 동안 이전 정의를 기준으로 동작하던 문제와, 템플릿 상태 변경 후에도 레이아웃 편집기의 병합 캐시 일부가 남아 편집기가 이전 내용을 받을 수 있던 문제를 수정했습니다.
- 캐시 버전 조회 주소(`?v=`)를 생략하고 공개 라우트·다국어·레이아웃 API 를 직접 호출하는 경우(외부 연동·봇 등), 그 응답이 어떤 갱신 경로로도 무효화되지 않는 별도 캐시에 남던 문제를 수정했습니다. 브라우저를 통한 일반 사용에는 영향이 없습니다.
- 확장·코어 업데이트가 중단되며 남은 임시 폴더와 오래된 백업본, 언어팩 설치 임시 파일이 정리되지 않고 계속 쌓이던 문제를 수정했습니다. 사흘이 지난 임시 산출물과 30일이 지난 백업본(최신 1개는 보존)이 매일 자동 정리됩니다.
- 개발 도구의 브라우저 콘솔 로그 파일이 하나의 파일에 무한히 쌓이던 문제를 수정했습니다. 이제 날짜별 파일로 나뉘어 7일간 보관됩니다.
- 예약 작업 저장 화면에서 설치된 확장이 제공하는 Artisan 명령이 "예약 실행이 허용된 명령이 아닙니다" 로 거부되던 문제를 수정했습니다. 터미널에서의 검증은 통과하는 명령이 관리자 화면 저장에서만 거부되었습니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다.
- 검색엔진 노출용 페이지 제목에서 타이틀 접미사가 제목과 붙어 표시되던 문제를 수정했습니다. 관리자 화면 안내대로 접미사를 공백으로 시작하게 입력해도 저장 시 선행 공백이 제거되어 "제목| 사이트명" 처럼 접착되었고, 페이지 제목이 없는 화면에서는 "| 사이트명" 처럼 구분자가 매달려 표시되었습니다. 이제 제목이 있으면 공백을 복원해 잇고, 제목이 없으면 구분자를 떼고 사이트명만 표시합니다.
## [7.0.6] - 2026-08-10
### Security
- 언어팩 안의 PHP 파일에 번역 배열 외의 코드가 들어 있으면 설치 단계에서 거부하도록 바꿨습니다. 언어팩의 PHP 파일은 활성화되면 사이트가 그대로 실행하므로, 파일을 만들거나 외부 명령을 실행하는 코드가 섞여 있으면 서버에서 임의 코드가 실행될 수 있었습니다. 이전에는 위험한 함수 몇 가지를 이름으로 골라 막았기 때문에 목록에 없는 함수나 함수 이름을 쓰지 않는 방식으로 우회할 수 있었습니다. 이제는 번역 배열만 통과시키므로 어떤 형태의 코드든 들어갈 수 없으며, 거부될 때는 어느 파일 몇 번째 줄이 문제인지 알려 드립니다. 함께 언어팩에 담을 수 있는 파일 형식을 `.json`·`.php`·`.md` 로 한정하고, PHP 파일은 정해진 언어 폴더 안에만 둘 수 있게 했습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1678)
- 언어팩을 설치하면서 곧바로 활성화하려면 언어팩 관리 권한이 필요하도록 바꿨습니다. 설치 권한과 활성화 권한은 원래 별도인데, 설치 요청에 '바로 켜기'를 함께 보내면 설치 권한만으로 활성화까지 되어 권한 경계가 무너져 있었습니다. 권한이 없으면 그 항목만 거부되고 설치 자체는 그대로 진행됩니다. 파일·URL·GitHub·번들 네 가지 설치 경로 모두 같은 기준을 적용하므로, 어느 경로로 설치하느냐에 따라 권한 경계가 달라지지 않습니다. 저장소에 동봉된 번들 언어팩의 재설치도 종전처럼 할 수 있으며, 다만 활성화 권한이 없으면 재설치한 언어팩이 꺼진 상태가 되어 다시 켜려면 관리 권한이 필요합니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1678)
- 설치 마법사의 PHP·Composer 실행 경로 입력에서 실행 파일이 아닌 값을 넣지 못하도록 형식 검사를 강화했습니다. 이전에는 실행 파일 뒤에 다른 파일이나 옵션을 덧붙이는 형태가 통과해, 설치가 끝나지 않은 상태에서 서버의 임의 코드가 실행될 수 있었습니다. 정상적인 입력(멀티 PHP 환경의 "PHP 경로 Composer 경로" 형태, 시놀로지·cPanel·Plesk 등의 다양한 PHP 실행 파일 이름, Windows 경로)은 그대로 사용할 수 있으며, 파일 존재 여부를 미리 확인하지 않으므로 접근이 제한된 서버에서도 정상 경로가 거부되지 않습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1677)
- 아직 공개하지 않은 예약 작업 기능에서, Artisan 명령 예약의 실행 제한을 허용 목록 방식으로 바꿔 정식 공개에 대비했습니다. 종전에는 위험한 명령 몇 가지만 거부하고 나머지를 모두 허용했기 때문에, 예약 작업 권한을 위임받은 계정이 파일을 만들거나 데이터베이스 구조를 바꾸는 명령을 등록할 수 있었습니다. 이제 캐시 정리·큐 처리·만료 데이터 정리 같은 유지보수 명령만 등록할 수 있고, 각 명령에 붙일 수 있는 옵션도 정해진 것만 허용합니다. 설치된 모듈·플러그인이 제공하는 명령은 자동으로 허용됩니다. 등록이 거부되면 왜 거부됐는지(허용 목록 밖 / 형식 오류 / 허용되지 않은 옵션 / 추가 인자 불가)를 구분해 안내합니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1679)
- 같은 기능에서, 저장한 예약 작업 명령이 실행 직전에 다시 해석되면서 저장할 때 확인한 것과 다른 명령이 실행될 수 있던 경로도 함께 막았습니다. 명령을 따옴표나 역슬래시로 감싸면 검사 단계와 실행 단계가 서로 다른 명령으로 읽었습니다. 이제 명령 형식을 `명령명 --옵션[=값]` 으로 한정하고 실행할 때도 해석된 결과를 그대로 사용하므로, 확인한 명령과 실행되는 명령이 달라질 수 없습니다. (KISA 측에서 제보해주신 내용을 확인하는 과정에서 함께 발견했습니다 — KVE-2026-1679)
- 보안 환경설정의 계정 잠금 시간을 `0`(무한대)으로 설정하면 실제로 무기한 잠기도록 수정했습니다. 화면 안내는 "0을 입력하면 무한대"였지만 실제로는 1분 뒤 자동 해제되어, 무차별 대입 시도를 영구 차단할 수 없었습니다. 무기한 잠긴 계정에는 남은 시간 대신 관리자 문의 안내가 표시됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 보안에 비밀번호 최소 길이와 특수문자 필수 여부를 추가했습니다. 두 항목은 설정 화면에만 있고 실제로는 어디에도 적용되지 않던 값으로, 이제 비밀번호를 새로 정하는 모든 화면(회원가입·비밀번호 변경·재설정·프로필 수정·관리자의 회원 등록과 수정)에 적용됩니다. 로그인에는 적용하지 않습니다 — 정책을 올렸을 때 기존 비밀번호를 쓰는 회원이 자기 계정에 로그인조차 못 하게 되는 것을 막기 위함입니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 본인인증 메일의 인증번호 자리수 설정이 4~10 범위를 벗어나면 조용히 잘려 저장 값과 다르게 동작하던 문제를 수정했습니다. 이제 허용 범위가 설정 화면에 표시되고, 범위를 벗어난 값은 기본값(6자리)으로 처리하며 기록을 남깁니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 보안의 로그인 시도 제한 항목에 IP 단위 요청 제한이 어떻게 계산되는지(설정값의 6배, 분당 최소 30회 보장) 안내를 추가했습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 업로드의 최대 파일 크기와 허용 파일 형식이 실제 업로드에 적용되도록 수정했습니다. 이전에는 설정을 바꿔도 어느 경로에서도 반영되지 않아, 허용 형식 목록에 없는 확장자(예: `.php`)도 업로드할 수 있었습니다. 허용 형식 목록을 비워 두면 종전처럼 형식을 제한하지 않습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 허용 파일 형식을 저장해도 업로드 제한에 반영되지 않던 문제를 수정했습니다. 입력 칸은 `jpg,png,pdf` 처럼 쉼표로 구분한 한 줄이지만 적용 단계가 그 형태를 받아들이지 못해, 업로드 탭을 한 번이라도 저장하면 형식 제한이 저장 이전 목록으로 남았습니다. 저장은 성공하고 화면에도 값이 보여 어긋난 것을 알 방법이 없었습니다. 대문자나 앞의 점(`.JPG`, `.png`)은 자동으로 정리되며, 이미 저장되어 있던 값도 그대로 적용됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 업로드된 파일의 저장 확장자를 파일명이 아니라 파일 내용에서 판별하도록 바꿨습니다. 확장자만 바꿔 올린 파일이 원래 형식과 다르게 저장되던 문제가 해소됩니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 본인인증의 '가입 후 본인확인' 정책을 켜면 회원가입 자체가 막혀 아무도 가입할 수 없던 문제를 수정했습니다. 이 정책은 가입은 받아들이고 계정을 '인증 대기' 상태로 둔 뒤 본인확인 안내를 보내는 설정인데, 안내를 보낸 직후 가입이 거절되어 '가입 전 본인확인' 정책과 구분이 없었습니다. 이제 설정한 대로 가입이 완료되고, 본인확인을 마치면 계정이 활성화됩니다. (안내 발송이 실패해도 가입은 유지되며 실패 사실은 기록에 남습니다.)
- 보안 환경설정의 '2단계 인증'이 실제로 동작합니다. 이전에는 항목만 있고 켜도 아무 일이 일어나지 않아, 관리자는 2단계 인증이 걸린 줄 알지만 비밀번호 하나로 로그인됐습니다. 이제 켜 두면 비밀번호 확인 뒤 인증번호를 메일로 보내고, 그 번호를 확인해야 로그인이 완료됩니다. 번호를 확인하기 전에는 로그인 상태가 만들어지지 않습니다. 안내 메일 문구는 환경설정 > 본인인증에서 '로그인 2단계 인증' 목적을 골라 따로 지정할 수 있습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 환경설정 > 업로드의 이미지 최대 가로/세로 크기와 품질이 실제 업로드에 적용됩니다. 이전에는 값을 저장해도 이미지를 줄이는 동작이 없어 원본이 그대로 저장됐습니다. 크기를 지정하지 않으면 종전처럼 줄이지 않으며, 지정한 크기 이내인 이미지는 다시 변환하지 않아 불필요한 화질 손실이 없습니다. (#81 @jiwonpapa 님께서 제보해주셨습니다.)
- 회원 상세 화면에 계정 잠금 상태 표시와 잠금 해제 버튼을 추가했습니다. 무기한 잠긴 계정은 로그인 성공으로 풀 수 없으므로 관리자가 직접 해제할 수 있어야 합니다. 해제는 활동 로그에 남습니다. (사용자 수정 권한이 필요합니다.) (#81 @jiwonpapa 님께서 제보해주셨습니다.)
### Added
- 회원가입 시 휴대폰번호와 전화번호를 선택 항목으로 입력할 수 있습니다. 두 항목 모두 필수가 아니며, 입력하면 내 정보에서 확인·수정할 수 있습니다.
#### 사이트맵
- 게시글·상품이 수백만 건으로 늘어나도 사이트맵을 안정적으로 생성하도록 개선했습니다. 예전에는 사이트맵을 만들 때 전체 데이터를 한꺼번에 메모리에 올리다가 데이터가 많은 사이트에서 생성이 실패했지만, 이제 나눠 읽어 처리하므로 메모리 부족 없이 완성됩니다. (#79 @jiwonpapa 님께서 제보해주셨습니다.)
- 사이트맵을 검색엔진 규격(파일당 5만 URL / 50MB)에 맞춰 여러 파일로 자동 분할하고, 이를 묶는 색인 파일을 함께 만듭니다. 한 파일에 담을 URL 수는 SEO 설정에서 조정할 수 있습니다.
- 게시글·상품·페이지를 공개/비공개로 바꾸거나 삭제하면 사이트맵에서 해당 항목만 자동으로 더하거나 빼도록 바꿨습니다. 전체를 다시 만들지 않고 바뀐 부분만 반영하므로 항상 최신 상태가 빠르게 유지됩니다.
- 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다.
- 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다.
- 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다.
- 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다.
- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.)
#### 설치·서버 환경
- 설치할 때 `root` 같은 데이터베이스 최고 권한 계정을 입력하면 설치가 진행되지 않고, 왜 사용할 수 없는지와 어떻게 해야 하는지를 함께 안내합니다. 최고 권한 계정 정보가 유출되면 데이터베이스 전체가 위험해지기 때문이며, 사이트 전용 데이터베이스 계정을 새로 만들어 필요한 권한만 부여해 입력하시면 됩니다. 읽기 전용 데이터베이스를 따로 쓰는 경우에도 동일하게 적용됩니다.
- 일부 서버 설정에서 모든 페이지가 백지로 나오던 문제에 대응했습니다. 서버에 `.js`·`.css`·`.json` 주소를 가로채는 정적 파일 최적화 설정(aaPanel·CyberPanel·Plesk 기본 템플릿 등)이 있으면 G7 의 일부 주소가 PHP 에 전달되지 못해 화면이 뜨지 않았습니다. 이제 G7 이 확장자 없는 주소도 함께 제공하며, 화면이 뜨지 않으면 브라우저가 이를 감지해 자동으로 전환하므로 서버 설정을 바꾸지 않아도 사이트가 표시됩니다. (sir.kr 커뮤니티의 hang 님께서 제보해주셨습니다.)
- 자산 파일 주소 방식을 설치 마법사가 자동으로 감지해 반영하고, 설치 후에도 관리자 > 환경설정 > 일반에서 바꿀 수 있습니다. 설치 2단계 서버 요구사항 화면에서 감지 결과를 미리 확인할 수 있고, 3단계에서 직접 고를 수도 있습니다. 화면이 아예 뜨지 않는 상황을 위해 `php artisan g7:asset-url-mode` 명령으로도 확인·전환할 수 있습니다. 검색엔진 봇은 자바스크립트를 실행하지 않으므로, 이 설정을 확정해 두면 봇에게도 올바른 주소가 전달됩니다.
- 서버에 OPcache가 켜져 있는지를 설치 화면과 관리자 환경설정 > 정보에서 확인할 수 있습니다. OPcache는 PHP 코드 해석 결과를 재사용해 사이트 응답 속도를 크게 높여 주는 기능으로, 꺼져 있으면 성능이 떨어진다는 안내와 함께 설정 방법을 알려 드립니다. 꺼져 있어도 설치는 그대로 진행되므로 설치가 막히지 않습니다.
#### 언어팩
- 사이트에서 쓰기로 한 언어의 언어팩을 한 번에 채워 넣는 기능을 추가했습니다. 한국어·영어 외 언어(일본어 등)는 설치되어 있어야 화면에 표시되는데, 설치가 빠져 있으면 오류 없이 한국어로 대체되어 운영자가 눈치채기 어려웠습니다. 이제 언어팩 관리 목록과 명령으로 "설치가 필요한 언어"를 한 번에 채울 수 있고, 여러 번 실행해도 안전합니다.
- 언어팩 관리 목록이 "설치되어 있다고 표시되지만 실제 파일이 없어 다른 언어로 대체되는" 상태를 눈에 띄게 보여주고, 그 자리에서 다시 설치할 수 있는 버튼을 제공합니다. 예전에는 목록에 정상으로 보이는데도 실제로는 번역이 적용되지 않는 경우를 발견하기 어려웠습니다. 파일이 사라진 언어팩은 화면의 다시 설치 버튼으로도, 언어팩을 채우는 명령으로도 복구할 수 있습니다.
#### 목록 성능
- 검색 인덱스가 실제로 내용을 담고 있는지 점검하고, 비어 있으면 다시 만들 수 있는 기능을 추가했습니다. 인덱스가 비면 검색은 오류 없이 "결과 없음" 만 돌려주기 때문에 그동안은 알아차릴 방법이 없었습니다. 관리자 > 개발 도구에서 「검색 인덱스 점검」 으로 확인할 수 있고, `php artisan search:index` 로도 실행됩니다. 점검·재생성 방식은 검색 엔진마다 다르므로, 다른 검색 엔진을 쓰는 확장도 자기 방식의 점검을 등록해 같은 화면에서 함께 다룰 수 있습니다.
- 다시 만들기는 **선택할 때만** 실행됩니다. 인덱스를 다시 만드는 동안에는 해당 인덱스가 잠기거나 전체 재색인이 일어나 운영 중인 사이트에 영향을 주기 때문입니다. 확장·코어 업데이트 화면과 명령어에 「업데이트 후 검색 인덱스 재생성」 선택 항목을 두었고, 선택하지 않아도 색인이 비어 있으면 그 사실은 알려 드립니다. 사이트 규모가 크다면 선택하지 말고 접속이 적은 시간에 따로 실행하시길 권합니다.
- 업데이트 화면의 「검색 인덱스 재생성」 선택이 그 창에서만 유효하도록 바로잡았습니다. 예전에는 한 번 선택하면 창을 닫거나 업데이트를 마친 뒤에도 선택이 남아 있어, 다음 확장을 업데이트할 때 아무것도 고르지 않았는데 인덱스 재생성이 다시 실행됐습니다.
- 확장을 업데이트하면 색인이 비어 있는 검색 인덱스가 있는지 결과가 함께 전달되도록 바로잡았습니다. 예전에는 점검은 이루어졌지만 그 결과가 화면까지 오지 않아 운영자가 알 수 없었습니다.
- 모듈·플러그인 업데이트 완료 안내에 확장 이름과 버전이 표시되지 않던 문제를 수정했습니다.
- 모듈·플러그인 목록의 총 개수와 페이지 이동이 동작하지 않던 문제를 수정했습니다. 설치된 확장이 12개를 넘으면 2페이지 이후 항목이 화면에 나타나지 않았습니다.
- `php artisan module:check-updates` 등 업데이트 확인 명령이 업데이트가 있는 확장을 「최신」 으로 표시하고 요약도 "업데이트 가능 0개" 로 알리던 문제를 수정했습니다.
- 통신이 끊겨 요청이 전송되지 못했을 때 내부 식별 문구가 그대로 표시되던 것을 네트워크 오류 안내로 바꿨습니다.
- 개발 도구에서 점검 명령이 이상을 발견해 알려 준 경우를 「실행 실패」 로 표시하던 것을 종료 코드 안내로 바꿨습니다. 점검은 정상 수행된 것이며, 발견된 내용은 출력에 그대로 표시됩니다.
- 목록 화면이 뒤쪽 페이지로 갈수록 느려지는 문제를 구조적으로 해결할 수 있도록, 확장이 함께 쓸 수 있는 공통 조회 방식을 코어에 추가했습니다. 목록을 두 단계(먼저 이번 페이지에 해당하는 항목만 추려내고, 그 항목에 대해서만 본문·상세 정보를 읽기)로 나눠 읽으므로 게시글·주문·로그가 수십만 건으로 늘어나도 마지막 페이지 조회 비용이 첫 페이지와 비슷하게 유지됩니다. 목록 정렬 기준을 미리 정해 둔 항목으로만 해석하는 공통 처리도 함께 제공하므로, 확장이 목록 조회를 직접 만들 때 정렬 처리를 매번 새로 구현하지 않아도 됩니다. 주문 목록의 「발송일」처럼 정렬 기준이 다른 표에 있는 값일 때도 총 건수와 페이지 경계가 어긋나지 않게 조회하는 공통 처리를 함께 넣었습니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
#### 대용량 목록
- 검색 결과나 목록이 아주 많을 때 화면이 열리지 않거나 서버가 멈추던 문제를 개선했습니다. 이제 총 건수는 일정 규모까지만 정확히 세고 그보다 많으면 "10,000건 이상" 처럼 표시하며, 다음 페이지로 넘기는 것은 끝까지 그대로 됩니다. 마지막 페이지로 바로 뛰는 버튼만 이때 감춰집니다 — 그 위치를 계산하려면 전체를 세야 하기 때문입니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 총 건수를 세는 범위와 주소로 직접 요청할 수 있는 최대 페이지 번호를 관리자 > 환경설정 > 고급에서 조정할 수 있습니다. 두 값 모두 0 으로 두면 제한하지 않습니다.
- 검색 결과가 많아 총 건수를 정확히 세지 못한 경우, 검색어를 더 구체적으로 입력하면 정확한 건수를 볼 수 있다는 안내를 함께 표시합니다.
- 활동 로그·알림 발송 기록·본인인증 기록·스케줄 실행 이력·회원 목록에도 같은 방식을 적용했습니다. 기록이 수십만 건 쌓여도 목록 첫 화면이 총 건수를 세는 비용에 끌려가지 않습니다.
- 알림함도 같은 방식으로 조회합니다. 알림이 오래 쌓인 계정에서도 목록이 빠르게 열립니다.
- 알림 발송 기록을 최신순으로 볼 때 뒤쪽 페이지를 이동하는 방식이 활동 로그와 동일해졌습니다. 몇 페이지를 넘겨도 속도가 일정하게 유지됩니다.
- 검색 결과 화면에서 다른 분류의 건수 배지가 필요 없을 때는 그 숫자를 세는 작업을 건너뛸 수 있습니다.
- 검색 결과의 탭 배지(게시글 N · 상품 N · 페이지 N)도 세지 못한 건수를 정확한 것처럼 표시하지 않습니다. 탭을 열어 본 목록과 배지의 숫자가 서로 다른 기준을 말하던 문제가 사라집니다.
- 활동 로그 목록을 페이지 번호 대신 이어보기 방식으로도 조회할 수 있습니다. 기록이 많이 쌓인 사이트에서 뒤쪽으로 갈수록 느려지지 않습니다. 화면 동작은 그대로이며, 필요할 때만 쓰는 선택지입니다.
- 통합 검색도 최신순·조회순·가격순처럼 목록 자체의 순서로 볼 때는 이어보기 방식으로 뒤쪽 페이지를 이동할 수 있습니다. 검색 결과가 아주 많아도 몇 페이지를 넘기든 속도가 일정합니다. 관련도순은 계산된 점수로 정렬하므로 종전의 페이지 번호 방식을 유지합니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
#### 성능 점검
- 확장이 자기 성능 측정 대상을 직접 등록할 수 있게 했습니다. 모듈·플러그인이 자신의 목록 화면, 관리자 화면, 저장 동작, 정기 작업 중 속도를 재고 싶은 것을 선언해 두면, 사이트 관리자가 성능 점검을 실행할 때 코어 항목과 함께 측정됩니다. 확장을 설치하면 그 확장의 측정 대상이 자동으로 목록에 나타나고, 제거하면 함께 사라집니다. 화면 측정은 응답 시간과 함께 그 화면이 실행한 데이터베이스 조회 횟수를 보여주므로, 목록 자체는 빠른데 화면이 느린 원인을 찾을 수 있습니다.
### Changed
- 레이아웃 편집기의 파일 목록·버전 이력·확장 목록이 목록에 필요한 정보만 받아오도록 바꿨습니다. 예전에는 목록 한 번에 그 템플릿의 모든 레이아웃 본문과 버전마다의 본문 사본까지 함께 실려, 레이아웃이 많거나 저장을 자주 한 템플릿일수록 편집기 진입이 느려졌습니다. 편집 본문은 파일을 선택할 때 받아오므로 화면에 보이는 내용은 이전과 동일합니다. 버전 이력은 최근 100건까지 표시하며, 더 필요하면 조회 건수를 지정할 수 있습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 역할 관리 목록과 메뉴 관리 목록이 화면에서 쓰지 않는 정보를 함께 내려주던 것을 정리했습니다. 역할 목록은 권한 전체 목록을 세 가지 형태로 중복해서, 메뉴 목록은 하위 메뉴마다 역할 정보를 함께 싣고 있었습니다. 역할의 권한은 역할 수정 화면에서 종전대로 확인·편집할 수 있습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 레이아웃 버전 목록 조회에 건수 제한을 두었습니다. 허용 범위를 벗어난 값을 지정하면 안내 문구가 표시됩니다. 버전 이력의 '더 보기'는 조회 상한에 이르면 더 넓히지 않습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 알림 정의 목록이 지금 보고 있는 채널의 템플릿만 받아오도록 바꿨습니다. 예전에는 정의마다 모든 채널의 제목과 본문이 함께 실려, 채널을 여러 개 쓰는 사이트일수록 목록이 무거웠습니다. 채널을 지정하지 않고 목록을 조회하면 템플릿 본문은 함께 오지 않으며, 전체 채널의 내용이 필요하면 정의 단건 조회를 이용하면 됩니다. 화면에 보이는 내용과 '되돌리기' 버튼 표시 조건은 이전과 동일합니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 본인인증 메시지 정의 목록이 대표 템플릿 한 건만 받아오도록 바꿨습니다. 전체 템플릿은 정의를 열었을 때 종전대로 받아옵니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 역할 목록에 할당된 권한 개수를 함께 표시할 수 있도록 집계 값을 제공합니다. 권한 목록 자체를 싣지 않고도 규모를 확인할 수 있습니다. (#76 @jordy-bitree 님께서 제보해주셨습니다.)
- 페이지를 한 번 열 때마다 서버가 되풀이하던 준비 작업을 정리했습니다. 내부 동작 기록을 매 요청 400줄씩 남기던 부분, 환경설정 파일을 한 요청에서 스무 번 넘게 다시 읽던 부분, 이미 저장해 둔 목록을 매번 데이터베이스에서 다시 가져오던 부분을 없앴습니다. 화면에 보이는 내용은 그대로이며 응답이 빨라집니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 권한 확인이 화면 요소마다 데이터베이스를 다시 조회하던 것을 요청당 한 번으로 줄였습니다. 메뉴·버튼이 많은 화면일수록 체감 차이가 큽니다. 권한을 바꾸면 종전처럼 다음 요청부터 즉시 반영됩니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 게시글·상품·쿠폰 검색이 조건에 맞는 항목 전체를 먼저 메모리에 올린 뒤 다시 조회하던 방식을 없앴습니다. 검색 결과가 아주 많아도 메모리 사용량이 늘지 않습니다. (#82 @jiwonpapa 님께서 제보해주셨습니다.)
- 플러그인으로 다른 검색엔진을 연결했을 때 게시판·상품·페이지 검색이 그 엔진을 쓰지 않고 기본 방식으로 동작하던 문제를 해소했습니다. 이제 연결한 검색엔진이 모든 검색 화면에 적용됩니다. 전문검색을 제공하지 않는 데이터베이스에서도 검색어의 `%` `_` 같은 기호를 입력한 글자 그대로 찾도록 개선했습니다.
- 기간으로 걸러 보는 목록(주문·회원·쿠폰·스케줄 등)의 날짜 조건을 색인을 활용하는 방식으로 바꿨습니다. 기록이 많은 사이트에서 기간 검색이 빨라집니다.
- 설치 직후 기록 수준(`LOG_LEVEL`) 기본값을 운영 기준(`error`)으로 바꿨습니다. 이전 기본값은 정상 동작까지 모두 기록해 로그 파일이 빠르게 커졌습니다. 관리자 > 환경설정 > 고급에서 조정할 수 있으며, 그 값이 이 항목보다 우선합니다.
- 권장 서버 사양(CPU·메모리)을 시스템 요구사항 문서에 추가했습니다.
- 활동 로그·알림 발송 이력·본인인증 기록·스케줄 실행 이력·회원 목록을 뒤쪽 페이지에서도 빠르게 열 수 있도록 조회 방식을 바꿨습니다. 예전에는 페이지가 뒤로 갈수록 건너뛰는 기록의 본문·변경 내역까지 함께 읽어 느려졌지만, 이제 현재 페이지에 해당하는 기록만 상세 정보를 읽습니다. 화면에 보이는 내용은 이전과 동일합니다. (#74 @jiwonpapa 님께서 제보해주셨습니다.)
- 확장이 업로드 허용 형식을 넓힐 수 있도록 `core.attachment.allowed_extensions` 필터를 추가했습니다. 관리자 설정 목록을 기준으로, 확장이 자기 기능에 필요한 형식만 덧붙일 수 있습니다.
- 활동 로그·알림 발송 이력·회원 목록의 정렬 색인을 정비했습니다. 같은 시각에 쌓인 기록이 많은 구간에서 순서를 정하느라 생기던 추가 작업이 사라져, 기록이 많은 사이트일수록 목록이 빨라집니다. 기존 사이트도 업데이트 시 자동으로 반영되며, 기록이 아주 많은 경우 이 과정에 수 분이 걸리고 그동안 해당 기록의 저장이 잠시 대기할 수 있습니다.
- 확장이 검증 규칙을 추가할 때 쓰는 필터 이름 10개를 나머지 92개와 같은 규칙(`{대상}.{동작}_validation_rules`)으로 통일했습니다. 본인인증 메시지와 알림 관련 화면이 대상이며, 예전 이름(`...filter_index_rules` 형태)을 사용하던 확장은 새 이름으로 바꿔야 검증 규칙 추가가 계속 동작합니다. 코어와 기본 제공 확장에는 이 이름을 사용하던 곳이 없어 영향이 없습니다.
- 같은 규칙에서 벗어나 있던 필터 이름 6개(플러그인 설정 저장, 비밀번호 재설정 토큰 확인, 비밀번호 확인, 변경사항 조회, 통합 검색, 프로필 사진 업로드)를 추가로 통일했습니다. 이번에는 예전 이름도 함께 계속 발행하므로 기존 확장은 수정 없이 그대로 동작하며, 예전 이름을 사용 중인 확장이 있으면 서버 기록에 한 번 안내가 남습니다. 새로 만드는 확장은 새 이름을 사용하세요.
### Fixed
#### 화면 동작
- 화면에서 선택·클릭 같은 동작에 반응하도록 지정한 항목이 아무 반응도 하지 않던 문제를 수정했습니다. 오류나 경고 없이 조용히 동작하지 않아 원인을 찾기 어려웠습니다. 게시판 글쓰기의 분류 선택이 이에 해당해, 분류를 골라도 글에 반영되지 않았습니다. 함께, 반응은 하되 선택한 값만 비어 저장되던 경우도 수정했습니다.
- 화면에서 고른 값이 모듈·플러그인이 제공하는 동작에는 전달되지 않던 문제를 수정했습니다. 값을 저장하는 방식이 두 가지인데 그중 한 가지로 저장하면 화면에는 선택한 것으로 표시되지만 확장 기능은 그 값을 읽지 못했고, 오류나 경고가 남지 않아 화면만 봐서는 알 수 없었습니다. 이제 어느 방식으로 저장하든 같은 값이 전달됩니다.
#### 레이아웃 코드 편집
- 레이아웃 코드 편집 화면이 파일 목록을 불러올 때 편집하지 않는 레이아웃의 본문까지 전부 내려받던 문제를 수정했습니다. 레이아웃이 많은 템플릿에서는 목록을 여는 것만으로 수십 MB가 브라우저에 쌓여 화면이 크게 느려지거나, 디버그 모드에서는 탭이 메모리 부족으로 종료됐습니다. 이제 목록에는 이름·설명·크기·수정일만 실리고 본문은 선택한 파일만 불러옵니다.
#### 화면 편집기
- 화면 편집기가 주소 일부를 설정으로 바꿀 수 있는 화면도 알아보도록 화면 인식 방식을 넓혔습니다. 예를 들어 쇼핑몰은 주소를 바꾸거나 주소 없이 운영할 수 있는데, 편집기는 주소가 한 토막 있는 경우만 인식해 그 외의 사이트에서는 해당 화면의 상태 미리보기 전환이 표시되지 않았습니다. 이제 확장이 "이 토막은 있을 수도 없을 수도 있다"고 알려 줄 수 있으며, 함께 배포되는 쇼핑몰에 이미 적용되어 있습니다.
#### 설정 화면 입력
- 설정 화면에서 여러 입력칸을 고친 뒤 다른 탭에 갔다 돌아오면, 먼저 고친 칸에 입력했던 값이 그대로 남던 문제를 수정했습니다. 저장된 값은 원래 값이라 화면에 보이는 값과 실제 값이 어긋났고, 새로고침해야 드러났습니다. 이제 탭을 오갈 때 모든 입력칸이 저장된 값으로 함께 돌아옵니다.
#### 확장 업데이트
- 속도 최적화를 적용한 사이트에서, 확장을 설치·활성화·업데이트해도 그 확장이 새로 추가한 주소가 동작하지 않던 문제를 수정했습니다. 예전에는 최적화 시점에 저장해 둔 주소 목록만 사용해 새 주소가 목록에 없었고, 화면에는 오류 대신 "페이지를 찾을 수 없음"만 나와 원인을 알기 어려웠습니다. 이제 확장을 설치·활성화·비활성화·삭제·업데이트할 때와 코어 업데이트 후에 주소 목록이 자동으로 다시 만들어집니다.
- 코어를 업데이트하면 주소 목록 최적화가 꺼진 채로 남던 문제를 함께 수정했습니다. 업데이트 도중 목록을 비우기만 하고 다시 만들지 않아, 관리자가 직접 '시스템 최적화'를 실행할 때까지 사이트가 최적화 없이 동작했습니다.
- 모듈·플러그인 업데이트에서 '수정 유지'를 선택해도 직접 고친 화면이 새 버전으로 덮어써지던 문제를 수정했습니다. 업데이트 과정이 보존 여부를 판단하기 전에 모든 화면을 파일 기준으로 먼저 되돌려, 비교할 수정본이 남지 않아 '수정 유지'가 항상 무효가 됐습니다. 이제 선택한 대로 수정한 화면이 그대로 유지됩니다.
- 업데이트 전 표시되는 "수정된 레이아웃" 안내가 확인에 실패했을 때도 "수정된 레이아웃이 없습니다"라고 단언하던 문제를 수정했습니다. 네트워크가 끊긴 상태에서 그대로 '모두 교체'를 진행하면 직접 고친 화면이 사라질 수 있었습니다. 이제 확인하지 못했다는 사실과 함께 '수정 유지' 권장 안내가 표시됩니다.
- 템플릿 업데이트 안내가 그 템플릿에 등록된 모듈·플러그인 화면의 수정까지 자기 것으로 세던 문제를 수정했습니다. 실제로 교체·보존되는 대상은 템플릿 자신의 화면뿐이라 표시 건수와 실제 동작이 어긋났습니다.
- '업데이트 확인' 버튼을 눌러 업데이트가 발견돼도 항상 "모두 최신 상태입니다"라고 안내되던 문제를 수정했습니다. 모듈·플러그인·템플릿 관리 화면 모두에 해당합니다.
- 업데이트 완료 안내에 확장 이름과 버전이 표시되지 않고 자리표시자 글자가 그대로 노출되던 문제를 수정했습니다.
- 모듈·플러그인·템플릿 관리 화면의 목록 요약이 항상 "총 0개 중 1-0개 표시"로 나오고, 목록이 한 화면을 넘어도 다음 쪽으로 넘어갈 수 없던 문제를 수정했습니다.
- 존재하지 않는 확장 이름으로 수정 화면 확인을 요청하면 "수정된 화면 없음"으로 응답하던 것을 "찾을 수 없음"으로 구분해 응답하도록 바꿨습니다.
#### 관리자 화면
- 템플릿 레이아웃 편집 화면의 파일 목록에서 이름·설명이 빈칸으로 보이던 문제를 수정했습니다. 번역 제외 표기를 쓴 글자가 화면 구성 요소의 본문 자리에서만 해석되지 않아 아무것도 출력되지 않았습니다.
- 번역 제외 표기를 쓴 글자가 검색엔진 봇 화면에서 해석되지 않던 문제를 함께 수정했습니다. 일반 화면에는 값이 나오는데 봇 화면에서만 비어 보일 수 있었습니다.
#### 검색엔진 봇 화면
- 검색엔진 봇에게만 게시글·답글의 원글·댓글·페이지·상품 설명의 본문이 통째로 빈 채 전달되던 문제를 수정했습니다. 위지윅 편집기 플러그인이 켜져 있으면 본문 영역이 플러그인 화면으로 교체되는데, 봇용 화면을 만드는 쪽이 그 교체 규칙을 몰라 내용 없는 껍데기만 만들었습니다. 미리보기용 요약(메타 태그)에는 본문이 들어가 있어 겉으로는 정상으로 보였습니다. 이제 일반 화면과 봇용 화면이 같은 본문을 출력합니다. 업그레이드 시 이미 만들어져 있던 봇용 화면은 한 번 비워져 새로 만들어집니다. (#86 @koojunho 님께서 제보해주셨습니다.)
- 봇용 화면이 자료를 불러오는 주소에 검색어 같은 값을 끼워 넣어 둔 경우, 그 값이 해석되지 않아 목록이 통째로 비어 있던 문제를 수정했습니다. 예전에는 주소 안의 글 번호만 해석했고 검색어·화면 초기값은 그대로 남아 조회에 실패했습니다.
- 봇용 화면이 일반 화면과 다르게 해석하던 문법들을 일반 화면과 동일하게 맞췄습니다. 여러 조건을 묶은 표시 조건, 반복 목록의 다른 표기 방식, 글자만 넣은 조각, 데스크톱 전용으로 지정한 하위 구성·반복 목록, 지연 표기한 번역, 화면 시작 시 정해지는 초기값이 해당됩니다. 조건이 거짓인데도 내용이 노출되거나, 화면에는 보이는 내용이 봇에게만 사라지던 차이가 없어집니다.
- 봇용 화면에서 값 대신 `true`/`false` 가 출력되던 문제를 수정했습니다. 두 값 중 채워진 쪽을 쓰는 표기(`||`)가 값이 아니라 참·거짓으로 계산되어, 검색 화면 주소에 검색어 대신 `true` 가 들어가는 등의 증상이 있었습니다. 화면에 넣은 값이 참·거짓이거나 비어 있을 때도 봇용 화면에만 `false` 같은 글자가 그대로 노출되던 문제를 함께 수정했습니다.
- 검색엔진 봇용 화면에 넣는 사용자 작성 본문을 일반 화면과 같은 기준으로 정리하도록 수정했습니다. 예전에는 봇용 화면에만 본문이 아무 검사 없이 들어가, 글에 넣어 둔 스크립트나 외부 프레임이 봇용 주소로 접속했을 때 실제로 실행될 수 있었습니다. 또 서식 없이 글자로만 저장한 글의 태그가 봇용 화면에서만 서식으로 해석되어 두 화면의 본문이 달라 보였습니다. 이제 서식 없는 글은 글자 그대로, 서식 있는 글은 위험한 요소를 걷어낸 뒤 표시되며, 일반 화면과 결과가 같습니다. 게시글·답글·페이지·상품 설명 모두에 적용됩니다.
- 봇용 화면의 선택 상자(정렬·분류 등)에 항목 이름 대신 내부 식별자가 글자로 노출되던 문제를 수정했습니다. 이제 일반 화면과 같이 선택 항목의 이름이 표시됩니다.
- 봇용 화면에만 일반 화면에 없는 내용이 나타나거나, 반대로 있어야 할 내용이 사라지던 경우를 정리했습니다. 값이 비어 있을 때 대신 다른 내용을 채워 넣던 동작, 화면 폭 조건이 여러 개 겹칠 때 적용 순서가 달라지던 동작, 알아볼 수 없는 형태의 표시 조건을 봇용 화면에서만 숨기던 동작이 해당됩니다.
#### 화면 표시·목록 상태
- 건수가 아주 많아 총 건수를 끝까지 세지 못하는 목록에서 항목 번호가 0 이나 음수로 표시되던 문제를 수정했습니다. 번호를 지어내지 않고 「-」로 표시하며, 총 건수를 정확히 센 목록의 번호는 종전과 동일합니다.
- 활동 로그 목록의 총 건수가 끝까지 세지 못한 값인데도 정확한 숫자처럼 표시되던 문제를 수정했습니다. 이제 그 경우 「이상」 표시가 함께 나옵니다.
- 목록 표의 각 칸에 넣은 날짜·숫자 서식이 적용되지 않아 값이 비어 보이던 문제를 수정했습니다. 같은 서식을 일반 화면에서 쓰면 정상이었지만 목록 표의 칸 안에서는 값이 사라지거나(날짜 서식) 서식이 빠진 원래 값이 그대로 나왔습니다(숫자·대문자 서식). 목록 표와 카드 목록, 펼침 영역 등 반복해서 그려지는 모든 자리에서 서식이 정상 적용됩니다. (#87 @glitter-gim 님께서 제보해주셨습니다.)
- 위 문제와 같은 뿌리에서 비롯된 화면 표시 오류를 함께 정리했습니다. 같은 방식으로 작성한 값이 어디에 놓이느냐(목록 칸·본문·표시 조건·버튼 동작)에 따라 다르게 해석되던 것을 한 가지 기준으로 통일했으며, 아래 항목이 그 결과입니다. 대부분 오류 메시지 없이 값이 비거나 잘못 보이던 증상이라 눈치채기 어려웠습니다.
- 행마다 달라지는 조건(예: "활성 상태인 항목만 표시")을 목록에 걸면 해당하는 행만 걸러지지 않고 **목록 전체가 사라지던** 문제를 수정했습니다. 이제 행마다 조건을 따져 표시하며, 검색엔진 봇이 보는 화면에도 같은 규칙이 적용됩니다.
- 관리자 목록의 여러 개 고를 수 있는 상태·분류 필터처럼 항목 이름에 대괄호가 들어가는 값이 화면에 따라 비어 보이던 문제를 수정했습니다. 선택한 조건이 화면에 표시되지 않거나 조건 표시가 사라지던 증상이 해소됩니다.
- 값 하나를 계산하다 실패하면 그 값이 속한 영역 전체가 그려지지 않던 문제를 수정했습니다. 이제 문제가 된 자리만 비고 나머지는 정상 표시됩니다.
- 계산식에 날짜·숫자 서식을 함께 걸면 원본 값이 바뀌어도 처음 계산된 값이 계속 표시되던 문제를 수정했습니다.
- 목록 칸이나 합계 행 안에서 번역 문구가 문장 중간에 들어간 경우 번역되지 않고 원본 키가 그대로 보이던 문제, 그리고 일부 표시 값에 `{{ }}` 기호가 섞여 나오던 문제를 수정했습니다.
- 목록 칸이나 합계 행에서 '번역하지 않고 원문 그대로 표시'로 지정한 값에 보이지 않는 특수문자가 함께 출력되던 문제를 수정했습니다. 화면에서는 빈칸이나 깨진 글자로 보였고, 값을 복사해 붙여넣을 때 따라붙었습니다.
- 목록 표·카드의 셀에서 `true`/`false` 같은 값을 직접 쓴 자리가 비어 보이던 문제를 수정했습니다. 같은 값을 표시 조건에 쓰면 정상 동작해서, 같은 작성이 놓인 자리에 따라 갈렸습니다.
- 레이아웃 코드 편집 화면의 파일 목록과 선택한 파일 설명에 번역되지 않은 내부 표기(`$t:…` 나 `{{ … }}`)가 그대로 보이던 문제를 수정했습니다. 다른 템플릿의 레이아웃을 편집할 때 그 템플릿의 번역을 찾지 못해 생긴 문제로, 이제 해당 템플릿의 번역으로 표시하며 번역을 찾을 수 없으면 파일 이름을 대신 보여줍니다.
- 목록 화면에서 글이나 항목을 열어 보고 돌아올 때 보고 있던 페이지·검색어·필터가 사라지던 문제를 화면 엔진 차원에서 함께 수정했습니다. '현재 주소의 조건을 유지한다'고 지정해도 덧붙일 값이 없으면 유지가 아예 동작하지 않아 조건이 통째로 사라지던 동작을 바로잡았습니다. (#75 @jiwonpapa 님께서 제보해주셨습니다.)
#### 확장·데이터베이스 안정성
- 관리자 역할 관리·언어팩 관리·알림 목록에서 같은 시각에 등록된 항목이 여러 건일 때, 페이지를 넘기면 같은 항목이 두 번 보이고 다른 항목은 아예 보이지 않던 문제를 수정했습니다. 이제 페이지를 모두 넘겨도 목록에 있는 항목이 빠짐없이 한 번씩만 나옵니다.
- 메뉴 관리 목록에서 검색 조건을 하나도 걸지 않은 채 정렬만 바꾸면 순서가 그대로이던 문제를 수정했습니다. 검색 조건이 있을 때만 정렬이 반영되고 있었으며, 이제 조건 유무와 관계없이 선택한 정렬이 적용됩니다.
- 메뉴 관리 검색에서 한글 메뉴명으로 찾으면 결과가 나오지 않던 문제를 수정했습니다. 이제 한국어·영어 어느 쪽 메뉴명으로도 검색되고, 이름 정렬도 화면에 보이는 글자 기준으로 정렬됩니다.
- 목록의 열 제목을 눌렀을 때 지금 보고 있는 페이지의 줄만 순서가 바뀌면서 전체가 정렬된 것처럼 보이던 문제를 수정했습니다. 페이지를 나눠 불러오는 목록에서 열 제목 정렬을 지원하지 않는 화면은 이제 정렬 표시를 띄우지 않으므로, 정렬 결과를 잘못 읽을 일이 없습니다. 정렬은 화면 위쪽의 정렬 선택으로 이용할 수 있습니다.
- 언어팩 관리 목록이 모듈의 내장 번역을 실제 화면에 쓰이지 않는 위치 기준으로 보여주던 문제를 수정했습니다. 그 결과 일부 모듈의 한국어·영어 내장 번역이 목록에 아예 나오지 않았고, 번역 대상으로 표시된 문구를 고쳐도 화면에 반영되지 않았습니다. 이제 목록과 실제 화면의 문구 출처가 같습니다.
- 데이터베이스 연결이 일시적으로 끊기거나 확장 설치가 중간에 실패한 직후 확장 목록을 다시 만들면, 설치된 모듈·플러그인 정보가 통째로 비워지면서 게시판·쇼핑몰 등 모든 확장 화면이 500 오류를 내던 문제를 수정했습니다. 이제 확장 폴더가 그대로 남아 있는데 목록만 비어 있으면 기존 정보를 지우지 않고 보존하며, 왜 갱신하지 않았는지 로그에 남깁니다.
- 확장 목록이 손상된 상태에서 복구 명령을 실행하면 목록은 되살아나지만 확장 기능 연결이 비어 있는 채로 남아, 명령을 두 번 실행해야 완전히 복구되던 문제를 수정했습니다. 이제 한 번 실행으로 모두 복구됩니다.
- 데이터베이스에서 일부 컬럼의 설명이 비어 있던 문제를 바로잡았습니다. 회원·작성자 등 다른 표를 가리키는 컬럼이 대상이며, 업그레이드하면 이미 운영 중인 데이터베이스에도 설명이 채워집니다(직접 적어 넣은 설명은 그대로 둡니다).
- 본인인증 진행 상태를 조회하는 화면용 응답에 지금까지의 인증 시도 횟수가 함께 실려 나가던 것을 빼도록 바로잡았습니다. 원래 내보내지 않기로 한 값이며, 잠금 직전까지 시도 횟수를 맞춰 보는 데 쓰일 수 있었습니다. 화면 동작에는 영향이 없습니다.
- 플러그인 설정 화면이 저장된 비밀값(암호화 키·API 시크릿 등)을 다시 내려받던 것을 가려서 표시하도록 바로잡았습니다. 값이 저장되어 있다는 표시만 보여주며, 그 항목을 건드리지 않고 저장하면 저장된 값이 그대로 유지됩니다.
- 데이터베이스 계정 설정이 잘못되었을 때 아무 설명 없이 빈 화면만 뜨던 문제를 수정했습니다. 이전에는 최고 권한 계정을 쓰거나 사용자명이 비어 있으면 사이트 기능이 통째로 동작을 멈추면서도 화면에는 아무 안내가 없어 원인을 알 수 없었습니다. 이제 무엇이 잘못되었고 어떻게 고쳐야 하는지 설명하는 전용 안내 화면이 나타납니다. 서버 관리 명령은 평소대로 실행되므로 이 상태에서도 설정을 고칠 수 있습니다.
- 모듈·플러그인의 연동 기능이 실행된 뒤로 같은 요청의 나머지 처리가 요청 정보를 잃어버리던 문제를 수정했습니다. 백그라운드 작업을 별도 프로세스로 돌리지 않는 설정에서 이 현상이 나타나, 뒤이어 실행되는 연동 기능이 필요한 정보를 찾지 못하고 조용히 건너뛰었습니다. 대표적으로 비회원 장바구니에 담아 두고 로그인하면 담은 상품이 사라졌습니다.
- 모듈·플러그인 업데이트가 실패하거나 도중에 중단된 뒤, 확장은 정상인데 그 관리자 화면만 "페이지를 찾을 수 없습니다"로 나오고 하루가 지나야 돌아오던 문제를 수정했습니다. 업데이트가 진행되는 동안 사이트를 열어 본 사람이 있으면 그 순간의 "이 확장은 사용 중이 아님" 상태가 기억되는데, 실패로 끝나면 그 기억이 정리되지 않았습니다. 이제 실패로 끝나도 즉시 정리되며, 잘못된 상태가 애초에 기억되지 않도록 함께 보완했습니다.
- 데이터가 많은 사이트를 업그레이드할 때 기존 데이터를 채워 넣는 작업이 일부 행을 건너뛰던 문제를 수정했습니다. 100건씩 나눠 처리하면서 처리한 행이 대상 목록에서 빠지는 만큼 다음 묶음의 시작 위치가 밀려, 250건이면 100건이 처리되지 않은 채 남았습니다. 레이아웃 원본 대조값 채우기와 `json:convert-unicode` 명령이 대상이며, 이제 건수와 무관하게 전건이 처리됩니다. (#84 @glitter-gim 님께서 제보해주셨습니다.)
#### 개발자 도구
- 디버그 모드에서 개발자 도구의 상태 내려받기가 오류를 내며 실패하던 문제를 수정했습니다. 모듈·플러그인을 설치하거나 켜고 끄면 사이트가 라우트 정보를 미리 만들어 두는데, 그 상태에서는 상태 내려받기 기능이 필요한 코드를 찾지 못했습니다. 확장을 한 번이라도 건드린 사이트에서는 항상 실패했습니다.
- 같은 조건에서 브라우저 콘솔 로그 수집이 조용히 멈추던 문제를 수정했습니다. 로그를 받는 주소가 사라진 상태였는데 실패가 화면에도 기록에도 남지 않아, 디버그 모드를 켜 두었는데도 `storage/logs/browser.log` 만 비어 있었습니다.
- 관리자 환경설정에서만 디버그 모드를 켰을 때(`.env` 는 꺼진 상태) 개발자 도구 요청이 접근 거부 대신 오류로 응답하던 문제를 수정했습니다.
- 개발자 도구 응답 문구가 사이트 언어와 무관하게 항상 한국어로만 나오던 것을 사이트 언어를 따르도록 바꿨습니다.
#### 설정 저장·입력 검증
- 모듈·플러그인 설정의 숫자 항목이 숫자가 아닌 형태로 보관되어 있어도, 조회 시 설정 기본값과 같은 숫자 형태로 처리합니다. 이전에는 저장된 형태에 따라 기한 계산 같은 후속 처리에서 오류가 발생할 수 있었습니다. 숫자가 아닌 값, 참/거짓 항목, 목록 항목은 그대로 유지됩니다. 설정 안에 다시 묶여 있는 하위 항목까지 같은 규칙이 적용됩니다.
- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다.
- 한글로 쓴 설명이 글자 수 제한에 걸리지 않는데도 "너무 깁니다" 로 거부되던 문제를 수정했습니다. 글자 수를 바이트 단위로 세는 바람에 한글 한 글자가 세 글자로 계산되어, 500자 제한에서 167자만 넘어도 저장이 막혔습니다. 역할·권한·모듈·플러그인·템플릿 설명에 모두 해당합니다.
- 사용하던 언어팩을 끈 뒤 기존에 작성해 둔 내용을 수정하려 하면 저장이 통째로 막히던 문제를 수정했습니다. 꺼진 언어로 입력해 둔 번역이 남아 있으면 "지원하지 않는 언어" 로 거부되었습니다. 이제 그 번역은 그대로 보존한 채 수정할 수 있고, 언어팩을 다시 켜면 번역도 함께 되살아납니다.
- 알림 템플릿에서 제목을 비워 둘 수 있는데도 저장할 때 제목을 요구하던 문제를 수정했습니다. 제목 개념이 없는 발송 수단에서도 템플릿을 저장할 수 있습니다.
- 알림 정의에 존재하지 않는 발송 수단 이름이 그대로 저장되던 문제를 수정했습니다. 이제 사용할 수 없는 수단은 저장 시 안내와 함께 막히며, 플러그인이 추가한 수단은 정상적으로 선택할 수 있습니다. 수단을 제공하던 플러그인을 껐더라도 이미 저장된 설정은 계속 수정할 수 있습니다.
- 등록한 일정이 하나라도 있으면 일정 관리 목록이 열리지 않고 "총 0개"로만 표시되던 문제를 수정했습니다. 실행 이력 화면도 동일한 원인으로 열리지 않았으며, 이제 두 화면 모두 정상적으로 목록을 보여줍니다.
#### 안내 문구·주소 처리
- 일부 안내가 실제 문장 대신 내부 식별자(`messages.auth.unauthenticated` 같은 문자열)로 표시되던 문제를 수정했습니다. 로그인하지 않았거나 역할 권한이 없어 요청이 거절될 때, 메뉴에 역할을 지정할 때, 확장 식별자를 잘못 입력했을 때, 활동 로그를 기간·유형으로 검색할 때가 해당됩니다.
- 회원 정보 수정, 권한·메뉴 설정, 본인인증, 알림 설정 등 여러 화면에서 처리를 마친 뒤 표시되는 안내 문구가 번역된 문장 대신 내부 식별자가 그대로 보이던 문제를 수정했습니다. 이제 성공·실패·권한 없음 등 모든 안내가 사용 중인 언어에 맞는 문장으로 표시됩니다.
- 환경설정 저장이 실패했을 때 안내 문구에 항목 이름이 `security.password min length` 같은 내부 식별자로 표시되던 문제를 수정했습니다. 보안·업로드·SEO·고급·드라이버·메일 등 모든 설정 항목이 화면에 보이는 한글 항목명으로 안내됩니다.
- 플러그인을 GitHub 주소나 ZIP 파일로 설치할 때의 입력 안내가 내부 식별자로 표시되던 문제를 수정했습니다. 주소 형식이 잘못됐거나, ZIP이 아닌 파일을 올렸거나, 파일 용량을 초과했을 때 정상 문장으로 안내합니다.
- 목록의 열 제목을 눌러 정렬할 때, 이미 다른 열로 정렬 중이었다면 화살표는 새로 누른 열에 표시되면서 실제 정렬은 이전 열 기준으로 바뀌던 문제를 수정했습니다. 표시와 실제 정렬 기준이 어긋나 결과를 신뢰할 수 없었으며, 이제 누른 열 기준으로 한 번에 정렬됩니다.
- 목록에서 뒤쪽 페이지를 보던 중 정렬을 바꾸면 이전 페이지 번호에 그대로 머물러, 정렬을 바꿨는데 엉뚱한 구간이 보이거나 빈 목록이 나오던 문제를 수정했습니다. 이제 정렬을 바꾸면 항상 첫 페이지부터 보여줍니다. 사용자 관리·게시판 신고현황·페이지 관리 목록이 해당됩니다.
- 사용자 관리 목록에서 "페이지당 개수"가 10개로 표시되는데 실제로는 15개가 나오던 문제를 수정했습니다. 표시된 값과 실제 목록 건수가 일치합니다. 일정 관리 목록에서도 표시가 20개 목록을 10개로 잘못 안내하던 문제를 함께 바로잡았습니다.
- 알림 발송 이력에서 정렬을 "수신자명순" 또는 "제목순"으로 바꾸면 목록이 비면서 오류 안내가 뜨던 문제를 수정했습니다. 화면이 제공하는 정렬인데 서버가 거부하고 있었으며, 이제 두 정렬 모두 정상 동작합니다. 많은 이력에서도 빠르게 정렬되도록 색인을 함께 추가했습니다.
- 마이페이지 활동 목록에서 한 페이지에 표시할 개수를 0이나 음수로 요청하면 서버 오류가 나던 문제를 수정했습니다. 이제 허용 범위(1~100)로 자동 조정됩니다.
- 레이아웃 편집기 화면이 정상 동작하는데도 서버가 "페이지 없음"(404)으로 응답하던 문제를 수정했습니다. 화면은 그대로 보였지만 브라우저 캐싱과 서버 접속 기록에서 없는 페이지로 취급되던 상태입니다.
- 주소 끝에 슬래시가 붙은 형태(예: `/admin/`)로 접속하면 화면이 "페이지 없음"(404)으로 뜨던 문제를 수정했습니다. 특히 로그인이 풀린 상태에서 이렇게 접속하면 로그인 화면으로 넘어가지 않고 404가 나타났습니다. 이제 끝 슬래시가 있어도 정상적으로 같은 페이지로 연결됩니다.
#### 메뉴·플러그인 설정 무결성
- 메뉴를 수정할 때 자기 자신의 하위 메뉴 아래로 옮길 수 있던 문제를 수정했습니다. 이렇게 옮기면 메뉴 트리가 끊어져 관리자 화면에서 되돌릴 수 없었습니다. 메뉴 순서 변경 화면과 동일한 기준이 적용됩니다.
- 플러그인 설정을 저장할 때, 그 플러그인이 제공하지 않는 항목까지 설정 파일에 함께 기록되던 문제를 수정했습니다. 이제 플러그인이 실제로 제공하는 설정 항목만 저장됩니다.
- 문자·알림톡처럼 확장 기능이 추가하는 알림 발송 수단을 그 확장을 끈 뒤에도 발송 대상으로 삼아, 발송 이력에 "건너뜀"으로 남던 문제를 수정했습니다. 이제 확장을 끄면 해당 발송 수단은 발송 대상에서 완전히 제외되어 이력에도 남지 않고, 다시 켜면 알림 설정 그대로 복구됩니다.
## [7.0.5] - 2026-07-16
### Added
- 확장 기능이 화면을 그릴 때, 방문자의 접속 기기 종류처럼 요청마다 달라지는 정보를 화면 구성 데이터에 함께 실어 보낼 수 있는 통로를 마련했습니다. 쇼핑몰의 애플페이가 iOS 기기에서만 표시되도록 하는 데 이 통로가 쓰입니다.
- 확장 기능이 특정 요청 경로에만 자기 처리 로직을 끼워 넣을 수 있는 표준 연결 방식을 마련했습니다. 각 확장은 자기가 개입할 대상 경로를 명시해 선언하고, 코어가 요청이 들어올 때 대상에 해당하는 확장 로직만 골라 실행합니다. 이를 통해 쿠키 동의 처리나 결제 입금통보의 발신 서버 확인 같은 기능이 필요한 경로에서만 정확히 동작하도록 정리했습니다.
### Fixed
- 검색엔진 노출용으로 만들어 내보내는 페이지에 실제로 존재하지 않는 스타일 파일 주소(`/build/assets/app.css`)가 포함되어, 브라우저 콘솔에 404 오류가 남던 문제를 수정했습니다. 이 주소는 화면 표시에 쓰이지 않는 파일을 가리키던 불필요한 참조였으며, 검색엔진 노출용 페이지의 스타일은 템플릿이 제공하는 파일이 그대로 담당하므로 화면에는 영향이 없었습니다. 이제 이 참조를 제거해 오류가 발생하지 않습니다. (#64 @bigmsg 님께서 제보해주셨습니다.)
- 코어 업데이트를 `--prune` 옵션으로 실행하면 업로드 파일 연결(`public/storage`)이 삭제되어, 그동안 올린 이미지·첨부 파일이 모두 404로 열리지 않던 문제를 수정했습니다. 이제 `--prune` 업데이트가 이 연결을 지우지 않고 보존하며, 혹시 끊어져 있더라도 업데이트가 끝나는 시점에 자동으로 다시 이어 붙여 파일이 정상적으로 표시됩니다. 관리자 권한으로 업데이트를 실행할 때 이 연결을 다시 이어 붙이면서 소유자가 관리자 계정으로 바뀌던 문제도 함께 바로잡아, 원래 계정 소유가 그대로 유지됩니다. (#43 @bigmsg 님께서 제보해주셨습니다.)
## [7.0.4] - 2026-07-14
### Security
- 서버가 외부 주소를 대신 호출하는 기능들에서, 사내 서버나 클라우드 관리 주소처럼 외부에 공개되지 않은 내부 주소를 호출 대상으로 지정할 수 없도록 차단했습니다. 언어팩을 주소로 내려받을 때, 예약 작업으로 주소를 호출할 때, 쇼핑몰 배송비를 외부 계산 서버로 문의할 때가 해당합니다. 사내 서버를 주기적으로 호출하는 등 내부 주소가 꼭 필요한 운영 환경을 위해, 환경설정 > 보안에 "내부 네트워크 주소 호출 허용" 항목을 새로 추가했습니다. 이 항목은 기본적으로 꺼져 있습니다.
- 주소를 검사할 때 앞부분만 맞으면 통과시키던 방식을 실제 주소가 정확히 일치하는지 확인하도록 바로잡았습니다. 이전에는 신뢰하는 주소를 앞에 붙인 가짜 주소가 검사를 통과할 수 있었습니다. 본인인증 연동과 언어팩 업데이트 확인에 적용됩니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1546)
- 배포본에 원본 소스코드가 그대로 담긴 파일(소스맵)이 함께 포함되어, 누구나 주소만 알면 내려받아 볼 수 있던 문제를 차단했습니다. 이제 배포용으로 빌드하면 이 파일이 아예 만들어지지 않으며, 기존에 포함되어 있던 파일도 모두 제거했습니다. 아울러 이 파일이 서버에 남아 있더라도 웹으로는 내려받을 수 없도록 막았습니다. 내 컴퓨터에서 개발용으로 빌드할 때는 디버깅에 필요하므로 이전과 동일하게 생성됩니다. (#72 @glitter-gim 님께서 건의해주셨습니다.)
- 아직 공개하지 않은 예약 작업 기능에서, 서버 명령을 실행하는 두 유형(쉘 명령·Artisan 명령)에 대해 향후 정식 공개에 대비한 실행 제한 장치를 미리 마련했습니다. 쉘 명령 예약은 기본적으로 완전히 차단되며 서버 관리자가 실행 파일을 명시적으로 등록해야만 사용할 수 있고(관리자 화면에서는 넓힐 수 없습니다), Artisan 명령 예약도 데이터를 지우거나 임의 코드를 실행할 수 있는 위험한 명령은 거부됩니다. 이 기능은 현재 메뉴에 노출되지 않아 실제 사용 경로가 없습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1567)
### Fixed
- 설치 과정에서 임시로 만들어지는 파일에 관리자 비밀번호가 그대로 남을 수 있던 문제를 수정했습니다. 설치가 정상적으로 끝나면 이 파일은 지워지지만, 설치를 중간에 그만두거나 마지막 정리가 이뤄지지 않은 경우에는 남아 있을 수 있었습니다. 이제 비밀번호는 이 파일에 아예 기록되지 않으며, 설치에 꼭 필요한 동안에만 접근이 제한된 별도 파일에 보관했다가 관리자 계정이 만들어지는 즉시 지워집니다. 이미 설치를 마친 사이트도 업데이트하면 남아 있던 파일이 자동으로 정리됩니다. (sir.kr 커뮤니티의 코ppp 님께서 제보해주셨습니다.)
- 화면 편집기를 작은 화면에서 열면 상단 도구 모음의 버튼 글자가 세로로 쪼개지는 등 화면이 찌그러지던 문제를 수정했습니다. 화면 편집기는 왼쪽 화면 목록과 미리보기 화면을 나란히 놓고 쓰는 도구라 넓은 화면이 필요합니다. 이제 창이 좁아도 편집기를 억지로 우겨넣지 않고 원래 크기를 유지하며, 모자란 만큼은 가로 스크롤로 넘겨 보면 됩니다.
- 모바일에서 새로고침을 연속으로 눌렀을 때 간헐적으로 "초기화 실패" 또는 "페이지 로딩 실패" 화면이 뜨던 문제를 수정했습니다. 화면을 그리는 데 필요한 요청 하나가 회선 사정으로 잠깐 끊기면 그것만으로 페이지 전체가 뜨지 않았습니다. 이제 이런 일시적인 끊김은 자동으로 다시 시도해 복구하므로, 대부분의 경우 사용자는 아무것도 느끼지 못한 채 화면이 정상적으로 열립니다.
- 화면 구성에 필요한 스크립트를 끝내 불러오지 못한 경우, 아무 안내 없이 빈 화면만 남거나 영문 오류 문구가 그대로 노출되던 문제를 수정했습니다. 이제 무슨 일이 있었는지 알려주는 안내와 새로고침 버튼이 표시됩니다. 관리자 화면에도 동일하게 적용됩니다.
- 쇼핑몰 등 확장 기능을 불러오지 못한 상태에서 화면이 5초가량 빈 채로 멈춰 있다가 뜨던 문제를 수정했습니다. 이제 지체 없이 화면이 열리고, 불러오지 못한 확장 기능만 조용히 빠집니다.
- 외부 라이브러리를 쓰는 확장의 버전을 올린 뒤 그 확장을 업데이트하면 "번들 파일의 무결성 검증에 실패했습니다"라는 오류로 업데이트가 막히던 문제를 수정했습니다. 오류를 풀려면 업데이트가 필요한데 그 업데이트 자체가 막혀 빠져나올 수 없는 상태였습니다. 아울러 확장 버전만 올린 경우에도 번들 재빌드가 필요함을 `--check` 점검이 올바르게 알려주도록 바로잡았습니다. (이미 배포된 확장을 내려받아 쓰는 환경에는 영향이 없으며, 확장을 직접 개발·수정하는 경우에 해당합니다.)
- Windows 개발 환경에서 코어 의존성 번들을 다시 만들 때, 마지막 정리 단계에서 진행이 멈춘 채 끝나지 않던 문제를 수정했습니다. 이제 번들 빌드가 정상적으로 완료됩니다. 아울러 빌드가 중간에 실패하더라도 기존 번들 파일이 손상되지 않도록 새 결과가 완성된 뒤에만 교체하도록 개선했습니다. (확장을 직접 개발·수정하는 환경에 해당하며, 배포본을 내려받아 쓰는 환경에는 영향이 없습니다.)
- 확장에 외부 라이브러리를 새로 추가한 뒤 번들을 만들면, 새로 추가한 라이브러리가 번들에 담기지 않은 채로 만들어지던 문제를 수정했습니다. 검증은 통과하지만 실제 파일이 빠져 있어 설치 후 실행 중에 오류가 나던 상태였습니다. 이제 새로 추가한 라이브러리가 번들에 정상적으로 포함됩니다. (확장을 직접 개발·수정하는 환경에 해당합니다.)
## [7.0.3] - 2026-07-10
### Fixed
- 화면 구성 파일이 목록을 만들어 내는 과정에서 다국어 이름을 골라 쓸 때, 사이트를 어떤 언어로 보고 있든 항상 한국어 이름이 선택되던 문제를 수정했습니다. 이제 보고 있는 언어의 이름이 표시됩니다. 헤더의 배송국가 선택처럼 화면이 처음 열릴 때 목록을 만들어 두는 곳이 여기에 해당합니다.
- 메뉴를 눌러 화면을 이동했을 때, 필요한 정보를 여러 갈래로 나눠 받아오는 화면에서 먼저 도착한 정보가 뒤늦게 도착한 정보에 덮여 사라지는 경우가 있었습니다. 그 탓에 목록이 있어야 할 자리가 간헐적으로 비어 보이고 새로고침하면 정상으로 돌아오곤 했습니다. 이제 어느 정보가 먼저 도착하든 모두 반영됩니다. 이커머스 환경설정의 배송설정 탭에서 배송가능 국가 목록이 비어 보이던 것이 여기에 해당합니다.
- 이미 설치를 마친 환경의 설정 파일(`.env`)을 새 서버로 복사해, 데이터베이스가 아직 비어 있는 상태에서 설치를 이어가려 할 때 `php artisan migrate`로 테이블을 만들기도 전에 부팅 단계에서 오류가 나며 중단되던 문제를 수정했습니다. 이제 데이터베이스 작업 명령이 실행되는 동안에는 테이블 존재 여부를 실제로 확인하므로, 기존 서버의 설정을 재사용하는 신규 배포에서도 설치가 정상적으로 진행됩니다. (#41 @GyusoonKim 님께서 제보해주셨습니다.)
- 활동 로그 기록에 실패했을 때 그 기록을 유발한 요청까지 함께 실패하던 문제를 수정했습니다. 결제 입금통보 같은 외부 콜백이 활동 로그 문제로 오류를 반환하는 일이 없어집니다.
## [7.0.2] - 2026-07-08
### Added
- 설치된 모듈·플러그인이 늘어날수록 화면마다 개별로 내려받던 확장 스크립트·스타일 요청을 종류별로 하나로 묶어 내려받도록 개선해, 확장이 많은 사이트의 초기 로딩 요청 수를 크게 줄였습니다. 확장을 설치·활성화·비활성화하면 묶음이 자동으로 다시 만들어져 항상 현재 활성 상태와 일치합니다.
### Changed
- 코어 업데이트가 파일을 반영하는 기본 방식을 "새 버전이 실제로 추가·변경한 파일만 반영"으로 바꿔, 업데이트로 인한 사고 가능성을 크게 줄였습니다. 이제 코어가 건드리지 않은 파일은 그대로 두므로, `public/.htaccess` 에 직접 넣은 설정이나 사용자가 추가해 둔 파일이 업데이트 과정에서 사라지지 않습니다. 새 버전에 함께 담긴 기본 제공 확장(모듈·플러그인·템플릿·언어팩)의 갱신 파일은 이 과정에서도 정상적으로 최신 내용으로 반영되며, 사용자가 확장 폴더에 직접 추가해 둔 항목은 그대로 보존됩니다. 기존처럼 새 버전에서 제거된 파일 정리까지 포함한 전체 덮어쓰기를 원하면 `--prune` 옵션으로 업데이트하세요. (#64 @bigmsg 님께서 제보해주셨습니다.)
- 화면·데이터를 내려받을 때의 전송 용량을 줄여 초기 로딩과 페이지 이동 속도를 개선했습니다.
- 모듈·플러그인의 묶음 스크립트·스타일이 지금까지 압축 없이 전송되던 것을 압축 전송하도록 바꿔, 확장이 많은 사이트에서 첫 화면에 받는 용량을 크게 줄였습니다.
- 한글 등 다국어 텍스트가 응답에 불필요하게 부풀려 담기던 것을 원래 형태 그대로 담도록 바꿔, 특히 다국어 번역 데이터의 전송 용량을 줄였습니다.
- 화면을 그리는 데 쓰이지 않는 개발용 설명 주석을 화면 정보 응답에서 빼도록 해 응답 용량을 추가로 줄였습니다.
### Fixed
- 코어 업데이트 시 레이아웃 편집기·개발자 진단도구 번들의 빌드 설정 파일이 갱신 대상에서 누락되던 문제를 수정했습니다. 이제 코어를 업데이트하면 이 파일들도 최신 내용으로 반영됩니다.
- 설치 마법사에서 Vendor 설치 방식을 "Composer 실행"으로 선택해 설치할 때, 일부 Windows 환경에서 Composer 설치 로그의 문자 인코딩이 어긋나면 진행 상태 응답이 비어 진행 상황이 표시되지 않고 오류가 반복될 수 있던 문제를 보완했습니다. 어떤 문자가 섞이더라도 진행 로그가 항상 정상적으로 전달되도록 처리했습니다. (#62 @kitrio 님께서 제보해주셨습니다.)
- 설치 마법사의 "설치 환경 확인" 단계에서, storage 폴더 하위에 파일이 많이 쌓인 환경일수록 요구사항 검사가 오래 걸리던 문제를 개선했습니다. 폴더 쓰기 권한 검사가 불필요하게 깊은 하위까지 훑던 것을 필요한 범위로 제한해 이 단계가 즉시 완료되도록 했습니다.
- 설치 시 "읽기 전용 데이터베이스(Read DB)를 사용하지 않음"으로 선택했는데도, 읽기 전용 DB 접속 정보를 확인하려다 설치 마지막 단계에서 실패하던 문제를 수정했습니다. 이제 읽기 전용 DB를 사용하지 않으면 쓰기 DB 정보만으로 설치가 정상 완료됩니다. 아울러 설치 이후에도 읽기 전용 DB 접속 정보를 비워 두면 조회 연결이 자동으로 쓰기 DB로 대체되도록 하여, 별도의 읽기 전용 DB 없이도 안정적으로 동작합니다. (#63 @kitrio 님께서 제보해주셨습니다.)
## [7.0.1] - 2026-07-03
### Changed
@@ -1314,7 +1711,6 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- 휴대폰 인증, 아이핀 인증 등 본인인증 기능 제공
- 사용자 관리 화면 레이아웃 확장 (본인인증 상세/폼)
- 역할 기반 접근 제어 (본인인증 관리자)
- 다국어 지원 (ko, en)
#### 템플릿: sirsoft-admin_basic (관리자)
@@ -1350,7 +1746,6 @@ abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute
- CMS 페이지 표시 화면
- 에러 페이지 (403, 404, 500, 503)
- 점검 모드(maintenance) 전용 페이지
- 반응형 레이아웃 (데스크톱/태블릿/모바일)
#### 다국어 시스템 (i18n)
+45 -2
View File
@@ -8,7 +8,7 @@
| 항목 | 요구사항 |
|------|---------|
| **PHP** | 8.2 이상 (필수 확장 30개 포함) |
| **PHP** | 8.2 이상 (필수 확장 16개 포함 — 기능별 선택 확장은 별도) |
| **데이터베이스** | MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4) |
| **Composer** | 2.x |
| **Redis** | 6.0+ (프로덕션 권장, 선택) |
@@ -77,6 +77,49 @@ server {
}
```
#### 정적 파일 최적화 블록을 함께 쓰는 경우
아래처럼 확장자로 캐시 규칙을 거는 블록(aaPanel · CyberPanel · Plesk 기본 템플릿에
포함되어 있습니다)을 함께 사용한다면 주의가 필요합니다.
```nginx
location ~* \.(js|css|json|png|jpg|svg|woff2?)$ {
expires max;
access_log off;
}
```
nginx 는 **정규식 location 을 프리픽스 location(`location /`)보다 먼저** 매칭합니다.
G7 은 일부 동적 엔드포인트에 `.js` · `.css` · `.json` 확장자를 쓰므로, 위 블록 안에
PHP 핸들러가 없으면 그 요청들이 `try_files ... /index.php` 폴백에 닿지 못하고
nginx 가 직접 파일을 찾다가 404 를 반환합니다. 증상은 **모든 페이지가 백지**입니다.
해결 방법은 두 가지이며, 아무것도 하지 않아도 G7 이 스스로 복구합니다.
1. **그대로 두기** — 브라우저가 자산 로드 실패를 감지하면 확장자 없는 주소로 자동
전환합니다. 설치 마법사도 설치 시점에 이를 감지해 설정에 반영합니다.
설치 후 확정하려면 `php artisan g7:asset-url-mode extensionless` 를 실행하거나
관리자 > 환경설정 > 일반에서 "자산 파일 주소 방식" 을 변경하세요.
(검색엔진 봇은 JavaScript 를 실행하지 않으므로, SEO 를 쓴다면 이 확정을 권장합니다.)
2. **`/api/` 를 정규식 블록보다 우선시키기** — `^~` 는 정규식 location 보다 우선하므로
아래 블록을 추가하면 `/api/` 요청이 항상 PHP 로 갑니다. 확장자 기반 캐시 최적화를
정적 파일에만 그대로 유지할 수 있습니다.
```nginx
location ^~ /api/ {
try_files $uri $uri/ /index.php?$query_string;
}
```
서버가 동적 응답을 가로채는지는 아래 두 요청을 비교해 확인할 수 있습니다.
첫 번째만 실패하면 가로채는 것입니다.
```bash
curl -i https://example.com/api/system/asset-probe.js
curl -i https://example.com/api/system/asset-probe
```
### 3단계: 설치 마법사 실행
브라우저에서 접속합니다.
@@ -246,7 +289,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.1 g7
# (필요 시) mv g7-7.0.9 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+577
View File
@@ -0,0 +1,577 @@
<p align="center"><a href="README.md">English</a> | 한국어</p>
<p align="center">
<img src="https://img.shields.io/badge/그누보드7-Gnuboard7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="그누보드7 (Gnuboard7)">
</p>
<p align="center">
<strong>모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS</strong><br>
A modern, extensible CMS platform built with Laravel + React
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.9-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
<a href="#"><img src="https://img.shields.io/badge/status-Stable-brightgreen" alt="Status"></a>
</p>
<p align="center">
<a href="https://g7.gnuboard.com"><strong>데모 사이트</strong></a> ·
<a href="https://g7.gnuboard.com/admin"><strong>관리자 데모</strong></a>
</p>
---
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
---
## 그누보드7 소개
**그누보드7 (Gnuboard7)** 은 23년간 대한민국에서 가장 널리 사용된 오픈소스 CMS인 그누보드를, 현대적 기술 스택으로 **완전히 새로 설계**한 차세대 웹 플랫폼입니다.
Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터 다시 만들었습니다.
- **JSON 레이아웃 엔진**: React를 몰라도 JSON만으로 React 기반 UI를 선언적으로 정의. 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장. 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
- **하나의 플랫폼, 다양한 비즈니스**: 커뮤니티, 쇼핑몰, 구독, 예약 — 비즈니스 모델에 맞게 확장
- **정교한 권한 관리**: 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로, 서비스 규모가 커져도 통제력 유지
- **글로벌 레디**: 다국어(i18n) 네이티브 지원, 로케일 기반 UI, 다중 통화 대응
- **확장 시스템**: 모듈 + 플러그인 + 템플릿 3중 구조로 코어 수정 없이 기능 확장
---
## 주요 기능
현대적인 웹 플랫폼에 필요한 핵심 기능을 갖추었습니다.
| 영역 | 설명 |
|------|------|
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다. 결제 연동은 동일한 Extension Point 패턴으로 붙일 수 있으며, 해외 결제 수단은 별도 플러그인으로 제공됩니다 |
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
| **본인인증 (IDV)** | 회원가입·비밀번호 재설정·민감 작업 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다. 코어가 메일 프로바이더를 기본 내장하고, 외부 KCP·이니시스·SMS·PortOne·Stripe Identity 등은 동일한 Provider 계약으로 붙일 수 있는 확장점을 제공합니다. 서버가 HTTP 428 응답을 반환하면 프론트엔드 인터셉터가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 재실행합니다 |
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection), 로그인 시도 제한·계정 잠금(HTTP 423) 실제 구현, 설치 완료 후 인스톨러 엔드포인트 자동 차단(HTTP 410) 까지 다층 방어를 구성합니다 |
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 |
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
| **SEO** | `jaybizzle/crawler-detect` 기반으로 약 1,000종 봇(검색엔진·SNS unfurl·AI 검색)을 자동 감지하여 봇 요청에는 정적 HTML 을, 일반 사용자에게는 SPA 를 응답합니다. OG/Twitter 카드 메타와 모듈이 선언한 도메인 스키마(Article/Product/Offer/AggregateRating), Sitemap 자동·수동 생성, Generator 메타 태그까지 표준 SEO 표면을 코어에서 제공합니다 |
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이하며, 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되어 도메인별 자기설명이 가능합니다 |
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
---
## 기술 스택
| 구분 | 기술 |
|------|------|
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+ / MariaDB 10.3+, Redis 6.0+ |
| **프론트엔드** | React 19, Vite, Tailwind CSS 4 (다크 모드 지원) |
| **인증** | Laravel Sanctum (Bearer 토큰) |
| **테스트** | PHPUnit 11.x, Vitest |
| **코드 품질** | Laravel Pint (PSR-12) |
---
## 아키텍처
```
Gnuboard7
├── Core (Laravel 12)
│ ├── Controller → FormRequest → Service → Repository → Model
│ ├── Hook System (Action / Filter)
│ ├── Permission (Role → Permission → Scope)
│ ├── Identity Verification (Policy × Purpose × Provider × Message)
│ ├── Language Pack (가상 보호 행 + ZIP/GitHub 설치 + sub-key 보존)
│ ├── Notification (Definition × Template × Recipients)
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
│
├── Extensions
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
│ ├── Plugins — 결제, 인증, 마케팅 ...
│ ├── Templates — 관리자 UI, 사용자 UI
│ └── LanguagePacks — 일본어 등 공식/외부 언어팩
│
└── Template Engine
├── JSON Layout → React Components
└── Dynamic Rendering + Data Binding
```
### 템플릿 엔진 동작 흐름
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
#### 제공 기능
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용한 **위지윅 레이아웃 편집기** — 비개발자도 화면 블록을 직접 배치·편집하고 결과를 바로 확인할 수 있습니다
```mermaid
flowchart TB
subgraph Backend ["🔧 Backend — Laravel"]
A["📄 JSON 레이아웃 파일"] --> B["⚙️ LayoutService"]
B --> |"상속 해석<br/>extends / partial"| B
M["📦 모듈 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
P["🔌 플러그인 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
B --> C["🔒 권한 필터링<br/>사용자별 컴포넌트 제거"]
C --> D["📨 병합된 JSON 응답<br/>캐싱 · 1시간 TTL"]
end
subgraph Frontend ["⚛️ Frontend — React"]
D --> E["📥 LayoutLoader<br/>레이아웃 JSON 수신"]
E --> F["💾 상태 초기화<br/>_global · _local · _computed"]
E --> G["🌐 데이터 소스 로딩<br/>API 병렬 호출"]
F & G --> H["🎨 DynamicRenderer"]
H --> I{"❓ 조건 평가<br/>if 표현식"}
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React 컴포넌트"]
I --> |"❌ false"| K["⏭️ 렌더링 스킵"]
J --> L["🔗 데이터 바인딩<br/>표현식 → 실제 값"]
L --> N["🖱️ 이벤트 바인딩<br/>onClick → ActionDispatcher"]
N --> O["✨ React 렌더링"]
end
subgraph Actions ["👆 사용자 인터랙션"]
O --> |"클릭 · 입력"| Q["🎯 ActionDispatcher"]
Q --> R["🧭 navigate — 페이지 이동"]
Q --> S["📡 apiCall — API 호출"]
Q --> T["🔄 setState — 상태 변경"]
Q --> U["📋 openModal — 모달 열기"]
S --> |"onSuccess · onError"| Q
T --> |"상태 변경 → 리렌더링"| H
end
style Backend fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
style Frontend fill:#d1fae5,stroke:#059669,stroke-width:2px,color:#064e3b
style Actions fill:#fce7f3,stroke:#db2777,stroke-width:2px,color:#831843
style A fill:#2563eb,stroke:#1d4ed8,color:#fff
style B fill:#2563eb,stroke:#1d4ed8,color:#fff
style M fill:#7c3aed,stroke:#6d28d9,color:#fff
style P fill:#7c3aed,stroke:#6d28d9,color:#fff
style C fill:#dc2626,stroke:#b91c1c,color:#fff
style D fill:#059669,stroke:#047857,color:#fff
style E fill:#059669,stroke:#047857,color:#fff
style F fill:#0891b2,stroke:#0e7490,color:#fff
style G fill:#0891b2,stroke:#0e7490,color:#fff
style H fill:#d97706,stroke:#b45309,color:#fff
style I fill:#d97706,stroke:#b45309,color:#fff
style J fill:#2563eb,stroke:#1d4ed8,color:#fff
style K fill:#6b7280,stroke:#4b5563,color:#fff
style L fill:#7c3aed,stroke:#6d28d9,color:#fff
style N fill:#7c3aed,stroke:#6d28d9,color:#fff
style O fill:#059669,stroke:#047857,color:#fff
style Q fill:#e11d48,stroke:#be123c,color:#fff
style R fill:#be185d,stroke:#9d174d,color:#fff
style S fill:#be185d,stroke:#9d174d,color:#fff
style T fill:#be185d,stroke:#9d174d,color:#fff
style U fill:#be185d,stroke:#9d174d,color:#fff
linkStyle default stroke:#374151,stroke-width:2px
```
**JSON 레이아웃 예시** — 아래 JSON이 실제 React UI로 렌더링됩니다:
```json
{
"data_sources": [
{ "id": "products", "endpoint": "/api/products", "method": "GET" }
],
"layout": {
"type": "basic", "name": "Div",
"children": [
{ "type": "basic", "name": "H1", "text": "$t:product_list" },
{
"type": "basic", "name": "Div",
"iteration": { "source": "{{products?.data?.data}}", "item_var": "$item" },
"children": [
{ "type": "basic", "name": "Span", "text": "{{$item.name}}" }
]
},
{
"type": "basic", "name": "Button", "text": "$t:add",
"if": "{{products?.data?.abilities?.can_create}}",
"actions": [{
"event": "onClick",
"handler": "navigate",
"params": { "path": "/products/create" }
}]
}
]
}
}
```
모듈/플러그인을 활성화하면 해당 UI와 컴포넌트가 자동으로 주입됩니다.
개발자는 JSON만으로 UI를 추가하거나 변경할 수 있어 별도의 프론트엔드 빌드가 필요 없으며, 권한(abilities)에 따라 UI 요소가 자동으로 표시/숨김 처리됩니다.
### 핵심 시스템
플랫폼을 떠받치는 네 가지 시스템이 유기적으로 동작합니다.
#### 1. 확장 시스템 — 3원칙
1. **코어 수정 최소화** — 모든 비즈니스 로직은 모듈/플러그인으로 구현
2. **동적 로딩** — `composer.json` 하드코딩 없이 디렉토리 스캔으로 자동 발견
3. **Hook 기반 확장** — 서비스 계층에서 Action/Filter 훅으로 기능 주입
#### 2. 훅 시스템 (Action / Filter)
Laravel 이벤트와 별개로 동작하는 가벼운 훅 시스템입니다. Action 은 부수 작업(로깅, 알림), Filter 는 값 변형 (기본값 주입, 권한 확장)에 사용됩니다.
```php
// Service 계층에서 훅 발행
HookManager::doAction('core.user.after_create', $user, $data);
$data = HookManager::applyFilters('core.user.filter_create_data', $data);
// 모듈 Listener 가 훅 구독 (자동 발견)
public static function getSubscribedHooks(): array
{
return [
'core.user.after_create' => ['method' => 'onUserCreated', 'priority' => 20],
];
}
```
모듈/플러그인은 `Listeners/` 디렉토리에 클래스만 두면 `HookListenerRegistrar` 가 자동으로 구독합니다. 큐 직렬화를 통한 비동기 실행도 지원하며, 워커에서도 `Auth::user()`, `request()->ip()`, `App::getLocale()` 같은 컨텍스트가 자동 복원됩니다.
#### 3. 공통 캐시 시스템
`CacheInterface` 를 기반으로 **코어 · 모듈 · 플러그인** 이 키 충돌 없이 각자의 캐시를 관리합니다.
| 드라이버 | 접두사 | 용도 |
| --- | --- | --- |
| `CoreCacheDriver` | `g7:core:{key}` | 코어 서비스 (레이아웃, SEO, 알림, 설정 등) |
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | 모듈별 격리 캐시 (게시판 상품 리스트, 쿨다운 등) |
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | 플러그인별 격리 캐시 |
```php
// 모듈 서비스는 BaseModuleServiceProvider::$cacheServices 배열에 등록하면
// 생성자 타입힌트만으로 자동 주입됨 (Storage 패턴과 동일)
public function __construct(
private BoardRepositoryInterface $repository,
private CacheInterface $cache, // ← g7:module.sirsoft-board: 접두사 자동 적용
) {}
```
- **TTL 중앙 관리** — 모든 캐시 TTL 은 `g7_core_settings('cache.*_ttl')` 를 추종합니다. 하드코딩 금지
- **자동 무효화** — `CacheInvalidatable` 트레이트를 모델에 적용하면 `saved` / `deleted` 시점에 태그 기반으로 관련 캐시 자동 삭제
- **라이프사이클 연동** — 모듈 비활성화/삭제 시 `ModuleManager` 가 해당 모듈의 격리 캐시를 일괄 flush
- **프론트엔드 캐시 버스팅** — `ext.cache_version` 증가가 응답의 `config.json` 을 통해 전파되어 `?v=` 쿼리 파라미터 기반으로 브라우저 캐시까지 무효화
#### 4. 알림 시스템
**Definition × Template × Recipients** 3계층 모델로 멀티 채널 알림을 관리합니다.
```text
┌─────────────────────┐ ┌───────────────────────┐ ┌─────────────────────┐
│ NotificationDefini- │ 1..N │ NotificationTemplate │ │ Recipients (JSON) │
│ tion ├──────┤ (채널별 독립) ├──────┤ - trigger_user │
│ type=order.created │ │ channel=mail|db|... │ │ - related_user │
│ variables=[...] │ │ subject, body, │ │ - role │
│ │ │ click_url │ │ - specific_users │
└─────────────────────┘ └───────────────────────┘ └─────────────────────┘
```
- **Definition** — 알림 종류(`type`), 지원 채널, 변수 메타데이터 정의
- **Template** — 채널(`mail` / `database` / `broadcast`)마다 독립된 제목·본문·클릭 URL. 관리자가 다국어로 커스터마이징 가능
- **Recipients** — 템플릿별로 수신자 규칙을 JSON 으로 정의. 템플릿 단위 독립이므로 "메일은 주문자에게, DB 알림은 역할 보유자에게" 같은 분기 구성 가능
```php
// 모듈 Service 에서 훅 발행만 하면 발송 파이프라인이 자동 실행
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
// ↓ NotificationHookListener → NotificationDispatcher:
// 1. order.confirmed 정의 조회
// 2. 활성 템플릿 순회 (mail/database)
// 3. 템플릿의 recipients JSON 해석 → 수신자 Collection
// 4. 각 수신자에게 channel 별 발송 (GenericNotification)
// 5. notification_logs 에 발송 이력 기록
```
- 코어 기본 알림 3종: `welcome`, `reset_password`, `password_changed`
- 이커머스 모듈 알림 7종: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
#### 5. 언어팩 시스템
새 언어를 코어 수정 없이 추가할 수 있는 운영 도구로, 모듈/플러그인/템플릿 관리와 동일한 라이프사이클(설치 → 활성화 → 업데이트 → 제거 + 자동 백업/롤백) 을 제공합니다.
| 영역 | 동작 |
| --- | --- |
| 설치 경로 | ZIP 업로드 / GitHub URL / `lang-packs/_bundled` 번들 디렉토리 (코어 업데이트 시 일괄 동기화) |
| 적용 범위 | 코어, 모듈, 플러그인, 템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 |
| 사용자 수정 보존 | 다국어 JSON 컬럼은 sub-key 단위 (`name.ko` / `name.ja`) 로 user override 기록 — 한 언어 라벨만 수정해도 그 언어만 보존, 신규 언어는 자동 동기화 |
| 활성화 시점 | 활성/비활성 시 영향받는 모듈/플러그인의 entity 시더가 자동 재실행 → 메뉴·권한·역할·매니페스트·알림 라벨 즉시 DB 반영 |
| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
공식 일본어(ja) 번들 16종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
#### 6. 본인인증 (Identity Verification)
회원가입·비밀번호 재설정·민감 작업·결제 직전 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다.
```text
┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Policy │ │ Purpose │ │ Provider │
│ (강제 시점·실패 모드│ │ (인증 목적·허용 채널│ │ (메일·KCP·이니시스 │
│ ·단계·conditions) │ ◀▶ │ ·source 추적) │ ◀▶ │ ·SMS·외부 IDV ...) │
└────────────────────┘ └─────────────────────┘ └──────────────────────┘
│ │
└──────────▶ Message Template (정책×목적 매핑) ◀───┘
│
GenericNotification
```
- **정책 SSoT** — 정책 enable 토글이 라우트 코드 수정 없이 즉시 적용. 모든 API 라우트가 정책 DB 와 자동 매칭
- **428 인터셉터** — 서버가 HTTP 428 응답을 반환하면 프론트엔드가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 자동 재실행
- **선언형 등록** — 모듈/플러그인은 `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 만 선언하면 활성화/업데이트 시 자동 등록되며 운영자 편집값 보존
- **메시지 템플릿** — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의. 정책 → 목적 → 프로바이더 기본값 순서로 fallback
- **외부 Provider 슬롯** — 플러그인이 KCP·PortOne·토스인증·Stripe Identity 등을 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입 가능
- **이력 관리** — 관리자 화면에서 인증 수단 탭, 통합 검색, 상태/목적/채널/IP 멀티 필터, 보관주기(180일) 일괄 파기 제공
> 상세: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md)
---
## 빠른 시작
### 시스템 요구사항
- PHP 8.2+ (필수 확장 16개 포함 — `ctype`, `curl`, `dom`, `fileinfo`, `json`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, `zip` 등. `gd`/`imagick`, `intl`, `redis`, `bcmath` 등은 해당 기능을 쓸 때만 필요하며 전체 목록은 [docs/requirements.md](docs/requirements.md) 참조)
- MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4)
- Composer 2.x
- Node.js 20+ (프론트엔드 에셋을 직접 빌드할 때만 필요)
- 웹 서버(Apache 또는 Nginx) — 문서 루트를 `public/` 으로 지정
- Redis 6.0+ (선택 — 프로덕션의 캐시·큐에 권장)
### 설치
```bash
# 1. 프로젝트 클론
git clone https://github.com/gnuboard/g7.git
cd g7
# 2. 환경 설정 파일 복사
cp .env.example .env
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
```
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
---
## 기본 제공 확장
### 모듈
| 모듈 | 설명 |
|------|------|
| **sirsoft-board** | 게시판 — 다중 게시판, 댓글, 파일 첨부 |
| **sirsoft-ecommerce** | 쇼핑몰 — 상품, 주문, 결제, 배송, 쿠폰, 상품 문의 |
| **sirsoft-page** | 페이지 — 정적 콘텐츠 관리 |
### 플러그인
| 플러그인 | 설명 |
|---------|------|
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
| **sirsoft-pay_nicepayments** | 나이스페이먼츠 결제 연동 (통합결제창) |
| **sirsoft-pay_nhnkcp** | NHN KCP 결제 연동 (Standard Pay) |
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
| **sirsoft-verification_nhnkcp** | NHN KCP 휴대폰 본인확인 |
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
| **sirsoft-marketing** | 마케팅 도구 |
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
| **sirsoft-message_bizppurio** | 비즈뿌리오 메시지 발송 (문자·카카오 알림톡) |
### 템플릿
| 템플릿 | 설명 |
|--------|------|
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
| **sirsoft-basic** | 사용자 기본 템플릿 |
### 번들 언어팩
설치 시 함께 동반 설치할 수 있는 공식 언어팩입니다. 코어 + 주요 모듈/플러그인/템플릿이 일관된 번역으로 즉시 사용 가능합니다.
| 식별자 | 설명 |
| ------ | ---- |
| **g7-core-ja** | 코어 일본어 |
| **g7-module-sirsoft-board-ja** | 게시판 모듈 일본어 |
| **g7-module-sirsoft-ecommerce-ja** | 이커머스 모듈 일본어 |
| **g7-module-sirsoft-page-ja** | 페이지 모듈 일본어 |
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor5 플러그인 일본어 |
| **g7-plugin-sirsoft-daum_postcode-ja** | 다음 우편번호 플러그인 일본어 |
| **g7-plugin-sirsoft-gdpr-ja** | 개인정보 보호(GDPR) 플러그인 일본어 |
| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
| **g7-plugin-sirsoft-message_bizppurio-ja** | 비즈뿌리오 메시지 발송 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_nicepayments-ja** | 나이스페이먼츠 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP 휴대폰 본인확인 플러그인 일본어 |
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
> 한국어/영어는 코어/번들 확장에 내장되어 있으며 설치 없이 항상 활성 상태로 동작합니다. 새 언어는 ZIP 또는 GitHub URL 로 자유롭게 추가할 수 있습니다.
### 학습용 샘플 확장
확장 시스템 학습을 위한 최소 구현 샘플입니다. 관리자 UI 에서 "숨김 포함" 토글로 노출되며 CLI 에서는 항상 보입니다.
| 식별자 | 종류 | 설명 |
| ------ | ---- | ---- |
| **gnuboard7-hello_module** | 모듈 | Memo CRUD + 훅 발행 시연 |
| **gnuboard7-hello_plugin** | 플러그인 | Action/Filter 훅 구독 시연 |
| **gnuboard7-hello_admin_template** | Admin 템플릿 | Basic 컴포넌트 최소 셋 |
| **gnuboard7-hello_user_template** | User 템플릿 | 홈 + Memo 리스트 연동 |
---
## 비즈니스 모델
그누보드7 하나로 다양한 비즈니스를 운영할 수 있습니다.
| 모델 | 설명 | 상태 |
|------|------|------|
| **커뮤니티** | 게시판, 댓글, 회원 관리 | 정식 |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | 정식 |
---
## 기존 그누보드 사용자
기존 그누보드5에서 그누보드7으로 전환할 수 있도록, 회원·게시글·상품 등 주요 데이터의 **마이그레이션 툴을 제공할 예정**입니다.
---
## 문서
| 문서 | 링크 |
|------|------|
| 설치 가이드 | [INSTALL.md](INSTALL.md) |
| 전체 문서 | [docs/README.md](docs/README.md) |
| 시스템 요구사항 | [docs/requirements.md](docs/requirements.md) |
| 백엔드 개발 | [docs/backend/README.md](docs/backend/README.md) |
| 프론트엔드 개발 | [docs/frontend/README.md](docs/frontend/README.md) |
| 데이터베이스 | [docs/database-guide.md](docs/database-guide.md) |
| 확장 시스템 | [docs/extension/README.md](docs/extension/README.md) |
| 모듈 개발 | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
| API 레퍼런스 | [docs/backend/api/README.md](docs/backend/api/README.md) |
| API 문서 작성 규정 | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
---
## 기여하기
그누보드7은 오픈소스 프로젝트입니다. 모든 형태의 기여를 환영합니다.
- 버그 리포트 및 기능 제안: [GitHub Issues](https://github.com/gnuboard/g7/issues)
- 코드 스타일: Laravel Pint (PSR-12)
- 테스트: PHPUnit (백엔드) + Vitest (프론트엔드)
- AI 협업: AI 에이전트용 개발 규칙 명세(AGENTS.md)와 MCP 디버깅 도구를 내장하고 있어, AI 도구와 자연스럽게 협업할 수 있습니다
---
## 만든 사람들
**[SIRSOFT](https://sir.kr)** 에서 개발하고 있습니다.
### Core Team
<p>
<a href="https://github.com/HeuJung"><img src="https://github.com/HeuJung.png" width="60" alt="HeuJung"></a>&nbsp;&nbsp;
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>&nbsp;&nbsp;
<a href="https://github.com/thisgun"><img src="https://github.com/thisgun.png" width="60" alt="thisgun"></a>
</p>
### 커뮤니티 기여자
제보와 건의로 그누보드7을 함께 만들어 주신 분들입니다. 아래 목록은 변경 기록(CHANGELOG)에 남은 제보자 표기에서 모았습니다.
<!-- community-contributors:start -->
<p>
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
<a href="https://github.com/devrhee16" title="devrhee16"><img src="https://github.com/devrhee16.png" width="48" alt="devrhee16"></a>
<a href="https://github.com/GyusoonKim" title="GyusoonKim"><img src="https://github.com/GyusoonKim.png" width="48" alt="GyusoonKim"></a>
<a href="https://github.com/Lastorder-DC" title="Lastorder-DC"><img src="https://github.com/Lastorder-DC.png" width="48" alt="Lastorder-DC"></a>
<a href="https://github.com/miles44229" title="miles44229"><img src="https://github.com/miles44229.png" width="48" alt="miles44229"></a>
<a href="https://github.com/minyho" title="minyho"><img src="https://github.com/minyho.png" width="48" alt="minyho"></a>
</p>
<!-- community-contributors:end -->
코드 기여자 목록은 [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors)에서 확인할 수 있습니다.
---
## 커뮤니티
| 채널 | 링크 |
|------|------|
| GitHub | [github.com/gnuboard/g7](https://github.com/gnuboard/g7) |
| SIR 커뮤니티 | [sir.kr](https://sir.kr) |
| 문의 | minsup@sir.kr |
---
## 변경 기록
최근 변경된 사항에 대한 자세한 내용은 [CHANGELOG](CHANGELOG.md)를 참고해 주세요.
---
## 보안 취약점
보안 취약점을 발견하셨다면 [SIR 문의게시판](https://sir.kr/boards/co_qa)에 비밀글로 제보해 주세요.
---
## 라이선스
그누보드7은 [MIT 라이선스](LICENSE)에 따라 배포되는 오픈소스 소프트웨어입니다.
Copyright (c) 2026 SIRSOFT
---
<p align="center">
Made by <a href="https://sir.kr">SIRSOFT</a>
</p>
+309 -248
View File
@@ -1,14 +1,16 @@
<p align="center">English | <a href="README.ko.md">한국어</a></p>
<p align="center">
<img src="https://img.shields.io/badge/그누보드7-Gnuboard7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="그누보드7 (Gnuboard7)">
<img src="https://img.shields.io/badge/Gnuboard7-그누보드7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="Gnuboard7 (그누보드7)">
</p>
<p align="center">
<strong>모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS</strong><br>
A modern, extensible CMS platform built with Laravel + React
<strong>A modern, extensible CMS platform built with Laravel + React</strong><br>
The next generation of Gnuboard — Korea's most widely used open-source CMS
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.1-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.9-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
@@ -16,63 +18,72 @@
<a href="#"><img src="https://img.shields.io/badge/status-Stable-brightgreen" alt="Status"></a>
</p>
---
<p align="center">
<a href="https://g7.gnuboard.com"><strong>Live Demo</strong></a> ·
<a href="https://g7.gnuboard.com/admin"><strong>Admin Demo</strong></a>
</p>
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
<p align="center">
<sub>The demo UI language follows your browser (Korean/English); you can switch it in the UI.</sub>
</p>
---
## 그누보드7 소개
**그누보드7 (Gnuboard7)** 은 23년간 대한민국에서 가장 널리 사용된 오픈소스 CMS인 그누보드를, 현대적 기술 스택으로 **완전히 새로 설계**한 차세대 웹 플랫폼입니다.
Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터 다시 만들었습니다.
- **JSON 레이아웃 엔진**: React를 몰라도 JSON만으로 React 기반 UI를 선언적으로 정의. 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장. 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
- **하나의 플랫폼, 다양한 비즈니스**: 커뮤니티, 쇼핑몰, 구독, 예약 — 비즈니스 모델에 맞게 확장
- **정교한 권한 관리**: 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로, 서비스 규모가 커져도 통제력 유지
- **글로벌 레디**: 다국어(i18n) 네이티브 지원, 로케일 기반 UI, 다중 통화 대응
- **확장 시스템**: 모듈 + 플러그인 + 템플릿 3중 구조로 코어 수정 없이 기능 확장
[About](#about-gnuboard7) · [Key Features](#key-features) · [Tech Stack](#tech-stack) · [Architecture](#architecture) · [Quick Start](#quick-start) · [Bundled Extensions](#bundled-extensions) · [Business Models](#business-models) · [Migrating from Gnuboard 5](#migrating-from-gnuboard-5) · [Documentation](#documentation) · [Contributing](#contributing) · [Team](#team) · [Community](#community) · [Changelog](#changelog) · [License](#license)
---
## 주요 기능
## About Gnuboard7
현대적인 웹 플랫폼에 필요한 핵심 기능을 갖추었습니다.
**Gnuboard7** is a complete, ground-up redesign of Gnuboard — Korea's most widely used open-source CMS for 23 years — rebuilt on a modern stack.
| 영역 | 설명 |
|------|------|
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다. 결제 연동은 동일한 Extension Point 패턴으로 붙일 수 있으며, 해외 결제 수단은 별도 플러그인으로 제공됩니다 |
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
| **본인인증 (IDV)** | 회원가입·비밀번호 재설정·민감 작업 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다. 코어가 메일 프로바이더를 기본 내장하고, 외부 KCP·이니시스·SMS·PortOne·Stripe Identity 등은 동일한 Provider 계약으로 붙일 수 있는 확장점을 제공합니다. 서버가 HTTP 428 응답을 반환하면 프론트엔드 인터셉터가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 재실행합니다 |
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection), 로그인 시도 제한·계정 잠금(HTTP 423) 실제 구현, 설치 완료 후 인스톨러 엔드포인트 자동 차단(HTTP 410) 까지 다층 방어를 구성합니다 |
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 |
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
| **SEO** | `jaybizzle/crawler-detect` 기반으로 약 1,000종 봇(검색엔진·SNS unfurl·AI 검색)을 자동 감지하여 봇 요청에는 정적 HTML 을, 일반 사용자에게는 SPA 를 응답합니다. OG/Twitter 카드 메타와 모듈이 선언한 도메인 스키마(Article/Product/Offer/AggregateRating), Sitemap 자동·수동 생성, Generator 메타 태그까지 표준 SEO 표면을 코어에서 제공합니다 |
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이하며, 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되어 도메인별 자기설명이 가능합니다 |
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
Everything from security to architecture was rewritten from scratch on Laravel and React.
- **JSON layout engine**: define React-based UI declaratively with JSON alone, no React knowledge required. Modules and plugins inject or extend UI dynamically through JSON without a frontend build. When you need something more advanced, you can develop and register custom React components
- **One platform, many business models**: community, storefront, subscription, booking — extend it to fit your business
- **Fine-grained access control**: Role + Permission + Scope, a three-tier model that keeps control as your service grows
- **Global-ready**: native i18n support, locale-driven UI, multi-currency handling
- **Extension system**: modules + plugins + templates, a three-layer structure that adds functionality without touching the core
---
## 기술 스택
## Key Features
| 구분 | 기술 |
|------|------|
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+, Redis 6.0+ |
| **프론트엔드** | React 19, Vite, Tailwind CSS 4 (다크 모드 지원) |
| **인증** | Laravel Sanctum (Bearer 토큰) |
| **테스트** | PHPUnit 11.x, Vitest |
| **코드 품질** | Laravel Pint (PSR-12) |
Everything a modern web platform needs, built in.
| Area | Description |
|------|-------------|
| **Modular architecture** | Modules + plugins + templates, a three-layer extension structure. Independent modules (boards, commerce, and more) can be developed without modifying the core. Hook-based injection preserves the clear layering of the Service-Repository pattern |
| **Language pack system** | Install a new language from a ZIP file or GitHub URL without touching the core. Official bundled language packs (Japanese and others) are ready to use immediately, and labels an operator has edited are preserved per sub-key so a language pack never overwrites them. Packs apply independently to modules, plugins, and templates |
| **Localization** | A consistent multilingual development experience from backend to frontend. Active language packs automatically enrich notification channel labels, provider/registry payloads, and settings catalogs (payment methods, currencies, shippable countries). Activity log and message surfaces are separated so modules and plugins describe their own domain labels in their own territory |
| **Payment gateways** | A foundation for growing beyond a local business into global commerce. Payment integrations attach through the same extension-point pattern, and international gateways ship as separate plugins |
| **Access control** | Control menus, features, and even data scope per role. Role + Permission + Scope three-tier access control provides flexible management that mirrors your organization |
| **Identity verification (IDV)** | Every verification point — signup, password reset, sensitive operations — is managed centrally through declarative route/hook-level policies. The core ships a mail provider built in, and external providers (Korean identity-verification services such as KG Inicis and NHN KCP, as well as SMS, PortOne, and Stripe Identity-style services) attach through the same provider contract. When the server returns HTTP 428, the frontend interceptor opens a verification modal automatically and replays the original request on success |
| **Security** | Automatic input validation and token-based authentication. Layered defense designed in from the start (CSRF/XSS/SQL injection), a real implementation of login throttling and account lockout (HTTP 423), and automatic blocking of installer endpoints once setup completes (HTTP 410) |
| **Flexible screen composition** | Define a screen structure and see it applied immediately. Web-app-grade dynamic screens are achievable with JSON declarations alone, no frontend infrastructure required |
| **Layout editor** | A WYSIWYG layout editor lets you place screen blocks directly and see the result right away |
| **Proven foundation** | Built on Laravel + React — a stack adopted by companies worldwide, offering high extensibility and flexible UI implementation |
| **Shared cache system** | `CacheInterface` plus three drivers (core/module/plugin) isolate key prefixes automatically (`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`). Tag-based automatic invalidation and central TTL management via `g7_core_settings('cache.*_ttl')` keep operations free of hardcoded values |
| **Notification system** | A three-tier model — Definition × Template × Recipients — supports independent multi-channel delivery over mail, database, and real-time broadcast (Reverb). Targeting by author, role, specific users, or permission holders, plus hook-based dispatch, lets modules register their own notifications freely |
| **SEO** | Powered by `jaybizzle/crawler-detect`, roughly 1,000 bot types (search engines, social unfurlers, AI search) are detected automatically: bots receive static HTML while regular users get the SPA. OG/Twitter card metadata, domain schemas declared by modules (Article/Product/Offer/AggregateRating), automatic and manual sitemap generation, and generator meta tags are all provided by the core |
| **Activity log** | Administrator and user activity is recorded and searchable automatically. The Monolog-based structure is easy to extend, and action labels resolve from a module's or plugin's own translation files first, so each domain describes itself |
| **Search** | Full-text search powered by Laravel Scout, covering key content such as products and posts |
---
## 아키텍처
## Tech Stack
| Layer | Technology |
|-------|------------|
| **Backend** | PHP 8.2+, Laravel 12.x, MySQL 8.0+ / MariaDB 10.3+, Redis 6.0+ |
| **Frontend** | React 19, Vite, Tailwind CSS 4 (dark mode supported) |
| **Authentication** | Laravel Sanctum (Bearer tokens) |
| **Testing** | PHPUnit 11.x, Vitest |
| **Code quality** | Laravel Pint (PSR-12) |
---
## Architecture
```
Gnuboard7
@@ -81,66 +92,66 @@ Gnuboard7
│ ├── Hook System (Action / Filter)
│ ├── Permission (Role → Permission → Scope)
│ ├── Identity Verification (Policy × Purpose × Provider × Message)
│ ├── Language Pack (가상 보호 행 + ZIP/GitHub 설치 + sub-key 보존)
│ ├── Language Pack (virtual protected rows + ZIP/GitHub install + sub-key preservation)
│ ├── Notification (Definition × Template × Recipients)
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
│
├── Extensions
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
│ ├── Plugins — 결제, 인증, 마케팅 ...
│ ├── Templates — 관리자 UI, 사용자 UI
│ └── LanguagePacks — 일본어 등 공식/외부 언어팩
│ ├── Modules — board, commerce, page ...
│ ├── Plugins — payment, verification, marketing ...
│ ├── Templates — admin UI, user UI
│ └── LanguagePacks — official and third-party packs (Japanese and more)
│
└── Template Engine
├── JSON Layout → React Components
└── Dynamic Rendering + Data Binding
```
### 템플릿 엔진 동작 흐름
### How the template engine works
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
In Gnuboard7 you **declare the UI structure in JSON**, and the engine interprets it and renders React components.
#### 제공 기능
#### What it gives you
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용한 **위지윅 레이아웃 편집기** — 비개발자도 화면 블록을 직접 배치·편집하고 결과를 바로 확인할 수 있습니다
- Build React-based UI from JSON declarations alone — screen development without React expertise
- Modules and plugins inject or extend UI dynamically through JSON, with no frontend build
- Develop and register custom React components when a screen needs something more advanced
- Because the UI is defined as data (JSON) rather than code, a **WYSIWYG layout editor** lets non-developers place and edit screen blocks and see the result immediately
```mermaid
flowchart TB
subgraph Backend ["🔧 Backend — Laravel"]
A["📄 JSON 레이아웃 파일"] --> B["⚙️ LayoutService"]
B --> |"상속 해석<br/>extends / partial"| B
M["📦 모듈 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
P["🔌 플러그인 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
B --> C["🔒 권한 필터링<br/>사용자별 컴포넌트 제거"]
C --> D["📨 병합된 JSON 응답<br/>캐싱 · 1시간 TTL"]
A["📄 JSON layout file"] --> B["⚙️ LayoutService"]
B --> |"inheritance<br/>extends / partial"| B
M["📦 Module layout"] -.-> |"layout_extensions<br/>extension_point injection"| B
P["🔌 Plugin layout"] -.-> |"layout_extensions<br/>extension_point injection"| B
B --> C["🔒 Permission filtering<br/>drop components per user"]
C --> D["📨 Merged JSON response<br/>cached · 1 hour TTL"]
end
subgraph Frontend ["⚛️ Frontend — React"]
D --> E["📥 LayoutLoader<br/>레이아웃 JSON 수신"]
E --> F["💾 상태 초기화<br/>_global · _local · _computed"]
E --> G["🌐 데이터 소스 로딩<br/>API 병렬 호출"]
D --> E["📥 LayoutLoader<br/>receive layout JSON"]
E --> F["💾 State init<br/>_global · _local · _computed"]
E --> G["🌐 Data source loading<br/>parallel API calls"]
F & G --> H["🎨 DynamicRenderer"]
H --> I{"❓ 조건 평가<br/>if 표현식"}
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React 컴포넌트"]
I --> |"❌ false"| K["⏭️ 렌더링 스킵"]
H --> I{"❓ Condition eval<br/>if expression"}
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React component"]
I --> |"❌ false"| K["⏭️ Skip rendering"]
J --> L["🔗 데이터 바인딩<br/>표현식 → 실제 값"]
L --> N["🖱️ 이벤트 바인딩<br/>onClick → ActionDispatcher"]
N --> O["✨ React 렌더링"]
J --> L["🔗 Data binding<br/>expression → value"]
L --> N["🖱️ Event binding<br/>onClick → ActionDispatcher"]
N --> O["✨ React render"]
end
subgraph Actions ["👆 사용자 인터랙션"]
O --> |"클릭 · 입력"| Q["🎯 ActionDispatcher"]
Q --> R["🧭 navigate — 페이지 이동"]
Q --> S["📡 apiCall — API 호출"]
Q --> T["🔄 setState — 상태 변경"]
Q --> U["📋 openModal — 모달 열기"]
subgraph Actions ["👆 User interaction"]
O --> |"click · input"| Q["🎯 ActionDispatcher"]
Q --> R["🧭 navigate — page transition"]
Q --> S["📡 apiCall — API request"]
Q --> T["🔄 setState — state change"]
Q --> U["📋 openModal — open a modal"]
S --> |"onSuccess · onError"| Q
T --> |"상태 변경 → 리렌더링"| H
T --> |"state change → re-render"| H
end
style Backend fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
@@ -174,7 +185,7 @@ flowchart TB
linkStyle default stroke:#374151,stroke-width:2px
```
**JSON 레이아웃 예시** — 아래 JSON이 실제 React UI로 렌더링됩니다:
**JSON layout example** — the JSON below renders as a real React UI:
```json
{
@@ -206,29 +217,29 @@ flowchart TB
}
```
모듈/플러그인을 활성화하면 해당 UI와 컴포넌트가 자동으로 주입됩니다.
개발자는 JSON만으로 UI를 추가하거나 변경할 수 있어 별도의 프론트엔드 빌드가 필요 없으며, 권한(abilities)에 따라 UI 요소가 자동으로 표시/숨김 처리됩니다.
Activating a module or plugin injects its UI and components automatically.
Developers add or change UI with JSON alone — no separate frontend build — and UI elements are shown or hidden automatically according to permissions (abilities).
### 핵심 시스템
### Core systems
플랫폼을 떠받치는 네 가지 시스템이 유기적으로 동작합니다.
Four systems work together to hold the platform up.
#### 1. 확장 시스템 — 3원칙
#### 1. Extension system — three principles
1. **코어 수정 최소화** — 모든 비즈니스 로직은 모듈/플러그인으로 구현
2. **동적 로딩** — `composer.json` 하드코딩 없이 디렉토리 스캔으로 자동 발견
3. **Hook 기반 확장** — 서비스 계층에서 Action/Filter 훅으로 기능 주입
1. **Minimal core modification** — all business logic lives in modules and plugins
2. **Dynamic loading** — discovered automatically by directory scan, with no `composer.json` hardcoding
3. **Hook-based extension** — functionality is injected at the service layer through action and filter hooks
#### 2. 훅 시스템 (Action / Filter)
#### 2. Hook system (Action / Filter)
Laravel 이벤트와 별개로 동작하는 가벼운 훅 시스템입니다. Action 은 부수 작업(로깅, 알림), Filter 는 값 변형 (기본값 주입, 권한 확장)에 사용됩니다.
A lightweight hook system that operates separately from Laravel events. Actions handle side effects (logging, notifications); filters transform values (injecting defaults, extending permissions).
```php
// Service 계층에서 훅 발행
// Publish hooks from the service layer
HookManager::doAction('core.user.after_create', $user, $data);
$data = HookManager::applyFilters('core.user.filter_create_data', $data);
// 모듈 Listener 가 훅 구독 (자동 발견)
// A module listener subscribes to the hook (auto-discovered)
public static function getSubscribedHooks(): array
{
return [
@@ -237,249 +248,272 @@ public static function getSubscribedHooks(): array
}
```
모듈/플러그인은 `Listeners/` 디렉토리에 클래스만 두면 `HookListenerRegistrar` 가 자동으로 구독합니다. 큐 직렬화를 통한 비동기 실행도 지원하며, 워커에서도 `Auth::user()`, `request()->ip()`, `App::getLocale()` 같은 컨텍스트가 자동 복원됩니다.
Modules and plugins only need to place a class in their `Listeners/` directory and `HookListenerRegistrar` subscribes it automatically. Asynchronous execution through queue serialization is supported as well, and context such as `Auth::user()`, `request()->ip()`, and `App::getLocale()` is restored automatically inside the worker.
#### 3. 공통 캐시 시스템
#### 3. Shared cache system
`CacheInterface` 를 기반으로 **코어 · 모듈 · 플러그인** 이 키 충돌 없이 각자의 캐시를 관리합니다.
Built on `CacheInterface`, the **core, modules, and plugins** each manage their own cache without key collisions.
| 드라이버 | 접두사 | 용도 |
| Driver | Prefix | Purpose |
| --- | --- | --- |
| `CoreCacheDriver` | `g7:core:{key}` | 코어 서비스 (레이아웃, SEO, 알림, 설정 등) |
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | 모듈별 격리 캐시 (게시판 상품 리스트, 쿨다운 등) |
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | 플러그인별 격리 캐시 |
| `CoreCacheDriver` | `g7:core:{key}` | Core services (layouts, SEO, notifications, settings) |
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | Per-module isolated cache (board and product lists, cooldowns) |
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | Per-plugin isolated cache |
```php
// 모듈 서비스는 BaseModuleServiceProvider::$cacheServices 배열에 등록하면
// 생성자 타입힌트만으로 자동 주입됨 (Storage 패턴과 동일)
// Register a module service in the BaseModuleServiceProvider::$cacheServices array
// and it is injected from the constructor type hint alone (same as the storage pattern)
public function __construct(
private BoardRepositoryInterface $repository,
private CacheInterface $cache, // ← g7:module.sirsoft-board: 접두사 자동 적용
private CacheInterface $cache, // ← g7:module.sirsoft-board: prefix applied automatically
) {}
```
- **TTL 중앙 관리** — 모든 캐시 TTL 은 `g7_core_settings('cache.*_ttl')` 를 추종합니다. 하드코딩 금지
- **자동 무효화** — `CacheInvalidatable` 트레이트를 모델에 적용하면 `saved` / `deleted` 시점에 태그 기반으로 관련 캐시 자동 삭제
- **라이프사이클 연동** — 모듈 비활성화/삭제 시 `ModuleManager` 가 해당 모듈의 격리 캐시를 일괄 flush
- **프론트엔드 캐시 버스팅** — `ext.cache_version` 증가가 응답의 `config.json` 을 통해 전파되어 `?v=` 쿼리 파라미터 기반으로 브라우저 캐시까지 무효화
- **Central TTL management** — every cache TTL follows `g7_core_settings('cache.*_ttl')`. No hardcoding
- **Automatic invalidation** — apply the `CacheInvalidatable` trait to a model and related caches are dropped by tag on `saved` / `deleted`
- **Lifecycle integration** — when a module is deactivated or removed, `ModuleManager` flushes that module's isolated cache in bulk
- **Frontend cache busting** — incrementing `ext.cache_version` propagates through the response `config.json` and invalidates browser caches via a `?v=` query parameter
#### 4. 알림 시스템
#### 4. Notification system
**Definition × Template × Recipients** 3계층 모델로 멀티 채널 알림을 관리합니다.
Multi-channel notifications are managed through a three-tier model: **Definition × Template × Recipients**.
```text
┌─────────────────────┐ ┌───────────────────────┐ ┌─────────────────────┐
│ NotificationDefini- │ 1..N │ NotificationTemplate │ │ Recipients (JSON) │
│ tion ├──────┤ (채널별 독립) ├──────┤ - trigger_user │
│ type=order.created │ │ channel=mail|db|... │ │ - related_user │
│ variables=[...] │ │ subject, body, │ │ - role │
│ │ │ click_url │ │ - specific_users │
│ tion ├──────┤ (independent per ├──────┤ - trigger_user │
│ type=order.created │ │ channel) │ │ - related_user │
│ variables=[...] │ │ channel=mail|db|... │ │ - role │
│ │ │ subject, body, │ │ - specific_users │
│ │ │ click_url │ │ │
└─────────────────────┘ └───────────────────────┘ └─────────────────────┘
```
- **Definition** — 알림 종류(`type`), 지원 채널, 변수 메타데이터 정의
- **Template** — 채널(`mail` / `database` / `broadcast`)마다 독립된 제목·본문·클릭 URL. 관리자가 다국어로 커스터마이징 가능
- **Recipients** — 템플릿별로 수신자 규칙을 JSON 으로 정의. 템플릿 단위 독립이므로 "메일은 주문자에게, DB 알림은 역할 보유자에게" 같은 분기 구성 가능
- **Definition** — declares the notification type, supported channels, and variable metadata
- **Template** — an independent subject, body, and click URL per channel (`mail` / `database` / `broadcast`). Administrators can customize them in multiple languages
- **Recipients** — recipient rules declared as JSON per template. Because they are per-template, you can branch freely: "mail to the buyer, database notification to role holders"
```php
// 모듈 Service 에서 훅 발행만 하면 발송 파이프라인이 자동 실행
// A module service only publishes a hook; the delivery pipeline runs automatically
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
// ↓ NotificationHookListener → NotificationDispatcher:
// 1. order.confirmed 정의 조회
// 2. 활성 템플릿 순회 (mail/database)
// 3. 템플릿의 recipients JSON 해석 → 수신자 Collection
// 4. 각 수신자에게 channel 별 발송 (GenericNotification)
// 5. notification_logs 에 발송 이력 기록
// 1. Look up the order.confirmed definition
// 2. Iterate active templates (mail/database)
// 3. Resolve each template's recipients JSON → recipient collection
// 4. Deliver per channel to each recipient (GenericNotification)
// 5. Record the delivery in notification_logs
```
- 코어 기본 알림 3종: `welcome`, `reset_password`, `password_changed`
- 이커머스 모듈 알림 7종: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
- Three core notifications: `welcome`, `reset_password`, `password_changed`
- Seven e-commerce module notifications: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
- Real-time broadcasting runs on Laravel Reverb (WebSocket). Where Reverb is not configured, it skips gracefully without errors
- A single `GenericNotification` class handles every notification — adding a new notification type requires no new notification class
#### 5. 언어팩 시스템
#### 5. Language pack system
새 언어를 코어 수정 없이 추가할 수 있는 운영 도구로, 모듈/플러그인/템플릿 관리와 동일한 라이프사이클(설치 → 활성화 → 업데이트 → 제거 + 자동 백업/롤백) 을 제공합니다.
An operations tool for adding a new language without touching the core, with the same lifecycle as module, plugin, and template management (install → activate → update → remove, with automatic backup and rollback).
| 영역 | 동작 |
| Area | Behavior |
| --- | --- |
| 설치 경로 | ZIP 업로드 / GitHub URL / `lang-packs/_bundled` 번들 디렉토리 (코어 업데이트 시 일괄 동기화) |
| 적용 범위 | 코어, 모듈, 플러그인, 템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 |
| 사용자 수정 보존 | 다국어 JSON 컬럼은 sub-key 단위 (`name.ko` / `name.ja`) 로 user override 기록 — 한 언어 라벨만 수정해도 그 언어만 보존, 신규 언어는 자동 동기화 |
| 활성화 시점 | 활성/비활성 시 영향받는 모듈/플러그인의 entity 시더가 자동 재실행 → 메뉴·권한·역할·매니페스트·알림 라벨 즉시 DB 반영 |
| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
| Install paths | ZIP upload / GitHub URL / the `lang-packs/_bundled` bundled directory (synchronized in bulk on core updates) |
| Scope | Applied separately to the core, modules, plugins, and templates — a module pack activates only while the matching core pack is active |
| Preserving operator edits | Multilingual JSON columns record user overrides per sub-key (`name.ko` / `name.ja`), so editing one language's label preserves only that language while new languages sync automatically |
| Activation effects | On activation or deactivation, the entity seeders of affected modules and plugins re-run, so menus, permissions, roles, manifests, and notification labels reach the database immediately |
| Virtual protected rows | Korean and English are built into the core and bundled extensions and are always exposed as active and protected (editing and removal are blocked) |
| Security | Installation is blocked if a pack contains executable PHP beyond language translations |
공식 일본어(ja) 번들 12종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
Sixteen official Japanese (ja) bundled packs — the core plus the main modules, plugins, and templates — are ready to use, and step 4 of the installer links language pack cards to your module, plugin, and template selection so they can be installed together.
> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
> Details: [docs/extension/language-packs.md](docs/extension/language-packs.md) (Korean)
#### 6. 본인인증 (Identity Verification)
#### 6. Identity verification
회원가입·비밀번호 재설정·민감 작업·결제 직전 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다.
Every verification point — signup, password reset, sensitive operations, the moment before payment — is managed centrally through declarative route/hook-level policies.
```text
┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Policy │ │ Purpose │ │ Provider │
│ (강제 시점·실패 모드│ │ (인증 목적·허용 채널│ │ (메일·KCP·이니시스 │
│ ·단계·conditions) │ ◀▶ │ ·source 추적) │ ◀▶ │ ·SMS·외부 IDV ...) │
│ (enforcement point │ │ (verification goal, │ │ (mail, KCP, Inicis, │
│ failure mode, │ ◀▶ │ allowed channels, │ ◀▶ │ SMS, external IDV) │
│ step, conditions) │ │ source tracking) │ │ │
└────────────────────┘ └─────────────────────┘ └──────────────────────┘
│ │
└──────────▶ Message Template (정책×목적 매핑) ◀───┘
└──────────▶ Message Template (policy × purpose) ◀──┘
│
GenericNotification
```
- **정책 SSoT** — 정책 enable 토글이 라우트 코드 수정 없이 즉시 적용. 모든 API 라우트가 정책 DB 와 자동 매칭
- **428 인터셉터** — 서버가 HTTP 428 응답을 반환하면 프론트엔드가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 자동 재실행
- **선언형 등록** — 모듈/플러그인은 `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 만 선언하면 활성화/업데이트 시 자동 등록되며 운영자 편집값 보존
- **메시지 템플릿** — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의. 정책 → 목적 → 프로바이더 기본값 순서로 fallback
- **외부 Provider 슬롯** — 플러그인이 KCP·PortOne·토스인증·Stripe Identity 등을 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입 가능
- **이력 관리** — 관리자 화면에서 인증 수단 탭, 통합 검색, 상태/목적/채널/IP 멀티 필터, 보관주기(180일) 일괄 파기 제공
- **Policy as the single source of truth** — toggling a policy takes effect immediately without editing route code. Every API route matches against the policy database automatically
- **428 interceptor** — when the server returns HTTP 428, the frontend opens a verification modal automatically and replays the original request once verification succeeds
- **Declarative registration** — modules and plugins declare `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()`, and those are registered automatically on activation or update while preserving operator edits
- **Message templates** — define multilingual subjects and bodies per provider and per purpose/policy, falling back in the order policy → purpose → provider default
- **External provider slots** — plugins can inject their own SDK UI for services such as KCP, PortOne, Toss verification, or Stripe Identity through the standard G7 extension-point pattern
- **History management** — the admin screen offers tabs per verification method, unified search, multi-filters for status/purpose/channel/IP, and bulk destruction by retention period (180 days)
> 상세: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md)
> Details: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md) (Korean)
---
## 빠른 시작
## Quick Start
### 시스템 요구사항
### System requirements
- PHP 8.2+ (필수 확장 30개 포함)
- MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4)
- Node.js 20+ (빌드 시에만 필요)
- PHP 8.2+ with the required extensions (16 in total), including `ctype`, `curl`, `dom`, `fileinfo`, `json`, `mbstring`, `openssl`, `pdo_mysql`, `tokenizer`, `xml`, and `zip`. Additional extensions (`gd`/`imagick`, `intl`, `redis`, `bcmath`, and others) are optional and only needed for the features that use them — see [docs/requirements.md](docs/requirements.md)
- MySQL 8.0+ or MariaDB 10.3+ (utf8mb4)
- Composer 2.x
- Node.js 20+ (only needed when building frontend assets)
- A web server (Apache or Nginx) with the document root pointed at `public/`
- Redis 6.0+ (optional — recommended for cache and queue in production)
### 설치
### Installation
```bash
# 1. 프로젝트 클론
# 1. Clone the project
git clone https://github.com/gnuboard/g7.git
cd g7
# 2. 환경 설정 파일 복사
# 2. Install PHP dependencies
composer install
# 3. Copy the environment file
cp .env.example .env
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
# 4. Point your web server's document root at the public/ directory,
# then open /install in a browser and follow the setup wizard
```
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
The setup wizard creates the application key, configures the database connection, runs the migrations, and lets you choose which modules, plugins, templates, and language packs to install. Once setup completes, the installer endpoints are blocked automatically (HTTP 410).
> Detailed installation guide (Korean): [INSTALL.md](INSTALL.md)
---
## 기본 제공 확장
## Bundled Extensions
### 모듈
### Modules
| 모듈 | 설명 |
|------|------|
| **sirsoft-board** | 게시판 — 다중 게시판, 댓글, 파일 첨부 |
| **sirsoft-ecommerce** | 쇼핑몰 — 상품, 주문, 결제, 배송, 쿠폰, 상품 문의 |
| **sirsoft-page** | 페이지 — 정적 콘텐츠 관리 |
| Module | Description |
|--------|-------------|
| **sirsoft-board** | Boards — multiple boards, comments, file attachments |
| **sirsoft-ecommerce** | Storefront — products, orders, payments, shipping, coupons, product inquiries |
| **sirsoft-page** | Pages — static content management |
### 플러그인
### Plugins
| 플러그인 | 설명 |
|---------|------|
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
| **sirsoft-marketing** | 마케팅 도구 |
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
| Plugin | Description |
|--------|-------------|
| **sirsoft-pay_kginicis** | KG Inicis payment integration (Korean payment gateway) |
| **sirsoft-pay_nicepayments** | NICE Payments integration (Korean payment gateway, unified checkout) |
| **sirsoft-pay_nhnkcp** | NHN KCP payment integration (Korean payment gateway, Standard Pay) |
| **sirsoft-tosspayments** | Toss Payments integration (Korean payment gateway) |
| **sirsoft-verification_kginicis** | KG Inicis identity verification (Korean identity verification) |
| **sirsoft-verification_nhnkcp** | NHN KCP mobile identity verification (Korean identity verification) |
| **sirsoft-daum_postcode** | Daum postcode lookup (Korean address search) |
| **sirsoft-marketing** | Marketing tools |
| **sirsoft-ckeditor5** | CKEditor 5 editor |
| **sirsoft-gdpr** | Privacy and GDPR support |
| **sirsoft-message_bizppurio** | Bizppurio messaging (SMS/LMS and KakaoTalk alimtalk delivery) |
### 템플릿
### Templates
| 템플릿 | 설명 |
|--------|------|
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
| **sirsoft-basic** | 사용자 기본 템플릿 |
| Template | Description |
|----------|-------------|
| **sirsoft-admin_basic** | Default admin template |
| **sirsoft-basic** | Default user template |
### 번들 언어팩
### Bundled language packs
설치 시 함께 동반 설치할 수 있는 공식 언어팩입니다. 코어 + 주요 모듈/플러그인/템플릿이 일관된 번역으로 즉시 사용 가능합니다.
Official language packs you can install alongside the initial setup, so the core and the main modules, plugins, and templates share one consistent translation from the start.
| 식별자 | 설명 |
| ------ | ---- |
| **g7-core-ja** | 코어 일본어 |
| **g7-module-sirsoft-board-ja** | 게시판 모듈 일본어 |
| **g7-module-sirsoft-ecommerce-ja** | 이커머스 모듈 일본어 |
| **g7-module-sirsoft-page-ja** | 페이지 모듈 일본어 |
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor5 플러그인 일본어 |
| **g7-plugin-sirsoft-daum_postcode-ja** | 다음 우편번호 플러그인 일본어 |
| **g7-plugin-sirsoft-gdpr-ja** | 개인정보 보호(GDPR) 플러그인 일본어 |
| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
| Identifier | Description |
| ---------- | ----------- |
| **g7-core-ja** | Core, Japanese |
| **g7-module-sirsoft-board-ja** | Board module, Japanese |
| **g7-module-sirsoft-ecommerce-ja** | E-commerce module, Japanese |
| **g7-module-sirsoft-page-ja** | Page module, Japanese |
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor 5 plugin, Japanese |
| **g7-plugin-sirsoft-daum_postcode-ja** | Daum postcode plugin, Japanese |
| **g7-plugin-sirsoft-gdpr-ja** | Privacy/GDPR plugin, Japanese |
| **g7-plugin-sirsoft-marketing-ja** | Marketing plugin, Japanese |
| **g7-plugin-sirsoft-message_bizppurio-ja** | Bizppurio messaging plugin, Japanese |
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG Inicis payment plugin, Japanese |
| **g7-plugin-sirsoft-pay_nicepayments-ja** | NICE Payments plugin, Japanese |
| **g7-plugin-sirsoft-pay_nhnkcp-ja** | NHN KCP payment plugin, Japanese |
| **g7-plugin-sirsoft-tosspayments-ja** | Toss Payments plugin, Japanese |
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG Inicis identity verification plugin, Japanese |
| **g7-plugin-sirsoft-verification_nhnkcp-ja** | NHN KCP mobile identity verification plugin, Japanese |
| **g7-template-sirsoft-admin_basic-ja** | Default admin template, Japanese |
| **g7-template-sirsoft-basic-ja** | Default user template, Japanese |
> 한국어/영어는 코어/번들 확장에 내장되어 있으며 설치 없이 항상 활성 상태로 동작합니다. 새 언어는 ZIP 또는 GitHub URL 로 자유롭게 추가할 수 있습니다.
> Korean and English are built into the core and bundled extensions and are always active without installation. Any other language can be added freely from a ZIP file or a GitHub URL.
### 학습용 샘플 확장
### Sample extensions for learning
확장 시스템 학습을 위한 최소 구현 샘플입니다. 관리자 UI 에서 "숨김 포함" 토글로 노출되며 CLI 에서는 항상 보입니다.
Minimal implementations for learning the extension system. They appear in the admin UI when the "include hidden" toggle is on, and are always visible from the CLI.
| 식별자 | 종류 | 설명 |
| ------ | ---- | ---- |
| **gnuboard7-hello_module** | 모듈 | Memo CRUD + 훅 발행 시연 |
| **gnuboard7-hello_plugin** | 플러그인 | Action/Filter 훅 구독 시연 |
| **gnuboard7-hello_admin_template** | Admin 템플릿 | Basic 컴포넌트 최소 셋 |
| **gnuboard7-hello_user_template** | User 템플릿 | 홈 + Memo 리스트 연동 |
| Identifier | Type | Description |
| ---------- | ---- | ----------- |
| **gnuboard7-hello_module** | Module | Memo CRUD plus a hook publishing demo |
| **gnuboard7-hello_plugin** | Plugin | Action/filter hook subscription demo |
| **gnuboard7-hello_admin_template** | Admin template | A minimal set of basic components |
| **gnuboard7-hello_user_template** | User template | Home page plus a memo list integration |
---
## 비즈니스 모델
## Business Models
그누보드7 하나로 다양한 비즈니스를 운영할 수 있습니다.
One Gnuboard7 installation can run a range of businesses.
| 모델 | 설명 | 상태 |
|------|------|------|
| **커뮤니티** | 게시판, 댓글, 회원 관리 | 정식 |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | 정식 |
| Model | Description | Status |
|-------|-------------|--------|
| **Community** | Boards, comments, member management | Stable |
| **Commerce** | Product registration, orders, payments, shipping management | Stable |
---
## 기존 그누보드 사용자
## Migrating from Gnuboard 5
기존 그누보드5에서 그누보드7으로 전환할 수 있도록, 회원·게시글·상품 등 주요 데이터의 **마이그레이션 툴을 제공할 예정**입니다.
A migration tool from Gnuboard 5 is planned.
---
## 문서
## Documentation
| 문서 | 링크 |
|------|------|
| 설치 가이드 | [INSTALL.md](INSTALL.md) |
| 전체 문서 | [docs/README.md](docs/README.md) |
| 시스템 요구사항 | [docs/requirements.md](docs/requirements.md) |
| 백엔드 개발 | [docs/backend/README.md](docs/backend/README.md) |
| 프론트엔드 개발 | [docs/frontend/README.md](docs/frontend/README.md) |
| 데이터베이스 | [docs/database-guide.md](docs/database-guide.md) |
| 확장 시스템 | [docs/extension/README.md](docs/extension/README.md) |
| 모듈 개발 | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
| API 레퍼런스 | 준비 중 |
> Documentation is currently available in Korean only. English documentation is planned.
| Document | Link |
|----------|------|
| Installation guide | [INSTALL.md](INSTALL.md) |
| Full documentation | [docs/README.md](docs/README.md) |
| System requirements | [docs/requirements.md](docs/requirements.md) |
| Backend development | [docs/backend/README.md](docs/backend/README.md) |
| Frontend development | [docs/frontend/README.md](docs/frontend/README.md) |
| Database | [docs/database-guide.md](docs/database-guide.md) |
| Extension system | [docs/extension/README.md](docs/extension/README.md) |
| Module development | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
| Plugin development | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
| Template development | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
| Testing | [docs/testing-guide.md](docs/testing-guide.md) |
| API reference | [docs/backend/api/README.md](docs/backend/api/README.md) |
| API documentation policy | [docs/backend/api-documentation.md](docs/backend/api-documentation.md) |
---
## 기여하기
## Contributing
그누보드7은 오픈소스 프로젝트입니다. 모든 형태의 기여를 환영합니다.
Gnuboard7 is an open-source project, and contributions of every kind are welcome.
- 버그 리포트 및 기능 제안: [GitHub Issues](https://github.com/gnuboard/g7/issues)
- 코드 스타일: Laravel Pint (PSR-12)
- 테스트: PHPUnit (백엔드) + Vitest (프론트엔드)
- AI 협업: AI 에이전트용 개발 규칙 명세(AGENTS.md)와 MCP 디버깅 도구를 내장하고 있어, AI 도구와 자연스럽게 협업할 수 있습니다
- Bug reports and feature proposals: [GitHub Issues](https://github.com/gnuboard/g7/issues)
- Code style: Laravel Pint (PSR-12)
- Testing: PHPUnit (backend) + Vitest (frontend)
- AI collaboration: the repository ships a development rule specification for AI agents (AGENTS.md) along with MCP debugging tools, so AI tooling fits naturally into the workflow
---
## 만든 사람들
## Team
**[SIRSOFT](https://sir.kr)** 에서 개발하고 있습니다.
Developed by **[SIRSOFT](https://sir.kr)**.
### Core Team
@@ -489,37 +523,64 @@ cp .env.example .env
<a href="https://github.com/thisgun"><img src="https://github.com/thisgun.png" width="60" alt="thisgun"></a>
</p>
### Contributors
### Community Contributors
커뮤니티 기여자 목록은 [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors)에서 확인할 수 있습니다.
Thanks to everyone who reported an issue or suggested a feature that shipped — the list below is compiled from the attributions in our changelogs.
<!-- community-contributors:start -->
<p>
<a href="https://github.com/jiwonpapa" title="jiwonpapa"><img src="https://github.com/jiwonpapa.png" width="48" alt="jiwonpapa"></a>
<a href="https://github.com/Tuwasduliebst" title="Tuwasduliebst"><img src="https://github.com/Tuwasduliebst.png" width="48" alt="Tuwasduliebst"></a>
<a href="https://github.com/glitter-gim" title="glitter-gim"><img src="https://github.com/glitter-gim.png" width="48" alt="glitter-gim"></a>
<a href="https://github.com/jordy-bitree" title="jordy-bitree"><img src="https://github.com/jordy-bitree.png" width="48" alt="jordy-bitree"></a>
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/lyg-kaban" title="lyg-kaban"><img src="https://github.com/lyg-kaban.png" width="48" alt="lyg-kaban"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
<a href="https://github.com/ChoDongHyeon" title="ChoDongHyeon"><img src="https://github.com/ChoDongHyeon.png" width="48" alt="ChoDongHyeon"></a>
<a href="https://github.com/comtylove-netizen" title="comtylove-netizen"><img src="https://github.com/comtylove-netizen.png" width="48" alt="comtylove-netizen"></a>
<a href="https://github.com/devrhee16" title="devrhee16"><img src="https://github.com/devrhee16.png" width="48" alt="devrhee16"></a>
<a href="https://github.com/GyusoonKim" title="GyusoonKim"><img src="https://github.com/GyusoonKim.png" width="48" alt="GyusoonKim"></a>
<a href="https://github.com/Lastorder-DC" title="Lastorder-DC"><img src="https://github.com/Lastorder-DC.png" width="48" alt="Lastorder-DC"></a>
<a href="https://github.com/miles44229" title="miles44229"><img src="https://github.com/miles44229.png" width="48" alt="miles44229"></a>
<a href="https://github.com/minyho" title="minyho"><img src="https://github.com/minyho.png" width="48" alt="minyho"></a>
</p>
<!-- community-contributors:end -->
The list of code contributors is available on [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors).
---
## 커뮤니티
## Community
| 채널 | 링크 |
|------|------|
| Channel | Link |
|---------|------|
| GitHub | [github.com/gnuboard/g7](https://github.com/gnuboard/g7) |
| SIR 커뮤니티 | [sir.kr](https://sir.kr) |
| 문의 | minsup@sir.kr |
| SIR community (Korean) | [sir.kr](https://sir.kr) |
| Contact | minsup@sir.kr |
---
## 변경 기록
## Changelog
최근 변경된 사항에 대한 자세한 내용은 [CHANGELOG](CHANGELOG.md)를 참고해 주세요.
For details on recent changes, see the [CHANGELOG](CHANGELOG.md) (Korean).
---
## 보안 취약점
## Security Vulnerabilities
보안 취약점을 발견하셨다면 [SIR 문의게시판](https://sir.kr/boards/co_qa)에 비밀글로 제보해 주세요.
If you discover a security vulnerability, please report it as a private post on the [SIR inquiry board](https://sir.kr/boards/co_qa) (Korean board), or email minsup@sir.kr.
---
## 라이선스
## License
그누보드7은 [MIT 라이선스](LICENSE)에 따라 배포되는 오픈소스 소프트웨어입니다.
Gnuboard7 is open-source software distributed under the [MIT License](LICENSE).
Copyright (c) 2026 SIRSOFT
@@ -44,8 +44,8 @@ trait ResolvesActivityLogType
* properties.extension_origin 에 자동 주입합니다 (loggable 미지정 케이스의
* action 라벨 namespace fallback 용).
*
* @param string $action 액션명 (예: 'user.create')
* @param array $context Monolog context 배열
* @param string $action 액션명 (예: 'user.create')
* @param array $context Monolog context 배열
*/
protected function logActivity(string $action, array $context): void
{
@@ -58,9 +58,13 @@ trait ResolvesActivityLogType
$context['properties'] = $properties;
}
// 활동 로그는 부수 기록이다 — 실패해도 그것을 유발한 요청(결제 콜백 등)까지
// 죽여서는 안 된다. 채널 해석 실패는 Exception 이 아니라 Error 로 오므로
// (\Log::channel() 이 null 을 반환하면 "->info() on null" = \Error)
// Throwable 로 받아야 가드가 실제로 동작한다.
try {
Log::channel('activity')->info($action, $context);
} catch (\Exception $e) {
} catch (\Throwable $e) {
Log::error('Failed to record activity log', [
'action' => $action,
'error' => $e->getMessage(),
+141
View File
@@ -0,0 +1,141 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\QueryCollector;
use App\Enums\BenchmarkAxis;
use Illuminate\Support\Facades\Artisan;
use Symfony\Component\Console\Output\BufferedOutput;
/**
* 배치 축 실행기 — 배치 커맨드 소요 시간 + 피크 메모리
*
* 배치는 한 번에 대량을 처리하므로 "느려짐"이 시간보다 메모리로 먼저 드러납니다(청크
* 없이 전건 로딩 → OOM). 그래서 시간과 함께 피크 메모리를 함께 잽니다.
*
* 다른 축과 달리 트랜잭션으로 감싸지 않습니다 — 배치 커맨드는 내부에서 커밋하거나 DDL 을
* 실행할 수 있고, 대량 처리를 긴 트랜잭션에 담으면 락 보유 시간이 계측 자체보다 위험해집니다.
* 대신 데이터를 변경하는 배치는 `--allow-write` 없이는 실행하지 않습니다.
*/
class BatchAxisRunner implements BenchmarkAxisRunner
{
public function __construct(private readonly QueryCollector $collector) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::Batch;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
$notify = $onProgress ?? static fn (string $message) => null;
if ($profile->mutates() && ! $options->allowWrite) {
return BenchmarkResult::skipped($profile, '데이터를 변경하는 배치입니다. --allow-write 를 붙여 실행하세요.');
}
$command = (string) $profile->option('command');
$arguments = (array) $profile->option('arguments', []);
if (! array_key_exists($command, Artisan::all())) {
return BenchmarkResult::skipped($profile, "등록되지 않은 커맨드: {$command} (해당 확장이 설치되어 있는지 확인)");
}
$notify("배치 실행: {$command}");
// 피크 메모리는 프로세스 단위 누적값이라 감소하지 않는다. 실행 전 값을 함께
// 기록해야 "이 배치가 피크를 밀어올렸는지"를 판정할 수 있다.
$peakBefore = memory_get_peak_usage(true);
$usageBefore = memory_get_usage(true);
$buffer = new BufferedOutput;
$start = microtime(true);
try {
$collected = $this->collector->collect(
static fn () => Artisan::call($command, $arguments, $buffer)
);
$exitCode = (int) $collected['value'];
$queries = $collected['queries'];
} catch (\Throwable $e) {
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
}
$elapsedMs = (microtime(true) - $start) * 1000;
$peakAfter = memory_get_peak_usage(true);
$usageAfter = memory_get_usage(true);
$summary = $this->collector->summarize($queries);
$notes = [
sprintf('실행 전 피크 %s → 실행 후 피크 %s', $this->formatBytes($peakBefore), $this->formatBytes($peakAfter)),
];
if ($peakAfter <= $peakBefore) {
$notes[] = '이 배치가 프로세스 피크를 밀어올리지 않았습니다 (실행 전 피크가 이미 더 높음).';
}
if ($exitCode !== 0) {
$notes[] = "커맨드가 실패 종료했습니다 (exit={$exitCode}) — 시간·메모리는 실패 지점까지의 값입니다.";
}
$output = trim($buffer->fetch());
if ($output !== '') {
$notes[] = '커맨드 출력: '.$output;
}
return new BenchmarkResult(
profile: $profile,
headers: ['커맨드', '종료코드', '소요(ms)', '피크 메모리', '메모리 증가', '쿼리(건)'],
rows: [[
$command,
(string) $exitCode,
number_format($elapsedMs, 1),
$this->formatBytes($peakAfter),
$this->formatBytes(max(0, $usageAfter - $usageBefore)),
number_format($summary['count']),
]],
metrics: [
'command' => $command,
'arguments' => $arguments,
'exit_code' => $exitCode,
'elapsed_ms' => round($elapsedMs, 2),
'peak_memory_before_bytes' => $peakBefore,
'peak_memory_after_bytes' => $peakAfter,
'memory_delta_bytes' => $usageAfter - $usageBefore,
'query_count' => $summary['count'],
'db_ms' => $summary['db_ms'],
],
notes: $notes,
);
}
/**
* 바이트를 사람이 읽는 단위로 바꿉니다.
*
* @param int $bytes 바이트
* @return string 포맷된 문자열
*/
private function formatBytes(int $bytes): string
{
if ($bytes >= 1024 ** 3) {
return number_format($bytes / 1024 ** 3, 2).' GB';
}
if ($bytes >= 1024 ** 2) {
return number_format($bytes / 1024 ** 2, 1).' MB';
}
return number_format($bytes / 1024, 1).' KB';
}
}
+350
View File
@@ -0,0 +1,350 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\SyntheticSeeder;
use App\Enums\BenchmarkAxis;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 목록 조회 축 실행기 — 깊은 OFFSET 비용을 컬럼 폭 3축으로 계측
*
* 목록 조회는 OFFSET 이 커질수록 느려지는데, 그 원인이 "건너뛸 행의 넓은 컬럼까지 읽기"
* 인지 확인하려면 같은 필터·정렬로 (1) 전체 컬럼 (2) 실제 목록 컬럼 (3) 키 컬럼만 조회한
* 비용을 나란히 재야 합니다. 셋째 축이 지연 조인(`PaginatesWithDeferredJoin`)의 inner
* 쿼리에 해당하므로, 이 세 값의 배수가 곧 지연 조인 적용의 기대 효과입니다.
*/
class ListAxisRunner implements BenchmarkAxisRunner
{
/**
* 목록 1페이지 상한 (OFFSET 스캔 비용 대비 무시할 수준이라 고정)
*/
private const PAGE_LIMIT = 20;
/**
* 필터 선언에 쓸 수 있는 연산자 (닫힌 집합)
*
* @var array<int, string>
*/
private const FILTER_OPERATORS = ['=', '!=', '<>', '<', '<=', '>', '>=', 'like', 'in', 'not in'];
public function __construct(private readonly SyntheticSeeder $seeder) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::ListQuery;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
$notify = $onProgress ?? static fn (string $message) => null;
$table = (string) $profile->option('table');
if (! Schema::hasTable($table)) {
return BenchmarkResult::skipped($profile, "테이블이 없습니다: {$table} (해당 확장이 설치되어 있는지 확인)");
}
if (($options->fresh || $options->seed > 0) && app()->environment('production')) {
return BenchmarkResult::skipped($profile, '운영 환경에서는 시딩/비움을 사용할 수 없습니다.');
}
$filterError = $this->validateFilters((array) $profile->option('filters', []));
if ($filterError !== null) {
return BenchmarkResult::skipped($profile, $filterError);
}
if ($options->fresh) {
$this->seeder->truncate($table);
$notify("비움: {$table}");
}
$columns = $this->existingColumns($table, (array) $profile->option('columns', ['*']));
$order = $this->existingOrder($table, (array) $profile->option('order', [['id', 'desc']]));
if ($options->seed > 0) {
$notify("시딩: {$table} ".number_format($options->seed).' 건');
$this->seeder->seed(
$table,
$options->seed,
(array) $profile->option('seed_overrides', []),
fn (int $inserted, int $total) => $notify(sprintf(' 시딩 %s / %s', number_format($inserted), number_format($total))),
// 정렬 기준 컬럼은 nullable 이어도 채운다 — 비워두면 합성 행이 전부 동률이 되어
// 계측이 정렬 인덱스가 아니라 tie-break 경로를 잰다.
array_column($order, 0),
);
}
$rows = [];
$notes = [];
foreach ($options->offsets as $offset) {
$allMs = $this->measure($profile, ['*'], $order, $offset, $options->runs);
$listMs = $this->measure($profile, $columns, $order, $offset, $options->runs);
$idOnlyMs = $this->measure($profile, ['id'], $order, $offset, $options->runs);
$rows[] = [
'offset' => $offset,
'all_ms' => $allMs,
'list_ms' => $listMs,
'id_only_ms' => $idOnlyMs,
'ratio' => $idOnlyMs > 0 ? round($allMs / $idOnlyMs, 1) : null,
];
if ($options->explain) {
$notes[] = "EXPLAIN @ OFFSET {$offset} — 목록 컬럼";
foreach ($this->explain($profile, $columns, $order, $offset) as $line) {
$notes[] = ' '.$line;
}
// 지연 조인의 inner 가 실제로 어떤 계획을 타는지가 인덱스 설계의 근거다
$notes[] = "EXPLAIN @ OFFSET {$offset} — ID 만 (지연 조인 inner)";
foreach ($this->explain($profile, ['id'], $order, $offset) as $line) {
$notes[] = ' '.$line;
}
}
}
return new BenchmarkResult(
profile: $profile,
headers: ['OFFSET', '전체 컬럼(ms)', '목록 컬럼(ms)', 'ID만(ms)', '전체÷ID'],
rows: array_map(fn (array $row) => [
number_format($row['offset']),
number_format($row['all_ms'], 1),
number_format($row['list_ms'], 1),
number_format($row['id_only_ms'], 1),
$row['ratio'] !== null ? $row['ratio'].'×' : '-',
], $rows),
metrics: [
'table' => $table,
'columns' => $columns,
'runs' => $options->runs,
'offsets' => $rows,
],
notes: $notes,
);
}
/**
* 스키마에 실제로 존재하는 컬럼만 남깁니다.
*
* `['*']` 는 "목록이 전 컬럼을 그대로 노출한다" 는 선언입니다. 응답 계약상 넓은 컬럼을
* 뺄 수 없는 목록(활동 로그의 changes, 알림 발송 이력의 body 등)이 여기 해당하며, 이
* 경우 계측의 비교 축은 select * vs select id 가 됩니다. 확장 버전에 따라 컬럼 구성이
* 달라도 계측이 죽지 않도록 스키마에 없는 컬럼은 걸러냅니다.
*
* @param string $table 테이블명
* @param array<int, string> $columns 후보 컬럼
* @return array<int, string> 존재하는 컬럼 목록
*/
private function existingColumns(string $table, array $columns): array
{
if ($columns === ['*']) {
return Schema::getColumnListing($table);
}
$existing = array_values(array_filter(
$columns,
fn (mixed $column) => is_string($column) && Schema::hasColumn($table, $column)
));
return $existing === [] ? ['id'] : $existing;
}
/**
* 스키마에 존재하는 정렬 컬럼만 남깁니다.
*
* @param string $table 테이블명
* @param array<int, array{0: string, 1: string}> $order 후보 정렬
* @return array<int, array{0: string, 1: string}> 적용 가능한 정렬 목록
*/
private function existingOrder(string $table, array $order): array
{
$existing = array_values(array_filter(
$order,
fn (mixed $spec) => is_array($spec) && isset($spec[0]) && Schema::hasColumn($table, (string) $spec[0])
));
return $existing === [] ? [['id', 'desc']] : $existing;
}
/**
* 한 조합을 여러 번 실행해 중앙값(ms)을 돌려줍니다.
*
* `DB::enableQueryLog()` 는 오버헤드와 메모리 누적이 있어 쓰지 않고 직접 시간을 잽니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param array<int, string> $columns select 컬럼
* @param array<int, array{0: string, 1: string}> $order 정렬
* @param int $offset OFFSET
* @param int $runs 측정 횟수
* @return float 중앙값 (밀리초)
*/
private function measure(BenchmarkProfile $profile, array $columns, array $order, int $offset, int $runs): float
{
// 첫 회는 캐시 워밍 성격이라 버린다
$this->buildQuery($profile, $columns, $order, $offset)->get();
$samples = [];
for ($i = 0; $i < max(1, $runs); $i++) {
$start = microtime(true);
$this->buildQuery($profile, $columns, $order, $offset)->get();
$samples[] = (microtime(true) - $start) * 1000;
}
sort($samples);
return $samples[intdiv(count($samples), 2)];
}
/**
* 계측 대상 쿼리를 조립합니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param array<int, string> $columns select 컬럼
* @param array<int, array{0: string, 1: string}> $order 정렬
* @param int $offset OFFSET
* @return Builder 조립된 쿼리
*/
private function buildQuery(BenchmarkProfile $profile, array $columns, array $order, int $offset): Builder
{
$table = (string) $profile->option('table');
$query = DB::table($table)->select($columns);
if ($profile->option('soft_delete', false) && Schema::hasColumn($table, 'deleted_at')) {
$query->whereNull('deleted_at');
}
$this->applyFilters($query, $table, (array) $profile->option('filters', []));
foreach ($order as $spec) {
$query->orderBy($spec[0], $spec[1] ?? 'asc');
}
return $query->offset($offset)->limit(self::PAGE_LIMIT);
}
/**
* 선언된 필터를 계측 쿼리에 적용합니다.
*
* 값이 `[연산자, 값]` 형태면 그 연산자로, 그 밖에는 등가 비교로 해석합니다. 등가 비교만
* 지원하면 화면이 실제로 거는 필터를 선언할 방법이 없는 목록이 생깁니다 — 주문 목록은
* 상태 미지정 시 임시 주문 상태를 `NOT IN` 으로 제외하므로, 그것을 선언하지 못하면
* 계측이 화면과 다른 인덱스를 타게 됩니다.
*
* 연산자는 닫힌 집합으로 해석합니다. 임의 문자열을 그대로 넘기면 선언 오타가 조용히
* 통과하거나 의도치 않은 SQL 이 조립됩니다.
*
* @param Builder $query 계측 쿼리
* @param string $table 대상 테이블
* @param array<string, mixed> $filters 선언된 필터
*/
private function applyFilters(Builder $query, string $table, array $filters): void
{
foreach ($filters as $column => $declared) {
if (! Schema::hasColumn($table, (string) $column)) {
continue;
}
// [연산자, 값] 형태가 아니면 등가 비교 (배열 값을 IN 으로 오해석하지 않도록 형태로 판정)
if (! is_array($declared) || count($declared) !== 2 || ! is_string($declared[0])) {
$query->where($column, $declared);
continue;
}
[$operator, $value] = $declared;
match (strtolower($operator)) {
'=' => $query->where($column, '=', $value),
'!=', '<>' => $query->where($column, '!=', $value),
'<' => $query->where($column, '<', $value),
'<=' => $query->where($column, '<=', $value),
'>' => $query->where($column, '>', $value),
'>=' => $query->where($column, '>=', $value),
'like' => $query->where($column, 'like', $value),
'in' => $query->whereIn($column, (array) $value),
'not in' => $query->whereNotIn($column, (array) $value),
// 도달 불가 — run() 이 실행 전에 연산자를 검증해 거부한다
default => null,
};
}
}
/**
* 선언된 필터의 연산자를 실행 전에 검증합니다.
*
* 알 수 없는 연산자를 조용히 무시하면 그 필터가 빠진 채로 측정되어, 화면과 다른 것을
* 재면서도 정상 측정으로 보고됩니다. 실행 전에 사유와 함께 거부합니다.
*
* @param array<string, mixed> $filters 선언된 필터
* @return string|null 실패 사유 (문제 없으면 null)
*/
private function validateFilters(array $filters): ?string
{
foreach ($filters as $column => $declared) {
if (! is_array($declared) || count($declared) !== 2 || ! is_string($declared[0])) {
continue;
}
if (! in_array(strtolower($declared[0]), self::FILTER_OPERATORS, true)) {
return sprintf(
'필터 %s 의 연산자를 알 수 없습니다: %s (허용: %s)',
$column,
$declared[0],
implode(', ', self::FILTER_OPERATORS)
);
}
}
return null;
}
/**
* 실행 계획을 수집합니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param array<int, string> $columns select 컬럼
* @param array<int, array{0: string, 1: string}> $order 정렬
* @param int $offset OFFSET
* @return array<int, string> 실행 계획 요약 줄 목록
*/
private function explain(BenchmarkProfile $profile, array $columns, array $order, int $offset): array
{
$query = $this->buildQuery($profile, $columns, $order, $offset);
try {
$plan = DB::select('EXPLAIN '.$query->toSql(), $query->getBindings());
} catch (\Throwable $e) {
return ['실행 계획 수집 실패: '.$e->getMessage()];
}
return array_map(function ($row) {
$row = (array) $row;
return sprintf(
'type=%s key=%s rows=%s filtered=%s extra=%s',
$row['type'] ?? '-',
$row['key'] ?? '-',
$row['rows'] ?? '-',
$row['filtered'] ?? '-',
$row['Extra'] ?? '-'
);
}, $plan);
}
}
+261
View File
@@ -0,0 +1,261 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\BenchmarkIdentity;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\QueryCollector;
use App\Enums\BenchmarkAxis;
use Illuminate\Contracts\Http\Kernel as HttpKernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;
/**
* 화면 응답 축 실행기 — 화면 1장의 응답 시간 + 실행 쿼리 건수 + N+1 후보
*
* 사용자가 체감하는 단위가 "화면 한 장"이므로 네 축 중 값어치가 가장 큽니다. 목록 SELECT
* 는 빠른데 화면이 느린 경우(관계 지연 로딩으로 인한 N+1, 권한 조회 반복 등)를 잡는 것이
* 이 축의 목적입니다.
*
* 실행 방식은 라우트를 **HTTP 커널로 내부 요청** 처리하는 것입니다. `Route::dispatch` 로
* 라우트만 때리면 전역 미들웨어(인증·권한·로케일·타임존)를 건너뛰어 화면과 다른 것을 재게
* 됩니다. 커널을 쓰면 실제 요청과 같은 경로를 지납니다.
*
* 계측 계정 생성과 요청 처리 전체를 롤백되는 트랜잭션으로 감싸므로, 계측이 데이터에 흔적을
* 남기지 않습니다(`BenchmarkIdentity::withRolledBackTransaction`).
*/
class ScreenAxisRunner implements BenchmarkAxisRunner
{
public function __construct(
private readonly BenchmarkIdentity $identity,
private readonly QueryCollector $collector,
) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::Screen;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
if ($profile->mutates() && ! $options->allowWrite) {
return BenchmarkResult::skipped($profile, '데이터를 변경하는 화면입니다. --allow-write 를 붙여 실행하세요.');
}
[$uri, $uriError] = $this->resolveUri($profile);
if ($uriError !== null) {
return BenchmarkResult::skipped($profile, $uriError);
}
// `--as` 로 지정한 계정은 트랜잭션 진입 전에 확인한다 — 없는 계정으로 계측을 시작하면
// 롤백 구간 안에서 실패해 사유만 흐려진다.
if ($options->asUser !== null && $this->identity->findExistingUser($options->asUser) === null) {
return BenchmarkResult::skipped($profile, "계측에 사용할 계정을 찾을 수 없습니다: {$options->asUser}");
}
$method = strtoupper((string) $profile->option('method', 'GET'));
$query = (array) $profile->option('query', []);
$query['per_page'] ??= $options->perPage;
// 데이터를 변경하는 화면은 반복 측정 시 2회차부터 조건이 달라진다(중복 키, 재고 소진).
// 롤백은 계측 종료 시 한 번이므로 회차 간에는 되돌려지지 않는다 → 1회만 잰다.
$runs = $profile->mutates() ? 1 : max(1, $options->runs);
$notes = [];
if ($profile->mutates()) {
$notes[] = '데이터 변경 화면이라 1회만 측정했습니다 (회차 간 조건 변화 방지).';
}
try {
$measured = $this->identity->withRolledBackTransaction(
fn () => $this->measure($profile, (string) $uri, $method, $query, $runs, $options, $onProgress)
);
} catch (\Throwable $e) {
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
}
$summary = $measured['summary'];
foreach ($summary['n_plus_one'] as $candidate) {
$notes[] = sprintf('N+1 후보 %d회: %s', $candidate['count'], $candidate['sql']);
}
if ($summary['n_plus_one'] === []) {
$notes[] = 'N+1 후보 없음 (같은 SQL 5회 이상 반복 없음).';
}
return new BenchmarkResult(
profile: $profile,
headers: ['요청', '상태', '응답(ms)', '쿼리(건)', 'DB(ms)'],
rows: [[
$method.' '.$uri,
(string) $measured['status'],
number_format($measured['total_ms'], 1),
number_format($summary['count']),
number_format($summary['db_ms'], 1),
]],
metrics: [
'uri' => $uri,
'method' => $method,
'query' => $query,
'status' => $measured['status'],
'runs' => $runs,
'total_ms' => $measured['total_ms'],
'query_count' => $summary['count'],
'db_ms' => $summary['db_ms'],
'n_plus_one' => $summary['n_plus_one'],
'acting_user' => $measured['acting_user'],
],
notes: $notes,
);
}
/**
* 계측 대상 URI 를 해석합니다.
*
* 라우트명 선언을 권장하는 이유는 두 가지입니다 — 프리픽스를 문자열로 조립하지 않아도
* 되고, 라우트가 사라지면 계측이 조용히 404 를 재는 대신 사유를 남기고 건너뜁니다.
*
* 예외를 던지지 않고 사유 문자열을 함께 돌려주는 이유는, 이 축의 실패 계약이
* `BenchmarkResult::skipped` 이기 때문입니다 — 던지고 곧바로 잡는 우회로를 만들지 않습니다.
*
* @param BenchmarkProfile $profile 프로파일
* @return array{0: string|null, 1: string|null} [경로, 실패 사유]
*/
private function resolveUri(BenchmarkProfile $profile): array
{
$routeName = $profile->option('route');
if (is_string($routeName) && $routeName !== '') {
if (Route::getRoutes()->getByName($routeName) === null) {
return [null, "등록되지 않은 라우트명: {$routeName} (해당 확장이 설치되어 있는지 확인)"];
}
return [route($routeName, (array) $profile->option('route_params', []), false), null];
}
return ['/'.ltrim((string) $profile->option('uri'), '/'), null];
}
/**
* 내부 요청을 반복 실행해 응답 시간 중앙값과 쿼리 요약을 냅니다.
*
* @param BenchmarkProfile $profile 프로파일
* @param string $uri 경로
* @param string $method HTTP 메서드
* @param array<string, mixed> $query 쿼리스트링
* @param int $runs 측정 횟수
* @param BenchmarkRunOptions $options 실행 옵션
* @param \Closure|null $onProgress 진행 콜백
* @return array{status: int, total_ms: float, summary: array<string, mixed>, acting_user: array<string, mixed>} 계측 결과
*/
private function measure(
BenchmarkProfile $profile,
string $uri,
string $method,
array $query,
int $runs,
BenchmarkRunOptions $options,
?\Closure $onProgress,
): array {
$notify = $onProgress ?? static fn (string $message) => null;
$issued = $this->identity->issueToken(
(array) $profile->option('permissions', []),
$options->asUser !== null ? $this->identity->findExistingUser($options->asUser) : null
);
$notify(sprintf(
'인증: %s (%s)',
$issued['user']->email,
$issued['ephemeral'] ? '계측용 임시 계정 — 종료 시 롤백' : '지정 계정'
));
$samples = [];
$status = 0;
$queries = [];
// 첫 회는 라우트 매칭·컨테이너 해석 워밍이라 버린다 (측정 회차와 별도로 1회 더 실행)
for ($i = 0; $i <= $runs; $i++) {
$collected = $this->collector->collect(
fn () => $this->dispatch($uri, $method, $query, $issued['token'])
);
/** @var array{status: int, ms: float} $outcome */
$outcome = $collected['value'];
$status = $outcome['status'];
if ($i === 0) {
continue;
}
$samples[] = $outcome['ms'];
$queries = $collected['queries'];
}
sort($samples);
return [
'status' => $status,
'total_ms' => $samples[intdiv(count($samples), 2)],
'summary' => $this->collector->summarize($queries),
'acting_user' => [
'id' => $issued['user']->id,
'email' => $issued['user']->email,
'ephemeral' => $issued['ephemeral'],
],
];
}
/**
* 내부 요청 1회를 처리하고 소요 시간을 잽니다.
*
* 커널이 컨테이너의 `request` 인스턴스와 인증 가드 상태를 갈아치우므로, 처리 후
* 원상 복구합니다 — 복구하지 않으면 이어지는 회차/프로파일이 앞 요청의 인증 상태를
* 물려받아 계측이 서로 오염됩니다.
*
* @param string $uri 경로
* @param string $method HTTP 메서드
* @param array<string, mixed> $query 쿼리스트링/바디
* @param string $token Sanctum 토큰
* @return array{status: int, ms: float} 상태 코드와 소요 시간
*/
private function dispatch(string $uri, string $method, array $query, string $token): array
{
$previousRequest = app()->bound('request') ? app('request') : null;
$request = Request::create($uri, $method, $query);
$request->headers->set('Accept', 'application/json');
$request->headers->set('Authorization', 'Bearer '.$token);
$start = microtime(true);
try {
$response = app(HttpKernel::class)->handle($request);
$status = $response->getStatusCode();
// 스트리밍 응답은 본문 생성까지가 사용자 체감 시간이라 여기서 소비한다
$response->getContent();
} finally {
$ms = (microtime(true) - $start) * 1000;
Auth::forgetGuards();
if ($previousRequest !== null) {
app()->instance('request', $previousRequest);
}
}
return ['status' => $status, 'ms' => $ms];
}
}
+227
View File
@@ -0,0 +1,227 @@
<?php
namespace App\Benchmark\Axes;
use App\Benchmark\BenchmarkIdentity;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Benchmark\QueryCollector;
use App\Enums\BenchmarkAxis;
/**
* 쓰기 축 실행기 — 저장 경로 1회 소요 시간
*
* 목록 조회와 달리 저장 경로는 커맨드가 스스로 조립할 수 없습니다(주문 생성 하나에 재고·
* 쿠폰·마일리지·알림이 얽힘). 그래서 실행 자체는 소유 확장이 선언한 콜백에 맡기고, 이
* 실행기는 시간·쿼리 건수 계측과 안전장치만 담당합니다.
*
* 콜백은 `'Fqcn'`(invokable) 또는 `['Fqcn', 'method']` 형식만 허용합니다 — 코어 선언은
* `config/benchmark.php` 에 있고 이 파일은 `config:cache` 대상이라 클로저를 담을 수 없기
* 때문입니다. 확장 선언도 같은 스키마를 쓰므로 형식을 통일합니다.
*
* 선언 필드는 세 개입니다 — `prepare`(계측 제외 선행 준비, 회차마다 실행), `callback`
* (계측 대상, prepare 반환값을 인자로 받음), `cleanup`(트랜잭션이 되돌리지 못하는 잔여물 정리).
*
* 계측으로 생긴 행은 롤백되는 트랜잭션으로 되돌립니다. 트랜잭션이 되돌리지 못하는 것
* (파일·캐시·외부 호출)은 프로파일이 선언한 `cleanup` 콜백이 정리합니다.
*/
class WriteAxisRunner implements BenchmarkAxisRunner
{
public function __construct(
private readonly BenchmarkIdentity $identity,
private readonly QueryCollector $collector,
) {}
/**
* {@inheritDoc}
*/
public function axis(): BenchmarkAxis
{
return BenchmarkAxis::Write;
}
/**
* {@inheritDoc}
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult
{
$notify = $onProgress ?? static fn (string $message) => null;
if (! $options->allowWrite) {
return BenchmarkResult::skipped($profile, '쓰기 축입니다. --allow-write 를 붙여 실행하세요.');
}
$resolved = [];
foreach (['callback', 'prepare', 'cleanup'] as $field) {
if ($field !== 'callback' && $profile->option($field) === null) {
$resolved[$field] = null;
continue;
}
[$callable, $error] = $this->resolveCallable($profile->option($field), $profile, $field);
if ($error !== null) {
return BenchmarkResult::skipped($profile, $error);
}
$resolved[$field] = $callable;
}
$callback = $resolved['callback'];
$prepare = $resolved['prepare'];
$cleanup = $resolved['cleanup'];
$runs = max(1, $options->runs);
try {
$measured = $this->identity->withRolledBackTransaction(
fn () => $this->measure($callback, $prepare, $runs, $onProgress)
);
} catch (\Throwable $e) {
return BenchmarkResult::skipped($profile, '계측 실패: '.$e->getMessage());
} finally {
if ($cleanup !== null) {
// 트랜잭션 롤백으로 되돌지 않는 잔여물(파일·캐시)을 정리한다.
// 정리 실패가 계측 결과를 삼키면 안 되므로 사유만 알린다.
try {
$cleanup();
} catch (\Throwable $e) {
$notify('cleanup 실패: '.$e->getMessage());
}
}
}
$summary = $this->collector->summarize($measured['queries']);
return new BenchmarkResult(
profile: $profile,
headers: ['첫 회(ms)', '중앙값(ms)', '회차', '쿼리(건)', 'DB(ms)'],
rows: [[
number_format($measured['first_ms'], 1),
number_format($measured['median_ms'], 1),
(string) $runs,
number_format($summary['count']),
number_format($summary['db_ms'], 1),
]],
metrics: [
'first_ms' => $measured['first_ms'],
'median_ms' => $measured['median_ms'],
'runs' => $runs,
'samples_ms' => $measured['samples'],
'query_count' => $summary['count'],
'db_ms' => $summary['db_ms'],
'n_plus_one' => $summary['n_plus_one'],
],
notes: array_merge(
['계측으로 생긴 행은 롤백했습니다.'],
array_map(
fn (array $candidate) => sprintf('반복 쿼리 %d회: %s', $candidate['count'], $candidate['sql']),
$summary['n_plus_one']
)
),
);
}
/**
* 선언된 콜백을 호출 가능한 형태로 해석합니다.
*
* 예외를 던지지 않고 사유 문자열을 함께 돌려주는 이유는, 이 축의 실패 계약이
* `BenchmarkResult::skipped` 이기 때문입니다 — 던지고 곧바로 잡는 우회로를 만들지 않습니다.
*
* @param mixed $declared 선언값
* @param BenchmarkProfile $profile 프로파일 (오류 메시지용)
* @param string $field 선언 필드명 (오류 메시지용)
* @return array{0: callable|null, 1: string|null} [해석된 콜백, 실패 사유]
*/
private function resolveCallable(mixed $declared, BenchmarkProfile $profile, string $field): array
{
$where = $profile->qualifiedKey().' 의 '.$field;
if ($declared instanceof \Closure) {
return [null, "{$where}: 클로저는 쓸 수 없습니다 (config:cache 불가). 'Fqcn' 또는 ['Fqcn', 'method'] 형식으로 선언하세요."];
}
if (is_string($declared)) {
if (! class_exists($declared)) {
return [null, "{$where}: 클래스를 찾을 수 없습니다 — {$declared}"];
}
$instance = app($declared);
if (! is_callable($instance)) {
return [null, "{$where}: {$declared} 에 __invoke() 가 없습니다."];
}
return [$instance, null];
}
if (is_array($declared) && count($declared) === 2 && is_string($declared[0]) && is_string($declared[1])) {
[$class, $method] = $declared;
if (! class_exists($class)) {
return [null, "{$where}: 클래스를 찾을 수 없습니다 — {$class}"];
}
$instance = app($class);
if (! method_exists($instance, $method)) {
return [null, "{$where}: {$class}::{$method}() 가 없습니다."];
}
return [[$instance, $method], null];
}
return [null, "{$where}: 'Fqcn' 또는 ['Fqcn', 'method'] 형식이어야 합니다."];
}
/**
* 콜백을 반복 실행해 첫 회 시간과 중앙값을 냅니다.
*
* 첫 회를 버리지 않고 따로 보고하는 이유는, 저장 경로에서는 첫 회가 포함하는 비용
* (클래스 로딩, 설정 해석, 관계 초기 조회)이 실사용에서도 발생하기 때문입니다.
* 쿼리 요약은 마지막 회차 기준입니다 — 첫 회는 위 초기화 쿼리가 섞입니다.
*
* `prepare` 는 계측 구간 **밖**에서 회차마다 실행합니다. 저장 경로에는 선행 상태가
* 필요한 경우가 많고(주문 생성에는 임시 주문이 필요하고 임시 주문은 1회만 전환됨),
* 그 준비 비용이 측정값에 섞이면 재려던 것을 재지 못하게 됩니다.
*
* @param callable $callback 저장 경로 콜백 (prepare 반환값을 인자로 받음)
* @param callable|null $prepare 회차별 선행 준비 콜백 (계측 제외, 회차 번호를 인자로 받음)
* @param int $runs 측정 횟수
* @param \Closure|null $onProgress 진행 콜백
* @return array{first_ms: float, median_ms: float, samples: array<int, float>, queries: array<int, array{sql: string, time: float}>} 계측 결과
*/
private function measure(callable $callback, ?callable $prepare, int $runs, ?\Closure $onProgress): array
{
$notify = $onProgress ?? static fn (string $message) => null;
$samples = [];
$queries = [];
for ($i = 1; $i <= $runs; $i++) {
$notify("쓰기 계측 {$i} / {$runs}");
$context = $prepare !== null ? $prepare($i) : null;
$start = microtime(true);
$collected = $this->collector->collect(static function () use ($callback, $context) {
$callback($context);
});
$samples[] = (microtime(true) - $start) * 1000;
$queries = $collected['queries'];
}
$sorted = $samples;
sort($sorted);
return [
'first_ms' => $samples[0],
'median_ms' => $sorted[intdiv(count($sorted), 2)],
'samples' => $samples,
'queries' => $queries,
];
}
}
+144
View File
@@ -0,0 +1,144 @@
<?php
namespace App\Benchmark;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
/**
* 화면 계측용 인증 주체 해석기
*
* 관리자 화면의 응답 시간은 로그인 상태에서만 잴 수 있습니다. 두 방식을 지원합니다.
*
* 기본 — 프로파일이 선언한 권한만 가진 임시 관리자를 즉석 생성해 Sanctum 토큰 발급
* `--as` — 기존 계정을 지정 (그 계정의 실제 역할/권한으로 재측정)
*
* 두 경로 모두 계정/역할/토큰 생성이라는 **쓰기**를 동반하므로, 호출자가 열어둔 트랜잭션
* 안에서 수행하고 계측 후 롤백해 흔적을 남기지 않습니다(`withRolledBackTransaction`).
* 트랜잭션이 열려 있으면 Laravel 이 읽기 쿼리도 write PDO 로 보내므로(`Connection::getReadPdo`
* 의 `transactions > 0` 분기), 읽기/쓰기 분리 환경에서도 계측 요청이 이 계정을 인증할 수
* 있습니다. 운영 DB 에서도 잔여 계정이 생기지 않는 이유가 이 롤백입니다.
*/
class BenchmarkIdentity
{
/**
* 롤백되는 트랜잭션 안에서 콜백을 실행합니다.
*
* 계측이 만든 계정·토큰과, 계측 대상이 만든 행까지 함께 되돌립니다. 쓰기 축의 결과를
* 되돌리는 것도 같은 장치를 씁니다.
*
* @param \Closure $callback 트랜잭션 안에서 실행할 작업
* @return mixed 콜백 반환값
*/
public function withRolledBackTransaction(\Closure $callback): mixed
{
DB::beginTransaction();
try {
return $callback();
} finally {
// 계측 결과 산출 여부와 무관하게 되돌린다 — 예외로 빠져나가도 잔여 데이터 없음
DB::rollBack();
}
}
/**
* 계측에 사용할 Sanctum 토큰을 발급합니다.
*
* 호출 전에 `--as` 계정 존재를 `findExistingUser()` 로 확인해야 합니다 — 계정 부재는
* 계측 실패가 아니라 실행 전 판정 대상이라 이 메서드는 그 경우를 다루지 않습니다.
*
* @param array<int, string> $permissions 임시 계정에 부여할 권한 식별자 목록
* @param User|null $actor `--as` 로 확인된 기존 계정 (null 이면 임시 계정 생성)
* @return array{token: string, user: User, ephemeral: bool} 토큰과 인증 주체
*/
public function issueToken(array $permissions = [], ?User $actor = null): array
{
$user = $actor ?? $this->makeEphemeralAdmin($permissions);
return [
'token' => $user->createToken('g7-bench-'.Str::random(8))->plainTextToken,
'user' => $user,
'ephemeral' => $actor === null,
];
}
/**
* 지정된 기존 계정을 찾습니다.
*
* @param string $identifier 계정 ID 또는 이메일
* @return User|null 찾은 계정 (없으면 null)
*/
public function findExistingUser(string $identifier): ?User
{
return ctype_digit($identifier)
? User::find((int) $identifier)
: User::where('email', $identifier)->first();
}
/**
* 선언된 권한만 가진 임시 관리자를 생성합니다.
*
* 권한을 프로파일 선언에서 받는 이유는, 계측 대상 화면이 요구하는 권한만 주어야
* 미들웨어 통과 여부까지 실제와 같아지기 때문입니다(전권 계정으로 재면 권한 검사
* 비용과 분기가 달라집니다).
*
* @param array<int, string> $permissions 권한 식별자 목록
* @return User 생성된 임시 관리자
*/
private function makeEphemeralAdmin(array $permissions): User
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
$identifier = (string) $identifier;
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
);
$permissionIds[] = $permission->id;
}
$benchRole = Role::create([
'identifier' => 'g7_bench_'.Str::random(8),
'name' => json_encode(['ko' => '성능 계측 전용', 'en' => 'Benchmark Only']),
'description' => json_encode(['ko' => '성능 계측 임시 역할', 'en' => 'Temporary benchmark role']),
'is_active' => true,
]);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
if ($permissionIds !== []) {
$benchRole->permissions()->sync($permissionIds);
}
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$user->roles()->attach($benchRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
}
}
+275
View File
@@ -0,0 +1,275 @@
<?php
namespace App\Benchmark;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Enums\BenchmarkAxis;
/**
* 계측 프로파일 레지스트리 — 코어 config + 활성 확장 선언 수집
*
* 계측 대상은 "지금 이 설치본에 실제로 존재하는 것"이어야 하므로, 커맨드에 대상을
* 하드코딩하지 않고 소유자가 선언한 것을 런타임에 모읍니다. 코어는
* `config/benchmark.php`, 확장은 `getBenchmarkProfiles()` 오버라이드가 선언 지점입니다
* (채널·알림 정의 등 기존 확장 선언 훅과 동일한 수집 모델).
*
* 확장 선언은 격리 호출합니다 — 확장 하나의 잘못된 선언이 전체 목록을 날리면 계측 자체를
* 못 하게 되므로, 실패한 선언만 사유와 함께 `warnings()` 로 드러내고 나머지는 살립니다.
* 조용히 버리지는 않습니다(버려진 선언이 곧 계측 사각이 됩니다).
*/
class BenchmarkProfileRegistry
{
/**
* 수집된 프로파일 (정규화 키 → 프로파일)
*
* @var array<string, BenchmarkProfile>|null
*/
private ?array $profiles = null;
/**
* 수집 중 발생한 선언 오류 메시지
*
* @var array<int, string>
*/
private array $warnings = [];
public function __construct(
private readonly ModuleManagerInterface $moduleManager,
private readonly PluginManagerInterface $pluginManager,
) {}
/**
* 모든 프로파일을 정규화 키 기준으로 반환합니다.
*
* @return array<string, BenchmarkProfile> 정규화 키 → 프로파일
*/
public function all(): array
{
if ($this->profiles !== null) {
return $this->profiles;
}
$profiles = [];
foreach ($this->collectCore() as $profile) {
$profiles[$profile->qualifiedKey()] = $profile;
}
foreach ($this->collectExtensions() as $profile) {
$profiles[$profile->qualifiedKey()] = $profile;
}
return $this->profiles = $profiles;
}
/**
* 특정 축의 프로파일만 반환합니다.
*
* @param BenchmarkAxis $axis 대상 축
* @return array<string, BenchmarkProfile> 정규화 키 → 프로파일
*/
public function byAxis(BenchmarkAxis $axis): array
{
return array_filter($this->all(), fn (BenchmarkProfile $profile) => $profile->axis === $axis);
}
/**
* 키로 프로파일을 해석합니다.
*
* 정규화 키(`sirsoft-ecommerce/orders`)를 우선 대조하고, 없으면 짧은 키로 찾습니다.
* 짧은 키가 둘 이상의 확장에서 선언돼 모호하면 후보 목록을 사유로 돌려줍니다 —
* 임의로 하나를 고르면 어느 확장의 목록을 잰 것인지 알 수 없게 됩니다.
*
* @param string $key 프로파일 키 (정규화 키 또는 짧은 키)
* @return array{0: BenchmarkProfile|null, 1: string|null} [찾은 프로파일, 실패 사유]
*/
public function resolve(string $key): array
{
$profiles = $this->all();
if (isset($profiles[$key])) {
return [$profiles[$key], null];
}
$matches = array_filter($profiles, fn (BenchmarkProfile $profile) => $profile->key === $key);
if ($matches === []) {
return [null, "등록되지 않은 프로파일: {$key} (--list-profiles 로 목록 확인)"];
}
if (count($matches) > 1) {
$candidates = implode(', ', array_keys($matches));
return [null, "프로파일 키가 모호합니다: {$key} — 다음 중 하나로 지정하세요: {$candidates}"];
}
return [reset($matches), null];
}
/**
* 키로 프로파일을 찾습니다. (해석 실패 시 null)
*
* 사유가 필요하면 `resolve()` 를 씁니다.
*
* @param string $key 프로파일 키 (정규화 키 또는 짧은 키)
* @return BenchmarkProfile|null 찾은 프로파일
*/
public function find(string $key): ?BenchmarkProfile
{
return $this->resolve($key)[0];
}
/**
* 수집 중 무시된 선언의 사유 목록을 반환합니다.
*
* @return array<int, string> 경고 메시지
*/
public function warnings(): array
{
$this->all();
return $this->warnings;
}
/**
* 코어 프로파일을 수집합니다.
*
* @return array<int, BenchmarkProfile> 코어 프로파일 목록
*/
private function collectCore(): array
{
$declared = config('benchmark.profiles', []);
return $this->normalize(is_array($declared) ? $declared : [], 'core', 'core');
}
/**
* 활성 모듈/플러그인 프로파일을 수집합니다.
*
* @return array<int, BenchmarkProfile> 확장 프로파일 목록
*/
private function collectExtensions(): array
{
$profiles = [];
foreach ($this->moduleManager->getActiveModules() as $module) {
$profiles = array_merge(
$profiles,
$this->normalize($this->extract($module), 'module', $module->getIdentifier())
);
}
foreach ($this->pluginManager->getActivePlugins() as $plugin) {
$profiles = array_merge(
$profiles,
$this->normalize($this->extract($plugin), 'plugin', $plugin->getIdentifier())
);
}
return $profiles;
}
/**
* 확장의 `getBenchmarkProfiles()` 선언을 격리 호출로 읽습니다.
*
* @param object $extension 확장 인스턴스
* @return array<string, mixed> 선언 배열 (실패 시 빈 배열)
*/
private function extract(object $extension): array
{
if (! method_exists($extension, 'getBenchmarkProfiles')) {
return [];
}
try {
$declared = $extension->getBenchmarkProfiles();
} catch (\Throwable $e) {
$this->warnings[] = sprintf(
'%s: getBenchmarkProfiles() 호출 실패 — %s',
method_exists($extension, 'getIdentifier') ? $extension->getIdentifier() : $extension::class,
$e->getMessage()
);
return [];
}
return is_array($declared) ? $declared : [];
}
/**
* 선언 배열을 검증해 프로파일 객체로 정규화합니다.
*
* @param array<string, mixed> $declared 선언 배열 (키 → 정의)
* @param string $sourceKind 출처 종류 (core|module|plugin)
* @param string $sourceIdentifier 출처 식별자
* @return array<int, BenchmarkProfile> 정규화된 프로파일 목록
*/
private function normalize(array $declared, string $sourceKind, string $sourceIdentifier): array
{
$profiles = [];
foreach ($declared as $key => $definition) {
if (! is_string($key) || $key === '') {
$this->warnings[] = "{$sourceIdentifier}: 프로파일 키는 빈 문자열이 아닌 문자열이어야 합니다.";
continue;
}
if (! is_array($definition)) {
$this->warnings[] = "{$sourceIdentifier}/{$key}: 프로파일 정의는 배열이어야 합니다.";
continue;
}
$axis = BenchmarkAxis::tryFrom((string) ($definition['type'] ?? ''));
if ($axis === null) {
$this->warnings[] = sprintf(
'%s/%s: 알 수 없는 type — %s (허용: %s)',
$sourceIdentifier,
$key,
var_export($definition['type'] ?? null, true),
implode('|', BenchmarkAxis::values())
);
continue;
}
$missing = array_values(array_filter(
$axis->requiredOptions(),
fn (array $group) => array_filter(
$group,
fn (string $option) => isset($definition[$option])
) === []
));
if ($missing !== []) {
$this->warnings[] = sprintf(
'%s/%s: %s 축 필수 옵션 누락 — %s',
$sourceIdentifier,
$key,
$axis->value,
implode(', ', array_map(fn (array $group) => implode('|', $group), $missing))
);
continue;
}
$label = $definition['label'] ?? null;
unset($definition['type'], $definition['label']);
$profiles[] = new BenchmarkProfile(
key: $key,
axis: $axis,
sourceKind: $sourceKind,
sourceIdentifier: $sourceIdentifier,
options: $definition,
label: is_string($label) ? $label : null,
);
}
return $profiles;
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace App\Benchmark;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Enums\BenchmarkAxis;
use Illuminate\Support\Facades\DB;
/**
* 계측 결과 출력기 — 표준출력 표 / JSON / 마크다운 리포트
*
* 축을 모르고도 출력할 수 있는 이유는 `BenchmarkResult` 가 표시용 표와 기계 판독용 수치를
* 함께 담기 때문입니다. 축이 늘어도 이 클래스는 고치지 않습니다.
*
* 마크다운 리포트에 환경 정보를 반드시 함께 적습니다 — 계측값은 실행 머신·DB 버전·
* OPcache 여부에 종속되므로, 환경이 빠진 수치는 다른 리포트와 비교할 수 없는 숫자입니다.
* 문서에 옮길 수치는 이 경로로만 산출해 눈대중 기재를 막는 것이 `--report` 의 목적입니다.
*/
class BenchmarkReporter
{
/**
* 실행 환경 정보를 수집합니다.
*
* @return array<string, string> 항목 → 값
*/
public function environment(): array
{
return [
'APP_ENV' => (string) config('app.env'),
'G7 버전' => (string) config('app.version'),
'DB 연결' => (string) config('database.default'),
'DB 스키마' => $this->databaseName(),
'DB 버전' => $this->databaseVersion(),
'PHP 버전' => PHP_VERSION,
'OPcache' => function_exists('opcache_get_status') && @opcache_get_status(false) !== false ? 'on' : 'off',
'memory_limit' => (string) ini_get('memory_limit'),
'config:cache' => app()->configurationIsCached() ? 'on' : 'off',
'실행 머신' => php_uname('s').' '.php_uname('r').' / '.php_uname('m'),
];
}
/**
* JSON 으로 직렬화합니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @param array<int, string> $warnings 프로파일 수집 경고
* @return string JSON 문자열
*/
public function toJson(array $results, BenchmarkRunOptions $options, array $warnings = []): string
{
return (string) json_encode([
'generated_at' => now()->toIso8601String(),
'environment' => $this->environment(),
'options' => $options->toArray(),
'warnings' => $warnings,
'results' => array_map(fn (BenchmarkResult $result) => $result->toArray(), $results),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
/**
* 마크다운 리포트를 만듭니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @param array<int, string> $warnings 프로파일 수집 경고
* @return string 마크다운 문서
*/
public function toMarkdown(array $results, BenchmarkRunOptions $options, array $warnings = []): string
{
$lines = [
'# G7 성능 점검 리포트',
'',
'- 생성 시각: '.now()->toDateTimeString(),
'',
'## 실행 환경',
'',
'| 항목 | 값 |',
'| ------ | ------ |',
];
foreach ($this->environment() as $name => $value) {
$lines[] = "| {$name} | {$value} |";
}
$lines[] = '';
$lines[] = '계측값은 위 환경에 종속됩니다. 다른 리포트와 비교할 때는 환경이 같은지 먼저 확인하세요.';
$lines[] = '';
$lines[] = '## 실행 조건';
$lines[] = '';
$lines[] = '| 항목 | 값 |';
$lines[] = '| ------ | ------ |';
foreach ($options->toArray() as $name => $value) {
$lines[] = sprintf('| %s | %s |', $name, $this->stringify($value));
}
if ($warnings !== []) {
$lines[] = '';
$lines[] = '## 무시된 프로파일 선언';
$lines[] = '';
foreach ($warnings as $warning) {
$lines[] = '- '.$warning;
}
}
foreach (BenchmarkAxis::cases() as $axis) {
$axisResults = array_values(array_filter(
$results,
fn (BenchmarkResult $result) => $result->profile->axis === $axis
));
if ($axisResults === []) {
continue;
}
$lines[] = '';
$lines[] = sprintf('## %s (%s)', $axis->label(), $axis->value);
foreach ($axisResults as $result) {
$lines = array_merge($lines, $this->markdownSection($result));
}
}
return implode("\n", $lines)."\n";
}
/**
* 결과 1건의 마크다운 절을 만듭니다.
*
* @param BenchmarkResult $result 계측 결과
* @return array<int, string> 마크다운 줄 목록
*/
private function markdownSection(BenchmarkResult $result): array
{
$title = $result->profile->qualifiedKey();
if ($result->profile->label !== null) {
$title .= ' — '.$result->profile->label;
}
$lines = ['', '### '.$title, ''];
if ($result->skipped) {
$lines[] = '측정하지 않음: '.$result->skipReason;
return $lines;
}
$lines[] = '| '.implode(' | ', $result->headers).' |';
$lines[] = '| '.implode(' | ', array_fill(0, count($result->headers), '------')).' |';
foreach ($result->rows as $row) {
$lines[] = '| '.implode(' | ', $row).' |';
}
if ($result->notes !== []) {
$lines[] = '';
foreach ($result->notes as $note) {
// 실행 계획/SQL 은 표 안에서 깨지므로 코드 스팬으로 감싼다
$lines[] = '- '.(str_contains($note, '|') ? '`'.$note.'`' : $note);
}
}
return $lines;
}
/**
* 옵션 값을 표에 넣을 문자열로 바꿉니다.
*
* @param mixed $value 옵션 값
* @return string 표시 문자열
*/
private function stringify(mixed $value): string
{
return match (true) {
is_bool($value) => $value ? 'true' : 'false',
is_array($value) => implode(', ', array_map(fn (mixed $item) => (string) $item, $value)),
$value === null => '-',
default => (string) $value,
};
}
/**
* 계측이 사용한 DB 스키마명을 반환합니다.
*
* 읽기/쓰기 분리 설정에서는 스키마명이 `write`/`read` 하위에만 있어 최상위 `database`
* 가 비어 있습니다. 리포트에 스키마가 비면 어느 DB 를 잰 것인지 알 수 없으므로
* 세 위치를 순서대로 확인합니다.
*
* @return string 스키마명 (확인 불가 시 '-')
*/
private function databaseName(): string
{
$connection = (string) config('database.default');
foreach (['database', 'write.database', 'read.database'] as $key) {
$name = config("database.connections.{$connection}.{$key}");
if (is_string($name) && $name !== '') {
return $name;
}
}
return '-';
}
/**
* DB 서버 버전을 조회합니다.
*
* @return string DB 버전 (조회 실패 시 '-')
*/
private function databaseVersion(): string
{
try {
$row = DB::selectOne('select version() as version');
return (string) ($row->version ?? '-');
} catch (\Throwable) {
return '-';
}
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Benchmark\Contracts;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Enums\BenchmarkAxis;
/**
* 계측 축 실행기 계약
*
* 축이 늘어날 때 커맨드를 고치지 않고 실행기를 추가하도록 분리합니다. 커맨드는
* 프로파일의 `type` 으로 실행기를 고르는 일만 합니다.
*/
interface BenchmarkAxisRunner
{
/**
* 이 실행기가 담당하는 축을 반환합니다.
*
* @return BenchmarkAxis 담당 축
*/
public function axis(): BenchmarkAxis;
/**
* 프로파일 1건을 계측합니다.
*
* @param BenchmarkProfile $profile 계측 대상
* @param BenchmarkRunOptions $options 실행 옵션
* @param \Closure|null $onProgress 진행 상황 콜백 (string $message, 시딩 등 장시간 작업 알림용)
* @return BenchmarkResult 계측 결과
*/
public function run(BenchmarkProfile $profile, BenchmarkRunOptions $options, ?\Closure $onProgress = null): BenchmarkResult;
}
+97
View File
@@ -0,0 +1,97 @@
<?php
namespace App\Benchmark\DTO;
use App\Enums\BenchmarkAxis;
/**
* 계측 프로파일 — 코어 config / 확장 선언에서 수집된 계측 대상 1건 (Value Object)
*
* 레지스트리가 선언 배열을 검증해 이 객체로 정규화하고, 축 실행기가 그대로 받아 씁니다.
* 축별 옵션 스키마가 서로 다르므로 공통 필드(키·축·출처·라벨)만 프로퍼티로 승격하고
* 축 고유 옵션은 `options` 에 남깁니다 — 축이 늘 때 VO 를 고치지 않아도 되게 합니다.
*/
final readonly class BenchmarkProfile
{
/**
* @param string $key 선언된 프로파일 키 (확장 내부에서만 고유)
* @param BenchmarkAxis $axis 계측 축
* @param string $sourceKind 출처 종류 (core|module|plugin)
* @param string $sourceIdentifier 출처 식별자 (코어는 'core')
* @param array<string, mixed> $options 축 고유 옵션
* @param string|null $label 표시용 설명
*/
public function __construct(
public string $key,
public BenchmarkAxis $axis,
public string $sourceKind,
public string $sourceIdentifier,
public array $options = [],
public ?string $label = null,
) {}
/**
* 출처를 포함한 전역 고유 키를 반환합니다.
*
* 서로 다른 확장이 같은 키(`orders` 등)를 선언할 수 있으므로 충돌 시에는 이 키로
* 지목합니다. 커맨드는 짧은 키가 유일할 때만 짧은 키를 허용합니다.
*
* @return string `{출처}/{키}` 형태의 정규화 키
*/
public function qualifiedKey(): string
{
return $this->sourceIdentifier.'/'.$this->key;
}
/**
* 축 고유 옵션 값을 읽습니다.
*
* @param string $name 옵션 키
* @param mixed $default 미선언 시 기본값
* @return mixed 옵션 값
*/
public function option(string $name, mixed $default = null): mixed
{
return $this->options[$name] ?? $default;
}
/**
* 이 프로파일 실행이 데이터를 변경하는지 판정합니다.
*
* 축 기본값을 쓰되, 프로파일이 `mutating` 을 명시하면 그 선언을 따릅니다.
* `screen` 축은 GET 이 아니면 변경으로 봅니다.
*
* @return bool 데이터 변경 여부
*/
public function mutates(): bool
{
$declared = $this->options['mutating'] ?? null;
if (is_bool($declared)) {
return $declared;
}
if ($this->axis === BenchmarkAxis::Screen) {
return strtoupper((string) $this->option('method', 'GET')) !== 'GET';
}
return $this->axis->mutatesByDefault();
}
/**
* 배열로 직렬화합니다. (`--json` / 리포트 출력용)
*
* @return array<string, mixed> 직렬화 결과
*/
public function toArray(): array
{
return [
'key' => $this->key,
'qualified_key' => $this->qualifiedKey(),
'axis' => $this->axis->value,
'source' => ['kind' => $this->sourceKind, 'identifier' => $this->sourceIdentifier],
'label' => $this->label,
'options' => $this->options,
];
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Benchmark\DTO;
/**
* 계측 결과 1건 — 축 실행기의 산출물 (Value Object)
*
* 출력기(`BenchmarkReporter`)가 축을 몰라도 표/JSON/마크다운을 만들 수 있도록,
* 표시용 표(`headers`/`rows`)와 기계 판독용 수치(`metrics`)를 함께 담습니다.
* 축이 늘어날 때 출력기를 고치지 않아도 되는 지점이 여기입니다.
*/
final readonly class BenchmarkResult
{
/**
* @param BenchmarkProfile $profile 계측 대상 프로파일
* @param array<int, string> $headers 표 헤더
* @param array<int, array<int, string>> $rows 표 행 (표시용 문자열)
* @param array<string, mixed> $metrics 기계 판독용 원시 수치
* @param array<int, string> $notes 부가 설명 줄 (실행 계획, N+1 후보 등)
* @param bool $skipped 실행하지 않았는지 여부
* @param string|null $skipReason 실행하지 않은 사유
*/
public function __construct(
public BenchmarkProfile $profile,
public array $headers = [],
public array $rows = [],
public array $metrics = [],
public array $notes = [],
public bool $skipped = false,
public ?string $skipReason = null,
) {}
/**
* 실행하지 않은 결과를 만듭니다.
*
* 실패/건너뜀을 결과 목록에서 빼면 리포트가 "전부 측정됨"으로 읽히므로, 사유를 담은
* 결과로 남겨 표와 JSON 양쪽에 드러냅니다.
*
* @param BenchmarkProfile $profile 대상 프로파일
* @param string $reason 건너뛴 사유
* @return self 건너뜀 결과
*/
public static function skipped(BenchmarkProfile $profile, string $reason): self
{
return new self(profile: $profile, skipped: true, skipReason: $reason);
}
/**
* 배열로 직렬화합니다.
*
* @return array<string, mixed> 직렬화 결과
*/
public function toArray(): array
{
return [
'profile' => $this->profile->qualifiedKey(),
'axis' => $this->profile->axis->value,
'label' => $this->profile->label,
'source' => ['kind' => $this->profile->sourceKind, 'identifier' => $this->profile->sourceIdentifier],
'skipped' => $this->skipped,
'skip_reason' => $this->skipReason,
'headers' => $this->headers,
'rows' => $this->rows,
'metrics' => $this->metrics,
'notes' => $this->notes,
];
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
namespace App\Benchmark\DTO;
/**
* 계측 실행 옵션 — 커맨드 옵션을 축 실행기로 한 번 전달하는 값 묶음 (Value Object)
*
* 축마다 쓰는 옵션이 다르지만(offsets 는 list 축만, allowWrite 는 write/batch 축만)
* 실행기 시그니처를 하나로 두기 위해 한 객체로 모읍니다.
*/
final readonly class BenchmarkRunOptions
{
/**
* @param array<int, int> $offsets 측정할 OFFSET 목록 (list 축)
* @param int $runs 측정 횟수 (첫 회는 버림)
* @param int $seed 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함, list 축)
* @param bool $fresh 시딩 전 대상 테이블 비움 (list 축)
* @param bool $explain 실행 계획 수집 (list 축)
* @param bool $allowWrite 데이터 변경 축 실행 허용
* @param string|null $asUser 계측에 사용할 기존 계정 (ID 또는 이메일, screen 축)
* @param int $perPage 목록/화면 1페이지 건수
*/
public function __construct(
public array $offsets = [0],
public int $runs = 3,
public int $seed = 0,
public bool $fresh = false,
public bool $explain = false,
public bool $allowWrite = false,
public ?string $asUser = null,
public int $perPage = 20,
) {}
/**
* 배열로 직렬화합니다. (리포트의 실행 조건 기재용)
*
* @return array<string, mixed> 직렬화 결과
*/
public function toArray(): array
{
return [
'offsets' => $this->offsets,
'runs' => $this->runs,
'seed' => $this->seed,
'fresh' => $this->fresh,
'explain' => $this->explain,
'allow_write' => $this->allowWrite,
'as_user' => $this->asUser,
'per_page' => $this->perPage,
];
}
}
+203
View File
@@ -0,0 +1,203 @@
<?php
namespace App\Benchmark;
use App\Benchmark\DTO\BenchmarkProfile;
use Illuminate\Support\Facades\Schema;
/**
* 목록 프로파일 선언에서 필요한 색인을 도출하고 스키마와 대조합니다.
*
* 배경: 지연 조인은 inner 가 키 컬럼만 읽게 만들지만, 그 inner 가 **인덱스 순서 그대로**
* 끝나야 깊은 OFFSET 이 싸진다. 정렬을 덮는 인덱스가 없으면 inner 도 filesort 로 전체를
* 훑으므로 개선 폭이 사라진다. 그런데 어떤 색인이 필요한지는 지금까지 테이블마다 사람이
* 손으로 설계했고, 맞는지 검사하는 장치가 없었다.
*
* 필요한 정보는 이미 계측 프로파일에 선언돼 있다 — `filters` / `order` / `soft_delete` 를
* 이으면 그대로 색인 레시피가 된다:
*
* (등치 필터 컬럼들 → soft_delete 면 deleted_at → 정렬 컬럼들 → 기본키)
*
* 이 클래스는 그 도출을 한 곳에 두어, 새 목록이 프로파일을 선언하는 순간 필요한 색인이
* 자동으로 드러나게 한다. 색인 설계를 사람의 기억이 아니라 이미 있는 선언에 붙이는 것이라
* 호출자가 추가로 선언할 것은 없다.
*
* 등치가 아닌 필터(`in` / `not in` / 범위)는 선행 컬럼에 넣지 않는다 — 선행 컬럼이 등치로
* 고정되지 않으면 뒤따르는 정렬 컬럼을 인덱스 순서로 쓸 수 없기 때문이다. 이 판정을 틀리면
* "색인이 있는데도 filesort" 라는, 눈으로는 구분되지 않는 상태를 정상으로 보고하게 된다.
*/
class ListIndexAdvisor
{
/** 선행 컬럼으로 쓸 수 있는 연산자 (등치만) */
private const EQUALITY_OPERATORS = ['='];
/**
* 프로파일이 요구하는 색인 선행 컬럼 목록을 도출합니다.
*
* @param array<string, mixed> $options 목록 프로파일 옵션
* @return array<int, string> 선행 컬럼 순서 (등치 → deleted_at → 정렬 → 기본키)
*/
public function requiredColumns(array $options, string $keyName = 'id'): array
{
$leading = [];
foreach (($options['filters'] ?? []) as $column => $declared) {
if (is_array($declared) && count($declared) === 2 && is_string($declared[0])) {
// [연산자, 값] 형태 — 등치만 선행 컬럼 자격이 있다
if (in_array(strtolower($declared[0]), self::EQUALITY_OPERATORS, true)) {
$leading[] = (string) $column;
}
continue;
}
// 값만 준 형태는 등가 비교
$leading[] = (string) $column;
}
if ($options['soft_delete'] ?? false) {
$leading[] = 'deleted_at';
}
foreach (($options['order'] ?? []) as $spec) {
$column = is_array($spec) ? ($spec[0] ?? null) : $spec;
if (is_string($column) && $column !== '') {
$leading[] = $column;
}
}
// 정렬 끝에 기본키가 없으면 동률 구간에서 filesort 가 남는다
if (end($leading) !== $keyName) {
$leading[] = $keyName;
}
return array_values(array_unique($leading));
}
/**
* 도출한 색인이 실제 스키마에 있는지 대조합니다.
*
* @param array<string, mixed> $options 목록 프로파일 옵션
* @return array{
* table: string|null,
* required: array<int, string>,
* satisfied_by: string|null,
* partial_by: string|null,
* status: string
* } status: satisfied | tiebreak_missing | missing | table_absent | not_applicable
*/
public function inspect(array $options, string $keyName = 'id'): array
{
$table = $options['table'] ?? null;
$required = $this->requiredColumns($options, $keyName);
$result = [
'table' => $table,
'required' => $required,
'satisfied_by' => null,
'partial_by' => null,
'status' => 'not_applicable',
];
if (! is_string($table) || $table === '') {
return $result;
}
if (! Schema::hasTable($table)) {
$result['status'] = 'table_absent';
return $result;
}
// 기본키를 뺀 형태 — 색인은 있으나 tie-break 만 빠진 경우를 구분한다.
// 이 둘은 처방이 다르다: 전자는 색인 신설, 후자는 기존 색인에 기본키를 덧붙이는 교체.
$withoutKey = $required;
if (end($withoutKey) === $keyName) {
array_pop($withoutKey);
}
foreach (Schema::getIndexes($table) as $index) {
$columns = $index['columns'] ?? [];
if ($result['satisfied_by'] === null && $this->hasPrefix($columns, $required)) {
$result['satisfied_by'] = $index['name'];
}
if ($this->hasPrefix($columns, $withoutKey)) {
// UNIQUE 색인은 값이 중복되지 않으므로 그 컬럼만으로 이미 전순서다.
// 기본키를 덧붙일 필요가 없고, 붙여도 filesort 가 줄지 않는다.
if ($result['satisfied_by'] === null && ($index['unique'] ?? false)) {
$result['satisfied_by'] = $index['name'];
}
if ($result['partial_by'] === null) {
$result['partial_by'] = $index['name'];
}
}
}
$result['status'] = match (true) {
$result['satisfied_by'] !== null => 'satisfied',
$result['partial_by'] !== null => 'tiebreak_missing',
default => 'missing',
};
return $result;
}
/**
* 프로파일에 선언된 면제 사유를 돌려줍니다.
*
* 행 수가 고정되거나 상한이 작아 색인이 불필요한 목록은 프로파일에 사유를 적어 면제한다.
* 사유 없는 면제를 허용하지 않는 이유는, 면제가 쌓이면 검사 자체가 무의미해지는데
* 사유가 없으면 나중에 그 판단이 여전히 옳은지 확인할 방법이 없기 때문이다.
*
* @param array<string, mixed> $options 목록 프로파일 옵션
* @return string|null 면제 사유 (없으면 null)
*/
public function exemptionReason(array $options): ?string
{
$reason = $options['index_exempt'] ?? null;
return is_string($reason) && trim($reason) !== '' ? trim($reason) : null;
}
/**
* 프로파일 목록을 한 번에 점검합니다.
*
* @param array<string, BenchmarkProfile> $profiles 목록 축 프로파일
* @return array<int, array<string, mixed>> 프로파일별 점검 결과
*/
public function inspectAll(array $profiles): array
{
$rows = [];
foreach ($profiles as $key => $profile) {
$options = $profile->options ?? [];
$rows[] = array_merge(
['profile' => is_string($key) ? $key : $profile->qualifiedKey()],
$this->inspect($options),
['exemption' => $this->exemptionReason($options)]
);
}
return $rows;
}
/**
* 인덱스 컬럼이 요구 목록을 선행 프리픽스로 포함하는지 판정합니다.
*
* @param array<int, string> $columns 인덱스 컬럼 순서
* @param array<int, string> $required 요구 선행 컬럼
*/
private function hasPrefix(array $columns, array $required): bool
{
if ($required === [] || count($columns) < count($required)) {
return false;
}
return array_slice(array_values($columns), 0, count($required)) === array_values($required);
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
namespace App\Benchmark;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
/**
* 실행 쿼리 수집기 — 화면/쓰기/배치 축의 쿼리 건수·시간·N+1 후보 산출
*
* 목록 SELECT 는 빨라도 화면이 느린 대표적 원인이 N+1 이므로, 응답 시간만 재고 끝내면
* 원인을 못 찾습니다. 이 수집기는 계측 구간에서 실행된 쿼리를 모아 건수·DB 시간과, 같은
* SQL 이 반복 실행된 그룹(N+1 후보)을 함께 돌려줍니다.
*
* `DB::listen` 은 한 번 등록하면 해제할 수 없으므로, 리스너는 인스턴스당 한 번만 등록하고
* 수집 여부는 플래그로 켰다 끕니다 — 계측 구간 밖의 쿼리가 섞이지 않게 합니다.
*/
class QueryCollector
{
/**
* 같은 SQL 이 이 횟수 이상 반복되면 N+1 후보로 본다
*/
private const N_PLUS_ONE_THRESHOLD = 5;
/**
* 수집 구간 여부
*/
private bool $collecting = false;
/**
* 수집된 쿼리 (sql, time)
*
* @var array<int, array{sql: string, time: float}>
*/
private array $queries = [];
public function __construct()
{
DB::listen(function (QueryExecuted $event) {
if (! $this->collecting) {
return;
}
$this->queries[] = ['sql' => $event->sql, 'time' => (float) $event->time];
});
}
/**
* 콜백 실행 구간의 쿼리를 수집합니다.
*
* @template TReturn
*
* @param \Closure(): TReturn $callback 계측 대상 작업
* @return array{value: TReturn, queries: array<int, array{sql: string, time: float}>} 반환값과 수집 결과
*/
public function collect(\Closure $callback): array
{
$this->queries = [];
$this->collecting = true;
try {
$value = $callback();
} finally {
$this->collecting = false;
}
return ['value' => $value, 'queries' => $this->queries];
}
/**
* 수집된 쿼리를 요약합니다.
*
* @param array<int, array{sql: string, time: float}> $queries 수집 결과
* @return array{count: int, db_ms: float, n_plus_one: array<int, array{count: int, sql: string}>} 요약
*/
public function summarize(array $queries): array
{
$grouped = [];
foreach ($queries as $query) {
$grouped[$query['sql']] = ($grouped[$query['sql']] ?? 0) + 1;
}
arsort($grouped);
$candidates = [];
foreach ($grouped as $sql => $count) {
if ($count < self::N_PLUS_ONE_THRESHOLD) {
continue;
}
$candidates[] = ['count' => $count, 'sql' => $sql];
}
return [
'count' => count($queries),
'db_ms' => round(array_sum(array_column($queries, 'time')), 2),
'n_plus_one' => $candidates,
];
}
}
+234
View File
@@ -0,0 +1,234 @@
<?php
namespace App\Benchmark;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* 계측용 합성 행 시딩기
*
* 깊은 OFFSET 비용은 행 수와 행 폭이 있어야 재현되므로, 계측 전에 대상 테이블을 원하는
* 규모까지 채웁니다. 스키마를 introspect 해 NOT NULL + 기본값 없는 컬럼만 타입에 맞춰
* 채우므로 계측 대상이 늘 때마다 시더를 따라 만들지 않아도 됩니다.
*
* 합성 값은 실제 부모 행을 가리키지 않으므로 외래키 제약을 일시 해제합니다. 본 시딩이
* 재현하려는 것은 대상 테이블의 OFFSET 스캔 비용(행 수·행 폭)이고 조인은 계측 대상이
* 아니므로, 부모 무결성 없이 목적에 충분합니다 — 폐기 가능한 계측용 DB 에서만 쓰는 것을
* 전제로 하며, 운영 환경 거부는 호출자(`ListAxisRunner`)가 담당합니다.
*/
class SyntheticSeeder
{
/**
* 한 번에 삽입할 행 수
*/
private const CHUNK_SIZE = 500;
/**
* 계측용 합성 행을 시딩합니다.
*
* @param string $table 대상 테이블
* @param int $count 시딩 건수
* @param array<string, mixed> $overrides 컬럼별 고정값 (실제 조회 조건과 맞추기 위한 지정)
* @param \Closure|null $onProgress 진행 콜백 (int $inserted, int $total)
* @param array<int, string> $requiredColumns nullable 이어도 반드시 채울 컬럼 (계측이 정렬 기준으로 삼는 컬럼)
*/
public function seed(string $table, int $count, array $overrides = [], ?\Closure $onProgress = null, array $requiredColumns = []): void
{
$fillable = $this->fillableColumns($table);
$required = $this->requiredColumnDefinitions($table, $requiredColumns, $fillable);
$chunk = [];
$inserted = 0;
for ($i = 1; $i <= $count; $i++) {
$chunk[] = $this->synthesizeRow($table, array_merge($fillable, $required), $overrides, $i);
if (count($chunk) >= self::CHUNK_SIZE) {
$this->insert($table, $chunk);
$inserted += count($chunk);
$chunk = [];
if ($onProgress !== null) {
$onProgress($inserted, $count);
}
}
}
if ($chunk !== []) {
$this->insert($table, $chunk);
$inserted += count($chunk);
if ($onProgress !== null) {
$onProgress($inserted, $count);
}
}
}
/**
* 대상 테이블을 비웁니다.
*
* 다른 테이블이 FK 로 참조하면 TRUNCATE 가 거부되므로 제약을 잠시 내립니다.
*
* @param string $table 대상 테이블
*/
public function truncate(string $table): void
{
Schema::withoutForeignKeyConstraints(function () use ($table) {
DB::table($table)->truncate();
});
}
/**
* 값을 반드시 채워야 하는 컬럼 목록을 반환합니다.
*
* @param string $table 대상 테이블
* @return array<int, array<string, mixed>> Schema::getColumns() 항목 목록
*/
private function fillableColumns(string $table): array
{
return array_values(array_filter(
Schema::getColumns($table),
fn (array $column) => ! ($column['auto_increment'] ?? false)
&& ! $column['nullable']
&& ($column['default'] === null)
));
}
/**
* 계측이 정렬 기준으로 삼는 컬럼의 정의를 반환합니다.
*
* `fillableColumns()` 는 "NOT NULL + 기본값 없음" 만 남기므로, Laravel 기본 `timestamps()`
* 처럼 nullable 로 선언된 `created_at` 계열은 여기서 빠진다. 그런데 목록 프로파일 대부분이
* `created_at` 으로 정렬하므로, 그대로 두면 합성 행이 전부 NULL 동률이 되어 계측이
* 정렬 인덱스가 아니라 tie-break 경로를 재게 된다.
*
* @param string $table 대상 테이블
* @param array<int, string> $names 반드시 채울 컬럼명 목록
* @param array<int, array<string, mixed>> $already 이미 채우기로 한 컬럼 목록
* @return array<int, array<string, mixed>> 추가로 채울 컬럼 정의 목록
*/
private function requiredColumnDefinitions(string $table, array $names, array $already): array
{
if ($names === []) {
return [];
}
$covered = array_column($already, 'name');
$columns = collect(Schema::getColumns($table))->keyBy('name');
$extra = [];
foreach (array_unique($names) as $name) {
if (in_array($name, $covered, true) || ! $columns->has($name)) {
continue;
}
$column = $columns->get($name);
// auto increment 키는 DB 가 채운다
if ($column['auto_increment'] ?? false) {
continue;
}
$extra[] = $column;
}
return $extra;
}
/**
* 합성 행 1건을 만듭니다.
*
* @param string $table 대상 테이블
* @param array<int, array<string, mixed>> $fillable 채워야 하는 컬럼 목록
* @param array<string, mixed> $overrides 컬럼별 고정값
* @param int $index 행 인덱스 (고유값 생성용)
* @return array<string, mixed> 합성 행
*/
private function synthesizeRow(string $table, array $fillable, array $overrides, int $index): array
{
$row = [];
foreach ($fillable as $column) {
$row[$column['name']] = array_key_exists($column['name'], $overrides)
? $overrides[$column['name']]
: $this->synthesizeValue($column, $index);
}
// NULL 허용 컬럼이라 위 루프에서 빠졌더라도, 실제 조회 조건에 쓰이는 컬럼은
// 채워야 계측이 화면과 같은 인덱스를 타게 된다.
foreach ($overrides as $name => $value) {
if (! array_key_exists($name, $row) && Schema::hasColumn($table, $name)) {
$row[$name] = $value;
}
}
return $row;
}
/**
* 합성 행을 삽입합니다. (외래키 제약 일시 해제)
*
* @param string $table 대상 테이블
* @param array<int, array<string, mixed>> $chunk 삽입할 행 묶음
*/
private function insert(string $table, array $chunk): void
{
Schema::withoutForeignKeyConstraints(function () use ($table, $chunk) {
DB::table($table)->insert($chunk);
});
}
/**
* 컬럼 타입에 맞는 합성 값을 만듭니다.
*
* @param array<string, mixed> $column Schema::getColumns() 항목
* @param int $index 행 인덱스 (고유값 생성용)
* @return mixed 합성 값
*/
private function synthesizeValue(array $column, int $index): mixed
{
$type = strtolower((string) ($column['type_name'] ?? $column['type'] ?? 'varchar'));
return match (true) {
str_contains($type, 'int') => $index,
str_contains($type, 'decimal'), str_contains($type, 'float'), str_contains($type, 'double') => 1000,
str_contains($type, 'bool'), str_contains($type, 'tinyint') => 0,
str_contains($type, 'json') => '{}',
// 시간 컬럼을 한 값으로 채우면 정렬이 전부 동률이 되어 filesort 가 지배해 버린다.
// 정렬 인덱스 효과를 재는 것이 목적이므로 행마다 값을 분산시킨다.
str_contains($type, 'date'), str_contains($type, 'time') => now()->subMinutes($index),
str_contains($type, 'text') => str_repeat('벤치마크 본문 ', 100),
default => $this->synthesizeString($column, $index),
};
}
/**
* 문자열 컬럼의 선언 길이에 맞는 합성 값을 만듭니다.
*
* 컬럼 길이를 무시하고 고정 길이 문자열을 넣으면 `varchar(10)` 류(신고 대상 타입,
* 우편번호, 로그 타입 등)에서 "Data too long" 으로 시딩 자체가 실패한다.
*
* @param array<string, mixed> $column Schema::getColumns() 항목
* @param int $index 행 인덱스 (고유값 생성용)
* @return string 합성 문자열
*/
private function synthesizeString(array $column, int $index): string
{
$length = 40;
// type 은 'varchar(10)' 형태로 선언 길이를 담고 있다
if (preg_match('/\((\d+)\)/', (string) ($column['type'] ?? ''), $m) === 1) {
$length = max(1, (int) $m[1]);
}
// 짧은 컬럼은 고유성 확보가 우선이라 인덱스 자체를 문자열로 쓴다
if ($length <= 12) {
return substr((string) $index, -$length);
}
return substr("bench-{$index}-".Str::random(8), 0, $length);
}
}
@@ -0,0 +1,167 @@
<?php
namespace App\Console\Commands;
use App\Support\ApiDoc\ResourceFieldDescriber;
use Illuminate\Console\Command;
use Symfony\Component\Finder\Finder;
/**
* 기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다.
*
* 파라미터 TODO 는 `api:docgen-backfill-params` 가 채우지만, 응답 필드 TODO 는 전용
* 백필 커맨드가 없어 재생성(`api:docgen`)으로만 갱신되었습니다. 실측 서버 없이
* 재생성하면 이미 실측된 응답 예시값이 퇴행하므로, 이 커맨드는 재생성 대신 응답 필드
* 표의 `<!-- TODO: 설명 -->` 셀만 in-place 치환합니다.
*
* 채움 규칙 SSoT 는 ApiDocScaffolder::responseFieldTable 과 동일한 ResourceFieldDescriber
* 입니다 — 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다.
*
* 도메인 특이 필드(ResourceFieldDescriber 가 null 반환)는 TODO 를 그대로 둡니다.
* 응답 필드 표만 대상으로 하며, 파라미터 표(`<!-- TODO: 용도 -->`)는 건드리지 않습니다.
*/
class ApiDocBackfillFieldsCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'api:docgen-backfill-fields
{--dry-run : 치환하지 않고 채울 건수만 리포트}';
/**
* @var string 커맨드 설명
*/
protected $description = '기존 API 문서의 응답 필드 설명 TODO 를 리소스 계약 사전 설명으로 소급 채웁니다';
/**
* @var string 응답 필드 표를 식별하는 고유 헤더
*/
private const FIELD_TABLE_HEADER = '| 필드 | 타입 | 실측 예시값 | 용도/설명 |';
/**
* @var string 응답 필드 설명 TODO 마커
*/
private const TODO_MARKER = '<!-- TODO: 설명 -->';
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$describer = new ResourceFieldDescriber;
$dryRun = (bool) $this->option('dry-run');
$filled = 0;
$skipped = 0;
$filesChanged = 0;
foreach ($this->apiDocFiles() as $file) {
$content = file_get_contents($file);
$result = $this->backfillFile($content, $describer, $filled, $skipped);
if ($result !== $content) {
$filesChanged++;
if (! $dryRun) {
file_put_contents($file, $result);
}
}
}
$prefix = $dryRun ? '[dry-run] ' : '';
$this->info("{$prefix}응답 필드 설명 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개");
return self::SUCCESS;
}
/**
* 대상 API 문서 파일 경로를 수집합니다.
*
* @return array<int, string> 파일 경로 목록
*/
private function apiDocFiles(): array
{
$dirs = array_filter([
base_path('docs/backend/api'),
...glob(base_path('modules/_bundled/*/docs/api')),
...glob(base_path('plugins/_bundled/*/docs/api')),
], 'is_dir');
if ($dirs === []) {
return [];
}
$files = [];
foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) {
$files[] = $f->getRealPath();
}
return $files;
}
/**
* 단일 문서의 응답 필드 표 TODO 를 채웁니다.
*
* 응답 필드 표 헤더 이후 표 행만 대상으로 하며, `<!-- TODO: 설명 -->` 셀을
* ResourceFieldDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다.
* 파라미터 표는 헤더가 달라 진입하지 않으므로 건드리지 않습니다.
*
* @param string $content 문서 내용
* @param ResourceFieldDescriber $describer 설명기
* @param int $filled 채운 건수 (참조 누적)
* @param int $skipped 유지 건수 (참조 누적)
* @return string 치환된 문서 내용
*/
private function backfillFile(string $content, ResourceFieldDescriber $describer, int &$filled, int &$skipped): string
{
$lines = explode("\n", $content);
$inFieldTable = false;
foreach ($lines as $i => $line) {
// 응답 필드 표 헤더 진입 감지
if (str_contains($line, self::FIELD_TABLE_HEADER)) {
$inFieldTable = true;
continue;
}
// 표 구분선(| --- | ...) 은 건너뜀
if ($inFieldTable && preg_match('/^\|\s*-+\s*\|/', $line)) {
continue;
}
// 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료
if ($inFieldTable && ! str_starts_with(ltrim($line), '|')) {
$inFieldTable = false;
continue;
}
if (! $inFieldTable || ! str_contains($line, self::TODO_MARKER)) {
continue;
}
// 응답 필드 행 파싱: | name | type | `sample` | <!-- TODO: 설명 --> |
$cells = array_map('trim', explode('|', trim($line, '| ')));
if (count($cells) < 4) {
continue;
}
[$name, $type] = [$cells[0], $cells[1]];
$desc = $describer->describe($name, $type);
if ($desc === null) {
$skipped++;
continue;
}
$safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc);
$lines[$i] = str_replace(self::TODO_MARKER, $safe, $line);
$filled++;
}
return implode("\n", $lines);
}
}
@@ -0,0 +1,162 @@
<?php
namespace App\Console\Commands;
use App\Support\ApiDoc\ParameterDescriber;
use Illuminate\Console\Command;
use Symfony\Component\Finder\Finder;
/**
* 기존 API 문서의 파라미터 용도 TODO 를 공통 파라미터 설명으로 소급 채웁니다.
*
* 실측 서버 없이 재생성하면 이미 실측된 응답표·사람 서술이 퇴행하므로, 이 커맨드는
* 재생성 대신 파라미터 표의 `<!-- TODO: 용도 -->` 셀만 in-place 치환합니다.
* 채움 규칙 SSoT 는 ApiDocScaffolder 와 동일한 ParameterDescriber 입니다 —
* 이후 정상 재생성(실측 서버 가동 시)도 같은 설명을 산출하므로 멱등합니다.
*
* 도메인 특이 파라미터(ParameterDescriber 가 null 반환)는 TODO 를 그대로 둡니다.
*/
class ApiDocBackfillParamsCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'api:docgen-backfill-params
{--dry-run : 치환하지 않고 채울 건수만 리포트}';
/**
* @var string 커맨드 설명
*/
protected $description = '기존 API 문서의 파라미터 용도 TODO 를 공통 파라미터 설명으로 소급 채웁니다';
/**
* @var string 파라미터 표를 식별하는 고유 헤더
*/
private const PARAM_TABLE_HEADER = '| 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 |';
/**
* @var string 파라미터 용도 TODO 마커
*/
private const TODO_MARKER = '<!-- TODO: 용도 -->';
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$describer = new ParameterDescriber;
$dryRun = (bool) $this->option('dry-run');
$filled = 0;
$skipped = 0;
$filesChanged = 0;
foreach ($this->apiDocFiles() as $file) {
$content = file_get_contents($file);
$result = $this->backfillFile($content, $describer, $filled, $skipped);
if ($result !== $content) {
$filesChanged++;
if (! $dryRun) {
file_put_contents($file, $result);
}
}
}
$prefix = $dryRun ? '[dry-run] ' : '';
$this->info("{$prefix}파라미터 용도 채움: {$filled}건, TODO 유지(도메인 특이): {$skipped}건, 변경 파일: {$filesChanged}개");
return self::SUCCESS;
}
/**
* 대상 API 문서 파일 경로를 수집합니다.
*
* @return array<int, string> 파일 경로 목록
*/
private function apiDocFiles(): array
{
$dirs = array_filter([
base_path('docs/backend/api'),
...glob(base_path('modules/_bundled/*/docs/api')),
...glob(base_path('plugins/_bundled/*/docs/api')),
], 'is_dir');
if ($dirs === []) {
return [];
}
$files = [];
foreach (Finder::create()->files()->in($dirs)->name('*.md') as $f) {
$files[] = $f->getRealPath();
}
return $files;
}
/**
* 단일 문서의 파라미터 표 TODO 를 채웁니다.
*
* 파라미터 표 헤더 이후 표 행만 대상으로 하며, `<!-- TODO: 용도 -->` 셀을
* ParameterDescriber 결과로 치환합니다. 설명이 null(도메인 특이)이면 유지합니다.
*
* @param string $content 문서 내용
* @param ParameterDescriber $describer 설명기
* @param int $filled 채운 건수 (참조 누적)
* @param int $skipped 유지 건수 (참조 누적)
* @return string 치환된 문서 내용
*/
private function backfillFile(string $content, ParameterDescriber $describer, int &$filled, int &$skipped): string
{
$lines = explode("\n", $content);
$inParamTable = false;
foreach ($lines as $i => $line) {
// 파라미터 표 헤더 진입 감지
if (str_contains($line, self::PARAM_TABLE_HEADER)) {
$inParamTable = true;
continue;
}
// 표 구분선(| --- | ...) 은 건너뜀
if ($inParamTable && preg_match('/^\|\s*-+\s*\|/', $line)) {
continue;
}
// 표가 아닌 라인(빈 줄 또는 | 로 시작하지 않음) → 표 종료
if ($inParamTable && ! str_starts_with(ltrim($line), '|')) {
$inParamTable = false;
continue;
}
if (! $inParamTable || ! str_contains($line, self::TODO_MARKER)) {
continue;
}
// 파라미터 행 파싱: | name | location | type | 필수 | 허용값 | <!-- TODO: 용도 --> |
$cells = array_map('trim', explode('|', trim($line, '| ')));
if (count($cells) < 5) {
continue;
}
[$name, $location, $type] = [$cells[0], $cells[1], $cells[2]];
$desc = $describer->describe($name, $location, $type);
if ($desc === null) {
$skipped++;
continue;
}
$safe = str_replace(['|', "\n", "\r"], ['\\|', ' ', ''], $desc);
$lines[$i] = str_replace(self::TODO_MARKER, $safe, $line);
$filled++;
}
return implode("\n", $lines);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,125 @@
<?php
namespace App\Console\Commands;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Services\SettingsService;
use App\Support\AssetUrl;
use Illuminate\Console\Command;
/**
* 자산 URL 모드 조회/전환 Artisan 커맨드 (이슈 #486 §9)
*
* 정적 최적화 블록(`location ~* \.(js|css|json)$`)이 동적 응답을 가로채는 서버에서는
* **관리자 화면 자체가 뜨지 않는다**. "브라우저로 설정을 바꾸세요" 는 순환 참조이므로
* CLI 탈출구가 반드시 필요하다.
*
* ```
* php artisan g7:asset-url-mode # 현재 모드 + 진단 안내
* php artisan g7:asset-url-mode extensionless # 전환
* ```
*/
class AssetUrlModeCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'g7:asset-url-mode
{mode? : 전환할 모드 (extension | extensionless). 생략 시 현재 모드만 출력}';
/**
* @var string 커맨드 설명
*/
protected $description = '자산 URL 모드를 조회하거나 전환합니다 (정적 최적화 서버 대응)';
/**
* 커맨드를 실행합니다.
*
* @param SettingsService $settingsService 환경설정 서비스
* @param SeoCacheManagerInterface $seoCacheManager SEO 캐시 매니저
* @return int 종료 코드
*/
public function handle(SettingsService $settingsService, SeoCacheManagerInterface $seoCacheManager): int
{
$current = AssetUrl::mode();
$requested = $this->argument('mode');
if ($requested === null) {
$this->showStatus($current);
return Command::SUCCESS;
}
if (! in_array($requested, [AssetUrl::MODE_EXTENSION, AssetUrl::MODE_EXTENSIONLESS], true)) {
$this->error(__('asset_url_mode.invalid', ['mode' => $requested]));
return Command::FAILURE;
}
if ($requested === $current) {
$this->info(__('asset_url_mode.already', ['mode' => $current]));
return Command::SUCCESS;
}
$saved = $settingsService->saveSettings([
'_tab' => 'general',
'general' => ['asset_url_mode' => $requested],
]);
if (! $saved) {
$this->error(__('asset_url_mode.save_failed'));
return Command::FAILURE;
}
// SEO 프리렌더 캐시에는 생성 시점의 자산 URL 이 그대로 구워져 있다.
// 모드를 바꾸면 그 URL 들이 전부 어긋나므로 함께 비운다 (계획서 §알려진 한계).
$seoCacheManager->clearAll();
$this->info(__('asset_url_mode.switched', ['from' => $current, 'to' => $requested]));
$this->line(__('asset_url_mode.seo_cache_cleared'));
return Command::SUCCESS;
}
/**
* 현재 모드와 진단 안내를 출력합니다.
*
* @param string $current 현재 모드
*/
private function showStatus(string $current): void
{
$this->line('');
$this->line(' '.__('asset_url_mode.current', ['mode' => $current]));
$this->line('');
// audit:allow asset-url-builder-required reason: 두 모드의 URL 형태를 나란히
// 보여주는 대조표라 빌더로 생성할 수 없다 (빌더는 현재 모드 하나만 만든다).
$rows = $current === AssetUrl::MODE_EXTENSION
? [
['/api/templates/{id}/routes.json', '/api/templates/{id}/routes'],
['/api/modules/bundle.js', '/api/modules/bundle/js'],
['/api/templates/assets/{id}/js/a.js', '/api/templates/assets/{id}?file=js/a.js'],
]
: [
['/api/templates/{id}/routes', '/api/templates/{id}/routes.json'],
['/api/modules/bundle/js', '/api/modules/bundle.js'],
['/api/templates/assets/{id}?file=js/a.js', '/api/templates/assets/{id}/js/a.js'],
];
$this->table(
[__('asset_url_mode.table.in_use'), __('asset_url_mode.table.alternative')],
$rows,
);
$this->line(' '.__('asset_url_mode.diagnose_title'));
$this->line(' curl -i '.rtrim(config('app.url'), '/').'/api/system/asset-probe.js');
$this->line(' curl -i '.rtrim(config('app.url'), '/').'/api/system/asset-probe');
$this->line('');
$this->line(' '.__('asset_url_mode.diagnose_hint'));
$this->line('');
$this->line(' '.__('asset_url_mode.switch_hint'));
$this->line('');
}
}
+395
View File
@@ -0,0 +1,395 @@
<?php
namespace App\Console\Commands;
use App\Benchmark\BenchmarkProfileRegistry;
use App\Benchmark\BenchmarkReporter;
use App\Benchmark\Contracts\BenchmarkAxisRunner;
use App\Benchmark\DTO\BenchmarkProfile;
use App\Benchmark\DTO\BenchmarkResult;
use App\Benchmark\DTO\BenchmarkRunOptions;
use App\Enums\BenchmarkAxis;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* 성능 계측 커맨드 — 목록 조회 / 화면 응답 / 쓰기 작업 / 배치 작업 4축
*
* 계측 대상은 소유자가 선언합니다 — 코어는 `config/benchmark.php`, 확장은
* `getBenchmarkProfiles()` 오버라이드입니다. 커맨드에 대상을 하드코딩하지 않는 이유는,
* 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기 때문입니다.
*
* 축별 실행은 `App\Benchmark\Axes\*` 실행기가 담당하고, 이 커맨드는 프로파일 선택·옵션
* 해석·출력만 합니다.
*
* tinker 스크립트 대신 커맨드로 만든 이유: tinker 는 REPL 이라 스크립트 실행 후에도 STDIN 을
* 기다려 출력이 유실된다.
*/
class BenchCommand extends Command
{
protected $signature = 'g7:bench
{--profile=* : 계측할 프로파일 키 (짧은 키 또는 출처/키 형태). 다중 지정 가능}
{--axis= : 축 단위 실행 (list|screen|write|batch)}
{--all : 등록된 모든 프로파일 실행}
{--offsets=0,20000,50000,99980 : 측정할 OFFSET 목록 (쉼표 구분, list 축)}
{--runs=3 : 측정 횟수 (첫 회는 버림)}
{--per-page=20 : 목록/화면 1페이지 건수}
{--seed=0 : 계측 전 합성 행 시딩 건수 (0 = 시딩 안 함, list 축)}
{--fresh : 시딩 전에 대상 테이블을 비움 (운영 환경에서는 거부)}
{--explain : 각 OFFSET 의 실행 계획도 함께 수집 (list 축)}
{--as= : 화면 계측에 사용할 기존 계정 (ID 또는 이메일). 미지정 시 계측용 임시 계정}
{--allow-write : 데이터를 변경하는 축(write/batch/비-GET 화면) 실행 허용}
{--database= : 계측에 사용할 데이터베이스명 (미지정 시 기본 연결. 개발 DB 오염 방지용)}
{--json : 기계 판독용 JSON 출력}
{--report= : 마크다운 리포트 저장 경로 (값 없이 --report 만 주면 storage/app/benchmarks/ 아래 자동 생성)}
{--list-profiles : 등록된 프로파일 목록 출력}';
protected $description = '목록 조회·화면 응답·쓰기·배치 4축 성능을 계측합니다 (대상은 코어 config + 확장 선언에서 수집)';
protected $aliases = ['g7:bench:pagination'];
/**
* 리포트 기본 저장 디렉토리 (저장소 미추적 — 계측값은 실행 머신 사양에 종속되므로 축적하지 않음)
*/
private const REPORT_DIR = 'app/benchmarks';
/**
* @param BenchmarkProfileRegistry $registry 프로파일 레지스트리
* @param BenchmarkReporter $reporter 결과 출력기
* @param iterable<BenchmarkAxisRunner> $runners 축 실행기 목록 (컨테이너 태그 주입)
*/
public function __construct(
private readonly BenchmarkProfileRegistry $registry,
private readonly BenchmarkReporter $reporter,
private readonly iterable $runners,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$this->applyDatabaseOverride();
if ($this->option('list-profiles')) {
return $this->listProfiles();
}
[$profiles, $selectionError] = $this->selectProfiles();
if ($selectionError !== null) {
$this->error($selectionError);
return self::FAILURE;
}
if ($profiles === []) {
$this->error('계측할 프로파일이 없습니다. --profile / --axis / --all 중 하나를 지정하세요.');
return self::FAILURE;
}
$options = $this->runOptions();
// 계측 결과가 환경에 좌우되므로 실행 환경을 먼저 드러낸다
if (! $this->option('json')) {
foreach ($this->reporter->environment() as $name => $value) {
$this->line(sprintf(' %-14s %s', $name, $value));
}
$this->newLine();
}
$this->reportCollectionWarnings();
$results = [];
foreach ($profiles as $profile) {
$results[] = $this->runProfile($profile, $options);
}
return $this->output($results, $options);
}
/**
* 계측용 데이터베이스 지정을 반영합니다.
*
* 계측은 대량 시딩을 동반하므로 개발 DB 대신 폐기 가능한 DB 를 지정할 수 있어야 합니다.
* config 가 캐시된 환경에서는 `.env` / `--env` 로 연결을 바꿀 수 없어 런타임 치환이
* 유일한 수단입니다.
*/
private function applyDatabaseOverride(): void
{
$database = (string) $this->option('database');
if ($database === '') {
return;
}
$connection = config('database.default');
config([
"database.connections.{$connection}.database" => $database,
"database.connections.{$connection}.write.database" => $database,
"database.connections.{$connection}.read.database" => $database,
]);
DB::purge($connection);
}
/**
* 등록된 프로파일 목록을 출력합니다.
*
* @return int 종료 코드
*/
private function listProfiles(): int
{
$profiles = $this->registry->all();
if ($this->option('json')) {
$this->line((string) json_encode(
array_map(fn (BenchmarkProfile $profile) => $profile->toArray(), array_values($profiles)),
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
));
return self::SUCCESS;
}
$this->table(
['프로파일', '축', '출처', '설명'],
array_map(fn (BenchmarkProfile $profile) => [
$profile->qualifiedKey(),
$profile->axis->value,
$profile->sourceKind,
$profile->label ?? '-',
], array_values($profiles))
);
$this->reportCollectionWarnings();
return self::SUCCESS;
}
/**
* 실행할 프로파일을 고릅니다.
*
* @return array{0: array<int, BenchmarkProfile>, 1: string|null} [실행 대상, 실패 사유]
*/
private function selectProfiles(): array
{
$keys = array_filter((array) $this->option('profile'), 'strlen');
if ($keys !== []) {
$selected = [];
foreach ($keys as $key) {
[$profile, $error] = $this->registry->resolve((string) $key);
if ($error !== null) {
return [[], $error];
}
$selected[] = $profile;
}
return [$selected, null];
}
$axisOption = (string) $this->option('axis');
if ($axisOption !== '') {
$axis = BenchmarkAxis::tryFrom($axisOption);
if ($axis === null) {
return [[], "알 수 없는 축: {$axisOption} (허용: ".implode('|', BenchmarkAxis::values()).')'];
}
return [array_values($this->registry->byAxis($axis)), null];
}
return [$this->option('all') ? array_values($this->registry->all()) : [], null];
}
/**
* 커맨드 옵션을 실행 옵션으로 해석합니다.
*
* @return BenchmarkRunOptions 실행 옵션
*/
private function runOptions(): BenchmarkRunOptions
{
$asUser = (string) $this->option('as');
return new BenchmarkRunOptions(
offsets: $this->parseOffsets(),
runs: max(1, (int) $this->option('runs')),
seed: max(0, (int) $this->option('seed')),
fresh: (bool) $this->option('fresh'),
explain: (bool) $this->option('explain'),
allowWrite: (bool) $this->option('allow-write'),
asUser: $asUser === '' ? null : $asUser,
perPage: max(1, (int) $this->option('per-page')),
);
}
/**
* OFFSET 목록을 파싱합니다.
*
* @return array<int, int> 오름차순 정렬된 OFFSET 목록
*/
private function parseOffsets(): array
{
$offsets = array_map('intval', array_filter(explode(',', (string) $this->option('offsets')), 'strlen'));
sort($offsets);
return $offsets === [] ? [0] : $offsets;
}
/**
* 프로파일 1건을 계측합니다.
*
* @param BenchmarkProfile $profile 대상 프로파일
* @param BenchmarkRunOptions $options 실행 옵션
* @return BenchmarkResult 계측 결과
*/
private function runProfile(BenchmarkProfile $profile, BenchmarkRunOptions $options): BenchmarkResult
{
$runner = $this->runnerFor($profile->axis);
if ($runner === null) {
return BenchmarkResult::skipped($profile, "축 실행기가 없습니다: {$profile->axis->value}");
}
if (! $this->option('json')) {
$this->line(sprintf('▶ %s (%s)', $profile->qualifiedKey(), $profile->axis->label()));
}
$onProgress = $this->option('json')
? null
: fn (string $message) => $this->line(' '.$message);
return $runner->run($profile, $options, $onProgress);
}
/**
* 축에 대응하는 실행기를 찾습니다.
*
* @param BenchmarkAxis $axis 대상 축
* @return BenchmarkAxisRunner|null 실행기 (없으면 null)
*/
private function runnerFor(BenchmarkAxis $axis): ?BenchmarkAxisRunner
{
foreach ($this->runners as $runner) {
if ($runner->axis() === $axis) {
return $runner;
}
}
return null;
}
/**
* 무시된 프로파일 선언을 알립니다.
*
* 선언 오류를 조용히 버리면 그 대상이 계측 사각으로 남으므로 매 실행에 드러냅니다.
*/
private function reportCollectionWarnings(): void
{
if ($this->option('json')) {
return;
}
foreach ($this->registry->warnings() as $warning) {
$this->warn('무시된 프로파일 선언 — '.$warning);
}
}
/**
* 계측 결과를 출력합니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @return int 종료 코드
*/
private function output(array $results, BenchmarkRunOptions $options): int
{
$warnings = $this->registry->warnings();
if ($this->option('json')) {
$this->line($this->reporter->toJson($results, $options, $warnings));
} else {
foreach ($results as $result) {
$this->newLine();
$this->line(sprintf('%s (%s)', $result->profile->qualifiedKey(), $result->profile->axis->label()));
if ($result->skipped) {
$this->warn(' 측정하지 않음: '.$result->skipReason);
continue;
}
$this->table($result->headers, $result->rows);
foreach ($result->notes as $note) {
$this->line(' '.$note);
}
}
}
if ($this->wantsReport()) {
$path = $this->writeReport($results, $options, $warnings);
$this->newLine();
$this->info('리포트 저장: '.$path);
}
// 전부 건너뛴 실행을 성공으로 보고하면 "측정했다"로 읽히므로 실패로 돌려준다
$measured = array_filter($results, fn (BenchmarkResult $result) => ! $result->skipped);
return $measured === [] ? self::FAILURE : self::SUCCESS;
}
/**
* 리포트 출력이 요청되었는지 판정합니다.
*
* `--report` 는 값이 선택이라 `option()` 만으로는 "미지정"과 "값 없이 지정"을
* 구분할 수 없으므로 원시 입력을 확인합니다.
*
* @return bool 리포트 출력 여부
*/
private function wantsReport(): bool
{
return $this->input->hasParameterOption('--report')
|| $this->input->hasParameterOption('--report=')
|| (string) $this->option('report') !== '';
}
/**
* 마크다운 리포트를 저장합니다.
*
* @param array<int, BenchmarkResult> $results 계측 결과
* @param BenchmarkRunOptions $options 실행 옵션
* @param array<int, string> $warnings 프로파일 수집 경고
* @return string 저장된 절대 경로
*/
private function writeReport(array $results, BenchmarkRunOptions $options, array $warnings): string
{
$given = (string) $this->option('report');
$path = $given !== ''
? $given
: storage_path(self::REPORT_DIR.'/bench-'.now()->format('Ymd-His').'.md');
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0755, true);
}
file_put_contents($path, $this->reporter->toMarkdown($results, $options, $warnings));
return $path;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
/**
* 오래된 확장 프론트엔드 병합 번들 파일을 정리하는 커맨드
*
* 번들 캐시 파일(`storage/app/ext-bundles/{type}.{version}.{js|css}`)은 Laravel
* 캐시 스토어 밖 파일시스템이라 version bump 나 cache:clear 가 구버전 파일을
* 지우지 않는다(TTL/GC 없음). 이 커맨드가 현재 version 외의 구파일을 삭제한다.
* (`CleanupLayoutPreviewsCommand` 파일 산출물 GC 패턴 미러)
*/
class CleanupExtensionBundlesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'ext-bundles:cleanup';
/**
* The console command description.
*/
protected $description = '오래된 확장 프론트엔드 병합 번들 파일(구 version)을 삭제합니다';
/**
* Execute the console command.
*
* @param ExtensionBundleService $service 번들 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ExtensionBundleService $service): int
{
$currentVersion = $service->getCurrentVersion();
$deleted = $service->cleanupStaleBundles($currentVersion);
$this->info("오래된 확장 번들 {$deleted}건이 삭제되었습니다. (현재 version: {$currentVersion})");
return self::SUCCESS;
}
}
@@ -109,7 +109,7 @@ class ConvertJsonUnicodeEscapes extends Command
/**
* 테이블 존재 여부를 확인합니다.
*
* @param string $table 테이블명 (프리픽스 포함)
* @param string $table 테이블명 (프리픽스 포함)
* @return bool 존재 여부
*/
private function tableExists(string $table): bool
@@ -126,10 +126,10 @@ class ConvertJsonUnicodeEscapes extends Command
/**
* 특정 테이블의 특정 컬럼을 변환합니다.
*
* @param string $table 테이블명
* @param string $column 컬럼명
* @param int $chunkSize 청크 크기
* @param bool $dryRun 드라이런 모드
* @param string $table 테이블명
* @param string $column 컬럼명
* @param int $chunkSize 청크 크기
* @param bool $dryRun 드라이런 모드
* @return array{int, int} [변환 건수, 스킵 건수]
*/
private function processColumn(string $table, string $column, int $chunkSize, bool $dryRun): array
@@ -152,11 +152,13 @@ class ConvertJsonUnicodeEscapes extends Command
$this->components->twoColumnDetail("{$table}.{$column}", "대상 {$total}건 처리 중...");
// chunkById(키셋 순회) 필수 — 콜백이 필터 조건 컬럼을 UTF-8 로 다시 써서 처리된
// 행이 `LIKE '%\u%'` 결과에서 이탈한다. OFFSET 기반 chunk() 는 그만큼 앞으로 밀려
// 아직 변환되지 않은 행을 건너뛴다. (대상 테이블 8종 모두 `id` auto-increment PK)
DB::table($table)
->whereNotNull($column)
->where($column, 'LIKE', '%\\\\u%')
->orderBy('id')
->chunk($chunkSize, function ($rows) use ($table, $column, $dryRun, &$converted, &$skipped) {
->chunkById($chunkSize, function ($rows) use ($table, $column, $dryRun, &$converted, &$skipped) {
foreach ($rows as $row) {
$original = $row->{$column};
$decoded = json_decode($original, true);
+34 -5
View File
@@ -94,9 +94,12 @@ class BuildCoreCommand extends Command
return $this->runWatchBundles($projectPath);
}
// 3개 번들을 npm 스크립트로 순차 호출하므로 빌드 환경변수를 세 곳 모두에 주입한다.
$buildEnv = $this->buildEnv($productionMode);
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true);
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true, $buildEnv);
if ($engineResult !== Command::SUCCESS) {
return $engineResult;
@@ -104,7 +107,7 @@ class BuildCoreCommand extends Command
// ── 2) 레이아웃 편집기 번들 (lazy, /admin/layout-editor/* 진입 시 로드) ──
$this->info('🔨 코어 빌드 시작 (레이아웃 편집기)'.($productionMode ? ' (프로덕션)' : ''));
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true);
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true, $buildEnv);
if ($editorResult !== Command::SUCCESS) {
$this->error('❌ 레이아웃 편집기 번들 빌드 실패');
@@ -114,7 +117,7 @@ class BuildCoreCommand extends Command
// ── 3) DevTools 번들 (lazy, 디버그 모드에서만 로드) ──
$this->info('🔨 코어 빌드 시작 (DevTools)'.($productionMode ? ' (프로덕션)' : ''));
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true);
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true, $buildEnv);
if ($devtoolsResult !== Command::SUCCESS) {
$this->error('❌ DevTools 번들 빌드 실패');
@@ -197,7 +200,13 @@ class BuildCoreCommand extends Command
$this->info('🔨 코어 빌드 시작 (전체)'.($productionMode ? ' (프로덕션)' : ''));
}
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
// 감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요
$result = $this->runNpmCommand(
$buildCommand,
$projectPath,
! $watchMode,
$watchMode ? [] : $this->buildEnv($productionMode)
);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (전체)');
@@ -311,15 +320,30 @@ class BuildCoreCommand extends Command
}
}
/**
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
*
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
*
* @param bool $productionMode 프로덕션 빌드 여부
* @return array<string, string> Process 에 주입할 환경변수
*/
private function buildEnv(bool $productionMode): array
{
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
@@ -328,6 +352,11 @@ class BuildCoreCommand extends Command
$process = new Process($command);
$process->setWorkingDirectory($cwd);
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
if ($env !== []) {
$process->setEnv($env);
}
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
@@ -7,6 +7,7 @@ use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Search\SearchIndexMaintenanceManager;
use App\Services\CoreUpdateService;
use App\Services\LanguagePackService;
use Illuminate\Support\Facades\File;
@@ -109,6 +110,10 @@ trait BundledExtensionUpdatePrompt
// 확장별 전략 오버라이드
$strategies = $this->collectPerExtensionStrategies($updates, $globalStrategy, $force);
// 검색 인덱스 재생성 여부 — 인덱스 잠금·재색인 비용이 있어 운영 중인 사이트에서는
// 기본값(아니오)을 유지하고 유지보수 시간에 별도로 수행하는 것이 안전하다.
$rebuildSearchIndex = $this->askRebuildSearchIndex($force);
// 일괄 실행
return $this->executeBulkUpdate(
$moduleManager,
@@ -116,6 +121,7 @@ trait BundledExtensionUpdatePrompt
$templateManager,
$updates,
$strategies,
$rebuildSearchIndex,
);
}
@@ -200,8 +206,11 @@ trait BundledExtensionUpdatePrompt
TemplateManager $templateManager,
array $updates,
array $strategies,
bool $rebuildSearchIndex = false,
): array {
$manifest = $this->buildBundledUpdateManifest($updates, $strategies);
// 운영자의 선택을 매니페스트에 실어 spawn 자식과 in-process fallback 이 같은 결정을 따르게 한다
$manifest['rebuild_search_index'] = $rebuildSearchIndex;
if ($this->bundledManifestIsEmpty($manifest)) {
return ['success' => 0, 'failed' => 0, 'skipped' => 0, 'has_updates' => false];
}
@@ -228,6 +237,7 @@ trait BundledExtensionUpdatePrompt
$templateManager,
$updates,
$strategies,
$rebuildSearchIndex,
);
}
@@ -385,6 +395,7 @@ trait BundledExtensionUpdatePrompt
TemplateManager $templateManager,
array $updates,
array $strategies,
bool $rebuildSearchIndex = false,
): array {
$success = 0;
$failed = 0;
@@ -452,6 +463,8 @@ trait BundledExtensionUpdatePrompt
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
$this->runSearchIndexRebuildIfRequested($rebuildSearchIndex);
return [
'success' => $success,
'failed' => $failed,
@@ -460,4 +473,60 @@ trait BundledExtensionUpdatePrompt
];
}
/**
* 검색 인덱스 재생성 여부를 운영자에게 확인합니다.
*
* 재생성은 인덱스 잠금 또는 전체 재색인을 유발하므로 기본값은 "아니오" 입니다.
* `--force` (무인 실행) 시에도 묻지 않고 수행하지 않습니다 — 무인 실행이
* 대용량 테이블을 잠그는 일이 없어야 합니다. 필요하면 `core:update
* --rebuild-search-index` 로 명시하거나 이후 `search:index --repair` 를 실행합니다.
*
* @param bool $force 강제(무인) 실행 여부
* @return bool 재생성 수행 여부
*/
private function askRebuildSearchIndex(bool $force): bool
{
// 커맨드가 옵션으로 이미 선택을 받았으면 그대로 따른다
if ($this->hasOption('rebuild-search-index') && (bool) $this->option('rebuild-search-index')) {
return true;
}
if ($force) {
return false;
}
$manager = app(SearchIndexMaintenanceManager::class);
// 점검을 제공하지 않는 엔진에서는 물을 것이 없다
if (! $manager->hasMaintainer() || $manager->unavailableReason() !== null) {
return false;
}
$this->newLine();
$this->line(' '.__('search.index.rebuild_cost_warning'));
return $this->unifiedConfirm(__('search.index.rebuild_after_bulk_confirm'), false);
}
/**
* 요청이 있었으면 검색 인덱스를 재생성합니다.
*
* @param bool $requested 재생성 요청 여부
* @return void
*/
private function runSearchIndexRebuildIfRequested(bool $requested): void
{
if (! $requested) {
return;
}
$report = app(SearchIndexMaintenanceManager::class)->repairStale();
$this->newLine();
$this->info('검색 인덱스: '.$report->summary());
foreach ($report->failed as $identifier => $message) {
$this->warn(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
}
}
}
+104 -4
View File
@@ -8,6 +8,7 @@ use App\Exceptions\UpgradeHandoffException;
use App\Extension\CoreVersionChecker;
use App\Extension\Helpers\CoreBackupHelper;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\Helpers\StorageLinkHelper;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
@@ -16,6 +17,7 @@ use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
use App\Support\ConfigCacheHelper;
use App\Support\RouteCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -28,11 +30,13 @@ class CoreUpdateCommand extends Command
protected $signature = 'core:update
{--force : 버전 비교 없이 강제 업데이트}
{--no-backup : 백업 생성 건너뛰기}
{--prune : 코어가 제거한 파일 정리 + targets 전체 덮어쓰기(기존 방식). 미지정 시 코어가 실제 변경/추가한 파일만 적용하고 나머지는 보존}
{--no-maintenance : 유지보수 모드 활성화 건너뛰기}
{--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)}
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}
{--zip= : 수동 업데이트용 ZIP 파일 경로 (GitHub 다운로드 대신 지정 ZIP 추출 사용)}
{--vendor-mode=auto : vendor 설치 모드 (auto|composer|bundled)}';
{--vendor-mode=auto : vendor 설치 모드 (auto|composer|bundled)}
{--rebuild-search-index : 번들 확장 일괄 업데이트 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
protected $description = '그누보드7 코어를 최신 버전으로 업데이트합니다';
@@ -346,6 +350,12 @@ class CoreUpdateCommand extends Command
//
// `--no-backup` 모드면 backupPath 가 null 이므로 manifest 생성을 스킵 — 롤백
// 자체가 불가능한 모드이므로 기존 동작 유지.
// 증분 적용(3-way) 대상 목록. 백업 있음 + --prune 미지정 시에만 산출.
// null 로 남으면(백업 부재 또는 --prune) applyUpdate 가 전체 덮어쓰기로 회귀.
$applyList = null;
$applyStats = null;
$prune = (bool) $this->option('prune');
if ($backupPath !== null) {
$bar->setMessage('신규 파일 manifest 생성 중...');
$log('신규 파일 manifest 생성 시작');
@@ -371,15 +381,51 @@ class CoreUpdateCommand extends Command
'error' => $manifestError->getMessage(),
]);
}
// ── 증분 적용 대상 산출 (3-way) ──
// --prune 이면 전체 덮어쓰기이므로 산출 자체를 스킵.
if (! $prune) {
try {
$applyStats = CoreBackupHelper::computeApplyList(
$backupPath,
$pendingPath,
(array) config('app.update.targets', []),
(array) config('app.update.protected_paths', []),
(array) config('app.update.excludes', []),
);
$applyList = $applyStats['apply'];
$log(sprintf(
'증분 적용 대상 산출 완료 (added=%d, changed=%d, total=%d)',
$applyStats['added_count'],
$applyStats['changed_count'],
count($applyList),
));
} catch (\Throwable $applyError) {
// 산출 실패 시 안전하게 전체 덮어쓰기로 회귀 (applyList=null 유지)
$applyList = null;
$applyStats = null;
$log("증분 적용 대상 산출 실패 (전체 덮어쓰기로 회귀): {$applyError->getMessage()}");
Log::warning('코어 업데이트: 증분 적용 대상 산출 실패', [
'error' => $applyError->getMessage(),
]);
}
}
}
// ── Step 7: 파일 적용 ──
$bar->setMessage(__('settings.core_update.step_apply'));
$bar->advance();
$log('코어 파일 덮어쓰기 시작');
$service->applyUpdate($pendingPath, $onProgress);
$log('코어 파일 덮어쓰기 완료');
if ($prune) {
$log('코어 파일 덮어쓰기 시작 (--prune: 전체 덮어쓰기 + orphan 정리)');
} elseif ($applyList !== null) {
$log(sprintf('코어 파일 적용 시작 (증분: 코어 변경분 %d건만 적용, 나머지 보존)', count($applyList)));
} else {
$log('코어 파일 덮어쓰기 시작 (백업 부재 — 전체 덮어쓰기로 회귀)');
}
$service->applyUpdate($pendingPath, $onProgress, prune: $prune, applyList: $applyList);
$log('코어 파일 적용 완료');
// ── Step 8: vendor 디렉토리 복사 (_pending → 운영) ──
if ($composerSkipped) {
@@ -474,6 +520,12 @@ class CoreUpdateCommand extends Command
$this->surfacePermissionWarnings($service, $log);
$log('업데이트 경로 소유권 복원 완료');
// 소유권 복원 완료 후 · 유지보수 해제 전에 `public/storage` symlink 를 방어 복구한다.
// `--prune` 이 릴리즈 소스에 없는 symlink 를 orphan 으로 삭제했거나(#43) 손상된
// 경우에도, 첫 요청부터 `/storage/...` 가 200 을 반환하도록 링크를 재생성한다.
StorageLinkHelper::ensurePublicStorageLink();
$log('public/storage symlink 확인/복구 완료');
$service->cleanupPending($pendingPath);
if ($backupPath) {
@@ -492,6 +544,11 @@ class CoreUpdateCommand extends Command
// 상태를 가드하고 실패를 안전하게 흡수한다.
ConfigCacheHelper::rebuild();
// 라우트 캐시도 같은 이유로 되살린다. 코어 업데이트는 routes/*.php 와 vendor 를
// 교체하므로 흐름 중간에 비우는 것이 맞지만, 비운 채로 끝내면 이후 모든 요청이
// 라우트를 다시 등록한다. 재생성은 파일이 전부 안착한 이 지점에서만 안전하다.
RouteCacheHelper::rebuild();
$log('정리 완료');
$bar->finish();
@@ -503,6 +560,9 @@ class CoreUpdateCommand extends Command
$this->info("그누보드7 코어가 {$toVersion} 버전으로 업데이트되었습니다!");
$this->newLine();
// ── 파일 적용 방식 요약 (부수의무: 설명의무) ──
$this->summarizeApplyMode($prune, $applyList, $applyStats);
// _bundled 확장 일괄 업데이트 프롬프트 (번들에 신버전이 있을 때만 표시).
//
// 주의: Step 11 restoreOwnership 이후에 실행되므로, 프롬프트 내 실제
@@ -577,6 +637,11 @@ class CoreUpdateCommand extends Command
$this->surfacePermissionWarnings($service, $log);
$log('핸드오프 cleanup 완료 (버전 toVersion 고정 + 캐시 clear + 소유권 복원)');
// 핸드오프 시에도 파일은 이미 toVersion 으로 반영돼 `public/storage` symlink 가
// 삭제/손상됐을 수 있으므로 유지보수 해제 전에 동일하게 방어 복구한다(#43).
StorageLinkHelper::ensurePublicStorageLink();
$log('public/storage symlink 확인/복구 완료 (핸드오프)');
if (! empty($pendingPath)) {
$service->cleanupPending($pendingPath);
}
@@ -1068,6 +1133,41 @@ class CoreUpdateCommand extends Command
return true;
}
/**
* 파일 적용 방식(증분/prune/fallback)과 통계를 사용자에게 요약 출력합니다.
*
* @param bool $prune --prune 지정 여부
* @param array<int, string>|null $applyList 증분 적용 대상 목록 (null 이면 전체 덮어쓰기)
* @param array{apply:array<int,string>, added_count:int, changed_count:int, has_symlink:bool}|null $applyStats computeApplyList 산출 통계
*/
private function summarizeApplyMode(bool $prune, ?array $applyList, ?array $applyStats): void
{
if ($prune) {
$this->line(__('settings.core_update.apply_mode_prune'));
$this->newLine();
return;
}
if ($applyList === null) {
// 백업 부재 또는 산출 실패 → 전체 덮어쓰기로 회귀
$this->warn(__('settings.core_update.apply_mode_fallback'));
$this->newLine();
return;
}
// 증분 모드
$added = $applyStats['added_count'] ?? 0;
$changed = $applyStats['changed_count'] ?? 0;
$this->line(__('settings.core_update.apply_mode_incremental', [
'added' => $added,
'changed' => $changed,
]));
$this->line(__('settings.core_update.apply_mode_incremental_prune_hint'));
$this->newLine();
}
/**
* 업데이트 로그를 파일로 저장합니다.
*
@@ -2,10 +2,12 @@
namespace App\Console\Commands\Core;
use App\Extension\ExtensionManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Search\SearchIndexMaintenanceManager;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
@@ -45,7 +47,8 @@ class ExecuteBundledUpdatesCommand extends Command
protected $signature = 'core:execute-bundled-updates
{--manifest= : 업데이트 매니페스트 JSON 파일 경로 (필수)}
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}';
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}
{--rebuild-search-index : 일괄 업데이트 후 검색 인덱스 재생성 (매니페스트의 선택보다 우선)}';
protected $description = '번들 확장 일괄 업데이트를 실행합니다 (CoreUpdateCommand 내부용 — fresh PHP 프로세스에서 호출)';
@@ -63,7 +66,7 @@ class ExecuteBundledUpdatesCommand extends Command
// (Artisan::call 대신 직접 메서드 호출 — nested Artisan::call 이 outer 명령의
// output buffer 를 덮어쓰는 Laravel 동작 회피)
try {
app(\App\Extension\ExtensionManager::class)->updateComposerAutoload();
app(ExtensionManager::class)->updateComposerAutoload();
} catch (\Throwable $e) {
Log::warning('bundled update spawn 자식: updateComposerAutoload 호출 실패', [
'error' => $e->getMessage(),
@@ -77,6 +80,9 @@ class ExecuteBundledUpdatesCommand extends Command
return self::FAILURE;
}
// 부모(core:update)가 운영자 선택을 매니페스트에 실어 보낸다 — 자식이 임의로 정하지 않는다
$rebuildSearchIndex = (bool) $this->option('rebuild-search-index') || (bool) ($manifest['rebuild_search_index'] ?? false);
$modules = $manifest['modules'] ?? [];
$plugins = $manifest['plugins'] ?? [];
$templates = $manifest['templates'] ?? [];
@@ -166,6 +172,17 @@ class ExecuteBundledUpdatesCommand extends Command
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
// 검색 인덱스 재생성 — 운영자가 부모 단계에서 선택했을 때만 수행 (인덱스 잠금·재색인 비용)
if ($rebuildSearchIndex) {
$report = app(SearchIndexMaintenanceManager::class)->repairStale();
$this->newLine();
$this->info('검색 인덱스: '.$report->summary());
foreach ($report->failed as $identifier => $message) {
$this->warn(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
}
}
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
$this->line(self::RESULT_PREFIX.json_encode([
'success' => $success,
@@ -174,5 +191,4 @@ class ExecuteBundledUpdatesCommand extends Command
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
@@ -12,6 +12,8 @@ use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Services\CoreUpdateService;
use App\Support\ConfigCacheHelper;
use App\Support\RouteCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -251,7 +253,10 @@ class ExecuteUpgradeStepsCommand extends Command
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다.
\App\Support\ConfigCacheHelper::rebuild();
ConfigCacheHelper::rebuild();
// 라우트 캐시도 같은 이유로 되살린다 — 업그레이드 스텝이나 번들 확장 업데이트가
// 라우트를 바꿨을 수 있고, 비운 채로 끝내면 이후 모든 요청이 라우트를 재등록한다.
RouteCacheHelper::rebuild();
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
@@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Services\IdentityLogService;
use Illuminate\Console\Command;
/**
* 만료 시각이 지난 본인인증 challenge 를 만료 상태로 전환하는 커맨드
*
* 상태 전환만 수행하며 행을 삭제하지 않습니다 (물리 파기는 identity:prune-logs 담당).
*/
class ExpireIdentityChallengesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'identity:expire-challenges';
/**
* The console command description.
*/
protected $description = '만료 시각이 지난 본인인증 challenge 를 만료 처리합니다';
/**
* Execute the console command.
*
* @param IdentityLogService $service 본인인증 이력 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(IdentityLogService $service): int
{
$expired = $service->expirePastDue();
$this->info("본인인증 challenge {$expired}건이 만료 처리되었습니다.");
return self::SUCCESS;
}
}
@@ -41,6 +41,12 @@ class UpdateAutoloadCommand extends Command
$this->info('오토로드 파일이 성공적으로 생성되었습니다.');
$this->line(' → bootstrap/cache/autoload-extensions.php');
// 방금 생성한 PSR-4 매핑을 현재 프로세스의 ClassLoader 에 즉시 반영.
// 이 커맨드는 매핑이 깨진 상태를 복구하는 경로이기도 한데, 재등록 없이 아래
// loadModules/loadPlugins 를 호출하면 부팅 시점의 (깨진) 매핑으로 확장 클래스를
// 찾지 못해 훅 캐시가 modules/plugins 0건으로 생성된다 — 모든 확장 훅이 조용히 소실.
$extensionManager->reregisterRuntimeAutoload();
// 오토로드 캐시와 동일 생명주기의 정적 훅 매핑 캐시도 함께 재생성.
// 코어 업데이트(clearAllCaches → 이 커맨드) 시 코어 리스너 추가/변경/삭제가
// 캐시에 반영되도록 보장한다 (미갱신 시 stale 매핑으로 훅 발화 누락/과잉 위험).
@@ -2,13 +2,16 @@
namespace App\Console\Commands\LanguagePack;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use App\Models\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
/**
* 설치된 언어팩 목록을 출력합니다.
* 언어팩 목록을 출력합니다.
*
* 모듈/플러그인/템플릿의 list 커맨드와 동일한 운영 도구.
* 모듈/플러그인/템플릿의 list 커맨드와 동일한 운영 도구. Service::list() 를 경유하므로
* 설치된 DB 행뿐 아니라 "번들에 있으나 미설치"(uninstalled) 및 "active 로 기록됐으나
* 설치본 파일 부재(드리프트)" 상태를 함께 표면화한다(이슈 #496).
*/
class ListLanguagePackCommand extends Command
{
@@ -20,13 +23,13 @@ class ListLanguagePackCommand extends Command
/**
* @var string
*/
protected $description = '설치된 언어팩 목록을 출력합니다.';
protected $description = '언어팩 목록을 출력합니다 (미설치 번들 및 드리프트 상태 포함).';
/**
* @param LanguagePackRepositoryInterface $repository Repository
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackRepositoryInterface $repository,
private readonly LanguagePackService $service,
) {
parent::__construct();
}
@@ -43,11 +46,11 @@ class ListLanguagePackCommand extends Command
$filters['scope'] = $scope;
}
$paginator = $this->repository->paginate($filters, 100);
$paginator = $this->service->list($filters, 100);
$packs = $paginator->items();
if (empty($packs)) {
$this->info('설치된 언어팩이 없습니다.');
$this->info('언어팩이 없습니다.');
return self::SUCCESS;
}
@@ -61,7 +64,7 @@ class ListLanguagePackCommand extends Command
$pack->locale,
$pack->vendor,
$pack->version,
$pack->status,
$this->formatStatus($pack),
];
}
@@ -72,4 +75,19 @@ class ListLanguagePackCommand extends Command
return self::SUCCESS;
}
/**
* 표시용 상태 문자열을 만듭니다. 드리프트(active + 설치본 부재)는 명시적으로 표기합니다.
*
* @param LanguagePack $pack 대상 팩
* @return string 표시 상태
*/
private function formatStatus(LanguagePack $pack): string
{
if ((bool) $pack->getAttribute('files_missing')) {
return $pack->status.' (파일 없음)';
}
return (string) $pack->status;
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Models\LanguagePack;
use App\Services\LanguagePack\LanguagePackBaseLocales;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 정책(supported_locales) 기준으로 미설치 번들 언어팩을 멱등 설치(프로비저닝)합니다.
*
* fresh install / 시더 / 수동 복구가 공유하는 단일 프로비저닝 SSoT 입니다.
* base locale(ko/en)은 가상 보호 행으로 항상 서빙되므로 대상에서 제외되고,
* 나머지 로케일(예: ja)은 `lang-packs/_bundled/` 에 소스가 있어도 설치본 디렉토리로
* 복사·등록되어야 서빙되므로 이 커맨드가 그 프로비저닝을 담당합니다.
*
* 대상은 두 갈래입니다 — 미설치 번들 팩(신규 프로비저닝)과, 설치 행은 있으나 설치본 파일이
* 사라진 드리프트 팩(수동 복구). 후자는 슬롯이 점유돼 있어 `getUninstalledBundledPacks()` 가
* 잡지 못하므로 `getDriftedInstalledPacks()` 로 따로 모읍니다. 이 커맨드가 복구 경로를 겸하지 않으면
* 드리프트는 화면에서 보이기만 하고 CLI 로는 고칠 수 없습니다.
*
* 멱등성: 정상 설치된 팩은 두 집합 어디에도 속하지 않으므로 재실행 시 신규 설치 0 건으로 수렴합니다.
*/
class ProvisionLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:provision
{--locale=* : 대상 로케일(미지정 시 supported_locales 에서 base locale 제외)}
{--scope=* : core/module/plugin/template 로 대상 스코프 한정}
{--no-activate : 설치 후 자동 활성화하지 않음}';
/**
* @var string
*/
protected $description = '정책(supported_locales) 기준 미설치 번들 언어팩을 멱등 설치합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$targetLocales = $this->resolveTargetLocales();
if (empty($targetLocales)) {
$this->info('프로비저닝 대상 로케일이 없습니다 (base locale 외 supported_locales 미선언).');
return self::SUCCESS;
}
$scopeFilter = array_values(array_filter(array_map('strval', (array) $this->option('scope'))));
$autoActivate = ! (bool) $this->option('no-activate');
$inScope = fn (LanguagePack $pack) => in_array($pack->locale, $targetLocales, true)
&& (empty($scopeFilter) || in_array($pack->scope, $scopeFilter, true));
// 미설치 번들(신규 프로비저닝) + 드리프트 설치 행(수동 복구) 이 모두 대상이다.
// 드리프트 행은 슬롯이 점유돼 있어 getUninstalledBundledPacks() 가 잡지 못하므로 따로 합친다.
$candidates = $this->service->getUninstalledBundledPacks()->filter($inScope)
->concat($this->service->getDriftedInstalledPacks()->filter($inScope))
->values();
if ($candidates->isEmpty()) {
$this->info(sprintf(
'설치하거나 복구할 번들 언어팩이 없습니다 (로케일: %s). 이미 프로비저닝된 상태입니다.',
implode(', ', $targetLocales),
));
return self::SUCCESS;
}
$installed = 0;
$repaired = 0;
$skipped = 0;
$failed = 0;
foreach ($candidates as $pack) {
$identifier = (string) ($pack->getAttribute('bundled_identifier') ?? $pack->identifier);
$blockedReason = $pack->getAttribute('install_blocked_reason');
// 대상 확장 미설치/미활성 등으로 설치가 불가한 팩은 best-effort 로 건너뛴다.
if ($blockedReason !== null && $blockedReason !== '') {
$this->warn(sprintf('건너뜀 (설치 불가: %s): %s', $blockedReason, $identifier));
$skipped++;
continue;
}
try {
$isRepair = (bool) $pack->getAttribute('files_missing');
$result = $this->service->installFromBundled($identifier, $autoActivate);
$this->info(sprintf(
'%s: %s v%s (status=%s)',
$isRepair ? '복구' : '설치',
$result->identifier,
$result->version,
$result->status,
));
$isRepair ? $repaired++ : $installed++;
} catch (Throwable $e) {
$this->warn(sprintf('실패 (건너뜀): %s — %s', $identifier, $e->getMessage()));
$failed++;
}
}
$this->info(sprintf(
'프로비저닝 완료 — 설치 %d, 복구 %d, 건너뜀 %d, 실패 %d',
$installed,
$repaired,
$skipped,
$failed,
));
// 성과가 하나도 없고 실패만 있었다면 실패로 보고 (멱등 재실행/전량 스킵은 성공).
return $installed === 0 && $repaired === 0 && $failed > 0 ? self::FAILURE : self::SUCCESS;
}
/**
* 프로비저닝 대상 로케일을 해석합니다.
*
* `--locale` 지정 시 그 값, 미지정 시 `config('app.supported_locales')` 에서 base locale(ko/en)을 제외한 집합.
*
* @return array<int, string> 대상 로케일 목록 (base locale 제외)
*/
private function resolveTargetLocales(): array
{
$explicit = array_values(array_filter(array_map('strval', (array) $this->option('locale'))));
$source = ! empty($explicit)
? $explicit
: array_map('strval', (array) config('app.supported_locales', []));
return array_values(array_unique(array_filter(
$source,
fn (string $locale) => $locale !== '' && ! LanguagePackBaseLocales::isBaseLocale($locale),
)));
}
}
@@ -96,6 +96,8 @@ class MigrateSettingsToJsonCommand extends Command
'debug_mode' => 'debug',
'sql_query_log' => 'debug',
'log_level' => 'debug',
'outbound_proxy' => 'debug',
'outbound_proxy_bypass' => 'debug',
];
/**
@@ -189,8 +189,13 @@ class BuildModuleCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 빌드 실행
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
$buildPath,
! $watchMode,
$watchMode ? [] : $this->buildEnv($productionMode)
);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info("✅ 빌드 완료: {$identifier}");
@@ -338,15 +343,30 @@ class BuildModuleCommand extends Command
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
*
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
*
* @param bool $productionMode 프로덕션 빌드 여부
* @return array<string, string> Process 에 주입할 환경변수
*/
private function buildEnv(bool $productionMode): array
{
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
@@ -355,6 +375,11 @@ class BuildModuleCommand extends Command
$process = new Process($command);
$process->setWorkingDirectory($cwd);
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
if ($env !== []) {
$process->setEnv($env);
}
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
@@ -116,7 +116,7 @@ class CheckModuleUpdatesCommand extends Command
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
$isUpdate = (bool) ($detail['update_available'] ?? true);
if ($isUpdate) {
$updateCount++;
}
@@ -143,7 +143,7 @@ class CheckModuleUpdatesCommand extends Command
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('modules.commands.check_updates.summary', [
'total' => count($result['details']),
'total' => $result['checked_count'] ?? count($result['details']),
'updates' => $updateCount,
]));
@@ -2,14 +2,22 @@
namespace App\Console\Commands\Module;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\InvalidatesLayoutCache;
use App\Services\ExtensionBundleService;
use App\Services\LayoutResolverService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearModuleCacheCommand extends Command
{
use ClearsTemplateCaches;
use InvalidatesLayoutCache;
/**
* The name and signature of the console command.
*/
@@ -26,14 +34,19 @@ class ClearModuleCacheCommand extends Command
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository,
private CacheInterface $cache
protected LayoutRepositoryInterface $layoutRepository,
private ExtensionBundleService $bundleService,
private LayoutResolverService $layoutResolver
) {
parent::__construct();
}
/**
* Execute the console command.
*
* 실재하는 서버 캐시(모듈 레이아웃 서빙/해석 캐시 + 상태 키 + 미들웨어 인덱스
* + 버전 포함 키)를 무효화한다.
* 종전 forget 대상(module.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
*/
public function handle(): int
{
@@ -59,6 +72,11 @@ class ClearModuleCacheCommand extends Command
$clearedCount = $this->clearModuleCache($identifier);
// 상태 키(ext.modules.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
ModuleManager::invalidateModuleStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('modules.commands.cache_clear.success_single', [
'module' => $identifier,
'count' => $clearedCount,
@@ -67,25 +85,19 @@ class ClearModuleCacheCommand extends Command
// 모든 모듈 캐시 삭제
$this->info(__('modules.commands.cache_clear.clearing_all'));
// 전체 모듈 캐시 키 삭제
$cacheKeys = [
'modules.all',
'modules.active',
'modules.installed',
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
// 각 모듈별 캐시 삭제
$allModules = $this->moduleManager->getAllModules();
foreach ($allModules as $moduleName => $module) {
foreach ($this->moduleManager->getAllModules() as $module) {
$clearedCount += $this->clearModuleCache($module->getIdentifier());
}
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
ModuleManager::invalidateModuleStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
// 모듈 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('module');
$this->info('✅ '.__('modules.commands.cache_clear.success_all', ['count' => $clearedCount]));
}
@@ -108,26 +120,23 @@ class ClearModuleCacheCommand extends Command
/**
* 특정 모듈의 캐시를 삭제합니다.
*
* 모듈이 소유한 실재 서버 캐시는 레이아웃 캐시(서빙/병합 + 해석)다 — 각각
* 트레이트(InvalidatesLayoutCache)와 LayoutResolverService 단일 지점으로
* 위임해 키 규약 드리프트를 방지한다. 해석 캐시(layout_resolver.*)는 버전
* 접미사 없는 고정 키에 레이아웃 row ID 를 저장하므로 능동 삭제하지 않으면
* TTL 동안 이전 해석 결과가 유지된다 (#588 동종 보강).
*
* @param string $identifier 모듈 식별자
* @return int 캐시를 무효화한 레이아웃 수
*/
private function clearModuleCache(string $identifier): int
{
$clearedCount = 0;
$this->invalidateExtensionLayoutCache($identifier, 'module');
$this->layoutResolver->clearResolutionCacheByModule($identifier);
// 모듈별 캐시 키
$cacheKeys = [
"module.config.{$identifier}",
"module.info.{$identifier}",
"module.routes.{$identifier}",
"module.permissions.{$identifier}",
"module.menus.{$identifier}",
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
return $clearedCount;
return $this->layoutRepository
->getBySourceIdentifier($identifier, LayoutSourceType::Module)
->count();
}
}
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
@@ -17,6 +18,7 @@ use Illuminate\Support\Facades\Validator;
class InstallModuleCommand extends Command
{
use HasProgressBar;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -24,7 +26,8 @@ class InstallModuleCommand extends Command
protected $signature = 'module:install
{identifier : 설치할 모듈 식별자}
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}';
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -114,6 +117,9 @@ class InstallModuleCommand extends Command
Log::info(__('modules.commands.install.success', ['module' => $identifier]));
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ModuleManager;
@@ -16,6 +17,7 @@ class UpdateModuleCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -26,7 +28,8 @@ class UpdateModuleCommand extends Command
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 module.json 기준)}';
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 module.json 기준)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -203,6 +206,9 @@ class UpdateModuleCommand extends Command
'layout_strategy' => $layoutStrategy,
]);
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
+103 -21
View File
@@ -37,10 +37,15 @@ use Illuminate\Support\Facades\Config;
class PlaywrightIssueToken extends Command
{
protected $signature = 'playwright:issue-token
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}';
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}
{--no-admin-role : admin 역할을 부여하지 않고 지정한 권한만 가진 계정을 만든다 (권한 분기 검증용)}
{--gc-hours=6 : 이 시간(시)보다 오래된 playwright 테스트 유저/역할을 발급 전 정리. 0 이면 정리 안 함}';
protected $description = 'Playwright E2E 용 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS 3중 가드)';
/** 테스트 전용 역할 식별자 접두사 */
private const TEST_ROLE_PREFIX = 'playwright_test_';
public function handle(): int
{
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
@@ -61,9 +66,20 @@ class PlaywrightIssueToken extends Command
// SettingsServiceProvider::applyDebugConfig 는 bypass flag 가 있으면 settings JSON 덮어쓰기를 이미 건너뛴 상태.
Config::set('app.debug', true);
// 발급 전 오래된 테스트 아티팩트 정리 — 이 커맨드는 호출마다 유저 1명 + 역할 1개를
// 새로 만들지만 회수 주체가 없어 그대로 누적된다. 정리하지 않으면 회원/역할 관리 화면이
// 테스트 잔재로 뒤덮인다(실측: 발급 2974회 → 유저 2974명·역할 2974개 잔존).
// 시간 임계값을 두어 동시 실행 중인 다른 워커의 계정은 건드리지 않는다.
$gcHours = (int) $this->option('gc-hours');
if ($gcHours > 0) {
$this->pruneStaleTestArtifacts($gcHours);
}
$permissions = $this->option('permissions') ?: [];
$user = $this->makeAdminUser($permissions);
// --no-admin-role: 지정한 권한만 가진 계정을 만든다.
// 기본값(플래그 없음)은 종전대로 admin 역할을 함께 부여한다 — 기존 spec 무영향.
$user = $this->makeAdminUser($permissions, ! $this->option('no-admin-role'));
$token = $user->createToken('playwright-'.uniqid())->plainTextToken;
$this->line($token);
@@ -71,6 +87,54 @@ class PlaywrightIssueToken extends Command
return self::SUCCESS;
}
/**
* 임계 시간보다 오래된 playwright 테스트 유저/역할을 정리한다.
*
* 대상: `playwright_test_*` 역할과, 그 역할이 유일한 확장 역할인 유저(= 이 커맨드가
* factory 로 만든 계정). 실제 회원과 구분되도록 반드시 역할 소유 관계로만 판별한다.
*
* chunkById(키셋 순회) 필수 — 콜백이 순회 대상 행을 삭제하므로 OFFSET 기반 chunk()/each()
* 는 다음 페이지가 줄어든 결과 집합을 지나쳐 일부를 건너뛴다.
*
* @param int $hours 이 시간보다 오래된 아티팩트만 정리
* @return void
*/
private function pruneStaleTestArtifacts(int $hours): void
{
$threshold = now()->subHours($hours);
$roles = 0;
$users = 0;
Role::where('identifier', 'like', self::TEST_ROLE_PREFIX.'%')
->where('created_at', '<', $threshold)
->chunkById(100, function ($chunk) use (&$roles, &$users) {
foreach ($chunk as $role) {
// 이 역할을 가진 유저 중, 다른 playwright 역할이 없는 계정만 제거 대상
foreach ($role->users()->get() as $user) {
$otherTestRoles = $user->roles()
->where('roles.id', '!=', $role->id)
->where('roles.identifier', 'like', self::TEST_ROLE_PREFIX.'%')
->count();
if ($otherTestRoles === 0) {
$user->tokens()->delete();
$user->roles()->detach();
$user->forceDelete();
$users++;
}
}
$role->permissions()->detach();
$role->users()->detach();
$role->delete();
$roles++;
}
});
if ($roles > 0) {
$this->info("[gc] playwright 테스트 잔재 정리: 역할 {$roles}건, 유저 {$users}건");
}
}
/**
* 권한 식별자 배열로 관리자 유저를 생성하고 권한을 부여한다.
*
@@ -78,19 +142,32 @@ class PlaywrightIssueToken extends Command
* 1. User factory 로 신규 유저 생성
* 2. 권한 식별자별로 Permission 행 보장 (firstOrCreate)
* 3. uniqid 접미사로 격리된 test role 생성 + 권한 sync
* 4. admin role 보장 (firstOrCreate) + 유저-역할 부여
* 4. (withAdminRole 일 때만) admin role 보장 (firstOrCreate) + 유저-역할 부여
*
* `withAdminRole = false` 는 **권한 분기(읽기 전용 등) 검증 전용**이다. 기본값 true 는
* admin 역할을 함께 붙이므로, 요청한 권한만 가진 세션을 만들 수 없다 —
* admin 역할이 사이트의 전체 권한을 보유하기 때문에 `--permissions` 로 좁혀도
* 화면은 항상 최대 권한으로 렌더된다(실측: admin 역할 권한 263건).
*
* @param array<int, string> $permissions 부여할 권한 식별자 목록
* @param bool $withAdminRole admin 역할 동반 부여 여부
* @return User 생성된 유저
*/
private function makeAdminUser(array $permissions): User
private function makeAdminUser(array $permissions, bool $withAdminRole = true): User
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
// Role/Permission 의 name·description 은 모델에서 array 로 캐스팅된다.
// 여기서 json_encode 한 문자열을 넣으면 Eloquent 가 한 번 더 인코딩해
// 이중 인코딩된 값이 저장되고, 관리자 화면(역할 선택 등)에 JSON 원문이
// `{"ko":"\uXXXX…"}` 형태로 그대로 노출된다. 배열을 그대로 넘긴다.
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'name' => ['ko' => $identifier, 'en' => $identifier],
'description' => ['ko' => $identifier, 'en' => $identifier],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
@@ -101,28 +178,33 @@ class PlaywrightIssueToken extends Command
$testRole = Role::create([
'identifier' => 'playwright_test_'.uniqid(),
'name' => json_encode(['ko' => 'Playwright 테스트 관리자', 'en' => 'Playwright Test Admin']),
'description' => json_encode(['ko' => 'E2E 자동화 전용', 'en' => 'E2E automation only']),
'name' => ['ko' => 'Playwright 테스트 관리자', 'en' => 'Playwright Test Admin'],
'description' => ['ko' => 'E2E 자동화 전용', 'en' => 'E2E automation only'],
'is_active' => true,
]);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
if (! empty($permissionIds)) {
$testRole->permissions()->sync($permissionIds);
}
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
if ($withAdminRole) {
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => ['ko' => '관리자', 'en' => 'Admin'],
'description' => ['ko' => '시스템 관리자', 'en' => 'System Admin'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
}
// test role 은 항상 부여한다 — GC(pruneStaleTestArtifacts)가 이 역할로 테스트 계정을
// 식별하므로, 빠지면 --no-admin-role 로 만든 계정이 영구 잔존한다.
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
@@ -0,0 +1,377 @@
<?php
namespace App\Console\Commands;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\Traits\ComputesLayoutContentHash;
use App\Extension\Traits\InvalidatesLayoutCache;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\File;
/**
* Playwright E2E 용 시드 레이아웃 설치/제거 커맨드.
*
* 레이아웃 편집기 E2E 중 **저장(PUT)까지 수행하는** spec 은 편집 결과가 그대로 영속된다.
* 이들이 제품 화면(`home` / `admin_dashboard` 등)을 대상으로 하면 실행할 때마다 빈 표·차트가
* 누적돼 개발 사이트가 오염된다(실측: `home` 20,321 → 33,696 bytes, 빈 표 7개). spec 안에
* 원복 장치를 넣어도 편집기가 캐시한 문서를 다시 밀어 넣어 오염 시점 크기로 되돌아왔다.
*
* 그래서 저장 spec 전용 시드 화면(`e2e_sandbox`)을 제품 레이아웃과 분리한다. 이 커맨드가
* 그 화면을 **활성 템플릿 디렉토리에만** 설치하므로 `_bundled`(배포 원본)은 손대지 않고,
* 활성 디렉토리는 Git 무시 대상이라 릴리스 산출물에도 포함되지 않는다.
*
* 설치 절차(템플릿별):
* ① `tests/Playwright/fixtures/seed-layouts/{identifier}.e2e_sandbox.json` 을
* `templates/{identifier}/layouts/e2e_sandbox.json` 으로 복사
* ② 활성 `templates/{identifier}/routes.json` 에 라우트 1건 추가(멱등 — 마커로 식별).
* 편집기 라우트 트리는 routes.json 에서 만들어지므로 이 항목이 없으면 `?route=` 로
* 시드 화면을 열 수 없다
* ③ 시드 레이아웃 DB 행만 upsert. 편집기의 조회/저장 대상은 DB 행이라 이 단계가 없으면 404
*
* ③ 에서 `template:refresh-layout`(전체 재동기화)을 쓰지 않는 이유: 그 경로는 파일에 없는
* DB 레이아웃을 지우고 모든 레이아웃을 파일 기준으로 되돌린다. 편집기 UI 로 저장한 변경은
* 파일이 아니라 DB 에만 있으므로, E2E 를 돌릴 때마다 사람이 편집기로 만든 결과가 사라진다.
* 시드 설치는 시드 행 하나만 건드린다.
*
* `--remove` 는 ①②를 되돌리고 시드 DB 행만 삭제한다 (다른 레이아웃 무영향).
*
* 보안 가드 (2중) — `PlaywrightIssueToken` 과 동일 규약:
* ① CLI 한정 — `php_sapi_name() === 'cli'` 확인. production 웹 요청에서 절대 도달 불가
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 부여 필수.
* `.env` 영구 수정 없이 인라인 환경변수로만 활성화 가능 → 무심코 production 으로 새지 않음
*
* 호출 예시 (PowerShell):
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-layout
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:seed-layout --remove
*/
class PlaywrightSeedLayout extends Command
{
use ComputesLayoutContentHash, InvalidatesLayoutCache;
protected $signature = 'playwright:seed-layout
{--template=* : 대상 템플릿 identifier (생략 시 기본 대상 전체)}
{--remove : 시드 레이아웃/라우트를 제거한다}';
protected $description = 'Playwright E2E 전용 시드 레이아웃 설치/제거 (CLI + G7_PLAYWRIGHT_BYPASS 가드)';
/** 시드 레이아웃 이름 — 파일명·DB name·라우트 layout 이 모두 이 값을 공유한다. */
private const LAYOUT_NAME = 'e2e_sandbox';
/**
* routes.json 의 시드 라우트 식별 마커.
*
* 경로 문자열은 템플릿 타입마다 다르므로(사용자 템플릿 vs 관리자 템플릿), 제거 시에는
* 경로가 아니라 이 마커로 대상을 찾는다.
*/
private const ROUTE_MARKER = 'playwright-seed-layout';
/** 원본 routes.json 보관 파일 접미사 — 제거 시 서식까지 바이트 동일 복원용 */
private const ROUTES_BACKUP_SUFFIX = '.playwright-backup';
/**
* 기본 대상 템플릿 → 라우트 경로.
*
* 관리자 템플릿 라우트는 관리자 URL 프리픽스 규약을 따른다.
*
* @var array<string, string>
*/
private const DEFAULT_TARGETS = [
'sirsoft-basic' => '/e2e-sandbox',
'sirsoft-admin_basic' => '*/admin/e2e-sandbox',
];
public function __construct(
private LayoutRepositoryInterface $layoutRepository,
private TemplateRepositoryInterface $templateRepository
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드 (0 성공)
*/
public function handle(): int
{
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
if (php_sapi_name() !== 'cli') {
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
return self::FAILURE;
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
return self::FAILURE;
}
// settings JSON 이 debug 를 덮어써도 시드 작업이 막히지 않도록 인라인 강제
// (PlaywrightIssueToken 과 동일 근거 — bypass flag 인지 시 SettingsServiceProvider 는 이미 우회 상태).
Config::set('app.debug', true);
$remove = (bool) $this->option('remove');
$targets = $this->resolveTargets();
if ($targets === []) {
$this->error('대상 템플릿이 없습니다.');
return self::FAILURE;
}
foreach ($targets as $identifier => $routePath) {
if (! $this->processTemplate($identifier, $routePath, $remove)) {
return self::FAILURE;
}
}
return self::SUCCESS;
}
/**
* `--template` 옵션을 기본 대상 목록과 대조해 처리 대상을 결정합니다.
*
* @return array<string, string> identifier => 라우트 경로
*/
private function resolveTargets(): array
{
$requested = $this->option('template') ?: [];
if ($requested === []) {
return self::DEFAULT_TARGETS;
}
$targets = [];
foreach ($requested as $identifier) {
if (! isset(self::DEFAULT_TARGETS[$identifier])) {
$this->warn("시드 라우트 경로가 정의되지 않은 템플릿입니다 — 건너뜁니다: {$identifier}");
continue;
}
$targets[$identifier] = self::DEFAULT_TARGETS[$identifier];
}
return $targets;
}
/**
* 템플릿 1개에 대해 시드 레이아웃 설치 또는 제거를 수행합니다.
*
* @param string $identifier 템플릿 identifier
* @param string $routePath 시드 라우트 경로
* @param bool $remove true 면 제거, false 면 설치
* @return bool 성공 여부
*/
private function processTemplate(string $identifier, string $routePath, bool $remove): bool
{
$templateDir = base_path("templates/{$identifier}");
// 활성 디렉토리가 없으면 그 템플릿은 설치되지 않은 것 — 조용히 건너뛴다
// (E2E 대상 템플릿이 환경마다 다를 수 있으므로 실패로 취급하지 않는다).
if (! File::isDirectory($templateDir)) {
$this->warn("활성 템플릿 디렉토리가 없어 건너뜁니다: {$identifier}");
return true;
}
$template = $this->templateRepository->findByIdentifier($identifier);
if (! $template) {
$this->warn("템플릿이 DB 에 없어 건너뜁니다: {$identifier}");
return true;
}
$layoutPath = "{$templateDir}/layouts/".self::LAYOUT_NAME.'.json';
if ($remove) {
if (File::exists($layoutPath)) {
File::delete($layoutPath);
}
$this->removeSeedRoute($identifier, $templateDir);
$this->layoutRepository->deleteByName($template->id, self::LAYOUT_NAME);
} else {
$fixturePath = base_path(
'tests/Playwright/fixtures/seed-layouts/'.$identifier.'.'.self::LAYOUT_NAME.'.json'
);
if (! File::exists($fixturePath)) {
$this->error("시드 레이아웃 fixture 가 없습니다: {$fixturePath}");
return false;
}
$content = json_decode((string) File::get($fixturePath), true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($content)) {
$this->error("시드 레이아웃 fixture 파싱 실패: {$fixturePath}");
return false;
}
File::copy($fixturePath, $layoutPath);
$this->addSeedRoute($identifier, $templateDir, $routePath);
$this->layoutRepository->updateOrCreate(
['template_id' => $template->id, 'name' => self::LAYOUT_NAME],
[
'content' => $content,
'source_type' => 'template',
'original_content_hash' => $this->computeContentHash($content),
'original_content_size' => $this->computeContentSize($content),
]
);
}
// 편집기/공개 서빙 캐시에서 시드 레이아웃 키를 지운다. 삭제 경로에서도 캐시가 남으면
// 편집기가 stale 문서를 계속 받는다.
$this->forgetSeedLayoutCache($template->id, $identifier);
$this->info(($remove ? '제거' : '설치')." 완료: {$identifier} ({$routePath})");
return true;
}
/**
* 활성 routes.json 에 시드 라우트를 추가합니다.
*
* 재작성은 JSON 재직렬화라 원본의 주석 그룹 사이 빈 줄 같은 서식이 사라진다. 그래서 첫
* 설치 시 원본을 `.playwright-backup` 으로 보관하고, 제거 때 그 파일을 그대로 되돌린다
* (바이트 동일 복원). 백업이 이미 있으면 덮어쓰지 않는다 — 비정상 종료로 시드가 남은 상태의
* routes.json 을 "원본" 으로 굳히지 않기 위함.
*
* @param string $identifier 템플릿 identifier
* @param string $templateDir 활성 템플릿 디렉토리 절대 경로
* @param string $routePath 등록할 시드 라우트 경로
*/
private function addSeedRoute(string $identifier, string $templateDir, string $routePath): void
{
$path = "{$templateDir}/routes.json";
$routes = $this->readRoutes($identifier, $path);
if ($routes === null) {
return;
}
$backupPath = $path.self::ROUTES_BACKUP_SUFFIX;
if (! File::exists($backupPath)) {
File::copy($path, $backupPath);
}
// 마커 항목을 먼저 걷어내 반복 실행이 라우트를 중복 추가하지 않게 한다.
$entries = $this->withoutSeedRoutes($routes);
$entries[] = [
'_marker' => self::ROUTE_MARKER,
'_comment' => 'Playwright E2E 전용 시드 화면 — playwright:seed-layout 이 설치/제거한다. 제품 라우트가 아니다.',
'path' => $routePath,
'layout' => self::LAYOUT_NAME,
'auth_required' => str_starts_with($routePath, '*/admin'),
'meta' => [
'title' => 'E2E Sandbox',
],
];
$routes['routes'] = $entries;
File::put(
$path,
json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"
);
}
/**
* 활성 routes.json 에서 시드 라우트를 제거합니다.
*
* 백업이 있으면 그것을 그대로 복원해 서식까지 원상 복구한다. 백업이 없으면(다른 경로로 시드가
* 들어간 경우) 마커 항목만 걷어낸 재직렬화로 대체한다.
*
* @param string $identifier 템플릿 identifier
* @param string $templateDir 활성 템플릿 디렉토리 절대 경로
*/
private function removeSeedRoute(string $identifier, string $templateDir): void
{
$path = "{$templateDir}/routes.json";
$backupPath = $path.self::ROUTES_BACKUP_SUFFIX;
if (File::exists($backupPath)) {
File::put($path, File::get($backupPath));
File::delete($backupPath);
return;
}
$routes = $this->readRoutes($identifier, $path);
if ($routes === null) {
return;
}
$routes['routes'] = $this->withoutSeedRoutes($routes);
File::put(
$path,
json_encode($routes, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)."\n"
);
}
/**
* routes.json 을 디코드해 반환합니다 (부재/파싱 실패 시 경고 후 null).
*
* @param string $identifier 템플릿 identifier (경고 메시지용)
* @param string $path routes.json 절대 경로
* @return array<string, mixed>|null 디코드 결과
*/
private function readRoutes(string $identifier, string $path): ?array
{
if (! File::exists($path)) {
$this->warn("routes.json 이 없어 라우트 처리를 건너뜁니다: {$identifier}");
return null;
}
$routes = json_decode((string) File::get($path), true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($routes)) {
$this->warn("routes.json 을 파싱할 수 없어 라우트 처리를 건너뜁니다: {$identifier}");
return null;
}
return $routes;
}
/**
* 라우트 배열에서 시드 마커가 붙은 항목을 제외해 반환합니다.
*
* @param array<string, mixed> $routes 디코드된 routes.json
* @return array<int, mixed> 마커 항목이 제거된 라우트 배열
*/
private function withoutSeedRoutes(array $routes): array
{
return array_values(array_filter(
$routes['routes'] ?? [],
fn ($route) => ! is_array($route) || ($route['_marker'] ?? null) !== self::ROUTE_MARKER
));
}
/**
* 시드 레이아웃의 캐시 키를 삭제합니다.
*
* `InvalidatesLayoutCache::forgetLayoutCacheKeys` 는 레이아웃 객체의
* `template_id` / `name` / `source_type` / `source_identifier` 만 읽으므로,
* 삭제 후(모델 부재) 에도 동일 형태의 경량 객체로 호출할 수 있다.
*
* @param int $templateId 템플릿 ID
* @param string $identifier 템플릿 identifier (버전 포함 공개 서빙 키에 사용)
*/
private function forgetSeedLayoutCache(int $templateId, string $identifier): void
{
$this->forgetLayoutCacheKeys(
(object) [
'template_id' => $templateId,
'name' => self::LAYOUT_NAME,
'source_type' => null,
'source_identifier' => null,
],
$identifier
);
}
}
@@ -189,8 +189,13 @@ class BuildPluginCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 빌드 실행
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
$buildPath,
! $watchMode,
$watchMode ? [] : $this->buildEnv($productionMode)
);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info("✅ 빌드 완료: {$identifier}");
@@ -338,15 +343,30 @@ class BuildPluginCommand extends Command
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
*
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
*
* @param bool $productionMode 프로덕션 빌드 여부
* @return array<string, string> Process 에 주입할 환경변수
*/
private function buildEnv(bool $productionMode): array
{
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
@@ -355,6 +375,11 @@ class BuildPluginCommand extends Command
$process = new Process($command);
$process->setWorkingDirectory($cwd);
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
if ($env !== []) {
$process->setEnv($env);
}
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
@@ -116,7 +116,7 @@ class CheckPluginUpdatesCommand extends Command
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
$isUpdate = (bool) ($detail['update_available'] ?? true);
if ($isUpdate) {
$updateCount++;
}
@@ -143,7 +143,7 @@ class CheckPluginUpdatesCommand extends Command
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('plugins.commands.check_updates.summary', [
'total' => count($result['details']),
'total' => $result['checked_count'] ?? count($result['details']),
'updates' => $updateCount,
]));
@@ -2,14 +2,21 @@
namespace App\Console\Commands\Plugin;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ExtensionMiddlewareRegistry;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\InvalidatesLayoutCache;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearPluginCacheCommand extends Command
{
use ClearsTemplateCaches;
use InvalidatesLayoutCache;
/**
* The name and signature of the console command.
*/
@@ -26,14 +33,20 @@ class ClearPluginCacheCommand extends Command
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository,
private CacheInterface $cache
protected LayoutRepositoryInterface $layoutRepository,
private ExtensionBundleService $bundleService
) {
parent::__construct();
}
/**
* Execute the console command.
*
* 실재하는 서버 캐시(플러그인 레이아웃 캐시 + 상태 키 + 미들웨어 인덱스
* + 버전 포함 키)를 무효화한다.
* 종전 forget 대상(plugin.config.{id} 등)은 writer 가 없는 유령 키였다 (#588).
* 해석 캐시(layout_resolver.*)는 모듈 레이아웃 전용이라 플러그인은 비대상
* (LayoutResolverService::resolve 의 fromModules 스코프는 plugin row 를 반환하지 않음).
*/
public function handle(): int
{
@@ -59,6 +72,11 @@ class ClearPluginCacheCommand extends Command
$clearedCount = $this->clearPluginCache($identifier);
// 상태 키(ext.plugins.*) + 미들웨어 인덱스 + 버전 포함 키 무효화 (버전 bump)
PluginManager::invalidatePluginStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('plugins.commands.cache_clear.success_single', [
'plugin' => $identifier,
'count' => $clearedCount,
@@ -67,25 +85,19 @@ class ClearPluginCacheCommand extends Command
// 모든 플러그인 캐시 삭제
$this->info(__('plugins.commands.cache_clear.clearing_all'));
// 전체 플러그인 캐시 키 삭제
$cacheKeys = [
'plugins.all',
'plugins.active',
'plugins.installed',
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
// 각 플러그인별 캐시 삭제
$allPlugins = $this->pluginManager->getAllPlugins();
foreach ($allPlugins as $pluginName => $plugin) {
foreach ($this->pluginManager->getAllPlugins() as $plugin) {
$clearedCount += $this->clearPluginCache($plugin->getIdentifier());
}
// 상태 키 + 미들웨어 인덱스 + 버전 포함 키 무효화는 전체에서 1회면 충분
PluginManager::invalidatePluginStatusCache();
ExtensionMiddlewareRegistry::flush();
$this->incrementExtensionCacheVersion();
// 플러그인 프론트엔드 병합 번들 파일 삭제 (캐시 키 forget 만으로는 미삭제)
$clearedCount += $this->bundleService->clearBundles('plugin');
$this->info('✅ '.__('plugins.commands.cache_clear.success_all', ['count' => $clearedCount]));
}
@@ -108,25 +120,18 @@ class ClearPluginCacheCommand extends Command
/**
* 특정 플러그인의 캐시를 삭제합니다.
*
* 플러그인이 소유한 실재 서버 캐시는 레이아웃 캐시뿐이다 — 트레이트 단일 지점
* (InvalidatesLayoutCache)으로 위임해 키 규약 드리프트를 방지한다.
*
* @return int 캐시를 무효화한 레이아웃 수
*/
private function clearPluginCache(string $identifier): int
{
$clearedCount = 0;
$this->invalidateExtensionLayoutCache($identifier, 'plugin');
// 플러그인별 캐시 키 (메뉴 캐시 없음)
$cacheKeys = [
"plugin.config.{$identifier}",
"plugin.info.{$identifier}",
"plugin.routes.{$identifier}",
"plugin.permissions.{$identifier}",
];
foreach ($cacheKeys as $key) {
if ($this->cache->forget($key)) {
$clearedCount++;
}
}
return $clearedCount;
return $this->layoutRepository
->getBySourceIdentifier($identifier, LayoutSourceType::Plugin)
->count();
}
}
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\PluginManager;
@@ -16,6 +17,7 @@ use Illuminate\Support\Facades\Validator;
class InstallPluginCommand extends Command
{
use HasProgressBar;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -23,7 +25,8 @@ class InstallPluginCommand extends Command
protected $signature = 'plugin:install
{identifier : 설치할 플러그인 식별자}
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}';
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -111,6 +114,9 @@ class InstallPluginCommand extends Command
Log::info(__('plugins.commands.install.success', ['plugin' => $identifier]));
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Console\Commands\Traits\RebuildsSearchIndex;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\PluginManager;
@@ -16,6 +17,7 @@ class UpdatePluginCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
use RebuildsSearchIndex;
/**
* The name and signature of the console command.
@@ -26,7 +28,8 @@ class UpdatePluginCommand extends Command
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 plugin.json 기준)}';
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 plugin.json 기준)}
{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 유지보수 시간에 수행하세요)}';
/**
* The console command description.
@@ -202,6 +205,9 @@ class UpdatePluginCommand extends Command
'layout_strategy' => $layoutStrategy,
]);
// 검색 인덱스 재생성은 운영자가 선택했을 때만 수행한다 (인덱스 잠금·재색인 비용)
$this->handleSearchIndexRebuild();
return Command::SUCCESS;
}
@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Services\ActivityLogService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 활동 로그를 정리하는 커맨드
*/
class PruneActivityLogsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'activity-log:prune {--days=365 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 활동 로그를 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
*
* @param ActivityLogService $service 활동 로그 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ActivityLogService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->prune($days);
$this->info("활동 로그 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Services\IdentityLogService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 본인인증 이력을 파기하는 커맨드
*
* 관리자 화면의 수동 파기 버튼과 동일한 기본 보관주기(180일)를 사용합니다.
*/
class PruneIdentityLogsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'identity:prune-logs {--days=180 : 보관 기간(일)}';
/**
* The console command description.
*/
protected $description = '보관 기간이 지난 본인인증 이력을 파기합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
* 보존 기간 하한은 그 계층이 소유합니다.
*
* @param IdentityLogService $service 본인인증 이력 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(IdentityLogService $service): int
{
$days = (int) $this->option('days');
$purged = $service->purge($days);
$this->info("본인인증 이력 {$purged}건이 파기되었습니다. (보관 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Console\Commands;
use App\Services\NotificationLogService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 알림 발송 이력을 정리하는 커맨드
*/
class PruneNotificationLogsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'notification-log:prune {--days=90 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 알림 발송 이력을 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
*
* @param NotificationLogService $service 알림 발송 이력 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(NotificationLogService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->prune($days);
$this->info("알림 발송 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,123 @@
<?php
namespace App\Console\Commands;
use App\Services\AttachmentService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 소유자 없는 고아 첨부파일 정리 커맨드
*
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자 없이 먼저 만들어지므로, 폼을 저장하지 않고
* 이탈하면 파일과 기록이 그대로 남습니다. 그 잔존물을 회수합니다.
*
* 사용자 파일을 실제로 파기하므로 기본 꺼짐(운영자 옵트인)입니다. 스케줄 호출
* (`--scheduled`)은 설정을 false 폴백으로 확인한 뒤에만 수행합니다.
*
* @example php artisan attachments:prune-orphans --dry-run
* @example php artisan attachments:prune-orphans --days=7 --limit=100
*/
class PruneOrphanAttachmentsCommand extends Command
{
/**
* 커맨드 이름 및 시그니처
*
* @var string
*/
protected $signature = 'attachments:prune-orphans
{--dry-run : 실제 삭제 없이 대상 건수만 확인}
{--limit=500 : 한 번에 처리할 최대 건수}
{--days= : 보존기간(일) 재정의 — 미지정 시 환경설정값}
{--scheduled : 스케줄러 호출 표시 — 자동 정리 토글이 꺼져 있으면 조기 종료}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '소유자 없이 방치된 고아 첨부파일을 정리합니다.';
/**
* @param AttachmentService $attachmentService 첨부파일 서비스
*/
public function __construct(
protected AttachmentService $attachmentService
) {
parent::__construct();
}
/**
* 커맨드 실행
*
* @return int 종료 코드
*/
public function handle(): int
{
if ($this->option('scheduled') && ! $this->isCleanupEnabled()) {
$this->info('고아 첨부 자동 정리가 꺼져 있어 실행하지 않았습니다. (upload.orphan_cleanup_enabled = false)');
return Command::SUCCESS;
}
$days = $this->resolveRetentionDays();
if ($days < 1) {
$this->info('고아 첨부 보존기간이 1일 미만이어서 정리를 수행하지 않았습니다.');
return Command::SUCCESS;
}
$limit = max(1, (int) $this->option('limit'));
$isDryRun = (bool) $this->option('dry-run');
$result = $this->attachmentService->pruneOrphans($days, $limit, $isDryRun);
if ($isDryRun) {
$this->info("[DRY RUN] 보존기간({$days}일) 경과 고아 첨부: {$result['scanned']}건");
return Command::SUCCESS;
}
$this->info(sprintf(
'보존기간(%d일) 경과 고아 첨부 %d건 중 %d건을 삭제했습니다. (실패 %d건)',
$days,
$result['scanned'],
$result['deleted'],
$result['failed'],
));
Log::info('PruneOrphanAttachmentsCommand: 고아 첨부 정리 완료', [
'days' => $days,
'limit' => $limit,
] + $result);
return Command::SUCCESS;
}
/**
* 자동 정리 토글을 false 폴백으로 조회합니다.
*
* @return bool 자동 정리 활성 여부
*/
private function isCleanupEnabled(): bool
{
return (bool) g7_core_settings('upload.orphan_cleanup_enabled', false);
}
/**
* 보존기간을 해석합니다 (옵션 > 환경설정 > 기본 30일).
*
* @return int 보존기간(일)
*/
private function resolveRetentionDays(): int
{
$option = $this->option('days');
if ($option !== null && $option !== '') {
return (int) $option;
}
return (int) g7_core_settings('upload.orphan_retention_days', 30);
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Services\ScheduleService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 스케줄 실행 이력을 정리하는 커맨드
*
* 이력 행은 출력(longText)을 포함해 실행마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
*/
class PruneScheduleHistoryCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'schedules:prune-history {--days=90 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 스케줄 실행 이력을 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달합니다.
* 보존 기간 하한과 확장 훅 발행은 그 계층이 소유합니다.
*
* @param ScheduleService $service 스케줄 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(ScheduleService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->pruneHistory($days);
$this->info("스케줄 실행 이력 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Seo\SeoCacheStatsService;
use Illuminate\Console\Command;
/**
* 보존 기간이 지난 SEO 캐시 통계를 정리하는 커맨드
*
* 통계 행은 봇 요청마다 누적되므로 정리 주체가 없으면 무한히 성장합니다.
*/
class PruneSeoCacheStatsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'seo:prune-stats {--days=30 : 보존 기간(일)}';
/**
* The console command description.
*/
protected $description = '보존 기간이 지난 SEO 캐시 통계 기록을 삭제합니다';
/**
* Execute the console command.
*
* 파기 자체는 도메인 서비스가 수행합니다 — 커맨드는 옵션만 전달하고,
* 보존 기간 하한은 그 계층이 소유합니다.
*
* @param SeoCacheStatsService $service SEO 캐시 통계 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(SeoCacheStatsService $service): int
{
$days = (int) $this->option('days');
$deleted = $service->cleanup($days);
$this->info("SEO 캐시 통계 {$deleted}건이 삭제되었습니다. (보존 {$days}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,81 @@
<?php
namespace App\Console\Commands;
use App\Services\StorageLeftoverPruneService;
use Illuminate\Console\Command;
/**
* 스토리지 잔존물 정리 커맨드
*
* 확장/코어 업데이트·설치가 중단되며 남긴 임시 산출물(_pending 스테이징 ·
* storage/app/temp · vendor 번들 스테이징)과 오래된 백업본, 레거시 브라우저 로그를
* 회수한다. 로직은 `StorageLeftoverPruneService` 가 소유한다
* (`CleanupExtensionBundlesCommand` 위임 패턴 미러).
*/
class PruneStorageLeftoversCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'storage:prune-leftovers
{--days=3 : 임시 산출물 보존일 (mtime 기준, 경과분 삭제)}
{--backup-days=30 : 백업 보존일 (identifier 별 최신 1개는 나이와 무관하게 보존)}
{--dry-run : 실제 삭제 없이 대상 목록만 확인}';
/**
* The console command description.
*/
protected $description = '중단된 업데이트·설치가 남긴 임시 산출물과 오래된 백업본을 정리합니다';
/**
* 대상군 키 → 출력 라벨.
*
* @var array<string, string>
*/
private const GROUP_LABELS = [
'staging' => '업데이트 스테이징 잔존',
'temp' => '코어 임시 산출물',
'vendor_bundle_staging' => 'vendor 번들 스테이징',
'extension_backups' => '확장 백업',
'core_backups' => '코어 백업',
'legacy_browser_log' => '레거시 브라우저 로그',
];
/**
* Execute the console command.
*
* @param StorageLeftoverPruneService $service 잔존물 정리 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(StorageLeftoverPruneService $service): int
{
$days = max(0, (int) $this->option('days'));
$backupDays = max(0, (int) $this->option('backup-days'));
$dryRun = (bool) $this->option('dry-run');
$result = $service->prune($days, $backupDays, $dryRun);
$total = 0;
foreach (self::GROUP_LABELS as $key => $label) {
$paths = $result[$key] ?? [];
$count = count($paths);
$total += $count;
$this->line("{$label}: {$count}건");
if ($dryRun) {
foreach ($paths as $path) {
$this->line(" - {$path}");
}
}
}
$this->info($dryRun
? "삭제 대상 합계 {$total}건 (dry-run — 실제 삭제 없음)"
: "합계 {$total}건이 삭제되었습니다. (임시 보존일: {$days}일, 백업 보존일: {$backupDays}일)");
return self::SUCCESS;
}
}
@@ -0,0 +1,194 @@
<?php
namespace App\Console\Commands\Search;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Enums\SearchIndexStatus;
use App\Search\DTO\SearchIndexHealth;
use App\Search\SearchIndexMaintenanceManager;
use Illuminate\Console\Command;
/**
* 검색 인덱스 점검·재생성 커맨드
*
* 인덱스는 있는데 내용이 색인되지 않아 검색이 조용히 0건을 돌려주는 상태를 검출하고,
* 운영자가 선택하면 재생성합니다. 이 상태는 예외도 로그도 남기지 않으므로
* "원래 검색이 안 되는 줄" 알고 지나가기 쉽습니다.
*
* 점검 방법은 **활성 Scout 엔진의 유지보수기**가 정합니다. 확장이 자체 검색 엔진을
* 등록하면서 유지보수기를 함께 등록하면 이 커맨드가 그대로 그 엔진을 다룹니다
* (`core.search.index_maintainers` 필터 훅).
*
* 사용 예시:
* php artisan search:index # 활성 엔진의 인덱스 점검 (읽기 전용)
* php artisan search:index --repair # 색인 누락 인덱스 재생성
* php artisan search:index --filter=table=pages # 엔진별 필터 전달
* php artisan search:index --json # 기계 판독용 출력
*/
class SearchIndexCommand extends Command
{
use HasUnifiedConfirm;
protected $signature = 'search:index
{--repair : 색인이 누락된 인덱스를 재생성 (미지정 시 점검만)}
{--filter=* : 엔진별 필터 (key=value 형태, 다중 지정 가능. FULLTEXT: table, index, samples)}
{--json : 기계 판독용 JSON 출력}';
protected $description = '활성 검색 엔진의 인덱스가 실제로 내용을 색인하고 있는지 점검하고, 누락 시 재생성합니다';
/**
* 커맨드를 실행합니다.
*
* @param SearchIndexMaintenanceManager $manager 검색 인덱스 유지보수 진입점
* @return int 종료 코드 (색인 누락 잔존 시 1)
*/
public function handle(SearchIndexMaintenanceManager $manager): int
{
$driver = $manager->driver();
$filters = $this->parseFilters();
if (! $manager->hasMaintainer()) {
$this->components->warn(__('search.index.no_maintainer', ['driver' => $driver]));
return self::SUCCESS;
}
if (($reason = $manager->unavailableReason()) !== null) {
$this->components->warn($reason);
return self::SUCCESS;
}
$results = $manager->inspect($filters);
if ($results === []) {
$this->components->warn(__('search.index.no_targets', ['driver' => $driver]));
return self::SUCCESS;
}
$report = null;
if ($this->option('repair')) {
$targets = array_values(array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild()));
if ($targets !== [] && $this->confirmRebuild($targets)) {
$report = $manager->repairStale($filters);
$results = $manager->inspect($filters);
}
}
if ($this->option('json')) {
$this->line((string) json_encode([
'driver' => $driver,
'results' => array_map(fn (SearchIndexHealth $h) => $h->toArray(), $results),
'repair' => $report?->toArray(),
], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
} else {
$this->render($driver, $results, $report?->summary());
}
$remaining = array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild());
return ($remaining === [] && ($report === null || $report->failed === [])) ? self::SUCCESS : self::FAILURE;
}
/**
* `--filter=key=value` 옵션을 연관 배열로 해석합니다.
*
* @return array<string, string>
*/
private function parseFilters(): array
{
$filters = [];
foreach ((array) $this->option('filter') as $raw) {
if (! is_string($raw) || ! str_contains($raw, '=')) {
continue;
}
[$key, $value] = explode('=', $raw, 2);
$key = trim($key);
if ($key !== '') {
$filters[$key] = trim($value);
}
}
return $filters;
}
/**
* 재생성 대상을 보여 주고 진행 여부를 확인합니다.
*
* @param array<int, SearchIndexHealth> $targets 재생성 대상
* @return bool 진행 여부
*/
private function confirmRebuild(array $targets): bool
{
$this->newLine();
$this->components->info(__('search.index.rebuild_targets', ['count' => count($targets)]));
foreach ($targets as $target) {
$this->line(' - '.$target->identifier.' ('.$target->measurement.')');
}
$this->components->warn(__('search.index.rebuild_cost_warning'));
if ($this->unifiedConfirm(__('search.index.rebuild_confirm'), true)) {
return true;
}
$this->components->info(__('search.index.rebuild_skipped'));
return false;
}
/**
* 판정 결과를 표로 출력합니다.
*
* @param string $driver 드라이버명
* @param array<int, SearchIndexHealth> $results 판정 결과
* @param string|null $repairSummary 재생성 요약 (수행했을 때만)
* @return void
*/
private function render(string $driver, array $results, ?string $repairSummary): void
{
$rows = array_map(fn (SearchIndexHealth $health) => [
$health->identifier,
'<fg='.$health->status->consoleColor().'>'.$health->status->label().'</>',
$health->measurement,
], $results);
$this->newLine();
$this->line(' '.__('search.index.driver_label', ['driver' => $driver]));
$this->table([__('search.index.col.index'), __('search.index.col.status'), __('search.index.col.measurement')], $rows);
$counts = [];
foreach (SearchIndexStatus::cases() as $case) {
$counts[$case->value] = count(array_filter($results, fn (SearchIndexHealth $h) => $h->status === $case));
}
$this->line(' '.__('search.index.counts', [
'healthy' => $counts[SearchIndexStatus::Healthy->value],
'degraded' => $counts[SearchIndexStatus::Degraded->value],
'stale' => $counts[SearchIndexStatus::Stale->value],
'skipped' => $counts[SearchIndexStatus::Skipped->value],
'total' => count($results),
]));
if ($repairSummary !== null) {
$this->newLine();
$this->components->info($repairSummary);
}
if ($counts[SearchIndexStatus::Stale->value] > 0 && ! $this->option('repair')) {
$this->newLine();
$this->components->warn(__('search.index.stale_hint'));
}
if ($counts[SearchIndexStatus::Degraded->value] > 0) {
$this->line(' <comment>'.__('search.index.degraded_hint').'</comment>');
}
}
}
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Enums\SitemapGenerationMode;
use App\Jobs\GenerateSitemapJob;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
@@ -17,7 +18,10 @@ class SeoGenerateSitemapCommand extends Command
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'seo:generate-sitemap {--sync : 큐 드라이버를 무시하고 동기 실행}';
protected $signature = 'seo:generate-sitemap
{--sync : 큐 드라이버를 무시하고 동기 실행}
{--rebuild : 저장소 상태와 무관하게 전체 재생성 (--mode=full 과 동일)}
{--mode=auto : 재생성 모드 (full|auto|incremental)}';
/**
* @var string 커맨드 설명
@@ -31,6 +35,14 @@ class SeoGenerateSitemapCommand extends Command
*/
public function handle(): int
{
$mode = $this->resolveMode();
if ($mode === null) {
$allowed = implode(', ', array_map(fn (SitemapGenerationMode $m): string => $m->value, SitemapGenerationMode::cases()));
$this->error('잘못된 모드입니다. 허용: '.$allowed);
return Command::INVALID;
}
$forceSync = (bool) $this->option('sync');
// queue.default 는 SettingsServiceProvider 가 drivers.queue_driver 와 동기화하되,
// testing 환경에서는 phpunit.xml 값을 보존하므로 격리가 유지된다.
@@ -38,13 +50,29 @@ class SeoGenerateSitemapCommand extends Command
$isSyncDriver = $connection === 'sync';
if ($forceSync || $isSyncDriver) {
GenerateSitemapJob::dispatchSync();
$this->info('Sitemap이 생성되었습니다.');
GenerateSitemapJob::dispatchSync($mode);
$this->info("Sitemap이 생성되었습니다. (모드: {$mode->value})");
} else {
GenerateSitemapJob::dispatch();
$this->info('Sitemap 생성이 큐에 디스패치되었습니다.');
GenerateSitemapJob::dispatch($mode);
$this->info("Sitemap 생성이 큐에 디스패치되었습니다. (모드: {$mode->value})");
}
return Command::SUCCESS;
}
/**
* --rebuild / --mode 옵션으로부터 재생성 모드를 해석합니다.
*
* --rebuild 는 --mode 보다 우선하며 Full 로 강제합니다.
*
* @return SitemapGenerationMode|null 재생성 모드 (유효하지 않으면 null)
*/
private function resolveMode(): ?SitemapGenerationMode
{
if ((bool) $this->option('rebuild')) {
return SitemapGenerationMode::Full;
}
return SitemapGenerationMode::tryFrom((string) $this->option('mode'));
}
}
@@ -181,8 +181,13 @@ class BuildTemplateCommand extends Command
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 빌드 실행
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
// 빌드 실행 (감시 모드에는 소스맵 억제를 주입하지 않는다 — 개발 중 디버깅 필요)
$result = $this->runNpmCommand(
$buildCommand,
$buildPath,
! $watchMode,
$watchMode ? [] : $this->buildEnv($productionMode)
);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info("✅ 빌드 완료: {$identifier}");
@@ -285,15 +290,30 @@ class BuildTemplateCommand extends Command
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* 빌드 프로세스에 주입할 환경변수를 구성합니다.
*
* 프로덕션 빌드에서는 소스맵을 생성하지 않습니다. 배포 산출물에 원본 코드가
* 포함되는 것을 막기 위함이며, 각 vite config 가 이 값을 읽습니다.
*
* @param bool $productionMode 프로덕션 빌드 여부
* @return array<string, string> Process 에 주입할 환경변수
*/
private function buildEnv(bool $productionMode): array
{
return $productionMode ? ['G7_BUILD_SOURCEMAP' => '0'] : [];
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @param array<string, string> $env 추가로 주입할 환경변수 (부모 환경에 병합됨)
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true, array $env = []): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
@@ -302,6 +322,11 @@ class BuildTemplateCommand extends Command
$process = new Process($command);
$process->setWorkingDirectory($cwd);
// Symfony Process 는 지정한 env 를 부모 환경에 병합하므로(PATH 등 유지) 추가분만 넘긴다.
if ($env !== []) {
$process->setEnv($env);
}
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
@@ -116,7 +116,7 @@ class CheckTemplateUpdatesCommand extends Command
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
$isUpdate = (bool) ($detail['update_available'] ?? true);
if ($isUpdate) {
$updateCount++;
}
@@ -143,7 +143,7 @@ class CheckTemplateUpdatesCommand extends Command
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('templates.commands.check_updates.summary', [
'total' => count($result['details']),
'total' => $result['checked_count'] ?? count($result['details']),
'updates' => $updateCount,
]));
@@ -2,15 +2,17 @@
namespace App\Console\Commands\Template;
use App\Contracts\Extension\CacheInterface;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Models\Template;
use App\Models\TemplateLayout;
use App\Services\ExtensionBundleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ClearTemplateCacheCommand extends Command
{
use ClearsTemplateCaches;
/**
* The name and signature of the console command.
*/
@@ -27,7 +29,7 @@ class ClearTemplateCacheCommand extends Command
*/
public function __construct(
private TemplateManager $templateManager,
private CacheInterface $cache
private ExtensionBundleService $bundleService
) {
parent::__construct();
}
@@ -65,6 +67,10 @@ class ClearTemplateCacheCommand extends Command
/**
* 특정 템플릿의 캐시 삭제
*
* 라이프사이클(update/deactivate/uninstall)과 동일한 무효화 단일 지점
* (`TemplateManager::clearTemplateCache()`)을 경유한다 — 커맨드가 키 목록을
* 별도로 유지하면 키 규약 변경 시 유령 forget 으로 사문화된다 (#588, 공개 #119).
*/
private function clearSingleTemplateCache(string $identifier): void
{
@@ -76,34 +82,12 @@ class ClearTemplateCacheCommand extends Command
$this->info(__('templates.commands.cache_clear.clearing_single', ['template' => $identifier]));
$clearedCount = 0;
// 고정 키(config/components_manifest) + 현재 버전 routes/language + 레이아웃 캐시
$clearedCount = $this->templateManager->clearTemplateCache($identifier);
// 1. 레이아웃 캐시 삭제
$templateRecord = Template::where('identifier', $identifier)->first();
if ($templateRecord) {
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
foreach ($layouts as $layout) {
$this->cache->forget("layout.{$identifier}.{$layout->name}");
$clearedCount++;
}
}
// 2. Routes 캐시 삭제
$this->cache->forget("template.routes.{$identifier}");
$clearedCount++;
// 3. 다국어 파일 캐시 삭제
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
foreach ($supportedLocales as $locale) {
$this->cache->forget("template.language.{$identifier}.{$locale}");
$clearedCount++;
}
// 4. 활성 템플릿 타입 캐시 삭제
if ($templateRecord) {
$this->cache->forget("templates.active.{$templateRecord->type}");
$clearedCount++;
}
// 상태 키(ext.templates.*) + 버전 포함 키 무효화 (버전 bump)
TemplateManager::invalidateTemplateStatusCache();
$this->incrementExtensionCacheVersion();
$this->info('✅ '.__('templates.commands.cache_clear.success_single', [
'template' => $identifier,
@@ -124,34 +108,18 @@ class ClearTemplateCacheCommand extends Command
$this->info(__('templates.commands.cache_clear.clearing_all'));
$clearedCount = 0;
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
// 모든 설치된 템플릿의 캐시 삭제
$templates = Template::all();
foreach ($templates as $templateRecord) {
// 1. 레이아웃 캐시 삭제
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
foreach ($layouts as $layout) {
$this->cache->forget("layout.{$templateRecord->identifier}.{$layout->name}");
$clearedCount++;
}
// 2. Routes 캐시 삭제
$this->cache->forget("template.routes.{$templateRecord->identifier}");
$clearedCount++;
// 3. 다국어 파일 캐시 삭제
foreach ($supportedLocales as $locale) {
$this->cache->forget("template.language.{$templateRecord->identifier}.{$locale}");
$clearedCount++;
}
// 모든 설치된 템플릿의 캐시 삭제 (설치 레코드 기준 — 라이프사이클과 동일 지점)
foreach (Template::all() as $templateRecord) {
$clearedCount += $this->templateManager->clearTemplateCache($templateRecord->identifier);
}
// 4. 활성 템플릿 타입 캐시 삭제
$this->cache->forget('templates.active.admin');
$this->cache->forget('templates.active.user');
$clearedCount += 2;
// 상태 키 + 버전 포함 키 무효화는 전체에서 1회면 충분
TemplateManager::invalidateTemplateStatusCache();
$this->incrementExtensionCacheVersion();
// 확장 프론트엔드 병합 번들 파일 전체 삭제 (템플릿 캐시 정리는 페이지 전면 갱신)
$clearedCount += $this->bundleService->clearBundles();
$this->info('✅ '.__('templates.commands.cache_clear.success_all', [
'count' => $clearedCount,
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Traits;
use App\Console\Helpers\ConsoleConfirm;
use Symfony\Component\Console\Input\StreamableInputInterface;
/**
* Laravel Command 컨텍스트에서 표준화된 yes/no 프롬프트를 제공하는 트레이트.
@@ -12,6 +13,7 @@ use App\Console\Helpers\ConsoleConfirm;
* 재질문 루프는 ConsoleConfirm::parse() 와 공유한다.
*
* - `--no-interaction` 시: $default 즉시 반환
* - 응답을 받을 수 없는 실행(콘솔 없는 STDIN) 시: $default 즉시 반환
* - empty 입력 시: $default 반환
* - yes/y → true, no/n → false (대소문자 무시)
* - 그 외 입력: "yes, y, no, n 중 하나로 입력해 주세요." 출력 후 재질문
@@ -26,7 +28,7 @@ trait HasUnifiedConfirm
*/
protected function unifiedConfirm(string $question, bool $default = false): bool
{
if (! $this->input->isInteractive()) {
if (! $this->input->isInteractive() || ! $this->canPromptForAnswer()) {
return $default;
}
@@ -47,4 +49,38 @@ trait HasUnifiedConfirm
$this->warn(' yes, y, no, n 중 하나로 입력해 주세요.');
}
}
/**
* 이 실행이 프롬프트 응답을 실제로 받을 수 있는지 판정합니다.
*
* Symfony 는 `posix_isatty()` 로 비대화 실행을 감지해 `isInteractive()` 를 false 로
* 내리는데, Windows PHP 에는 posix 확장이 없어 그 분기가 실행되지 않는다. 그래서
* `--no-interaction` 없이 CI·스케줄러·에이전트처럼 콘솔이 없는 곳에서 부르면
* `isInteractive()` 가 true 로 남고 `ask()` 가 오지 않을 응답을 무한히 기다린다.
* 프롬프트 출력마저 버퍼에 갇혀 있어 겉으로는 "커맨드가 느리다" 로만 보인다.
*
* `stream_isatty()` 는 posix 확장 없이도 Windows 를 포함해 동작하므로 이를 쓴다.
* 판정 재료가 없으면 true 를 돌려 기존 동작(질문)을 유지한다 — 물어보지 못해 멈추는
* 것보다 물어볼 수 있는데 안 묻는 쪽이 더 위험하기 때문이다.
*
* @return bool 응답을 받을 수 있으면 true
*/
protected function canPromptForAnswer(): bool
{
// 테스트는 QuestionHelper 를 대체하거나 입력을 주입하므로 STDIN 을 쓰지 않는다
if ($this->laravel !== null && $this->laravel->runningUnitTests()) {
return true;
}
// 입력 스트림이 주입된 실행(CommandTester::setInputs 등)도 STDIN 과 무관하다
if ($this->input instanceof StreamableInputInterface && is_resource($this->input->getStream())) {
return true;
}
if (! \defined('STDIN') || ! is_resource(STDIN) || ! \function_exists('stream_isatty')) {
return true;
}
return stream_isatty(STDIN);
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Console\Commands\Traits;
use App\Search\DTO\SearchIndexHealth;
use App\Search\DTO\SearchIndexRepairReport;
use App\Search\SearchIndexMaintenanceManager;
/**
* 설치·업데이트 커맨드에 "검색 인덱스 재생성" 선택 옵션을 제공하는 트레이트.
*
* 재생성 비용은 엔진마다 다르지만(테이블 잠금 / 전체 재색인) 어느 쪽이든 운영 중인
* 사이트에 영향을 줍니다. 그래서 **기본값은 재생성하지 않음**이고, 운영자가
* `--rebuild-search-index` 로 명시했을 때만 수행합니다.
*
* 옵션을 주지 않아도 점검 결과는 안내합니다 — 색인이 누락되면 검색이 오류 없이 0건을
* 돌려주므로, 알려주지 않으면 운영자가 알 방법이 없습니다.
*/
trait RebuildsSearchIndex
{
/**
* 커맨드 `$signature` 에 붙일 옵션 정의.
*
* @return string 옵션 시그니처 조각
*/
public static function rebuildSearchIndexOption(): string
{
return '{--rebuild-search-index : 완료 후 색인이 누락된 검색 인덱스를 재생성 (인덱스가 잠기거나 재색인됩니다 — 운영 중에는 점검 결과만 확인하고 유지보수 시간에 수행하세요)}';
}
/**
* 작업 완료 후 검색 인덱스를 점검하고, 요청이 있었으면 재생성합니다.
*
* @param bool|null $requested 재생성 요청 여부 (null 이면 커맨드 옵션에서 읽음)
* @return SearchIndexRepairReport|null 재생성 보고 (재생성을 하지 않았으면 null)
*/
protected function handleSearchIndexRebuild(?bool $requested = null): ?SearchIndexRepairReport
{
$manager = app(SearchIndexMaintenanceManager::class);
// 점검을 제공하지 않는 엔진에서는 조용히 넘어간다 (검색 자체는 정상 동작)
if (! $manager->hasMaintainer() || $manager->unavailableReason() !== null) {
return null;
}
$requested ??= (bool) $this->option('rebuild-search-index');
if ($requested) {
$report = $manager->repairStale();
$this->reportRebuild($report);
return $report;
}
$this->warnStale($manager->inspect());
return null;
}
/**
* 재생성하지 않은 경우, 색인 누락 사실만 안내합니다.
*
* @param array<int, SearchIndexHealth> $results 점검 결과
* @return void
*/
private function warnStale(array $results): void
{
$stale = array_values(array_filter($results, fn (SearchIndexHealth $h) => $h->needsRebuild()));
if ($stale === []) {
return;
}
$this->newLine();
$this->warn('⚠️ '.__('search.index.stale_after_update', ['count' => count($stale)]));
foreach ($stale as $health) {
$this->line(' - '.$health->identifier);
}
$this->line(' '.__('search.index.stale_hint'));
}
/**
* 재생성 보고를 콘솔에 출력합니다.
*
* @param SearchIndexRepairReport $report 재생성 보고
* @return void
*/
private function reportRebuild(SearchIndexRepairReport $report): void
{
$this->newLine();
if (! $report->didRebuild()) {
$this->info('✅ '.$report->summary());
return;
}
$this->info('✅ '.$report->summary());
foreach ($report->repaired as $identifier) {
$this->line(' '.__('search.index.rebuilt_item', ['index' => $identifier]));
}
foreach ($report->failed as $identifier => $message) {
$this->error(' '.__('search.index.rebuild_failed_item', ['index' => $identifier, 'error' => $message]));
}
foreach ($report->remaining as $identifier) {
$this->warn(' '.__('search.index.still_stale_item', ['index' => $identifier]));
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Contracts\ApiDoc;
/**
* API 문서 실측용 완전 샘플 시더 계약
*
* 확장(모듈/플러그인)이 자신의 API 도메인 대표 엔티티에 완전한 샘플 레코드를
* 멱등하게 생성하고, docgen 이 상세 GET 실측(path 파라미터 치환)에 사용할
* 도메인별 대표 route key 맵을 제공하기 위한 인터페이스입니다.
*
* 확장은 이 인터페이스를 구현한 클래스를 `{Namespace}\Support\ApiDoc\ApiDocSampleService`
* 규약 위치에 두며, `api:docgen --scope=module:{id}` 실행 시 커맨드가 자동으로 발견합니다.
* 코어의 `App\Support\ApiDoc\ApiDocSampleService` 도 동일 계약을 구현합니다.
*/
interface ApiDocSampleSeeder
{
/**
* 도메인별 완전 샘플을 멱등 생성하고 대표 route key 맵을 반환합니다.
*
* 반환 맵의 키는 라우트 도메인 그룹명(`domain_group`, 예: pages)이며,
* 값은 그 도메인의 상세 GET path 파라미터를 실측 가능한 실제 레코드로
* 치환하기 위한 모델 FQCN·route key 이름·route key 값입니다.
*
* 선택 필드 `path_params`(param 명 => 실제 값 맵)를 두면, route-model binding
* 도 도메인 폴백도 없는 문자열 path 파라미터(예: 게시판 slug 라우팅
* `boards/{slug}/posts/{id}`)를 param 명 정확 일치로 치환해 상세 GET 을
* 실측할 수 있습니다. 미제공 시 route key 기반 자동 치환만 적용됩니다.
*
* @return array<string, array{model: class-string, key: string, value: string, path_params?: array<string, string>}> 도메인 => 대표 레코드 정보
*/
public function seed(): array;
}
@@ -25,6 +25,17 @@ interface CacheableExtensionInterface
*/
public function getStorage(): StorageInterface;
/**
* 카테고리별 스토리지 드라이버를 반환합니다.
*
* getStorageDiskFor($category) 가 결정한 디스크의 인스턴스를 반환하며,
* 기본 구현은 getStorage() 와 동일한 디스크를 사용합니다.
*
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
* @return StorageInterface 카테고리 디스크의 스토리지 인스턴스
*/
public function getStorageFor(string $category): StorageInterface;
/**
* 확장 도메인에 격리된 캐시 드라이버를 반환합니다.
*
@@ -0,0 +1,34 @@
<?php
namespace App\Contracts\Extension;
/**
* 확장 선언 미들웨어의 라우트 매칭 인덱스 계약.
*
* 활성 확장(모듈/플러그인)이 `getMiddleware()` 로 선언한 미들웨어를 수집해,
* 요청 시점에 라우트 이름·URI 를 각 선언의 `targets` 술어와 대조하여 매칭되는
* 미들웨어 FQCN 목록을 반환합니다. 코어 self-gate 게이트(`ExtensionMiddlewareGate`)가
* 이 계약을 사용해 그룹/timing 별로 매칭 미들웨어를 동적 실행합니다.
*
* 부팅 시점 라우트 순회로 확장 간 미들웨어를 부착할 수 없는 제약(확장 라우트는
* booted 지연 등록 + 순서 미보장)을 요청 시점 매칭으로 원천 회피합니다.
* IDV `IdentityPolicyRepository::getRouteScopeIndex()` 미러.
*
* @since 7.0.5
*/
interface ExtensionMiddlewareRegistryInterface
{
/**
* 현재 요청에 매칭되는 확장 미들웨어 FQCN 목록을 반환합니다.
*
* 게이트 래퍼가 자기 그룹·timing 을 전달하면, 그 축의 엔트리 중 라우트명 또는
* URI 술어가 매칭되는 것을 확장 로드 순서대로 반환합니다.
*
* @param string $routeName 현재 요청 라우트 이름 (무명 라우트면 빈 문자열)
* @param string $path 현재 요청 URI path (선행 슬래시 없음, 무명 라우트 타게팅용)
* @param string $group 요청이 속한 그룹 ('web'|'api')
* @param string $timing 실행 시점 ('before_core'|'after_core')
* @return array<int, class-string> 매칭된 확장 미들웨어 FQCN (선언 순서 = 확장 로드 순서)
*/
public function resolveForRoute(string $routeName, string $path, string $group, string $timing): array;
}
+11 -7
View File
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 확장(모듈/플러그인) 스토리지 인터페이스
*
@@ -50,12 +52,14 @@ interface StorageInterface
/**
* 파일의 공개 URL을 반환합니다.
*
* public disk인 경우 직접 URL을 반환하고,
* private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
* public 디스크이거나 `filesystems.disks.{disk}.url` 이 설정된 디스크(S3+CDN 등)면
* 직접 URL 을 반환하고, 그 외에는 null 을 반환합니다 (별도 API 엔드포인트 사용).
* 생성 결과는 디스크 종류와 무관하게 `core.storage.filter_url` 필터 훅을 항상 통과하므로,
* 확장이 URL 을 공급/수정/차단할 수 있습니다 (null 반환도 훅 발화 대상).
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @return string|null 파일 URL (private disk인 경우 null)
* @return string|null 파일 URL (직접 URL 불가 디스크이고 훅 공급도 없으면 null)
*/
public function url(string $category, string $path): ?string;
@@ -110,9 +114,9 @@ interface StorageInterface
* @param string $path 파일 경로
* @param string $filename 다운로드 시 표시될 파일명
* @param array $headers 추가 HTTP 헤더
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null 파일 스트림 (파일이 없으면 null)
* @return StreamedResponse|null 파일 스트림 (파일이 없으면 null)
*/
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse;
/**
* 사용할 디스크를 변경한 새 인스턴스를 반환합니다.
@@ -132,7 +136,7 @@ interface StorageInterface
* @param string $path 파일 경로
* @param string $filename 다운로드 시 표시될 파일명
* @param array $headers 추가 HTTP 헤더
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null 다운로드 응답 (파일이 없으면 null)
* @return StreamedResponse|null 다운로드 응답 (파일이 없으면 null)
*/
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse;
}
@@ -7,10 +7,18 @@ use App\Enums\DeactivationReason;
interface TemplateManagerInterface
{
/**
* 모든 템플릿을 로드하고 초기화합니다.
* 모든 템플릿을 로드하고 초기화합니다. (항상 재스캔)
*/
public function loadTemplates(): void;
/**
* 템플릿이 아직 로드되지 않았을 때만 로드합니다. (멱등)
*
* "맵이 채워져 있기만 하면 되는" 소비자용 진입점. 재스캔이 필요한 경우
* (설치/삭제/업데이트 직후)에만 `loadTemplates()` 를 직접 호출합니다.
*/
public function ensureLoaded(): void;
/**
* /templates 디렉토리를 스캔하여 사용 가능한 템플릿을 발견합니다.
*
@@ -0,0 +1,39 @@
<?php
namespace App\Contracts\Pagination;
use App\Enums\TotalRelation;
/**
* 총 건수 정확도를 스스로 밝히는 페이지 결과 계약
*
* 표준 `LengthAwarePaginator` 는 `total()` 이 항상 정확하다고 가정한다. 대용량 목록에서
* 상한을 걸고 센 결과는 그 가정을 만족하지 못하므로, 정확도를 함께 실어 나른다.
*
* 이 계약을 구현한 페이지는 `BaseApiCollection::paginationMeta()` 가 자동으로 알아보고
* `total_relation` / `total_is_exact` / `result_cap` 메타를 응답에 덧붙인다. 컬렉션 쪽
* 코드 변경은 필요 없다.
*/
interface BoundedTotalAware
{
/**
* 총 건수와 실제 매칭 건수의 관계를 반환합니다.
*
* @return TotalRelation 정확(Exact) 또는 하한(AtLeast)
*/
public function totalRelation(): TotalRelation;
/**
* 총 건수 집계에 적용된 상한을 반환합니다.
*
* @return int|null 상한 (무제한이면 null)
*/
public function resultCap(): ?int;
/**
* 총 건수가 상한에 걸려 잘렸는지 여부를 반환합니다.
*
* @return bool 잘렸으면 true (= totalRelation 이 AtLeast)
*/
public function isTruncated(): bool;
}
@@ -2,6 +2,7 @@
namespace App\Contracts\Repositories;
use Illuminate\Contracts\Pagination\CursorPaginator;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
@@ -18,20 +19,22 @@ interface ActivityLogRepositoryInterface
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
*/
public function getPaginatedForModel(Model $model, array $filters = []): LengthAwarePaginator;
/**
* 활동 로그 목록을 페이지네이션하여 조회합니다.
*
* 요청에 `cursor` 가 있으면 키셋(커서) 방식으로 응답합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
* @return LengthAwarePaginator|CursorPaginator 페이지네이션된 로그 목록
*/
public function getPaginated(array $filters = []): LengthAwarePaginator;
public function getPaginated(array $filters = []): LengthAwarePaginator|CursorPaginator;
/**
* 활동 로그를 삭제합니다.
*
* @param int $id 삭제할 활동 로그 ID
* @param int $id 삭제할 활동 로그 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
@@ -39,7 +42,7 @@ interface ActivityLogRepositoryInterface
/**
* 여러 활동 로그를 일괄 삭제합니다.
*
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @return int 삭제된 건수
*/
public function deleteMany(array $ids): int;
@@ -62,4 +65,12 @@ interface ActivityLogRepositoryInterface
* @return int 익명화된 row 수
*/
public function anonymizeUserId(int $userId): int;
/**
* 보존 기간이 지난 활동 로그를 삭제합니다.
*
* @param int $days 보존 기간 (일)
* @return int 삭제된 건수
*/
public function deleteOlderThan(int $days): int;
}
@@ -4,6 +4,7 @@ namespace App\Contracts\Repositories;
use App\Models\Attachment;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
/**
* 첨부파일 Repository 인터페이스
@@ -18,6 +19,17 @@ interface AttachmentRepositoryInterface
*/
public function findById(int $id): ?Attachment;
/**
* 소프트삭제된 행까지 포함해 ID로 첨부파일 조회
*
* 영구 삭제(파일+forceDelete) 경로 전용이다. 소프트삭제된 행은 기본 조회에서 빠지므로,
* 이 메서드 없이 영구 삭제를 시도하면 대상을 찾지 못해 매 회차 실패만 누적된다.
*
* @param int $id 첨부파일 ID
* @return Attachment|null 첨부파일 또는 null
*/
public function findByIdWithTrashed(int $id): ?Attachment;
/**
* 여러 ID로 첨부파일 조회 (order 정렬)
*
@@ -145,4 +157,20 @@ interface AttachmentRepositoryInterface
* @param string $collection 컬렉션명
*/
public function reorderAfterDelete(string $attachmentableType, int $attachmentableId, string $collection): void;
/**
* 소유자 없이 방치된 고아 첨부 후보를 오래된 순으로 조회합니다.
*
* 폼 저장 전에 즉시 업로드되는 첨부는 소유자(attachmentable) 없이 먼저 생성되므로,
* 폼을 저장하지 않고 이탈하면 그대로 남습니다. 그 회수 대상을 찾습니다.
*
* 확장이 소유한 첨부(source_identifier 보유)는 그 확장의 라이프사이클 소관이므로 제외하고,
* 현역으로 쓰이는 첨부 ID 는 호출자가 넘겨 보호합니다.
*
* @param Carbon $threshold 기준 시각 (이 시각 이전 업로드가 대상)
* @param int $limit 최대 조회 건수
* @param array<int, int> $protectedIds 보호할 첨부 ID 목록 (현역 사이트 로고 등)
* @return Collection 고아 첨부 후보 (created_at 오름차순, 소프트 삭제분 포함)
*/
public function findOrphanCandidates(Carbon $threshold, int $limit, array $protectedIds = []): Collection;
}
@@ -72,13 +72,24 @@ interface LanguagePackRepositoryInterface
public function getActiveCoreLocales(): array;
/**
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
*
* 전량 순회 용도로는 쓰지 않는다 — page 를 생략하면 HTTP `page` 파라미터가 암묵
* 해석되어, 무관한 요청 파라미터가 순회 범위를 바꾼다({@see self::allForUpdateCheck()}).
*
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor)
* @param int $perPage 페이지당 건수
* @param int|null $page 페이지 번호 (null 이면 요청 파라미터에서 해석)
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator;
public function paginate(array $filters = [], int $perPage = 20, ?int $page = null): LengthAwarePaginator;
/**
* 업데이트 확인용 전체 언어팩 컬렉션을 조회합니다.
*
* @return Collection<int, LanguagePack> 설치된 전체 언어팩
*/
public function allForUpdateCheck(): Collection;
/**
* 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
@@ -24,7 +24,20 @@ interface LayoutExtensionVersionRepositoryInterface
* @param int $extensionId 레이아웃 확장 ID
* @return Collection 버전 컬렉션
*/
public function getVersions(int $extensionId): Collection;
public function getVersions(int $extensionId, int $limit = 100): Collection;
/**
* 확장의 특정 버전 번호 조회 (본문 포함)
*
* 목록(getVersions)은 경량 조회라 `content` 를 담지 않고 건수 상한이 있다. 단건은 그
* 대체 경로이므로 반드시 전용 조회를 쓴다 — 목록 조회를 재사용하면 본문이 사라지고
* 상한 밖의 오래된 버전을 찾지 못한다.
*
* @param int $extensionId 레이아웃 확장 ID
* @param int $version 버전 번호
* @return TemplateLayoutExtensionVersion|null 찾은 버전 모델 또는 null
*/
public function findVersionByNumber(int $extensionId, int $version): ?TemplateLayoutExtensionVersion;
/**
* 확장 ID 목록의 현재(최신) 버전 번호 맵 조회
@@ -6,6 +6,7 @@ use App\Enums\LayoutSourceType;
use App\Models\TemplateLayout;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Collection as SupportCollection;
interface LayoutRepositoryInterface
{
@@ -17,6 +18,17 @@ interface LayoutRepositoryInterface
*/
public function getByTemplateId(int $templateId): Collection;
/**
* 목록 표시용 경량 레이아웃 행 조회
*
* 본문(`content`)을 반환에 포함하지 않는다 — 설명·크기 등 본문 파생값만 계산해 담는다.
* 편집 대상 본문은 상세 조회가 제공한다.
*
* @param int $templateId 템플릿 ID
* @return Collection<int, array> 목록 행 배열 컬렉션
*/
public function getListByTemplateId(int $templateId): SupportCollection;
/**
* 특정 레이아웃 조회 (템플릿 ID와 이름으로)
*
@@ -26,6 +38,19 @@ interface LayoutRepositoryInterface
*/
public function findByName(int $templateId, string $name): ?TemplateLayout;
/**
* 특정 레이아웃을 영구 삭제 (템플릿 ID와 이름으로)
*
* soft delete 가 아닌 `forceDelete` 다. 이름으로 재등록될 수 있는 레이아웃
* (파일 → DB 동기화 대상) 은 soft delete 잔여 행이 남으면 재등록이 충돌하므로
* 파일 기준 동기화 경로와 동일하게 영구 삭제한다.
*
* @param int $templateId 템플릿 ID
* @param string $name 레이아웃 이름
* @return bool 삭제 여부 (대상 부재 시 false)
*/
public function deleteByName(int $templateId, string $name): bool;
/**
* ID로 레이아웃 조회
*
@@ -113,12 +138,16 @@ interface LayoutRepositoryInterface
public function updateContent(int $id, array $content, int $newLockVersion): TemplateLayout;
/**
* 특정 레이아웃의 모든 버전 조회
* 특정 레이아웃의 최근 버전 목록 조회 (최신순)
*
* 버전 행은 저장할 때마다 쌓이고 정리되지 않으므로 조회 건수에 상한이 있습니다.
* 목록이 쓰지 않는 `content`(버전마다 레이아웃 본문 사본)는 조회하지 않습니다.
*
* @param int $layoutId 레이아웃 ID
* @param int $limit 조회할 최대 버전 수
* @return Collection 버전 컬렉션
*/
public function getVersionsByLayoutId(int $layoutId): Collection;
public function getVersionsByLayoutId(int $layoutId, int $limit = 100): Collection;
/**
* 특정 버전 조회
@@ -40,6 +40,17 @@ interface MenuRepositoryInterface
*/
public function findById(int $id): ?Menu;
/**
* 여러 ID로 메뉴를 한 번에 조회합니다.
*
* 정적 라우트(`PUT menus/order`)에서 스코프 게이트를 재적용할 때 대상 전체를 한 번에
* 확인하기 위한 조회입니다. 관계는 로드하지 않습니다(소유자 판정에 불필요).
*
* @param array<int, int> $ids 메뉴 ID 목록
* @return Collection 메뉴 컬렉션
*/
public function findByIds(array $ids): Collection;
/**
* 슬러그로 메뉴를 찾습니다.
*
@@ -48,6 +59,14 @@ interface MenuRepositoryInterface
*/
public function findBySlug(string $slug): ?Menu;
/**
* URL 로 메뉴를 찾습니다.
*
* @param string $url 메뉴 URL
* @return Menu|null 찾은 메뉴 모델 또는 null
*/
public function findByUrl(string $url): ?Menu;
/**
* 새로운 메뉴를 생성합니다.
*
@@ -5,6 +5,7 @@ namespace App\Contracts\Repositories;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\CursorPaginator;
use Illuminate\Pagination\LengthAwarePaginator;
interface NotificationLogRepositoryInterface
@@ -39,10 +40,18 @@ interface NotificationLogRepositoryInterface
*/
public function bulkDelete(array $ids): int;
/**
* 보존 기간이 지난 발송 이력을 삭제합니다.
*
* @param int $days 보존 기간 (일)
* @return int 삭제된 건수
*/
public function deleteOlderThan(int $days): int;
/**
* 페이지네이션 목록 조회.
*
* @param User|null $scopeUser 스코프 적용 대상 사용자 (null이면 스코프 미적용)
*/
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator;
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator|CursorPaginator;
}
@@ -31,6 +31,14 @@ interface PermissionRepositoryInterface
*/
public function findByIdentifier(string $identifier): ?Permission;
/**
* 여러 ID로 권한을 일괄 조회합니다.
*
* @param array<int> $ids 권한 ID 배열
* @return Collection 권한 컬렉션 (ID 기준)
*/
public function getByIds(array $ids): Collection;
/**
* 새로운 권한을 생성합니다.
*
@@ -0,0 +1,43 @@
<?php
namespace App\Contracts\Repositories;
use Carbon\Carbon;
/**
* SEO 캐시 통계 Repository 인터페이스
*/
interface SeoCacheStatRepositoryInterface
{
/**
* 캐시 통계 레코드를 기록합니다.
*
* @param array<string, mixed> $attributes 기록할 속성 (url, locale, layout_name, module_identifier, type, response_time_ms)
*/
public function record(array $attributes): void;
/**
* 전체 캐시 통계를 집계합니다.
*
* @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간)
* @return array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} 집계 결과
*/
public function aggregate(?Carbon $since = null): array;
/**
* 지정한 컬럼으로 그룹화하여 캐시 통계를 집계합니다.
*
* @param string $groupBy 그룹 기준 컬럼 (layout_name | module_identifier)
* @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간)
* @return array<int, array{group: string|null, total: int, hits: int, misses: int, avg_response_time_ms: float|null}> 그룹별 집계 결과
*/
public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array;
/**
* 기준 시점보다 오래된 통계 레코드를 삭제합니다.
*
* @param Carbon $cutoff 기준 시점
* @return int 삭제된 레코드 수
*/
public function deleteOlderThan(Carbon $cutoff): int;
}
@@ -0,0 +1,62 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\SitemapUrl;
/**
* 사이트맵 URL 저장소 인터페이스
*
* sitemap_urls 테이블에 대한 리소스 단위 증분 갱신과 스트리밍 조회를 제공합니다.
* 리스너는 SitemapIndexer 를 경유해 이 저장소를 사용하며(직접 Model/DB 접근 금지),
* 전체 재생성/증분 파일 작성은 이 저장소의 스트림을 소비합니다.
*/
interface SitemapUrlRepositoryInterface
{
/**
* 한 리소스의 사이트맵 URL 행을 현재 상태로 대체합니다(멱등).
*
* (resource_type, resource_id) 로 기존 행을 제거한 뒤 전달된 항목을 삽입하므로,
* 같은 입력으로 여러 번 호출해도 테이블 내용이 동일합니다(loc 변경/슬러그 변경 흡수).
*
* @param string $type 리소스 유형
* @param string $id 리소스 PK (문자열)
* @param array<int, array{loc: string, contributor: string, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $entries URL 항목
*/
public function upsertForResource(string $type, string $id, array $entries): void;
/**
* 한 리소스의 사이트맵 URL 행을 모두 제거합니다(비공개/삭제 시).
*
* @param string $type 리소스 유형
* @param string $id 리소스 PK (문자열)
*/
public function removeForResource(string $type, string $id): void;
/**
* 공개(is_visible) URL 을 id 순으로 스트리밍합니다(유계 메모리).
*
* @param string|null $contributor 기여자 식별자로 스코핑 (null = 전체)
* @return iterable<int, SitemapUrl> URL 순회자
*/
public function streamVisible(?string $contributor = null): iterable;
/**
* 공개(is_visible) URL 총 개수를 반환합니다.
*
* @return int 공개 URL 수
*/
public function countVisible(): int;
/**
* 한 기여자의 모든 사이트맵 URL 행을 전량 대체합니다(전체 재생성).
*
* 기여자 스코프로 기존 행을 제거한 뒤 스트림을 청크 삽입하여
* 대용량에서도 메모리를 유계로 유지합니다.
*
* @param string $contributor 기여자 식별자
* @param iterable<int, array{loc: string, resource_type?: string, resource_id?: mixed, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $entries URL 항목 스트림
* @return int 삽입된 행 수 (진행상황 누적 URL 표기용 — count 쿼리 없이 스트림 누적)
*/
public function replaceAllForContributor(string $contributor, iterable $entries): int;
}
@@ -144,17 +144,21 @@ interface UserRepositoryInterface
* `locked_until` 을 현재 시각 + $minutes 로 설정하고 `failed_login_attempts` 를
* 0 으로 리셋합니다 (다음 잠금 윈도우 시작점). 잠금 해제 시각을 반환합니다.
*
* `$minutes <= 0` 은 보안 환경설정의 "0 = 무한대" 규약에 따라 영구 잠금으로 처리하며,
* 이때 `locked_until` 은 NULL, `locked_permanently` 는 true 가 되고 null 을 반환합니다.
*
* @param User $user 잠글 사용자
* @param int $minutes 잠금 유지 시간(분)
* @return Carbon 잠금 해제 시각
* @param int $minutes 잠금 유지 시간(분). 0 이하는 무기한
* @return Carbon|null 잠금 해제 시각 (영구 잠금은 null)
*/
public function lockAccount(User $user, int $minutes): Carbon;
public function lockAccount(User $user, int $minutes): ?Carbon;
/**
* 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다.
*
* 정상 로그인 성공 시 호출됩니다 (`failed_login_attempts=0`,
* `locked_until=null`, `last_failed_login_at=null`).
* 정상 로그인 성공 시 또는 관리자 수동 해제 시 호출됩니다
* (`failed_login_attempts=0`, `locked_until=null`, `locked_permanently=false`,
* `last_failed_login_at=null`).
*
* @param User $user 대상 사용자
*/
@@ -163,10 +167,55 @@ interface UserRepositoryInterface
/**
* 사용자의 계정이 현재 시점에 잠금 상태인지 판정합니다.
*
* `locked_until` 이 NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다.
* `locked_permanently` 가 true 면 항상 true 입니다. 그 외에는 `locked_until` 이
* NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다.
*
* @param User $user 대상 사용자
* @return bool 잠금 여부
*/
public function isLocked(User $user): bool;
/**
* UUID 로 사용자를 찾습니다.
*
* @param string $uuid 사용자 UUID
* @return User|null 찾은 사용자 모델 또는 null
*/
public function findByUuid(string $uuid): ?User;
/**
* UUID 목록에 해당하는 사용자의 정수 ID 배열을 반환합니다.
*
* @param array $uuids 사용자 UUID 배열
* @return array<int, int> 사용자 ID 배열
*/
public function getIdsByUuids(array $uuids): array;
/**
* 사용자 ID 목록에 해당하는 이름을 ID 로 색인해 반환합니다.
*
* 목록 화면이 작성자 이름을 행마다 조회하면 N+1 이 되므로, 표시에 필요한
* 이름만 한 번에 모아 오기 위한 배치 조회입니다.
*
* @param array $ids 사용자 ID 배열
* @return array<int, string> 사용자 ID => 이름
*/
public function getNamesByIds(array $ids): array;
/**
* 사용자 ID 목록의 지정 컬럼을 일괄 갱신합니다.
*
* @param array $ids 사용자 ID 배열
* @param array $data 갱신할 컬럼 값
* @return int 갱신된 행 수
*/
public function updateManyByIds(array $ids, array $data): int;
/**
* 사용자 ID 목록의 인증 토큰을 모두 삭제합니다.
*
* @param array $ids 사용자 ID 배열
* @return int 삭제된 토큰 수
*/
public function deleteTokensByUserIds(array $ids): int;
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace App\Enums;
/**
* 성능 계측 축 Enum
*
* `g7:bench` 커맨드가 재는 네 가지 대상을 구분합니다. 프로파일 선언의 `type` 필드
* 값 도메인이며, 축마다 필수 옵션과 실행기(`App\Benchmark\Axes\*`)가 다릅니다.
*/
enum BenchmarkAxis: string
{
/**
* 목록 SELECT 비용 (전체 컬럼 / 목록 컬럼 / 키 컬럼 3축 비교)
*/
case ListQuery = 'list';
/**
* 화면 1장 응답 시간 + 실행 쿼리 건수 + N+1 후보
*/
case Screen = 'screen';
/**
* 저장 경로 1회 소요 시간 (주문 생성, 게시글 등록 등)
*/
case Write = 'write';
/**
* 배치 커맨드 소요 시간 + 피크 메모리
*/
case Batch = 'batch';
/**
* 사람이 읽을 수 있는 라벨
*
* @return string 축 라벨
*/
public function label(): string
{
return match ($this) {
self::ListQuery => '목록 조회',
self::Screen => '화면 응답',
self::Write => '쓰기 작업',
self::Batch => '배치 작업',
};
}
/**
* 프로파일 선언에서 반드시 채워야 하는 옵션 키 목록
*
* 각 원소는 "대안 그룹"이며, 그룹마다 최소 하나가 선언되어야 합니다
* (`screen` 축은 라우트명 또는 URI 중 하나). 레지스트리가 이 목록으로 선언을
* 검증하며, 누락된 선언은 사유와 함께 경고로 드러내고 목록에서 제외합니다
* (조용히 버리면 계측 사각이 됩니다).
*
* @return array<int, array<int, string>> 필수 옵션 대안 그룹 목록
*/
public function requiredOptions(): array
{
return match ($this) {
self::ListQuery => [['table']],
self::Screen => [['route', 'uri']],
self::Write => [['callback']],
self::Batch => [['command']],
};
}
/**
* 기본적으로 데이터를 변경하는 축인지 여부
*
* true 인 축은 `--allow-write` 없이는 실행을 거부합니다. `screen` 축은 선언한
* HTTP 메서드에 따라 달라지므로 프로파일 단위로 다시 판정합니다.
*
* @return bool 데이터 변경 축 여부
*/
public function mutatesByDefault(): bool
{
return match ($this) {
self::ListQuery, self::Screen => false,
self::Write, self::Batch => true,
};
}
/**
* 모든 값 배열
*
* @return array<int, string> 축 값 목록
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}
+16 -2
View File
@@ -5,10 +5,15 @@ namespace App\Enums;
/**
* 본인인증 코어 purpose Enum.
*
* 코어가 계약으로 보장하는 4종 — `MailIdentityProvider` 가 모두 지원합니다.
* 코어가 계약으로 보장하는 5종 — `MailIdentityProvider` 가 모두 지원합니다.
* 모듈/플러그인은 `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()`
* 로 추가 purpose 를 선언할 수 있으며, 그 값은 `IdentityVerificationManager::declaredPurposes`
* 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 4종만 정의합니다.
* 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 5종만 정의합니다.
*
* case 를 추가하면 `IdentityVerificationManager::$corePurposes` 등록과
* `lang/{ko,en}/identity.php` 의 `purposes.{value}` 라벨을 함께 추가해야 합니다.
* 하나라도 빠지면 `label()` 이 i18n 키 원문을 그대로 돌려주고, 관리자 화면의
* 목적 목록에서 그 목적이 통째로 빠집니다.
*
* @since 7.0.0-beta.5
*/
@@ -26,6 +31,15 @@ enum IdentityVerificationPurpose: string
/** 민감 작업 (결제 등) */
case SensitiveAction = 'sensitive_action';
/**
* 로그인 2단계 인증
*
* 비밀번호 확인을 통과한 뒤 한 단계를 더 요구하는 용도입니다. 다른 purpose 와 달리
* 이 challenge 는 **아직 로그인하지 않은 주체**를 대상으로 하므로, 검증을 마치기
* 전까지 토큰이 발급되지 않습니다.
*/
case Login = 'login';
/**
* 코어 purpose 값 배열.
*
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Enums;
/**
* 검색 인덱스 건강도 등급 (엔진 중립)
*
* 판정 방법은 엔진마다 다릅니다 — 각 `SearchIndexMaintainer` 구현이 자기 방식으로
* 판정한 뒤 이 등급 중 하나로 답합니다. 코어는 등급만 보고 재생성 대상을 고릅니다.
*/
enum SearchIndexStatus: string
{
/** 정상 — 색인된 내용으로 검색이 성립한다 */
case Healthy = 'healthy';
/** 부분 — 일부만 성립. 엔진 특성일 수 있어 자동 재생성 대상이 아니다 */
case Degraded = 'degraded';
/** 색인 누락 — 검색이 성립하지 않는다. 재생성 대상 */
case Stale = 'stale';
/** 판정 불가 — 표본·연결 부재 등 (사유를 함께 기록) */
case Skipped = 'skipped';
/**
* 사용자 친화 라벨을 반환합니다.
*
* @return string lang 키 해석 결과 (locale 자동 반영)
*/
public function label(): string
{
return __('search.index.status.'.$this->value);
}
/**
* 콘솔 출력용 색상명을 반환합니다.
*
* @return string Symfony 콘솔 색상명
*/
public function consoleColor(): string
{
return match ($this) {
self::Healthy => 'green',
self::Degraded => 'yellow',
self::Stale => 'red',
self::Skipped => 'gray',
};
}
/**
* 모든 케이스의 string 값 목록.
*
* @return array<int, string>
*/
public static function allValues(): array
{
return array_column(self::cases(), 'value');
}
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Enums;
/**
* 사이트맵 <changefreq> 값 Enum (sitemaps.org 폐쇄 어휘)
*
* https://www.sitemaps.org/protocol.html 의 <changefreq> 는 크롤러에게 페이지 갱신
* 빈도를 알려주는 힌트로, 허용 값이 7개로 고정된 폐쇄 어휘다. 이 Enum 이 그 SSoT 이며,
* 잘못된 문자열이 사이트맵 XML 에 유입되는 것을 저장(SitemapIndexer)과 렌더
* (SitemapXmlRenderer) 두 경계에서 차단한다.
*
* 기여자(getUrlsLazy)·리스너·정적 URL 은 리터럴 문자열 대신 이 Enum 의 case 를 사용해
* 오타를 작성 시점에 잡는다.
*/
enum SitemapChangeFreq: string
{
/**
* 접근할 때마다 변경 (예: 실시간 시세)
*/
case Always = 'always';
/**
* 시간 단위 변경
*/
case Hourly = 'hourly';
/**
* 일 단위 변경
*/
case Daily = 'daily';
/**
* 주 단위 변경
*/
case Weekly = 'weekly';
/**
* 월 단위 변경
*/
case Monthly = 'monthly';
/**
* 연 단위 변경
*/
case Yearly = 'yearly';
/**
* 사실상 변경 없음 (예: 보관 URL)
*/
case Never = 'never';
/**
* 임의 문자열을 유효한 changefreq 값으로 정규화합니다.
*
* 대소문자·앞뒤 공백을 흡수하고, 폐쇄 어휘에 없는 값은 null 로 떨어뜨려
* 사이트맵 XML 에 비표준 값이 출력되는 것을 방지합니다.
*
* @param string|null $value 검증할 원본 값
* @return string|null 유효하면 정규화된 값, 아니면 null
*/
public static function normalize(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
return self::tryFrom(strtolower(trim($value)))?->value;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Enums;
/**
* 사이트맵 재생성 모드 Enum
*
* SitemapManager::regenerate 와 GenerateSitemapJob, seo:generate-sitemap 커맨드가
* 공유하는 재생성 모드 도메인입니다.
*
* - Full: 저장소 상태와 무관하게 각 기여자를 스트리밍해 sitemap_urls 를 전량 대체한 뒤 파일 재작성.
* - Auto: 저장소가 비어 있으면 Full, 아니면 Incremental 로 동작(스케줄러 기본).
* - Incremental: 기여자 재쿼리 없이 저장소의 현재 델타만으로 파일 재작성.
*/
enum SitemapGenerationMode: string
{
/**
* 전체 재생성 (관리자 수동 = 항상 Full)
*/
case Full = 'full';
/**
* 자동 판정 (빈 저장소=Full / 채워짐=Incremental)
*/
case Auto = 'auto';
/**
* 증분 재생성 (저장소 델타만 파일로 반영)
*/
case Incremental = 'incremental';
/**
* 저장소 상태를 반영해 실제 실행 모드(Full 또는 Incremental)로 해석합니다.
*
* Auto 는 저장소가 비어 있으면 Full, 아니면 Incremental 로 해석됩니다.
*
* @param int $visibleCount 저장소의 공개 URL 수
* @return self 실제 실행 모드 (Full | Incremental)
*/
public function resolve(int $visibleCount): self
{
return match ($this) {
self::Full => self::Full,
self::Incremental => self::Incremental,
self::Auto => $visibleCount === 0 ? self::Full : self::Incremental,
};
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Enums;
/**
* 목록 총 건수(total)와 실제 매칭 건수의 관계
*
* 대용량 목록에서 `COUNT(*)` 전량 집계는 비용이 커서 상한을 걸고 센다.
* 상한 이하면 센 값이 곧 실제 건수이고, 상한을 넘으면 "그 이상"이라는 사실만 알 수 있다.
* 화면은 이 값을 보고 "1,234건" 과 "10,000건 이상" 을 구분해 표기한다.
*/
enum TotalRelation: string
{
/** 정확 — total 이 실제 매칭 건수와 같다 */
case Exact = 'exact';
/** 하한 — 실제 매칭 건수가 total 이상이다 (상한 초과로 정확히 세지 않음) */
case AtLeast = 'at_least';
/**
* 모든 값을 문자열 배열로 반환합니다.
*
* @return array<int, string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인합니다.
*
* @param string $value 검증할 값
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 총 건수가 정확한지 여부를 반환합니다.
*
* @return bool 정확하면 true
*/
public function isExact(): bool
{
return $this === self::Exact;
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string lang 키 해석 결과
*/
public function label(): string
{
return __('pagination.total_relation.'.$this->value);
}
}
+19 -4
View File
@@ -12,6 +12,10 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
* HTTP 423 Locked 응답으로 매핑되며, 프론트엔드 토스트는 다국어 키
* `auth.account_locked` 로 잔여 분(`minutes` 플레이스홀더)을 노출합니다.
*
* `login_lockout_time = 0`(무한대) 으로 잠긴 계정은 해제 시각이 없으므로
* `lockedUntil` / `remainingMinutes` 가 모두 null 입니다. 이 경우 프론트엔드는
* 잔여 시간 대신 `auth.account_locked_permanently` 안내를 노출합니다.
*
* 컨트롤러는 본 예외를 별도로 catch 하여 ResponseHelper 응답을 만들거나,
* 글로벌 예외 핸들러가 자동으로 423 JSON 응답으로 변환합니다.
*
@@ -20,15 +24,26 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
class AccountLockedException extends HttpException
{
public function __construct(
public readonly Carbon $lockedUntil,
public readonly int $remainingMinutes,
public readonly ?Carbon $lockedUntil,
public readonly ?int $remainingMinutes,
?string $message = null,
) {
parent::__construct(
423,
$message ?? 'auth.account_locked',
$message ?? ($lockedUntil === null ? 'auth.account_locked_permanently' : 'auth.account_locked'),
null,
['Retry-After' => max(1, $remainingMinutes * 60)]
// 영구 잠금은 재시도 시점을 제시할 수 없으므로 Retry-After 를 붙이지 않는다.
$remainingMinutes === null ? [] : ['Retry-After' => max(1, $remainingMinutes * 60)]
);
}
/**
* 영구 잠금 여부를 반환합니다.
*
* @return bool 해제 시각이 없는 무기한 잠금이면 true
*/
public function isPermanent(): bool
{
return $this->lockedUntil === null;
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 보호된 역할(코어/확장 소유) 수정 시도 시 발생하는 예외
*
* 삭제 경로에만 있던 코어/확장 소유 역할 보호를 수정·상태변경 경로까지 대칭
* 적용하기 위한 예외입니다. 비-슈퍼관리자 액터는 `admin` 등 코어/확장 소유
* 역할의 권한·활성상태를 변경할 수 없습니다(KVE-2026-1919).
*/
class CannotModifyProtectedRoleException extends Exception
{
/**
* 보호된 역할 수정 시도 시 예외를 생성합니다.
*/
public function __construct()
{
parent::__construct(__('exceptions.cannot_modify_protected_role'));
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 비-슈퍼관리자 액터가 슈퍼 관리자 계정/역할을 수정하려 할 때 발생하는 예외
*
* 삭제 경로에만 있던 슈퍼 관리자 보호 가드를 수정·상태변경·권한부여 경로까지
* 대칭 적용하기 위한 등급 상한(rank ceiling) 위반 예외입니다.
*/
class CannotModifySuperAdminException extends Exception
{
/**
* 슈퍼 관리자 수정 시도 시 예외를 생성합니다.
*/
public function __construct()
{
parent::__construct(__('exceptions.cannot_modify_super_admin'));
}
}
@@ -0,0 +1,22 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 권한 상승(escalation) 시도 시 발생하는 예외
*
* 비-슈퍼관리자 액터가 역할에 자신이 보유하지 않은 권한, 또는 자신의 범위(scope)보다
* 넓은 범위의 권한을 부여하려 할 때 발생합니다(KVE-2026-1919 권한 상승 차단).
*/
class PermissionEscalationException extends Exception
{
/**
* 권한 상승 시도 시 예외를 생성합니다.
*/
public function __construct()
{
parent::__construct(__('exceptions.cannot_grant_unheld_permission'));
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 스케줄 실행 실패 예외
*
* 저장된 스케줄을 실행하는 시점에 차단 목록에 걸리거나(artisan/shell 화이트리스트, 내부망 URL),
* 실행 자체가 실패했을 때 발생합니다. `ScheduleService` 가 이 예외를 잡아 실행 이력에 기록합니다.
*/
class ScheduleExecutionException extends Exception
{
/**
* 거부 사유 코드 (`ScheduleCommandValidator::ARTISAN_REASON_*`).
*
* 관리자에게 보이는 메시지는 사유와 무관하게 동일하지만, 운영 진단 로그에는
* 어느 규칙에 걸렸는지가 남아야 한다 — 업그레이드 점검 스텝을 두지 않기로 했으므로
* 실행 이력과 로그가 유일한 통로다.
*/
public ?string $reasonCode = null;
/**
* 허용되지 않은 Artisan 명령
*
* @param string|null $reasonCode 거부 사유 코드 (`ScheduleCommandValidator::ARTISAN_REASON_*`)
* @return self 생성된 예외
*/
public static function artisanNotAllowed(?string $reasonCode = null): self
{
$exception = new self(__('schedule.artisan_not_allowed'));
$exception->reasonCode = $reasonCode;
return $exception;
}
/**
* 허용되지 않은 쉘 명령
*
* @return self 생성된 예외
*/
public static function shellNotAllowed(): self
{
return new self(__('schedule.shell_not_allowed'));
}
/**
* 쉘 명령 실행 실패
*
* @param string $errorOutput 프로세스 표준 에러 출력
* @param int $exitCode 프로세스 종료 코드
* @return self 생성된 예외
*/
public static function shellCommandFailed(string $errorOutput, int $exitCode): self
{
return new self($errorOutput !== '' ? $errorOutput : __('schedule.shell_command_failed'), $exitCode);
}
/**
* 공개되지 않은(내부망) URL 호출 차단
*
* @return self 생성된 예외
*/
public static function urlNotPublic(): self
{
return new self(__('schedule.url_not_public'));
}
/**
* HTTP 요청 실패
*
* @param int $status 응답 상태 코드
* @return self 생성된 예외
*/
public static function httpRequestFailed(int $status): self
{
return new self(__('schedule.http_request_failed', ['status' => $status]), $status);
}
}
@@ -40,6 +40,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider
*/
protected array $storageServices = [];
/**
* 카테고리별 StorageInterface 가 필요한 서비스 클래스 매핑 (클래스 ⇒ 카테고리).
*
* 등록된 클래스에는 getStorageFor($category) 인스턴스가 주입되어, 확장이
* getStorageDiskFor() 를 오버라이드하면 해당 서비스의 put/getDisk() 가
* 서비스 코드 무수정으로 카테고리 디스크를 따릅니다.
*
* @var array<class-string, string>
*/
protected array $storageCategoryServices = [];
/**
* CacheInterface 가 필요한 서비스 클래스 목록.
*
@@ -111,13 +122,17 @@ abstract class AbstractExtensionServiceProvider extends ServiceProvider
*/
protected function registerStorageBindings(): void
{
if (empty($this->storageServices)) {
return;
if (! empty($this->storageServices)) {
$this->app->when($this->storageServices)
->needs(StorageInterface::class)
->give(fn () => $this->resolveExtension()->getStorage());
}
$this->app->when($this->storageServices)
->needs(StorageInterface::class)
->give(fn () => $this->resolveExtension()->getStorage());
foreach ($this->storageCategoryServices as $service => $category) {
$this->app->when($service)
->needs(StorageInterface::class)
->give(fn () => $this->resolveExtension()->getStorageFor($category));
}
}
/**
+194 -11
View File
@@ -9,6 +9,7 @@ use App\Contracts\Extension\StorageInterface;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Extension\Cache\ModuleCacheDriver;
use App\Extension\Storage\ModuleStorageDriver;
use App\Extension\Traits\ReportsLifecycleFailure;
use Illuminate\Database\Seeder;
use ReflectionClass;
@@ -21,6 +22,8 @@ use ReflectionClass;
*/
abstract class AbstractModule implements CacheableExtensionInterface, ModuleInterface
{
use ReportsLifecycleFailure;
/**
* 모듈 디렉토리 경로 (캐시)
*/
@@ -551,6 +554,37 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return [];
}
/**
* 이 모듈이 등록할 HTTP 미들웨어 선언을 반환합니다.
*
* 모듈이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트
* (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets`
* 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명
* 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도
* 됩니다 (순수하게 유지).
*
* 기본적으로 빈 배열 반환. 미들웨어가 필요한 모듈만 오버라이드합니다.
*
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
* [
* [
* 'class' => VerifyGuestOrderToken::class, // 미들웨어 FQCN (class_exists 검증)
* 'groups' => ['api'], // 등록 그룹 배열: ['web'] | ['api'] | ['web','api']
* 'timing' => 'after_core', // 'after_core'(기본, 코어 그룹 미들웨어 뒤) | 'before_core'(코어 전처리보다 먼저)
* 'targets' => ['self'], // 라우트명/URI 패턴 배열. 'self' = 자기 확장 prefix 자동 치환
* ],
* ]
*
* targets 카탈로그: 'self'(자기 라우트) | 'all_extensions'(모든 확장, 코어 제외) |
* 'core'(코어만) | 'everything'·'*'(전부) | 'module:{id}' | 'plugin:{id}' |
* 원시 라우트명 glob·brace(`api.modules.x.*`, `{a,b}`) | '/' 로 시작하는 URI 패턴(무명 라우트용).
* targets 누락/빈배열 시 등록 거부. 상세: docs/backend/middleware.md "확장 미들웨어 선언".
*/
public function getMiddleware(): array
{
return [];
}
/**
* Declarative i18n getter family — 모듈이 선언하는 다국어/SSoT 데이터 4종.
*
@@ -718,6 +752,38 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return [];
}
/**
* 성능 계측 프로파일 정의를 반환합니다.
*
* 이 모듈이 소유한 목록/화면/저장 경로/배치 중 성능을 재고 싶은 대상을 선언합니다.
* `g7:bench` 커맨드가 코어 `config/benchmark.php` 선언과 함께 수집합니다
* (`App\Benchmark\BenchmarkProfileRegistry`). 계측 대상을 코어 커맨드에 하드코딩하지
* 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기
* 때문입니다. 키는 모듈 내부에서만 고유하면 되고, 다른 확장과 겹치면 커맨드가
* `{식별자}/{키}` 로 지목합니다.
*
* `write` 축의 `callback` 은 클로저를 쓸 수 없습니다 — 코어 선언과 스키마를 공유하고
* 코어 쪽은 `config:cache` 대상이므로, 형식을 `'Fqcn'`(invokable) 또는
* `['Fqcn', 'method']` 로 통일합니다.
*
* @return array<string, array<string, mixed>> 프로파일 키 → 정의
* [
* 'orders' => [
* 'type' => 'list', // list | screen | write | batch
* 'label' => '주문 목록',
* 'table' => 'ecommerce_orders',
* 'columns' => ['id', 'order_number', ...],
* 'order' => [['ordered_at', 'desc']],
* 'filters' => ['order_status' => 'paid'],
* 'soft_delete' => true,
* ],
* ]
*/
public function getBenchmarkProfiles(): array
{
return [];
}
/**
* 모듈 설치 시 실행할 시더 클래스 목록 반환
*
@@ -864,6 +930,31 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return [];
}
/**
* 신뢰하는 외부 스크립트 호스트 목록을 반환합니다.
*
* module.json 의 `trusted_script_hosts` 배열에서 읽습니다. 이 모듈이 레이아웃
* `scripts[].src` 로 로드하는 외부 CDN 호스트를 선언합니다. 코어는 이 목록을
* 집계(AbstractModule/AbstractPlugin → TrustedScriptHosts)해 런타임 스크립트 로더·
* 저장측 검증·정적 검사가 same-origin 이 아닌 스크립트 중 **선언된 호스트만** 허용하도록
* 합니다 (KVE-2026-1915 신뢰 출처 허용목록).
*
* @return array<int, string> 신뢰 호스트명 목록 (예: ['cdn.example.com'])
*/
public function getTrustedScriptHosts(): array
{
$hosts = $this->loadManifest()['trusted_script_hosts'] ?? [];
if (! is_array($hosts)) {
return [];
}
return array_values(array_filter(
array_map(fn ($host) => is_string($host) ? trim($host) : '', $hosts),
fn ($host) => $host !== ''
));
}
/**
* 레이아웃 확장 파일 경로 반환
*
@@ -1022,11 +1113,11 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
*
* @param string $pageType 레이아웃 meta.seo.page_type
* @return array<string, array{expr: string, label: string|array<string, string>}>
* 키별 데이터 경로 메타 — 예:
* [
* 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'vendor-module::seo.auto_value.product_image'],
* 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
* ]
* 키별 데이터 경로 메타 — 예:
* [
* 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'vendor-module::seo.auto_value.product_image'],
* 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
* ]
*/
public function seoOgDefaultMeta(string $pageType): array
{
@@ -1038,7 +1129,7 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
*
* @param string $pageType 페이지 타입
* @return array<string, array{expr: string, label: string|array<string, string>}> 키별 데이터 경로 메타
* (label = 번역 키 권장 — seoOgDefaultMeta 참조)
* (label = 번역 키 권장 — seoOgDefaultMeta 참조)
*/
public function seoTwitterDefaultMeta(string $pageType): array
{
@@ -1054,11 +1145,11 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
*
* @param string $pageType 페이지 타입
* @return array<string, array{expr: string, label: string|array<string, string>}>
* 점 경로 키별 데이터 경로 메타 (label = 번역 키 권장 — seoOgDefaultMeta 참조) — 예:
* [
* 'name' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
* 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => 'vendor-module::seo.auto_value.product_price'],
* ]
* 점 경로 키별 데이터 경로 메타 (label = 번역 키 권장 — seoOgDefaultMeta 참조) — 예:
* [
* 'name' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
* 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => 'vendor-module::seo.auto_value.product_price'],
* ]
*/
public function seoStructuredDataMeta(string $pageType): array
{
@@ -1190,6 +1281,29 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return $result;
}
/**
* 빌드된 에셋의 절대 파일 경로를 반환합니다.
*
* `getBuiltAssetPaths()` 는 module.json 의 상대 output 경로를 돌려주므로
* 파일을 실제로 읽으려면 모듈 루트(`getModulePath()`: 활성 dir 또는 `_bundled`
* 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합(ExtensionBundleService)이
* `getAssetFilePath()` 의 `base_path("modules/{id}/...")` 하드코딩을 복제하지
* 않고 `_bundled` 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다.
*
* @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...']
*/
public function getBuiltAssetAbsolutePaths(): array
{
$relative = $this->getBuiltAssetPaths();
$result = [];
foreach ($relative as $kind => $output) {
$result[$kind] = $this->getModulePath().'/'.$output;
}
return $result;
}
/**
* 모듈 스토리지 드라이버 인스턴스 반환
*
@@ -1223,6 +1337,75 @@ abstract class AbstractModule implements CacheableExtensionInterface, ModuleInte
return 'modules';
}
/**
* 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize)
*
* @var array<string, StorageInterface>
*/
private array $storageByDisk = [];
/**
* 카테고리별 스토리지 디스크 이름 반환
*
* 기본값은 getStorageDisk() 와 동일 (현행 동작 100% 보존).
* 특정 카테고리(예: 'images')만 다른 디스크를 쓰려면 모듈이 오버라이드합니다.
*
* 주의: 오버라이드 구현은 'settings' 카테고리에서 모듈 설정을 조회하면 안 됩니다 —
* 모듈 설정 로드가 getStorage()->get('settings', ...) 를 경유하므로 재귀 고리가 생깁니다.
* 모듈 설정 조회는 'images' 등 설정 저장과 무관한 카테고리에서만 수행합니다.
*
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
* @return string 디스크 이름
*/
public function getStorageDiskFor(string $category): string
{
return $this->getStorageDisk();
}
/**
* 카테고리별 스토리지 드라이버 인스턴스 반환
*
* getStorageDiskFor() 가 결정한 디스크의 드라이버를 디스크 단위로 memoize 하여 반환합니다.
* 기본 디스크와 동일하면 getStorage() 인스턴스를 그대로 재사용합니다.
*
* @param string $category 카테고리
* @return StorageInterface 스토리지 드라이버 인스턴스
*/
public function getStorageFor(string $category): StorageInterface
{
$disk = $this->getStorageDiskFor($category);
if (! isset($this->storageByDisk[$disk])) {
$base = $this->getStorage();
$this->storageByDisk[$disk] = ($disk === $base->getDisk()) ? $base : $base->withDisk($disk);
}
return $this->storageByDisk[$disk];
}
/**
* 공개 자산 디스크 설정값을 해석합니다.
*
* 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk).
* 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로
* 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다.
*
* @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용)
* @return string|null 사용할 디스크 이름 (스트리밍 유지면 null)
*/
protected function resolvePublicAssetDisk(?string $override = null): ?string
{
$disk = ($override !== null && $override !== '')
? $override
: (string) config('core.storage.public_asset_disk', '');
if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) {
return null;
}
return $disk;
}
/**
* 모듈 캐시 드라이버 인스턴스 반환
*

Some files were not shown because too many files have changed in this diff Show More