이 글의 배경이 되는 이야기는 14화: 영양정보가 0.1%만 채워져 있다면에서 읽을 수 있습니다.
이 글을 읽으면 알 수 있는 것
- HACCP 포장지 영양성분 텍스트가 왜 구조화된 API 데이터와 다른지
- 비정형 텍스트에서 10개 이상의 영양소를 추출하는 방법
- “미만” 표현의 한·영 혼합 패턴을 처리하는 설계
문제: 구조화되지 않은 영양정보
이전 글(04_haccp-discovery)에서 HACCP API의 포장지표기정보에 대해 다뤘습니다. 원재료뿐 아니라 영양성분 텍스트도 이 API에 들어 있습니다.
그런데 이 텍스트는 구조화된 데이터가 아닙니다. 포장지에 인쇄된 그대로의 문자열입니다.
열량 255kcal 나트륨 370mg 탄수화물 54g 당류 18g
단백질 3g 지방 2.6g 포화지방 1g 트랜스지방 0g
콜레스테롤 5mg 미만
사람이 읽으면 바로 이해할 수 있습니다. 프로그램이 쓰려면 “열량”이 “255”이고 단위가 “kcal”이라는 것을 분리해야 합니다.
원재료 파싱과 비슷한 문제이지만, 차이점이 있습니다. 원재료는 “무엇이 들어가 있는가”(이름 목록)를 추출하는 것이고, 영양성분은 “무엇이 얼마인가”(이름+수치+단위)를 추출하는 것입니다. 구조가 한 단계 더 복잡합니다.
표기 방식이 제각각
같은 영양소인데 포장지마다 다르게 적혀 있었습니다.
| 영양소 | 표기 변형 예시 |
|---|---|
| 열량 | 열량 255kcal, 열량: 255 kcal, Calories 255kcal, 열량(Kcal) 255 |
| 나트륨 | 나트륨 370mg, 나트륨: 370㎎, Sodium 370mg, 나트륨 / Sodium 370mg |
| 지방 | 지방 2.6g, Total Fat 2.6g, 총 지방 2.6g |
| 포화지방 | 포화지방 1g, 포화지방산 1g, Saturated Fat 1g, Sat. Fat 1g |
| 트랜스지방 | 트랜스지방 0g, 트렌스지방 0g (오타), Trans Fat 0g |
특히 “트렌스지방”이라는 오타가 무시할 수 없는 빈도로 존재했습니다. 이것도 잡아야 했습니다.
한국어와 영문이 병기되는 경우, 영문만 있는 경우, 한국어만 있는 경우가 섞여 있었습니다. 비율로 보면 한국어가 99.6%, 영문이 0.4% 정도였지만, 영문도 무시할 수 없었습니다.
추출 대상: 10개 영양소 + 1회 제공량
추출하려는 항목은 다음과 같습니다.
| 항목 | 단위 | 의무 여부 |
|---|---|---|
| 열량 | kcal | 의무 |
| 탄수화물 | g | 의무 |
| 당류 | g | 의무 |
| 단백질 | g | 의무 |
| 지방 | g | 의무 |
| 포화지방 | g | 의무 |
| 트랜스지방 | g | 의무 |
| 콜레스테롤 | mg | 의무 |
| 나트륨 | mg | 의무 |
| 식이섬유 | g | 선택 |
| 칼슘, 철분, 비타민C | mg | 선택 |
| 1회 제공량 | 가변 | 표기에 따름 |
의무 9개 항목은 법적으로 포장지에 있어야 합니다. 선택 항목은 있으면 추출하고, 없으면 넘어갑니다.
”미만” 문제
여기서 가장 까다로웠던 것은 “미만” 처리입니다.
트랜스지방이 0g이면 0이라고 적으면 되지만, 미량이 포함된 경우에는 이렇게 적힙니다:
트랜스지방 1g미만
콜레스테롤 5mg 미만
Trans Fat less than 1g
이 세 가지가 전부 같은 의미입니다. “1g보다 적다.”
그런데 프로그램 입장에서는 전혀 다른 패턴입니다.
| 패턴 | ”미만” 위치 | 수치 위치 |
|---|---|---|
1g미만 | 수치 뒤 | 영양소명 뒤 |
5mg 미만 | 수치+단위 뒤 (공백 있음) | 영양소명 뒤 |
less than 1g | 수치 앞 | 영양소명 뒤 |
한국어에서는 “미만”이 수치 뒤에 오고, 영어에서는 “less than”이 수치 앞에 옵니다. 방향이 반대입니다.
갈림길: 파싱 전략
| 방법 | 장점 | 단점 |
|---|---|---|
| 전체 텍스트를 한 번에 파싱 | 문맥 유지 | 패턴 간 간섭, 디버깅 어려움 |
| 영양소별로 개별 파싱 | 독립적, 디버깅 용이 | 같은 텍스트를 반복 탐색 |
| LLM 기반 추출 | 유연함 | 비용, 속도, 재현성 |
두 번째를 선택했습니다.
각 영양소마다 해당 영양소를 찾는 패턴 목록을 만들고, 첫 번째로 매칭되는 값을 사용합니다. 영양소 간 간섭이 없어서 디버깅이 쉽습니다.
다만 “지방”을 찾을 때 “포화지방”이나 “트랜스지방”에 걸리지 않도록 주의해야 했습니다. “지방” 패턴에는 “포화”나 “트랜스”가 앞에 오지 않는 경우만 매칭하는 조건을 넣었습니다.
”미만” 처리 설계
수치를 추출한 뒤, 그 앞뒤를 확인합니다.
수치 추출 성공
→ 수치 뒤 5글자 이내에 "미만"이 있는가? → "값 미만"으로 저장
→ 수치 앞 15글자 이내에 "less than"이 있는가? → "값 미만"으로 저장
→ 둘 다 아니면 → 그냥 수치로 저장
결과적으로 "1g미만", "5mg 미만", "less than 1g" 모두 "1 미만" 또는 "5 미만"이라는 통일된 형식으로 저장됩니다.
“미만”을 별도 플래그로 분리하는 방법도 고려했지만, 기존 영양성분 테이블의 구조를 바꾸지 않기 위해 문자열 안에 포함시키는 방식을 택했습니다.
전처리: 콤마 구분 숫자
영양성분 텍스트에서 한 가지 더 처리해야 할 것이 있었습니다. 열량이 높은 제품에서 이런 표기가 나왔습니다:
열량 2,911kcal
프로그램이 이걸 그대로 읽으면 “2”와 “911”로 분리되거나, 숫자로 인식하지 못합니다. 파싱 전에 2,911을 2911로 변환하는 전처리를 넣었습니다.
단, 아무 콤마나 제거하면 안 됩니다. 콤마 뒤에 정확히 3자리 숫자가 오는 경우만 천단위 구분자로 판단합니다.
1회 제공량 추출
영양성분 수치는 보통 “1회 제공량” 기준입니다. 이 기준이 없으면 수치의 의미를 알 수 없습니다.
1회 제공량 1개(50g)
1회제공량 1/10봉지(25g)
Serving Size 0.07oz(2g)
총 내용량 (300g)
이것도 포장지마다 표기가 달랐습니다. “1회 제공량”과 “1회제공량”(공백 유무), “Serving Size”(영문), “총 내용량”(전체 기준) 등.
100글자를 초과하면 잘라냅니다. 제공량 필드에 제품 설명 전체가 들어가 있는 비정상 데이터가 간혹 있었습니다.
결과
| 항목 | 수치 |
|---|---|
| 추출 대상 영양소 | 10개 + 1회 제공량 |
| 한국어 패턴 | 99.6% |
| 영문 패턴 | 0.4% |
| “미만” 처리 | 한국어(“미만”) + 영문(“less than”) 통합 |
| 추출 성공 제품 수 | ~1,600건 |
한계
추출 건수가 적습니다. 1,600건은 104만 전체 대비 0.15%입니다. HACCP API에 영양성분 텍스트가 있는 제품 자체가 적기 때문입니다. 하지만 이 1,600건은 의무 9개 항목이 모두 채워진, 기존 영양성분 API(포화지방 0.2%, 트랜스지방 0.1% 채움률)보다 훨씬 완전한 데이터입니다.
정규식 패턴은 본 적 없는 표기에 취약합니다. “에너지 150킬로칼로리”처럼 표준과 다른 표기가 나오면 추출에 실패합니다. 새 패턴이 발견되면 수동으로 추가해야 합니다.
“지방”과 “포화지방”의 구분이 어렵습니다. “포화지방” 바로 뒤에 “지방”이 나오는 배치에서, “지방” 패턴이 “포화지방”의 수치를 잡는 경우가 있었습니다. 부정 전방탐색(negative lookbehind)으로 대응했지만, 모든 배치 순서를 커버하지는 못합니다.
한 줄 교훈
같은 숫자 “1”이라도, 그 앞에 “less than”이 있는지 뒤에 “미만”이 있는지에 따라 의미가 완전히 달라집니다. 수치 추출은 수치만 보면 안 됩니다.