Blog/Developer

JavaScript로 금 주얼리 가격 계산기 만들기

실시간 캐럿별 그램당 가격으로 금 주얼리 가격 계산기를 만든다. 정확한 소수 연산, 캐싱, 마진, 그리고 명확한 평가 한계까지 다룬다.

Developer

보석상, 전당포, 이커머스 판매자는 모두 같은 작은 도구를 필요로 한다: 무게와 캐럿을 입력하면 재료 가치를 돌려주는 도구다. 이 글은 실시간 그램당 가격부터 브라우저 UI까지, 순수 JavaScript로 이 도구를 처음부터 끝까지 만든다.

이 계산기가 추정하는 것, 그리고 추정하지 않는 것

결과물은 재료 가치다: 현재 스팟 가격 기준으로 해당 제품에 들어간 금의 가치가 얼마인지, 다른 어떤 것도 더하기 전의 값이다. 이는 감정평가가 아니고, 보장된 재판매 가치도 아니며, 실제 구매자가 지불할 금액도 아니다.

완전히 제외되는 항목:

  • 보석, 에나멜, 또는 제품에 포함된 금이 아닌 모든 재료
  • 세공과 디자인(수작업으로 마감한 반지는 프레스로 찍어낸 것보다 제작비가 더 든다)
  • 국가와 거래 유형에 따라 달라지는 세금과 관세
  • 딜러 스프레드: 매장이 지불하는 금액과 스팟 가격이 말하는 금액 사이의 차이
  • 상태: 흠집, 빠진 보석, 닳은 조각
  • 매장이 마진 보호를 위해 적용하는 재매입 할인

이 계산기가 산출하는 모든 숫자에는 이 사실을 알리는 문구가 함께 표시되어야 한다. 이 면책 문구는 문서뿐 아니라 UI 자체에 넣어야 한다.

1단계: 8가지 캐럿별 그램당 가격 가져오기

API의 /v1/carat 엔드포인트는 한 번의 호출로 흔히 쓰이는 8가지 캐럿 순도의 그램당 가격을 반환하며, API 키가 필요 없다:

GET https://api.goldprice.dev/v1/carat?currency=USD

실제 응답은 다음과 같다:

{
  "currency": "USD",
  "timestamp": "2026-07-16T18:12:06.394451Z",
  "price_gram_24k": "128.24",
  "price_gram_22k": "117.55",
  "price_gram_21k": "112.21",
  "price_gram_20k": "106.86",
  "price_gram_18k": "96.18",
  "price_gram_16k": "85.49",
  "price_gram_14k": "74.80",
  "price_gram_10k": "53.43"
}

아래 코드에서 중요한 세부 사항이 두 가지 있다. 모든 가격 필드는 숫자가 아니라 문자열이므로, JSON 왕복 과정에서도 정확한 소수 자릿수가 그대로 보존된다. 그리고 currency는 API가 지원하는 31개 통화 중 어느 것이든 받을 수 있으므로, 쿼리 파라미터 하나만 바꾸면 동일한 엔드포인트로 USD, EUR, INR, IDR을 모두 다룰 수 있다.

2단계: 서버 측에서 60초간 캐싱하기

업스트림 데이터 자체도 비슷한 주기로 갱신되므로, 페이지가 로드될 때마다 폴링해봐야 얻는 것 없이 지연 시간만 늘어난다. 인메모리 캐시를 둔 작은 Node 서버를 앞단에 둔다:

import { createServer } from "node:http";

const UPSTREAM = "https://api.goldprice.dev/v1/carat";
const TTL_MS = 60_000;

const cache = new Map(); // currency -> { data, fetchedAt }

