개발일지 2026-06-22
피아노 학원 플랫폼 개발일지 (2026-06-22)
개요
React Provider 없이 어디서든 호출 가능한 싱글턴 전역 로딩바(fx) 를 4개 앱(admin/brand/academy/student)에 도입하고, 모든 조회(GET) 화면의 API 호출 앞뒤에 로딩 오버레이가 뜨도록 적용했다. 더불어 메뉴 관리 기본 노출 100건, 역할 관리 검색 필드 + 셀렉트 변경 시 백엔드 재조회, 로컬 DB SSH 터널링 작업까지 정리한다.
수정/생성 파일 목록
공통 인프라 (4개 앱 동일 구조)
admin|brand|academy|student/src/lib/fx.ts(신규/기존) — 로딩바 싱글턴 storeadmin|brand|academy|student/src/components/GlobalLoading.tsx(신규/기존) — Backdrop+Spinner 렌더 컴포넌트admin|brand|academy|student/src/App.tsx(수정) —<GlobalLoading />최상단 마운트
admin
src/components/UserManagement.tsx(수정) — 원장/강사/관리자/학생계정 공통 목록 조회에 로딩바src/pages/*.tsx12개 (수정) — AcademyReviews, CourseTemplates, PracticeRooms, Settlements, Payments, Notices, Academies, Faqs, Sheets, TrialRequests, CommonCodes, AcademyDetail 조회 함수에 로딩바src/pages/Roles.tsx(수정) — 검색 필드 + 셀렉트 변경 시 백엔드 재조회 + 로딩바src/pages/Menus.tsx(수정) — 기본 페이지 노출 100건src/components/DataTable.tsx(수정) —defaultRowsPerPage/rowsPerPageOptionsprop 옵션화
brand
src/pages/Notice.tsx, Reviews.tsx, Locations.tsx, Home.tsx, Trial.tsx(수정) — 조회 함수 로딩바
academy
src/pages/Students.tsx, Programs.tsx, AcademySettings.tsx, Popups.tsx(수정) — 조회 함수 로딩바
student
src/pages/MorePage.tsx(수정) — 조회 함수 로딩바 (나머지는 더미데이터라 제외)
문서
docs/로컬DB터널링_트러블슈팅.md(신규) — RDS SSH 터널링 트러블슈팅
파일별 상세
1. 로딩바 싱글턴 — lib/fx.ts
가장 핵심. React Context/Provider를 쓰지 않고 모듈 전역 상태 + 구독 패턴으로 만들어서, API 클라이언트든 페이지든 어디서나 fx.isLoadingbar(true/false) 한 줄로 제어할 수 있게 했다.
let count = 0;
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
export const loadingStore = {
subscribe(onChange: () => void) {
listeners.add(onChange);
return () => { listeners.delete(onChange); };
},
getSnapshot(): boolean { return count > 0; },
};
export const fx = {
isLoadingbar(on: boolean) {
count = on ? count + 1 : Math.max(0, count - 1); // 카운트 기반 → 중첩/동시요청 안전
emit();
},
resetLoading() { count = 0; emit(); },
async wrap<T>(task: Promise<T>): Promise<T> {
this.isLoadingbar(true);
try { return await task; } finally { this.isLoadingbar(false); }
},
};
포인트는 카운트 기반이라는 것. 단순 boolean이면 동시에 두 요청이 돌 때 먼저 끝난 쪽이 로딩바를 꺼버리는 문제가 생긴다. count로 관리하면 count > 0인 동안 계속 표시되므로 중첩 요청에 안전하다.
2. 렌더 컴포넌트 — GlobalLoading.tsx
store를 React 18 표준 useSyncExternalStore로 구독한다. 외부 store를 React에 연결하는 정석 API라 tearing(렌더 중 값 불일치)도 막아준다.
import React, { useSyncExternalStore } from 'react';
import { Backdrop, CircularProgress } from '@mui/material';
import { loadingStore } from '../lib/fx';
const GlobalLoading: React.FC = () => {
const open = useSyncExternalStore(loadingStore.subscribe, loadingStore.getSnapshot);
return (
<Backdrop open={open} sx={{ color: '#fff', zIndex: (t) => t.zIndex.modal + 10 }}>
<CircularProgress color="inherit" />
</Backdrop>
);
};
export default GlobalLoading;
zIndex를 modal + 10으로 줘서 다이얼로그/팝업보다 위에 뜨게 했다.
3. App.tsx 마운트
각 앱 App.tsx의 <CssBaseline /> 바로 아래에 <GlobalLoading />를 1회만 마운트하면 끝. 라우터·Provider 바깥에 둬서 어느 화면에서든 항상 살아있게 했다.
<ThemeProvider theme={theme}>
<CssBaseline />
<GlobalLoading /> {/* ← 추가 */}
<BrowserRouter ...>
4. 조회 함수 적용 패턴
목록/상세를 불러오는 GET 함수의 try 시작에 true, finally에 false를 넣는다.
const fetchData = async () => {
try {
fx.isLoadingbar(true);
const data = await api.list(...);
setRows(data);
} catch {
toast.error('데이터를 불러오는데 실패했습니다.');
} finally {
fx.isLoadingbar(false);
}
};
생성/수정/삭제(뮤테이션), 로그인 제출, 더미데이터만 쓰는 화면은 제외했다.
5. 메뉴 관리 100건 + DataTable 옵션화
DataTable이 페이지 크기를 10으로 하드코딩하고 있어서, 다른 페이지에 영향 없이 메뉴 관리만 100건을 보이도록 opt-in prop을 추가했다.
// DataTable
function DataTable<T>({
..., defaultRowsPerPage = 10, rowsPerPageOptions = [5, 10, 25],
}: DataTableProps<T>) {
const [rowsPerPage, setRowsPerPage] = useState(defaultRowsPerPage);
...
}
// Menus.tsx
<DataTable ... defaultRowsPerPage={100} rowsPerPageOptions={[25, 50, 100]} />
6. 역할 관리 — 검색 + 셀렉트 조회
기존엔 프론트에서 전체 받아 필터링했는데, 포털 셀렉트를 바꾸면 getRoles(site)로 백엔드 재조회하도록 바꿨다. 첫 진입만 전체 스피너(initialLoading)를 보이고, 이후 재조회는 깜빡임 없이 rows만 갱신한다. 셀렉트 옆에는 검색 항목 Select + 검색 입력 필드를 붙였다.
검증 & 트러블슈팅
- 3개 앱(brand/academy/student) 모두
tsc --noEmit통과. brand·student는 production build 성공. - academy build 실패:
src/components/common/FileUpload.tsx의 미사용Buttonimport 때문에CI=true환경에서 경고가 에러로 처리됨. 이번 작업과 무관한 기존 코드라 그대로 두고 별도 정리 대상으로 메모. - cwd 리셋 함정: 셸 작업 디렉터리가 매 명령마다 리셋되어,
cd없이 build를 돌렸더니 직전 디렉터리(academy)에서 실행되는 일이 있었다. 절대경로/명시적cd로 해결.
로컬 DB SSH 터널링
RDS가 private이라 EC2를 bastion으로 경유하는 SSH 터널(localhost:3306 → RDS:3306)을 백그라운드로 열었다.
nc -z -w 3 localhost 3306 && echo OPEN || echo CLOSED # 상태 확인
ssh -i <키> -o StrictHostKeyChecking=accept-new -L 3306:<RDS>:3306 -N -f ec2-user@<EC2>
EC2가 자정 자동정지/PC 재부팅 시 끊기므로, 그때 다시 열면 되는 idempotent 구조다.
결론 / 배운 점
- 전역 로딩바는 Context보다 모듈 싱글턴 +
useSyncExternalStore조합이 훨씬 가볍고, 컴포넌트 트리 밖(예: axios 인터셉터)에서도 제어할 수 있어 유연하다. - boolean이 아닌 카운트 기반으로 만들어야 동시 요청에서 로딩바가 조기 종료되지 않는다.
- DataTable 같은 공통 컴포넌트는 동작을 바꾸기보다 opt-in prop으로 확장해야 다른 화면을 깨뜨리지 않는다.
추가 정리 (23:30) — 공지사항/FAQ 본사↔학원 스코프 분리
개요
지금까지 공지사항(announcements)·FAQ(faqs)는 본사(admin) 전용이었다. 원장(OWNER)이 자기 학원 학생에게만 보이는 공지/FAQ를 직접 등록·관리할 수 있도록, 단일 테이블에 scope(SYSTEM/ACADEMY) + academy_id 컬럼을 추가하는 방식으로 본사 글과 학원 글을 한 테이블에서 분리했다. 학생은 본사(SYSTEM) + 소속 학원(ACADEMY) 글을 합산해서 본다.
핵심 설계:
SYSTEM=academy_idNULL, admin이 관리 (기존 본사/brand 글은 그대로 SYSTEM 유지)ACADEMY=academy_id= 토큰의 academyId, 원장이 관리- 학생 노출 필터:
scope='SYSTEM' OR (scope='ACADEMY' AND academy_id = #{academyId}) - academyId는 JWT(UserPrincipal)에서만 추출(
AcademyScope.require), URL 노출 금지
수정/생성 파일 목록
백엔드 (piano-backend)
resources/schema.sql(수정) — announcements/faqs에 scope+academy_id+복합인덱스publicsite/announcement/Announcement.java(수정) — scope, academyId 필드student/faq/Faq.java(수정) — scope, academyId 필드admin/announcement/AnnouncementAdminMapper.java(수정) — findByScopeAndAcademy, findForDisplayadmin/faq/FaqAdminMapper.java(수정) — 동일 2개 메서드student/faq/FaqMapper.java(수정) — findForDisplaymapper/AnnouncementAdminMapper.xml(수정) — resultMap/insert/select scope 반영mapper/AnnouncementMapper.xml(수정, brand 공개) — SYSTEM만 필터mapper/FaqAdminMapper.xml(수정) — 동일 패턴mapper/FaqMapper.xml(수정, student) — findForDisplayadmin/announcement/AnnouncementAdminService.java(수정) — academy/student 메서드admin/faq/FaqAdminService.java(수정) — 동일 패턴student/faq/FaqService.java(수정) — listForStudent(합산)student/faq/FaqController.java(수정) — 인증 기반 합산 조회academy/announcement/AcademyAnnouncementController.java(신규) —/api/v1/academy/announcementsacademy/faq/AcademyFaqController.java(신규) —/api/v1/academy/faqsstudent/announcement/StudentAnnouncementController.java(신규) —/api/v1/student/announcements
프론트 (academy)
src/api/announcements.ts(신규) — 원장 공지 CRUDsrc/api/faqs.ts(신규) — 원장 FAQ CRUDsrc/pages/Notices.tsx(신규) — 공지사항 관리 페이지src/pages/Faqs.tsx(신규) — FAQ 관리 페이지src/App.tsx(수정) —/notices,/faqs라우트 2개
프론트 (student)
src/api/announcements.ts(수정) —/public/notices→/student/announcements(인증)
DB (dev RDS, EC2 경유 수동 마이그레이션)
- announcements/faqs
ALTER+ 복합 인덱스 2개 - menus INSERT 2건 (site=ACADEMY: 공지사항 관리
/notices, FAQ 관리/faqs)
파일별 상세
1. 스키마 — scope/academy_id + 복합 인덱스
단일 테이블에 두 컬럼을 추가하고, 학생 조회 필터(scope, academy_id)에 맞는 복합 인덱스를 걸었다.
ALTER TABLE announcements
ADD COLUMN scope VARCHAR(20) NOT NULL DEFAULT 'SYSTEM' AFTER id,
ADD COLUMN academy_id BIGINT NULL AFTER scope;
CREATE INDEX idx_ann_scope_academy ON announcements (scope, academy_id);
faqs도 동일. DEFAULT 'SYSTEM'이라 기존 본사 글은 마이그레이션만으로 자동 SYSTEM이 된다.
2. 매퍼 — 본사/학원/학생 쿼리 분리
- 본사 목록(admin)·brand 공개는
WHERE scope='SYSTEM'만 보이게 필터 추가 → 원장 글이 본사/brand에 안 섞임. findByScopeAndAcademy: 원장 자기 학원 글만 (scope='ACADEMY' AND academy_id=#{academyId}).findForDisplay: 학생 합산 (scope='SYSTEM' OR (scope='ACADEMY' AND academy_id=#{academyId})).- insert는 scope/academy_id 포함(
COALESCE(#{scope},'SYSTEM')), update는 scope/academy_id를 건드리지 않음 → 소유권 고정.
3. 서비스 — 소유권 검증
팝업 기능이 이미 admin 서비스에 academy 메서드를 합쳐둔 패턴이라 그대로 따랐다. 원장 update/delete는 반드시 소유권을 검증한다.
public Announcement updateForAcademy(Long academyId, Long id, AnnouncementRequest req) {
Announcement existing = get(id);
AcademyScope.verifySameAcademy(academyId, existing.getAcademyId()); // 타학원/SYSTEM 차단
...
}
4. 컨트롤러 — 경로 기반 권한
SecurityConfig가 경로로 권한을 가르기 때문에(/academy/**=OWNER,TEACHER / /student/**=STUDENT) 별도 어노테이션 없이 신규 엔드포인트만 추가했다. academyId는 토큰에서 뽑는다.
Long academyId = AcademyScope.require(principal);
return ApiResponse.ok(service.createForAcademy(academyId, req));
5. 프론트 — academy 관리 페이지 + student 합산 노출
- academy: 기존 팝업 페이지(DataTable/AppDatePicker/fx 로딩바)를 템플릿 삼아 Notices/Faqs 페이지와 CRUD API를 만들고 라우트 2개 추가. DB menus INSERT만으로 사이드바에 자동 노출(메뉴 DB 구동 방식).
- student:
announcements.ts의 엔드포인트만/student/announcements(인증)로 바꾸면 MorePage는 무수정으로 합산 노출. FAQ는 기존/student/faqs그대로 두고 백엔드가 합산 처리.
검증 & 배포
- 백엔드
./gradlew clean compileJava/bootJar통과 → jar 교체 +systemctl restart <api-service>(active). - academy/student
tsc --noEmit+npm run build성공 → tar로 묶어 EC2 웹루트 교체. - dev 서버 E2E 검증:
- 원장 등록 →
scope=ACADEMY, academyId=1로 저장, 목록 정상. - 학생(academyId 1): 공지 9건·FAQ 5건 = 본사(SYSTEM) + 우리학원(ACADEMY) 합산 확인.
- 격리: 원장이 SYSTEM 글 삭제 시도 →
ACADEMY_FORBIDDEN차단. - 검증용 테스트 데이터는 삭제 정리.
- 원장 등록 →
트러블슈팅 메모
- 실DB는
sql.init.mode=never라 schema.sql이 자동 적용되지 않는다. RDS가 private이므로 EC2에 SSH로 들어가/etc/<앱>/<앱>.env의 DB 자격증명을 셸 변수로만 읽어(값 노출 없이) mysql을 실행했다. - 셸에서
... | grep -v "Using a password"로 끝내면 마지막 명령이 grep이라 EXIT=1이 떠서 실패로 착각하기 쉽다. 실제 ALTER는 성공했고DESC/SHOW INDEX로 재확인했다.
결론 / 배운 점
- 멀티테넌시 데이터 격리는 테이블 분리보다
scope + tenant_id한 쌍이 쿼리·인덱스·합산 노출 모두 단순하게 만든다. - 핵심은 insert엔 scope/academy_id 포함, update엔 제외(소유권 고정) + 읽기/쓰기 모두 토큰 academyId로 검증. URL에 academyId를 안 싣는 게 격리의 출발점.
- 이미 구현된 팝업 기능이 동일 패턴이라, 백엔드 서비스 구조부터 프론트 페이지까지 그대로 차용해 작업이 빨랐다.
댓글 0
- 첫 번째 댓글을 남겨보세요.