연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
한 고객사에서 연락처 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-5678은 BE 400 거절,01012345678은 통과. DB 컬럼에는 두 포맷이 섞여 누적된 채로 운영 중.- 표면 가설 3 건 (모두 부분 진단) — BE 정규식만 추가 / FE 입력 마스크만 추가 / 신규 데이터만 정규화. 세 가설 모두 한 축만 본 부분 해법.
- 진짜 범인 — 저장 포맷 / 입력 포맷 / 표시 포맷 세 축의 표준이 어디에도 명문화되어 있지 않았던 점. BE DTO 는
@IsString()만, FE 는 placeholder 만, Seed 는'010-1234-5678'하드코딩, 마이그레이션 정책은 부재.- 해결 — 저장은 숫자만 11 자 / 입력은 자유 / 표시는
formatPhone세 축을 명문화 한 뒤, BEphone.util.ts+ DTO@Transform → @Matches+ FE zodtransform → refine+ 기존 데이터 정규화 SQL 을 한 머지 에 묶었다.- 재발 방지 3 가드 — BE DTO
@Transform이 저장 직전 정규화 강제 + FE zodtransform/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 증상은 즉시 사라지지만 결정적으로 다음 두 가지를 못 잡는다.
- DB 에 하이픈 포함 / 숫자만 / 공백 포함 이 그대로 누적 됨. 저장 포맷 표준이 여전히 없다.
- 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축을 한 머지에 묶기

해결의 핵심은 세 축의 표준을 명문화 한 뒤 네 지점 (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 | 필드 |
|---|---|---|
| Student | CreateStudentDto | parentPhone / phone |
| Teacher | CreateTeacherDto / UpdateTeacherDto | phone |
| Academy | CreateAcademyDto / UpdateAcademyDto | phone |
UpdateStudentDto 는 phone 필드를 노출하지 않는 정책 이라 손대지 않았다. 수정 폼이 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/ 하위 에 둔 이유는, 스키마 변경 없이 값만 정규화하는 SQL 은 Prisma 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 자 로 통일됐다.

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