Prisma Decimal to Number 변환 시 silent failure 방지하기

API에서 계산 결과가 NaN으로 표시되는 버그를 발견했습니다. Prisma Decimal 타입을 Number()로 변환하면서 발생한 silent failure를 safeNumber 패턴으로 해결한 방법을 공유합니다.

Prisma Decimal to Number 변환 시 silent failure 방지하기

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 가능
    // ... 더 많은 필드
  };
}

이 코드의 문제:

  1. undefined가 전달되면 NaN 반환 (에러 없이)
  2. null이 전달되면 0 반환 (의도와 다름)
  3. 잘못된 문자열이면 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 트랜잭션으로 동시성 이슈도 해결해보세요.

시리즈 목차:

  1. Prisma N+1 쿼리 성능 문제 해결하기 (50% 속도 개선)
  2. Prisma 일괄 업데이트에서 동시성 이슈 해결하기
  3. Prisma Decimal to Number 변환 시 silent failure 방지하기 ← 현재 글