Tailwind가 프로젝트에서 빠진 뒤에도 클래스만 남아 아무 스타일도 받지 못하던 자리들이
있었고, 전역 스피너는 만들어만 두고 어디서도 호출하지 않았다. 두 손질이 같은 파일들을
건드려 한 커밋으로 둔다.
Tailwind 제거:
- components/ui의 옛 Tailwind 레이어 16개 삭제. 그중 field·icon-button만 실제로 쓰여
각각 FoxTextArea·FoxFormLabel과 @fox 아이콘 버튼 마크업으로 옮기고 지웠다.
- error.tsx 다섯 곳이 같은 모양이라 PageError로 모은다(PageLoading과 같은 이유).
- 홈·Empty 레이아웃·게시판 첨부 링크는 module.scss로, 행 관리 버튼 묶음은 FoxButtonGroup로.
- 테마 토글은 FoxIconButton을 쓰지 못한다 — label이 필수라 aria-label이 늘 붙고, 그러면
테마별로 바뀌는 안쪽 글자를 스크린리더가 읽지 못한다. @fox 아이콘 버튼의 마크업 계약을
그대로 따라 같은 스타일을 받게 했다.
스피너 호출:
- useBlockingAction — 스피너를 켠 채 도는 transition. 삭제처럼 확인 얼럿이 닫힌 뒤
진행돼 표시가 행 아이콘 하나뿐이던 자리 11곳에 붙였다. 버튼이 스스로 "저장 중"을
보여주는 자리(중복확인·주소검색·엑셀 업로드)는 그대로 뒀다.
- 스피너 막도 토스트와 같은 이유로 popover(top layer)에 얹는다 — 모달 안에서 시작한
동작도 덮어야 한다.
- loading.tsx를 (basic) 한 곳으로 모은다. 다섯 화면에만 있던 것을 모든 하위 화면이
받도록 하고, 새 화면을 추가해도 자동으로 따라오게 한다.
Co-Authored-By: Claude Opus 5
백엔드가 중복 로그인 감지를 넣으면서, 내 토큰이 밀리면 이후 모든 요청이
403 + code 905(만료된 토큰입니다)로 떨어진다(JwtAuthenticationFilter).
어느 화면·어느 API에서 걸리든 같은 결말이어야 해서 backendFetch 한 곳에서
처리한다.
- 905/904를 받으면 /logout/revoked로 보낸다. 렌더 중에는 쿠키를 지울 수 없어
라우트 핸들러에서 세션을 파기하고 /login으로 안내와 함께 되돌린다.
- redirect()는 예외로 던져지므로 Server Action의 catch가 삼키면 안 된다.
저장소를 호출하는 catch에 unstable_rethrow를 넣어 통과시킨다.
- 그 경로는 쿠키가 이미 없어도 지나가야 안내가 유지돼 proxy 공개 경로에 넣는다.
Co-Authored-By: Claude Opus 5
영상 id는 조회에서 채워 hidden input에 실려 있었지만 FoxFileUpload에
defaultFiles를 넘기지 않아 칸이 늘 비어 보였다. 어떤 영상이 붙어 있는지
알 수 없어 매번 다시 올려야 하는 것처럼 보였다.
조회 응답의 fileList[].orgnlFileNm을 함께 실어 와 그 이름을 그린다.
이름이 없으면 "등록된 영상"으로 대신한다.
Co-Authored-By: Claude Opus 5
저장할 때 키워드를 쉼표로 이어 quizTitle 한 칸에 넣으므로, 수정 화면을 열면 키워드가
전부 비어 있었다. 제목을 쉼표로 갈라 줄을 채운다.
설명은 저장 경로에 자리가 없어 되살릴 수 없다(사용자 확정) — 키워드만 채워지고 설명은
빈 칸으로 열린다. 백엔드가 키워드 목록을 받게 되면 이 변환은 필요 없어진다.
Co-Authored-By: Claude Opus 5
기획에 다시 들어갔다. a96f729를 그대로 되돌린다 — 폼 칸, 값 타입, 검증, 액션의 폼
읽기, 목록의 키워드 열, 검색 대상, 그리고 quizTitle을 키워드로 만드는 저장까지
원래대로다. 보존용으로 넣었던 hidden title은 함께 빠진다.
This reverts commit a96f729.
Co-Authored-By: Claude Opus 5
기획에서 빠진 항목이다. 폼 칸, 값 타입, 검증, 액션의 폼 읽기, 목록의 키워드 열,
검색 대상까지 함께 걷어낸다. 쇼츠·만화의 키워드는 그대로다.
QUIZ_TITLE은 지우지 않는다 — 키워드를 담던 칸이지만 수정 SQL이
`QUIZ_TITLE = #{quizTitle}`로 무조건 덮어써서 안 보내면 값이 지워진다. 조회해 온
값을 hidden으로 실어 그대로 돌려보낸다(quizPoint와 같은 처리).
Co-Authored-By: Claude Opus 5
백엔드 UPDATE가 `QUIZ_POINT = #{quizPoint}`로 무조건 덮어쓰는데 화면이 그 값을 보내지 않아,
퀴즈를 한 번 수정하면 포인트가 NULL이 됐다. 기획에 입력 칸이 없는 값이라 화면에는 두지 않고
hidden으로 실어 그대로 돌려보낸다.
값이 없을 때는 키 자체를 빼서 보낸다 — 빈 문자열을 보내면 INT 컬럼에 ''가 들어간다.
확인(임시 프로브): 수정은 `quizPoint:"10"`이 실리고, 등록은 그 키가 빠진다.
Co-Authored-By: Claude Opus 5
"등록은 잘 된다"는 것이 문제였다. 영상 업로드가 413으로 실패해도 화면은 오류만 띄우고 저장
버튼은 그대로 눌렸다. 영상은 필수 항목이 아니라 검증도 통과하고, `videoFileId`가 빈 값인 채로
저장돼 **영상 없는 콘텐츠가 조용히 만들어졌다.** 사용자에게는 정상 등록으로 보인다.
고른 영상이 끝내 올라가지 않으면 그 사실을 `videoUploadFailed`로 실어 보내고 Server Action이
저장을 막는다 — 업로드 중(`videoUploading`)을 막던 것과 같은 방식이다. 파일을 지우면 플래그와
오류가 함께 풀려 영상 없이 저장하려는 의도는 그대로 통한다.
413 메시지 자체는 정확하다(재실측: 2MB·6MB 모두 nginx 413). 인프라 상향 전까지는 이 경로로
영상을 올릴 수 없고, 그 사실이 저장 시점에 드러나는 편이 낫다.
확인: 100MB 초과 선택 → 오류 + `videoUploadFailed=1` + `videoFileId` 빈 값,
삭제 → 플래그·오류 모두 해제.
Co-Authored-By: Claude Opus 5
"저장할 수 없는 크기"의 출처는 dev API 앞단 nginx다. `client_max_body_size`가 기본값 1MiB라
그보다 큰 본문을 봉투 없이 413으로 끊는다(실측: 1,000KB→401 통과 / 1,050KB→413 nginx HTML).
영상은 사실상 항상 이 선을 넘어 브라우저 경로로는 올릴 수 없었다.
인프라를 백엔드 `maxUploadSize`와 같은 100m로 올리기로 해(사용자 확정) 그 값을 기준으로
`MAX_STREAM_UPLOAD_BYTES`를 둔다.
화면 쪽 문제도 함께 고친다 — 지금까지 영상 칸은 확장자만 안내하고 크기는 말하지 않았다.
사용자는 한도를 모른 채 고르고, 업로드가 끝나서야 사유 없는 실패를 봤다. 이제 「mp4 · 100MB
이하」를 입력칸 밑에 적고, 넘는 파일은 고르는 순간 "영상은 100MB 이하만 올릴 수 있습니다."로
막는다. 확장자 검사가 먼저라 mov를 고르면 확장자 사유가 뜬다.
한도와 안내 문구는 `formatMaxUploadSize`로 한 값에서 만든다 — 둘이 어긋나면 안내가 거짓말이 된다.
⚠️ 인프라 반영 전에는 이 상한을 통과한 파일도 413으로 끊긴다. 반영 여부는 업로드 엔드포인트에
1MiB 넘는 본문을 보내 401(통과)인지 413(차단)인지로 확인한다.
확인: 안내 문구 "mp4 · 100MB 이하", 150MB → "영상은 100MB 이하만 올릴 수 있습니다.",
mov → "mp4 파일만 올릴 수 있습니다.", 두 경우 모두 업로드가 시작되지 않고 videoFileId가 빈 값.
Co-Authored-By: Claude Opus 5
퀴즈가 콘텐츠관리 API 아래로 들어왔다. 경로를 `/api/v1/mngr/quiz`에서
`/api/v1/mngr/cntntns/quiz`로 옮기고 쇼츠·만화와 같은 규약으로 맞춘다 — 등록은 form(중첩
목록은 인덱스 표기), 수정은 JSON, 삭제는 soft delete.
학교·학년이 생겼다. `TB_COM_QUIZ_SCHUL_GRD`에 따로 저장되고 `schulGrdList`로 오간다 —
쇼츠·만화와 같은 모양이라 매핑·전송 규칙을 그대로 쓴다(학년 없는 학교는 `grdCd`를 빼서
GRD_CD에 빈 문자열이 박히지 않게 한다). 수정 화면이 저장된 학교·학년을 불러온다.
엑셀 일괄등록은 기획 중이라 넣지 않았다 — 백엔드에도 업로드 엔드포인트가 없고
`excel/download`만 있다. 버튼과 Server Action을 걷어내고 TODO만 남긴다. 다운로드 경로는 새
API로 옮겼다.
확인(임시 프로브로 실제 요청 캡처): 등록 `POST /cntntns/quiz` form +
`schulGrdList[0].schulCd`, 수정 `PUT /cntntns/quiz/42` JSON, 삭제 `DELETE /cntntns/quiz/42`,
학년 없는 학교는 `grdCd` 파라미터 자체가 빠짐. 응답 매핑도 `schulGrdList` → 학교 코드 +
학년 배열, `useYn:'N'` → 비공개로 확인.
⚠️ 백엔드 결함 5건을 Repository 주석에 적었다. 특히 **등록 INSERT의 컬럼 11개 : 값 10개**
불일치(`USE_YN` 자리 누락)로 지금은 등록이 SQL 오류로 끝난다. 프론트는 `useYn`을 정상적으로
보내므로 값 목록만 맞추면 그대로 동작한다.
Co-Authored-By: Claude Opus 5
화면과 스트리밍 라우트의 100MB 사전 검사를 걷어낸다. 라우트는 본문을 버퍼에 담지
않고 그대로 흘려보내므로 자체 상한이 필요 없고, 실제 한도는 앞단 프록시와 백엔드가
정한다 — 넘으면 413이 사유와 함께 화면까지 올라간다(직전 커밋).
쓰이지 않게 된 MAX_STREAM_UPLOAD_BYTES를 지운다. Server Action 경로의
MAX_ATTACHMENT_BYTES(10MB)는 미들웨어가 본문을 자르는 실제 제약이라 그대로 둔다.
Co-Authored-By: Claude Opus 5
시안 btn-page(5402:12368) — 80px 여백, 그룹 하나는 가운데, 버튼은 lg(높이 56)에
최소폭 160. 수제 .actions(가운데 flex + 32px 여백, md 버튼)를 걷어내고 패널이
전부 맡는다. 쇼츠·만화·퀴즈 세 폼이 같은 컴포넌트를 쓴다.
Co-Authored-By: Claude Opus 5
컷당 구성이 시안과 달랐다. 시안은 테두리 컨테이너 안 회색 판(#E6E8EA) 위에 흰 컷 카드
(radius 12·neutral/lv1 그림자)가 4열로 깔리고, 카드마다 **점선 상자 자체가 파일선택 버튼**
(CameraPlus 중앙, 높이 172)이며 아래에 「N컷 · 초기화 · 삭제」가 붙는다. 「컷 추가」는 컨테이너
흰 푸터에 있다. 기존 구현은 테두리 박스 안에 작은 132×74 썸네일 + 파일선택 버튼이었고
초기화가 없었다.
FoxFileUpload에 `thumbnailPicker` 변형을 추가했다 — 썸네일 상자만 그리고 상자를 파일선택
버튼으로 쓴다(파일선택 버튼·설명·삭제 오버레이 없음). 시안의 이 패턴을 담을 형태가 image
모드에 없었다. 비우기·삭제는 호출부가 바깥에 만든다는 계약을 주석으로 못박았다.
초기화는 그 컷의 React key를 갈아 끼워 새로 마운트한다 — 비제어 FoxFileUpload가 들고 있는
파일과 네이티브 입력을 밖에서 비울 방법이 그것뿐이다. 초기화하면 기존 첨부 유지용
`cutFileId`도 함께 비워져 저장 시 그 컷이 빠진다.
확인(임시 페이지): 상자 클릭 대상 aria-label 「1컷 이미지 선택」, 파일 주입 → 미리보기 표시,
초기화 → 미리보기·네이티브 입력 모두 비워짐, 컷 추가 → 5컷에서 삭제 활성, 삭제 → 4컷에서
비활성. 컨테이너 테두리 #B1B8BE·radius 12·판 #E6E8EA·카드 그림자·점선 #CDD1D5·높이 172가
계산값으로 시안과 일치.
Co-Authored-By: Claude Fable 5
dev API 앞 프록시가 client_max_body_size 미설정(기본 1MiB)이라 큰 업로드를 봉투
없이 413으로 거절한다(실측: 1,048,000B 통과·1,100,000B 거절). 앱 상한은 100MB라
설정 누락이다 — 인프라 요청 전까지 사유("크기를 넘었습니다")를 화면까지 살린다.
영상 확장자는 백엔드 전역 화이트리스트에 있는 mp4만 남긴다. mov 등을 골라 올리면
크기와 무관하게 서버에서 거절되는데 화면이 미리 막지 못하고 있었다.
Co-Authored-By: Claude Opus 5
백엔드가 toon/video 컨트롤러를 /cntntns 하나로 합치고 CRUD를 열었다. 목록은
searchCntntsType(V·T)으로 갈리고, 단건·등록·수정·삭제가 모두 실제 API를 탄다.
등록은 @RequestBody가 없어 form이라 중첩 목록을 keywordList[0].cntntsKeyword 같은
인덱스 표기로 편다. JSON으로 보내면 한 칸도 바인딩되지 않는다.
학교·학년은 새로 생긴 schulGrdList로 오간다 — 학년마다 한 줄이다.
컷은 upload/list/{moduleId}로 한 첨부에 묶어 올린다. 백엔드가 fileSn을 1,2,3으로
채번해 주므로 컷을 가리키는 건 아이디가 아니라 순번이 됐다. 응답의 fileList로
실제 순번을 받아 미리보기 주소를 만든다.
Co-Authored-By: Claude Opus 5
Fox*Icon 컴포넌트 141곳을 fox-ico 클래스로 바꾸고 옛 SVG 모듈을 지웠다.
아이콘 상자 규칙에는 상자 크기를 그대로 둔 채 font-size만 더했다 — 손으로 그린
인라인 svg가 든 자리도 같이 쓰이기 때문이다.
사이드바 메뉴 아이콘의 화이트리스트가 사라졌다. 폰트는 통째로 하나라 이름이 늘어도
번들이 커지지 않아, 백엔드가 주는 kebab 이름을 PascalCase로 바꿔 붙이기만 하면 된다.
@fox 13.3MB → 6.4MB.
Co-Authored-By: Claude Opus 5
`failure()`가 잡힌 예외의 `message`를 그대로 화면에 실었다. 그래서 코드 오류(TypeError 등)가
나면 "Cannot read properties of undefined (reading 'stack')" 같은 개발자용 문구가 폼 아래에
그대로 뜨고, 정작 스택은 아무 데도 남지 않아 원인을 좁힐 수 없었다.
이제 `runWrite`가 잡은 예외를 서버 콘솔에 그대로 찍고(`[contents] 저장 실패`), 화면에는 사람이
고칠 수 있는 사유만 낸다 — 백엔드가 돌려준 메시지(`BackendRequestError`)와 용량·확장자 같은
입력 오류(`ContentInputError`)뿐이고 나머지는 일반 문구로 바꾼다.
확인차 재현을 시도한 것: Server Action 안에서 `console.error(msg, undefined)`는 던지지 않고,
`POST /api/v1/mngr/quiz`(없는 엔드포인트)와 코드표 조회 실패는 각각 깨끗한 `BackendRequestError`로
돌아온다. 즉 저 문구를 만든 예외는 이 경로들이 아니며, 로그가 붙었으니 다음 실패에서 드러난다.
확장자 안내도 저장소 목록이 아니라 전역 화이트리스트와의 교집합을 말하도록 맞췄다 — mov를
올릴 수 없는데 "mov 파일만 올릴 수 있습니다"라고 안내하고 있었다.
Co-Authored-By: Claude Opus 5
학교·학년 코드표 조회(`fetchSchoolGradeCodes`)가 검증 인자로 들어가면서 try/catch **밖**에
놓였다. 저장 호출만 감싸고 있었으니 코드 조회가 실패하면 예외가 Server Action을 그대로 빠져나가
`Cannot read properties of undefined (reading 'stack')`로 끝난다.
실패는 실제로 일어난다 — 백엔드는 인가 실패도 200이 아닌 401에 봉투를 실어 돌려주고
(확인: `GET /api/v1/mngr/code/list/COM_SCHUL_CD` → `{"code":401,"message":"인가된 사용자가
아닙니다."}`), 세션 쿠키는 살아 있어도 액세스 토큰이 먼저 만료될 수 있다. 화면을 열 때는
멀쩡하다가 저장할 때 터지는 이유가 이것이다.
여섯 개 쓰기 액션의 본문을 `runWrite`로 감싸 코드표 조회·업로드·저장이 모두 한 울타리 안에서
일어나게 한다(게시판 액션과 같은 방식). `redirect()`는 예외로 흐름을 끊으므로 울타리 밖에 둔다.
이제 같은 실패가 폼 아래 메시지로 보인다.
함께 고친 것:
- 코드가 한 건도 없는 그룹이 `data: null`로 오면 예외 대신 빈 목록으로 다룬다. 학년(`GRD_CD`)은
학교에 따라 결과가 빌 수 있어 이것을 오류로 보면 화면 전체가 멎는다.
- `/contents`에 error·loading 경계를 둔다(다른 화면과 같은 구성). 없으면 조회 실패가 세그먼트
경계 없이 위로 튀어 같은 종류의 불투명한 오류로 보인다.
Co-Authored-By: Claude Opus 5
학교급을 프론트 상수 표(ELEM/MIDD/HIGH/ALL + 학년 수)로 들고 있었다. 그 표를 지우고 공통코드를
조회해 쓴다 — 1:1문의 진행상태(BBS_ANS_CD), 꾸미기 아이템 카테고리(ITEM_CATE_CD)와 같은 방식이다.
학년은 학교마다 다르므로 `GET /api/v1/mngr/code/list/GRD_CD?atrbNm={학교코드}`로 학교별로
조회한다(백엔드 매퍼가 `ATRB_NM1 = #{atrbNm}`로 거른다). 상세 목록 SQL이 `ATRB_NM1`을 select하지
않아 한 번에 받아 화면에서 가를 수 없어 학교 수만큼 왕복하고, `fetchSchoolGradeCodes()`가 그
왕복을 한 곳에 모아 `cache()`로 중복을 막는다.
저장에는 `GRD_CD` 코드값을 보낸다 — 화면의 학년 칩이 코드값을 그대로 hidden 필드에 싣고,
Server Action은 저장 직전에 코드표를 다시 조회해 그 학교에 없는 학년 코드를 거른다(화면이 보낸
값을 믿지 않는 기존 규칙과 같다).
"전체연령은 학년 선택 불가"가 하드코딩에서 데이터로 바뀌었다 — 학년 코드가 하나도 없는 학교는
칩을 그리지 않고 검증도 학년을 요구하지 않는다.
확인: 학교 전환 시 학년 칩이 그 학교 것으로 갈리고 이전 선택이 비워지는 것, 다중 선택이
`GRD001,GRD003`처럼 코드값으로 실리는 것, 학년 코드가 없는 학교는 칩이 0개인 것을 임시 페이지로
확인한 뒤 지웠다.
Co-Authored-By: Claude Opus 5