GenealogyProtein 개발일지 · 1편

통 값이 아니라 단백질 값을 본다

단백질 파우더 가격표는 통 값으로 적혀 있다. 사는 사람이 실제로 사는 것은 통이 아니라 그 안에 든 단백질이다. 두 값의 차이가 이 프로그램의 존재 이유다.

v1.0.0.02026.9.16.제품 51개Python · HTML
목적

통 값으로 적힌 단백질 파우더 가격을 단백질 1g당 가격이라는 축 하나로 바꿔 세운다. 순도·용량·배송비가 섞여 통 값 비교가 성립하지 않는 문제를 푸는 것이 목적이다.

계획
  1. 자료 수집. 다나와 검색 결과와 아마존 대한민국 배송가
  2. 유형 판정. 성분 항목으로 WPC·WPI·블렌드 3갈래 분류
  3. 순도 산출. 표시기준량과 기준량당 단백질로 계산
  4. 단가 계산과 데이터셋 출력. CSV·XLSX·JSON 세 벌
  5. 차트 작성. 랭킹 막대·순도 대 단가 산점도·유형별 폭
  6. 단일 파일 도구 HTML 작성. 거르기와 정렬 포함
체크리스트7/10
  • ☑제품 51개 수집. WPC 17개·블렌드 9개·WPI 25개
  • ☑단가 산출. 중앙값 WPC 62원/g·블렌드 78원/g·WPI 95원/g
  • ☑데이터셋 CSV·XLSX·JSON 세 벌 출력
  • ☑차트 2장 작성. 랭킹 막대와 분포·산점도
  • ☑단일 파일 도구 HTML 작성. 자료를 안에 심어 인터넷 없이 열림
  • ☑모순 최저가 1건 제외. 나우푸드 WPI 2268g 137,090원 대 4.54kg 107,400원
  • ☑차트 한글 깨짐 해결. .ttc 묶음에서 Noto Sans CJK JP 를 후보에 추가
  • ☐쿠팡·네이버쇼핑 직접 수집 (브라우저 도구가 접속 거부, 다나와 집계값으로 대체)
  • ☐수집 자동화 (사람이 붙여 넣은 한 벌이라 값이 굳어 있음, 다음 판 과제)
  • ☐가격 이력·모순 자동 판정·류신 함량 축·CSV 내보내기 (다음 판 과제)

01제작 취지와 실제 목적

단백질 보충제를 고를 때 화면에 보이는 숫자는 「2kg 62,200원」 같은 통 값이다. 이 값만으로는 두 제품 중 어느 쪽이 싼지 판단할 수 없다. 같은 2kg이라도 분말 100g에 든 단백질이 72g인 제품과 82g인 제품이 섞여 있기 때문이다. 게다가 시장은 WPC 농축유청과 WPI 분리유청으로 갈라져 있고, 두 유형은 순도가 다르므로 통 값 비교는 애초에 성립하지 않는다.

그래서 하나의 축으로 모든 제품을 세운다.

단백질 1g당 가격 = (판매가 + 배송비) ÷ [용량 × (표시기준량당 단백질 ÷ 표시기준량)]

분모의 대괄호 안이 「통 하나에 실제로 들어 있는 단백질 총량」이다. 표시기준량과 그 기준량당 단백질은 국내 제품이면 식품표시기준에 따라, 해외 제품이면 Supplement Facts에 반드시 적혀 있다. 이 두 수만 있으면 순도가 나오고, 순도에 용량을 곱하면 총 단백질량이 나온다.

이 도구가 계산하는 값은 「같은 돈으로 받는 단백질량」 하나다. 맛·용해도·소화 편의는 계산에 넣지 않았다.

WPC와 WPI를 가른 이유

두 유형은 제조 공정이 다르다. WPC는 유청을 한외여과로 농축한 것으로 단백질 비율이 통상 70~80%이며 유당과 지방이 남는다. WPI는 여기서 한 단계 더 걸러 90% 안팎까지 올린 것으로 유당이 거의 없다. 유당 소화가 어려운 사람에게 WPI는 대체재가 아니라 필수재다. 그러므로 「WPI가 비싸다」는 결론이 아니라, WPI가 얼마나 더 비싼지를 수치로 두고 그 값을 낼 이유가 있는지 사람이 판단하게 하는 것이 이 도구의 자리다.

