금액·수량 자료형 선택: 정수, numeric, 부가세 끝수 처리
견적서의 품목 세 줄에 부가세를 줄마다 계산해서 더한다고 해 보겠습니다. 같은 견적을 합계에서 부가세를 한 번 계산한 화면과 나란히 놓으면 부가세가 1원 다릅니다.
| 줄 | 공급가액 | 줄마다 10% 버림 |
|---|---|---|
| A | 3,335 | 333 |
| B | 3,335 | 333 |
| C | 3,335 | 333 |
| 합계 | 10,005 | 999 |
합계 10,005 원의 10% 를 버림하면 1,000 원입니다. 줄마다 버리면 999 원입니다. 계산이 틀린 곳은 없습니다. 끝수를 어디서 처리하느냐가 달랐을 뿐입니다.
숫자면 다 같다는 생각
금액과 수량은 숫자이니 숫자 칸에 넣고 더하면 된다고 흔히 생각합니다. 데이터베이스에도 JavaScript 에도 숫자 타입이 있고, 대부분의 계산은 그대로 맞습니다.
업무 시스템에서 숫자가 어긋나는 곳은 세 군데입니다. 소수를 정확히 담지 못하는 타입, 담을 수 있는 크기의 한계, 그리고 앞의 표처럼 끝수를 처리하는 위치입니다. 앞의 둘은 타입을 고르는 문제이고, 마지막은 규칙을 정하는 문제입니다.
부동소수점이 금액에 맞지 않는 이유
JavaScript 에서 0.1 + 0.2 는 0.30000000000000004 입니다. 버그가 아니라 부동소수점 형식의 성질입니다. 이진수로 정확히 나타낼 수 없는 십진 소수는 가장 가까운 값으로 저장되고, 그 오차가 계산을 거치며 드러납니다.
데이터베이스도 같습니다. PostgreSQL 문서의 타입 표는 real 과 double precision 을 「inexact」로 적습니다. 정확한 값이 필요한 경우를 위한 타입은 따로 권합니다.
The type numeric can store numbers with a very large number of digits. It is especially recommended for storing monetary amounts and other quantities where exactness is required.
money 타입 문서도 부동소수점으로 금액을 다루지 말라고 적습니다.
Floating point numbers should not be used to handle money due to the potential for rounding errors.
원 단위라면 정수가 가장 단순한 답
원화는 보통 소수점 아래가 없습니다. 그렇다면 금액을 정수로 저장하는 것이 가장 단순합니다. 정수 계산에는 오차가 없고, 비교와 합계가 빠릅니다.
달러처럼 센트가 있는 통화도 같은 방법을 씁니다. 12.34 달러를 1234 센트라는 정수로 저장합니다. 최소 단위의 정수로 저장하고, 화면에 보여 줄 때만 소수점을 찍습니다.
주의할 것은 정수의 크기입니다. PostgreSQL 의 integer 는 4바이트이고 최댓값이 2,147,483,647 입니다. 약 21억 원입니다. 전표 한 장의 금액으로는 충분해 보여도, 월별 매출 합계나 연간 누계는 중소기업에서도 쉽게 넘습니다. 합계를 저장하는 칸이나 합계를 계산한 결과는 8바이트인 bigint 가 맞습니다.
bigint 를 쓰면 JavaScript 쪽에서 한 번 더 생각할 것이 있습니다. JavaScript 의 일반 숫자가 정확하게 다루는 정수는 Number.MAX_SAFE_INTEGER, 즉 9,007,199,254,740,991 까지입니다. 원화 금액이 이 값을 넘을 일은 드물지만, ORM 이 bigint 칸을 JavaScript 의 BigInt 로 돌려주는 경우가 있어서 일반 숫자와 섞어 계산하면 타입 오류가 납니다. 쓰는 ORM 이 어떻게 돌려주는지 확인해야 합니다.
소수가 필요한 수량은 numeric
금액과 달리 수량은 소수가 필요할 때가 많습니다. 원료 12.5 kg, 원단 3.2 m 같은 값입니다. 이런 칸은 numeric 이 맞습니다. 소수점 아래 자리를 정해 두면(numeric(12, 3)) 그 자리까지 정확하게 저장합니다.
numeric 의 대가는 속도와 다루기입니다. PostgreSQL 문서는 numeric 계산이 정수나 부동소수점보다 매우 느리다고 적습니다. JavaScript 에는 대응하는 기본 타입이 없어서, ORM 은 보통 십진수 라이브러리 객체로 돌려줍니다. 이 값을 Number() 로 바꿔 계산하는 순간 다시 부동소수점이 됩니다. 계산도 그 라이브러리의 메서드로 해야 정확함이 유지됩니다.
그래서 저라면 원화 금액은 정수, 소수가 필요한 수량만 numeric 으로 두겠습니다. 모든 숫자 칸을 numeric 으로 통일하면 오차는 없지만, 모든 계산 코드가 라이브러리 메서드를 써야 하는 비용을 치릅니다.
끝수 처리를 어디서 할지 정하는 일
처음의 1원 차이로 돌아가겠습니다. 이것은 타입으로 풀리지 않습니다. 정수로 저장해도, numeric 으로 저장해도 줄마다 버림한 합과 합계에서 버림한 값은 다릅니다.
정해야 하는 것은 두 가지입니다.
첫째, 버림, 반올림, 올림 중 무엇인가. 이 규칙은 회사마다 다를 수 있습니다. 거래처와 주고받는 문서가 서로 다르게 계산하면 1원 차이로 대조가 맞지 않습니다.
둘째, 줄마다 처리하는가, 합계에서 한 번 처리하는가. 줄마다 처리하면 줄의 세액을 더한 값이 합계 세액과 일치합니다. 합계에서 처리하면 줄에는 세액을 표시하지 않거나, 표시하더라도 합과 맞지 않을 수 있습니다.
어느 쪽이 옳은지는 시스템이 정할 일이 아니라고 생각합니다. 회사가 지금 쓰는 서식과 거래처의 관행, 필요하면 세무 담당자의 확인을 따릅니다. 시스템이 할 일은 그 규칙을 한 함수에만 두는 것입니다. 견적서, 전표, 세금계산서가 각자 부가세를 계산하면 같은 거래가 문서마다 1원씩 달라집니다.
계산한 값을 저장할지 매번 계산할지
공급가액, 부가세, 합계 중 합계는 앞의 둘에서 계산할 수 있습니다. 그래도 거래 문서에는 세 값을 모두 저장하는 편이 맞다고 봅니다.
끝수 처리 규칙은 바뀔 수 있습니다. 규칙을 바꾼 뒤 지난 전표의 부가세를 매번 새 규칙으로 계산하면, 이미 거래처에 보낸 금액과 화면의 금액이 달라집니다. 발행한 문서의 금액은 그때 계산한 값을 그대로 박아 두어야 합니다. 저장한 합계가 공급가액과 부가세의 합과 맞는지는 저장할 때 한 번 검사합니다.
여기까지가 확실한 부분
부동소수점의 부정확함, numeric 권고, integer 와 bigint 의 범위는 PostgreSQL 문서 기준이고, 안전한 정수 범위는 JavaScript 명세의 값입니다. 부가세의 끝수를 어떻게 처리해야 하는지는 이 글에서 정하지 않았습니다. 세법이나 회사 규칙에 관한 판단은 세무 담당자와 확인해야 합니다. ORM 이 numeric 과 bigint 를 어떤 JavaScript 타입으로 돌려주는지는 도구마다 다릅니다.
함께 읽기
- 감사 로그와 변경 이력 설계거래처의 결제 조건이 「월말 마감 30일」에서 「월말 마감 60일」로 바뀌어 있습니다. 거래처 테이블에는 updatedAt 이 있어서 어제 오후 3시에 바뀌었다는 것은 압니다.
- 소프트 삭제(deletedAt)의 비용과 대안품목 코드 RM-001 을 잘못 만들어서 지웠다고 해 보겠습니다. 소프트 삭제라 행은 남고 deletedAt 에 시각이 찍힙니다. 이제 같은 코드로 품목을 다시 만들려고 하면 오류가 납니다.
- 복합 인덱스 칸 순서 정하는 법여러 회사가 함께 쓰는 업무 시스템에 전표 테이블이 있고, 인덱스가 이렇게 걸려 있다고 해 보겠습니다.
- 트랜잭션 경계는 어디서 나눠야 할까?출하 등록 버튼 하나가 서버에서 하는 일을 적어 보면 이렇습니다.
- 동시 수정 처리: 트랜잭션 격리 수준과 낙관적 잠금두 사람이 같은 거래처 화면을 열었다고 해 보겠습니다. 한 사람은 전화번호를 고치고 저장합니다. 1분 뒤 다른 사람이 주소를 고치고 저장합니다. 두 번째 저장이 끝나면 전화번호는 옛 번호로 돌아가 있습니다.