2단계 인증은 7.0.6 에서 서버측이 갖춰졌지만 인증번호를 입력할 화면이 어느 버전에도
없었다. 그래서 그 설정을 켠 사이트는 관리자를 포함한 전원이 로그인할 수 없었다.
원인은 `POST /api/auth/login` 이 조건에 따라 **다른 형태의 200** 을 돌려준다는 것이다.
평소에는 `{token, user}` 지만 2단계 인증이 켜져 있으면 `{two_factor_required,
challenge_id, ...}` 를 돌려준다. 프론트는 앞의 형태만 선언하고 `response.data.user.language`
를 바로 읽었으므로 그 자리에서 TypeError 가 났고, 영문 원문이 로그인 화면에 그대로 노출됐다.
서버는 정상 응답했으므로 서버 로그에는 아무 흔적도 남지 않는다.
이어서 `setToken(undefined)` 가 `localStorage` 에 문자열 `"undefined"` 를 남겼다.
이 값은 truthy 라 이후 모든 요청이 `Bearer undefined` 로 나가 401 이 되고, 사용자에게는
「세션이 만료되었습니다」로 보인다. 관리자 로그인은 한발 더 나가 `null->isAdmin` 으로
500 이 되어, 설정을 되돌릴 수단까지 함께 사라졌다.
## 구현
- 로그인 응답을 판별 유니온(`LoginResult`)으로 표현하고, 형태를 판별한 뒤에 읽는다.
`ApiClient.setToken` 은 비어 있지 않은 문자열만 저장한다.
- 사용자·관리자 로그인 화면에 인증번호 입력 단계를 추가했다. 같은 카드 안에서 넘어가며
「인증번호 다시 받기」와 「처음부터」를 제공한다. 관리자 판정은 코드 확인에 성공한 뒤에
수행하고, 거부할 때는 그 직전에 발급된 토큰을 회수한다.
- 재발송(`login/two-factor/resend`)은 기존 challenge 를 취소하고 새로 발행한다. 유효한
코드를 여러 개 살려 두면 대입 시도의 표적이 넓어진다.
- 인증번호를 보내지 못하면 401 이 아니라 503 으로 답한다. 자격 증명은 올바른데 401 로
뭉개면 사용자는 비밀번호를 의심하며 같은 시도를 반복하고, 운영자는 메일 설정이 깨진
사실을 알 방법이 없다.
- 공개 본인인증 경로(`identity/verify`·`cancel`)가 로그인 목적의 challenge 를 소진하지
못하도록 403 게이트를 세웠다. 소진되면 그 challenge 로 영영 로그인할 수 없다.
- 로그인 시도 제한 429 응답이 다국어 문구를 싣도록 했다(종전에는 프레임워크 기본 영문).
- 다국어 파라미터에서 파이프 표현식이 평가되지 않아 「유효시간 까지」처럼 값이 빠지던
문제를 함께 고쳤다. 같은 결함이 문의 목록 화면에도 있었다.
## 이번 점검에서 함께 고친 것
- 계정 잠금(423)·발송 실패(503) 응답이 사용자·관리자 컨트롤러에 동일하게 복제돼 있었고
그 주석 자신은 "단일 지점에서 만든다" 고 적혀 있었다. 페이로드에 필드가 하나 추가되면
한쪽만 따라가 같은 실패를 두 화면이 다르게 안내하게 된다 — 트레이트로 통합했다.
- 테스트가 개발자 자신의 사이트 설정을 읽고 있었다. 2단계 인증을 켜 둔 환경에서는 로그인
성공을 전제한 테스트가 503 으로 깨지는데 실패 메시지가 원인을 가리키지도 않는다.
같은 결함군을 위해 이미 존재하던 단일 지점에 그 축을 추가했다.
## 버전
코어 7.0.11 · sirsoft-basic 1.1.4 · sirsoft-admin_basic 1.0.9 ·
번들 일본어팩 3종 · 템플릿 엔진 engine-v1.65.0.
15 KiB
그누보드7 Basic 템플릿 — 에이전트 가이드
이 문서는 이 템플릿을 수정하는 에이전트·확장개발자를 위한 것입니다. 도입 검토·운영 관점은 README.md 를 보세요.
TL;DR (5초 요약)
1. 유형: 템플릿 (sirsoft-basic, type=user) — 방문자가 보는 사이트 전체 화면 166개. 게시판·이커머스 모듈은 방문자 화면을 갖지 않으므로 상점·게시판 UI 는 여기가 소유한다. 서버 코드 0줄
2. 확장 방식: 훅 0/0 — 확장점은 화면 구조다. 다른 확장이 `layout_extensions` 로 조각을 끼우고, `extensions/{확장}/` 오버라이드로 원본 조각을 대체한다
3. 건드리면 안 되는 것: `extends` 없는 독립 레이아웃에서 toast/modal 사용, 상점 경로 하드코딩(표현식 유지), 목록 이동의 `mergeQuery` 누락, 비회원 주문 토큰 진입 정리 제거
4. 작업 위치: `templates/_bundled/sirsoft-basic` — 활성 디렉토리 직접 수정 금지
5. 반영: `php artisan template:update sirsoft-basic --force`
1. 이 확장은 무엇인가
방문자가 보는 사이트 전체 화면을 소유하는 사용자(User) 템플릿입니다. 홈·게시판·상점· 마이페이지·인증·오류 화면 166개가 여기 있습니다.
이것이 왜 템플릿에 있는가가 이 확장을 이해하는 열쇠입니다. 게시판 모듈도 이커머스 모듈도
레이아웃이 전부 admin 그룹이고 방문자 화면을 갖지 않습니다 — 두 모듈은 관리자 CRUD 와 공개
API 까지만 소유하고, 그 API 를 소비해 실제로 그리는 것은 이 템플릿입니다. 상점 디자인을 바꾸는
작업은 이커머스 모듈이 아니라 여기입니다.
설계 원칙 넷:
- 모든 화면이
_user_base를 상속한다. 헤더·푸터·모바일 네비와 토스트·모달 호스트가 거기 있습니다.extends없이 독립 레이아웃을 만들면toast·openModal이 성공으로 기록되지만 화면에는 아무것도 나타나지 않습니다. - 화면은 조각으로 나눈다. 166개 중 124개가 partial 입니다. 조각은 여러 화면이 공유하며, 화면 하나를 고칠 때는 그 화면 이름의 partials 디렉토리를 함께 엽니다.
- 모듈 설정이 라우트에 스며든다. 상점 경로 9개가 표현식이라 운영자가 이커머스 설정에서 상점 경로를 바꾸거나 루트에 두면 라우트가 그에 맞춰 바뀝니다.
- 다른 확장의 조각을 갈아 끼울 수 있다.
extensions/{확장}/오버라이드가 그 장치이며, 지금은 주소 검색 조각 하나가 있습니다.
의도적으로 하지 않는 것: 서버 코드 일체(PHP 0줄 · 모델 0 · 라우트 파일 없음 · 훅 발행 0). 데이터는 전부 모듈·코어의 공개 API 에서 옵니다. 그래서 이 템플릿을 다른 것으로 바꿔도 데이터는 그대로이고, 반대로 이 템플릿만으로는 아무 기능도 동작하지 않습니다 — manifest 가 게시판· 이커머스·페이지 모듈과 주소 검색 플러그인을 의존으로 선언하는 이유입니다.
2. 디렉토리 지도
| 경로 | 역할 | 수정 시 필요한 절차 |
|---|---|---|
template.json |
manifest (버전 SSoT) | version 변경 시 package.json·package-lock.json 동기화 |
routes.json |
라우트 → 레이아웃 매핑 | php artisan template:update sirsoft-basic --force |
layouts/ |
레이아웃 JSON | php artisan template:update sirsoft-basic --force (빌드 불필요) |
extensions/ |
다른 확장 화면에 주입하는 레이아웃 조각 | php artisan template:update sirsoft-basic --force (빌드 불필요) |
seo-config.json |
SEO 렌더 설정 | php artisan template:update sirsoft-basic --force |
src/components/ |
React 컴포넌트 | php artisan template:build → php artisan template:update sirsoft-basic --force |
src/handlers/ |
템플릿 전용 액션 핸들러 | php artisan template:build → php artisan template:update sirsoft-basic --force |
dist/ |
커밋되는 빌드 산출물 | --production 으로 재빌드 (sourceMappingURL 잔존 금지) |
editor-spec.json |
레이아웃 편집기 스펙 | php artisan template:update sirsoft-basic --force |
editor-spec/ |
분할 편집기 스펙 | php artisan template:update sirsoft-basic --force |
tests/ |
테스트 | 변경 범위만 필터 실행 |
CHANGELOG.md |
변경 이력 | 버전 상향 시 항목 추가 (미기재 시 버전 상향 불가) |
components.json |
편집기 컴포넌트 선언 (레이아웃 저작자가 읽는 props 계약) | php artisan template:update sirsoft-basic --force |
docs/ |
개발자 문서 | 표면 변경 시 php artisan ext:docgen 재실행 |
lang/ |
다국어 | 키 추가 시 ko·en 동시 반영 + 번들 ja 팩 동기화 |
3. 핵심 흐름
서버 코드가 없으므로 흐름은 전부 라우트 → 레이아웃 → 데이터소스 → 컴포넌트입니다.
화면 렌더: 브라우저가 경로 진입 → routes.json 이 그 경로에 대응하는 레이아웃을 지목 →
레이아웃이 _user_base 를 상속해 헤더·푸터를 얻고 콘텐츠 슬롯을 채움 → data_sources 가
모듈·코어의 공개 API 를 호출 → 응답을 컴포넌트에 바인딩. 상점 경로는 이 첫 단계에서
표현식이 평가되어 운영자가 지정한 경로가 됩니다.
장바구니(비회원 포함): 진입 시 initCartKey 가 localStorage 의 장바구니 키를 확인하고
없으면 API 로 발급받습니다 → 담기·수량 변경·옵션 변경은 setCartOption ·
recalculateCart 등이 이커머스 API 를 호출하고 결과로 상태를 갱신 → 로그인하면 코어
auth.after_login 을 구독하는 이커머스 리스너가 비회원 장바구니를 회원 장바구니로 병합합니다
(그 병합은 서버 쪽 일이며 이 템플릿은 키만 넘깁니다).
비회원 주문 조회: 주문 완료 시 서버가 발급한 토큰을 saveGuestOrderToken 이 보관 →
조회 화면이 그 토큰으로 API 를 호출하면 서버의 VerifyGuestOrderToken 미들웨어가 신원을
확인합니다. 토큰이 곧 신원이므로 clearGuestTokenOnEntry 가 진입 시점에 남은 토큰을
정리합니다 — 공용 PC 에서 다음 사용자가 남의 주문을 열지 못하게 하는 방어입니다.
본인인증(IDV): 어떤 API 가 428 을 돌려주면 코어 인터셉터가 그것을 잡아, 부트스트랩에서 등록한 launcher 로 인증 화면을 엽니다. 인증을 마치면 원래 액션이 재개됩니다 — 각 화면이 428 을 개별 처리하지 않습니다.
4. 확장점
| 확장점 | 수 | 상세 |
|---|---|---|
| 제공 컴포넌트 | 79개 | 제공 컴포넌트 |
| 레이아웃 | 166개 | 레이아웃 목록 |
| 전용 핸들러 | 32개 | 템플릿 전용 핸들러 |
| 확장 오버라이드 | 1개 | 확장 오버라이드 |
이 템플릿은 훅을 발행하지도 구독하지도 않습니다(0/0). 템플릿의 확장점은 훅이 아니라 화면 구조 자체입니다.
| 확장점 | 어떻게 쓰는가 |
|---|---|
| 레이아웃 166개 | 다른 확장이 layout_extensions 로 자기 조각을 끼워 넣습니다 — 이커머스의 헤더 통화 선택기·마이페이지 마일리지 카드, 마케팅의 회원가입 동의 항목이 그 예입니다 |
| 제공 컴포넌트 79개 | 조각을 만드는 확장이 이 컴포넌트로 화면을 구성합니다. 여기 없는 컴포넌트를 쓰면 그 조각은 이 템플릿에서 렌더되지 않습니다 |
| 전용 핸들러 32개 | 조각의 액션에서 부를 수 있습니다. 네임스페이스가 붙은 10개(sirsoft-basic.*)는 전체 이름으로, 나머지는 이름만으로 부릅니다 |
| 확장 오버라이드 | extensions/{확장}/ 에 같은 이름의 조각을 두면 그 확장이 제공한 원본을 대체합니다 |
다른 확장이 이 템플릿에 화면을 얹는 방향이 정상입니다. 반대로 이 템플릿이 모듈의 관리자 화면에 개입하지는 않습니다.
seo-config.json 도 확장점입니다 — 봇 요청에 서버가 화면을 렌더할 때 어떤 속성을 HTML 로
내보낼지 이 파일이 정합니다. 새 컴포넌트가 텍스트를 담는 새 prop 을 쓰면 text_props 에
추가해야 봇 화면에 그 글자가 나타납니다.
5. 수정 시 동반 의무
_bundled에서만 수정하고php artisan template:update sirsoft-basic --force로 반영- manifest version 상향 시
package.json·package-lock.json동기화 + CHANGELOG 기재 - 레이아웃 JSON 변경 시 빌드 없이 update 만 — 신규 Tailwind 클래스는 빌드된 CSS 에 존재하는지 확인
- TSX/TS 변경 시
--production재빌드 후dist/커밋 (sourceMappingURL 잔존 금지) - 다국어 키 추가 시 ko·en 동시 반영 + 번들 ja 언어팩 증분 동기화
- 새 화면을 추가했다면
_user_base를 상속하는지,routes.json에 경로가 등록됐는지 확인 - 상점 관련 경로·링크는 표현식(
route_path·no_route)을 유지 — 문자열로 굳히지 않는다 - 목록 클러스터(목록 → 상세 → 형제 상세 → 폼 → 복귀) 이동 전 leg 에
mergeQuery: true - 텍스트를 담는 새 컴포넌트 prop 을 도입했다면
seo-config.json의text_props에 추가 (봇 화면에서만 글자가 사라진다) - 의존 모듈(게시판·이커머스·페이지)의 공개 API 응답 형태가 바뀌면 이 템플릿의 화면이 조용히 빈다 — 그 모듈을 올릴 때 함께 확인
extensions/sirsoft-daum_postcode/오버라이드는 원본이 바뀌어도 따라가지 않는다 — 그 플러그인 업그레이드 후 확인- TSX/TS 를 고쳤다면
template:build --production후dist/동반 커밋 (sourceMappingURL잔존 금지) - 프론트엔드 변경은 Playwright spec 동반 — 단위 테스트만으로는 화면 회귀가 드러나지 않는다
- 레이아웃·컴포넌트·
data_source를 건드렸다면docs/editor-spec.md의 동반 의무 표를 따라editor-spec/블록을 함께 갱신 — 컴포넌트는 팔레트·역량·중첩 넷 다 손대야 편집기에서 온전히 동작하고, 하나만 빠지면 절반만 동작한다. 반영은php artisan template:update sirsoft-basic --force(편집기는 활성 디렉토리만 읽는다) - 로그인 화면의 2단계 인증 단계를 고쳤다면 1단계·2단계
if의 상보성과login/loginTwoFactor의 상호배타if를 함께 확인 — 한쪽이 빠지면 인증번호 단계에서 Enter 가 새 challenge 를 발급한다
6. 금지 패턴
| 금지 | 올바른 사용 | 이유 |
|---|---|---|
extends 없는 독립 레이아웃에서 toast·openModal 사용 |
_user_base 를 상속하거나, 독립 레이아웃이라면 Toast·모달 호스트 컴포넌트를 직접 마운트 |
호스트가 없으면 핸들러는 성공으로 기록되는데 화면에는 아무것도 나타나지 않는다 |
상점 경로를 /shop/... 로 하드코딩 |
routes.json 의 표현식 유지 (route_path · no_route 반영) |
운영자가 경로를 바꾸거나 루트에 두면 하드코딩한 링크만 조용히 깨진다 |
목록 → 상세 → 목록 이동에서 mergeQuery 누락 |
목록 클러스터 내 모든 이동에 "mergeQuery": true |
검색어·페이지·필터가 사라져 사용자가 처음부터 다시 찾아야 한다 |
| 비회원 주문 토큰을 진입 시점에 정리하지 않음 | clearGuestTokenOnEntry 유지 |
토큰이 곧 신원이다 — 공용 PC 에서 다음 사용자가 남의 주문을 연다 |
401 오류 레이아웃에서 로그인으로 직접 리다이렉트 |
코어 TemplateApp.showRouteError 가드에 위임 |
이중 리다이렉트가 되고, 돌아올 위치를 코어가 이미 관리한다 |
모듈·플러그인에서 AuthManager.updateConfig() 호출 |
템플릿 부트스트랩에서만 | 여러 확장이 로그인 경로를 다투면 어느 값이 이기는지 설치 순서에 좌우된다 |
새 컴포넌트가 텍스트를 담는 prop 을 추가하면서 seo-config.json 을 그대로 두기 |
text_props 에 그 prop 추가 |
봇 화면에서만 그 글자가 사라진다 — 사람 눈에는 정상이라 검색 노출이 줄어든 뒤에야 드러난다 |
| 레이아웃 JSON 에 빌드된 CSS 에 없는 Tailwind 클래스 사용 | 기존 레이아웃에 쓰인 클래스이거나 빌드 산출물에 존재하는지 확인 | 그 스타일만 조용히 빠져 화면이 어긋난다 |
dist/ 재빌드 없이 src/ 만 고치고 커밋 |
template:build --production 후 dist/ 동반 커밋 |
브라우저가 받는 것은 커밋된 dist/ 다 — 소스 수정이 사문화된다 |
onSuccess·시퀀스 안에서 방금 저장한 상태(_global.*/_local.*)를 형제 액션의 if·값으로 재독 |
그 자리에서는 {{response.*}} 만 읽는다 |
그 시점 컨텍스트는 아직 갱신 전이라 stale 값으로 조용히 분기한다 |
7. 테스트 실행
| 종류 | 개수 | 위치 |
|---|---|---|
| PHPUnit | 0개 | — |
| Vitest | 147개 | vitest.config.ts |
| Playwright | 8개 | tests/Playwright |
| 시나리오 매니페스트 | 4개 | tests/scenarios |
# Vitest (확장 디렉토리에서) (PowerShell)
cd templates/_bundled/sirsoft-basic && powershell -Command "npm run test:run -- <대상>"
# Playwright E2E (확장 디렉토리에서) (Bash)
cd templates/_bundled/sirsoft-basic && npm run test:e2e -- specs/<대상>.spec.ts
무필터 전체 실행은 금지되어 있습니다 — 변경 범위에 걸리는 대상만 지정해 실행합니다.
8. 문서 목차
| 문서 | 내용 | 상태 |
|---|---|---|
| docs/README.md | 문서 통합 목차와 실측 집계 | ✅ |
| docs/architecture.md | 설계 의도·계층 지도·디렉토리 맵 | ✅ |
| docs/components.md | 템플릿이 제공하는 컴포넌트 | ✅ |
| docs/layouts.md | 레이아웃 목록과 라우트 매핑 | ✅ |
| docs/handlers.md | 템플릿 전용 핸들러와 부트스트랩 | ✅ |
| docs/editor-spec.md | 레이아웃 편집기에 선언한 팔레트·컨트롤·샘플 데이터 | ✅ |
| CHANGELOG.md | 변경 이력 | ✅ |