연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈

한 고객사에서 연락처 010-1234-5678 을 입력하면 BE 400, 01012345678 을 입력하면 통과되는 비대칭이 보고됐다. DB 를 열어 보니 같은 컬럼에 *하이픈 포함* 과 *숫자만* 이 섞여 누적돼 있었다. 진짜 범인은 *저장 포맷·입력 포맷·표시 포맷 세 축의 표준이 어디에도 없었던 점* 이었다. BE `phone.util.ts` + DTO `@Transform/@Matches` + FE zod `transform/refine` + 마이그레이션 SQL 을 *한 머지에 묶어* BE·FE·DB 세 곳을 동시에 정규화한 트러블슈팅을 정리한다.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • 증상같은 입력란 인데 010-1234-5678BE 400 거절, 01012345678통과. DB 컬럼에는 두 포맷이 섞여 누적된 채로 운영 중.
  • 표면 가설 3 건 (모두 부분 진단) — BE 정규식만 추가 / FE 입력 마스크만 추가 / 신규 데이터만 정규화. 세 가설 모두 한 축만 본 부분 해법.
  • 진짜 범인저장 포맷 / 입력 포맷 / 표시 포맷 세 축의 표준이 어디에도 명문화되어 있지 않았던 점. BE DTO 는 @IsString() 만, FE 는 placeholder 만, Seed 는 '010-1234-5678' 하드코딩, 마이그레이션 정책은 부재.
  • 해결저장은 숫자만 11 자 / 입력은 자유 / 표시는 formatPhone 세 축을 명문화 한 뒤, BE phone.util.ts + DTO @Transform → @Matches + FE zod transform → refine + 기존 데이터 정규화 SQL 을 한 머지 에 묶었다.
  • 재발 방지 3 가드 — BE DTO @Transform저장 직전 정규화 강제 + FE zod transform/refine제출 직전 정규화 강제 + Seed '010-1234-5678' 하드코딩 제거.

🌱 배경 — 입력 한 줄 차이로 운영이 갈렸다

운영 한 달이 지난 시점에서 한 고객사 운영자로부터 회원 등록어떤 회원은 되고 어떤 회원은 안 된다 는 보고가 들어왔다. 같은 화면, 같은 운영자, 같은 시점에서 입력 방식만 달랐다.

  • 운영자 A 가 010-1234-5678하이픈 포함 입력 → BE 400 “유효한 전화번호를 입력하세요”
  • 운영자 B 가 01012345678숫자만 입력 → 정상 등록

두 운영자 모두 같은 회원 정보 를 등록하려던 케이스였고, 한 명은 평소 습관대로 하이픈을 찍었고 다른 한 명은 키패드로 숫자만 쳤다. 화면에 placeholder 가 '010-1234-5678' 로 떠 있었다는 점이 하이픈을 찍어도 된다는 신호 로 작동했지만, 백엔드 검증은 하이픈을 거절하고 있었다.

DB 를 직접 열어 봤더니 상황은 더 안 좋았다.

-- 운영 DB read replica — 누적된 phone 포맷 분포
SELECT
  CASE
    WHEN "parentPhone" ~ '^\d{10,11}$'       THEN 'digits_only'
    WHEN "parentPhone" ~ '^\d{2,3}-\d{3,4}-\d{4}$' THEN 'hyphen_standard'
    WHEN "parentPhone" ~ '\D'                THEN 'mixed_other'
    ELSE 'unknown'
  END AS format_kind,
  COUNT(*) AS rows
FROM students
WHERE "parentPhone" IS NOT NULL
GROUP BY 1
ORDER BY rows DESC;

--  format_kind     | rows
-- -----------------+------
--  hyphen_standard | 412
--  digits_only     | 287
--  mixed_other     |   9   ← '010 1234 5678', '+82-10-...', '010.1234.5678'

하이픈 포함숫자만7 대 3 정도로 섞여 누적돼 있었고, 공백·점·국가코드 포함 같은 변종 케이스 9 건 도 같이 들어와 있었다. 한 컬럼이 네 가지 포맷을 동시에 담고 있는 상태였다.

직전 머지의 주간 출석 KST 자정 핫픽스시간 구간 일관성단일 헬퍼 로 모아 잡았던 직후라, 연락처 일관성같은 방식 — 단일 유틸 + DTO/Zod 강제 — 으로 잡을 만한 상황이었다.

📌 핵심: 같은 도메인 컬럼에 두 가지 이상의 포맷이 누적 돼 있으면 어느 한 쪽만 받는 BE 검증 으로는 기존 데이터 운영 이 막힌다. 저장 포맷·입력 포맷·표시 포맷 세 축을 분리 명문화 한 뒤 세 곳 (BE DTO / FE Zod / 기존 데이터 SQL)한 머지에 묶어 정규화하는 것이 표준 해법이다.


🔥 증상 — BE 400, FE 통과, DB 혼재

증상은 세 곳 에서 동시에 보고됐다.

1) BE — 하이픈 입력이 400 으로 거절

운영자가 학부모 연락처 칸에 010-1234-5678 을 입력하고 등록 을 누르면 BE 가 400 으로 거절했다.

# 운영자 A 의 실제 요청 (curl 재현)
curl -X POST https://api.example.dev/v1/academy/students \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "홍길동",
    "classId": 1,
    "parentPhone": "010-1234-5678"
  }'
{
  "statusCode": 400,
  "error": "Bad Request",
  "message": [
    "유효한 전화번호를 입력하세요 (10~11자 숫자)"
  ]
}

같은 회원 정보를 숫자만 으로 보내면 정상 통과했다.

