소수 입력값을 저장하는 기능을 작업하다가 요청 본문에서 예상하지 못한 숫자를 발견했다. 화면에 입력한 값은 3.57이었다. 퍼센트를 비율로 바꿔 보내는 필드라 기대한 값은 0.0357이었는데, 실제 요청에는 긴 소수 꼬리가 붙었다.
3.57 / 100;
// 0.035699999999999996
0.1 + 0.2가 정확히 0.3이 되지 않는다는 사실은 알고 있었다. 하지만 입력한 값이 서버로 넘어가고, 저장된 뒤 다시 화면으로 돌아오는 흐름 안에서 만나니 확인할 범위가 달라졌다.
작은 비율 하나가 금액 계산에 연결되는 화면이었다. 표시만 다듬으면 되는지, 실제로 저장할 값까지 달라지는지부터 구분해야 했다.
같은 함수를 쓰는데 어떤 값에서만 드러났다
1.23 / 100; // 0.0123
3.57 / 100; // 0.035699999999999996
첫 번째 결과는 깔끔해 보인다. 그렇다고 내부에서 십진 소수가 정확히 표현됐다는 뜻은 아니다. JavaScript의 Number는 이진 부동소수점을 사용하고, 많은 십진 소수는 근삿값으로 저장된다. 그 값을 문자열로 나타냈을 때 짧게 보이기도 하고 긴 꼬리가 드러나기도 한다.
그래서 몇 개의 입력에서 결과가 정상처럼 보인다는 이유로 공통 변환 함수를 신뢰하기는 어려웠다. 확인해야 할 것은 특정 숫자의 모양보다 그 함수가 어떤 입력 범위와 정밀도를 보장하는가였다.
서버의 숫자 타입은 브라우저까지 따라오지 않는다
서버 쪽에서는 십진 소수를 정해진 정밀도 안에서 다루는 자료형을 사용한다. 하지만 프론트엔드에서 받는 숫자는 일반적인 JavaScript Number다. 같은 숫자를 주고받아도 두 환경의 표현 방식은 다르다.
JSON 숫자를 일반적으로 파싱하는 경로. 서버의 자료형 정보는 브라우저에 유지되지 않는다.
다음 응답에는 숫자의 십진 표현이 들어 있지만, 서버가 사용한 자료형 정보까지 담겨 있지는 않다.
{ "rate": 0.0357 }
일반적인 JSON 파싱을 거치면 이 값은 Number가 된다. 반대로 프론트에서 계산한 근삿값을 보내면, 서버가 정확한 십진 자료형을 사용하더라도 사용자가 원래 의도한 값을 자동으로 알아내지는 못한다. 정해진 반올림 규칙에 따라 같아질 수는 있지만, 그것은 별도의 계약이다.
문자열 응답은 십진 표현을 보존하는 방법이 될 수 있다.
{ "rate": "0.0357" }
다만 프론트에서 곧바로 Number()를 적용하면 다시 이진 근사 표현으로 바뀐다. 정밀 계산이 필요하다면 원본 문자열에서 해당 계산 타입으로 들어가야 한다. 이미 잃은 정보를 나중에 정밀 계산 도구로 복구할 수는 없다.
서버가 정확한 타입을 쓴다는 사실과, 화면을 거친 값이 보존된다는 사실은 따로 확인해야 한다. 타입을 바꾸는 지점마다 무엇이 보존되는지 봐야 한다.
입력값, 표시값, 전송값을 나눠 봐야 했다
const percent = 0.0035 * 100;
// 0.35000000000000003
percent.toFixed(2);
// "0.35"
화면에는 0.35를 보여줄 수 있다. 그러나 표시 문자열을 만들었다고 해서 percent의 값이 바뀌지는 않는다. 저장 함수가 내부 값을 가져가면 화면에 보이지 않던 숫자가 요청 본문에 들어갈 수 있다.
| 구분 | 지켜야 하는 것 |
|---|---|
| 입력 문자열 | 빈값과 0, 입력 중인 소수점 등 사용자의 편집 상태 |
| 계산용 값 | 계산에 필요한 단위·범위·정밀도 |
| 표시 문자열 | 화면 목적에 맞는 자릿수와 단위 |
| 전송값 | API가 받기로 한 표현·단위·정밀도 |
입력 문자열의 "", "0", "0."는 다른 상태다. 입력할 때마다 숫자로 강제 변환하면 이런 차이가 사라질 수 있다. 편집 상태는 유지하고, 제출 시점에 유효성을 확인해 전송값으로 바꾸는 식으로 책임을 나눌 수 있다.
숫자로 변환하는 함수도 별도로 검토해야 한다.
parseFloat("123abc");
// 123
Number("9007199254740993");
// 9007199254740992
첫 번째는 잘못된 문자열의 일부를 받아들이고, 두 번째는 원래 정수 값을 잃는다. 유효한 형식인지, 유한한 값인지, 범위와 자릿수가 맞는지는 각각 확인해야 한다. 변환 실패를 무조건 0으로 치환하면 오류와 실제 0을 구분하지 못한다.
“소수 두 자리”보다 먼저 단위를 맞춘다
퍼센트를 입력받는 화면과 비율을 저장하는 서버가 모두 “소수 두 자리”라고 이해하면, 서로 다른 정밀도를 구현할 수 있다.
0.035699999999999996단위가 바뀌면 필요한 소수 자릿수도 바뀐다. 전송 표현은 API 계약으로 정한다.
그래서 자릿수는 단위와 함께 합의해야 한다. 다음은 이 필드에 적용할 수 있는 계약의 예시다. 실제 서비스의 정책을 그대로 옮긴 것은 아니다.
| 항목 | 계약 예시 |
|---|---|
| 입력 | 0~100%, 소수 둘째 자리까지 허용 |
| 초과 자릿수 | 자동 반올림하지 않고 입력 오류로 안내 |
| 전송 | 비율 단위, 소수 넷째 자리의 십진 문자열 |
| 서버 | 같은 범위·정밀도를 검증하고 십진 값으로 해석 |
| 저장 | 해당 범위와 소수 자릿수를 보존할 수 있는 타입 |
| 재조회 | 같은 의미의 십진 문자열 반환 |
문자열 전송은 서버가 받기로 합의했을 때 사용할 수 있다. 기존 API가 숫자만 받는다면 프론트 혼자 문자열로 바꾸면 안 된다. 필요한 정확성을 그 계약으로 충족할 수 있는지부터 확인해야 한다.
계약이 정해지면 변환 코드의 범위도 작아진다
위 예시처럼 퍼센트 소수 두 자리만 받는다면, 허용된 문자열을 작은 정수로 해석한 뒤 십진 문자열을 만들 수 있다. 아래 코드는 음수·지수 표기·구분자 없이 0~100%만 허용하는 제한된 예시다.
function percentToRateText(input) {
const match = /^(0|[1-9]\d{0,2})(?:\.(\d{1,2}))?$/.exec(input);
if (!match) throw new Error("퍼센트 입력 형식을 확인하세요.");
// 3.57% → 357: 0.01% 단위의 작은 정수
const units = Number(match[1]) * 100
+ Number((match[2] ?? "").padEnd(2, "0"));
if (units > 10000) throw new Error("100%를 초과할 수 없습니다.");
// 357 → "0.0357": 마지막까지 십진 문자열로 전송
const whole = Math.floor(units / 10000);
const fraction = String(units % 10000).padStart(4, "0");
return `${whole}.${fraction}`;
}
percentToRateText("3.57"); // "0.0357"
percentToRateText("100"); // "1.0000"
여기서 중요한 것은 문자열을 다루는 기법 자체보다 허용 범위, 단위, 출력 형식을 코드에서 설명할 수 있다는 점이다. 범위를 제한했으므로 중간 정수도 안전하다. 더 다양한 단위와 고정밀 계산이 필요하다면 검증된 십진 계산 도구를 선택하는 편이 적절할 수 있다.
표시를 위한 역변환도 같은 계약을 따라야 한다. 양방향에 무조건 같은 반올림 함수를 넣는 것이 아니라, 각 방향에서 의미를 보존하는지 확인해야 한다.
toPrecision(12)나 toFixed(4)로 특정 숫자의 꼬리를 없애는 것만으로는 이 계약을 대신할 수 없다. 왜 그 자릿수가 유효한지, 범위를 넘어선 입력은 어떻게 처리할지가 먼저다.
해결 여부는 저장하고 다시 꺼내서 확인한다
변환 함수의 반환값만 맞으면 끝이라고 생각하기 쉽다. 하지만 요청 직렬화, 서버 파싱, 저장 타입, 재조회 변환 중 어느 곳에서도 값이 바뀔 수 있다. 저장소의 자릿수 제한이 반올림인지 오류인지도 실제 동작을 확인해야 한다.
- 허용된 입력이 저장·재조회 뒤 같은 의미의 값으로 돌아오는가?
- 조회한 값을 수정 없이 다시 저장해도 값이 유지되는가?
- 표시 문자열뿐 아니라 전송값과 저장값도 계약에 맞는가?
- 최솟값·최댓값·초과 자릿수와 잘못된 문자열을 의도대로 처리하는가?
- 빈값과 실제 0을 서로 다른 상태로 처리하는가?
여기서 보존할 대상은 입력 문자열의 모양까지 항상 동일하다는 뜻은 아니다. 3.5와 3.50을 같은 값으로 정규화할 수 있다. 무엇을 같은 값으로 볼지도 계약에 포함된다.
처음 발견한 것은 긴 소수 꼬리였지만, 수정의 기준은 숫자를 보기 좋게 만드는 데 둘 수 없었다. 이제 이런 필드를 보면 단위와 유효 자릿수, 변환 위치, 서버의 해석 방식부터 확인하게 된다.
숫자 입력 기능의 완료 조건은 화면에 올바르게 보이는 것보다 넓다. 사용자가 입력한 의미가 전송·저장·재조회 뒤에도 유지되는지까지 확인해야 한다.
'D.evelop > JS, TS' 카테고리의 다른 글
| [02] JavaScript 금액 계산 — 반올림과 잔차 배분으로 합계 지키기 (0) | 2026.10.02 |
|---|---|
| [JS] 널 병합연산자 Nullish Coalescing Operator (0) | 2024.10.27 |
| [JS] 날짜 형식 커스텀 yyyy-mm-dd (0) | 2023.07.19 |
| [JS] value로 key값 찾기 (0) | 2023.07.19 |
| [Ajax]비동기 처리(with jQuery) (0) | 2023.02.03 |
댓글