Skip to content

Repository files navigation

빅맥계산기

아무 가격이나 넣으면 빅맥 몇 개인지 알려주는 웹앱입니다.

금액을 직접 넣거나 항목을 고르면 빅맥 개수로 환산해 드리고, 총 열량과 쌓았을 때의 높이, 최저시급 기준으로 필요한 노동시간을 함께 보여 드립니다. 카탈로그에 없는 물건은 쇼핑몰 검색과 추정으로 이어지는 3단 폴백으로 찾습니다.

React 18 + TypeScript + Vite · vite-plugin-pwa
상태관리 라이브러리 없음 · UI 라이브러리 없음 · 웹폰트 없음

시작하기

npm install
npm run dev        # http://localhost:5173
npm test           # 도메인·서비스 단위 테스트
npm run build      # 타입체크 + 프로덕션 빌드

실시간 가격 조회까지 로컬에서 써 보시려면 .env.example.env.local로 복사해 키를 채우신 뒤, 서버 함수까지 함께 도는 개발 서버(wrangler pages dev 또는 vercel dev)로 띄우시면 됩니다. 키가 없어도 로컬 카탈로그만으로 앱은 정상 동작합니다.

배포

두 곳에 동시에 올라갑니다. 갈리는 것은 환경변수 두 개뿐이고, 런타임 분기는 없습니다.

GitHub Pages Cloudflare Pages
용도 프로토타입 미리보기 실제 배포
조회 단계 1단계(카탈로그)만 3단계 전부
BASE_PATH /mac-per-mac/ 미설정 (루트)
VITE_API_BASE_URL none 미설정 (같은 출처)

GitHub Pages.github/workflows/deploy-pages.ymlmain에 푸시할 때마다 배포합니다. 레포의 Settings → Pages → SourceGitHub Actions로 바꿔 두시면 준비는 끝입니다. 정적 호스팅이라 서버 함수를 둘 수 없어서, 쇼핑몰·추정 단계는 아예 꺼진 채로 빌드됩니다. 있지도 않은 단계를 "검색 중"이라고 띄웠다가 실패하는 것보다는 이쪽이 정직하다고 판단했습니다.

