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

개발일지 2026-07-21

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

개요

이번 세션은 학원 운영에 필요한 레슨 사이클 관리 3종을 백엔드·프론트에 걸쳐 구현했다. ① 수강생 레벨(1~10) 부여/수정, ② 레슨일지 작성 시 예약 자동 완료 처리, ③ 노쇼(NOSHOW) 관리(표시/복구/현황). 세 가지 모두 이미 정의만 되어 있던 개념(레벨 컬럼, COMPLETED/NOSHOW 상태값)을 실제 동작하는 기능으로 살려낸 작업이다.


수정/생성 파일 목록

백엔드 (piano-backend)

  • academy/student/Student.java (수정) — 현재 레벨 join 필드 추가
  • academy/student/StudentController.java (수정) — 레벨 수정 엔드포인트
  • academy/student/StudentService.java (수정) — updateLevel() (progress upsert)
  • academy/student/dto/StudentLevelRequest.java (신규) — 레벨 요청 DTO(1~10 검증)
  • student/lesson/LessonProgressMapper.java + mapper/LessonProgressMapper.xml (수정) — upsert 추가
  • mapper/StudentMapper.xml (수정) — 학생 조회에 progress.level LEFT JOIN
  • academy/lesson/LessonJournalService.java (수정) — 일지 저장/삭제 시 예약 상태 전이
  • academy/booking/LessonBooking.java (수정) — ticketName join 필드
  • academy/booking/LessonBookingMapper.java + mapper/LessonBookingMapper.xml (수정) — updateStatus(조건부 전이), findNoshowsForAcademy
  • academy/booking/AcademyBookingService.java (신규) — 노쇼 표시/복구/목록
  • academy/booking/AcademyBookingController.java (수정) — 노쇼 3개 엔드포인트
  • academy/studentticket/StudentTicketService.java (수정) — markNoshow 감사 로그
  • common/ErrorCode.java (수정) — BOOKING_INVALID_STATUS

프론트 (piano-academy / academy 앱)

  • api/students.ts (수정) — level 타입 + updateStudentLevel
  • api/bookings.ts (수정) — getNoshows / markBookingNoshow / restoreBookingNoshow
  • pages/Students.tsx (수정) — 등급→레벨(Lv.1~10) 대체, 저장 시 레벨 갱신
  • pages/LessonJournals.tsx (수정) — 레벨 필드 + 노쇼 버튼
  • pages/Schedule.tsx (수정) — 지난 BOOKED에 노쇼 버튼
  • pages/Bookings.tsx (수정) — 예약현황에 노쇼 버튼
  • pages/NoShows.tsx (수정) — 더미 제거, 실데이터 연동 + 복구

파일별 상세

1. 수강생 레벨(1~10) 부여

가장 먼저 확인한 것은 "레벨을 어디에 저장하느냐"였다. 조사해보니 이미 student_lesson_progress 테이블에 level INT(1~10), level_name, progress 컬럼이 학생당 1건(UNIQUE(student_id))으로 존재했고, 학생 앱에서 조회 전용으로 쓰이고 있었다. 빠진 건 "원장/강사가 레벨을 쓰는 경로"뿐이었다.

처음엔 students 테이블에 level 컬럼을 새로 팔까 고민했지만, 그러면 기존 progress.level과 이중 관리가 된다. 기존 테이블을 재활용하기로 했다.

매퍼에 student_id UNIQUE를 활용한 upsert를 추가했다.

