단백질 파우더 가격표는 통 값으로 적혀 있다. 사는 사람이 실제로 사는 것은 통이 아니라 그 안에 든 단백질이다. 두 값의 차이가 이 프로그램의 존재 이유다.
통 값으로 적힌 단백질 파우더 가격을 단백질 1g당 가격이라는 축 하나로 바꿔 세운다. 순도·용량·배송비가 섞여 통 값 비교가 성립하지 않는 문제를 푸는 것이 목적이다.
단백질 보충제를 고를 때 화면에 보이는 숫자는 「2kg 62,200원」 같은 통 값이다. 이 값만으로는 두 제품 중 어느 쪽이 싼지 판단할 수 없다. 같은 2kg이라도 분말 100g에 든 단백질이 72g인 제품과 82g인 제품이 섞여 있기 때문이다. 게다가 시장은 WPC 농축유청과 WPI 분리유청으로 갈라져 있고, 두 유형은 순도가 다르므로 통 값 비교는 애초에 성립하지 않는다.
그래서 하나의 축으로 모든 제품을 세운다.
단백질 1g당 가격 = (판매가 + 배송비) ÷ [용량 × (표시기준량당 단백질 ÷ 표시기준량)]
분모의 대괄호 안이 「통 하나에 실제로 들어 있는 단백질 총량」이다. 표시기준량과 그 기준량당 단백질은 국내 제품이면 식품표시기준에 따라, 해외 제품이면 Supplement Facts에 반드시 적혀 있다. 이 두 수만 있으면 순도가 나오고, 순도에 용량을 곱하면 총 단백질량이 나온다.
이 도구가 계산하는 값은 「같은 돈으로 받는 단백질량」 하나다. 맛·용해도·소화 편의는 계산에 넣지 않았다.
두 유형은 제조 공정이 다르다. WPC는 유청을 한외여과로 농축한 것으로 단백질 비율이 통상 70~80%이며 유당과 지방이 남는다. WPI는 여기서 한 단계 더 걸러 90% 안팎까지 올린 것으로 유당이 거의 없다. 유당 소화가 어려운 사람에게 WPI는 대체재가 아니라 필수재다. 그러므로 「WPI가 비싸다」는 결론이 아니라, WPI가 얼마나 더 비싼지를 수치로 두고 그 값을 낼 이유가 있는지 사람이 판단하게 하는 것이 이 도구의 자리다.
| 비교 대상 | 보여 주는 축 | 한계 |
|---|---|---|
| 쿠팡·네이버쇼핑 정렬 | 통 값, 100g당 값 | 순도를 반영하지 않아 WPC가 항상 앞에 옴 |
| 다나와 가격비교 | 통 값 최저가 | 유형 구분이 상품 속성으로만 있고 축이 되지 못함 |
| 해외 커뮤니티 표 | 1회분당 가격($/serving) | 1회분 크기가 제품마다 달라 비교 단위가 흔들림 |
| GenealogyProtein | 단백질 1g당 가격(원/g) | 순도·용량·배송비를 한 축으로 흡수. 유형이 색으로 분리됨 |
차이의 핵심은 「1회분당」이 아니라 「단백질 1g당」을 쓴 것이다. 1회분은 제조사가 정하는 값이라 비교 단위가 될 수 없다. 같은 25g 단백질을 내는데 어떤 제품은 28g을 퍼 담으라 하고 어떤 제품은 47g을 퍼 담으라 한다. 단백질 1g은 제조사가 바꿀 수 없는 단위다.
처음 계획은 쿠팡·네이버쇼핑·아마존 세 곳을 직접 읽는 것이었다. 앞의 둘은 브라우저 도구가 접속 자체를 거부했다.
https://coupang.com is not allowed due to safety restrictions
https://search.shopping.naver.com is not allowed due to safety restrictions
대안으로 다나와를 썼다. 다나와 최저가는 네이버쇼핑·11번가·쿠팡 등 국내 판매처를 집계한 값이므로, 개별 판매처를 직접 읽지 못해도 국내 시장가의 하한선은 그대로 얻어진다. 다만 판매처가 아니라 집계값이라는 사실을 화면에 명시했다. 출처를 감추면 나중에 값이 어긋났을 때 원인을 못 찾는다.
아마존 제품 페이지를 페이지 가져오기 도구로 불렀더니 ROBOTS_DISALLOWED. 같은 주소를 브라우저 창에서 열자 정상 표시됐다. 자동 수집 도구와 사람이 쓰는 브라우저는 다른 규칙을 받는다. 브라우저로 열린 화면에서는 값이 원화로 나왔는데, 이는 대한민국 배송 기준가라 미국 현지가보다 높다. 환율 환산을 안 해도 되는 대신 통관·배송이 붙은 값임을 표에 적었다.
나우푸드 WPI에서 2268g이 137,090원인데 4.54kg이 107,400원으로 잡혔다. 두 배 큰 통이 더 싸다는 값이다. 병행수입·해외직구 판매처가 섞이면 이런 역전이 생긴다. 계산으로 걸러낼 방법이 없어 해당 건은 데이터셋에서 뺐다. 이상치를 남겨 두면 그래프의 최저 단가 자리를 그것이 차지해 버린다.
집계 최저가는 판매처마다 다른 값을 한 줄로 모은 것이다. 2268g이 137,090원인데 4.54kg이 107,400원으로 나온 것은 두 값을 낸 판매처가 서로 달랐기 때문이다.
matplotlib가 한글을 못 그렸다. 컨테이너에 Noto Sans CJK가 깔려 있는데도였다.
UserWarning: Glyph 45800 (\N{HANGUL SYLLABLE DAN}) missing from font(s) DejaVu Sans.
원인은 .ttc 묶음 글꼴의 등록 방식이었다. NotoSansCJK-Regular.ttc 안에는 JP·KR·SC·TC 네 벌이 들어 있는데 matplotlib는 첫 번째 것만 이름으로 등록한다. fc-list 에는 「Noto Sans CJK KR」이 보이지만 matplotlib 목록에는 「Noto Sans CJK JP」만 있었다. Noto Sans CJK는 범CJK 글꼴이라 JP 벌에도 한글 자형이 있으므로, 후보 목록에 JP를 넣어 해결했다.
for c in ["Noto Sans CJK KR", "Noto Sans CJK JP", "NanumGothic", "Malgun Gothic"]:
if any(f.name == c for f in font_manager.fontManager.ttflist):
plt.rcParams["font.family"] = c
break
fc-list 에 나오는 이름과 matplotlib 목록에 있는 이름은 서로 다를 수 있다. 묶음 글꼴을 쓸 때는 등록된 이름을 먼저 찍어 본다.
도구 화면의 랭킹 막대 위에 수백 픽셀의 빈 자리가 생겼다. 그림은 아래쪽에 몰려 있었다.
원인은 width·height 속성과 max-width:100% 의 충돌이었다. 폭은 CSS가 줄였는데 높이는 속성값 그대로 남아, 남는 세로 공간에 preserveAspectRatio 가 그림을 가운데 정렬해 위아래로 여백을 붙인 것이다. height:auto 한 줄로 해결됐다.
svg{display:block; max-width:100%; height:auto}
제품 이름이 「원데이뉴트리션…」이 아니라 「이뉴트리션…」으로 보였다. text-anchor="end" 로 오른쪽 맞춤한 글이 왼쪽 여백을 넘어 viewBox 밖으로 나간 것이다. 좌측 여백을 196→236으로 넓히고 이름 자르는 길이를 26→22자로 줄여 맞췄다.
거르기 조건을 바꾸면 차트가 빈 칸이 됐다. 차트를 outerHTML 로 통째 교체하는 구조인데 새로 만든 SVG에 id 를 안 넣어, 두 번째 호출의 getElementById 가 빈손으로 돌아온 것이다.
outerHTML 로 자신을 통째 바꾸는 요소는 새로 만든 조각에도 같은 id 를 다시 넣어야 한다.
| 유형 | 제품 수 | 최저 | 중앙값 | 최고 |
|---|---|---|---|---|
| WPC 농축유청 | 17 | 39 | 62 | 131 |
| 블렌드 | 9 | 51 | 78 | 113 |
| WPI 분리유청 | 25 | 42 | 95 | 174 |
단위는 모두 원/g(단백질 1g당 가격). 2026.9.16. 기준.
개발일지는 이미 HTML이었지만 GenealogyPost에 올릴 수는 없었다. 그림이 폴더 바깥을 가리키고 있었기 때문이다. 게시는 「보이는가」가 아니라 「파일 하나로 옮겨지는가」의 문제다.
개발일지 HTML 이 그림을 폴더 바깥에서 불러 게시판에 넣는 순간 길이 끊겼다. 그림·글꼴·스타일을 파일 안에 모두 넣어 다른 서버로 옮겨도 같게 보이는 게시본을 만드는 것이 목적이다.
1편의 개발일지 파일은 그림을 이렇게 불러 왔다.
<img src="../../image/chart01_rank_v1.0.0.0.png">
개발 폴더 안에서는 맞는 길이다. 파일을 게시판 편집기에 넣는 순간 이 길은 끊긴다. 상대 경로는 「내 폴더 구조가 상대편에도 있다」를 전제하는데, 게시판에는 image 폴더가 없다.
게시본은 그림·글꼴·스타일을 파일 안에 모두 넣어, 다른 폴더나 다른 서버로 옮겨도 같게 보이게 만든 것이다.
PNG를 base64로 바꿔 src 자리에 직접 넣는다. 외부 파일 참조가 사라지므로 HTML 한 장이 그림까지 들고 다닌다.
b64 = base64.b64encode(open(png, "rb").read()).decode()
html = html.replace(src, f"data:image/png;base64,{b64}")
| 파일 | 원본 | base64 |
|---|---|---|
| chart01_rank (랭킹 막대) | 357 KB | 479 KB |
| chart02_dist (분포·산점도) | 99 KB | 133 KB |
base64는 원본보다 약 34% 커진다. 두 장을 합쳐 620KB 안쪽이라 게시본 한 장으로 감당할 수 있는 크기다. 이보다 커지면 그림을 줄이거나 게시판 이미지 업로드로 갈라야 한다.
처음에는 개발일지 하나만 올릴 생각이었다. 그런데 GenealogyPost는 개발 기록을 모으는 곳이 아니라 정보를 다루는 온라인 잡지다. 독자가 찾는 것은 단가 표와 고르는 기준이다. 축을 어떻게 설계했고 자료 수집에서 어디가 막혔는지는 개발일지 쪽에 둔다. 그래서 같은 자료로 독자용 기사를 따로 썼다.
| 게시본 | 독자 | 답하는 질문 |
|---|---|---|
| 본편 「단백질 1g당 얼마인가」 | 보충제를 사는 사람 | WPC와 WPI 중 무엇을, 어느 용량으로 살 것인가 |
| 개발일지 1편 | 같은 것을 만들려는 사람 | 축을 어떻게 잡고 자료를 어디서 얻었나 |
| 개발일지 2편 | 게시본을 만들려는 사람 | 단일 파일 게시본을 어떻게 뽑나 |
세 편 모두 같은 데이터셋 한 벌에서 나온다. 자료가 갱신되면 posting.py 를 한 번 돌려 세 편이 함께 바뀐다. 게시본을 손으로 고치면 다음 갱신 때 그 수정이 사라진다.
게시판마다 허용 태그가 다르다. <style> 를 통째로 거르는 편집기를 만나면 글자만 남고 판이 무너진다. 대비로 본문의 골격 요소에는 인라인 style도 함께 넣었다. 표 테두리·그림 최대폭·글머리 여백처럼 없으면 글이 안 읽히는 것만 골라 이중으로 걸었다. 색과 세부 장식은 <style> 에만 두어 파일이 무거워지지 않게 했다.
1편은 Google Fonts를 <link> 로 불렀다. 게시본에서는 이를 빼고 맑은 고딕·Apple SD Gothic Neo 로 떨어지는 대체 목록만 남겼다. 게시판에서 글꼴 요청이 막히면 글자가 바뀌어 줄 바꿈 위치까지 달라진다.
html/posting/01-단백질-1g당-얼마인가.html · 독자용 본편html/posting/02-개발일지-1편.html · 축 설계와 자료 수집html/posting/03-개발일지-2편.html · 이 글develop/posting.py · 세 편을 한 번에 뽑는 생성기다른 Genealogy 프로젝트에서 만든 문체 검사기를 이 프로젝트 글에 처음 돌렸다. 네 편 모두 걸렸고, 검사기가 못 잡는 자리도 두 군데 나왔다.
다른 Genealogy 프로젝트에서 만든 문체 검사기를 이 프로젝트 글 네 편에 처음 돌렸다. 지적된 항목을 고치고 검사기가 못 잡는 자리까지 찾아 적는 것이 목적이다.
검사기 문체검사.py 는 대시·금지어·긴문장·마침표빠짐·겉멋 다섯 가지를 센다. v1.0.0.1 의 글 네 편에 돌린 결과는 다음과 같다.
| 글 | 대시 | 금지어 | 마침표빠짐 | 겉멋 |
|---|---|---|---|---|
| 개발일지 index | 7 | 2 | 2 | 0 |
| 게시본 본편 | 0 | 0 | 12 | 1 |
| 게시본 개발일지 1편 | 6 | 2 | 2 | 0 |
| 게시본 개발일지 2편 | 1 | 0 | 0 | 0 |
대시 14건은 모두 「자료 수집·다나와 검색 결과에서」처럼 항목 이름과 설명을 잇는 자리였다. 규칙대로 · 로 바꿨다.
검사기는 blockquote 를 검사에서 뺀다. 남의 말을 그대로 옮긴 자리라 우리 문체로 고칠 것이 아니라는 이유다. 그런데 이 프로젝트의 blockquote 세 개는 인용이 아니라 내가 지어낸 경구였다.
| 고치기 전 | 고친 뒤 |
|---|---|
| 목적은 「어떤 제품이 좋은가」가 아니다 | 이 도구가 계산하는 값은 「같은 돈으로 받는 단백질량」 하나다 |
| 집계 최저가는 「이 값에 살 수 있다」가 아니라 「어딘가에 이 값이 있었다」다 | 2268g이 137,090원인데 4.54kg이 107,400원으로 나온 것은 두 값을 낸 판매처가 서로 달랐기 때문이다 |
| 폴더 안에서 열리는 것과 남에게 보내도 열리는 것은 다른 일이다 | 게시본은 그림·글꼴·스타일을 파일 안에 모두 넣어, 다른 폴더로 옮겨도 같게 보이게 만든 것이다 |
세 개 모두 사실이 들어갈 자리를 교훈이 차지하고 있었다. 오른쪽 문장에는 수치와 대상이 있고 왼쪽에는 없다.
검사기는 <b> 와 <h2> 안의 글만 겉멋 다섯 꼴과 맞춰 본다. 이 프로젝트의 개발일지는 강조에 <strong> 을 썼고 게시본 본편만 <b> 를 썼다. 그래서 본편에서는 「순도가 높다고 비싼 것이 아니다」가 정의 경구로 잡혔지만, 개발일지의 같은 종류 문장 셋은 그냥 지나갔다.
| 잡히지 않은 문장 | 어느 꼴 | 고친 뒤 |
|---|---|---|
| 읽는 사람이 원하는 것은 「어떻게 만들었나」가 아니라 「그래서 뭘 사야 하나」다 | 정의 경구 | 독자가 찾는 것은 단가 표와 고르는 기준이다 |
| 게시본을 손으로 고치면 숫자가 서로 어긋나는 날이 온다 | 되풀이 격언 | 게시본을 손으로 고치면 다음 갱신 때 그 수정이 사라진다 |
| 외부 요청이 하나라도 있으면 그 파일은 「단일 파일」이 아니다 | 정의 경구 | 게시판에서 글꼴 요청이 막히면 글자가 바뀌어 줄 바꿈 위치까지 달라진다 |
꼴이 의 오탐금지어 목록의 꼴이 가 「글꼴이」에 걸린다. 부분 문자열로 찾기 때문이다. 다음 한 줄로 재현된다.
printf '<p>글꼴이 없어 네모로 나왔다.</p>' > t.html
python3 문체검사.py t.html
→ 금지어 꼴이
GenealogyKSIC 의 글 39편은 이 오탐에 걸리지 않았다. 「글꼴이」가 한 번 쓰였는데 그 자리가 표 안이어서 제외 구역이었기 때문이다. 우연히 비켜 간 것이지 규칙이 맞아서가 아니다.
검사기는 여섯 프로젝트가 같은 파일을 쓰므로 이 판에서는 고치지 않았다. 고친다면 금지어를 정규식으로 받고 꼴이 를 (?<!글)꼴이 로 바꾸는 한 줄이다.
html/devlog/index.html 대시 0 금지어 꼴이 마침표빠짐 0 겉멋 0
html/posting/01-단백질... 대시 0 금지어 - 마침표빠짐 0 겉멋 0 OK
html/posting/02-개발일지-1편 대시 0 금지어 꼴이 마침표빠짐 0 겉멋 0
html/posting/03-개발일지-2편 대시 0 금지어 - 마침표빠짐 0 겉멋 0 OK
남은 꼴이 둘은 위에 적은 오탐이다. 그 밖의 항목은 모두 0 이다.
문체검사.py 를 먼저 돌린다. 이번처럼 다 쓰고 나서 고치면 문장을 통째로 다시 써야 한다.<b> 로 통일한다. 그래야 겉멋 검사가 이 프로젝트 글에도 걸린다.