---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-30T03:15:00Z
concerns:
- "admin-console.jsp, admin-jam-list.jsp JS 내부의 post() 함수 구현 세부가 admin-post-categories / admin-unity-feeds와 정확히 동일한지 코드 레벨 비교 필요. 통합 시 파라미터 처리 분기가 예상치 않게 동작 변경될 수 있음. 구현 advisor가 통합 전 3-way diff 수행 권고."
- "header.jsp 내 .site-header CSS 변수 블록(--header-bg 등)은 global.css 의 토큰 범위와 겹치지 않는 컴포넌트 전용 변수이므로 중앙화 대상에서 제외. 구현 시 헷갈리지 않도록 명칭 분리 유지 확인 요망."
concerns_checked: true
references:
requirements: "(인라인 입력 — 이 문서의 ## 맥락 섹션)"
research: ".atp/work-session/20260630-113258/research/frontend-analysis.md"
adrs: []
---
# 프론트엔드 컴포넌트 개선 권고안
## 목표 / 비목표
### 목표
- FR-1: CSS 디자인 토큰 중복 제거 → 23개 파일 대신 단일 파일 변경으로 전역 반영
- FR-2: JS 공용 유틸리티(notify/openModal/post/handleResult/handleError) 중복 선언 제거
- FR-3: BibimbapModal 호출 패턴을 단일 방식으로 표준화
- FR-4: recruit-form의 CSRF 토큰 폴백 갭 수정 (보안)
- FR-5: 날짜 포맷 함수를 공용 파일로 추출하여 복사 중복 방지
### 비목표
- Webpack/Vite 등 빌드 도구 도입 (별도 의사결정 사항)
- CSS-in-JS, PostCSS 파이프라인 도입
- JSP 템플릿 엔진 교체 또는 React/Vue 마이그레이션
- 기존 HTML/JSP 구조 및 클래스명 리팩터링 (CSS 토큰만 외부화)
- header.jsp·footer.jsp·modal.jsp 기존 구현 변경
- 서버사이드 날짜 포매팅 도입
---
## 요약 (우선순위 매트릭스)
| 이슈 | 영향도 | 구현 비용 | 우선순위 |
|---|---|---|---|
| CSS 토큰 23개 파일 중복 | 높음 (유지보수 23배 비용) | 낮음 (파일 추가 + include 삽입) | P0 |
| 폼 제출 CSRF 갭 (recruit-form) | 높음 (보안) | 낮음 (10줄 이내 수정) | P0 — 보안 |
| JS 공용 유틸리티 분리 | 중간 (동작 불일치 방지) | 중간 (파일 작성 + 기존 인라인 제거) | P1 |
| 모달 패턴 표준화 | 중간 (신규 페이지 기준 제공) | 중간 (패턴 A/B 대체 + admin 2곳 수정) | P1 |
| 날짜 포맷 유틸리티 모듈 | 낮음 (예방적 분리) | 낮음 (파일 추출) | P2 |
---
## 권고 1 — CSS 토큰 중앙화 (P0)
### 접근
`src/main/webapp/css/global.css` 정적 파일을 신규 생성한다. 이 파일에 라이트/다크 테마 CSS 변수 블록, `body` 기본 스타일, `safe-area` padding 믹스인 클래스, `.admin-btn` 컴포넌트를 선언한다.
각 JSP의 `
` 최상단에 `` 를 삽입한다. 이 link는 `theme-init.jsp` `` 직후에 위치시켜 FOUC(Flash of Unstyled Content)를 방지한다.
Spring Boot WAR에서 `src/main/webapp/` 하위 정적 파일은 Servlet 컨테이너(Tomcat)가 직접 서빙한다. `UploadResourceConfig`의 `addResourceHandlers`는 `/profile/**` 전용이므로 `/css/**` 는 별도 등록 없이 Tomcat 기본 DefaultServlet이 처리한다. `src/main/resources/static/` 경로는 필요하지 않으며 신규 생성도 불필요하다.
### 파일 경로
| 역할 | 경로 |
|---|---|
| 신규 — 전역 CSS | `src/main/webapp/css/global.css` |
| 수정 대상 — 인라인 토큰 제거 | `src/main/webapp/WEB-INF/views/*.jsp` (23개) |
### global.css 선언 내용 (구체 명세)
```
/* 1. 라이트 테마 토큰 */
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.16);
--border: rgba(0, 0, 0, 0.08);
--shadow: rgba(0, 0, 0, 0.06);
--field-bg: #fff;
--button-text: #1a1a1a;
}
/* 2. 다크 테마 토큰 */
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.35);
--field-bg: #181818;
--button-text: #1a1a1a;
}
/* 3. body 기본 스타일 */
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
/* 4. admin-btn 컴포넌트 (admin 4개 파일 중복 제거) */
.admin-btn {
min-height: 2.25rem;
padding: 0 0.85rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card-bg);
color: var(--text);
font: inherit;
font-size: 0.8125rem;
font-weight: 800;
cursor: pointer;
}
.admin-btn:hover { border-color: rgba(232, 165, 75, 0.45); }
.admin-btn--primary { border-color: transparent; background: var(--accent); color: var(--button-text); }
.admin-btn--danger { border-color: rgba(200, 60, 60, 0.45); color: #c83c3c; }
```
**포함하지 않는 것**: 각 JSP 고유 레이아웃 클래스(`.admin-page`, `.form-page`, `.auth-main` 등), `max-width` 값(파일마다 48/72/78rem으로 다름), 페이지 전용 컴포넌트 CSS. 이들은 계속 인라인으로 유지한다.
**`--danger` 토큰**: `admin-unity-feeds.jsp`에만 있는 `--danger: #c83c3c / #ef7878` 변수는 global.css에 추가하지 않는다. 해당 파일이 유일한 사용처이므로 인라인 유지가 맞다.
**`--accent-soft` 미선언 파일 처리**: `login.jsp`와 `profile.jsp`는 `--accent-soft`를 선언하지 않는다. global.css에 전역 선언 후 이 두 파일의 인라인 블록에서 해당 변수가 없어도 문제 없다 (사용 위치가 없거나 다른 변수로 대체됨). 구현 시 각 파일의 실제 사용처를 확인하고 미사용이면 그냥 삭제, 사용이면 global.css 선언이 적용되므로 정상.
### 마이그레이션 경로 (단계별)
**Phase 1 — 파일 생성 + 1개 JSP 검증 (위험 최소)**
1. `src/main/webapp/css/global.css` 작성
2. `login.jsp` 1개에만 `` 태그 추가 + 인라인 토큰 블록 제거
3. 브라우저에서 라이트/다크 테마 전환 육안 검증
4. 문제 없으면 Phase 2 진행
**Phase 2 — admin 4개 파일 적용 (`.admin-btn` 중복 제거 포함)**
1. `admin-post-categories.jsp`, `admin-unity-feeds.jsp`, `admin-console.jsp`, `admin-jam-list.jsp` 에 `` 추가
2. 각 파일의 인라인 `html {}`, `html[data-theme="dark"] {}`, `body {}`, `.admin-btn*` 블록 제거
3. admin 기능(등록/수정/삭제) 동작 검증
**Phase 3 — 나머지 19개 JSP 일괄 적용**
1. 남은 파일에 `` 추가 + 인라인 토큰 제거
2. 각 페이지 라이트/다크 테마 시각 검증
### 위험 평가
| 위험 | 가능성 | 대응 |
|---|---|---|
| 일부 JSP가 인라인으로 토큰 값을 오버라이드하고 있다면 global.css 도입 후 이전과 동일 동작 | 낮음 (토큰 값 동일 확인됨) | Phase 1 검증 후 진행 |
| 특정 파일의 인라인 `html {}` 에 global.css에 없는 추가 변수 존재 (예: `--radius`, `--webgl-bg`) | 있음 (game-detail.jsp 확인) | 파일 전용 변수는 인라인 유지 — 중앙화 대상은 공통 8+4개 토큰만 |
| Tomcat DefaultServlet이 `/css/` 경로를 차단하는 보안 필터 존재 | 매우 낮음 | `/images/logo.png` 가 이미 동일 방식으로 서빙 중 — 검증됨 |
| FOUC: CSS 로드 전 테마 변수 미적용 깜빡임 | 낮음 | `theme-init.jsp`가 `` 최상단에서 `data-theme` 속성을 먼저 설정하고 있으므로 CSS 변수 로드 타이밍이 맞음 |
---
## 권고 2 — JS 공용 유틸리티 분리 (P1)
### 접근
`src/main/webapp/js/bibimbap-utils.js` 를 신규 생성하고 admin 4개 파일의 공통 함수(`post`, `handleResult`, `handleError`)를 이 파일로 추출한다. 이 파일은 `window.BibimbapUtils` 네임스페이스에 노출한다.
`notify()`와 `openModal()` 은 title이 파일마다 달라 단순 통합이 불가능하므로 별도 전략을 쓴다(권고 3에서 다룸).
각 JSP에서는 `` 직전에 `` 를 삽입하고, 기존 인라인 함수 선언을 제거한다.
### 파일 경로 및 API 계약 (함수 시그니처)
**신규 파일**: `src/main/webapp/js/bibimbap-utils.js`
```javascript
window.BibimbapUtils = (function () {
/**
* CSRF 토큰을 포함한 application/x-www-form-urlencoded POST 요청을 전송한다.
*
* @param {string} url - 요청 대상 URL
* @param {URLSearchParams|null} params - 요청 바디 파라미터 (null 이면 빈 URLSearchParams 사용)
* @param {string} csrfToken - X-CSRF-Token 헤더에 설정할 토큰 값
* @returns {Promise}
*/
function post(url, params, csrfToken) {
// ...
}
/**
* fetch Response가 ok이면 location.reload(), 아니면 오류 메시지를 콜백으로 전달한다.
*
* @param {Response} res - fetch가 반환한 Response 객체
* @param {function(string)} onError - 오류 메시지 문자열을 받는 콜백
*/
function handleResult(res, onError) {
// ...
}
/**
* 네트워크/예외 오류 발생 시 오류 메시지를 콜백으로 전달한다.
*
* @param {function(string)} onError - 오류 메시지 문자열을 받는 콜백
* @returns {function} catch 핸들러로 사용 가능한 함수
*/
function makeErrorHandler(onError) {
// ...
}
return { post: post, handleResult: handleResult, makeErrorHandler: makeErrorHandler };
})();
```
**시그니처 결정 이유**:
- `post(url, params, csrfToken)`: 기존 admin 4개 파일의 `post()` 는 클로저로 `CSRF_TOKEN` 변수를 캡처하는 방식이었다. 전역 파일로 추출하면 클로저 캡처가 불가능하므로 `csrfToken`을 명시 파라미터로 받는다. 호출부가 항상 토큰을 직접 넘기므로 의도가 코드에 드러나고 타입 검사도 명확해진다.
- `handleResult(res, onError)`: 기존 구현은 `notify()`를 직접 호출했다. `notify()`는 파일마다 title이 달라 전역화 불가능하므로, 대신 오류 메시지 문자열을 콜백으로 전달하는 방식으로 변경한다. 호출부가 메시지를 받아 직접 표시하므로 유연성과 분리가 동시에 달성된다.
- `makeErrorHandler(onError)`: 기존 `handleError()`는 매직 문자열 `'요청 중 오류가 발생했습니다.'` 를 하드코딩했다. 유틸리티 레벨에서 특정 언어 문자열을 결정하지 않도록 콜백으로 위임한다.
**inflate 경고**: `post()` 의 세 번째 파라미터 `csrfToken`이 항상 사용되는지 구현 시 확인 필요. 만약 호출부 중 하나라도 빈 문자열을 넘기는 패턴이 발견되면 `BibimbapCsrf.token()` 통합을 고려한다 (concerns 참조).
### 마이그레이션 경로
1. `bibimbap-utils.js` 작성 (3개 함수 포함)
2. `admin-post-categories.jsp` 에 script 태그 추가 + 인라인 `post`, `handleResult`, `handleError` 함수 제거. `BibimbapUtils.post(...)`, `BibimbapUtils.handleResult(...)`, `BibimbapUtils.makeErrorHandler(...)` 로 호출부 수정
3. 기능 검증 후 나머지 admin 3개 파일에 동일 적용
### 위험 평가
| 위험 | 가능성 | 대응 |
|---|---|---|
| admin 파일마다 `post()` 구현이 미묘하게 달라 통합 시 동작 변경 | 중간 | 통합 전 4개 파일 `post()` 구현 3-way diff 수행 (concerns 등록) |
| `handleResult` 시그니처 변경으로 기존 호출부 수정 필요 | 확실 | 마이그레이션 범위를 admin 4개로 한정, 변경이 넓지 않음 |
---
## 권고 3 — 모달 사용 패턴 표준화 (P1)
### 접근
**표준 패턴 채택**: `BibimbapModal` API (`window.BibimbapModal.alert / .confirm / .prompt`) 를 직접 호출하는 인라인 패턴을 표준으로 정한다. 기존 헬퍼 래퍼(`openModal()`, `notify()`)는 더 이상 신규 작성하지 않는다.
**패턴 A (openModal 래퍼) 처리**: login/signup/game-register/profile 4개 파일의 `openModal()` 로컬 함수를 제거하고 호출부를 `window.BibimbapModal.alert({...})` 직접 호출로 대체한다. `BibimbapModal` 미존재 폴백은 `else { alert(message); }` 로 단순화한다.
**패턴 B (notify 래퍼) 처리**: admin 4개 파일의 `notify()` 를 제거한다. 권고 2의 `BibimbapUtils.handleResult(res, onError)` 에서 `onError` 콜백이 `BibimbapModal.alert` 를 직접 호출하도록 호출부를 작성한다. 이렇게 하면 `notify()` 래퍼 없이도 동일 효과를 얻는다.
**패턴 C (인라인 직접 체크) 처리**: posts-form/recruit-form/game-detail 은 이미 인라인이므로 현재 형태 유지. 다만 `game-detail.jsp` 내 `notifyError()` / `confirmAction()` 헬퍼(라인 1553-1561)는 파일 전용이므로 제거하지 않는다. 단일 파일에서의 로컬 헬퍼는 허용.
**window.confirm 직접 사용 수정 (보안/일관성)**:
- `admin-post-categories.jsp:323` 의 `window.confirm()` → `BibimbapModal.confirm({...})` 으로 변경
- `admin-unity-feeds.jsp:401` 의 `window.confirm()` → `BibimbapModal.confirm({...})` 으로 변경
이 두 곳은 삭제 확인 흐름이므로 `onConfirm` 콜백에 실제 삭제 로직을 이동한다.
### 표준 패턴 예시 (참고용)
```javascript
// 알림 (확인 버튼만)
if (window.BibimbapModal) {
window.BibimbapModal.alert({ title: '제목', message: '내용', confirmText: '확인' });
} else {
alert('내용');
}
// 확인/취소 대화
if (window.BibimbapModal) {
window.BibimbapModal.confirm({
title: '삭제 확인',
message: '삭제하시겠습니까?',
onConfirm: function () { /* 삭제 로직 */ }
});
} else {
if (window.confirm('삭제하시겠습니까?')) { /* 삭제 로직 */ }
}
```
### 파일 영향 맵
| 변경 유형 | 경로 | 역할 |
|---|---|---|
| 수정 — openModal 제거 | login.jsp, signup.jsp, game-register.jsp, profile.jsp | 패턴 A → 직접 호출 |
| 수정 — notify 제거 | admin-console.jsp, admin-jam-list.jsp, admin-post-categories.jsp, admin-unity-feeds.jsp | 패턴 B → 콜백 방식 |
| 수정 — window.confirm 교체 | admin-post-categories.jsp:323, admin-unity-feeds.jsp:401 | BibimbapModal.confirm 사용 |
| 유지 — 변경 없음 | game-detail.jsp (notifyError/confirmAction), posts-form.jsp, recruit-form.jsp | 파일 전용 헬퍼 허용 |
### 마이그레이션 경로
1. `admin-post-categories.jsp` + `admin-unity-feeds.jsp` 의 `window.confirm` 2곳 먼저 수정 (보안 갭과 연결)
2. login/signup openModal 제거 + 직접 호출 전환
3. game-register/profile 동일 처리
4. admin 4개 파일 notify 제거 (권고 2와 동시 진행)
### 위험 평가
| 위험 | 가능성 | 대응 |
|---|---|---|
| BibimbapModal이 modal.jsp 로드 전에 JS가 실행되는 경우 | 낮음 (header.jsp가 modal.jsp를 항상 include) | 변경 없이 현 구조 유지 |
| window.confirm 삭제 시 콜백 이동 누락으로 삭제 로직 미실행 | 중간 | 각 파일 수정 후 삭제 기능 수동 검증 |
---
## 권고 4 — 폼 제출 유틸리티 + CSRF 갭 수정 (P0 — 보안)
### 접근
**즉시 수정 (CSRF 갭)**: `recruit-form.jsp` 의 fetch 호출부에서 `BibimbapCsrf` 미존재 폴백(라인 390-394)이 CSRF 토큰을 전혀 포함하지 않는 문제를 수정한다. 두 가지 방법 중 **방법 A를 채택**한다.
**방법 A (채택)**: 폴백 브랜치에 `hidden input`에서 추출한 토큰을 직접 삽입한다.
```javascript
// recruit-form.jsp 수정안
var csrfToken = (document.querySelector('input[name="_csrf"]') || {}).value || '';
fetch(form.action, {
method: 'POST',
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}) : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken // 갭 수정
},
body: body
})
```
**전제**: `recruit-form.jsp` 에 `` hidden input을 추가해야 한다. 현재 `recruit-form.jsp` 에는 이 hidden input이 없다. 추가하면 `new FormData(form)` 이 자동으로 `_csrf` 파라미터를 포함하므로 서버사이드 form 파라미터 검증도 함께 강화된다.
**방법 B (미채택)**: `theme-init.jsp` 의 `BibimbapCsrf` 를 항상 신뢰하여 폴백 분기 자체를 제거. 단, `BibimbapCsrf` 미존재 케이스를 완전히 제거하면 `theme-init.jsp` 가 로드 실패 시 CSRF 토큰이 아예 없어지는 더 큰 갭이 생긴다. 따라서 채택하지 않는다.
**401 리다이렉트 불일치 수정**: `recruit-form.jsp` 의 401 처리를 `posts-form.jsp` 와 동일하게 `redirectLogin` 함수로 분리한다.
**성공 시 폴백 불일치 수정**: `recruit-form.jsp:419-421` 의 `alert(...)` 호출을 `posts-form.jsp:260-261` 패턴에 맞게 `alert` 없이 `go()` 만 호출하도록 변경한다.
### 파일 영향 맵
| 변경 유형 | 경로 | 역할 |
|---|---|---|
| 수정 (보안) | recruit-form.jsp | hidden _csrf input 추가 + 폴백 브랜치 토큰 삽입 |
| 수정 (일관성) | recruit-form.jsp | 401 처리 redirectLogin 함수 분리 |
| 수정 (일관성) | recruit-form.jsp | 성공 폴백 alert 제거 |
| 변경 없음 | posts-form.jsp | 현행 유지 (기준 파일) |
### 마이그레이션 경로
1. `recruit-form.jsp` 에 `` 추가 (JSP EL 또는 request attribute 사용)
2. JS 폴백 브랜치에 `X-CSRF-Token` 헤더 추가
3. 401 처리 함수 분리
4. 성공 폴백 통일
5. 실제 폼 제출(등록) + 401 시나리오(로그아웃 후 제출) 수동 검증
### 위험 평가
| 위험 | 가능성 | 대응 |
|---|---|---|
| hidden _csrf input 추가 시 서버 컨트롤러가 기대하는 파라미터명 불일치 | 낮음 | posts-form.jsp와 동일 파라미터명 `_csrf` 사용 |
| BibimbapCsrf가 항상 존재한다고 가정하고 폴백을 제거하고 싶은 유혹 | 중간 | 방법 B 미채택 이유 참조 — 폴백 브랜치 유지 |
---
## 권고 5 — 날짜 포맷 유틸리티 모듈 (P2)
### 접근
`game-detail.jsp` 인라인의 `fmtAbsolute`, `fmtRelative`, `buildTimeEl` 세 함수를 `src/main/webapp/js/bibimbap-date.js` 로 추출하고 `window.BibimbapDate` 네임스페이스에 노출한다.
`game-detail.jsp` 는 `` 를 추가하고 기존 인라인 선언을 제거한다. 다른 detail 페이지(posts-detail, recruit-detail, jam-detail)에서 날짜 포맷이 필요해질 때 이 파일을 include하면 된다 — 현재는 필요하지 않으므로 강제 적용하지 않는다.
### 파일 경로 및 API 계약 (함수 시그니처)
**신규 파일**: `src/main/webapp/js/bibimbap-date.js`
```javascript
window.BibimbapDate = (function () {
/**
* ISO 8601 문자열을 ko-KR 로케일 절대 날짜/시각 문자열로 변환한다.
*
* @param {string} iso - ISO 8601 날짜 문자열
* @returns {string} - "2026. 6. 30. 오전 10:00:00" 형식, 파싱 실패 시 빈 문자열
*/
function fmtAbsolute(iso) { ... }
/**
* ISO 8601 문자열을 상대 시각 문자열로 변환한다 (7일 이내: "n분/시간/일 전", 초과: 절대).
*
* @param {string} iso - ISO 8601 날짜 문자열
* @returns {string} - "3시간 전" 또는 절대 날짜, 파싱 실패 시 빈 문자열
*/
function fmtRelative(iso) { ... }
/**
*