개발일지 2026-06-16
피아노 학원 플랫폼 개발일지 (2026-06-16)
개요
플랫폼은 관리자 / 학원 / 학생 3개 포털로 구성되어 있는데, 정작 DB 기반 메뉴·권한 관리는 관리자 포털에만 존재했다. 학원·학생 앱의 사이드바는 프론트에 하드코딩되어 있어 역할 기반 노출 제어가 불가능했다.
이번 작업의 목표는 menus 테이블에 site 컬럼(ADMIN/ACADEMY/STUDENT) 을 추가해 세 포털의 메뉴를 모두 DB에서 등록·관리하고, 각 포털이 로그인한 사용자의 역할(role)에 맞는 메뉴만 동적으로 받아 렌더링하도록 전환하는 것이다.
핵심 설계 원칙은 "메뉴-권한만 동적화, URL 접근제어(SecurityConfig·Role enum)는 그대로 유지" 였다. Role enum은 JWT·SecurityConfig와 강하게 결합돼 있어서, 역할 자체를 완전 동적화하면 로그인 장애 위험이 컸기 때문이다. 그래서 "어떤 메뉴를 보여줄지"만 DB로 빼고, "어떤 URL에 접근 가능한지"는 기존 5개 역할 그룹을 유지했다.
수정/생성 파일 목록
백엔드 (piano-backend)
.../admin/menu/Menu.java(수정) —site필드 추가.../admin/menu/MenuMapper.java(수정) —findBySiteAndRoleCode메서드 추가.../resources/mapper/MenuMapper.xml(수정) — resultMap·insert·update에 site 반영 + 신규 select.../admin/menu/MenuService.java(수정) —listBySiteAndRole+ create/update 빌더에 site.../admin/menu/MenuRequest.java(수정) — site 필드 추가, path의@NotBlank제거.../menu/MyMenuController.java(신규) — 공통GET /api/v1/menus/my.../config/SecurityConfig.java(수정) —/api/v1/menus/**authenticated 매처.../resources/migration-<DATE>-menu-site.sql(신규) — 멱등 ALTER + INSERT.../resources/schema.sql(수정) — menus.site 컬럼 + 인덱스.../resources/data.sql(수정) — 기존 ADMIN 명시 + 학원/학생 시드
프론트 (admin)
admin/src/api/menus.ts(수정) —MenuSite타입 +MenuItem/MenuForm에 siteadmin/src/pages/Menus.tsx(수정) — 포털 필터·컬럼·폼 site 선택
프론트 (academy)
academy/src/api/menus.ts(신규) —getMyMenus()→/menus/myacademy/src/contexts/MenuContext.tsx(신규) — admin 패턴 복제(buildTree)academy/src/components/Sidebar.tsx(수정) — 하드코딩 제거 → DB 메뉴 + iconMapacademy/src/App.tsx(수정) —<MenuProvider>적용
프론트 (student)
student/src/api/menus.ts(신규) —getMyMenus()→/menus/mystudent/src/contexts/MenuContext.tsx(신규)student/src/components/Layout.tsx(수정) — 바텀네비/사이드바 동적화student/src/App.tsx(수정) —<MenuProvider>적용
파일별 상세
1. Menu 엔티티 — site 필드 추가
기존 menus 테이블 매핑 엔티티에 포털 구분 컬럼을 추가했다.
public class Menu {
private Long id;
private String site; // 포털 구분: ADMIN / ACADEMY / STUDENT
private String name;
private String path;
// ...
private List<String> roleCodes;
}
2. MenuMapper.xml — resultMap / insert / update / 신규 select
site 컬럼을 resultMap에 추가하고, 핵심으로 site + role 동시 필터 쿼리를 새로 만들었다. 기존 findByRoleCode에 AND m.site = #{site} 조건만 추가한 형태다.
<select id="findBySiteAndRoleCode" resultMap="menuMap">
SELECT DISTINCT m.*
FROM menus m
JOIN menu_roles mr ON m.id = mr.menu_id
JOIN roles r ON mr.role_id = r.id
WHERE r.code = #{roleCode}
AND m.site = #{site}
AND m.visible = TRUE
ORDER BY m.sort_order ASC, m.id ASC
</select>
insert/update에는 COALESCE(#{site}, 'ADMIN') / COALESCE(#{site}, site) 를 적용해, site가 안 들어와도 안전하게 동작하도록 했다.
3. MenuService — listBySiteAndRole
서비스에 site/role 기반 조회 메서드를 추가하고, create/update 빌더에도 site를 반영했다.
@Transactional(readOnly = true)
public List<Menu> listBySiteAndRole(String site, String roleCode) {
return menuMapper.findBySiteAndRoleCode(site, roleCode);
}
4. MyMenuController — 공통 엔드포인트 (핵심)
세 포털이 공유하는 단일 엔드포인트다. 로그인한 사용자의 role을 보고 site를 자동 판별하는 것이 포인트. 클라이언트는 site를 몰라도 되고, 그냥 /menus/my만 호출하면 된다.
@RestController
@RequestMapping("/api/v1/menus")
@RequiredArgsConstructor
public class MyMenuController {
private final MenuService menuService;
@GetMapping("/my")
public ApiResponse<List<Menu>> myMenus(@AuthenticationPrincipal UserPrincipal principal) {
String site = resolveSite(principal.getRole());
return ApiResponse.ok(menuService.listBySiteAndRole(site, principal.getRole().name()));
}
private String resolveSite(Role role) {
return switch (role) {
case SUPER_ADMIN, OPS_CS -> "ADMIN";
case OWNER, TEACHER -> "ACADEMY";
case STUDENT -> "STUDENT";
};
}
}
switch 식을 enum에 대해 망라(exhaustive)로 쓰면 나중에 역할이 추가될 때 컴파일러가 누락을 잡아준다.
5. SecurityConfig — 매처 추가 (기존 미변경)
// 학생
.requestMatchers("/api/v1/student/**").hasRole(Role.STUDENT.name())
// 공통 메뉴 조회 (모든 포털: 로그인만 되면 허용, site/role 필터는 서버 처리)
.requestMatchers("/api/v1/menus/**").authenticated()
.anyRequest().authenticated()
사실 anyRequest().authenticated()로도 커버되지만, 의도를 명확히 하려고 명시적 매처를 넣었다. 기존 admin/academy/student 매처와 Role enum은 일절 건드리지 않았다.
6. 마이그레이션 SQL — 멱등(idempotent) ALTER + INSERT
운영 RDS는 기존 관리자 메뉴 데이터를 보존해야 하므로 DROP/CREATE가 아니라 ALTER + INSERT 방식으로 짰다. 반복 실행해도 안전하도록 컬럼 존재 체크와 INSERT IGNORE를 사용했다.
-- 1) site 컬럼 추가 (이미 있으면 skip)
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'menus' AND COLUMN_NAME = 'site'
);
SET @ddl := IF(@col_exists = 0,
"ALTER TABLE menus ADD COLUMN site VARCHAR(20) NOT NULL DEFAULT 'ADMIN' AFTER id",
"SELECT 'site column already exists' AS msg");
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2~3) 학원(200~)/학생(300~) 메뉴 시드 + 4) menu_roles 매핑 (INSERT IGNORE)
DEFAULT 'ADMIN' 덕분에 컬럼만 추가된 상태에서 구버전 백엔드가 떠도 호환되어, 배포 순서 꼬임에 대한 안전망이 된다.
7. 프론트 — admin 메뉴관리에 포털(site) 추가
MenuSite 타입을 도입하고, 목록을 site별로 필터링하도록 했다. 트리 구성과 상위 메뉴 후보 모두 같은 site 안에서만 계산되게 해서, 포털 간 부모-자식이 섞이지 않게 했다.
const siteRows = useMemo(
() => rows.filter(r => (r.site ?? 'ADMIN') === siteFilter),
[rows, siteFilter]
);
const treeRows = useMemo(() => flattenTree(siteRows), [siteRows]);
상단에 포털 셀렉트 필터, 테이블에 포털 칩 컬럼, 추가/수정 폼에 포털 선택 필드를 넣었다.
8. 프론트 — academy / student 동적화
admin의 MenuContext(buildTree 포함) 패턴을 두 앱에 복제했다. 3개 앱이 빌드 독립적이라 공유 패키지 셋업 리스크를 피하고 각 앱 내부에 동일 패턴을 복제하는 쪽을 택했다.
DB의 icon 문자열을 MUI 아이콘으로 바꾸는 iconMap을 각 앱이 쓰는 아이콘만 추려 넣었다.
const iconMap: Record<string, React.ReactNode> = {
Dashboard: <DashboardIcon />, People: <PeopleIcon />,
MeetingRoom: <MeetingRoomIcon />, School: <SchoolIcon />,
// ...
};
const renderIcon = (icon: string | null) => (icon && iconMap[icon]) || <ListAltIcon />;
학생 앱은 모바일 바텀네비 공간 제약 때문에 상위 4개 + '더보기' 규칙을 적용하고, PC 사이드바는 전체 트리를 렌더하도록 분기했다.
const sideNavItems = menuTree;
const moreItem = menuTree.find(m => m.path === '/more');
const bottomNavItems = moreItem
? [...menuTree.filter(m => m.path !== '/more').slice(0, 4), moreItem]
: menuTree.slice(0, 5);
MenuProvider는 AuthProvider 안쪽에 배치했다. useAuth()의 user가 채워진 뒤 메뉴를 로드해야 하기 때문이다.
트러블슈팅 메모 (삽질 기록)
path의 @NotBlank 함정
기존 MenuRequest는 path에 @NotBlank가 걸려 있었다. 그런데 대메뉴(그룹 메뉴)는 path=''(빈 문자열)로 들어간다. site 추가하면서 관리 화면에서 그룹 메뉴를 만들 때 검증에 걸릴 수 있어 @NotBlank를 제거했다. 스키마는 path VARCHAR(100) NOT NULL이라 빈 문자열은 허용된다.
EC2 env 파일이 root 소유
/etc/<앱>/<앱>.env가 root 소유라 source가 "Permission denied"로 실패했다. sudo bash -c '...' 안에서 env를 source 하도록 바꿔 해결했다. (DB 비밀번호는 출력에 노출되지 않게 처리)
RDS mysqldump 플래그 비호환
백업 시 --set-gtid-purged=OFF가 unknown variable 에러를 냈다. RDS 쪽 클라이언트가 MariaDB 계열이라 해당 플래그가 없었다. --no-tablespaces --skip-lock-tables로 바꿔 백업 성공.
actuator health 500
배포 후 /actuator/health가 500을 반환했는데, 로그인·메뉴 API는 모두 200으로 정상이었다. 이번 변경과 무관한 기존 현상으로 판단(헬스 인디케이터 설정 이슈)하고 별도 표시만 남겼다.
검증 & 배포
배포 절차 (검증된 기존 패턴)
- DB 백업 →
mysqldump menus menu_roles(sudo + env source) - 마이그레이션 적용 →
mysql < migration-<DATE>-menu-site.sql - 백엔드 →
./gradlew clean bootJar -x test→ scp → JAR 백업 후 교체 →systemctl restart <api-service> - 프론트 3개 → 각 앱
CI=false npm run build→ tar → scp → 디렉터리 백업 후 교체 →chown <UID>:<GROUP>
DB 마이그레이션 결과
| site | 메뉴 수 |
|---|---|
| ADMIN | 33 (기존 보존) |
| ACADEMY | 8 |
| STUDENT | 7 |
menu_roles: 학원 16개(8메뉴 × 2역할), 학생 7개.
API E2E (운영, role별 메뉴 수)
| 역할 | 포털 | 메뉴 수 |
|---|---|---|
| SUPER_ADMIN | ADMIN | 33 (전체) |
| OPS_CS | ADMIN | 28 (시스템 제외 — 회귀 OK) |
| OWNER | ACADEMY | 8 |
| TEACHER | ACADEMY | 8 |
| STUDENT | STUDENT | 7 |
GET /api/v1/menus/my 호출 시 OWNER는 ACADEMY 메뉴, STUDENT는 STUDENT 메뉴, ADMIN은 ADMIN 메뉴를 정확히 반환했다.
프론트 라이브 번들 확인
- admin:
main.40a5cf70.js - academy:
main.d08aad59.js - student:
main.5bda6414.js
academy/student 번들 모두 menus/my 문자열을 포함 → 동적 메뉴 코드가 정상 반영됨을 확인.
롤백 자산
- JAR:
<api>.jar.bak-<TS> - 프론트:
*.bak-<TS> - DB:
/tmp/<table>-backup-<TS>.sql
결론 / 배운 점
- "동적화의 경계를 어디에 그을 것인가" 가 이번 작업의 핵심 의사결정이었다. 메뉴 노출만 DB로 빼고 URL 접근제어(보안 핵심)는 정적으로 유지함으로써, 기능 확장과 로그인 안정성을 동시에 챙겼다.
- 공통 엔드포인트 + 서버 측 site 판별 패턴은 클라이언트를 단순하게 만든다. 3개 앱이 같은
/menus/my만 호출하고 site는 토큰의 role에서 서버가 결정한다. - 운영 DB 변경은 멱등 마이그레이션 + 사전 백업 + DEFAULT 컬럼의 3중 안전망으로 무중단·무손실 적용이 가능했다.
- 기존
popups테이블이 이미site컬럼으로 BRAND/ACADEMY/STUDENT를 구분하고 있었는데, 동일 패턴을 메뉴에도 적용해 코드베이스의 일관성을 유지했다.
댓글 0
- 첫 번째 댓글을 남겨보세요.