Prisma Decimal to Number 변환 시 silent failure 방지하기
API에서 계산 결과가 NaN으로 표시되는 버그를 발견했습니다. Prisma Decimal 타입을 Number()로 변환하면서 발생한 silent failure를 safeNumber 패턴으로 해결한 방법을 공유합니다.
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 방지하기 ← 현재 글