02기존 소프트웨어와의 차별성

비교 대상보여 주는 축한계
쿠팡·네이버쇼핑 정렬통 값, 100g당 값순도를 반영하지 않아 WPC가 항상 앞에 옴
다나와 가격비교통 값 최저가유형 구분이 상품 속성으로만 있고 축이 되지 못함
해외 커뮤니티 표1회분당 가격($/serving)1회분 크기가 제품마다 달라 비교 단위가 흔들림
GenealogyProtein단백질 1g당 가격(원/g)순도·용량·배송비를 한 축으로 흡수. 유형이 색으로 분리됨

차이의 핵심은 「1회분당」이 아니라 「단백질 1g당」을 쓴 것이다. 1회분은 제조사가 정하는 값이라 비교 단위가 될 수 없다. 같은 25g 단백질을 내는데 어떤 제품은 28g을 퍼 담으라 하고 어떤 제품은 47g을 퍼 담으라 한다. 단백질 1g은 제조사가 바꿀 수 없는 단위다.

03개발 절차

  1. 자료 수집 · 다나와 검색 결과에서 제품명·최저가·영양정보 사양을 긁어 정리. 아마존은 대한민국 배송 기준 원화 표시가와 회분 수를 받음
  2. 유형 판정 · 상품 사양의 「성분」 항목으로 WPC·WPI·블렌드 3갈래 분류. 성분이 둘 이상이면 블렌드
  3. 순도 산출 · 표시기준량과 기준량당 단백질로 순도를 냄. 영양정보가 없는 제품은 유형별 통상 순도로 추정하고 별도 표시
  4. 단가 계산·데이터셋 · CSV·XLSX·JSON 세 벌로 냄
  5. 차트 · 랭킹 막대, 순도 대 단가 산점도, 유형별 폭. 색은 WPI 파랑 · WPC 주황 · 블렌드 청록
  6. 도구 HTML · 거르기와 정렬이 되는 단일 파일. 자료를 안에 심어 인터넷 없이 열림

04자료 수집에서 막힌 곳

쿠팡·네이버쇼핑 화면 조회 차단

처음 계획은 쿠팡·네이버쇼핑·아마존 세 곳을 직접 읽는 것이었다. 앞의 둘은 브라우저 도구가 접속 자체를 거부했다.

https://coupang.com is not allowed due to safety restrictions
https://search.shopping.naver.com is not allowed due to safety restrictions

대안으로 다나와를 썼다. 다나와 최저가는 네이버쇼핑·11번가·쿠팡 등 국내 판매처를 집계한 값이므로, 개별 판매처를 직접 읽지 못해도 국내 시장가의 하한선은 그대로 얻어진다. 다만 판매처가 아니라 집계값이라는 사실을 화면에 명시했다. 출처를 감추면 나중에 값이 어긋났을 때 원인을 못 찾는다.

아마존은 robots.txt로 막히고 브라우저로 열렸다

아마존 제품 페이지를 페이지 가져오기 도구로 불렀더니 ROBOTS_DISALLOWED. 같은 주소를 브라우저 창에서 열자 정상 표시됐다. 자동 수집 도구와 사람이 쓰는 브라우저는 다른 규칙을 받는다. 브라우저로 열린 화면에서는 값이 원화로 나왔는데, 이는 대한민국 배송 기준가라 미국 현지가보다 높다. 환율 환산을 안 해도 되는 대신 통관·배송이 붙은 값임을 표에 적었다.

같은 제품인데 최저가가 서로 모순된 건

나우푸드 WPI에서 2268g이 137,090원인데 4.54kg이 107,400원으로 잡혔다. 두 배 큰 통이 더 싸다는 값이다. 병행수입·해외직구 판매처가 섞이면 이런 역전이 생긴다. 계산으로 걸러낼 방법이 없어 해당 건은 데이터셋에서 뺐다. 이상치를 남겨 두면 그래프의 최저 단가 자리를 그것이 차지해 버린다.