async function fetchCarat(currency) {
  const res = await fetch(`${UPSTREAM}?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`upstream ${res.status}`);
  }
  const data = await res.json();
  if (typeof data.price_gram_24k !== "string") {
    throw new Error("malformed upstream response");
  }
  return data;
}

async function getCarat(currency) {
  const cached = cache.get(currency);
  const now = Date.now();

  if (cached && now - cached.fetchedAt < TTL_MS) {
    return cached.data;
  }

  try {
    const data = await fetchCarat(currency);
    cache.set(currency, { data, fetchedAt: now });
    return data;
  } catch (err) {
    if (cached) {
      return cached.data; // stale-if-error: serve last known value
    }
    throw err;
  }
}

const server = createServer(async (req, res) => {
  const url = new URL(req.url, "http://localhost");
  if (url.pathname !== "/api/carat") {
    res.writeHead(404).end("not found");
    return;
  }
  const currency = (url.searchParams.get("currency") || "USD").toUpperCase();

  try {
    const data = await getCarat(currency);
    res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(data));
  } catch (err) {
    res.writeHead(502, { "content-type": "application/json" }).end(
      JSON.stringify({ error: "carat_unavailable" })
    );
  }
});

server.listen(3001);

단일 엔드포인트에는 프레임워크가 필요 없고, 이건 순수 node:http다. 실패 처리는 정상 경로만큼 중요하다: 업스트림 응답이 200이 아니거나 형식이 잘못된 본문이면 예외를 던지고, 캐시된 값이 있고 아직 TTL 안에 있다면 getCarat은 마지막으로 캐시된 값으로 폴백한다. 캐시가 전혀 없을 때만 오류가 502로 클라이언트까지 전달된다.

브라우저 JavaScript에서 api.goldprice.dev를 직접 호출하지 마라. 이 API는 설계상 브라우저 CORS를 허용하지 않으므로, 클라이언트 측 코드에서의 fetch는 실패한다. 모든 요청은 자체 서버 엔드포인트를 거치도록 라우팅해야 하며, 이렇게 하면 캐시도 브라우저 탭마다 따로가 아니라 모든 방문자가 공유하게 된다.

3단계: 재료 가치 - 무게 곱하기 그램당 가격

핵심 공식은 곱셈 한 번이다: weight_grams × per_gram_karat_price. 까다로운 부분은 두 피연산자 모두 정확한 소수 연산으로 유지해야 한다는 점이다. 금액의 부동소수점 곱셈은 오차가 누적되기 때문이다:

console.log(0.1 + 0.2); // 0.30000000000000004

API는 이미 정확한 소수 문자열을 제공한다. 해법은 이를 Number로 변환했다가 되돌리는 대신, 최소 단위(센트)의 정수로 유지하는 것이다:

function toMinorUnits(decimalString, decimals = 2) {
  const [whole, frac = ""] = decimalString.split(".");
  const paddedFrac = (frac + "0".repeat(decimals)).slice(0, decimals);
  const sign = whole.startsWith("-") ? -1n : 1n;
  const wholeAbs = whole.replace("-", "");
  return sign * (BigInt(wholeAbs) * 10n ** BigInt(decimals) + BigInt(paddedFrac || "0"));
}

function fromMinorUnits(minorUnits, decimals = 2) {
  const negative = minorUnits < 0n;
  const abs = negative ? -minorUnits : minorUnits;
  const divisor = 10n ** BigInt(decimals);
  const whole = abs / divisor;
  const frac = (abs % divisor).toString().padStart(decimals, "0");
  return `${negative ? "-" : ""}${whole}.${frac}`;
}

function materialValue(weightGrams, perGramPriceString) {
  const priceMinor = toMinorUnits(perGramPriceString, 2); // cents
  const weightMilligrams = BigInt(Math.round(weightGrams * 1000)); // 3dp precision
  const totalMinor = (priceMinor * weightMilligrams) / 1000n;
  return fromMinorUnits(totalMinor, 2);
}

여기에는 별도의 빅넘버 의존성이 필요 없다. 현재 모든 JS 런타임이 네이티브로 제공하는 BigInt만 쓰면 된다. 무게는 일반 float로 들어오므로(보석상의 저울은 0.01g 또는 0.001g 단위까지 표시한다), 곱셈 전에 정수 밀리그램으로 변환하고, 다시 나누는 연산은 곱셈이 끝난 마지막 단계에서 이뤄진다. 그래야 중간 값이 정확하게 유지된다. 정수 나눗셈은 반올림이 아니라 절삭한다는 점에 유의하라. 이 계산기를 프로덕션에 배포하기 전에, API 자체가 각 캐럿 가격을 해당 통화의 최소 소수 단위로 반올림하는 방식과 마찬가지로, 당신의 통화 최소 단위에 대한 명시적인 반올림 규칙을 정해야 한다.

4단계: 마진과 세공비를 별도로 추가하기

3단계에서 나온 재료 가치는 스팟 가격에서 파생된 숫자다. 이것이 매장이 고객에게 제시해야 할 값은 아니다. 여기에 더할 입력값이 두 가지 더 있으며, 이들은 스팟 가격 숫자에 섞어 넣지 말고 별도의 항목으로 두어야 한다:

  • 판매자 마진: 매장의 마크업 또는 매입 시 할인, 보통 퍼센트 값
  • 세공비: 원료 금을 완성품으로 만드는 비용으로, 보통 정액 요금이거나 재료 가치의 일정 비율이며 스팟 가격과는 완전히 무관하다
function quoteBreakdown(materialValueMinor, marginPercent, makingChargeMinor) {
  const marginMinor = (materialValueMinor * BigInt(Math.round(marginPercent * 100))) / 10000n;
  return {
    material: materialValueMinor,
    margin: marginMinor,
    makingCharge: makingChargeMinor,
    total: materialValueMinor + marginMinor + makingChargeMinor,
  };
}

이 세 값을 하나로 뭉친 합계 대신 세 개의 필드로 유지하는 것이 이 계산기를 감사 가능하게 만드는 핵심이다. 고객이나 규제 기관은 스팟 가격에서 파생된 재료 가치를 별도의 항목으로, 매장이 추가하기로 선택한 부분과 구분해서 확인할 수 있어야 한다.

5단계: 타임스탬프, 통화, 그리고 한계 표시하기

브라우저 측은 업스트림 API가 아니라 항상 자체 서버를 호출한다:

async function loadCarat(currency) {
  const res = await fetch(`/api/carat?currency=${encodeURIComponent(currency)}`);
  if (!res.ok) {
    throw new Error(`carat request failed: ${res.status}`);
  }
  return res.json();
}

function estimateValue(weightGrams, karat) {
  const karatKey = `price_gram_${karat}k`;

  return loadCarat("USD").then((data) => {
    const perGram = data[karatKey];
    if (!perGram) {
      throw new Error(`unsupported karat: ${karat}`);
    }
    return {
      material: materialValue(weightGrams, perGram),
      timestamp: data.timestamp,
      currency: data.currency,
    };
  });
}

document.getElementById("calc-btn").addEventListener("click", () => {
  const grams = Number(document.getElementById("weight").value);
  const karat = Number(document.getElementById("karat").value);

  estimateValue(grams, karat)
    .then((result) => {
      document.getElementById("result").textContent =
        `${result.material} ${result.currency} as of ${result.timestamp}`;
    })
    .catch((err) => {
      document.getElementById("result").textContent =
        "Price unavailable right now. Try again shortly.";
      console.error(err);
    });
});

UI가 보여주는 모든 결과 옆에는 타임스탬프와 통화가 함께 표시되어야 하고, 그 아래에는 고정된 면책 문구가 있어야 한다: 이는 재료 가치 추정치일 뿐 감정평가가 아니며, 보석, 세공, 세금, 상태를 반영하지 않는다는 내용이다. 이 문구는 놓치기 쉬운 툴팁이 아니라 템플릿에 한 번 박아 넣어라.

6단계: fetch 실패와 잘못된 데이터 처리하기

서버 경계에서 실패는 세 가지 형태로 발생하며, 위 코드는 이미 세 가지 모두를 처리한다:

  • 업스트림 요청 자체가 실패하는 경우(네트워크 오류, 타임아웃): fetchCarat에서 잡혀 캐시로 폴백한다
  • 응답이 200이 아닌 상태 코드인 경우: 명시적으로 확인해 네트워크 실패와 동일하게 처리한다
  • 응답 본문이 정상이 아닌 경우(필드 누락, 타입 불일치): typeof data.price_gram_24k !== "string" 검사가 잘못된 본문이 캐시되거나 응답되기 전에 잡아낸다

신선한 캐시 항목이 있다면 이런 실패는 사용자에게 전혀 전달되지 않는다. 사용자는 실제 타임스탬프가 붙은 마지막으로 정상이었던 값을 받는다. 사용할 수 있는 캐시가 전혀 없을 때만, 클라이언트는 깨진 계산이나 조용한 0 대신 명확한 오류 상태를 받는다.

라이선스

이 글 전반에서 사용한 익명 엔드포인트는 개발과 테스트 용도로는 문제없다. 원시 API 데이터의 상업적 이용(과금하는 제품이나 매출을 발생시키는 용도)은 Pro 요금제 이상의 라이선스가 필요하다. 페이지에 표시할 실시간 시세만 필요하고 연산에 쓸 원시 데이터가 필요 없다면, 공식 임베드형 위젯은 상업적 표시 용도로도 무료이며 API 키도 필요 없다.

관련 읽을거리

guías relacionadas

Developer

Backtest local-currency gold without FX look-ahead bias

Build a reproducible local-currency gold backtest from settled XAU/USD bars and dated FX observations, without inserting future rates into historical rows.

Leer →
Developer

Gold price WebSocket on Reddit: the streaming questions, answered

The real-time gold streaming questions that come up on Reddit, answered: whether a gold WebSocket exists, how the goldprice.dev XAU and XAG tick stream works end to end, and which other APIs actually offer one (most metals APIs just poll).

Leer →
Developer

Gold price API on Stack Overflow: developer troubleshooting, answered

The gold-price-API errors developers hit on Stack Overflow, fixed: 401 auth, 400 invalid_symbol, 429 rate limits, CORS from the browser, reading the response, and pulling historical data.

Leer →

goldprice.dev

Precios del oro en tiempo real, OHLC histórico y agregación multi-fuente — disponible via REST y SSE.