사이트픽 블로그
← 목록으로

개발일지 2026-07-09

피아노 학원 플랫폼 개발일지 (2026-07-09)

개요

이틀에 걸쳐 mforet.kr(피아노 학원 플랫폼)의 악보 도메인과 디자인 시스템을 크게 손봤다. 크게 네 덩어리다.

  1. 악보 장르 카테고리 — admin 관리 화면(대분류/소분류 트리 CRUD) → 공개 트리 API → sheets 프론트 네비게이션 실연동까지 풀 스택으로.
  2. 악보 요청 기능 — sheet_requests 테이블 + 등록 API + student 앱의 요청 페이지.
  3. 로그인 — sheets 로그인 모달이 엉뚱한 위치에 뜨던 버그 수정 + 실제 유저 테이블 조회로 로그인 실연동 + 데모 문구 제거.
  4. "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 (신규) — 트리 응답 DTO
  • sheet_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=true
  • sheets/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 build Node 에러: 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

  • 첫 번째 댓글을 남겨보세요.