집계 최저가는 판매처마다 다른 값을 한 줄로 모은 것이다. 2268g이 137,090원인데 4.54kg이 107,400원으로 나온 것은 두 값을 낸 판매처가 서로 달랐기 때문이다.

05디버깅 기록

한글이 네모로 나온 차트

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 목록에 있는 이름은 서로 다를 수 있다. 묶음 글꼴을 쓸 때는 등록된 이름을 먼저 찍어 본다.

SVG 위쪽에 생긴 빈 공간

도구 화면의 랭킹 막대 위에 수백 픽셀의 빈 자리가 생겼다. 그림은 아래쪽에 몰려 있었다.

원인은 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 를 다시 넣어야 한다.

06v1.0.0.2 결과

유형제품 수최저중앙값최고
WPC 농축유청173962131
블렌드95178113
WPI 분리유청254295174

단위는 모두 원/g(단백질 1g당 가격). 2026.9.16. 기준.

읽어낸 것

07그림

제품 51개의 단백질 1g당 가격 가로 막대그래프. WPC는 주황, WPI는 파랑, 블렌드는 청록으로 칠해져 있다.
그림 1 제품별 단백질 1g당 가격. 위가 쌀수록이며 색이 유형이다. 위쪽 다섯 자리를 WPC가 차지하고, 아래쪽 여섯 자리를 WPI가 차지한다. 별표는 순도 추정값.
왼쪽은 유형별 단가 분포 점 그래프, 오른쪽은 순도 대 단가 산점도.
그림 2 왼쪽은 유형별 단가 분포와 중앙값, 오른쪽은 순도 대 단가. 오른쪽 그림에 기울기가 보이지 않는 것이 이번 판의 발견이다.

08다음 판에서 할 것


GenealogyProtein 개발일지 · 2편

게시본은 파일 하나여야 한다

개발일지는 이미 HTML이었지만 GenealogyPost에 올릴 수는 없었다. 그림이 폴더 바깥을 가리키고 있었기 때문이다. 게시는 「보이는가」가 아니라 「파일 하나로 옮겨지는가」의 문제다.

v1.0.0.22026.9.16.게시본 3편Python
목적

개발일지 HTML 이 그림을 폴더 바깥에서 불러 게시판에 넣는 순간 길이 끊겼다. 그림·글꼴·스타일을 파일 안에 모두 넣어 다른 서버로 옮겨도 같게 보이는 게시본을 만드는 것이 목적이다.

계획
  1. 상대 경로 그림 참조가 끊기는 지점 확인
  2. PNG 를 base64 로 바꿔 HTML 안에 심기
  3. 독자용 본편과 개발일지 두 편으로 게시본 분리
  4. 게시판 편집기 대비. 인라인 style 병기와 외부 글꼴 요청 제거
  5. 세 편을 한 번에 뽑는 생성기 posting.py 작성
체크리스트5/8
  • ☑그림 2장 base64 삽입. 456 KB 가 612 KB 로 약 34% 증가
  • ☑게시본 3편 분리. 독자용 본편·개발일지 1편·개발일지 2편
  • ☑골격 요소에 인라인 style 병기. 표 테두리·그림 최대폭·글머리 여백
  • ☑외부 글꼴 요청 제거. 맑은 고딕·Apple SD Gothic Neo 대체 목록만 남김
  • ☑posting.py 작성. 데이터셋 한 벌에서 세 편을 함께 생성
  • ☐정적 SVG 차트로 교체 (base64 PNG 대비 용량 1/20, 다음 판 과제)
  • ☐본편에 예산별 계산 칸 추가 (다음 판 과제)
  • ☐수집 자동화 (1편에 적은 대로 아직 그대로)

01무엇이 문제였나

1편의 개발일지 파일은 그림을 이렇게 불러 왔다.

<img src="../../image/chart01_rank_v1.0.0.0.png">

개발 폴더 안에서는 맞는 길이다. 파일을 게시판 편집기에 넣는 순간 이 길은 끊긴다. 상대 경로는 「내 폴더 구조가 상대편에도 있다」를 전제하는데, 게시판에는 image 폴더가 없다.

게시본은 그림·글꼴·스타일을 파일 안에 모두 넣어, 다른 폴더나 다른 서버로 옮겨도 같게 보이게 만든 것이다.