curl -X POST https://api.example.dev/v1/academy/students \
  -H "Authorization: Bearer ***" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "홍길동",
    "classId": 1,
    "parentPhone": "01012345678"
  }'
{ "success": true, "data": { "id": "stu-***", "parentPhone": "01012345678" }, "initialPassword": "5678" }

같은 입력값 에 대해 동일 검증입력 표기 차이결과가 갈리는 비대칭이었다.

2) FE — 폼은 통과시키는데 BE 가 막는다

FE 폼은 placeholder 만 '010-1234-5678' 로 표시하고 있었고, 입력값 자체에는 어떤 정규식 검증도 없었다. 하이픈을 입력해도 폼은 통과시켜 BE 까지 그대로 전송했고, BE 에서 400 이 떨어진 뒤에야 폼 하단 빨간 에러로 “유효한 전화번호를 입력하세요 (10~11자 숫자)” 가 떴다.

// apps/academy-portal/src/pages/students/create.tsx (수정 전)
const [parentPhone, setParentPhone] = useState('');

// ... JSX
<Input
  name="parentPhone"
  placeholder="010-1234-5678"          // ← 하이픈을 *허용한다는 신호*
  value={parentPhone}
  onChange={(e) => setParentPhone(e.target.value)}
/>

placeholder 가 하이픈을 보여 주는 화면 에서 입력 직후에는 통과 시키고 제출 후 BE 응답에서 빨간 에러 를 띄우는 흐름은 운영자 체감 품질을 가장 깎는 패턴 이다.

3) DB — 같은 컬럼에 네 가지 포맷이 누적

위에서 본 카운트 쿼리증상 3 번째 표면 이다. 412 행 하이픈 포함 + 287 행 숫자만 + 9 행 변종같은 parentPhone 컬럼섞여 있었다. BE 응답 그대로 화면에 그리는 코드목록에서 어떤 행은 하이픈 포함, 어떤 행은 숫자만 으로 표시 비일관 까지 만들고 있었다.

// apps/academy-portal/src/pages/students/list.tsx (수정 전)
<TableCell>{student.parentPhone}</TableCell>
//          ↑ DB 그대로. 어떤 행은 010-1234-5678, 어떤 행은 01012345678 로 표시

⚠️ 주의: 저장 포맷이 정해지지 않은 컬럼FE 표시 까지 오염 시킨다. 같은 검색어로 목록 필터링 을 하면 하이픈 유무 에 따라 결과 행이 갈린다. 본 사고는 BE 검증 이 표면이지만 영향 범위는 BE / FE / DB 세 곳 동시에 다.


🔍 탐색 — 세 가설 모두 한 축만 본 부분 해법

분석을 시작하기 전, 부분 해법 세 가지 가 후보로 올라왔다. 셋 다 짧게는 증상을 줄이지만, 세 축 (저장 / 입력 / 표시) 중 한 축만 손보는 미봉이라 모두 탈락시켰다.

가설 A — BE 정규식만 조금 더 느슨하게 (하이픈 허용)

가장 빠른 안. DTO 의 정규식을 /^\d{10,11}$/ 에서 /^[\d-]+$/ 로 풀어 하이픈도 통과시키자는 안이었다.

// 가설 A — DTO 정규식만 풀기
@Matches(/^[\d-]+$/, { message: '전화번호 형식이 올바르지 않습니다' })
parentPhone?: string;

BE 400 증상은 즉시 사라지지만 결정적으로 다음 두 가지를 못 잡는다.

  1. DB 에 하이픈 포함 / 숫자만 / 공백 포함 이 그대로 누적 됨. 저장 포맷 표준이 여전히 없다.
  2. FE 목록 표시 비일관 도 그대로. 어떤 행은 010-1234-5678, 어떤 행은 01012345678섞여 보이는 문제가 그대로 남는다.

🔍 단서: 입력 표기 다양성DB 까지 그대로 흘려보내면 조회·필터·중복 검출 모두행마다 다른 규칙 으로 돌게 된다. 저장 포맷은 단일DB 의 책임 이다.

가설 B — FE 입력 마스크만 조금 더 똑똑하게

두 번째 안. FE 입력 직전에 010-1234-5678 마스크 를 깔아 항상 하이픈 포함으로 BE 전송하자는 안이었다. BE 정규식은 그대로 /^\d{10,11}$/ 이 아닌 하이픈 포함 정규식 으로 바꾸기.

문제는 FE 가 한 개가 아니라는 점 이었다.

  • academy-portal (고객사 관리자) — 보호자 / 회원 / 운영자 등록·수정 4 폼
  • admin-portal (운영자) — 고객사 등록·수정 2 폼
  • parent-report (보호자 앱) — 회원가입·로그인 / 마이페이지 3 폼
  • Unity Lobby (회원 앱) — 비밀번호 변경 시 본인 확인 1 폼

네 개 앱 × 평균 3 폼 = 12 입력 지점 마다 각자의 마스크 라이브러리 / 정규식 을 흩뿌리면 다음 사고 때 다시 흩어진다. 입력 표준이 클라이언트마다 다르면 결국 BE 가 받아 주는 정규식이 가장 느슨한 클라이언트 기준 이 되어 DB 가 또 더러워진다.

⚠️ 주의: 클라이언트가 둘 이상 인 상황에서 입력 정규화를 FE 만으로 해결하면 외부 호출 / Postman / curl클라이언트 외 경로항상 새는 구멍 이 된다. 서버 측 마지막 관문 정규화어떤 경우에도 필수 다.

가설 C — 신규 데이터만 정규화 (기존 데이터는 손대지 않음)

세 번째 안. DTO + 마이그레이션은 안 하고 신규 등록·수정에만 normalizePhone 을 호출해 앞으로 들어오는 행만 숫자만 으로 만들자는 안이었다.

