개발일지 2026-07-09
피아노 학원 플랫폼 개발일지 (2026-07-09)
개요
이틀에 걸쳐 mforet.kr(피아노 학원 플랫폼)의 악보 도메인과 디자인 시스템을 크게 손봤다. 크게 네 덩어리다.
- 악보 장르 카테고리 — admin 관리 화면(대분류/소분류 트리 CRUD) → 공개 트리 API → sheets 프론트 네비게이션 실연동까지 풀 스택으로.
- 악보 요청 기능 —
sheet_requests테이블 + 등록 API + student 앱의 요청 페이지. - 로그인 — sheets 로그인 모달이 엉뚱한 위치에 뜨던 버그 수정 + 실제 유저 테이블 조회로 로그인 실연동 + 데모 문구 제거.
- "Fresh Forest · Bronze Gold" 컬러 시스템 — brand(마케팅 사이트)와 sheets(악보 사이트)에 동일한 색 체계를 적용.
이 글은 하루치라기보다 이틀치 작업을 묶어 정리한 것이다.
수정/생성 파일 목록
백엔드 (piano-backend)
.../admin/sheetcategory/*(신규) — 악보 카테고리 트리 CRUD (엔티티/매퍼/서비스/컨트롤러).../publicsite/sheetcategory/PublicSheetCategoryController.java(신규) — 공개 트리 API.../publicsite/sheetcategory/PublicSheetCategoryService.java(신규) — active 필터 + 트리 조립.../publicsite/sheetcategory/dto/SheetCategoryTreeResponse.java(신규) — 트리 응답 DTOsheet_requests테이블 + 등록 API (신규) — 악보 요청 저장migration-*.sql(신규) — sheet_categories 시드, sheet_requests DDL, COMMENT 보강
프론트 (sheets — Next.js)
sheets/src/lib/api/sheetCategories.ts(신규) — 공개 카테고리 트리 fetch + 하드코딩 폴백sheets/src/lib/hooks/useCategoryTree.ts(신규) — 카테고리 트리 로딩 훅sheets/src/components/Gnb.tsx(수정) — 실데이터로 네비 렌더 + 모바일 드로어sheets/src/components/MegaMenu.tsx(수정) — tree prop 기반 렌더sheets/src/app/layout.tsx(수정) — LoginModal 을 루트로 이동sheets/src/components/Header.tsx(수정) — LoginModal 렌더 제거sheets/.env.production(신규) — AUTH_LIVE=truesheets/src/components/LoginModal.tsx(수정) — 데모 문구 제거sheets/src/app/globals.css(수정) — 브라운/베이지 → 포레스트/브론즈/세이지sheets/src/app/request/page.tsx,sheets/src/components/home/BestScores.tsx(수정) — 텍스트 색 세이지로
프론트 (brand — Vite/MUI)
brand/src/theme.ts(수정) — FOREST/GOLD 스케일 + navy/gold → forest/bronze 매핑brand/src/index.css(수정) — 배경/텍스트/스크롤바 색brand/src/pages/*,brand/src/components/*(수정, 14개) — 하드코딩 navy/gold 치환
프론트 (student)
- 악보 요청 페이지(
/sheet-request) + 상담 연락처 자동 하이픈 + 회원가입 약관 전체 동의 등
파일별 상세
1. 악보 카테고리 — admin CRUD → 공개 API → 네비 실연동
먼저 sheet_categories 테이블을 만들었다. self-reference 트리 구조(대분류 11 / 소분류 39)로, parent_id 로 부모를 가리키고 sort_order, active 컬럼을 둔다.
admin 쪽은 대분류/소분류를 다루는 마스터-디테일 관리 화면을 만들었고, 백엔드는 트리 CRUD 를 구현했다.
그 다음이 이번 작업의 핵심인데, 실제 사용자 사이트(sheets) 가 이 카테고리를 여전히 하드코딩(src/lib/data/categories.ts)으로 쓰고 있었다. 이걸 DB 기반으로 바꾸되, sheets 는 output:'export' 정적 사이트라 SSG/[category] 라우트/sitemap 은 건드리지 않고 네비게이션만 런타임 fetch 로 실데이터를 뿌리는 방향으로 갔다.
공개 API 는 인증 불필요(/api/v1/public/** 는 이미 permitAll)로 트리 구조를 반환한다.
@RestController
@RequestMapping("/api/v1/public/sheet-categories")
public class PublicSheetCategoryController {
@GetMapping
public ApiResponse<List<SheetCategoryTreeResponse>> tree() {
return ApiResponse.ok(service.tree());
}
}
서비스는 기존 admin SheetCategoryMapper.findAll(null, null) 을 재사용해 전체를 flat 하게 가져온 뒤, active == true 만 필터링하고 parentId 로 그룹핑해 children 을 중첩시킨다.
프론트는 신규 fetch 모듈과 훅을 만들었다. 핵심은 fetch 실패/빈 응답 시 기존 하드코딩으로 조립한 폴백을 반환한다는 점.
// sheets/src/lib/api/sheetCategories.ts
export async function getCategoryTree(): Promise<ApiCategoryNode[]> {
try {
const res = await apiGet<ApiCategoryNode[]>('/public/sheet-categories');
if (res?.length) return res;
} catch { /* 폴백으로 */ }
return fallbackTreeFromHardcoded();
}
훅은 초기값을 하드코딩 폴백 트리로 둬서 첫 페인트 때 즉시 레이아웃이 잡히게 하고, useEffect 로 API 로드가 성공하면 state 를 교체한다.
// sheets/src/lib/hooks/useCategoryTree.ts
export function useCategoryTree() {
const [tree, setTree] = useState<ApiCategoryNode[]>(fallbackTree);
useEffect(() => {
getCategoryTree().then(setTree).catch(() => {});
}, []);
return { tree };
}
링크 slug 는 기존 SSG 라우트와 반드시 일치해야 하므로, 대분류는 DB slug(영문) 우선·없으면 기존 매핑, 소분류는 기존과 동일한 subSlug(name) 을 그대로 쓴다. 덕분에 API 데이터로 렌더해도 [category]/[sub] 정적 경로와 매칭된다.
2. 악보 요청 (sheet_requests)
sheet_requests 테이블과 등록 API 를 추가하고, student 앱에 /sheet-request 페이지를 만들었다. 곡명·아티스트·편성·난이도·메모를 받아 저장하고, 내 요청현황 탭에서 상태(검토중/제작중/등록완료/제작불가)를 확인한다. 상담 연락처는 입력 시 자동으로 하이픈을 넣도록 포맷팅을 붙였다.
3. 로그인 — 모달 위치 버그 + 실연동
버그: 로그인 팝업이 화면 중앙이 아니라 엉뚱한 위치에 떴다.
원인은 CSS의 고전적인 함정이었다. LoginModal 이 <header class="site-header"> 안에서 렌더되고 있었는데, 이 헤더에 position: sticky + backdrop-filter: blur(14px) 가 걸려 있었다. backdrop-filter(그리고 transform/filter/perspective)는 하위 position:fixed 요소의 포함 블록(containing block)을 그 조상으로 만들어버린다. 그래서 모달의 inset:0 이 뷰포트가 아니라 77px짜리 헤더 박스를 기준으로 잡혀 카드가 위로 잘렸다.
해결은 간단하다. LoginModal 을 헤더 밖, layout.tsx 루트(body 직속 자식)로 이동시켰다.
// sheets/src/app/layout.tsx
<main>{children}</main>
<LoginModal /> {/* header 밖으로 이동 → inset:0 이 뷰포트 기준 */}
<footer>...</footer>
옮긴 뒤 parent=BODY, height=767, 카드 top:208(중앙) 로 정상 배치됐다.
실연동: 모달은 이미 apiLogin → getMe 를 호출하도록 배선되어 있었고, 백엔드 /api/v1/auth/login·/auth/me 도 실제 users 테이블을 조회하고 있었다. 정작 막고 있던 건 AUTH_LIVE 플래그가 false(목 모드)였던 것뿐. .env.production 에 NEXT_PUBLIC_AUTH_LIVE=true 를 넣어 실서버 로그인으로 전환했다(악보 데이터는 아직 목 유지). 잘못된 자격증명으로 curl 하면 401 LOGIN_FAILED 가 떨어지는 걸 확인했고, CORS 도 Access-Control-Allow-Origin: https://mforet.kr 로 정상.
마지막으로 "데모 모드입니다. 아무 이메일/비밀번호나…" 안내 문구 블록을 통째로 제거했다(이제 실로그인이라 불필요).
4. "Fresh Forest · Bronze Gold" 컬러 시스템
디자인 가이드 이미지를 받아 brand·sheets 에 동일한 색 체계를 적용했다.
- Primary – Fresh Forest: 950
#0E2B19/ 900#1B3D26(메인) / 800#2F5E36/ 700#4C7F4D/ 600#6CA96B - Accent – Bronze/Gold: 700
#7A6412/ 600#A38B2D(포인트) / 500#D4B358/ 300#EAD68C/ 100#F6EED6 - Neutral – Sage: 50
#F4F6EF(배경) / 100#E8EFE3/ 200#D9E2D0/ 600#5C6F53(본문) / 900#1C2B1F(제목) - 사용 비율: Sage 60% / Fresh Forest 25% / Bronze Gold 10% / Warm Gold 5%
brand 는 MUI라 중앙 theme.ts 에서 FOREST/GOLD 스케일을 export 하고, 기존 코드가 참조하던 BRAND.navy/BRAND.gold 를 각각 FOREST[900]/GOLD[600] 으로 매핑해 하위 호환을 유지했다. 덕분에 개별 컴포넌트를 깨지 않고 톤만 바뀐다.
const BRAND = {
navy: FOREST[900], // #1a1a2e → #1B3D26
gold: GOLD[600], // #c9a84c → #A38B2D
...
};
하드코딩된 navy/gold 를 쓰던 14개 페이지/컴포넌트는 sed 그룹 치환으로 한 번에 정리했다. sheets 는 MUI가 아니라 globals.css 기반이라 브라운/베이지 팔레트(#2d231c, #8a532d 등)를 포레스트/브론즈/세이지로 매핑했다. 단, 카테고리별 파스텔 강조색은 카테고리 구분 용도라 그대로 뒀다.
검증 & 배포
- 백엔드:
compileJava통과 → blue-green 배포. 스모크로curl https://mforet.kr/api/v1/public/sheet-categories→ 인증 없이 200, 트리 JSON(대분류 + children),active=false제외 확인. - sheets: Node 20 로
next build(정적 export) → tar → EC2 scp →/var/www/piano/sheets/전개 →chown nginx:nginx.https://mforet.kr/sheets/200. 배포된 CSS에#1b3d26×31,#a38b2d×26, 구 브라운 0개 확인. - brand: Node 20 로
vite build→ nginx 루트/var/www/piano/에 전개(하위 subdir 보존).https://mforet.kr/200. 번들에#1B3D26×34,#A38B2D×12, 구 navy/gold 0개.
트러블슈팅 메모 (삽질 기록)
backdrop-filter포함 블록 함정:position:fixed; inset:0인데도 뷰포트가 아니라 조상 박스 기준으로 잡히면, 조상에backdrop-filter/transform/filter/perspective가 있는지 의심하자. 이번엔 sticky 헤더의 blur 가 범인이었다.vite buildNode 에러:SyntaxError: node:fs/promises does not provide export 'constants'→ 구 Node 문제.nvm use 20으로 해결.- sed 배치 치환 셸 함정:
for f in $FILES; do sed ... "$f"; done루프가 셸 프록시 환경에서 cwd 리셋/인자 병합으로 조용히 실패했다. 모든 파일을 한 sed 명령의 다중 인자로 넘기는 패턴(sed -i '' -E -e '...' f1 f2 f3)이 안정적이었다. - nginx 루트 배포 주의: brand 는
/var/www/piano/루트에 index.html·assets 가 깔린다. 이 루트에는piano/sheets/academy/admin형제 디렉터리도 같이 있어서rm -rf금지. assets 는 해시 파일명이라 브랜드 assets 만 통째 교체해 stale 파일을 방지했다. - AUTH_LIVE vs USE_MOCK 분리: 로그인은 실연동(AUTH_LIVE=true)이지만 악보 데이터는 아직 목(USE_MOCK=true). 두 플래그를 분리해 둔 덕에 로그인만 먼저 실서버로 붙일 수 있었다.
결론 / 배운 점
- 정적 사이트에서 부분 실연동:
output:'export'로 SEO·정적 생성을 유지하면서, 네비게이션만 런타임 fetch + 하드코딩 폴백으로 실데이터를 붙이는 절충이 잘 먹혔다. 폴백을 초기 state 로 두면 첫 페인트도 안 깨진다. - 테마 하위 호환 매핑: 색을 갈아엎을 때 토큰 이름(navy/gold)은 유지하고 값만 새 색으로 매핑하면, 수십 개 참조를 건드리지 않고 전면 리브랜딩이 된다.
- CSS 포함 블록은 매번 잊을 만하면 다시 물린다.
position:fixed가 이상하게 잡히면 조상의backdrop-filter/transform부터 본다.
댓글 0
- 첫 번째 댓글을 남겨보세요.