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

개발일지 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 (신규/기존) — 로딩바 싱글턴 store
  • admin|brand|academy|student/src/components/GlobalLoading.tsx (신규/기존) — Backdrop+Spinner 렌더 컴포넌트
  • admin|brand|academy|student/src/App.tsx (수정) — <GlobalLoading /> 최상단 마운트

admin

  • src/components/UserManagement.tsx (수정) — 원장/강사/관리자/학생계정 공통 목록 조회에 로딩바
  • src/pages/*.tsx 12개 (수정) — 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 / rowsPerPageOptions prop 옵션화

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의 미사용 Button import 때문에 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_id NULL, 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, findForDisplay
  • admin/faq/FaqAdminMapper.java (수정) — 동일 2개 메서드
  • student/faq/FaqMapper.java (수정) — findForDisplay
  • mapper/AnnouncementAdminMapper.xml (수정) — resultMap/insert/select scope 반영
  • mapper/AnnouncementMapper.xml (수정, brand 공개) — SYSTEM만 필터
  • mapper/FaqAdminMapper.xml (수정) — 동일 패턴
  • mapper/FaqMapper.xml (수정, student) — findForDisplay
  • admin/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/announcements
  • academy/faq/AcademyFaqController.java (신규) — /api/v1/academy/faqs
  • student/announcement/StudentAnnouncementController.java (신규) — /api/v1/student/announcements

프론트 (academy)

  • src/api/announcements.ts (신규) — 원장 공지 CRUD
  • src/api/faqs.ts (신규) — 원장 FAQ CRUD
  • src/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

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