02해결 · 그림을 파일 안에 심는다

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 KB479 KB
chart02_dist (분포·산점도)99 KB133 KB

base64는 원본보다 약 34% 커진다. 두 장을 합쳐 620KB 안쪽이라 게시본 한 장으로 감당할 수 있는 크기다. 이보다 커지면 그림을 줄이거나 게시판 이미지 업로드로 갈라야 한다.

03게시본 세 편으로 나눈 이유

처음에는 개발일지 하나만 올릴 생각이었다. 그런데 GenealogyPost는 개발 기록을 모으는 곳이 아니라 정보를 다루는 온라인 잡지다. 독자가 찾는 것은 단가 표와 고르는 기준이다. 축을 어떻게 설계했고 자료 수집에서 어디가 막혔는지는 개발일지 쪽에 둔다. 그래서 같은 자료로 독자용 기사를 따로 썼다.

게시본독자답하는 질문
본편 「단백질 1g당 얼마인가」보충제를 사는 사람WPC와 WPI 중 무엇을, 어느 용량으로 살 것인가
개발일지 1편같은 것을 만들려는 사람축을 어떻게 잡고 자료를 어디서 얻었나
개발일지 2편게시본을 만들려는 사람단일 파일 게시본을 어떻게 뽑나

세 편 모두 같은 데이터셋 한 벌에서 나온다. 자료가 갱신되면 posting.py 를 한 번 돌려 세 편이 함께 바뀐다. 게시본을 손으로 고치면 다음 갱신 때 그 수정이 사라진다.

04디버깅

게시판 편집기가 style 태그를 지운다

게시판마다 허용 태그가 다르다. <style> 를 통째로 거르는 편집기를 만나면 글자만 남고 판이 무너진다. 대비로 본문의 골격 요소에는 인라인 style도 함께 넣었다. 표 테두리·그림 최대폭·글머리 여백처럼 없으면 글이 안 읽히는 것만 골라 이중으로 걸었다. 색과 세부 장식은 <style> 에만 두어 파일이 무거워지지 않게 했다.

글꼴을 바깥에서 불러오면 게시판에서 깨진다

1편은 Google Fonts를 <link> 로 불렀다. 게시본에서는 이를 빼고 맑은 고딕·Apple SD Gothic Neo 로 떨어지는 대체 목록만 남겼다. 게시판에서 글꼴 요청이 막히면 글자가 바뀌어 줄 바꿈 위치까지 달라진다.

05산출물

06다음 판에서 할 것


GenealogyProtein 개발일지 · 3편

검사기를 돌려 내 글을 걸렀다

다른 Genealogy 프로젝트에서 만든 문체 검사기를 이 프로젝트 글에 처음 돌렸다. 네 편 모두 걸렸고, 검사기가 못 잡는 자리도 두 군데 나왔다.

v1.0.0.22026.9.17.지적 34건Python
목적

다른 Genealogy 프로젝트에서 만든 문체 검사기를 이 프로젝트 글 네 편에 처음 돌렸다. 지적된 항목을 고치고 검사기가 못 잡는 자리까지 찾아 적는 것이 목적이다.

계획
  1. 글 네 편에 문체검사.py 실행과 지적 집계
  2. 대시와 금지어 지적 수정
  3. 검사에서 빠지는 구역에 들어간 경구 확인과 수정
  4. 금지어 오탐 재현
  5. 고친 뒤 재검사
체크리스트6/9
  • ☑네 편 검사. 대시 14건·금지어 4건·마침표빠짐 16건·겉멋 1건
  • ☑대시 14건을 · 로 교체. 모두 항목 이름과 설명을 잇는 자리
  • ☑blockquote 안 경구 3건 수정. 인용이 아니라 직접 지어낸 문장이었음
  • ☑겉멋 검사가 b 태그만 보는 탓에 지나친 문장 3건을 손으로 찾아 수정
  • ☑금지어 하나가 「글꼴이」에서 걸리는 오탐을 한 줄로 재현
  • ☑재검사 결과 네 편 모두 대시 0·마침표빠짐 0·겉멋 0
  • ☐해당 오탐 수정 (검사기를 여섯 프로젝트가 함께 써서 이 판에서는 보류)
  • ☐강조 태그를 b 로 통일 (다음 판 과제)
  • ☐글 올리기 전에 검사기 먼저 실행 (이번 판은 다 쓰고 나서 고침)