이 안의 함정은 목록 조회 / 검색 / 비밀번호 초기화기존 데이터신규 데이터섞어 다룬다 는 점이었다.

// 비밀번호 초기화 — apps/api/src/application/services/student.application.service.ts
async resetPassword(studentId: string): Promise<string> {
  const student = await this.prisma.student.findUnique({ where: { id: studentId } });
  if (!student.parentPhone) return this.generateRandomPassword();

  // ← 여기서 student.parentPhone 이 '010-1234-5678' 일 수도, '01012345678' 일 수도 있음
  return student.parentPhone.slice(-4);
}

비밀번호 초기 값 = parentPhone 뒤 4 자 규칙은 01012345678 의 뒤 4 자는 “5678”, 010-1234-5678 의 뒤 4 자는 “5678”우연히 같지만, 길이가 다른 변종 (010 1234 5678 → “5678” 앞 공백 포함)비밀번호가 깨진다. 신규만 정규화기존 612 행영구적 변종 케이스 로 남긴다.

📌 핵심: 데이터 정합성 사고신규 데이터 정규화 만으로는 끝나지 않는다. 기존 데이터를 같은 머지에서 일괄 정규화 하지 않으면 조회 / 검색 / 파생 값 (비밀번호 / 알림 수신자)행 단위로 갈라진다.

세 가설 모두 한 축만 본 부분 해법이라, 진짜 답은 세 축을 동시에 잡는 쪽으로 좁혀졌다.


🔬 진짜 범인 — 표준 부재가 만든 3축 분열

코드를 따라가 보니 어디에도 “저장은 숫자만 / 입력은 자유 / 표시는 하이픈” 표준이 명문화되어 있지 않았다. BE / FE / Seed / 마이그레이션 정책 네 곳각자의 가정 으로 작성돼 있었다.

1) BE DTO — @IsString() 만, 정규식 없음

본 머지 직전의 DTO 는 다음과 같았다.

// apps/api/src/application/dtos/academy-student.dto.ts (수정 전)
export class CreateStudentDto {
  @IsString()
  parentPhone: string;   // ← 정규식 없음. 어떤 문자열이든 통과
}

@IsString() 걸려 있어 어떤 문자열이든 통과했고, DB 컬럼은 그대로 받아 저장했다. BE 가 막아 주리라는 가정 으로 FE 가 입력 표준 정규화를 안 해도 되는 공간 을 만든 셈이다.

2) FE 입력 — placeholder 만, 정규식·마스크 없음

증상 2 번째 에서 본 코드. placeholder 가 하이픈을 보여주고 있었지만 입력값 검증 / 마스크어디에도 없었다. react-hook-form / zod 도 도입 전 이라 입력 → 상태 → fetch 가 직선으로 흘렀다.

3) Seed 데이터 — '010-1234-5678' 하드코딩

마지막 한 곳. Seed 데이터처음부터 하이픈 포함 으로 작성돼 있었다.

// apps/api/prisma/seed.ts (수정 전 일부)
const studentsToCreate = [
  { name: '홍길동', parentPhone: '010-1234-5678' },
  { name: '이몽룡', parentPhone: '010-1111-2222' },
  // ...
];

개발 환경의 첫 데이터하이픈 포함 이라 FE 가 그 표시를 기준 으로 만들어졌고, placeholder 도 그 표시 그대로 가져왔다. Seed 가 표준 부재 시기에 박혀 BE 가 받는 표기 다양성을 정당화한 셈이다.

4) 마이그레이션 정책 — 기존 데이터 정규화 절차 부재

본 머지 시점까지 DB 컬럼 포맷 정책 변경기존 데이터 일괄 정규화 SQL마이그레이션 폴더 에 같이 두는 절차가 명문화되지 않았다. Prisma Migrate 의 자동 마이그레이션스키마 변경 만 처리하고 값 정규화 SQL수동 작성 이 표준이지만, 수동 작성을 어디에 두고 누가 실행할지팀 안에 합의되지 않았다.

종합 — 3 축 표준 부재

네 가지한 사고 로 묶이는 것은 저장 포맷·입력 포맷·표시 포맷 세 축의 표준이 어디에도 없었기 때문이다.

📊 데이터: DB 가 받는 표기FE 입력 표기 다양성그대로의 사본 인 상태였다. BE 가 한 줄의 정규식 만 더했어도 입력 다양성을 정규화 후 저장 으로 축소 시킬 수 있었지만, 그 한 줄을 누가 어디에 두는지팀 표준 으로 없었다는 게 진짜 범인이다.

영역본 머지 직전 상태영향
BE DTO@IsString() 만 — 정규식 / 변환 없음어떤 표기든 통과 → DB 가 표기 다양성 그대로 흡수
FE 입력placeholder 만 — 정규식 / 마스크 없음입력 직후에는 통과, BE 응답 후 빨간 에러
Seed 데이터'010-1234-5678' 하드코딩개발 환경 첫 표시가 하이픈 포함 으로 굳어짐
마이그레이션값 정규화 SQL 절차 없음기존 데이터 그대로 누적
결과DB 한 컬럼에 4 가지 포맷 누적조회 / 검색 / 비밀번호 초기 값 모두 행마다 갈림

🛠️ 해결 — 저장 / 입력 / 표시 3축을 한 머지에 묶기

