1. 문제 상황
증상
API에서 설정 데이터를 조회했는데, 프론트엔드에서 계산 결과가 NaN으로 표시되는 현상이 발생했습니다.
// 프론트엔드 콘솔
console.log(config.overtimeMultiplier); // NaN
console.log(10 * config.overtimeMultiplier); // NaN
에러 메시지
에러 메시지가 없습니다. 이것이 바로 silent failure의 무서운 점입니다.
- TypeScript 컴파일: 성공
- Lint: 통과
- 런타임 에러: 없음
- 결과:
NaN이 조용히 전파됨
발생 시점
- Prisma에서
Decimal타입 필드를 조회한 후 Number()함수로 변환하는 과정에서- 특정 조건에서
undefined또는 잘못된 값이 전달될 때
영향 범위
- 금액 계산이 모두
NaN으로 표시 - 사용자에게 "NaN원"이 보이는 치명적인 UX 문제
- 데이터베이스에
NaN이 저장될 위험
2. 원인 분석
JavaScript의 Number() 함수 동작
JavaScript의 Number() 함수는 놀라울 정도로 관대합니다:
Number(null) // 0 (!)
Number(undefined) // NaN
Number('') // 0 (!)
Number('abc') // NaN
Number({}) // NaN
Number([]) // 0 (!)
Number([1]) // 1 (!)
Number([1,2]) // NaN
핵심 문제: Number()는 절대 에러를 throw하지 않습니다. 대신 NaN을 반환합니다.
TypeScript의 한계
TypeScript는 컴파일 타임에만 타입을 검사합니다:
function convertToNumber(value: unknown): number {
return Number(value); // TypeScript: "OK, number 반환이네"
}
convertToNumber(undefined); // 런타임: NaN
TypeScript는 Number(unknown)이 실제로 유효한 숫자인지 런타임에서 검증하지 않습니다.
Prisma Decimal 타입의 특성
Prisma의 Decimal 타입은 JavaScript의 number와 다릅니다:
// Prisma 스키마
model Config {
multiplier Decimal @default(1.5)
}
// Prisma가 반환하는 실제 값
const result = await prisma.config.findUnique({...});
console.log(typeof result.multiplier); // "object" (Decimal 인스턴스)
console.log(result.multiplier); // Decimal { s: 1, e: 0, d: [1, 5] }
Prisma의 Decimal은 객체이지만, Number()로 변환하면 보통 잘 작동합니다:
Number(result.multiplier); // 1.5 (정상 작동)
문제는 예외 상황입니다:
// 잘못된 JOIN이나 null 관계에서
const config = result.config; // undefined일 수 있음
Number(config?.multiplier); // NaN (config가 undefined면)
기존 코드의 문제점
// Before: 위험한 코드
export function toConfig(prisma: PrismaConfig): Config {
return {
id: prisma.id,
baseHoursPerDay: Number(prisma.baseHoursPerDay), // undefined → NaN
overtimeMultiplier: Number(prisma.overtimeMultiplier), // null → 0 (잘못된 값)
nightMultiplier: Number(prisma.nightMultiplier), // NaN 가능
// ... 더 많은 필드
};
}
이 코드의 문제:
undefined가 전달되면NaN반환 (에러 없이)null이 전달되면0반환 (의도와 다름)- 잘못된 문자열이면
NaN반환 (에러 없이)
3. 해결 방법
Step 1: safeNumber 헬퍼 함수 구현
/**
* 안전한 숫자 변환 (NaN, null, undefined 방지)
* @param value - 변환할 값
* @param fieldName - 에러 메시지용 필드명
* @throws Error 유효하지 않은 숫자 값인 경우
*/
function safeNumber(value: unknown, fieldName: string): number {
// 1. null/undefined 명시적 체크
if (value === null || value === undefined) {
throw new Error(
`Invalid ${fieldName}: received ${String(value)}, expected a valid number`
);
}
// 2. Number 변환
const num = Number(value);
// 3. NaN 체크
if (Number.isNaN(num)) {
throw new Error(
`Invalid ${fieldName}: received ${String(value)}, expected a valid number`
);
}
return num;
}
Step 2: 변환 함수에 적용
// After: 안전한 코드
export function toConfig(prisma: {
id: string;
companyId: string;
baseHoursPerDay: unknown; // ← unknown으로 타입 변경
overtimeMultiplier: unknown; // ← unknown으로 타입 변경
nightMultiplier: unknown;
// ...
}): Config {
return {
id: prisma.id,
companyId: prisma.companyId,
baseHoursPerDay: safeNumber(prisma.baseHoursPerDay, 'baseHoursPerDay'), // ← 에러 throw
overtimeMultiplier: safeNumber(prisma.overtimeMultiplier, 'overtimeMultiplier'), // ← 에러 throw
nightMultiplier: safeNumber(prisma.nightMultiplier, 'nightMultiplier'), // ← 에러 throw
// ...
};
}
Step 3: API 레벨에서 에러 처리
// API Route
export async function GET(request: NextRequest) {
try {
const prismaData = await prisma.config.findUnique({
where: { companyId },
});
if (!prismaData) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const config = toConfig(prismaData); // ← 여기서 에러 throw 가능
return NextResponse.json({ config });
} catch (error) {
// safeNumber에서 throw한 에러 포착
if (error instanceof Error && error.message.startsWith('Invalid ')) {
console.error('Data validation error:', error.message);
return NextResponse.json(
{ error: 'Data validation failed', details: error.message },
{ status: 500 }
);
}
throw error;
}
}
Before/After 비교
// ❌ Before: Silent failure
baseHoursPerDay: Number(prisma.baseHoursPerDay) // undefined → NaN (에러 없음)
// ✅ After: Explicit error
baseHoursPerDay: safeNumber(prisma.baseHoursPerDay, 'baseHoursPerDay')
// undefined → Error: "Invalid baseHoursPerDay: received undefined, expected a valid number"
4. 핵심 개념 정리
Number() vs safeNumber() 동작 비교
| 입력 값 | Number() |
safeNumber() |
|---|---|---|
1.5 |
1.5 |
1.5 |
"1.5" |
1.5 |
1.5 |
Decimal(1.5) |
1.5 |
1.5 |
undefined |
NaN |
Error throw |
null |
0 |
Error throw |
"" |
0 |
Error throw (선택적) |
"abc" |
NaN |
Error throw |
{} |
NaN |
Error throw |
Fail-Fast vs Fail-Silent
| 접근 방식 | 특징 | 적합한 상황 |
|---|---|---|
| Fail-Silent | 에러 없이 기본값 반환 | 사용자 입력 (graceful degradation) |
| Fail-Fast | 즉시 에러 throw | 내부 데이터 변환 (버그 조기 발견) |
데이터베이스 → 애플리케이션 변환에서는 Fail-Fast가 적합합니다.
5. 베스트 프랙티스
체크리스트
- [ ] Prisma Decimal 필드 변환 시
safeNumber()사용 - [ ] 변환 함수 입력 타입을
unknown으로 명시 - [ ] 필드명을 에러 메시지에 포함
- [ ] API 레벨에서 변환 에러 처리
- [ ] 테스트에서 에러 케이스 검증
테스트 작성 예시
describe('toConfig', () => {
it('유효하지 않은 숫자 값이면 에러', () => {
const invalidConfig = {
...validConfig,
baseHoursPerDay: 'invalid',
};
expect(() => toConfig(invalidConfig)).toThrow('Invalid baseHoursPerDay');
});
it('undefined 값이면 에러', () => {
const invalidConfig = {
...validConfig,
overtimeMultiplier: undefined,
};
expect(() => toConfig(invalidConfig)).toThrow('Invalid overtimeMultiplier');
});
it('null 값이면 에러', () => {
const invalidConfig = {
...validConfig,
nightMultiplier: null,
};
expect(() => toConfig(invalidConfig)).toThrow('Invalid nightMultiplier');
});
it('Decimal 형식 (Prisma) 정상 처리', () => {
const prismaWithDecimal = {
...validConfig,
baseHoursPerDay: { toString: () => '8' } as unknown, // Decimal mock
overtimeMultiplier: { toString: () => '1.5' } as unknown,
};
const result = toConfig(prismaWithDecimal);
expect(result.baseHoursPerDay).toBe(8);
expect(result.overtimeMultiplier).toBe(1.5);
});
});
대안: Zod 스키마 사용
더 복잡한 검증이 필요하다면 Zod를 사용할 수 있습니다:
import { z } from 'zod';
const configSchema = z.object({
id: z.string(),
companyId: z.string(),
baseHoursPerDay: z.coerce.number().min(1).max(24),
overtimeMultiplier: z.coerce.number().min(1).max(3),
nightMultiplier: z.coerce.number().min(0).max(2),
// ...
});
export function toConfig(prisma: unknown): Config {
return configSchema.parse(prisma);
}
장점: 더 상세한 검증 (min/max 등)
단점: 번들 사이즈 증가, 오버헤드
6. FAQ
Q: 왜 TypeScript가 이 문제를 잡지 못하나요?
A: TypeScript는 컴파일 타임 타입 검사기입니다. Number()의 반환 타입이 number이므로 TypeScript는 "OK"라고 판단합니다. 하지만 number 타입에는 NaN도 포함됩니다. TypeScript는 NaN과 유효한 숫자를 구분하지 않습니다.
Q: Number.isNaN() vs isNaN()의 차이점은?
A:
isNaN("abc") // true (문자열을 숫자로 변환 시도)
Number.isNaN("abc") // false (타입이 number가 아님)
isNaN(NaN) // true
Number.isNaN(NaN) // true
isNaN(undefined) // true (!)
Number.isNaN(undefined) // false
Number.isNaN()이 더 정확합니다. 타입이 number이고 값이 NaN인 경우만 true를 반환합니다.
Q: 왜 null은 에러로 처리하나요? Number(null) = 0인데요.
A: Number(null) === 0이지만, 이는 보통 의도한 동작이 아닙니다. 데이터베이스에서 null이 온다는 것은 값이 없다는 의미입니다. 이를 0으로 처리하면 "값이 없음"과 "값이 0"을 구분할 수 없습니다.
Q: 성능에 영향이 있나요?
A: 거의 없습니다. 추가되는 연산:
=== null비교: O(1)=== undefined비교: O(1)Number.isNaN(): O(1)
이 함수가 병목이 되는 상황은 초당 수백만 번 호출하는 경우 정도입니다.
Q: 프론트엔드에서도 이 패턴을 사용해야 하나요?
A: 상황에 따라 다릅니다:
- API 응답 파싱: 권장. 서버에서 잘못된 데이터가 올 수 있음
- 사용자 입력: 선택적. 폼 validation으로 처리하는 것이 UX에 좋음
- 내부 계산: 권장. 버그를 조기에 발견
7. 참고 자료
8. 다음 단계
안전한 숫자 변환을 구현했다면, Prisma 트랜잭션으로 동시성 이슈도 해결해보세요.
시리즈 목차:
- Prisma N+1 쿼리 성능 문제 해결하기 (50% 속도 개선)
- Prisma 일괄 업데이트에서 동시성 이슈 해결하기
- Prisma Decimal to Number 변환 시 silent failure 방지하기 ← 현재 글