<insert id="upsert" parameterType="com.piano.api.student.lesson.StudentLessonProgress">
    INSERT INTO student_lesson_progress
        (academy_id, student_id, level, level_name, progress, teacher_id, teacher_comment)
    VALUES (#{academyId}, #{studentId}, #{level}, #{levelName}, COALESCE(#{progress}, 0),
            #{teacherId}, #{teacherComment})
    ON DUPLICATE KEY UPDATE
        level = VALUES(level),
        level_name = COALESCE(VALUES(level_name), level_name),
        teacher_id = VALUES(teacher_id),
        teacher_comment = COALESCE(VALUES(teacher_comment), teacher_comment)
</insert>

서비스는 학원 스코프 검증 후, 요청자가 강사면 본인 teachers.id를 평가 강사로 기록한다.

@Transactional
public Student updateLevel(Long academyId, Long id, StudentLevelRequest req, UserPrincipal principal) {
    Student student = get(academyId, id); // 존재 + 학원 스코프 검증
    Long teacherId = null;
    Teacher teacher = teacherMapper.findByUserId(principal.getUserId());
    if (teacher != null) teacherId = teacher.getId();

    StudentLessonProgress progress = StudentLessonProgress.builder()
        .academyId(academyId).studentId(student.getId())
        .level(req.level()).levelName(req.levelName())
        .teacherId(teacherId).teacherComment(req.teacherComment())
        .build();
    progressMapper.upsert(progress);
    return studentMapper.findById(id); // level 포함 재조회
}

엔드포인트는 학생 정보 수정(PUT /academy/students/{id})과 분리해 PUT /academy/students/{id}/level로 독립시켰다. 학생 목록/상세 조회 쿼리에는 LEFT JOIN student_lesson_progress로 현재 레벨을 실어 보낸다.

프론트에서는 학생 팝업의 기존 "등급(초/중/고)" 셀렉트를 레벨 1~10 셀렉트로 대체하고, 목록의 등급 칩도 Lv.N으로 바꿨다. 저장 핸들러에서 레벨이 바뀌면 별도 API를 순차 호출한다.

2. 레슨일지 작성 → 예약 자동 완료

"레슨일지를 쓰면 예약이 완료돼야 하는 것 아니냐"는 지적에서 출발했다. 확인해보니 레슨일지 저장은 lesson_notes만 건드리고 lesson_bookings.status는 손대지 않아, 일지를 써도 예약은 계속 "예정(BOOKED)"이었다.

핵심은 레벨이나 상태를 레슨일지에 종속시키지 않는 것이었다. lesson_notes(예약당 여러 건)와 예약 상태는 별개다. 그래서 booking 매퍼에 조건부 상태 전이를 추가했다.

<update id="updateStatus">
    UPDATE lesson_bookings SET status = #{status}
    WHERE id = #{id}
    <if test="fromStatus != null">AND status = #{fromStatus}</if>
</update>

fromStatus를 지정하면 그 상태일 때만 바뀌므로 취소(CANCELLED)된 예약은 안전하게 보호된다. LessonJournalService에서:

// 저장 시: BOOKED일 때만 완료 처리
bookingMapper.updateStatus(bookingId, "COMPLETED", "BOOKED");
// 삭제(작성취소) 시: COMPLETED였던 것만 예정 복원
bookingMapper.updateStatus(bookingId, "BOOKED", "COMPLETED");

한 곳(백엔드 status)만 바꾸니 세 화면이 동시에 일관됐다 — academy 스케줄, academy 레슨일지 목록, 학생 앱 예약 화면 모두 같은 lesson_bookings.status를 읽기 때문. 프론트 표시만 손대는 방식(A안)이 아니라 DB를 실제로 바꾸는 방식으로 간 게 신의 한 수였다.

3. 노쇼(NOSHOW) 관리

노쇼는 상태값만 정의돼 있고, 화면(NoShows.tsx)은 이준서/강서연이 하드코딩된 완전 더미였다. 버튼을 눌러도 화면 state만 바뀌고 백엔드 연동이 전혀 없었다.

정책을 먼저 확정했다.

  • 노쇼 = 차감 유지(몰수): 예약 시 이미 수강권이 1회 차감되므로 노쇼여도 복구하지 않는다.
  • 노쇼 표시: 원장 + 강사.
  • 노쇼 복구: 원장만. 예약을 예정으로 되돌리고 수강권 1회 복구.

기존 자산을 최대한 재사용했다. 상태 전이는 위의 updateStatus, 수강권 복구는 이미 있던 StudentTicketService.restore()(+1, DEPLETED→ACTIVE 자동 전환)를 그대로 썼다.

@Transactional
public void markNoshow(Long academyId, Long bookingId) {
    LessonBooking booking = requireBooking(academyId, bookingId);
    int updated = bookingMapper.updateStatus(bookingId, "NOSHOW", "BOOKED");
    if (updated == 0) throw new BusinessException(ErrorCode.BOOKING_INVALID_STATUS);
    studentTicketService.markNoshow(booking.getStudentTicketId(), bookingId); // 감사 로그(delta=0)
}

@Transactional
public void restoreNoshow(Long academyId, Long bookingId, UserPrincipal principal) {
    if (principal.getRole() != Role.OWNER) throw new BusinessException(ErrorCode.FORBIDDEN);
    LessonBooking booking = requireBooking(academyId, bookingId);
    int updated = bookingMapper.updateStatus(bookingId, "BOOKED", "NOSHOW");
    if (updated == 0) throw new BusinessException(ErrorCode.BOOKING_INVALID_STATUS);
    studentTicketService.restore(booking.getStudentTicketId(), bookingId); // 수강권 +1
}

컨트롤러에 PATCH /{id}/noshow, PATCH /{id}/restore, GET /noshows 세 엔드포인트를 추가했다.

프론트는 4개 화면에 손을 댔다.

  • NoShows: 더미를 걷어내고 getNoshows() 실데이터 + "복구" 버튼 + KPI 실집계.
  • Schedule / Bookings: 지난 시각의 BOOKED 행에 "노쇼" 버튼(isPast() 판별).
  • LessonJournals: 미작성 BOOKED 건에 "노쇼" 버튼.
const isPast = (b: AcademyBooking) =>
  new Date(`${b.lessonDate}T${b.startTime}`).getTime() < Date.now();

문제 정의 / 배경

세 기능 모두 "개념은 있는데 배선이 안 된" 상태였다.

  • 레벨: 테이블·엔티티·조회 API까지 있는데 쓰기 경로 없음.
  • 완료: COMPLETED 상태값은 있는데 아무도 바꿔주지 않음.
  • 노쇼: NOSHOW 상태값 + 더미 화면만 있고 실로직 0.

공통 해법은 기존 자산 재사용 + 최소 배선이었다. 새 테이블/컬럼 없이(스키마 변경 0, 마이그레이션 불필요) 매퍼 메서드와 서비스 몇 개만 추가해 완성했다.


검증 & 배포

  • 백엔드: ./gradlew compileJava, bootJar 성공. EC2에 무중단 절차(stop→좀비 kill→포트 확인→jar 교체→start)로 배포. ping=200, 신규 엔드포인트 401(인증 필요=존재) 확인.
  • 프론트: Node 20에서 npm run build 성공. academy를 /var/www/piano/academy에 배포, chown nginx:nginx. /academy/noshows, /academy/schedule 등 200 확인.
  • 배포는 두 번 나눠 진행했다(레벨+완료 1차, 노쇼 2차).

트러블슈팅 메모 (삽질 기록)

  • Node 16 → 20: 로컬 기본 Node가 16이라 vite build가 node:fs/promises의 constants export 오류로 실패. nvm use 20.20.0으로 해결.
  • 백엔드 좀비 JVM: systemctl restart만 하면 이전 JVM이 8080을 물고 있어 크래시 루프가 난다. stop → pgrep/pkill -9 → 포트 free 확인 → jar 교체 → start 절차를 지켜야 안전. (pkill을 SSH로 실행하면 그 세션이 끊겨 exit 255가 나는데, 정상이다.)
  • 커밋 범위 주의: 두 저장소 모두 내가 안 건드린 다른 작업(악보 액션로그, 결제 링크 등)이 섞여 있었다. git add .로 뭉뚱그리지 않고, 이번 작업 파일만 명시적으로 스테이징해 커밋했다.

결론 / 배운 점

  • **"진실의 원천을 어디 둘 것인가"**가 설계의 핵심이었다. 레벨은 학생에 종속된 progress에, 예약 상태는 booking에. 레슨일지(예약당 N건)에 종속시키지 않아야 삭제/재작성에도 데이터가 안 깨진다.
  • 상태를 DB에서 실제로 바꾸면 그 데이터를 읽는 모든 화면이 공짜로 일관된다. 프론트에서 표시만 우회하는 것보다 근본적이다.
  • 기존 restore(), updateStatus() 같은 원자적 연산을 재사용하니 노쇼 복구 로직을 거의 새로 짜지 않아도 됐다. 조사(Explore)에 시간을 쓴 만큼 구현이 짧아졌다.

댓글 0

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