이 글을 읽으면 알 수 있는 것
- 식약처 A코드(영문 화학명)가 왜 소비자에게 보여줄 수 없는 데이터인지
- 3-Layer 시각화 모델의 설계: 상위 성분 / 역할 그루핑 / 알레르기+위험
- 수천 개 A코드 중 166건만 번역해서 79% 커버리지를 달성한 파레토 전략
- HACCP rawmtrl 데이터에서 원재료 역할을 자동 분류하는 방법
- ingredient_groups JSON 구조 설계와 웹 역적용
문제: 소비자가 읽을 수 없는 성분표
식약처 원재료 사전의 A코드(식품원료)는 이런 이름을 가지고 있습니다.
A0286 Carthamus tinctorius L. seed oil
A0142 Glycine max (L.) Merr.
A0501 Oryza sativa L.
A1023 Capsicum annuum L.
영문 학명입니다. 이걸 제품 상세 화면에 그대로 보여줄 수는 없습니다. “Glycine max”가 콩이라는 것을 아는 소비자는 거의 없습니다.
이전 글에서 다룬 B코드(첨가물)는 “L-글루탐산나트륨”처럼 한국어 화학명이라도 있었습니다. A코드는 아예 영문 학명뿐입니다. 번역 없이는 서비스에 노출 자체가 불가능합니다.
문제는 규모였습니다. A코드는 수천 건. 전부 번역하는 것은 비현실적이었고, 전부 번역할 필요도 없었습니다.
먼저 설계: 3-Layer 시각화 모델
번역을 시작하기 전에, “번역된 성분명을 어떻게 보여줄 것인가”를 먼저 설계했습니다. 보여주는 방식이 정해져야 번역에 필요한 데이터가 무엇인지 알 수 있기 때문입니다.
앱(React Native)의 제품 상세 화면에서 성분 정보를 세 단계로 보여주는 구조를 설계했습니다.
Layer 1: 상위 3 성분
제품에 가장 많이 포함된 원재료 상위 3개를 한 줄로 보여줍니다.
밀가루, 설탕, 팜유
대부분의 사용자는 여기서 끝납니다. “이 과자는 밀가루, 설탕, 팜유가 주재료구나.” 이 정보만으로도 제품의 성격을 파악할 수 있습니다.
Layer 2: 역할 그루핑
전체 원재료를 역할별로 묶어서 보여줍니다. 성분표의 긴 나열을 구조화하는 계층입니다.
주재료: 밀가루, 설탕, 팜유
맛/향: 소금, 바닐라향
색/외관: 카라멜색소
형태/식감: 유화제, 팽창제
보존: 프로피온산칼슘
원재료가 20개, 30개인 제품도 5개 카테고리로 정리되면 한눈에 파악됩니다. 이 역할 분류가 3-Layer 모델의 핵심입니다.
Layer 3: 알레르기 + 위험
알레르기 유발 원재료와 사용기준 제한이 있는 첨가물을 별도로 강조합니다.
⚠ 알레르기: 밀, 대두, 우유
⚠ 사용기준 주의: 타르색소(적색40호)
세 계층은 독립적이 아니라 점진적 공개(progressive disclosure) 구조입니다. 대부분 Layer 1에서 만족하고, 궁금한 사람만 Layer 2를 열고, 알레르기가 있는 사람은 Layer 3를 확인합니다.
Layer 2의 역할 분류: HACCP rawmtrl 활용
역할 그루핑은 사람이 일일이 지정한 것이 아닙니다. HACCP 품목제조보고서의 rawmtrl(원재료) 데이터에 이미 역할 정보가 들어 있었습니다.
HACCP 데이터에는 제조사가 신고한 원재료별 용도 구분이 있습니다. “주원료”, “부원료”, “식품첨가물” 등의 분류입니다. 이 정보를 5개 역할로 재매핑했습니다.
ROLE_MAPPING = {
# HACCP 원재료 구분 → 3-Layer 역할
'main_ingredient': 'primary', # 주재료
'sub_ingredient': 'primary', # 주재료 (부원료도 주재료 그룹)
'flavoring': 'taste_aroma', # 맛/향
'coloring': 'color_visual', # 색/외관
'emulsifier': 'texture', # 형태/식감
'thickener': 'texture', # 형태/식감
'leavening': 'texture', # 형태/식감
'preservative': 'preservation', # 보존
'antioxidant': 'preservation', # 보존
}
HACCP rawmtrl에 역할 정보가 없는 원재료는 첨가물 사전의 용도 분류(감미료, 보존료, 유화제 등)를 참조하여 자동 분류했습니다. 두 소스 모두 없는 경우에만 primary(주재료)로 기본 분류됩니다.
이 자동 분류의 정확도가 100%는 아닙니다. 하지만 “분류 없이 성분명을 나열하는 것”보다는 “대략적으로라도 역할별로 묶어 보여주는 것”이 사용자에게 유의미하다고 판단했습니다.
커버리지 측정: 어떤 A코드를 먼저 번역할 것인가
3-Layer 구조가 설계되었으니, 번역이 필요합니다. Layer 1과 Layer 2에 표시되는 한국어 이름이 없으면 화면이 비어 있게 됩니다.
A코드는 수천 건입니다. 전부 번역하려면 시간이 걸립니다. 어떤 것부터 할지 정해야 했습니다.
기준은 하나. 실제 제품에 가장 많이 등장하는 A코드부터.
-- A코드별 등장 제품 수 집계
SELECT
sm.acode,
sm.eng_name,
COUNT(DISTINCT rim.product_id) AS product_count
FROM substance_master sm
JOIN raw_ingredient_map rim ON rim.substance_id = sm.id
WHERE sm.code_group = 'A'
GROUP BY sm.acode, sm.eng_name
ORDER BY product_count DESC;
결과를 누적 커버리지로 계산하면 전형적인 파레토 분포가 나옵니다.
순위 A코드 등장 제품 수 누적 커버리지
1 A0501 (Oryza sativa / 쌀) 412,000 25.3%
2 A0142 (Glycine max / 대두) 285,000 42.8%
3 A0089 (Triticum aestivum / 밀) 198,000 55.0%
...
50 ... 12,000 71.2%
100 ... 4,800 76.1%
166 ... 1,200 79.0%
...
500 ... 180 89.3%
상위 3개만으로 55%, 상위 166개로 79%를 커버합니다. 나머지 수천 건은 각각 등장 빈도가 극히 낮습니다.
번역 실행: 53건 → 166건
초기에 수작업으로 번역한 A코드는 53건이었습니다. 가장 흔한 원재료들 — 쌀, 밀, 대두, 설탕, 소금, 고추 등.
이 53건만으로도 커버리지가 54%였습니다. 하지만 Layer 1에서 “상위 3 성분” 중 하나라도 번역이 안 되어 있으면, 해당 제품의 Layer 1은 불완전해집니다.
Top 200 A코드 목록에서 미번역 113건을 추가 번역했습니다.
Phase 1 번역 (수작업)
- 기존: 53건 → 커버리지 54%
- 추가: 113건 → 커버리지 79%
- 합계: 166건
113건을 추가하는 데 걸린 시간은 하루. A코드의 영문 학명을 한국어 일상명으로 바꾸는 작업입니다.
A0286 Carthamus tinctorius L. seed oil → 홍화씨유 (잇꽃씨 기름)
A0142 Glycine max (L.) Merr. → 대두 (콩)
A0501 Oryza sativa L. → 쌀
A1023 Capsicum annuum L. → 고추
A0712 Sesamum indicum L. → 참깨
번역은 단순 1:1 대응이 아니었습니다. 몇 가지 원칙을 정했습니다.
- 가장 익숙한 이름 우선: “Glycine max”의 정확한 번역은 “대두”지만, 괄호 안에 “콩”을 병기
- 가공 형태 반영: 같은 원료라도 “대두”와 “대두유(콩기름)“는 구분
- 공포 표현 금지: 이전 첨가물 번역과 동일한 원칙 유지
ingredient_groups: JSON 구조 설계
3-Layer 데이터를 API로 내려보내기 위한 JSON 구조입니다. 제품별로 ingredient_groups 필드에 저장됩니다.
{
"top3": ["밀가루", "설탕", "팜유"],
"groups": [
{
"role": "primary",
"label": "주재료",
"items": [
{
"name": "밀가루",
"acode": "A0089",
"description": "밀을 빻아 만든 가루"
},
{
"name": "설탕",
"acode": "A0623",
"description": "사탕수수나 사탕무에서 추출한 감미료"
}
]
},
{
"role": "taste_aroma",
"label": "맛/향",
"items": [
{
"name": "소금",
"acode": "A0991",
"description": null
}
]
},
{
"role": "preservation",
"label": "보존",
"items": [
{
"name": "프로피온산칼슘",
"acode": null,
"description": "미생물 번식을 억제하는 보존료"
}
]
}
],
"alerts": {
"allergy": ["밀", "대두"],
"risk": []
}
}
구조의 핵심 설계 결정 몇 가지:
top3를 별도 필드로 분리한 이유. groups[0].items의 앞 3개를 꺼내는 것으로도 구현할 수 있지만, Layer 1은 가장 많이 조회되는 데이터입니다. 별도 필드로 두면 클라이언트가 groups 전체를 파싱하지 않아도 됩니다.
description 필드는 nullable. 모든 원재료에 설명을 붙이는 것은 불가능하고 불필요합니다. 설명이 있으면 인라인으로 한 줄 표시하고, 없으면 이름만 표시합니다.
acode가 null인 항목. 첨가물(B코드)은 A코드가 없습니다. 반대로 식품원료(A코드)는 첨가물 사전에 없습니다. 같은 구조에 두 종류의 원재료가 공존하므로 nullable로 설계했습니다.
웹 역적용: 앱 전용 설계를 웹에도
ingredient_groups는 원래 React Native 앱의 제품 상세 화면을 위해 설계했습니다. 그런데 웹(Next.js) 쪽 제품 상세 페이지에도 성분 정보를 보여줘야 했습니다.
같은 API, 같은 JSON 구조를 웹에서도 그대로 사용했습니다. 플랫폼별로 데이터 구조를 따로 만드는 것보다, 하나의 구조를 양쪽에서 소비하는 편이 유지보수 비용이 낮습니다.
웹에서는 3-Layer를 아코디언 UI로 구현했습니다.
[제품 상세 페이지]
주요 성분: 밀가루, 설탕, 팜유 ← Layer 1 (항상 표시)
▸ 전체 성분 보기 ← 클릭 시 Layer 2 펼침
주재료: 밀가루, 설탕, 팜유, 전분
맛/향: 소금, 바닐라향
색/외관: 카라멜색소
형태/식감: 유화제(레시틴), 팽창제
보존: 프로피온산칼슘
⚠ 알레르기 유발: 밀, 대두 ← Layer 3 (해당 시 항상 표시)
Layer 3의 알레르기 정보는 아코디언 밖에 항상 노출됩니다. 알레르기는 “궁금해서 여는” 정보가 아니라 “반드시 보여야 하는” 정보이기 때문입니다.
나머지 21%: Phase 2 계획
166건 번역으로 79%를 달성했지만, 나머지 21%에 해당하는 제품들은 여전히 번역이 불완전합니다.
나머지 A코드들의 특성:
- 등장 빈도가 낮음 (제품 수 1,200건 미만)
- 특수 원재료가 많음 (허브류, 수입 원료, 특산물 등)
- 영문 학명이 복잡하여 단순 검색으로 번역하기 어려운 것들도 포함
Phase 2 계획은 이전 첨가물 번역과 동일한 구조입니다.
Phase 2: LLM 초안 → 사람 검수
1. 미번역 A코드 목록 추출
2. LLM(Gemini)에게 영문 학명 + 출현 빈도 + 맥락 제공
3. 한국어 이름 + 한 줄 설명 초안 생성
4. is_verified=False로 적재
5. 사람이 검수 후 is_verified=True로 변경
Phase 2를 실행하면 커버리지는 90% 이상으로 올라갈 것으로 예상합니다. 나머지 10%는 등장 빈도가 극히 낮아(제품 수 100건 미만) 우선순위가 낮습니다.
결과
| 항목 | 수치 |
|---|---|
| 전체 A코드 | 수천 건 |
| Phase 1 번역 (수작업) | 166건 |
| 제품 커버리지 | 79% (약 126만 제품) |
| 번역 전 커버리지 (53건) | 54% |
| 커버리지 증가분 | +25%p (113건 추가) |
| Layer 2 역할 분류 | 5개 카테고리 자동 분류 |
커버리지 효율
번역 53건 → 커버리지 54% (건당 1.02%p)
번역 166건 → 커버리지 79% (건당 0.48%p)
번역 500건 → 커버리지 ~89% (건당 0.18%p) ← 예상
투입 대비 효율이 급격히 떨어지는 구간이 있습니다. 166건은 “수작업으로 감당할 수 있으면서 효율이 아직 괜찮은” 마지막 지점이었습니다. 이 이후부터는 LLM의 영역입니다.
교훈
- “전부 번역”은 목표가 아니다. 수천 건의 A코드를 모두 번역하지 않아도, 상위 166건이면 전체 제품의 79%를 커버한다. 파레토 분포는 번역 전략에서도 유효하다.
- 보여주는 방식을 먼저 설계해야 번역 범위가 정해진다. 3-Layer 모델을 먼저 만들었기 때문에 “어떤 데이터가 반드시 필요한지”가 명확해졌다.
- 이미 있는 데이터를 먼저 활용한다. HACCP rawmtrl의 역할 정보를 재활용하여 자동 분류를 구현했다. 새 데이터를 만들기 전에 기존 데이터에 답이 있는지 확인하는 것이 먼저다.
- 하나의 데이터 구조를 여러 플랫폼에서 소비한다. ingredient_groups JSON을 앱과 웹에서 동일하게 사용하여 유지보수 비용을 줄였다.