직접 정리한 연락처 포맷 3-Layer 변환 흐름도 — 입력 Layer 자유 표기 / 정규화 Layer normalizePhone / 저장 Layer DB 숫자만 / 표시 Layer formatPhone 분기와 zod phoneSchema transform refine 표준
직접 정리한 연락처 포맷 3-Layer 변환 흐름도 — 입력 Layer 자유 표기 / 정규화 Layer normalizePhone / 저장 Layer DB 숫자만 / 표시 Layer formatPhone 분기와 zod phoneSchema transform refine 표준

해결의 핵심은 세 축의 표준을 명문화 한 뒤 네 지점 (BE 유틸 + DTO + FE Zod + 마이그레이션) 을 같은 PR 에 묶어 동시에 머지 한 것이었다.

표준 명문화 — 한 줄로 끝낼 수 있는 약속

저장 포맷: 숫자만 11 자 (휴대폰) 또는 9~10 자 (지역번호) — DB 단일
입력 포맷: 자유 — 하이픈 / 공백 / 점 모두 허용, BE 가 정규화 후 저장
표시 포맷: 11 자 → 010-1234-5678, 10 자 → 02-1234-5678 (formatPhone)

한 줄 약속변경 명세서 (docs/changes/2026-02-03_연락처_포맷_통일.md) 에 명문화한 뒤 코드를 바꿨다.

BE — phone.util.ts + DTO @Transform → @Matches

먼저 유틸 함수 한 곳을 만들었다. 서비스 코드 어디서도 직접 정규식을 다시 짜지 않게 강제하기 위해서다.

// apps/api/src/common/utils/phone.util.ts (신규)
/**
 * 전화번호 정규화 (숫자만 추출)
 * @example normalizePhone('010-1234-5678') → '01012345678'
 * @example normalizePhone('010 1234 5678') → '01012345678'
 */
export function normalizePhone(phone: string | null | undefined): string {
  if (!phone) return '';
  return phone.replace(/\D/g, '');
}

/**
 * 전화번호 포맷팅 (표시용)
 * @example formatPhone('01012345678') → '010-1234-5678'
 * @example formatPhone('0212345678')  → '02-1234-5678'
 */
export function formatPhone(phone: string | null | undefined): string {
  if (!phone) return '';
  const n = normalizePhone(phone);

  // 휴대폰 11 자 (010-XXXX-XXXX)
  if (n.length === 11) return `${n.slice(0, 3)}-${n.slice(3, 7)}-${n.slice(7)}`;

  // 서울 지역번호 10 자 (02-XXXX-XXXX)
  if (n.length === 10 && n.startsWith('02')) {
    return `${n.slice(0, 2)}-${n.slice(2, 6)}-${n.slice(6)}`;
  }

  // 기타 지역번호 10 자 (0XX-XXX-XXXX)
  if (n.length === 10) return `${n.slice(0, 3)}-${n.slice(3, 6)}-${n.slice(6)}`;

  // 서울 지역번호 9 자 (02-XXX-XXXX)
  if (n.length === 9 && n.startsWith('02')) {
    return `${n.slice(0, 2)}-${n.slice(2, 5)}-${n.slice(5)}`;
  }

  return n;
}

/**
 * 전화번호 유효성 검사
 * - 휴대폰: 010, 011, 016, 017, 018, 019 (10~11 자)
 * - 지역번호: 02 (9~10 자), 0XX (10 자)
 */
export function isValidPhone(phone: string | null | undefined): boolean {
  if (!phone) return false;
  const n = normalizePhone(phone);
  return /^(01[016789]\d{7,8}|02\d{7,8}|0[3-6][1-5]\d{6,7})$/.test(n);
}

다음으로 DTO@Transform → @Matches 두 줄 데코레이터 를 추가했다. 순서가 중요하다.

  // apps/api/src/application/dtos/academy-student.dto.ts
  import { Transform } from 'class-transformer';
  import { Matches } from 'class-validator';
+ import { normalizePhone } from '../../common/utils/phone.util';

  export class CreateStudentDto {
    @ApiPropertyOptional({
      description: '학부모 연락처 (하이픈 포함/미포함 모두 허용, 저장 시 숫자만)',
      example: '010-1234-5678',
    })
    @IsOptional()
    @IsString()
+   @Transform(({ value }) => (value ? normalizePhone(value) : value))
+   @Matches(/^\d{10,11}$/, { message: '유효한 전화번호를 입력하세요 (10~11자 숫자)' })
    parentPhone?: string;
  }

@Transform 이 정규식 검증보다 먼저 실행되어 하이픈을 떼어 낸 뒤 @Matches숫자만 10~11 자깨끗하게 검증한다. 글로벌 ValidationPipe 에 transform: true 가 깔려 있어 @Transform 이 자동 발동한다.

// apps/api/src/main.ts (전제 — 본 머지 이전부터 깔려 있던 설정)
app.useGlobalPipes(new ValidationPipe({
  transform: true,        // ← class-transformer 자동 발동
  whitelist: true,
  forbidNonWhitelisted: true,
}));

같은 패턴 으로 4 컬럼 6 DTO같은 두 줄 을 추가했다.

모델DTO필드
StudentCreateStudentDtoparentPhone / phone
TeacherCreateTeacherDto / UpdateTeacherDtophone
AcademyCreateAcademyDto / UpdateAcademyDtophone

UpdateStudentDtophone 필드를 노출하지 않는 정책 이라 손대지 않았다. 수정 폼이 phone 을 표시하지 않는 화면 정책DTO 가 phone 을 받지 않는 백엔드 정책같은 방향 이라는 점을 코드 리뷰 에서 확인했다.

FE — zod transform → refine 두 줄

FE 는 같은 변환 → 검증 흐름을 zod 스키마 로 가져왔다.

// apps/academy-portal/src/lib/validations/phone.ts (신규)
import { z } from 'zod';

