개발일지 2026-08-15
M forêt 개발일지 (2026-08-15)
개요
오늘은 관리자(admin) 화면 두 곳을 손봤다.
- 학원 목록 검색에 "첨부서류 미비/완비" 필터 추가 —
/admin/academies목록에서 필수 첨부서류(사업자등록증·통장 사본·통신판매허가증)가 모두 갖춰졌는지 여부로 학원을 걸러볼 수 있게 했다. 기존 목록에 이미 완비/미비 칩이 떠 있었는데, 이제 그 상태로 검색까지 가능해졌다. - 회원현황 상단에 "학원 변경신청" KPI 카드 추가 —
/admin/member-status최상단 KPI 줄에 대기중인 학원 변경신청 건수를 보여주는 카드를 하나 붙였다. 클릭하면 변경신청 관리 화면으로 바로 이동한다. 백엔드 변경 없이 기존 API 재사용으로 처리했다.
수정/생성 파일 목록
백엔드 (piano-backend)
src/main/java/com/piano/api/admin/AcademyAdminController.java(수정) — 목록/페이지 API에docsStatus파라미터 추가src/main/java/com/piano/api/academy/AcademyService.java(수정) —docsStatus시그니처 전달src/main/java/com/piano/api/academy/AcademyMapper.java(수정) —@Param("docsStatus")추가src/main/resources/mapper/AcademyMapper.xml(수정) — 첨부서류 완비 판정 조인/필터 프래그먼트 추가
프론트 (admin)
src/api/academies.ts(수정) —AcademySearch에docsStatus필드 추가src/pages/Academies.tsx(수정) — 검색줄에 "첨부서류(전체/완비/미비)" 드롭다운 추가src/pages/MemberStatus.tsx(수정) — 상단 KPI 에 "학원 변경신청" 카드 추가
파일별 상세
1. 첨부서류 필터 — "완비"의 판정 기준을 어디에 둘 것인가
학원 목록에는 이미 docs_complete 라는 SELECT CASE 별칭이 있었다. 필수 서류 3종이 모두 있으면 1, 아니면 0. 화면 칩도 row.docsComplete ? '완비' : '미비' 로 그 값을 그대로 쓰고 있었다.
문제는 "완비"의 조건이 단순하지 않다는 점이었다. 필수 서류 3종은 각각 두 경로 중 하나로 충족될 수 있다.
- 가입신청 첨부(
academy_signup_requests의 bizLicense/bankbook/mailorder) — 학원이 가입할 때 낸 서류 - 학원 직접 첨부(
academy_documents의 이름 매칭 +file_id NOT NULL) — 나중에 관리자가 직접 붙인 서류
즉 "완비"는 사업자등록증(가입 OR 직접) AND 통장(가입 OR 직접) AND 통신판매(가입 OR 직접) 이라는 복합 조건이다. 이걸 필터에서 다시 쓰려면 WHERE docs_complete = 1 처럼 별칭을 참조할 수 없다(SELECT 별칭은 WHERE 에서 못 씀). 그래서 같은 판정식(predicate)을 WHERE 절에 그대로 다시 쓰는 방식으로 갔다.
2. XML — 조인/판정식을 프래그먼트로 뽑아 재사용
searchSelectBody(목록)와 searchCount(총건수)가 같은 판정 로직을 공유해야 하므로, 반복을 피하려고 세 개의 <sql> 프래그먼트로 분리했다.
docsCompleteJoins— 최신 가입신청(sr) LEFT JOIN +academy_documents를 집계한(has_biz/has_bank/has_mail) 서브쿼리(ad) LEFT JOINdocsCompleteWhenComplete— "3종 모두 존재" 를 판정하는 boolean 식docsStatusFilter— 상태 필터
<sql id="docsStatusFilter">
<if test="docsStatus == 'COMPLETE'">
AND <include refid="docsCompleteWhenComplete"/>
</if>
<if test="docsStatus == 'INCOMPLETE'">
AND NOT <include refid="docsCompleteWhenComplete"/>
</if>
</sql>
포인트 두 가지:
docsStatusFilter는 공용searchWhere의<where>안에 넣어, 앞의AND는<where>가 알아서 떼준다. 별도로 trailing-AND 를 걱정할 필요가 없다.searchCount는docsStatus가 있을 때만 조인을 붙인다. 필터 안 걸린 일반 카운트는 무거운 조인 없이 싸게 돌게 유지했다.
INCOMPLETE(미비)의 정의를 화면 칩과 정확히 일치시킨 것도 중요하다. 화면 칩은 docsComplete ? '완비' : '미비' 이므로, 미비 = NOT 완비(즉 docs_complete 가 0 인 것뿐 아니라 NULL 인 경우도 포함)여야 한다. AND NOT (...) 로 자연스럽게 커버된다.
3. 백엔드 4계층에 docsStatus 관통시키기
컨트롤러 → 서비스 → 매퍼 인터페이스 → XML 로 파라미터 하나를 느슨하게 흘려보냈다. 컨트롤러는 @RequestParam(required = false) 로 받아 그대로 넘긴다.
@GetMapping("/paged")
public ApiResponse<...> listPaged(
...,
@RequestParam(required = false) Boolean includeInactive,
@RequestParam(required = false) String docsStatus, // 추가
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
...
}
academyService.search/searchPaged 의 유일한 호출자가 이 컨트롤러뿐이라는 걸 grep 으로 확인하고 시그니처를 바꿨다. DDL 변경은 전혀 없어서 db-migrate 도 필요 없었다(MyBatis 가 재배포 시 새 XML 을 그대로 집어간다).
4. 프론트 — 검색 드롭다운 한 개
Academies.tsx 검색줄의 "소속대리점" 필터 뒤, "검색" 버튼 앞에 select 하나를 추가했다.
<TextField
size="small" select label="첨부서류" sx={{ width: 120 }}
value={search.docsStatus || ''}
onChange={(e) => setSearch((p) => ({ ...p, docsStatus: e.target.value || undefined }))}
>
<MenuItem value="">전체</MenuItem>
<MenuItem value="COMPLETE">완비</MenuItem>
<MenuItem value="INCOMPLETE">미비</MenuItem>
</TextField>
AcademySearch 인터페이스에는 docsStatus?: string 한 줄만 추가하면 됐다. 빈 문자열은 undefined 로 넘겨 파라미터 자체가 안 붙게 했다(= 전체).
5. 회원현황 KPI — 백엔드 없이 기존 API 재사용
/admin/member-status 는 수강생 통계(전체/재원/휴원/신규/퇴원) 5개 카드가 있는 화면이었다. 여기에 "학원 변경신청" 대기 건수를 6번째 카드로 붙였다.
새 통계 API 를 만들 필요는 없었다. 이미 학원 변경신청 관리용 getAcademyChangeRequests(status) 가 있었고, 상태 "대기"(=PENDING)로 조회한 목록의 length 를 세면 그만이었다.
const [changeReqPending, setChangeReqPending] = useState(0);
useEffect(() => {
(async () => {
try {
const list = await getAcademyChangeRequests('대기');
setChangeReqPending(list.length);
} catch { /* 카운트 실패해도 화면은 그대로 */ }
})();
}, []);
KPI 배열을 6개로 늘리고, 마지막 카드에 클릭 핸들러와 강조 플래그를 달았다.
{ label: '학원 변경신청', value: `${num(changeReqPending)}건`, sub: '대기중',
onClick: () => navigate('/academy-change-requests'),
highlight: changeReqPending > 0 },
대기 건이 1건이라도 있으면 카드를 붉은 톤(배경 #fff5f5, 테두리 #f5b5b5, 값 #d32f2f)으로 강조하고, 클릭 시 변경신청 관리 화면으로 이동한다. 그리드도 md 기준 6열로 넓혔다.
검증 & 배포
백엔드 (도커 → ECR → EC2 #1)
- git commit/push (
fa14ce6) docker build --platform linux/arm64→ ECR push (:fa14ce6,:latest)- EC2 #1(단독) 컨테이너 교체 → 부팅 확인
sec-on ping = 200,Started PianoApiApplication,HikariPool-1 - Start completed
- E2E(CloudFront 경유):
GET /api/v1/public/ping→ 200GET /api/v1/auth/me→ 401(정상)GET /api/v1/admin/academies/paged?docsStatus=INCOMPLETE→ 401(인증 필요, 즉 라우팅 정상 — 500 아님)
프론트 (S3 + CloudFront)
- admin 빌드 → dev URL 스캔 게이트 통과 → S3 업로드(index.html no-cache / assets immutable) → CloudFront
/admin/*무효화(약 18초 Completed) - 검증: index.html 이 방금 올린 번들 해시(
index-jR7f5Wdl.js)를 참조, member-status 페이지 200 - git push (
3c5b293) — 작업 3파일만 선별 스테이징
트러블슈팅 메모 (삽질 기록)
- "서버가 왜 안 죽지?": 배포 후 EC2 자동 정지 스케줄을 점검하다, EventBridge Scheduler 의 정지 스케줄(
piano-ec2-stop-0000)이 DISABLED 라 애초에 안 꺼지고 있었다는 걸 발견. 게다가 스케줄 이름과 실제 cron 이 어긋나 있었다(이름은 0000 인데 실제는 00:30, 이름은 1000 인데 실제는 06:30). 이름을 믿지 말고 실제ScheduleExpression을 봐야 한다는 교훈. 결국 정지 01:00 / 기동 08:00(Asia/Seoul)로 다시 맞추고 둘 다 ENABLED 로 살렸다. - member-status 요청이 모호: "학원 변경신청 하나 추가하고 카운팅"이 카드 추가인지 표 추가인지, 카운트 기준이 전체인지 대기중인지 애매해서, 구현 전에 확인받아 "KPI 카드 1개 / 대기중(PENDING)만"으로 확정하고 진행했다.
- 프론트 레포에 커밋 안 된 변경이 잔뜩: 기존에 작업 중이던 파일이 수십 개 널려 있어서, 오늘 작업과 무관한 걸 끌고 들어가지 않도록 오늘 건드린 3파일만 선별 스테이징해서 커밋했다.
결론 / 배운 점
- SELECT 별칭(
docs_complete)은 WHERE 에서 못 쓴다. 화면 표시용 판정과 검색용 필터가 같은 기준이어야 한다면, 판정식을<sql>프래그먼트로 뽑아 SELECT·WHERE 양쪽에서 재사용하는 게 깔끔하다. "완비"의 정의가 복합 조건일수록 이 재사용이 중복/불일치를 막아준다. - 새 화면 요소가 항상 새 API 를 의미하진 않는다. member-status KPI 카드는 이미 있던 변경신청 목록 API 하나로 백엔드 손 하나 안 대고 끝냈다.
- 인프라 스케줄은 이름이 아니라 실제 표현식을 봐야 한다.
piano-ec2-stop-0000이 실제로 00:30 을, 그것도 DISABLED 상태로 물고 있던 걸 이름만 보고 지나쳤다면 계속 헤맸을 것이다.
댓글 0
- 첫 번째 댓글을 남겨보세요.