개발일지 2026-09-01
개발일지 2026-09-01
오늘은 피아노 학원 플랫폼(엠포레)에서 여섯 갈래 작업을 했다. 레슨 일지를 메모 한 칸으로 줄이고, 가입 경로 두 곳에 휴대폰 인증을 붙이고, 자료실에 미리보기를 만들고, 일정 화면의 색 충돌과 조작 방식을 손봤다. 중간에 마이그레이션 재적용으로 운영 데이터가 잘못 바뀐 사고가 하나 있었는데, 그 기록도 남긴다.
수정/생성 파일 목록
백엔드 (piano-backend)
| 경로 | 구분 | 요약 |
|---|---|---|
academy/lesson/LessonJournalService.java | 수정 | 노쇼·무효 예약도 일지(메모) 저장 허용 |
verification/PhoneVerificationService.java | 수정 | 발송 횟수 제한 + requireVerifiedWithin 추가 |
verification/PhoneVerificationMapper.java | 수정 | countRecentSends 추가 |
resources/mapper/PhoneVerificationMapper.xml | 수정 | 최근 발송 건수 조회 쿼리 |
resources/migration-20260831f-phone-verification-signup.sql | 신규 | target_id NULL 허용 |
publicsite/signup/PublicSignupController.java | 수정 | 원장 휴대폰 인증 발송/확인 엔드포인트 |
publicsite/signup/AcademySignupService.java | 수정 | 제출 시 인증 게이트 + 소진 |
publicsite/studentregister/StudentRegisterController.java | 수정 | 학생 연락처 인증 발송/확인 엔드포인트 |
publicsite/studentregister/StudentRegisterService.java | 수정 | 가입 시 인증 게이트 + 소진 |
admin/resource/ResourceFileService.java | 수정 | 자료실 미리보기(페이지 PNG 렌더) |
academy/resource/AcademyResourceFileController.java | 수정 | 학원용 미리보기 엔드포인트 |
admin/resource/ResourceFileAdminController.java | 수정 | 본사용 미리보기 엔드포인트 |
file/FileService.java | 수정 | 범용 getContent(fileId) 추가 |
프론트 (academy)
| 경로 | 구분 | 요약 |
|---|---|---|
lib/lessonMemo.ts | 신규 | 메모 합치기/제목 추출/미리보기 |
components/LessonJournalDialog.tsx | 수정 | 레벨 + 메모 한 칸으로 |
components/LessonManualJournalDialog.tsx | 수정 | 위와 동일 |
pages/LessonJournals.tsx | 수정 | 칼럼 정리 + 노쇼/무효 메모 버튼 |
pages/StudentDetail.tsx | 수정 | 레슨일지 탭 칼럼 정리 |
pages/AcademySignup.tsx | 수정 | 원장 휴대폰 인증 UI |
api/signup.ts | 수정 | 인증 API |
components/ResourcePreviewDialog.tsx | 신규 | 자료실 미리보기 |
api/resources.ts | 수정 | 미리보기 API |
pages/Resources.tsx | 수정 | 미리보기 → 출력 흐름 |
lib/scheduleColors.ts | 신규 | 일정 화면 색 단일 출처 |
components/schedule/WeekTimeGrid.tsx | 수정 | 색 적용 + 클릭 순환 |
pages/Schedule.tsx | 수정 | 버튼 제거 + 클릭 순환 + 범례 정리 |
프론트 (admin / student)
| 경로 | 구분 | 요약 |
|---|---|---|
admin/popups/resources/ResourcePreview.tsx | 신규 | 자료실 관리 미리보기 |
admin/components/PrintModal.tsx | 신규 | academy 것 이식 |
admin/api/resourceFiles.ts | 수정 | 미리보기 API |
admin/pages/ResourceFiles.tsx | 수정 | 파일명 클릭 → 미리보기 |
admin/lib/popup/registry.ts | 수정 | 팝업 등록 |
student/pages/RegisterPage.tsx | 수정 | 연락처 인증 UI |
student/api/auth.ts | 수정 | 인증 API |
문서
| 경로 | 구분 | 요약 |
|---|---|---|
docs/DEPLOY.md | 수정 | brand 루트 sync exclude 에 downloads/* |
print-agent/README.md | 수정 | 설치 파일 배포를 EC2 → S3 로 |
print-agent/BUILD.md | 수정 | 배포 위치 안내 정정 |
1. 레슨 일지를 메모 한 칸으로
배경
레슨 일지 작성 폼에 제목·학습 내용·피드백·다음 과제·연습 곡목 다섯 칸이 있었다. 현장에서는 이걸 다 채우지 않는다. 레벨만 두고 나머지는 메모 하나로 합쳐 달라는 요청.
걸림돌 — title 은 NOT NULL
lesson_notes.title 이 varchar(120) NOT NULL 이고 목록 칼럼에도 노출된다. 제목 칸을 없앤다고 컬럼을 비울 수는 없다. 메모 첫 줄을 제목으로 저장하는 방식으로 풀었다.
export const memoTitle = (memo: string): string => {
const first = memo
.split('\n')
.map((v) => v.trim())
.find((v) => v.length > 0);
return (first ?? '레슨 일지').slice(0, TITLE_MAX);
};
기존 데이터를 잃지 않기
이미 다섯 칸이 채워진 일지가 20건 있었다. 폼만 바꾸면 다음 수정 때 나머지 네 칸이 조용히 지워진다. 그래서 일지를 열 때 합쳐서 보여준다.
export const composeMemo = (journal) => {
const parts: string[] = [];
if (title && title !== summary) parts.push(title);
if (summary) parts.push(summary);
if (feedback) parts.push(`[피드백] ${feedback}`);
if (tasks.length) parts.push(`[다음 과제] ${tasks.join(', ')}`);
if (pieces.length) parts.push(`[연습 곡목] ${pieces.join(', ')}`);
return parts.join('\n').slice(0, MEMO_MAX);
};
목록 칼럼이 중복이던 문제
배포 후 "목록에 왜 학습내용이 보이지"라는 지적을 받았다. 열어 보니 헤더가 「학습 내용」인데 내용은 j.title 을 그리고 있었다. 제목 = 메모 첫 줄이 되면서 옆의 「메모」 칼럼과 같은 내용이 두 번 나온 것. 칼럼을 하나로 합치고 미리보기도 composeMemo 로 통일했다.
노쇼·무효도 메모 가능하게
서버가 이 상태를 아예 막고 있었다.
// 노쇼/취소 예약은 레슨이 진행되지 않았으므로 일지를 작성할 수 없다.
if ("NOSHOW".equals(booking.getStatus()) || "CANCELLED".equals(booking.getStatus())) {
throw new BusinessException(ErrorCode.BOOKING_INVALID_STATUS);
}
이 가드를 없앴다. 상태가 뒤집힐 걱정은 없었는데, 저장 뒤 상태 변경이 원래부터 기대 상태를 명시한 조건부 업데이트였기 때문이다.
bookingMapper.updateStatus(bookingId, "COMPLETED", "BOOKED"); // BOOKED 일 때만 완료로
노쇼 예약은 BOOKED 가 아니므로 그대로 노쇼로 남는다. 목록 조회도 탭별로 status='NOSHOW' 를 걸어 두어 메모를 써도 계속 노쇼 탭에 있다.
2. 가입 경로 두 곳에 휴대폰 인증
학원 가입 신청(원장)과 학생 회원가입에 인증번호 확인을 붙였다. 승인되면 그 번호로 계정이 만들어지고 알림이 나가므로 실사용 번호여야 한다.
공용 서비스는 이미 있었다
PhoneVerificationService 가 비밀번호 찾기·OTP 로그인에서 쓰이고 있었다. 재사용하되 두 가지가 부족했다.
(1) 인증번호는 3분인데 신청서는 그보다 오래 걸린다. 인증을 끝낸 사실을 따로 인정하는 메서드를 더했다.
public PhoneVerification requireVerifiedWithin(String phone, String purpose, int withinMinutes) {
PhoneVerification entity = verificationMapper.findLatest(phone, purpose);
if (entity == null || !entity.isVerified()) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "휴대폰 인증을 먼저 완료해주세요.");
}
if (entity.getCreatedAt() == null
|| entity.getCreatedAt().isBefore(KstClock.now().minusMinutes(withinMinutes))) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "휴대폰 인증이 만료되었습니다. 인증을 다시 받아주세요.");
}
return entity;
}
(2) 로그인 없이 문자를 쏘는 경로가 생긴다. 기존 경로는 "이미 존재하는 계정과 이름·전화가 일치할 때"만 발송했지만, 가입은 그런 게이트가 없다. 번호별 발송 제한을 넣었다.
if (verificationMapper.countRecentSends(phone, purpose, SEND_WINDOW_MINUTES) >= SEND_LIMIT_PER_WINDOW) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"인증번호를 너무 자주 요청했습니다. " + SEND_WINDOW_MINUTES + "분 후 다시 시도해주세요.");
}
스키마 한 줄
가입 시점에는 아직 계정이 없어 target_id 에 넣을 PK 가 없는데 컬럼이 NOT NULL 이었다.
ALTER TABLE phone_verifications
MODIFY COLUMN target_id BIGINT NULL COMMENT 'users.id 또는 admin_users.id (가입 인증은 대상 계정이 없어 NULL)';
프론트 규칙
번호를 고치면 인증이 풀리게 했다. 인증한 번호와 제출하는 번호가 어긋나면 게이트가 무의미하다. 학원 가입 화면은 「대표자와 동일」 체크로도 원장 번호가 바뀌므로 그 경로에도 해제를 걸었다.
문자를 받아야 하므로 01 로 시작하는 번호만 인증되게 했다(학원 대표번호는 유선일 수 있어 인증 대상이 아니다).
검증
문자를 실제로 쏘지 않고 확인하려고, DB 에 인증 레코드를 직접 넣고 API 를 호출했다.
| 확인 | 결과 |
|---|---|
| 잘못된 번호 발송 | 휴대폰 번호를 정확히 입력해주세요. |
| 발송 전 코드 확인 | 인증번호를 먼저 요청해주세요. |
| 틀린 코드 | 인증번호가 일치하지 않습니다. |
| 맞는 코드 | {"ok":true} |
| 미인증 번호로 제출 | 휴대폰 인증을 먼저 완료해주세요. |
3. 자료실 미리보기 — 처음엔 틀리게 만들었다
1차 시도 (실패)
자료실 파일을 확인하고 출력하도록 미리보기를 만들었다. S3 원본이 Content-Disposition: attachment 라 브라우저가 열지 못해서, 서버가 inline 으로 다시 내려주고 프론트가 blob 으로 받아 <iframe> 에 띄웠다.
$ curl -I .../sample.pdf
Content-Disposition: attachment ← 그대로 쓰면 다운로드가 시작됨
Content-Type: application/pdf
동작은 했다. 그런데 지적을 받았다 — "출력을 딱 우리 프린터 프로그램으로만 해야 되는데".
맞는 지적이었다. <iframe> 에 PDF 를 띄우면 브라우저 뷰어의 인쇄·저장 버튼이 함께 열린다. 원본 PDF 가 브라우저로 통째로 넘어가는 것도 문제였다. 이 서비스는 인쇄를 로컬 Print Agent 로 모으는 게 요건인데 정면으로 어긋난다.
2차 — 악보 미리보기 방식을 따랐다
같은 저장소의 악보사이트가 이미 답을 갖고 있었다. 서버가 페이지 이미지를 만들어 <img> 로만 보여준다. 뷰어 자체가 없으니 인쇄 버튼도 없다.
원본을 내려주던 엔드포인트는 지우고 두 개로 나눴다.
| 엔드포인트 | 역할 |
|---|---|
/{id}/preview | 삭제 — 원본 PDF 통째 전달 |
/{id}/preview/info | 형태(PDF/IMAGE/NONE)와 페이지 수 |
/{id}/preview/page/{page} | 해당 페이지 PNG 1장 |
PDFBox 는 이미 의존성에 있었다(악보 미리보기 생성에 쓰고 있었다).
대형 PDF 함정
고정 DPI 로 렌더했더니 실제 자료(대형 POP, 2551×1077pt)가 4252×1796px 로 나왔다. 페이지 크기를 보고 DPI 를 낮추게 했다.
private static float dpiFor(PDDocument doc, int pageIndex) {
var box = doc.getPage(pageIndex).getMediaBox();
float longSidePt = Math.max(box.getWidth(), box.getHeight());
if (longSidePt <= 0) return PREVIEW_DPI;
return Math.min(PREVIEW_DPI, 72f * PREVIEW_MAX_PX / longSidePt);
}
2551×1077pt → DPI 56 → 2000×845px → PNG 0.80MB
A4 서식은 120DPI 그대로다.
배운 점
"미리보기"라는 단어만 보고 가장 쉬운 구현(iframe)을 골랐는데, 이 제품에서 미리보기가 왜 필요한지(출력 전 확인 → 출력은 Print Agent 로만)를 먼저 봤어야 했다. 같은 저장소에 이미 같은 제약을 푼 코드가 있었던 것도 뼈아프다. 새 걸 만들기 전에 검색하라는 규칙이 괜히 있는 게 아니다.
4. 사고 기록 — 마이그레이션 재적용으로 운영 데이터가 바뀌었다
무슨 일이
migration-20260831f 를 적용하려고 db-migrate 러너를 돌렸다. 러너는 schema_migrations 이력에 없는 파일을 실행한다. 그런데 앞선 6건이 이력에 없었다 — 이전 세션에서 손으로 적용했기 때문이다. 그래서 7건이 한꺼번에 실행됐다.
TOTAL files : 217
SKIPPED(기적용) : 210
APPLIED(신규) : 7 ← 1건만 기대했는데 7건
DDL 은 중복 에러를 흡수해서 무해했지만, DML 이 섞인 파일 하나가 문제였다.
-- migration-20260831d
UPDATE signup_fees SET archived = 1 WHERE active = 0 AND archived = 0;
이 구문이 다시 돌면서 비활성 플랜 2건(「가입비 B」·「가입비C」)을 이력으로 내려버렸다. 관리 화면에 플랜이 1개만 남는 상태.
어떻게 알았나
러너 결과에 "APPLIED 7건"이 찍힌 걸 보고, 그 7개 파일에 DML 이 있는지 바로 확인했다.
grep -l "UPDATE" migration-2026083*.sql
5개가 걸렸고, 그중 20260831d 의 UPDATE 가 현재 데이터에 파괴적이라는 걸 확인한 뒤 실제 테이블을 조회해 피해를 특정했다.
복구
먼저 현재 상태를 통째로 스냅샷으로 떠 두고(db-backup/restore-migration-rerun-20260831.sql) 되돌렸다. 예약 booking_type 도 같이 확인했는데, 그쪽 마이그레이션은 판정 규칙이 데이터에만 의존해서 재실행해도 같은 결과가 나왔다(레슨 42 · 상담 7 · 체험 4).
교훈
- 멱등한 DDL 과 멱등하지 않은 DML 을 한 파일에 섞지 말 것.
IF NOT EXISTS로 감싼 DDL 은 몇 번 돌려도 안전하지만,WHERE active = 0같은 조건부 UPDATE 는 "지금 데이터"에 따라 결과가 달라진다. - 손으로 적용했으면 이력에도 남겨야 한다. 이력 테이블이 곧 안전장치인데, 우회하면 다음 사람(또는 다음 나)이 그 대가를 치른다.
- 러너의 "APPLIED N건"을 무심히 넘기지 말 것. 기대한 숫자와 다르면 멈추고 확인.
5. Print Agent 설치 파일 링크가 죽어 있었다
자료실 출력 작업을 하다가 설치 파일 URL 을 눌러 봤더니 403 이었다.
브라우저 → mforet.kr/downloads/PrintAgentSetup.exe
↓ CloudFront
/api/* → ALB → EC2 ← ALB 로 보내는 건 이것뿐
그 외 → S3 (프론트 버킷) ← /downloads/ 가 여기로 감
↓
객체 없음 → 403
파일은 EC2 두 대의 /var/www/piano/downloads/ 에 멀쩡히 있었다. 프론트가 nginx → S3+CloudFront 로 옮겨지면서 이 경로만 따라오지 못한 것. print-agent/README.md 의 배포 절차도 scp 로 EC2 에 올리는 옛 방식이라, 그대로 따라 해도 계속 안 열린다.
EC2 원본을 받아 S3 에 올리고 체크섬으로 확인했다.
aws s3 cp PrintAgentSetup.exe $B/downloads/PrintAgentSetup.exe $P \
--content-type "application/octet-stream" \
--content-disposition 'attachment; filename="PrintAgentSetup.exe"'
한 가지 함정이 더 있다. brand 앱은 버킷 루트로 --delete sync 를 한다. 다음 brand 배포 때 downloads/ 가 통째로 지워진다. 문서 세 곳(DEPLOY.md, print-agent/README.md, BUILD.md)에 exclude 와 이유를 적어 뒀다.
악보사이트·학원·본사 세 앱이 모두 이 URL 을 쓰므로 한 번에 살아났다.
6. 일정 화면 — 색 충돌과 조작 방식
색이 겹쳤다
"상담, 노쇼랑 국가공휴일 색상이 겹침"이라는 지적. 열어 보니 지적보다 넓었다.
상담 #e67e22 ┐ 같은 주황 (ΔE 11.6 — 사실상 구분 불가)
국가공휴일 #f57c00 ┘
노쇼 · 취소 · 학원휴무일 · 국가공휴일 → 범례에서 전부 #e35d5b 한 색
범례 자체에도 문제가 있었다.
- 「체험 레슨」이 똑같이 두 줄
- 「노쇼」는 범례에 없음
- 「학원 휴무일」이 월간
#e35d5b, 주간#e0e0e0— 뷰마다 다른 색
원인은 명확했다. 색을 화면마다 직접 적어 뒀다. 그리드와 두 범례가 각자 값을 들고 있으니 한쪽만 고쳐지고 어긋난다.
lib/scheduleColors.ts 한 곳에서만 정하고 모두가 참조하게 바꿨다. 계열이 겹치지 않게 나눴다.
| 항목 | 색 | |
|---|---|---|
| 레슨(예정) | #1a1a2e | 남색 |
| 레슨(완료) | #9e9e9e | 회색 |
| 상담 | #7048a8 | 보라 ← 주황에서 변경 |
| 체험 | #00897b | 청록 |
| 노쇼 | #d32f2f | 빨강 (일정 화면에서 빨강은 여기만) |
| 취소 | #8d6e63 | 갈색 |
| 학원 휴무일 | #455a64 | 슬레이트 |
| 국가 공휴일 | #1565c0 | 파랑 |
| 추가 오픈 | #2e7d32 | 초록 |
눈으로 "달라 보인다"고 판단하지 않고 CIELAB ΔE 로 검산했다. 가장 가까운 두 색이 11.6 → 22.0 으로 벌어졌다.
조작을 클릭 한 번으로
원래는 상단의 「레슨 차단」·「추가 오픈」 버튼으로 모드를 켜고 칸을 눌러야 했다. 이걸 없애고 빈 칸을 누를 때마다 순환하게 바꿨다.
빈 칸 클릭 → 추가 오픈 → 레슨 차단 → 없음 → (반복)
서버는 손댈 필요가 없었다. 오픈↔차단 전환이 이미 "같은 시각의 반대 타입을 지우고 넣는" 방식이라, create(BLOCK) 한 번이면 OPEN 이 알아서 빠진다.
// 같은 강사·날짜·시각의 반대 type 제거(BLOCK↔OPEN 모순 방지 = 토글 전환)
overrideMapper.deleteOpposite(req.teacherId(), date, req.startTime(), type);
hover 테두리 색으로 다음에 무엇이 될지 미리 알리게 했다.
const nextColor = !ov
? SCHEDULE_COLORS.open // 비어 있음 → 추가 오픈(초록)
: ov.type === 'OPEN'
? SCHEDULE_COLORS.noshow // 추가 오픈 → 레슨 차단(빨강)
: '#bbb'; // 레슨 차단 → 해제(회색)
함께 푼 제한 하나. 원래 근무시간 안의 빈 칸은 추가 오픈이 막혀 있었다(이미 예약 가능한 시간이라). 버튼이 없어진 지금 그 제한을 남기면 어떤 칸은 첫 클릭이 먹고 어떤 칸은 안 먹어서 규칙이 두 개가 된다. 모든 빈 칸이 같은 순서로 돌도록 제한을 없앴다.
그리고 런타임 에러를 냈다
배포 직후 제보를 받았다.
Uncaught ReferenceError: setMode is not defined
모드 상태를 지우면서 초기화 한 줄을 남겼다.
if (teacherId == null) {
setWorkSchedules([]);
setMode(null); // ← 남음
return;
}
Vite 빌드는 타입 검사를 하지 않는다. npm run build 가 통과했다고 안심한 게 잘못이었다. ESLint 도 TS 프로젝트에서는 no-undef 를 끄는 게 보통이라 안 걸린다. 고친 뒤 tsc --noEmit 으로 미정의 참조가 더 없는지 확인했다.
npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E "Cannot find name|TS2304|TS2552"
상태나 변수를 제거하는 변경에서는 빌드 통과 ≠ 안전이다. 심볼을 지웠으면 전체 검색으로 잔재를 확인하는 습관이 필요하다.
검증 & 배포
백엔드는 ALB 뒤 EC2 2대를 순차로 교체하는 무중단 배포를 썼다. 배포 중 0.5초 간격으로 헬스체크를 때리는 프로브를 함께 돌렸다.
| 배포 | 요청 | 실패 |
|---|---|---|
| 노쇼 메모 | 329 | 0 |
| 학원 가입 인증 | 313 | 0 |
| 학생 가입 인증 | 304 | 0 |
| 자료실 미리보기 | 322 | 0 |
| 미리보기 이미지 전환 | 328 | 0 |
| admin 미리보기 | 328 | 0 |
배포가 실제로 반영됐는지는 운영 컨테이너의 클래스 파일을 직접 열어 확인했다.
sudo docker cp piano-api:/app/app.jar /tmp/dep.jar
python3 -c "
import zipfile
z = zipfile.ZipFile('/tmp/dep.jar')
c = z.read('BOOT-INF/classes/.../AcademyResourceFileController.class')
print('원본 inline 엔드포인트 남아있나:', b'inline' in c)
print('preview/info:', b'/{id}/preview/info' in c)
"
프론트는 매번 배포 전에 localhost:8080 하드코딩 스캔을 통과시켰다(빌드 산출물에 dev URL 이 박히면 운영 로그인이 통째로 죽는다).
결론 / 배운 점
오늘 가장 값진 건 기능이 아니라 세 번의 실수였다.
- 미리보기를 iframe 으로 만든 것 — 기능 이름만 보고 구현을 골랐다. 제품에서 그 기능이 왜 필요한지, 같은 저장소에 이미 답이 있는지 먼저 봤어야 했다.
- 마이그레이션 재적용 — 이력을 우회한 과거의 편의가 오늘 운영 데이터를 건드렸다. 멱등하지 않은 DML 은 DDL 과 같은 파일에 두면 안 된다.
- setMode 잔재 — Vite 빌드 통과를 안전 신호로 착각했다. 심볼을 지우는 변경은 전체 검색과
tsc로 확인해야 한다.
색 문제도 결국 같은 뿌리였다. 같은 값을 여러 곳에 적어 두면 반드시 어긋난다. 색이든 URL 이든 마이그레이션 이력이든, 한 사실은 한 곳에서만 관리해야 한다.
댓글 0
- 첫 번째 댓글을 남겨보세요.