/** 전화번호 정규화 (숫자만 추출) */
export const normalizePhone = (value: string): string => value.replace(/\D/g, '');

/** 전화번호 포맷팅 (표시용) */
export const formatPhone = (phone: string | null | undefined): string => {
  if (!phone) return '-';
  const n = normalizePhone(phone);
  if (n.length === 11) return `${n.slice(0, 3)}-${n.slice(3, 7)}-${n.slice(7)}`;
  if (n.length === 10) return `${n.slice(0, 2)}-${n.slice(2, 6)}-${n.slice(6)}`;
  return phone;
};

/** 필수 전화번호 — 입력 자유, 출력 숫자만 10~11 자 */
export const phoneSchema = z
  .string()
  .min(1, '연락처를 입력하세요')
  .transform(normalizePhone)
  .refine((v) => /^\d{10,11}$/.test(v), '10~11자 숫자를 입력하세요');

/** 선택 전화번호 — 빈 값 허용, 입력된 경우 정규화 */
export const phoneSchemaOptional = z
  .string()
  .transform((v) => (v ? normalizePhone(v) : ''))
  .refine((v) => v === '' || /^\d{10,11}$/.test(v), '10~11자 숫자를 입력하세요');

핵심은 transform → refine 순서다. 입력값 자유서버와 동일한 함수 (normalizePhone)정규화 서버와 동일한 정규식 (/^\d{10,11}$/) 으로 검증한다. BE 와 FE 의 정규식이 한 문자라도 다르면 클라이언트 통과 / 서버 거절 비대칭 이 다시 나오므로, 값을 똑같이 맞추는 것유일한 일관성 보장 수단이다.

폼 컴포넌트는 react-hook-form + shadcn/ui Form + zodResolver 패턴으로 옮겼다.

// apps/academy-portal/src/pages/students/create.tsx (발췌)
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { phoneSchemaOptional } from '@/lib/validations/phone';

const studentFormSchema = z.object({
  name: z.string().min(1, '이름을 입력하세요'),
  classId: z.number().min(1, '반을 선택하세요'),
  parentPhone: phoneSchemaOptional,
  phone: phoneSchemaOptional,
});

type StudentFormData = z.infer<typeof studentFormSchema>;

export function StudentCreatePage() {
  const { register, handleSubmit, watch, formState: { errors } } = useForm<StudentFormData>({
    resolver: zodResolver(studentFormSchema),
    defaultValues: { name: '', classId: 0, parentPhone: '', phone: '' },
  });

  const phoneValue = watch('phone');
  const parentPhoneValue = watch('parentPhone');

  /** 표시용 포맷 (입력 도중 시각 피드백) — 저장은 그대로 숫자만 */
  const formatPhoneDisplay = (value: string) => {
    if (!value) return '';
    if (value.length <= 3) return value;
    if (value.length <= 7) return `${value.slice(0, 3)}-${value.slice(3)}`;
    return `${value.slice(0, 3)}-${value.slice(3, 7)}-${value.slice(7, 11)}`;
  };

  return (
    <form onSubmit={handleSubmit((data) => create({ resource: 'academy/students', values: data }))}>
      <Input
        placeholder="010-1234-5678"
        value={formatPhoneDisplay(parentPhoneValue || '')}
        onChange={(e) => setValue('parentPhone', normalizePhone(e.target.value))}
      />
      {errors.parentPhone && <p>{errors.parentPhone.message}</p>}
      {/* ... */}
    </form>
  );
}

입력 도중에는 formatPhoneDisplay 가 시각적으로 하이픈을 끼워 주고, 상태에는 normalizePhone(value) 로 숫자만 저장한다. 제출 시점에는 zod 가 다시 한 번 정규화 후 검증하므로 어떤 경로로 값이 들어와도 숫자만 10~11 자 가 BE 로 간다.

목록 표시formatPhone 한 줄 로 통일했다.

// apps/academy-portal/src/pages/students/list.tsx (수정 후)
import { formatPhone } from '@/lib/validations/phone';

<TableCell className="text-center w-[160px] whitespace-nowrap">
  {formatPhone(student.parentPhone)}
</TableCell>

기존 데이터 정규화 — 마이그레이션 SQL 같은 머지에 묶기

마지막 한 곳. 기존 612 행 + 변종 9 행같은 머지 에 정규화했다.

-- apps/api/prisma/migrations/manual/normalize_phone_numbers.sql
-- 전화번호 정규화 마이그레이션 (숫자만 저장)
-- 실행일: 2026-02-03
-- 영향 테이블: students, teachers, academies

-- 1. 회원 테이블: parentPhone, phone 정규화
UPDATE students
SET "parentPhone" = REGEXP_REPLACE("parentPhone", '[^0-9]', '', 'g')
WHERE "parentPhone" IS NOT NULL AND "parentPhone" ~ '[^0-9]';

UPDATE students
SET phone = REGEXP_REPLACE(phone, '[^0-9]', '', 'g')
WHERE phone IS NOT NULL AND phone ~ '[^0-9]';

-- 2. 운영자 테이블: phone 정규화
UPDATE teachers
SET phone = REGEXP_REPLACE(phone, '[^0-9]', '', 'g')
WHERE phone IS NOT NULL AND phone ~ '[^0-9]';

-- 3. 고객사 테이블: phone 정규화
UPDATE academies
SET phone = REGEXP_REPLACE(phone, '[^0-9]', '', 'g')
WHERE phone IS NOT NULL AND phone ~ '[^0-9]';

