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

개발일지 2026-06-26

엠포레 피아노 플랫폼 개발일지 (2026-06-26)

개요

오늘은 크게 세 갈래의 작업을 했다.

  1. 학원 삭제를 "완전 삭제(DELETE)"에서 "비활성화(soft delete)"로 전환 — 실수로 지워도 복구 가능하게, 그리고 발송/목록에서 자연스럽게 빠지게.
  2. DB 마이그레이션 자동 반영을 스킬(db-migrate)로 분리 — "DDL 반영해줘"라고 매번 말하지 않아도, 백엔드에 마이그레이션을 만들면 알아서 개발 RDS에 멱등하게 적용되도록.
  3. 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 반영까지 해줘"라는 요청이 나왔고, "그건 매번 말 안 하게 스킬로 빼라"는 피드백을 받았다. 그래서 마이그레이션 자동 반영 스킬을 만들었다.

핵심 설계: 이력 테이블 + 멱등 러너

매번 안전하게 돌리려면 "이미 적용한 파일은 건너뛴다"가 필요하다. 그래서:

  1. schema_migrations(filename PK, applied_at) 이력 테이블을 둔다.
  2. migration-*.sql 중 이력에 없는 파일만 실행한다.
  3. 각 statement 실행 중 중복 적용 에러는 흡수한다(이미 컬럼/인덱스/테이블/PK가 있는 경우). MySQL 에러코드 기준:
    • 1050(Table exists), 1060(Duplicate column), 1061(Duplicate key), 1062(Duplicate entry), 1091(Can't DROP; not exist), 1054(Unknown column) 등.
  4. 파일이 끝나면 이력에 기록 + 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-tasks BUILD 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 의 constants export 에러. 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

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