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

개발일지 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초 간격으로 헬스체크를 때리는 프로브를 함께 돌렸다.

배포요청실패
노쇼 메모3290
학원 가입 인증3130
학생 가입 인증3040
자료실 미리보기3220
미리보기 이미지 전환3280
admin 미리보기3280

배포가 실제로 반영됐는지는 운영 컨테이너의 클래스 파일을 직접 열어 확인했다.

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 이 박히면 운영 로그인이 통째로 죽는다).


결론 / 배운 점

오늘 가장 값진 건 기능이 아니라 세 번의 실수였다.

  1. 미리보기를 iframe 으로 만든 것 — 기능 이름만 보고 구현을 골랐다. 제품에서 그 기능이 왜 필요한지, 같은 저장소에 이미 답이 있는지 먼저 봤어야 했다.
  2. 마이그레이션 재적용 — 이력을 우회한 과거의 편의가 오늘 운영 데이터를 건드렸다. 멱등하지 않은 DML 은 DDL 과 같은 파일에 두면 안 된다.
  3. setMode 잔재 — Vite 빌드 통과를 안전 신호로 착각했다. 심볼을 지우는 변경은 전체 검색과 tsc 로 확인해야 한다.

색 문제도 결국 같은 뿌리였다. 같은 값을 여러 곳에 적어 두면 반드시 어긋난다. 색이든 URL 이든 마이그레이션 이력이든, 한 사실은 한 곳에서만 관리해야 한다.

댓글 0

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