-- 검증 쿼리 (실행 후 0 이어야 함)
-- SELECT COUNT(*) FROM students WHERE "parentPhone" ~ '[^0-9]';
-- SELECT COUNT(*) FROM students WHERE phone ~ '[^0-9]';
-- SELECT COUNT(*) FROM teachers WHERE phone ~ '[^0-9]';
-- SELECT COUNT(*) FROM academies WHERE phone ~ '[^0-9]';

Prisma Migrate 의 자동 마이그레이션 폴더가 아니라 manual/ 하위 에 둔 이유는, 스키마 변경 없이 값만 정규화하는 SQLPrisma Migrate 의 history 트래킹에 들어가지 않는 게 맞기 때문 이다. 수동 적용 SQL실행 시점 / 실행자 / 검증 쿼리팀 채널에 기록하는 절차변경 명세서 에 같이 명문화했다.

# 운영 DB 에 직접 적용
psql $DATABASE_URL -f apps/api/prisma/migrations/manual/normalize_phone_numbers.sql

# 적용 후 검증
psql $DATABASE_URL -c "SELECT COUNT(*) FROM students WHERE \"parentPhone\" ~ '[^0-9]';"  # → 0
psql $DATABASE_URL -c "SELECT COUNT(*) FROM students WHERE phone ~ '[^0-9]';"            # → 0
psql $DATABASE_URL -c "SELECT COUNT(*) FROM teachers WHERE phone ~ '[^0-9]';"            # → 0
psql $DATABASE_URL -c "SELECT COUNT(*) FROM academies WHERE phone ~ '[^0-9]';"           # → 0

네 컬럼 모두 0 이 떨어지면 기존 데이터 정규화 완료. 이 시점부터 DB 컬럼 한 줄 가정숫자만 10~11 자통일됐다.

docs.nestjs.com
npmjs.com

3 축 표준 부재 한 컬럼 4 포맷 누적에서 BE DTO Transform 와 FE zod transform 그리고 마이그레이션 SQL 을 한 머지로 묶어 저장 입력 표시 세 축을 한 번에 통일한 진짜 원인을 찾은 순간
3 축 표준 부재 한 컬럼 4 포맷 누적에서 BE DTO Transform 와 FE zod transform 그리고 마이그레이션 SQL 을 한 머지로 묶어 저장 입력 표시 세 축을 한 번에 통일한 진짜 원인을 찾은 순간


✅ 검증 — 단위 / 통합 / 운영 세 시점 모두 확인

검증은 세 시점 으로 나눠 돌렸다.

단위 — phone.util.spec.ts 신규 + DTO 검증 통과 케이스

normalizePhone / formatPhone / isValidPhone 세 함수의 엣지 케이스9 개 단위 테스트 로 추가했다.

// apps/api/src/common/utils/__tests__/phone.util.spec.ts
import { normalizePhone, formatPhone, isValidPhone } from '../phone.util';

describe('normalizePhone', () => {
  it.each([
    ['010-1234-5678',  '01012345678'],
    ['010 1234 5678',  '01012345678'],
    ['01012345678',    '01012345678'],
    ['02-1234-5678',   '0212345678'],
    ['+82-10-1234-5678', '821012345678'], // 국가코드는 정규화만, 검증 단계에서 탈락
    ['',               ''],
    [null,             ''],
    [undefined,        ''],
  ])('normalizePhone(%j) → %j', (input, expected) => {
    expect(normalizePhone(input)).toBe(expected);
  });
});

describe('formatPhone', () => {
  it.each([
    ['01012345678', '010-1234-5678'],
    ['0212345678',  '02-1234-5678'],
    ['0312345678',  '031-234-5678'],
    ['021234567',   '02-123-4567'],
    ['12345',       '12345'],       // 길이 미달은 정규화만, 포맷 없이 반환
  ])('formatPhone(%j) → %j', (input, expected) => {
    expect(formatPhone(input)).toBe(expected);
  });
});

describe('isValidPhone', () => {
  it.each([
    ['01012345678',    true],
    ['010-1234-5678',  true],  // 내부에서 normalizePhone 호출 후 검증
    ['0212345678',     true],
    ['0119876543',     true],   // 011 휴대폰
    ['821012345678',   false],  // 국가코드 포함은 탈락
    ['012345',         false],
    ['',               false],
    [null,             false],
  ])('isValidPhone(%j) → %j', (input, expected) => {
    expect(isValidPhone(input)).toBe(expected);
  });
});

9 개 단위전부 통과 한 뒤 DTO 검증e2e 에서 확인했다.

pnpm test apps/api -- phone.util.spec
# PASS  apps/api/src/common/utils/__tests__/phone.util.spec.ts
#   normalizePhone (8 tests)
#   formatPhone (5 tests)
#   isValidPhone (8 tests)

통합 — DTO + zod 의 비대칭 케이스 회귀 테스트

증상의 비대칭 (하이픈 입력은 거절, 숫자만 입력은 통과) 이 수정 후어떻게 동작하는지통합 테스트 로 굳혔다.

// apps/api/test/students.e2e-spec.ts (발췌)
describe('POST /academy/students — parentPhone 정규화', () => {
  it.each([
    ['010-1234-5678'],
    ['01012345678'],
    ['010 1234 5678'],
    ['010.1234.5678'],
  ])('입력 %j → 저장 01012345678', async (input) => {
    const res = await request(app.getHttpServer())
      .post('/v1/academy/students')
      .set('Authorization', `Bearer ${academyToken}`)
      .send({ name: '회원A', classId: 1, parentPhone: input })
      .expect(201);

    expect(res.body.data.parentPhone).toBe('01012345678');
  });

  it('숫자 9 자는 400 으로 거절', async () => {
    const res = await request(app.getHttpServer())
      .post('/v1/academy/students')
      .set('Authorization', `Bearer ${academyToken}`)
      .send({ name: '회원B', classId: 1, parentPhone: '012345678' })
      .expect(400);

    expect(res.body.message).toContain('유효한 전화번호를 입력하세요 (10~11자 숫자)');
  });
});

