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

개발일지 2026-07-27

피아노 학원 플랫폼 개발일지 (2026-07-27)

개요

이날은 관리자(admin) 화면 두 곳을 실데이터/실기능으로 완성하는 작업을 했다.

  1. 기기 모니터링 — 기기 행을 클릭하면 그 학원의 실제 출력(PRINT) 로그를 팝업으로 보여주도록 추가. 기존엔 "마지막 출력" 시각 한 건만 보였는데, 이제 클릭하면 상세 이력 목록(서버 페이징)을 볼 수 있다.
  2. 멤버십 결제 내역 — 하드코딩 목업(BILLS 배열)을 제거하고 GET /admin/billings 실 API로 교체. 상태/결제수단 코드→한글 라벨 변환, 월·상태 필터, 실제 CSV 내보내기까지 붙였다.

수정/생성 파일 목록

프론트 (admin)

  • admin/src/api/printAgentDevices.ts (수정) — getPrintLogs API + 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

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