01검사 결과

검사기 문체검사.py 는 대시·금지어·긴문장·마침표빠짐·겉멋 다섯 가지를 센다. v1.0.0.1 의 글 네 편에 돌린 결과는 다음과 같다.

글대시금지어마침표빠짐겉멋
개발일지 index7220
게시본 본편00121
게시본 개발일지 1편6220
게시본 개발일지 2편1000

대시 14건은 모두 「자료 수집·다나와 검색 결과에서」처럼 항목 이름과 설명을 잇는 자리였다. 규칙대로 · 로 바꿨다.

02검사기가 못 잡은 자리 둘

blockquote 안에 내가 쓴 경구를 넣었다

검사기는 blockquote 를 검사에서 뺀다. 남의 말을 그대로 옮긴 자리라 우리 문체로 고칠 것이 아니라는 이유다. 그런데 이 프로젝트의 blockquote 세 개는 인용이 아니라 내가 지어낸 경구였다.

고치기 전고친 뒤
목적은 「어떤 제품이 좋은가」가 아니다이 도구가 계산하는 값은 「같은 돈으로 받는 단백질량」 하나다
집계 최저가는 「이 값에 살 수 있다」가 아니라 「어딘가에 이 값이 있었다」다2268g이 137,090원인데 4.54kg이 107,400원으로 나온 것은 두 값을 낸 판매처가 서로 달랐기 때문이다
폴더 안에서 열리는 것과 남에게 보내도 열리는 것은 다른 일이다게시본은 그림·글꼴·스타일을 파일 안에 모두 넣어, 다른 폴더로 옮겨도 같게 보이게 만든 것이다

세 개 모두 사실이 들어갈 자리를 교훈이 차지하고 있었다. 오른쪽 문장에는 수치와 대상이 있고 왼쪽에는 없다.

겉멋 검사가 b 태그만 본다

검사기는 <b> 와 <h2> 안의 글만 겉멋 다섯 꼴과 맞춰 본다. 이 프로젝트의 개발일지는 강조에 <strong> 을 썼고 게시본 본편만 <b> 를 썼다. 그래서 본편에서는 「순도가 높다고 비싼 것이 아니다」가 정의 경구로 잡혔지만, 개발일지의 같은 종류 문장 셋은 그냥 지나갔다.

잡히지 않은 문장어느 꼴고친 뒤
읽는 사람이 원하는 것은 「어떻게 만들었나」가 아니라 「그래서 뭘 사야 하나」다정의 경구독자가 찾는 것은 단가 표와 고르는 기준이다
게시본을 손으로 고치면 숫자가 서로 어긋나는 날이 온다되풀이 격언게시본을 손으로 고치면 다음 갱신 때 그 수정이 사라진다
외부 요청이 하나라도 있으면 그 파일은 「단일 파일」이 아니다정의 경구게시판에서 글꼴 요청이 막히면 글자가 바뀌어 줄 바꿈 위치까지 달라진다

03금지어 꼴이 의 오탐

금지어 목록의 꼴이 가 「글꼴이」에 걸린다. 부분 문자열로 찾기 때문이다. 다음 한 줄로 재현된다.

printf '<p>글꼴이 없어 네모로 나왔다.</p>' > t.html
python3 문체검사.py t.html
→ 금지어 꼴이

GenealogyKSIC 의 글 39편은 이 오탐에 걸리지 않았다. 「글꼴이」가 한 번 쓰였는데 그 자리가 표 안이어서 제외 구역이었기 때문이다. 우연히 비켜 간 것이지 규칙이 맞아서가 아니다.

검사기는 여섯 프로젝트가 같은 파일을 쓰므로 이 판에서는 고치지 않았다. 고친다면 금지어를 정규식으로 받고 꼴이 를 (?<!글)꼴이 로 바꾸는 한 줄이다.

04고친 결과

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 이다.

05다음 판에서 할 것