개발일지 2026-07-27
피아노 학원 플랫폼 개발일지 (2026-07-27)
개요
이날은 관리자(admin) 화면 두 곳을 실데이터/실기능으로 완성하는 작업을 했다.
- 기기 모니터링 — 기기 행을 클릭하면 그 학원의 실제 출력(PRINT) 로그를 팝업으로 보여주도록 추가. 기존엔 "마지막 출력" 시각 한 건만 보였는데, 이제 클릭하면 상세 이력 목록(서버 페이징)을 볼 수 있다.
- 멤버십 결제 내역 — 하드코딩 목업(BILLS 배열)을 제거하고
GET /admin/billings실 API로 교체. 상태/결제수단 코드→한글 라벨 변환, 월·상태 필터, 실제 CSV 내보내기까지 붙였다.
수정/생성 파일 목록
프론트 (admin)
admin/src/api/printAgentDevices.ts(수정) —getPrintLogsAPI +PrintLog/PrintLogPage타입 추가,AgentDevice.academyId필드 추가admin/src/pages/DeviceMonitoring.tsx(수정) — 기기 행onClick→ 출력이력 Dialog +TablePagination서버 페이징admin/src/api/membershipBilling.ts(신규) — 멤버십 결제 조회 API 클라이언트 + 타입admin/src/pages/MembershipBilling.tsx(수정) — 목업 제거, 실 API 연동, 코드→라벨 변환, 필터, CSV 내보내기
파일별 상세
1. 기기 모니터링 — 학원별 출력이력 팝업
배경
기기 모니터링 목록의 "마지막 출력" 컬럼은 그 학원에서 가장 최근에 찍힌 PRINT 로그 타임스탬프 한 건만 보여줬다. 실제로 언제, 누가, 무슨 곡을 출력했는지는 알 수 없었다. 그래서 기기 행을 클릭하면 그 학원의 PRINT 로그 목록을 다이얼로그로 띄우도록 했다.
- 컬럼: 출력시각 / 사용자(이름 + 이메일) / 역할 / 곡명
- 최신순, 서버 페이징 20건씩
- IP·상세는 표시하지 않음
API 클라이언트 (printAgentDevices.ts)
먼저 학원 식별을 위해 AgentDevice에 academyId를 추가하고, 출력 로그 조회 API를 붙였다.
export interface AgentDevice {
id: number;
machineId: string;
academyId?: number; // ← 추가 (팝업에서 학원별 로그 조회에 사용)
hostName?: string;
userName?: string;
academyName?: string;
// ...
}
/** 학원별 출력(PRINT) 로그 1건. */
export interface PrintLog {
id: number;
createdAt?: string;
userName?: string;
userEmail?: string;
userRole?: string;
songTitle?: string;
}
/** 출력 로그 페이징 응답. */
export interface PrintLogPage {
items: PrintLog[];
page: number;
size: number;
total: number;
totalPages: number;
}
/** 학원별 출력 로그 조회(최신순, 서버 페이징 page 1-base). */
export async function getPrintLogs(
academyId: number,
page = 1,
size = 20,
): Promise<PrintLogPage> {
const { data } = await client.get<ApiResult<PrintLogPage>>('/admin/print-agent/print-logs', {
params: { academyId, page, size },
});
return data.data ?? { items: [], page, size, total: 0, totalPages: 0 };
}
화면 (DeviceMonitoring.tsx)
기기 행에 onClick 을 걸고, 선택된 기기(selected)와 페이지(logPage)가 바뀔 때마다 로그를 조회하는 구조로 만들었다. TablePagination은 0-base라, 서버(1-base) 호출 시 logPage + 1로 변환한다.
// 출력이력 팝업 상태
const [selected, setSelected] = useState<AgentDevice | null>(null);
const [logs, setLogs] = useState<PrintLog[]>([]);
const [logPage, setLogPage] = useState(0); // 0-base (TablePagination)
const [logTotal, setLogTotal] = useState(0);
useEffect(() => {
if (!selected?.academyId) {
if (selected && !selected.academyId)
setLogError('학원 정보가 없어 출력 이력을 조회할 수 없습니다.');
return;
}
let alive = true;
setLogLoading(true);
getPrintLogs(selected.academyId, logPage + 1, PAGE_SIZE)
.then((res) => {
if (!alive) return;
setLogs(res.items);
setLogTotal(res.total);
})
.catch(() => alive && setLogError('출력 이력을 불러오지 못했습니다.'))
.finally(() => alive && setLogLoading(false));
return () => { alive = false; }; // 언마운트/재조회 시 stale 응답 무시
}, [selected, logPage]);
행 클릭 → Dialog 오픈:
<TableRow key={d.id} hover onClick={() => openLogs(d)} sx={{ cursor: 'pointer' }}>
{/* ... */}
</TableRow>
역할 코드는 화면 상수로 한글 매핑했다.
const ROLE_LABELS: Record<string, string> = {
STUDENT: '학생',
TEACHER: '강사',
ACADEMY_ADMIN: '학원관리자',
SUPER_ADMIN: '본사관리자',
OPS_CS: '운영/CS',
};
로그가 0건이면 "출력 이력이 없습니다" 빈 상태 문구를 보여주고, 학원 정보가 없는 기기는 에러 알림으로 안내한다.
2. 멤버십 결제 내역 — 목업 → 실데이터 연동
배경
멤버십 결제 화면은 그동안 하드코딩 BILLS 배열로 목업만 보여줬다. 이걸 실제 구독 결제 내역 API(GET /admin/billings)로 교체했다.
API 클라이언트 (membershipBilling.ts, 신규)
export interface MembershipBillingRow {
id: number;
academyId?: number;
academyName?: string;
amount?: number;
status: string; // PENDING/PAID/CANCELLED/REFUNDED/OVERDUE
method?: string; // CARD/TRANSFER/VIRTUAL/EASY/TERMINAL
pgTno?: string; // KSNET 거래번호
period?: string; // 'YYYY-MM'
billedAt?: string;
paidAt?: string;
}
export interface MembershipBillingSearchParams {
period?: string; // 'YYYY-MM'
status?: string;
}
export const getMembershipBilling = async (
params: MembershipBillingSearchParams,
): Promise<MembershipBillingRow[]> => {
const { data } = await client.get<ApiResult<MembershipBillingRow[]>>('/admin/billings', { params });
return data.data ?? [];
};
화면 (MembershipBilling.tsx)
- 목업 제거: 하드코딩
BILLS삭제 → API 조회로 대체 - 코드 → 한글 라벨: 결제상태(PAID→완료 등), 결제수단(CARD→카드 등)을 라벨로 변환
- 필터: 월(period), 상태(status) 필터로 서버 파라미터 전달
- CSV(엑셀) 내보내기: 목업 다운로드가 아니라 조회된 실제 데이터를 CSV로 출력
- 영수증: 영수증 조회는 아직 백엔드 미구현이라, 임시로
pg_tno(KSNET 거래번호) 컬럼으로 대체 표시
검증 & 배포
- admin 앱 빌드 통과, 로컬에서 기기 모니터링 행 클릭 → 팝업 정상 표시, 페이징 동작 확인.
- 멤버십 결제 화면에서 실제
/admin/billings응답으로 목록·필터·CSV 확인. - 운영(EC2) admin 배포.
/var/www/piano/admin/반영, nginx 서빙.
트러블슈팅 / 배운 점
- 서버 페이징 인덱스 변환: MUI
TablePagination은 0-base, 백엔드는 1-base.logPage + 1변환을 빼먹으면 항상 한 페이지 어긋난다. 컴포넌트 경계에서 명확히 변환하는 게 안전. - stale 응답 방지: 팝업을 빠르게 여닫거나 페이지를 연타하면 이전 요청 응답이 늦게 도착해 화면을 덮어쓸 수 있다.
let alive = true플래그 + cleanup으로 언마운트/재조회 시 오래된 응답을 무시하도록 처리. - academyId 없는 기기 방어: 기기 데이터에 학원 정보가 없으면 로그 조회가 불가능하므로, 조회 전에 막고 사용자에게 안내 메시지를 보여준다.
- 미구현 대체 표시: 영수증 조회 기능이 아직 없을 때, 화면을 비워두기보다
pg_tno같은 거래 식별자로 임시 대체해두면 실무에서 추적은 가능하다. (추후 실제 영수증 API 연동 시 교체)
결론
관리자 화면 두 곳(기기 모니터링·멤버십 결제)을 목업에서 실데이터/실기능으로 끌어올렸다. 특히 출력이력 팝업은 "마지막 한 건"만 보이던 정보를 누가·언제·무슨 곡을 출력했는지 상세히 추적할 수 있게 해준다.
댓글 0
- 첫 번째 댓글을 남겨보세요.