개발일지 2026-06-26
엠포레 피아노 플랫폼 개발일지 (2026-06-26)
개요
오늘은 크게 세 갈래의 작업을 했다.
- 학원 삭제를 "완전 삭제(DELETE)"에서 "비활성화(soft delete)"로 전환 — 실수로 지워도 복구 가능하게, 그리고 발송/목록에서 자연스럽게 빠지게.
- DB 마이그레이션 자동 반영을 스킬(
db-migrate)로 분리 — "DDL 반영해줘"라고 매번 말하지 않아도, 백엔드에 마이그레이션을 만들면 알아서 개발 RDS에 멱등하게 적용되도록. - SMS 발송 고도화 — 학원 직접 선택 팝업, 변수 치환(
{1}~{10}/#{변수명}), 예약 발송(scheduledAt), 동일 번호 중복 발송 허용 등.
이 글은 그중 ①②를 중심으로 정리한다. (SMS 쪽은 별도 흐름이 길어 요약만.)
수정/생성 파일 목록
백엔드 (piano-backend)
.../academy/Academy.java(수정) —active필드 추가.../academy/AcademyMapper.java(수정) —search에includeInactive파라미터,delete→deactivate/activate.../resources/mapper/AcademyMapper.xml(수정) — resultMap/where에 active 반영, soft delete 쿼리, SMS 쿼리에active=1필터.../academy/AcademyService.java(수정) —search(..., includeInactive),deactivate/activate.../admin/AcademyAdminController.java(수정) —includeInactive쿼리파라미터,DELETE→비활성화,POST /{id}/activate추가.../resources/schema.sql(수정) — academies에active컬럼 + 인덱스 (신규 환경 SSOT).../resources/migration-20260626-academy-active.sql(신규) — 증분 DDL.../admin/sms/dto/SmsSendRequest.java(수정) —scheduledAt필드.../admin/sms/SmsSendService.java(수정) — 예약 발송 시 RESERVED 로그 적재
프론트 (admin)
.../api/academies.ts(수정) —active/includeInactive,deleteAcademy→deactivateAcademy+activateAcademy.../pages/Academies.tsx(수정) — 비활성 chip, 비활성/복원 액션, "비활성 포함" 필터 체크박스.../api/smsSend.ts(수정) —scheduledAt필드.../pages/SmsSend.tsx(수정) — 예약 발송 UI(날짜/시간 → scheduledAt 전송)
도구 / 스킬
~/.claude/skills/db-migrate/SKILL.md(신규) — DB 마이그레이션 자동 반영 스킬~/.claude/skills/db-migrate/runner.py(신규) — 멱등 마이그레이션 러너
1. 학원 비활성화(soft delete) 풀스택
배경 / 문제 정의
기존 학원 삭제는 DELETE FROM academies WHERE id = ? 였다. 운영 중 실수로 지우면 복구가 불가능하고, 연관 데이터(발송 이력 등)와의 정합성도 깨질 위험이 있었다. 그래서 "지우는 대신 숨긴다"는 방향으로 바꿨다.
설계 결정(상의 후 확정):
- 방식: 하드 삭제 대신 별도
active컬럼(1=활성, 0=비활성). - 목록 노출: 기본은 비활성 숨김,
includeInactive필터로 포함 조회.
1-1. 엔티티 — Academy.java
private LocalDateTime reviewedAt;
private Boolean active; // 1=활성, 0=비활성(soft delete). 비활성은 기본 목록에서 숨김
private LocalDateTime createdAt;
1-2. 매퍼 인터페이스 — AcademyMapper.java
search에 includeInactive를 받고, delete 대신 deactivate/activate를 둔다.
List<Academy> search(..., @Param("agencyId") Long agencyId,
@Param("includeInactive") boolean includeInactive);
int deactivate(@Param("id") Long id);
int activate(@Param("id") Long id);
1-3. 매퍼 XML — AcademyMapper.xml
resultMap·컬럼에 active를 추가하고, 검색 where에 비활성 숨김 조건을 넣는다.
<result property="active" column="active"/>
...
<if test="!includeInactive">AND a.active = 1</if>
하드 delete를 soft update로 교체:
<update id="deactivate">UPDATE academies SET active = 0 WHERE id = #{id}</update>
<update id="activate">UPDATE academies SET active = 1 WHERE id = #{id}</update>
그리고 SMS 발송 대상 조회 쿼리 4개(활성 번호/ID목록/발송대상 등)에도 AND active = 1을 붙였다. 비활성 학원은 문자도 안 가게.
1-4. 서비스 — AcademyService.java
public List<Academy> search(..., Long agencyId, boolean includeInactive) {
return academyMapper.search(..., agencyId, includeInactive);
}
// soft delete: 완전 삭제 대신 active=0
@Transactional
public void deactivate(Long id) { get(id); academyMapper.deactivate(id); }
@Transactional
public Academy activate(Long id) { get(id); academyMapper.activate(id); return academyMapper.findById(id); }
1-5. 컨트롤러 — AcademyAdminController.java
DELETE는 의미를 유지하되 내부적으로 비활성화하고, 복원용 POST /{id}/activate를 추가했다.
@DeleteMapping("/{id}")
public ApiResponse<Void> deactivate(@PathVariable Long id) {
academyService.deactivate(id); return ApiResponse.ok();
}
@PostMapping("/{id}/activate")
public ApiResponse<Academy> activate(@PathVariable Long id) {
return ApiResponse.ok(academyService.activate(id));
}
1-6. 프론트 API — academies.ts
export const deactivateAcademy = async (id: number): Promise<void> => {
await client.delete(`/admin/academies/${id}`);
};
export const activateAcademy = async (id: number): Promise<Academy | null> => {
const { data } = await client.post<ApiResult<Academy>>(`/admin/academies/${id}/activate`);
return data.data ?? null;
};
1-7. 프론트 화면 — Academies.tsx
- 휴지통 아이콘(DeleteIcon)을 비활성(BlockIcon)/복원(RestoreIcon)으로 교체.
- 비활성 학원은 이름 옆에
비활성chip + 흐린 색. - "비활성 포함" 체크박스로 즉시 재조회.
<FormControlLabel
control={<Checkbox size="small" checked={!!search.includeInactive}
onChange={(e) => {
const next = { ...search, includeInactive: e.target.checked || undefined };
setSearch(next); fetchData(next);
}} />}
label="비활성 포함" />
1-8. 스키마 / 마이그레이션
신규 환경용 SSOT인 schema.sql에는 컬럼+인덱스를 넣고, 증분 적용용 마이그레이션을 따로 만들었다.
-- migration-20260626-academy-active.sql
ALTER TABLE academies
ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1 AFTER reviewed_at;
CREATE INDEX idx_academies_active ON academies (active);
2. DB 마이그레이션 자동 반영 스킬 (db-migrate)
배경 / 문제 정의
실DB(RDS)는 sql.init.mode=never라서 schema.sql/migration-*.sql이 앱 기동 시 자동 실행되지 않는다. 즉 마이그레이션을 만들어도 사람이 직접 RDS에 적용해야 한다. 게다가 RDS는 private이라 EC2 경유 SSH 터널을 먼저 열어야 접속된다.
오늘 학원 비활성화 작업을 하면서 "DDL 반영까지 해줘"라는 요청이 나왔고, "그건 매번 말 안 하게 스킬로 빼라"는 피드백을 받았다. 그래서 마이그레이션 자동 반영 스킬을 만들었다.
핵심 설계: 이력 테이블 + 멱등 러너
매번 안전하게 돌리려면 "이미 적용한 파일은 건너뛴다"가 필요하다. 그래서:
schema_migrations(filename PK, applied_at)이력 테이블을 둔다.migration-*.sql중 이력에 없는 파일만 실행한다.- 각 statement 실행 중 중복 적용 에러는 흡수한다(이미 컬럼/인덱스/테이블/PK가 있는 경우). MySQL 에러코드 기준:
- 1050(Table exists), 1060(Duplicate column), 1061(Duplicate key), 1062(Duplicate entry), 1091(Can't DROP; not exist), 1054(Unknown column) 등.
- 파일이 끝나면 이력에 기록 + commit.
이렇게 하면 사람이 손으로 이미 적용한 DDL이 섞여 있어도, 또는 같은 스킬을 두 번 돌려도 안전하다.
# runner.py 핵심 발췌
DUP_OK_CODES = {1050, 1054, 1060, 1061, 1062, 1091, 1826}
for stmt in split_statements(sql_text):
try:
cur.execute(stmt)
except pymysql.err.MySQLError as e:
code = e.args[0] if e.args else None
if code in DUP_OK_CODES:
absorbed += 1
continue # "이미 반영됨"으로 간주
conn.rollback()
raise
cur.execute("INSERT INTO schema_migrations (filename) VALUES (%s)", (fname,))
conn.commit()
보안 / 운영 가드
- DB 비번은
공통/adminPassword에서 읽어 환경변수로만 주입, 평문 출력 금지. - EC2가 꺼져 있으면(00:00~10:00 자동정지) 임의로 켜지 않고 사용자에게 알리고 중단(비용 발생 액션).
- 개발 RDS(
piano_dev) 전용 — 운영(prod)은 제외. schema.sql은 전체 재생성 위험이 있어 이 스킬이 실행하지 않는다. 증분은 반드시migration-*.sql로.
3. SMS 발송 고도화 (요약)
- 변수 치환: 번호형
{1}~{10}+ 이름형#{변수명}동시 지원, 템플릿 변수 유효성 검증. - 학원 직접 선택 팝업: 행 클릭으로 추가/선택 표시, hover 스타일 강화.
- 예약 발송: 프론트에서 날짜/시간 선택 →
scheduledAt(ISO) 전송. 백엔드는scheduledAt이 있으면 즉시 발송 대신RESERVED상태로 발송 이력을 적재하고, 스케줄러가 매 정각/30분에 처리. - 동일 번호 중복 발송 허용: 같은 번호여도 학원별 개별 API 호출(duplicateFlag Y), 발송 이력은 수신자별 1건씩.
// SmsSendService — 예약 분기
if (req.scheduledAt() != null && !req.scheduledAt().isBlank()) {
LocalDateTime scheduled = LocalDateTime.parse(req.scheduledAt());
for (Academy a : targets) {
messageLogService.create(MessageLog.builder()
.channel("SMS").recipient(a.getName()).recipientPhone(a.getPhone())
.contentSummary(summary).content(content)
.status("RESERVED").targetMode(req.targetMode())
.scheduledAt(scheduled).build());
}
return new SmsSendResult(targets.size(), messageType, "RESERVED");
}
4. 검증 & 배포
빌드
- admin:
npx tsc --noEmit통과, Vite 프로덕션 빌드 성공(Node 20). - backend:
./gradlew compileJava --rerun-tasksBUILD SUCCESSFUL.
DB 반영 (db-migrate 러너)
- 1차 실행: 마이그레이션 10건 적용. 이미 손으로 적용됐던 것들은 중복 에러를 흡수하고 이력만 기록.
- 예:
migration-20260626-academy-active.sql (중복2흡수)— 컬럼/인덱스가 이미 있어 2건 흡수.
- 예:
- 2차 실행: 10건 전부 SKIP — 멱등성 확인.
- 검증:
SHOW COLUMNS FROM academies LIKE 'active'→tinyint(1) NOT NULL DEFAULT 1존재, 데이터 3건 전부 활성.
5. 트러블슈팅 메모 (삽질 기록)
- Vite 빌드가 Node 16에서 깨짐:
node:fs/promises의constantsexport 에러. Vite는 Node 18+ 필요. →nvm use 20.20.0로 해결. nvm 소싱 시$NVM_DIR가 비어 절대경로(source /Users/.../.nvm/nvm.sh)로 우회. - StatusChip이 sx prop 미지원:
<StatusChip sx={...}>로 썼다가 안 먹음. defaultColorMap이 이미비활성→default라 sx/colorMap 제거하고 Box로 감싸 간격 처리. - gradlew compileJava가 UP-TO-DATE로 변경 미반영:
--rerun-tasks로 강제 재빌드. - Edit "File has not been read yet": 편집이 read 캐시를 무효화해서, 이어지는 편집 전엔 해당 구간을 다시 read.
6. 결론 / 배운 점
- soft delete는 단순히 컬럼 하나가 아니다: 목록 기본 필터, 발송 대상 쿼리, 복원 엔드포인트, UI 표기/액션까지 한 흐름으로 다뤄야 일관된다. 특히 "비활성이면 문자도 안 간다"처럼 도메인 부수효과 쿼리를 빠뜨리지 않는 게 중요.
- 반복되는 운영 작업은 스킬로: "DDL 반영"처럼 매번 똑같이 하는 일은 멱등 러너 + 이력 테이블로 자동화하면, 손으로 적용했든 안 했든 안전하게 수렴한다. 멱등성은 "중복 에러 흡수 + 적용 이력"이라는 두 축으로 보장했다.
댓글 0
- 첫 번째 댓글을 남겨보세요.