- Published on
페이지마다 항공권 요금이 달랐던 이유
페이지마다 항공권 요금이 달랐던 이유
들어가며
요금 계산은 복잡합니다. 대부분의 서비스에서 정률 할인과 정액 할인을 제공하는데, 실제로 할인이 적용된 금액을 보면 단순히 금액 × 할인율로 계산된 값이 그대로 표시되지 않습니다. 내부 정책에 따라 다양한 조건이 추가로 적용되기 때문입니다. 예를 들어, 최대 할인 금액 제한이나 최소 할인 금액 보장이 있고, 특정 자리수에서 절상 또는 절하 처리가 되기도 합니다. 또한 퍼센트 계산에 포함되는 범위(기준 금액)도 서비스나 상품에 따라 달라질 수 있습니다.
이런 복잡한 계산 로직이 여러 페이지에 분산되어 있고, 각 페이지를 다른 개발자가 구현하다 보면 문제가 발생합니다.
문제 상황: 같은 항공권인데 페이지마다 다른 요금?
(개발 단계에서 발견) 항공권 예약 시스템에서 같은 항공권인데도 페이지마다 다른 요금이 표시되는 문제가 발생했습니다. 전체 구매 플로우가 페이지 단위로 각기 다른 개발자에 의해 구현되다 보니, 할인 계산 로직이 페이지마다 제각각인 문제가 있었습니다. 결과적으로 사용자가 단계를 넘어갈 때마다 최종 금액이 달라지는 현상을 겪게 되었습니다.
검색 리스트 페이지 할인 금액 UI

예약 페이지 할인 금액 UI