4 가지 표기 입력 → 단일 저장 표기 (01012345678)4 회 모두 통과했고, 길이 미달 케이스400 으로 거절되는 회귀 케이스도 같은 테스트 파일 에 묶었다.

운영 — DB 카운트 쿼리 적용 전 / 후

운영 DB 의 마이그레이션 SQL 실행 전 / 후동일한 카운트 쿼리 로 비교했다.

시점digits_onlyhyphen_standardmixed_other
적용 전2874129
적용 후70800

적용 후 총 708 행숫자만 표기 로 통일됐고, 변종 9 건도 한 번에 정규화됐다. 적용 후 카운트원본 합 (287 + 412 + 9 = 708)일치 하므로 데이터 손실 없음 을 확인.

📊 데이터: 마이그레이션 SQL 의 정규식 REGEXP_REPLACE('[^0-9]', '', 'g')BE normalizePhonereplace(/\D/g, '')완전 동일 의미 라는 점이 적용 전 / 후 행 수가 합산으로 일치 한다는 사실로 간접 검증 됐다.


🛡️ 예방 — 3 축 표준코드 위치 4 곳 에 강제

같은 사고가 다른 도메인 컬럼 (예: 사업자번호 / 주민번호 / 우편번호) 에서 재발하지 않도록 코드 위치 4 곳3 축 표준기계적으로 강제 하는 가드를 깔았다.

1) BE DTO 표준 — @Transform → @Matches 두 줄 의무

문자열 정규화가 필요한 모든 DTO 필드@Transform 먼저 / @Matches 다음 패턴을 팀 컨벤션 으로 명문화했다.

// apps/api/src/common/decorators/normalized-string.decorator.ts (신규)
import { applyDecorators } from '@nestjs/common';
import { Transform } from 'class-transformer';
import { Matches } from 'class-validator';

/** 정규화 함수 + 정규식 패턴을 묶은 표준 데코레이터 */
export function NormalizedString(
  normalize: (value: string) => string,
  pattern: RegExp,
  message: string,
) {
  return applyDecorators(
    Transform(({ value }) => (value ? normalize(value) : value)),
    Matches(pattern, { message }),
  );
}

// 사용 예
export class CreateStudentDto {
  @NormalizedString(normalizePhone, /^\d{10,11}$/, '유효한 전화번호를 입력하세요 (10~11자 숫자)')
  @IsOptional()
  parentPhone?: string;
}

두 데코레이터를 한 줄로 묶어 팀원이 순서를 헷갈리지 않게 했다. @Transform 이 빠진 채 @Matches 만 두면 본 사고가 그대로 재발 하므로 컨벤션을 데코레이터 한 줄로 표현 한 것이 표준화 핵심.

2) FE zod 표준 — transform → refine 두 줄 의무

FE 도 같은 두 줄 패턴@/lib/validations/ 하위에서 공용 스키마 로 표준화했다.

// apps/academy-portal/src/lib/validations/normalized.ts (신규)
import { z } from 'zod';

/** 정규화 함수 + 정규식 패턴 + 에러 메시지를 받아 zod 스키마 생성 */
export function normalizedStringSchema(
  normalize: (value: string) => string,
  pattern: RegExp,
  errorMessage: string,
) {
  return z
    .string()
    .min(1, '값을 입력하세요')
    .transform(normalize)
    .refine((v) => pattern.test(v), errorMessage);
}

// 사용 예 — phone.ts 가 이 함수를 호출하는 한 줄로 줄어듦
export const phoneSchema = normalizedStringSchema(
  normalizePhone,
  /^\d{10,11}$/,
  '10~11자 숫자를 입력하세요',
);

BE 데코레이터와 FE 스키마 함수가 같은 시그니처 (정규화 함수 + 정규식 + 메시지)대응하므로, 팀 내 코드 리뷰 에서 BE / FE 한 짝이 동기화돼 있는지눈으로 즉시 확인할 수 있다.

3) Seed 데이터 정규화 — Seed Lint 단계 추가

Seed 가 표준 부재 시기의 표기를 굳히는 위치 였다는 점을 잊지 않기 위해, Seed 실행 직전정규화 함수를 강제로 호출 하는 헬퍼를 깔았다.

// apps/api/prisma/seed-helpers.ts (신규)
import { normalizePhone } from '../src/common/utils/phone.util';

/** Seed 객체 안의 phone 필드를 자동 정규화 */
export function withNormalizedPhones<T extends Record<string, any>>(record: T): T {
  const result: any = { ...record };
  for (const key of Object.keys(result)) {
    if (key.toLowerCase().endsWith('phone') && typeof result[key] === 'string') {
      result[key] = normalizePhone(result[key]);
    }
  }
  return result;
}

// apps/api/prisma/seed.ts 에서 사용
import { withNormalizedPhones } from './seed-helpers';

const studentsToCreate = [
  withNormalizedPhones({ name: '홍길동', parentPhone: '010-1234-5678' }),
  // ...
];
// → DB 에는 '01012345678' 로 저장

Seed 작성자가 어떤 표기로 적든 DB 에는 정규화된 형태로만 들어가는 가드.

4) 마이그레이션 절차 — manual/ 하위 SQL + 변경 명세서 의무

마지막 한 곳. 스키마 변경 없이 값만 정규화하는 SQL위치 / 명명 / 실행 절차팀 컨벤션 으로 명문화했다.

# docs/conventions/db-data-normalization.md (신규)