Cloudflare Pages — 빌드 명령은 npm run build, 출력 디렉터리는 dist입니다. functions/api/*.ts가 Pages Functions로 자동 배포되어 /api/…를 같은 출처에서 서빙하므로 CORS 설정이 따로 필요하지 않습니다. 대시보드의 Settings → Environment variablesNAVER_CLIENT_ID, NAVER_CLIENT_SECRET, ANTHROPIC_API_KEY를 등록해 주세요.

서버 함수는 특정 런타임에 매이지 않도록 짜여 있습니다. 로직은 api/_core/에 모아 두고, api/*.ts가 Vercel(Node) 어댑터, functions/api/*.ts가 Cloudflare(Workers) 어댑터 역할을 합니다. process.env나 호스팅별 SDK는 코어가 아니라 어댑터만 알고 있습니다.

프로토타입의 한계가 하나 있습니다. GitHub Pages에서는 "재배포 없이 prices.json만 교체"가 성립하지 않습니다. 사이트가 곧 레포이기 때문에 JSON을 고치는 일이 곧 배포입니다. 이 성질이 꼭 필요하시면 usePricesurl을 외부 호스트로 돌리시면 됩니다.

구조

src/
├── domain/          ← DOM·React를 import하지 않는 순수 TS. 계산은 전부 여기.
│   ├── bigmac.ts      toBigMacs, scaleOf
│   ├── format.ts      한국식 숫자, 높이·열량 비유, 노동시간
│   ├── catalog.ts     parsePriceData, loadPrices, searchCatalog
│   ├── market.ts      나라·시절별 원화 환산, 통화 표기, 빅맥 지수
│   ├── units.ts       같은 금액을 다른 일상 단위로
│   ├── hangul.ts      초성 추출·매칭
│   └── fallback.ts    prices.json을 빌드 시점에 번들로 끌어온 fallback
├── services/        ← 조회 파이프라인
│   ├── priceLookup.ts    3단 폴백 오케스트레이션
│   └── shoppingFilter.ts 쇼핑 응답 정제 (순수 함수)
├── hooks/           usePrices · useUrlState · useReducedMotion
├── components/      전부 직접 만든 것. 외부 UI 라이브러리 없음.
│   ├── HeightCompare.tsx   높이 비교 그림 (실제 비율)
│   ├── InputSummary.tsx    결과가 있을 때 입력을 대신하는 52px 한 줄
│   ├── MarketPicker.tsx    기준 고르기 = 나라·시절 비교
│   ├── AltUnits.tsx        "이 돈이면 또" 환산 목록
│   ├── pixel/sprites.ts    비교 그림용 스프라이트 (문자 격자)
│   └── pixel/glyphs.ts     카탈로그 항목 8×8 픽토그램 (같은 형식)
└── share/renderCard.ts   공유 카드 Canvas 렌더러

api/                 서버 함수 (런타임 중립 코어 + Vercel 어댑터)
├── _core/             로직. Request/Response도 process.env도 모른다.
│   ├── shopping.ts      네이버 쇼핑 프록시
│   ├── estimate.ts      Claude 추정 폴백
│   ├── guards.ts        인메모리 캐시 + 레이트 리밋
│   └── types.ts         Env·결과 타입, CORS
├── shopping.ts        Vercel(Node) 어댑터
└── estimate.ts

functions/           Cloudflare Pages Functions 어댑터
├── _adapter.ts
└── api/{shopping,estimate}.ts

public/data/prices.json  ← 재배포 없이 갈아끼우는 가격 데이터

src/domain/은 DOM이나 React에 의존하지 않습니다. 테스트도 environment: 'node'로 돌려서, 그 제약이 말뿐이 되지 않도록 환경 자체로 막아 두었습니다.

가격 데이터

앱은 /data/prices.json을 fetch하고, 실패하면 번들에 들어 있는 fallback을 사용합니다. 가격이 바뀌면 이 파일만 갈아 끼우시면 되고 재배포는 필요하지 않습니다. public/_headers(Cloudflare)와 vercel.jsonCache-Control: no-cache를, 서비스워커에는 NetworkFirst를 걸어 두었습니다. (GitHub Pages는 예외입니다. 위의 배포 절을 참고해 주세요.)

fallback.ts는 같은 JSON을 빌드 시점에 import합니다. 손으로 복사해 두면 두 벌이 조용히 어긋나기 때문에, 원본 한 벌만 두고 거기서 끌어오도록 했습니다.

카탈로그 항목의 uncertain: true는 값이 확실하지 않다는 뜻이고 UI에서 "추정치"로 표시됩니다. priceNote에는 기준 시점과 근거를 적어 둡니다.

다른 나라·다른 시절과 견주기

헤더의 빅맥 가격 알약을 누르면 기준 목록이 열립니다. 고르는 화면이 곧 비교 화면입니다. 각 줄에 지금 금액 기준 개수가 함께 적혀 있어서, 목록을 훑는 동안 비교가 끝납니다. 본 화면에 비교표를 위한 자리를 따로 내지 않은 이유이기도 합니다. 이 앱에서 세로 공간은 늘 모자란 자원입니다.

목록은 두 묶음입니다. 지금, 다른 나라에서 / 이 돈을 그때 들고 갔다면. 지난 시절은 구조적으로 다른 나라와 똑같습니다 — 빅맥 값이 다르고 최저시급이 다를 뿐입니다. 그래서 타입을 따로 만들지 않고 Marketera만 더했고, 환산·URL·공유 카드가 전부 그대로 돌아갑니다. 목록을 갈라 보여주는 일만 UI가 합니다.

89만원이면 지금은 156개, 2010년이었다면 261개, 2000년이었다면 356개입니다. 같은 화면에서 필요 노동시간은 반대로 움직입니다. 2010년 최저시급은 4,110원이라 같은 돈을 벌려면 216시간이 필요했고, 지금은 86시간입니다. 빅맥은 비싸졌지만 빅맥을 사기 위한 노동은 줄었다는 이야기가 한 화면에 같이 놓입니다.

나라나 시절을 바꿀 때 무엇이 따라 움직이고 무엇이 그대로인지는 이렇게 갈랐습니다.

무엇을 따라가나 이유
빅맥 가격 · 최저시급 고른 기준 "거기서/그때 산다면"이라는 하나의 가정으로 일관됩니다
랜드마크 · 카탈로그 · 언어 읽는 사람 63빌딩은 읽는 사람의 머릿속 자입니다
열량 · 높이 불변 빅맥은 어디서나 583kcal, 8.5cm입니다

미국을 골랐다고 63빌딩이 엠파이어스테이트빌딩으로 바뀌지는 않습니다. 비교 대상 나라와 읽는 사람은 다른 축이고, 둘을 뭉뚱그리면 "미국 빅맥 110개 = 도쿄타워 2.4개" 같은 뒤죽박죽이 나옵니다.

계산 자체는 bigmac.ts를 전혀 건드리지 않습니다. toBigMacs의 두 번째 인자와 minimumWageKRW 옵션을 바꿔 넣는 일이 곧 기능입니다.

toBigMacs(priceKRW, market.bigMacPrice * market.fxToKRW, {
  minimumWageKRW: market.minimumWage * market.fxToKRW,
})

?m=US로 링크에 실리고, 공유 카드에도 "미국 기준"이 찍힙니다. 기본 나라(한국)일 때는 둘 다 붙지 않습니다.

덤: 빅맥 지수 한국 156개와 미국 110개의 비율이 그대로 빅맥 지수입니다. 따로 계산하는 값이 아니라 이미 목록에 떠 있는 두 숫자에서 떨어지는 값이라, market.test.tsvaluationGap()과 개수 비율이 같다는 것을 불변식으로 확인합니다. 지난 시절에는 이 문구를 띄우지 않습니다. 같은 통화라 "저평가"라는 말이 성립하지 않고, 그냥 물가가 오른 것이기 때문입니다.

환율은 prices.json에 날짜와 함께 박혀 있습니다. 매일 움직이는 값이지만, 환율 API를 붙이면 "오프라인에서 다 돈다"는 성질이 깨집니다. 목록 아래에 기준일을 밝혀 두었습니다.

한국을 뺀 나머지 나라의 빅맥 가격·환율·최저시급은 대략치이고 uncertain: true로 표시됩니다. 실제로 쓰시기 전에 확인해 주세요. 최저임금은 나라마다 고시 단위가 달라서(미국은 연방/주, 태국은 일급, 베트남은 월급) 시급으로 환산한 근거를 각 항목의 note에 적어 두었습니다.

연도별 항목의 최저시급은 실제 고시값이지만 빅맥 가격은 대략치입니다.

실시간 가격 조회

카탈로그에 없는 항목은 lookupPrice()가 세 단계로 찾습니다. 각 단계에는 타임아웃이 있고, 실패하면 조용히 다음 단계로 넘어갑니다.

단계 소스 source confidence 타임아웃
1 로컬 카탈로그 (퍼지 매칭) catalog exact / estimated 3초
2 네이버 쇼핑 (/api/shopping) shopping market 3초
3 Claude 추정 (/api/estimate) estimate estimated 25초

2·3단계는 apiBaseUrl로 켜고 끕니다. null이면 요청을 보내지 않고 단계 자체를 건너뜁니다. GitHub Pages처럼 서버 없이 단독으로 도는 배포를 위한 장치입니다.

세 단계가 모두 빈손이면 PriceLookupError('not_found')를, 3단계가 "값을 매길 수 없다"고 판단하면 PriceLookupError('unpriceable')을 던집니다. UI에서는 각각 "가격을 못 찾았어요"와 "이건 값을 매기기 어렵네요"로 안내합니다.

3단계 타임아웃이 3초가 아닌 이유 요청하신 값은 세 단계 모두 3초였습니다만, 3단계는 Claude가 웹 검색까지 돌고 오기 때문에 3초 안에 끝나는 일이 사실상 없습니다. 그대로 두면 폴백 체인의 마지막 칸이 늘 죽은 코드가 되어 25초로 잡았습니다. src/services/priceLookup.tsSTAGE_TIMEOUT_MS 한 줄만 고치시면 원래대로 되돌릴 수 있습니다.

네이버 쇼핑 API 설정

  1. 네이버 개발자센터에 네이버 계정으로 로그인합니다.
  2. 상단의 Application → 애플리케이션 등록으로 이동합니다.
  3. 애플리케이션 정보를 입력합니다.
    • 애플리케이션 이름: 자유롭게 정하시면 됩니다 (예: 빅맥계산기).
    • 사용 API: 검색을 선택합니다. 쇼핑 검색은 검색 API에 포함되어 있습니다.
    • 비로그인 오픈 API 서비스 환경: WEB 설정을 고르고 서비스 URL에 배포 도메인을 넣습니다. 로컬 개발용으로 http://localhost:3000도 함께 등록해 두시면 편합니다.
  4. 등록을 마치면 Client IDClient Secret이 발급됩니다.
  5. 발급받은 값을 .env.local에 넣습니다.
    NAVER_CLIENT_ID=발급받은_ID
    NAVER_CLIENT_SECRET=발급받은_SECRET
    
  6. 배포하실 때는 호스팅의 환경변수에 같은 두 값을 등록해 주세요 (Cloudflare Pages는 Settings → Environment variables입니다).

검색 API에는 하루 25,000회 호출 제한이 있습니다. /api/shopping은 같은 검색어를 1시간 캐시하고 IP당 분당 20회로 제한해서 여유를 두었습니다.

/api/shopping이 하는 일은 다음과 같습니다.

  • display=20으로 받아온 뒤 title에서 HTML 태그와 엔티티를 걷어냅니다.
  • 중고·케이스·필름·부품·렌탈처럼 본품이 아닌 결과를 키워드로 걸러냅니다.
  • 검색어 토큰이 하나도 걸리지 않는 결과를 제외합니다.
  • 남은 lprice상하위 20%를 잘라낸 뒤 중앙값을 대표가로 삼습니다. 평균은 묶음판매나 오타 가격 같은 이상치에 쉽게 휘둘려서 쓰지 않았습니다.
  • 남은 결과가 3개 미만이면 실패로 보고 404를 돌려줍니다. 호출자는 3단계로 넘어갑니다.

정제 로직 자체는 src/services/shoppingFilter.ts에 순수 함수로 떼어 두었습니다. HTTP나 시크릿과 섞이면 단위 테스트를 할 수 없기 때문이고, 파이프라인상의 위치는 요청하신 대로 /api/shopping 엔드포인트 그대로입니다.

Claude 추정 설정

  1. Anthropic Console에서 API 키를 발급합니다.
  2. .env.localANTHROPIC_API_KEY를 넣고, 호스팅에도 같은 값을 등록합니다.

/api/estimateweb_search 툴을 붙여 호출하고, 시스템 프롬프트로 JSON만 출력하도록 지시합니다(코드펜스는 금지입니다). 응답 스키마는 이렇습니다.

{
  "priceKRW": 1234567,
  "label": "에펠탑 건설비 (현재가치 환산)",
  "reasoning": "어떻게 그 값에 도달했는지 한두 문장",
  "confidence": "high | medium | low",
  "sourceUrl": "https://..."
}

가격을 특정할 수 없을 때는 priceKRW: null을 돌려주고, UI는 "이건 값을 매기기 어렵네요"로 안내합니다.

모델은 기본값이 claude-sonnet-4-6이며 CLAUDE_MODEL 환경변수로 바꾸실 수 있습니다. 더 정확한 추정이 필요하시면 claude-sonnet-5를 넣으시면 됩니다.

디자인

맥도날드의 빨강·노랑 대신 "가격표 / 영수증 / 계산기"의 시각 언어를 썼습니다. 개념은 그대로 두되 표현만 요즘 감각에 맞췄습니다. 딱딱한 1px 테두리 대신 헤어라인 링과 부드러운 그림자를 쓰고, 반경을 넉넉히 주고, 스프링 이징의 미세한 모션을 얹었습니다.

  • 강조색은 딥 그린 #146B58 하나뿐이고 나머지는 전부 무채색입니다. 색을 바꾸시려면 src/styles/tokens.css--accent 두 줄(라이트/다크)만 고치시면 됩니다. 파생 톤(hover, soft, ring)은 손으로 고른 알파 헥스가 아니라 color-mix로 뽑기 때문에 따라옵니다.
  • 숫자는 시스템 등폭 서체에 tabular-nums를 씁니다. 웹폰트는 받지 않습니다. 오프라인 동작과 공유 카드의 Canvas 렌더링이 폰트 로딩을 기다리지 않게 하려는 이유입니다.
  • 종이 질감은 이미지 없이 인라인 SVG 노이즈 한 겹과 상단의 옅은 강조색 워시로 냅니다.
  • 버거는 사진 대신 직접 그린 SVG입니다. currentColor를 따르므로 어디에 놓아도 주변 색을 그대로 받습니다.
  • 다크 모드는 prefers-color-scheme으로 자동 전환되고, 큰 숫자에만 은은한 발광이 붙습니다. 공유 카드는 라이트로 고정했습니다. 공유된 이미지가 보는 사람의 설정에 따라 달라지면 곤란하기 때문입니다. 카드 팔레트는 tokens.css의 라이트 값과 맞춰 두었습니다.

높이 비교 그림

"18.9km"라는 수치만으로는 그게 얼마나 높은지 감이 오지 않습니다. 그래서 쌓아 올린 빅맥과 비유 대상을 실제 비율대로 나란히 세우고, 눈대중 기준이 되도록 사람을 함께 그렸습니다.

세우는 순서는 빅맥 기둥 → 비유 대상 → 사람입니다. 주인공이 맨 앞에 오고 눈대중 기준인 사람이 맨 뒤로 빠지며, 범례도 같은 순서라 그림과 글이 나란히 읽힙니다. 사람은 "성인 키"라는 제 항목을 갖습니다. 다만 비유 대상이 사람일 때는 눈대중용 사람을 또 세우지 않습니다 — 같은 사람이 둘 서 있으면 "왜 두 명이지"부터 묻게 됩니다.

그림은 문자 격자로 적어 둔 픽셀 스프라이트입니다(components/pixel/sprites.ts). '#'는 진한 톤, '+'는 옅은 톤, '.'는 빈 칸입니다. 렌더링할 때 가로로 이어진 칸을 하나의 사각형으로 합쳐 SVG로 펼칩니다. 이미지 파일을 두지 않은 이유는 앱의 나머지와 같습니다. 오프라인 동작과 번들 크기, 그리고 강조색 하나만 바꾸면 그림까지 전부 따라오게 하기 위해서입니다.

그림에는 두 가지 한계가 있고, 감추는 대신 화면에 드러냅니다.

  • 사람이 사라지는 구간: 롯데월드타워(555m)와 사람(1.73m)은 320:1이라 한 그림에 실제 비율로 넣으면 사람이 1픽셀도 되지 않습니다. 그래서 사람이 4픽셀 아래로 작아지면 아예 그리지 않습니다. 점 하나만 남으면 오히려 오해를 부르기 때문입니다. 반대로 비교 대상이 빅맥 한 개일 만큼 장면이 작을 때도, 사람이 그림판을 통째로 덮어 버리므로 같은 이유로 그리지 않습니다.
  • 비율이 7배를 넘는 구간: 작은 쪽이 보이지 않으므로 실제 비율을 포기하고 기둥을 한 번 끊어 표시한 뒤, 범례에 "그림은 비율 축약"이라고 적습니다.

빅맥 한 개도 사지 못하는 금액이거나, 비유 대상이 빅맥 그 자체일 때는 그림을 아예 그리지 않습니다. 빅맥 두 개를 빅맥 한 개와 견주는 그림은 아무것도 알려주지 않고, 그 구간은 큰 숫자가 이미 개수를 말하고 있기 때문입니다.

도메인은 어떤 그림이 쓰이는지 알지 못합니다. pickLandmark()id만 돌려주고, 그 id에 어떤 스프라이트를 붙일지는 UI가 정합니다.

픽토그램

카탈로그 항목의 아이콘은 이모지가 아니라 8×8 픽셀 픽토그램입니다 (components/pixel/glyphs.ts). 이모지는 이 앱의 유일한 총천연색 요소라 "강조색 하나, 나머지 무채색"과 정면으로 부딪혔습니다. 그레이스케일 필터는 답이 아닙니다 — 플랫폼마다 다르게 뭉개지고 격자에도 안 맞습니다.

대신 비교 그림이 쓰는 렌더러(parseSprite)를 그대로 재사용합니다. 같은 문자 격자 형식이고 currentColor를 타므로, 무채색 원칙과 강조색 교체가 공짜로 따라옵니다. 픽셀 아트가 UI에서 겉돌던 문제도 같은 처방으로 풀립니다. 기본은 --ink-2이고 선택된 항목만 강조색입니다.

8×8인 이유는 12·16·20·24·32px이 전부 정수배라서입니다. shapeRendering="crispEdges" 에서 흐려지지 않습니다. 48개 항목을 전부 그리지는 않았고, 자주 보이는 13개에 그림을 주고 나머지는 카테고리로 떨어집니다.

4px 격자

픽셀 아트가 튀어 보이던 이유는 장난스러워서가 아니라 UI에 격자의 흔적이 하나도 없어서 그림만 다른 규칙을 쓰는 것처럼 보였기 때문입니다. --px: 4px를 두고 세 군데를 거기에 맞췄습니다.

  • 반경 10/14/20 → 8/12/16 (--px의 2·3·4배)
  • 영수증 점선을 1px dotted 대신 --leader 그라디언트로. 점 한 칸이 픽셀 한 칸과 같은 크기가 됩니다.
  • 큰 숫자 아래 캡션을 알약에서 4px 세로 룰로. 알약은 반경 999px이라 격자와 무관했고, 큰 숫자 옆에서 또 하나의 덩어리로 경쟁해 붕 떠 보였습니다.

열량 비유

"9만 kcal"도 "18.9km"와 같은 문제를 안고 있어서, 높이 사다리와 똑같은 짜임을 열량에도 놓았습니다. 계단 한 층 오르기(5kcal) → 한 정거장 걷기(50) → 남산 오르기(300) → 마라톤 완주(2,800) → 서울에서 부산까지 걷기(20,000) → 지구 한 바퀴 걷기(200만). 비율이 1 이상인 것 중 가장 큰 활동을 골라 "서울에서 부산까지 걷기 4.6번"처럼 지표 아래 한 줄로 붙습니다.

사다리의 이웃끼리는 최소 3배씩 벌려 두었고, 그 간격을 테스트가 지킵니다. 높이 사다리를 처음 만들 때 너무 촘촘하게 잡았다가 무엇을 넣어도 "1.1개"만 나와서 사다리가 있으나 마나 했던 적이 있습니다. 걷기는 체중 70kg 기준 1km에 약 50kcal로 잡았습니다.

이 돈이면 또

빅맥 개수만으로는 "많다"는 느낌만 남아서, 같은 돈을 아메리카노 몇 잔인지 지하철 몇 번인지로도 세어 보여 줍니다. 앱이 하는 일("돈을 단위로 바꾼다")을 그대로 한 번 더 하는 것이라 새 개념이 늘지 않습니다. 줄을 누르면 그 항목을 기준으로 다시 계산합니다.

보고 있는 항목 자신은 뺍니다 — 아메리카노를 골라놓고 "아메리카노 1잔"을 다시 보여줄 이유가 없습니다. 한 개도 못 사는 단위도 뺍니다. "0잔"은 아무 말도 하지 않으면서 자리만 차지합니다.

세는 말(잔·번·줄)은 카탈로그에 없어서 domain/units.ts에 따로 두었습니다. 48개 항목에 전부 붙이는 대신 셈이 자연스러운 것만 골랐습니다.

이 자리에는 원래 버거 아이콘을 개수만큼 늘어놓는 격자가 있었습니다. 개수를 그림으로 한 번 더 말할 뿐이었는데, 그 일은 바로 위의 높이 비교 그림이 이미 더 잘 하고 있었습니다. 같은 자리에 새 정보를 놓는 편이 낫다고 판단해 걷어냈습니다.

첫 화면

처음 들어온 사람에게 이 앱이 무엇을 해주는지 말로 설명하는 대신 계산해서 보여줍니다. 아직 아무것도 안 고른 상태의 패널에는 안내문 대신 실제로 계산된 예시 하나가 결과 패널을 축소한 모양으로 들어갑니다.

        이런 걸 해 드립니다
   ┌────────────────────────────┐
   │   🖥️ 맥 미니 M4  89만원      │
   │            ↓               │
   │       빅맥  156 개          │
   │  9만 1,030kcal · 13.3m …   │
   └────────────────────────────┘

숫자는 전부 실제 데이터에서 그때그때 계산합니다. 가격이 바뀌면 예시도 따라 바뀌고, 고른 나라·시절도 그대로 반영됩니다(2010년을 고르면 예시가 261개가 됩니다). 손으로 적어둔 설명이 본문과 어긋날 일이 없고, 덤으로 기준 고르기가 무슨 일을 하는지도 이 카드에서 드러납니다. 카드를 누르면 그 항목이 그대로 골라집니다.

새 시각 언어는 하나도 늘리지 않았습니다. 결과 패널을 그대로 작게 만든 것이라, 진짜 결과가 떴을 때 "아까 그거"로 읽힙니다.

표지(스플래시)는 두지 않았습니다. "한 화면에 들어가고 세로 스크롤 없이"가 이 앱의 뼈대인데 표지는 넘어가야 할 화면을 하나 더 만들고, ?q=…&p=…로 들어온 사람은 결과를 보러 온 것이라 표지를 만나면 곤란합니다. 무엇보다 표지는 한 번 넘기면 끝이지만 빈 화면은 쓸 때마다 다시 보입니다.

브랜드 옆의 "아무 가격이나 빅맥 개수로"는 결과가 뜬 뒤에도 남습니다. 화면이 좁으면 (max-width: 30rem) 이름만 남기고 접습니다.

화면에 담기

레이아웃은 100dvh 고정이고 페이지 자체는 스크롤하지 않습니다. 데스크톱은 좌(입력)/우(영수증) 2열, 모바일은 1열 스택입니다.

결과가 나오면 입력 영역을 52px 한 줄로 접습니다. 직접 입력·검색·칩 세 카드는 휴대폰 본문 세로의 39%(227px)를 먹는데, 한 번 고르고 나면 그만큼 중요하지 않습니다. 접힌 바는 방금 고른 항목과 금액을 그대로 이고 있어서 무엇을 계산했는지 잃지 않고, "바꾸기"를 누르면 다시 펼쳐집니다. 항목을 새로 고르면 저절로 접힙니다.

금액을 치는 동안에는 접지 않습니다. 입력칸이 사라지면 키보드까지 닫히기 때문에, 포커스가 있는 동안은 펼친 채로 둡니다.

접기 여부는 CSS가 정합니다. 데스크톱은 좌측 열에 자리가 남아 접을 이유가 없고, 자바스크립트로 화면 폭을 재면 첫 렌더가 한 박자 늦게 흔들립니다.

이 227px을 되찾아 큰 숫자(--display)와 비교 그림을 키웠습니다. 결과 패널의 가시 높이가 338px → 512px이 되어, 큰 숫자·비교 그림·지표 3줄이 스크롤 없이 들어갑니다.

나머지도 몇 가지 양보하도록 해 두었습니다.

  • 인기 항목 칩이 한 줄 가로 스크롤로 바뀝니다. 서너 줄을 먹으면 결과가 화면 밖으로 밀려 나기 때문입니다.
  • 결과와 환산 목록만 스크롤하고 공유 버튼은 패널 바닥에 붙어 남습니다. 화면 크기와 상관없이 공유·초기화가 늘 눈에 들어옵니다.
  • 인기 항목 칩을 카드에서 꺼내 맨몸으로 둡니다. 카드 여백과 라벨이 50px쯤을 먹는데, 그 자리는 비교 그림이 쓰는 편이 낫습니다.
  • 높이 비교 그림을 지표보다 위에 둡니다. 첫 화면에 남아야 할 것은 큰 숫자와 그림이고, 세 줄짜리 수치는 스크롤해서 봐도 되는 참고 정보입니다.

한때 max-height: 46rem 아래에서 비교 그림을 숨겼던 적이 있는데, 잘못된 판단이었습니다. 휴대폰 브라우저의 실제 표시 높이는 주소창·하단바를 빼면 660px 안팎이라 사실상 모든 기기에서 그림이 사라졌습니다. 세로 기준으로 무언가를 감출 때는 기기 해상도가 아니라 브라우저 표시 높이로 재야 합니다.

공유

  • 이미지: 1080×1350 Canvas에 영수증 카드를 그립니다. navigator.canShare가 파일을 받아 주면 Web Share로, 아니면 PNG 다운로드로 폴백합니다.
  • 링크: 상태를 ?q=맥%20미니%20M4&p=890000&m=US 형태로 URL에 반영합니다 (replaceState). 첫 로드 때 파싱해서 복원합니다.

테스트

npm test

도메인과 서비스 레이어를 덮습니다. 환산 경계값, scale 구간, 한국식 숫자 포맷, 높이 단위 전환과 비유, 랜드마크·활동 사다리 선택(정렬·최소 간격·id 중복·구간별 결과), 나라 해석(모르는 ?m=이면 기본 나라로)·통화 표기·빅맥 지수와 개수 비율의 일치, 시절 분류와 정렬·물가가 오르는 방향, 스프라이트 격자 정합성(행 길이·허용 문자·사다리와의 연결), 초성 검색, 다른 단위 환산(내림·보고 있는 항목 제외·0개 제외·한도·잘못된 금액), parsePriceData의 스키마 거부(markets 중복 id·환율 0·defaultMarket 부재), loadPrices의 fallback 폴백, 쇼핑 응답 정제(절사·중앙값·키워드 필터), 그리고 lookupPrice의 폴백 순서(1번 실패 → 2번 성공, 1·2번 실패 → 3번 성공, 타임아웃, "값을 매길 수 없음"의 비폴백 전파)까지 확인합니다.

아이콘 다시 만들기

node scripts/generate-icons.mjs

PWA 아이콘 PNG를 픽셀부터 직접 그려서 생성합니다. 이미지 편집기나 외부 의존성 없이도 언제든 다시 만들 수 있게 해 둔 것입니다.

이 단계에서 하지 않은 것

  • localStorage/sessionStorage 외의 브라우저 스토리지 API
  • 외부 UI 라이브러리
  • 로그인, 히스토리, 가격 알림

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages