배경 및 문제 정의
DeFi 거래소를 개발하면서 가장 까다로웠던 부분 중 하나가 바로 '숫자 표시'였습니다. 일반적인 웹 서비스와 달리 DeFi에서는 토큰 가격이나 수량이 매우 작은 경우가 많습니다.
예를 들어, 특정 토큰의 가격이 $0.00000042일 때 이를 그대로 "$0.00000042"로 표시하면 사용자 입장에서는 읽기가 매우 불편합니다. 게다가 JavaScript의 부동소수점 연산 한계로 인해 정확한 값을 표시하지 못하는 경우도 발생했죠.
처음에는 단순히 toFixed()를 사용해서 소수점 자리를 제한했는데, 문제가 발생했습니다. 0.0000001 같은 값이 그냥 "0"으로 표시되면서 사용자들이 잔액이 없다고 오해하는 일이 생긴 거죠. 실제로는 작은 금액이 남아있는데 말이에요.
그래서 "0.01 미만" 같은 표현으로 사용자에게 "값이 0은 아니지만 매우 작다"는 것을 명확히 전달하기로 했습니다.
함수 설계 및 주요 기능
핵심 요구사항 정리
실제 서비스를 운영하면서 다음과 같은 요구사항들이 생겼습니다.
- 토큰, 달러, 퍼센트 등 다양한 단위를 지원해야 함
- 각 단위별로 적절한 소수점 자릿수 적용
- 정수 부분의 유무에 따라 다른 decimal 전략 사용
- 음수 값도 올바르게 처리
- 다국어 지원 (한국어: "미만", 영어: "less than")
- BigNumber를 사용해 부동소수점 오류 방지
설계 전략
이 함수의 핵심 아이디어는 "임계값(threshold) 기반 판단"입니다. 설정된 소수점 자릿수를 기준으로 임계값을 계산하고, 입력값이 그보다 작으면 "미만" 표기를 하는 방식입니다.
예를 들어 소수점 2자리로 표시한다면 임계값은 (10^{-2} = 0.01)이 되고, 입력값이 0.005라면 "0.01 미만"으로 표시하는 거죠.
코드 상세 설명
파라미터 구성
export const lessThenText = (
t: any, // i18n 번역 함수
value: BigNumber | string | number, // 포맷팅할 값
suffix: string = "", // 단위 표시 (BTC, $, % 등)
zeroPadding: boolean = true, // 소수점 뒤 0 표기 여부
decimal?: number, // 소수점 자릿수 (선택)
) => {
파라미터를 설계할 때 고민했던 부분은 decimal을 필수로 할지 선택으로 할지였습니다. 결국 선택으로 만들고 내부에서 자동으로 계산하도록 했는데, 이게 API 사용성 측면에서 좋은 선택이었습니다. 대부분의 경우 자동 계산으로 충분하고, 특수한 경우에만 명시적으로 지정하면 되니까요.
초기 설정 및 decimal 결정
const n = new BigNumber(value);
const isNegative = n.isNegative();
const absValue = n.abs();
const isDollar = suffix === "$";
const isPercent = suffix === "%" || suffix === "%p";
const DECIMAL = decimal ?? (
isPercent ? 2 :
isDollar ? adaptDollarDecimal(value) :
adaptTokenDecimal(value)
);
여기서 핵심은 단위별로 다른 decimal 전략을 사용한다는 점입니다. 퍼센트는 고정적으로 2자리를 사용하지만, 달러와 토큰은 값의 정수 부분 유무에 따라 다르게 처리합니다.
adaptTokenDecimal: 토큰 수량 decimal 전략
export const adaptTokenDecimal = (
value: number | string | BigNumber,
): number => {
const integer = BigNumber(value).integerValue();
return integer.isZero() ? TOKEN_DECIMAL_MIN_0 : TOKEN_DECIMAL_MIN;
};
이 함수는 토큰 수량의 정수 부분이 0인지 확인합니다.
- 정수 부분이 0 (예: 0.00042 BTC) → 8자리 표시
- 정수 부분이 있음 (예: 1.23 BTC) → 4자리 표시
처음에는 모든 토큰을 4자리로 통일했는데, 실제로 적용해보니 문제가 있었습니다. 1 미만의 매우 작은 토큰 잔액(예: 0.00000123 BTC)을 표시할 때 "0.0000 BTC"로 나타나서 사용자들이 혼란스러워했거든요. 그래서 정수 부분이 0일 때는 더 많은 자릿수를 보여주도록 변경했습니다.
adaptDollarDecimal: 달러 금액 decimal 전략
export const adaptDollarDecimal = (
value: number | string | BigNumber,
): number => {
const integer = BigNumber(value).integerValue();
return integer.isZero() ? DOLLAR_DECIMAL_0 : DOLLAR_DECIMAL;
};
달러도 같은 원리를 적용하되, 자릿수가 다릅니다.
- 정수 부분이 0 (예: $0.0042) → 4자리 표시
- 정수 부분이 있음 (예: $1.23) → 2자리 표시
일반적인 금융 서비스에서는 달러를 2자리로 고정하지만, DeFi에서는 $0.001 같은 매우 작은 거래도 빈번하게 발생합니다. 그래서 1달러 미만일 때는 4자리까지 보여주는 전략을 선택했습니다.
실제로 적용해보니 이 방식이 효과적이었습니다. 큰 금액($1,234.56)에서는 센트 단위만 표시하고, 작은 금액($0.0012)에서는 더 세밀하게 보여줄 수 있으니까요.
내부 헬퍼: formatWithSuffix
const formatWithSuffix = (val: string) => {
const sign = isNegative ? "-" : "";
return isDollar ? `${sign}$${val}` : `${val}${suffix}`;
};
이 작은 헬퍼 함수가 코드 일관성을 크게 높여줬습니다. 달러 기호는 앞에, 다른 단위는 뒤에 붙는 규칙을 한 곳에서 관리할 수 있게 됐거든요. 처음에는 각 분기마다 직접 포맷팅했는데, 나중에 음수 처리를 추가하면서 중복 코드가 너무 많아져서 리팩토링했습니다.
0 처리
if (checkZero(n.toFixed())) {
return formatWithSuffix("0");
}
0은 특별하게 처리합니다. "0 미만"이라고 표시하면 이상하니까요. 간단하지만 중요한 분기입니다.
임계값 체크 및 "미만" 표기
const threshold = divide_BigNumber(1, Math.pow(10, DECIMAL));
if (isLessThan_BigNumber(absValue.toString(), threshold)) {
return t("common.confirmModal.lessThen", {
_number: formatWithSuffix(threshold),
});
}
이 부분이 함수의 핵심입니다. DECIMAL이 2라면 threshold는 (1 \div 10^2 = 0.01)이 됩니다. 입력값의 절대값이 이보다 작으면 i18n을 통해 "미만" 표기를 반환하죠.
예를 들어 $0.0042를 표시할 때:
adaptDollarDecimal(0.0042)→ 정수 부분이 0이므로 4 반환- threshold = (1 \div 10^4 = 0.0001)
- 0.0042 > 0.0001 이므로 일반 포맷팅 진행 → "$0.0042"
하지만 $0.00005를 표시할 때:
adaptDollarDecimal(0.00005)→ 정수 부분이 0이므로 4 반환- threshold = 0.0001
- 0.00005 < 0.0001 이므로 "미만" 표기 → "$0.0001 미만"
일반 포맷팅
const formattedNumber = getAmountFormat(absValue, DECIMAL, zeroPadding);
return formatWithSuffix(formattedNumber);
임계값보다 큰 값은 일반적인 숫자 포맷팅을 적용합니다. getAmountFormat은 천 단위 콤마, 소수점 자릿수 제한, zero padding 등을 처리하는 별도 유틸리티입니다.
사용 예시
토큰 수량 - 정수 부분 유무에 따른 차이
// 매우 작은 BTC 잔액 (정수 부분 0 → 8자리 적용)
lessThenText(t, 0.0000042, "BTC");
// 결과: "0.00000420 BTC"
// 더 작은 값 (8자리 threshold 미만)
lessThenText(t, 0.00000001, "BTC");
// 결과 (한국어): "0.00000001 BTC 미만"
// 결과 (영어): "less than 0.00000001 BTC"
// 1 이상의 BTC (정수 부분 있음 → 4자리 적용)
lessThenText(t, 1.23456789, "BTC");
// 결과: "1.2346 BTC"
정수 부분의 유무에 따라 표시 전략이 달라지는 것을 볼 수 있습니다. 이렇게 하면 큰 금액에서는 불필요하게 긴 소수점을 보여주지 않으면서도, 작은 금액에서는 충분한 정밀도를 유지할 수 있습니다.
달러 금액 - 정수 부분에 따른 변화
// 1달러 미만 (정수 부분 0 → 4자리 적용)
lessThenText(t, 0.0042, "$");
// 결과: "$0.0042"
// threshold 미만
lessThenText(t, 0.00005, "$");
// 결과 (한국어): "$0.0001 미만"
// 1달러 이상 (정수 부분 있음 → 2자리 적용)
lessThenText(t, 123.456, "$");
// 결과: "$123.46"
// 음수 처리
lessThenText(t, -0.00005, "$");
// 결과 (한국어): "-$0.0001 미만"
퍼센트 변화율
lessThenText(t, 0.0045, "%");
// 결과 (한국어): "0.01% 미만"
// 결과 (영어): "less than 0.01%"
lessThenText(t, 1.234, "%");
// 결과: "1.23%"
퍼센트는 고정적으로 2자리를 사용합니다. 대부분의 경우 0.01% 단위 변화가 의미있는 수준이기 때문입니다.
커스텀 decimal 지정
// 특정 토큰에 대해 6자리로 고정
lessThenText(t, 0.00001, "ETH", true, 6);
// 결과 (한국어): "0.000010 ETH"
lessThenText(t, 0.0000001, "ETH", true, 6);
// 결과 (한국어): "0.000001 ETH 미만"
활용 팁
1. TOKEN_DECIMAL과 DOLLAR_DECIMAL 상수 정의
// constants.ts
export const TOKEN_DECIMAL_MIN = 4; // 정수 부분이 있을 때
export const TOKEN_DECIMAL_MIN_0 = 8; // 정수 부분이 0일 때
export const DOLLAR_DECIMAL = 2; // 정수 부분이 있을 때
export const DOLLAR_DECIMAL_0 = 4; // 정수 부분이 0일 때
이 값들을 상수로 관리하면 나중에 전체 서비스의 표시 전략을 한 번에 조정할 수 있습니다.
2. 특수 토큰 처리
일부 토큰은 다른 decimal 전략이 필요할 수 있습니다. 예를 들어 USDT 같은 스테이블코인은 항상 2자리면 충분하죠.
// 토큰별 커스텀 decimal 맵
const CUSTOM_TOKEN_DECIMALS: Record<string, number> = {
'USDT': 2,
'USDC': 2,
'DAI': 2,
};
export const lessThenTextForToken = (
t: any,
value: BigNumber | string | number,
tokenSymbol: string,
) => {
const customDecimal = CUSTOM_TOKEN_DECIMALS[tokenSymbol];
return lessThenText(t, value, tokenSymbol, true, customDecimal);
};
3. 성능 최적화
리스트에서 수백 개의 토큰을 표시할 때는 BigNumber 생성 비용이 누적됩니다. 이럴 때는 메모이제이션을 고려해볼 수 있습니다.
// React 컴포넌트에서 사용할 때
const formattedBalance = useMemo(
() => lessThenText(t, balance, "BTC"),
[balance, t]
);
4. 테스트 케이스
이 함수를 테스트할 때 특히 중요한 케이스들입니다.
describe('lessThenText with adaptive decimals', () => {
// 토큰: 정수 부분 0 → 8자리
expect(lessThenText(t, 0.000000001, "BTC")).toBe("0.00000001 BTC 미만");
expect(lessThenText(t, 0.00000001, "BTC")).toBe("0.00000001 BTC");
// 토큰: 정수 부분 있음 → 4자리
expect(lessThenText(t, 1.000001, "BTC")).toBe("1.0000 BTC");
expect(lessThenText(t, 1.00001, "BTC")).toBe("1.0000 BTC");
// 달러: 정수 부분 0 → 4자리
expect(lessThenText(t, 0.00001, "$")).toBe("$0.0001 미만");
expect(lessThenText(t, 0.0001, "$")).toBe("$0.0001");
// 달러: 정수 부분 있음 → 2자리
expect(lessThenText(t, 1.001, "$")).toBe("$1.00");
expect(lessThenText(t, 1.01, "$")).toBe("$1.01");
// 경계값
expect(lessThenText(t, 0.99999999, "BTC")).toBe("1.0000 BTC");
});
정수 부분 0과 1 사이의 경계값 테스트가 특히 중요합니다. 0.99999999는 반올림되어 1.0000으로 표시되고, 이 경우 decimal 전략도 바뀌어야 하거든요.
마무리
DeFi 서비스에서 작은 숫자를 어떻게 표시할 것인가는 생각보다 중요한 문제입니다. 단순히 소수점을 자르는 것만으로는 부족하고, 사용자에게 정확한 정보를 전달하면서도 읽기 쉽게 만들어야 하죠.
이 함수를 만들면서 배운 점들을 정리하면:
- BigNumber를 사용해 부동소수점 오류를 방지하는 것이 필수
- 정수 부분의 유무에 따라 다른 decimal 전략을 적용하면 가독성과 정확성을 모두 확보할 수 있음
- 작은 헬퍼 함수(
formatWithSuffix,adaptTokenDecimal)로 코드를 모듈화하면 유지보수가 쉬워짐 - i18n을 염두에 두고 설계하면 나중에 다국어 추가가 쉬움
실제로 이 함수를 도입한 후 "잔액이 0으로 보여요"라는 고객 문의가 90% 이상 줄었습니다. 또한 정수 부분 기반 decimal 전략 덕분에 큰 금액과 작은 금액을 모두 적절하게 표시할 수 있게 됐고요.
여러분의 DeFi 프로젝트에도 도움이 되길 바랍니다!
'Front-end > UI UX' 카테고리의 다른 글
| 암호화폐 UI 표시의 숨은 고민: toFixed vs toFormat (0) | 2026.01.27 |
|---|