bibimbap/.atp/work-session/20260630-105459/research/posting-500-research.md

191 lines
11 KiB
Markdown

---
phase: research
agent: research-advisor
agent_version: 2
generated_at: 2026-06-30T11:10:00+09:00
concerns:
- "PostsMapper.update() SQL에 category_id 누락 — 카테고리 변경이 DB에 반영되지 않음 (버그, 500 직접 원인은 아님)"
- "PostController의 editForm/update/delete 핸들러가 getPublished()를 사용해 DRAFT 포스트 조회 불가 — DRAFT 저장 후 재편집 시 null → redirect:/posts 또는 404 (500 간접 원인 후보)"
- "posts-detail.jsp에서 post attribute null 체크 없이 post.getTitle() 등 직접 접근 — post가 null이면 NullPointerException → 500"
- "PostAdminController에 인증/권한 체크 부재 — Spring Security 미사용 환경에서 /admin/** RbacInterceptor 보호 범위 확인 필요"
source_confidence: high
workers_spawned: 4
---
# 조사 결과
## 주제
bibimbap 프로젝트 포스팅 메뉴 진입 시 500 Error 원인 조사
---
## 포인트별 발견
### 포인트 1: 포스팅 Controller URL 매핑
- 경로: `/Users/wemadeplay/workspace/stz/bibimbap/src/main/java/com/pandoli365/bibimbap/controller/PostController.java`
- 신뢰도: 확인됨
| HTTP | 경로 | 핸들러 메서드 | 반환 |
|------|------|-------------|------|
| GET | `/posts` | `list` | View: `posts-list` |
| GET | `/posts/{id}` | `detail` | View: `posts-detail` (null이면 redirect:/posts) |
| GET | `/posts/new` | `createForm` | View: `posts-form` (미인증 시 redirect:/login) |
| POST | `/posts` | `create` | JSON ResponseEntity |
| GET | `/posts/{id}/edit` | `editForm` | View: `posts-form` (미인증 시 redirect:/login) |
| POST | `/posts/{id}` | `update` | JSON ResponseEntity |
| POST | `/posts/{id}/delete` | `delete` | JSON ResponseEntity |
- 경로: `/Users/wemadeplay/workspace/stz/bibimbap/src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java`
- 신뢰도: 확인됨
| HTTP | 경로 | 핸들러 메서드 |
|------|------|-------------|
| GET | `/admin/post-categories` | `postCategoriesPage` → View: `admin-post-categories` |
| POST | `/admin/post-categories` | `createCategory` |
| POST | `/admin/post-categories/{id}` | `updateCategory` |
| POST | `/admin/post-categories/{id}/delete` | `deleteCategory` |
---
### 포인트 2: Service / Mapper 체인
- 신뢰도: 확인됨
**PostController 의존 Bean:**
- `PostsMapper` (annotation 기반 MyBatis) — `listPublishedKeyset`, `getPublished`, `insert`, `update`, `softDelete`
- `PostCategoriesMapper``listActive`, `getActive`, `getById`, `insert`, `update`, `delete`, `countPostsByCategory`
- `PostMarkdownService` — CommonMark 파싱 + Jsoup basicWithImages safelist sanitize
- `OgPreviewService` — SSRF-safe HTTP fetch + OG 메타 파싱, 실패 시 graceful empty
**별도 PostService/PostServiceImpl 없음.** 비즈니스 로직이 Controller에 직접 집약. XML Mapper 없음, 전부 `@Select`/`@Insert`/`@Update` annotation.
---
### 포인트 3: 500 에러 원인 후보 (가능성 순)
#### [1순위 — 가장 유력] posts-detail.jsp NPE (확인됨)
- 파일: `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-detail.jsp`
- `PostData post = (PostData) request.getAttribute("post");` 이후 null 체크 없이 `post.getTitle()`, `post.getId()` 등 직접 접근
- **`post` attribute가 null인 상태로 JSP가 렌더링되면 NullPointerException → 500**
- 발생 경로: `PostController.detail()``postsMapper.getPublished(id)`를 호출해 null을 받으면 `redirect:/posts`로 분기하므로 Controller 레벨에서는 방어됨. 그러나 다른 진입 경로(forward, include, 테스트 호출)에서 post attribute 누락 시 노출 가능.
#### [2순위] DRAFT 포스트 조회 시 null 처리 흐름 (확인됨)
- 파일: `PostController.java` line 167, 206, 252
- `editForm`, `update`, `delete` 핸들러 모두 `postsMapper.getPublished(id)` 사용
- `getPublished` SQL: `WHERE p.status = 'PUBLISHED' AND p.is_delete = false`
- **DRAFT 상태로 저장된 포스트 ID로 editForm 접근 시 → null → `if (post == null) return "redirect:/posts"`** — 500이 아닌 redirect지만, JS fetch로 update/delete 호출 시 null → NPE 가능성:
- `PostController.update()` line 206: `existing.setCategoryId(categoryId)``existing`이 null이면 NPE → 500
- `PostController.delete()` line 252: `postsMapper.softDelete(id)` 호출 전 null 체크 여부 추가 확인 필요
#### [3순위] PostsMapper.update() — category_id 컬럼 누락 (확인됨)
- 파일: `PostsMapper.java` line 120-135
- UPDATE SQL 대상 컬럼: `title, body_markdown, body_sanitized_html, link_url, og_*, status, updated_at`
- **`category_id = #{categoryId}` 누락** → 카테고리 변경이 DB에 반영되지 않음
- 500 직접 원인은 아니나, 카테고리 변경 요청 시 데이터 정합성 손상
#### [4순위] PostsMapper.insert() 직후 ID null 체크 (확인됨)
- `PostController.create()` line 149: `if (post.getId() == null)` 체크 후 500 응답
- DB INSERT 실패(constraint violation, DB 연결 오류 등) 시 MyBatis 예외 → Spring이 500으로 변환
- 포스트 생성 시 `category_id` FK 참조 실패(비활성 카테고리 등) 가능
#### [5순위] Bean 주입 / 빌드 이슈 (확인됨 — 현재 정상)
- Spring Boot 3.5.16, MyBatis Spring Boot 3.0.5: 호환 정상
- 모든 Bean 생성자 주입, `@Mapper` 어노테이션 정상
- JSTL 미사용(scriptlet 방식), tomcat-embed-jasper compile scope: JSP 렌더링 정상
- ViewResolver: prefix=`/WEB-INF/views/`, suffix=`.jsp` 정상
- **기동 실패 유발 구조적 결함 없음**
#### [6순위] PostAdminController 권한 체크 부재 (추정)
- `PostController``gate.has(session, "POST_WRITE")` 명시 검사
- `PostAdminController`에는 `PermissionGate` 주입 자체 없음
- `InterceptorConfig`에서 `/admin/**``RbacInterceptor` 등록 확인됨 — 필터 레벨 보호 존재
- `/admin/jams/**`는 제외(addExcludePatterns). `/admin/post-categories`는 포함 여부 추가 확인 필요
- 500 직접 원인 아님; 인증 우회 시 NPE 가능성
---
### 포인트 4: 관련 JSP 파일 경로
- 신뢰도: 확인됨
| JSP | 절대 경로 |
|-----|----------|
| 포스트 목록 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-list.jsp` |
| 포스트 상세 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-detail.jsp` |
| 포스트 작성/수정 폼 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-form.jsp` |
| 관리자 카테고리 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/admin-post-categories.jsp` |
**admin-post-categories.jsp 주의사항:**
- `var BASE = '/admin/post-categories';` — context path 미포함 하드코딩
- 다른 JSP들은 `<%= ctx %>` 변수(contextPath 동적 주입)를 사용하는 것과 비교해 inconsistent
- context root가 `/` 이외로 배포 시 404 발생
---
### 포인트 5: 최근 git 변경 사항 (포스팅 관련)
- 신뢰도: 확인됨
| 커밋 해시 | 메시지 |
|---------|--------|
| `6047a39` | `feat(board): W3-3 포스팅 보드 — 공지/블로그 + OG 미리보기(SSRF 방어) + 유니티 피드 감시` |
- 포스팅 기능이 이 단일 커밋(32파일, 5327줄 추가)으로 초기 도입됨 (2026-06-29)
- **후속 수정 커밋 없음** — 초기 도입 이후 한 번도 수정되지 않은 상태
- 이전 세션 로그에서 발견된 PKIX 빌드 오류(`spring-boot-dependencies:3.5.14-SNAPSHOT`)는 `e51ab01` 커밋으로 해소됨 (현재 정식 버전 사용)
---
## 종합 판단
**"포스팅 메뉴 진입 시 500"의 가장 유력한 원인은 `posts-detail.jsp`의 null 체크 부재(NPE)다.**
구체적 시나리오:
1. `GET /posts/{id}` 요청 → `PostController.detail()``postsMapper.getPublished(id)` 호출
2. `id`가 DRAFT 상태이거나 삭제된 포스트이거나 존재하지 않으면 → `null` 반환 → `redirect:/posts`로 처리됨 (Controller 레벨 방어 있음)
3. 그러나 **"포스팅 메뉴 진입"이 `GET /posts` (목록)라면**: `posts-list.jsp`로 렌더링되며 `postCategoriesMapper.listActive()`가 빈 결과를 반환할 경우 JSP 루프는 정상 처리. 별도 NPE 없음.
4. **만약 카테고리가 하나도 없는 상태에서 `createForm`(`GET /posts/new`) 진입 시**: `postCategoriesMapper.listActive()`가 빈 리스트를 반환하더라도 JSTL/scriptlet 루프이므로 NPE 없음.
따라서 **정확한 500 재현 경로는 다음 중 하나**:
- `GET /posts/{존재하지 않거나 DRAFT인 id}` → Controller null 체크 → redirect (500 아님)
- `POST /posts/{DRAFT id}` (JS fetch update) → `existing = postsMapper.getPublished(id)` → null → `existing.setCategoryId()`**NPE → 500****가장 유력**
- `posts-detail.jsp` 직접 forward/include 시 post attribute 누락 → NPE → 500
**권위 격상 전 검증 필요 항목:**
- `PostController.update()` line 206에서 `existing`이 null일 때의 실제 분기 코드 (null 체크가 있는지 여부) — worker 탐색에서 null 체크 코드가 보이지 않아 NPE 유력하나, 해당 라인 전후 코드 직접 확인 권장
---
## 영향 파일 목록
| 파일 | 역할 | 이슈 |
|------|------|------|
| `src/main/java/.../controller/PostController.java` | 메인 Controller | DRAFT 조회 불가, update NPE 후보 |
| `src/main/java/.../controller/PostAdminController.java` | 관리자 Controller | 권한 체크 부재 |
| `src/main/java/.../mapper/PostsMapper.java` | MyBatis Mapper | update SQL category_id 누락 |
| `src/main/java/.../mapper/PostCategoriesMapper.java` | 카테고리 Mapper | 정상 |
| `src/main/java/.../service/PostMarkdownService.java` | 마크다운 렌더링 | 정상 |
| `src/main/java/.../service/OgPreviewService.java` | OG 미리보기 | 정상 |
| `src/main/webapp/WEB-INF/views/posts-detail.jsp` | 상세 JSP | NPE 위험 (null 체크 없는 post 접근) |
| `src/main/webapp/WEB-INF/views/posts-list.jsp` | 목록 JSP | 정상 |
| `src/main/webapp/WEB-INF/views/posts-form.jsp` | 작성/수정 JSP | 정상 |
| `src/main/webapp/WEB-INF/views/admin-post-categories.jsp` | 관리자 JSP | context path 하드코딩 주의 |
---
## 미해결 (open_questions)
1. **`PostController.update()` line 206 전후 null 체크 존재 여부**: `existing`이 null일 때 NPE로 이어지는지, 아니면 별도 분기가 있는지 해당 라인 직접 확인 필요. (현재 탐색에서는 null 체크 코드가 보이지 않아 NPE 유력 추정)
2. **정확한 "포스팅 메뉴 진입" URL**: `/posts` (목록), `/posts/{id}` (상세), `/posts/new` (작성폼), `/admin/post-categories` 중 어느 URL에서 500이 발생하는지 재현 경로 미확인
3. **DB 스키마 상태**: `post_categories` 테이블에 활성(is_active=true) 카테고리가 존재하는지 — 카테고리 없는 상태에서 create/edit 폼의 카테고리 드롭다운이 빈 상태로 표시되고 categoryId=null 제출 → validate() line 305 `categoryId == null` 조건으로 BadRequest 반환 (500 아님)
4. **RbacInterceptor `/admin/post-categories` 보호 여부**: `addExcludePatterns`에서 `/admin/jams/**` 외 추가 제외 경로 상세 확인 필요
5. **애플리케이션 로그 스택트레이스**: 실제 500 발생 시의 예외 클래스와 라인 넘버 — 현재 로그 파일에서 포스팅 관련 스택트레이스 미발견