## 값 정규화 SQL 절차

1. **위치**: `apps/api/prisma/migrations/manual/<YYYYMMDD>_<설명>.sql`
2. **명명**: `normalize_<컬럼>_<목적>.sql` 또는 `backfill_<컬럼>.sql`
3. **헤더 필수**: 실행일 / 영향 테이블 / 변경 명세서 링크
4. **검증 쿼리 포함**: 적용 후 0 이 떨어지는 카운트 쿼리를 SQL 파일 안에 주석으로 동봉
5. **실행 절차**: 팀 채널에 *적용 전 카운트 / 적용 후 카운트 / 실행자 / 시점* 기록
6. **변경 명세서**: `docs/changes/<YYYY-MM-DD>_<제목>.md` 에 SQL 경로 인용

문서 한 페이지다음 정규화 사고팀 표준 으로 작동한다.

정리 — 4 가드의 책임 분담

가드위치막는 사고
NormalizedString 데코레이터BE DTO@Transform 누락으로 BE 가 표기 다양성 흡수
normalizedStringSchema 함수FE zod클라이언트 통과 / 서버 거절 비대칭
withNormalizedPhones 헬퍼Seed개발 환경 첫 데이터가 표기 표준을 굳혀 버림
manual/ 하위 SQL + 절차 문서마이그레이션기존 데이터 누적 / 변종 케이스 잔존

📌 핵심: 4 가드가 각각 다른 시점 을 책임진다. DTO 데코레이터는 요청 수신 단계, zod 스키마는 제출 단계, Seed 헬퍼는 개발 환경 부팅 단계, manual SQL 절차는 정책 변경 단계. 한 곳이 빠지면 그 시점에서 표기가 새는 구멍 이 만들어진다.


📋 정리 — 핵심 요약

본 머지에서 굳힌 결정 7 건 을 표로 정리한다. 직전 편 (devlog-62)주간 출석 KST 자정 핫픽스시간 구간 일관성단일 헬퍼 로 모아 잡았다면, 본 사고는 연락처 표기 일관성같은 방식 (단일 유틸 + DTO/Zod 강제) 으로 잡은 두 번째 사례다.

결정안티패턴 (변경 전)권장 패턴 (변경 후)
저장 포맷❌ DB 컬럼이 하이픈 포함 / 숫자만 / 변종 4 가지를 흡수✅ 숫자만 11 자 (휴대폰) / 9~10 자 (지역번호) — DB 단일
입력 포맷❌ FE placeholder 만 — 입력값 정규화 없음✅ FE 자유 입력 — zod transform 으로 제출 직전 정규화
표시 포맷❌ BE 응답을 그대로 그리기 — 행마다 표기 다름formatPhone 한 줄 — 11 자 → 010-1234-5678, 10 자 → 02-1234-5678
BE 검증@IsString() 만 — 어떤 표기든 통과@Transform(normalizePhone) → @Matches(/^\d{10,11}$/) 두 줄
FE 검증❌ placeholder + 상태 직선 흐름✅ zod transform(normalize) → refine(/^\d{10,11}$/) 두 줄
Seed'010-1234-5678' 하드코딩withNormalizedPhones 헬퍼 가 어떤 표기로 적어도 숫자만 저장
기존 데이터❌ 신규만 정규화 — 기존 612 행 + 변종 9 건 잔존manual/normalize_phone_numbers.sql 같은 머지 포함 — 708 행 일괄 정규화

핵심을 세 줄 로 다시 정리한다.

  1. 연락처 같은 표기 다양 컬럼의 표준은 세 축 (저장 / 입력 / 표시) 분리 명문화 다. 세 축이 한 약속으로 묶이지 않으면 BE 정규식 한 줄 추가로는 DB 누적 / FE 표시 비일관 을 못 잡는다. 변경 명세서 한 페이지팀 합의 의 시작점.
  2. BE 는 @Transform → @Matches 두 줄, FE 는 zod transform → refine 두 줄로 동일 구조를 맞춘다. 정규화 함수와 정규식 패턴이 BE / FE 동일 해야 클라이언트 통과 / 서버 거절 비대칭 이 사라진다. 공용 데코레이터 (NormalizedString) + 공용 스키마 함수 (normalizedStringSchema)팀 컨벤션 의 강제 수단.
  3. 기존 데이터 정규화 SQL 은 같은 PR 에 묶는다. Prisma Migrate 자동 마이그레이션 이 아닌 manual/ 하위 에 두되, 적용 절차 / 검증 쿼리변경 명세서 에 의무 기재. 신규만 정규화조회 / 검색 / 파생 값행 단위로 분열 시키므로 항상 같이 푼다.

다음 편 (devlog-64) 에서는 react-hook-form + zod + shadcn/ui Form 폼 표준 자체를 프론트엔드 패턴 가이드 관점에서 정리한다. 본 사고가 연락처 한 필드3 축 통일 이었다면, 다음 편은 모든 입력 필드동일한 zod 스키마 → react-hook-form → shadcn Form 흐름기본기로 깔아 둔 구현기 다.


📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
  47. 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
  48. 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
  49. 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
  50. 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
  51. 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
  52. 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
  53. 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
  54. 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로
  55. 55. Cloud SQL 리전 트랩 — US→Taiwan 71% 트러블슈팅
  56. 56. QR 배치고사 + Firebase Hosting 멀티 사이트 배포
  57. 57. 1,974줄 풀 백업 — 1인 개발에서 상태 관리하는 법
  58. 58. 주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
  59. 59. 연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
  60. 60. react-hook-form + Zod 폼 표준 정착기
  61. 61. Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
  62. 62. 교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
  63. 63. 교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음