식단 옵티마이저를 네 번 다시 썼다
규칙에서 그리디+재시도까지, 식단 엔진을 네 세대 다시 썼다. 매번 측정이 재작성을 강제했고, 코어에서 LLM을 빼 결정론으로 짠 이유 — 우리가 AI를 '어디에 쓰지 않을지'부터 설계하는 방식.
밥비서의 식단 엔진은 네 세대를 거쳤다. 한 번 잘 짜고 다듬은 게 아니라, 코어를 네 번 폐기하고 다시 썼다. 리포에는 그 흔적이 세대별 디렉토리로 남아 있다.
처음부터 큰 그림을 그려두고 거기로 수렴한 게 아니다. 매번 측정이 다음 재작성을 강제했다. 어떤 식단이 나오는지, 얼마나 걸리는지, 사용자가 어떤 식단에 별점을 낮게 주는지를 보고 나서야 다음 세대의 모양이 정해졌다. 1인 창업 제품의 코어를 네 번 갈아엎는 건 즐거운 일이 아니다. 그런데도 그렇게 한 이유와, 그 끝에서 우리가 LLM을 코어에서 빼기로 결정한 이야기를 적는다.
네 세대의 궤적
각 세대를 한 줄로 줄이면 이렇다. 숫자가 아니라 구조만 적는다.
- 1세대 — 규칙·휴리스틱. 끼니 슬롯을 규칙과 휴리스틱으로 채웠다. 빠르게 만들었고, 빠르게 한계를 봤다.
- 2세대 — 그리디 + 재시도. 식단 구성을 탐색 문제로 다시 봤다. 하드 제약을 먼저 만족시키고, 안 되면 조건을 완화해 재시도한다. 현재 운영 중인 코어가 이 계열이다.
- 3세대 — 테마 카드. 계절·날씨·상황 같은 컨텍스트를 “테마 카드”로 묶어 식단 구성에 반영했다. 알고리즘 위에 컨텍스트 레이어를 얹은 셈이다. A/B 실험으로 효과를 검증하며 굴렸다.
- 4세대 — 알고리즘이 아니라 측정 체계. 여기서 우리는 코어를 또 갈아엎는 대신 측정과 주간 회고, 안전한 AB 실험을 정비했다. 더 다시 쓰는 것보다, 무엇을 다시 써야 하는지 아는 게 먼저였다.
2세대 코어의 골격은 의사코드로 이렇게 요약된다.
plan = empty
for slot in week_slots:
candidates = filter(recipes, hard_constraints(slot)) # 못 먹는 재료, 영양 한계 등
if candidates is empty:
relax(soft_preferences) # 선호를 단계적으로 완화
continue
pick = argmin(candidates, weighted_cost) # 다양성·예산·구성비·준비시간 등
plan.add(slot, pick)
return plan
여기서 중요한 건 hard_constraints, weighted_cost의 구체적인 숫자가 아니라 “하드 제약 먼저, 소프트 선호 나중, 안 되면 완화 재시도”라는 단계 구조다.
왜 매번 버렸나
전환마다 우리를 떠민 건 신념이 아니라 신호였다.
1세대 → 2세대. 규칙과 휴리스틱은 표현력에서 막혔다. 끼니 슬롯, 인원, 못 먹는 재료, 구성비 같은 조건이 서로 얽히면 규칙은 예외의 예외로 불어났다. 식단 구성을 제약 만족·탐색 문제로 다시 정의하자, 같은 조건을 가중 점수와 재시도로 다룰 수 있었다. 속도도 체감 가능한 수준으로 나아졌다 — 정확한 벤치마크는 내부 관측이라 단정하지 않지만, 사용자가 기다리는 시간이 분명히 줄었다.
2세대 → 3세대. 알고리즘은 좋은 식단을 짰지만, 지금의 사정을 몰랐다. 같은 사용자라도 더운 주와 명절이 낀 주는 먹고 싶은 게 다르다. 컨텍스트를 테마 카드라는 일급 개념으로 끌어올렸다.
3세대 → 4세대. 가장 절제가 필요한 전환이었다. 더 갈아엎고 싶은 충동은 늘 있지만, 측정 없이 갈아엎는 건 도박이다. 그래서 다음 세대는 알고리즘이 아니라, 무엇이 좋아졌는지 말할 수 있는 체계로 정했다.
그리고 모든 세대를 관통하는 결정이 하나 있었다 — 왜 LLM에게 식단 생성을 통째로 맡기지 않았나. 우리도 거기서 출발했다. 초기엔 “일주일 식단 짜줘”를 LLM에 던졌다. 두 차례 시범서비스를 돌리며 그 그림이 무너졌다. LLM 단독 생성은 정합성·비용·속도·편차에서 매번 흔들렸다. 같은 입력에 다른 결과가 나오고, 호출당 비용이 쌓이고, 하드 제약을 어겨도 그럴듯해 보였다.
결론 — LLM-free 결정론 코어
그래서 우리는 코어에서 LLM을 뺐다. 식단을 만드는 일은 결정론 엔진이 하고, LLM은 만들어진 결과를 판단·검증하는 게이트된 가산 레이어로만 붙는다. 생성자에서 판단자로 역할을 옮긴 것이다.
운영에서 이 분리가 결정적이었던 이유는 세 가지다. 설명 가능성 — 같은 입력이면 같은 식단이 나오고, 왜 그 식단인지 추적할 수 있다. 하드 제약 검증 — 못 먹는 재료가 식탁에 오르는 일은 확률이 아니라 코드로 막아야 한다. 재현성 — 버그를 재현하고 회귀를 잡으려면 결과가 흔들리면 안 된다. LLM을 생성자로 두면 이 셋이 전부 약해진다.
이건 LLM을 불신해서가 아니다. 도구를 어디에 둘지의 문제다. 우리에게 deterministic-first, LLM-optional은 슬로건이 아니라 디렉토리 구조이자, 네 번의 재작성이 남긴 결론이다.
AI를 어디에 쓰지 않을지부터
우리가 파트너 제품에 가져가는 첫 질문은 “여기에 AI를 어떻게 넣을까”가 아니라 “여기서 AI를 어디에 쓰지 않을지”다. 결정론으로 먼저 짜고, LLM은 그 위에 게이트해 얹는다 — 이 순서가 속도·비용·정합성·재현성을 동시에 지킨다. 네 번 다시 쓴 끝에 우리가 배운 방법론이고, 도메인에 묶여 있지 않다.
같은 엔진을 당신의 제품 아래로 옮기는 이야기가 궁금하다면 — partners@creativengine.ai.