개발일지 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 JOINacademy/lesson/LessonJournalService.java(수정) — 일지 저장/삭제 시 예약 상태 전이academy/booking/LessonBooking.java(수정) — ticketName join 필드academy/booking/LessonBookingMapper.java+mapper/LessonBookingMapper.xml(수정) —updateStatus(조건부 전이),findNoshowsForAcademyacademy/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타입 +updateStudentLevelapi/bookings.ts(수정) —getNoshows/markBookingNoshow/restoreBookingNoshowpages/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의constantsexport 오류로 실패.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
- 첫 번째 댓글을 남겨보세요.