구체적인 문제 사례
문제는 크게 두 가지로 나뉩니다:
1. 완전히 엉뚱한 할인 금액: 공통 문서 노후화
문제 상황:
- 리스트 페이지: 할인 금액 OOOO원
- 예약 페이지: 할인 금액 XXXX원
- 결제 페이지: 할인 금액 DDDD원
- 예약 완료 페이지: 할인 금액 QQQQ원
원인:
- 공통 할인 금액 계산 문서가 노후화되어 최신 규칙을 반영하지 못함
- 엣지 케이스에 대한 내용이 문서에 누락됨
- 각 개발자가 알고 있는 히스토리가 달라서 서로 다른 계산 로직을 구현
해결:
- PM과의 논의: 엣지 케이스에 대한 명확한 규칙을 PM과 논의하여 정리
- 공통 문서 업데이트: 최신 규칙과 엣지 케이스를 반영한 공통 문서 작성
- 코드 공통화: 계산 로직을
@privia/flight-core모듈로 추출하여 모든 UI에서 동일한 로직 사용 - 유닛 테스트 코드 추가: 엣지 케이스를 포함한 다양한 시나리오에 대한 테스트 코드 작성
이 문제는 공통화를 통해 비교적 간단하게 해결할 수 있었습니다.
2. 10원, 1원의 오차: JavaScript 부동소수점 계산 정확도 문제
문제 상황:
- 리스트 페이지: 할인 금액 OOOO원
- 예약 페이지: 할인 금액 OOOO원 (일치)
- 결제 페이지: 할인 금액 XXXX원 (1원 또는 10원 차이)
- 예약 완료 페이지: 할인 금액 OOOO원 (일치)
원인:
- 결제 페이지는 타팀에서 개발 중이었고, 결제 API를 사용
- 결제 API에서는 Java로 계산한 할인 값을 그대로 UI에 노출
- 우리 팀은 프론트엔드에서 JavaScript로 직접 계산
원인 분석: Java와 JavaScript의 계산 정확도 차이
결제 페이지는 할인 금액 계산 결과를 API를 통해 받고 있었고(Java로 계산), 우리 팀에서는 할인 금액 계산 결과를 프론트엔드에서 JavaScript로 직접 계산해야 했습니다.
언어적인 정확도 차이였을까?
기술적으로 Java의 double 타입과 JavaScript의 number 타입은 모두 IEEE 754 double precision 표준을 따릅니다. 따라서 기본적인 부동소수점 연산 방식은 동일합니다.
하지만 다음과 같은 차이가 있습니다:
- Java: 금융 계산에는
BigDecimal을 사용하는 것이 업계 표준입니다.BigDecimal은 임의 정밀도 산술을 제공하여 부동소수점 오류 없이 정확한 계산을 보장합니다. - JavaScript: 언어 자체에
BigDecimal과 같은 정밀도 보장 타입이 없습니다.number타입만 사용하므로 부동소수점 오류가 발생합니다.
따라서 결제 API(Java)에서는 BigDecimal을 사용하여 정확한 계산을 하고 있었지만, 프론트엔드(JavaScript)에서는 number 타입으로 계산하다 보니 부동소수점 오류가 발생한 것입니다.
왜 하필 10원과 1원 차이가 발생했을까?
핵심은 절상/절삭 처리와 부동소수점 오차의 상호작용입니다:
M포인트 할인: 10원 단위 절삭 (
Math.floor(value / 10) * 10)- 부동소수점 오차가 10원 단위 경계값 근처에서 발생하면 10원 차이로 증폭
- 예:
72,510.0→72,509.99999999999→Math.floor(72,509.99999999999 / 10) * 10=72,500(10원 차이)
청구 할인: 소수점 절상 (
Math.ceil())- 부동소수점 오차가 정수 경계에서 발생하면 1원 차이로 증폭
- 예:
96,425.0→96,425.00000000001→Math.ceil()=96,426(1원 차이)
각 페이지에서 계산 순서가 다르면 오차의 방향이 달라져서 최종 결과가 달라집니다.
JavaScript Number 타입의 정밀도 한계
JavaScript의 숫자 타입은 IEEE 754 표준을 따르는 64비트 부동소수점 형식입니다. 일반적으로 약 2^53 (9,007,199,254,740,992) 이상의 정수에서 정밀도 문제가 발생한다고 알려져 있지만, 실제로는 소수점 연산에서는 훨씬 작은 숫자에서도 오류가 발생합니다.
정수 연산 vs 소수점 연산의 차이
// ✅ 정수 연산: 큰 숫자까지도 정확
// Number.MAX_SAFE_INTEGER = 9,007,199,254,740,992
1000000 + 2000000 // 3000000 (정확)
// ❌ 소수점 연산: 작은 숫자에서도 오류 발생
0.1 + 0.2 // 0.30000000000000004 (오류 발생)
1234567 * 0.05 // 61728.350000000006 (오류 발생)
왜 항공권 금액 계산에서 문제가 발생했는가?
항공권 금액은 성인 1인 기준 보통 몇 만원에서 수천만원 수준입니다. 실제로 확인한 항공권은 최대 약 1,000만원 수준까지 있었습니다. 이는 Number.MAX_SAFE_INTEGER(약 9,007조)보다 훨씬 작지만, 그럼에도 불구하고 부동소수점 오류가 발생하는 이유는 다음과 같습니다.
1. 소수점 곱셈 연산의 특성
부동소수점 오류는 숫자의 절대 크기가 아니라 소수점 표현의 한계 때문에 발생합니다. 할인율(0.05, 0.07, 0.1 등)을 곱하는 연산에서 문제가 발생합니다. 특히 0.07 같은 소수는 2진수로 정확히 표현할 수 없어 매우 작은 숫자에서도 오차가 명확하게 나타납니다:
// 예시 1: 매우 작은 숫자에서도 오차 발생
// 정확한 값: 3 × 0.07 = 0.21
3 * 0.07 // 0.21000000000000002 (소수점 표현 한계로 인한 오차)
// 예시 2: 작은 금액에서도 문제 발생
// 정확한 값: 100 × 0.07 = 7.0
100 * 0.07 // 7.000000000000001 (소수점 표현 한계로 인한 미세한 오차)
핵심 포인트:
3 × 0.07같은 매우 작은 숫자에서도 오차 발생- 숫자의 크기와 무관하게 소수점 곱셈 연산에서 항상 오차 가능성 존재
- 금액 계산에서는 이 작은 오차가 반올림/절상 처리 시 최종 결과에 영향을 미침
2. IEEE 754 부동소수점 표현의 한계
IEEE 754 표준은 소수를 2진수로 표현하는데, 일부 소수(특히 0.1, 0.05, 0.07 등)는 2진수로 정확히 표현할 수 없습니다:
0.07 (10진수) = 0.00010001111010111000010100011110101110000101... (2진수, 무한 반복)
0.05 (10진수) = 0.000011001100110011... (2진수, 무한 반복)
0.07은 0.05보다 더 복잡한 무한 반복 패턴을 가지므로, 더 작은 숫자에서도 오차가 명확하게 나타납니다. 이러한 무한 반복 소수를 유한한 비트로 표현하다 보면 근사값이 되고, 이로 인해 계산 오차가 발생합니다.
3. 연속된 계산에서의 오차 누적
할인 계산은 여러 단계로 이루어지며, 각 단계에서 발생한 오차가 누적됩니다. 특히 0.07 같은 복잡한 소수는 각 단계에서 더 큰 오차를 만들어냅니다:
// 실제 항공권 할인 계산 과정
1단계: 프로모션 항공권료 = 1,500,000 - 50,000 = 1,450,000 // 정수 연산, 정확
2단계: M포인트 할인 = 1,450,000 × 0.05 = 72,500.00000000001 // 소수점 오차 발생
3단계: 10원 단위 절삭 = Math.floor(72,500.00000000001 / 10) * 10 = 72,500 // 오차가 작아서 문제 없음
4단계: 청구 할인 = (1,450,000 - 72,500) × 0.07 = 96,425.00000000001 // 0.07 곱셈에서 오차 발생
5단계: 소수점 절상 = Math.ceil(96,425.00000000001) = 96,426 // ❌ 1원 오차 발생!
해결 방안: Big.js 라이브러리 도입
정밀한 금액 계산을 위해 big.js 라이브러리를 도입했습니다. big.js는 임의 정밀도 산술을 제공하여 부동소수점 오류 없이 정확한 계산을 보장합니다.
라이브러리 선택: Big.js vs 다른 대안들
정밀한 숫자 계산을 위한 여러 라이브러리 후보를 검토한 결과, big.js를 선택했습니다:
| 라이브러리 | 크기 | API 복잡도 | 성능 | 브라우저 호환성 |
|---|---|---|---|---|
| big.js | ~5KB | ⭐⭐⭐ 매우 간단 | ⭐⭐⭐ 우수 | ⭐⭐⭐ 모든 브라우저 |
| decimal.js | ~30KB | ⭐⭐ 복잡 | ⭐⭐ 보통 | ⭐⭐⭐ 모든 브라우저 |
| bignumber.js | ~20KB | ⭐⭐ 복잡 | ⭐⭐ 보통 | ⭐⭐⭐ 모든 브라우저 |
| Native BigInt | 0KB | ⭐ 정수만 | ⭐⭐⭐ 매우 빠름 | ⭐⭐ 최신 브라우저만 |
Big.js를 선택한 이유:
- 경량성: 약 5KB로 번들 크기 영향 최소화
- 간결한 API:
Big(value).mul(rate).toNumber()형태로 직관적이고 학습 곡선이 낮음 - 충분한 정밀도: 금액 계산에 필요한 정밀도를 완벽하게 제공
- 브라우저 호환성: IE11을 포함한 모든 브라우저 지원
- 소수점 연산 지원: Native BigInt와 달리 소수점 연산을 지원하여 할인율 계산에 적합
왜 금액 × 7 ÷ 100 방식으로 해결하지 않았는가?
할인율 계산에서 금액 × 0.07 대신 금액 × 7 ÷ 100 방식으로 해결할 수 있을 것 같습니다. 실제로 이 방법은 단순한 경우에는 더 정확한 결과를 제공합니다:
// 방법 1: 금액 × 0.07
123456 * 0.07 // 8641.920000000002 (❌ 오차 발생)
// 방법 2: 금액 × 7 ÷ 100
123456 * 7 // 864192 (✅ 정수 곱셈, 정확)
864192 / 100 // 8641.92 (✅ 단순한 경우 정확)
하지만 × 7 ÷ 100 방식은 다음과 같은 한계가 있습니다:
- 연속 계산에서의 오차 누적: 여러 단계 계산에서 중간값에 소수점이 포함되면 오차 발생
- 할인율 변경에 대한 유연성 부족: 관리적인 측면에서 예상하지 못한 변수(할인율 변경, 새로운 할인 정책 추가 등)가 생길 수 있는데, 각 할인율마다 다른 계산 로직이 필요하여 유지보수가 어렵습니다
- 코드로 명확화된 정확성 요구사항 부족:
Big()함수를 사용함으로써 이 부분이 정확한 계산을 요구하는 중요한 로직임을 코드만 봐도 명확하게 알 수 있습니다
따라서 big.js를 사용하면 모든 할인율에 대해 동일한 방식으로 처리할 수 있고, 연속된 복잡한 계산에서도 정확성을 보장할 수 있습니다.
실행 내용
1. M포인트 할인 계산 개선
개선 전:
// ❌ 부동소수점 오류 발생 가능
const mPointDiscount = promotionAirFare * 0.05
const mPointDiscountAmount = Math.floor(mPointDiscount / 10) * 10
개선 후:
// ✅ Big.js를 사용한 정확한 계산
const M_POINT_DISCOUNT_AMOUNT = Big(promotionAirFare).mul(0.05).div(10).floor().mul(10).toNumber() // 10원 단위 절삭
const mPointDiscountAmount = Math.min(M_POINT_DISCOUNT_AMOUNT, 200000)
2. 청구 할인 계산 개선
국제선 청구 할인 (개선 전):
// ❌ 부정확한 값에 소수점 절상 적용
const discountedBillAmount = Math.ceil((promotionAirFare - mPointDiscountAmount) * percentage)
국제선 청구 할인 (개선 후):
// ✅ Big.js를 사용한 정확한 계산 후 절상
const discountedBillAmount = Math.ceil(
Big(promotionAirFare - mPointDiscountAmount)
.mul(percentage)
.toNumber()
)
3. 공통화 전략 및 적용 범위
기존에는 각 페이지/컴포넌트마다 할인 계산 로직이 분산되어 있었습니다. 이를 @privia/flight-core 패키지의 공통 유틸리티로 추출하여 다음과 같은 이점을 얻었습니다:
사용 위치 (총 20+ 컴포넌트/페이지):
- 항공권 검색 결과 리스트 페이지
- 예약 전 가격 정보 섹션
- 예약 완료 페이지
- 메타 검색 페이지
- 공유 UI 컴포넌트
공통화의 이점:
- 일관성 보장: 모든 페이지에서 동일한 계산 로직 사용
- 유지보수성 향상: 버그 수정 시 한 곳만 수정하면 모든 사용처에 자동 반영
- 개발 생산성 향상: 새로운 페이지 개발 시 기존 로직 재사용 가능
- 버그 예방: 각 페이지마다 로직을 재구현할 때 발생할 수 있는 실수 방지
- 성능 최적화: 공통 모듈로 번들 크기 최적화 (중복 코드 제거)
결과 및 성과
1. 계산 정확도 개선
Before (기존 방식):
const price = 1234567
const discount = price * 0.05
// 결과: 61728.34999999999 ❌ (부정확)
After (Big.js 사용):
const price = 1234567
const discount = Big(price).mul(0.05).toNumber()
// 결과: 61728.35 ✅ (정확)
2. 프론트엔드-백엔드 금액 일치율 향상
- 개선 전: 약 95% 일치 (특정 금액대에서 1~2원 차이 발생)
- 개선 후: 100% 일치 (모든 테스트 케이스에서 정확한 금액 계산)
3. 코드 품질 향상
- 재사용성: 계산 로직을 함수로 분리하여 20+ 컴포넌트에서 일관되게 사용합니다
- 유지보수성: 계산 규칙 변경 시 한 곳만 수정하면 전체에 반영됩니다
- 코드 중복 제거: 기존 분산된 계산 로직을 공통 모듈로 통합하여 중복 코드를 제거했습니다
- 테스트 코드 추가: 엣지 케이스를 포함한 다양한 시나리오에 대한 유닛 테스트 코드를 작성하여 계산 로직의 정확성을 보장합니다. 특히 부동소수점 오차가 발생할 수 있는 경계값과 절상/절삭 처리 케이스를 집중적으로 테스트합니다
- 계산 정확도 보장:
big.js를 사용하여 모든 할인 계산에서 정확한 결과를 보장합니다. M포인트 할인(10원 단위 절삭)과 청구 할인(소수점 절상) 모두에서 부동소수점 오차 없이 정확한 계산이 이루어집니다
4. 성능 영향
big.js 라이브러리는 경량이며(약 5KB), 계산 성능 저하는 미미합니다:
- 평균 계산 시간: 기존 대비 +0.1ms 미만
- 사용자 체감: 전혀 없음
결론
이번 경험을 통해 두 가지 중요한 교훈을 얻었습니다:
1. 복잡한 공통 로직의 공통화는 필수입니다
할인 계산처럼 복잡한 로직은 여러 페이지에 분산되어 있으면 문제가 발생하기 쉽습니다. 각 페이지를 다른 개발자가 구현하다 보면:
- 계산 순서나 방식이 달라질 수 있습니다
- 문서가 노후화되어 최신 규칙을 반영하지 못할 수 있습니다
- 작은 오차가 누적되어 최종 결과가 달라질 수 있습니다
이번 사례에서 계산 로직을 공통 모듈(@privia/flight-core)로 추출하여 20개 이상의 컴포넌트와 페이지에서 일관되게 사용하도록 개선한 결과:
- 코드 중복을 제거하고 유지보수성을 크게 향상시켰습니다
- 계산 규칙 변경 시 한 곳만 수정하면 전체 시스템에 자동 반영되는 구조를 구축했습니다
- 모든 페이지에서 동일한 계산 결과를 보장할 수 있게 되었습니다
복잡한 비즈니스 로직은 반드시 공통 모듈로 추출하여 관리해야 합니다.
2. 금융 데이터 계산에서는 big.js를 습관화해야 합니다
Java에서는 BigDecimal이 업계 표준이지만, JavaScript에서는 언어 자체 기능만으로는 금융 계산에 부적합합니다.
JavaScript의 number 타입은 모든 숫자를 64비트 부동소수점(IEEE 754)으로 처리하므로, 소수점 연산에서 항상 오차가 발생할 수 있습니다. 특히:
- 할인율 계산 (
금액 × 0.07) - 여러 단계의 연속 계산
- 반올림/절삭 처리가 필요한 경우
이런 상황에서는 big.js와 같은 정밀도 보장 라이브러리를 사용하는 것이 필수적입니다.
금융 데이터나 정확한 금액 계산이 필요한 경우, JavaScript에서는 big.js 사용을 습관화해야 합니다. 단순한 계산이라도 나중에 복잡해질 수 있고, 작은 오차가 누적되어 큰 문제로 발전할 수 있기 때문입니다.