자동 생성을 한 번 더 눌렀을 뿐인데 햄이 사라졌다.
메뉴에서 장보기 목록을 만들고, 목록에 없던 햄을 직접 하나 넣고, 대파는 이미 사서 체크해 둔 상태였다. 그다음 식단에서 메뉴 하나를 바꿨으니 목록도 맞춰야 했다. 자동 생성을 다시 돌렸다. 바뀐 메뉴의 재료는 잘 들어왔다. 그런데 햄이 없었고, 체크해 둔 대파도 없었다. 산 걸 다시 사라는 목록이 됐다.
이상하게도 코드는 쓴 그대로 움직이고 있었다. 처음 설계한 대로였다.
재료 한 벌, 메뉴 수만큼
장보기 자동 생성의 출발점은 단순하다. 식단표의 메뉴 하나는 레시피 하나를 가리키고, 레시피에는 재료 목록이 있다. 그러니 메뉴 하나는 재료 한 벌이다. 한 주 동안 된장찌개가 두 번 나오면 두부도 두 번 사야 한다.
범위는 하루, 그 날짜가 속한 주(일요일부터 토요일까지), 그 달 전체 중에서 고른다. 주가 달을 가로지르면 같은 달 안의 날짜만 모은다. 결과는 지금 보고 있는 날짜의 장보기에 들어간다.
모은 재료는 이름과 단위가 같은 것끼리 묶는다.
var aggregated = occurrences
.GroupBy(i => new { i.Name, Unit = i.Unit ?? "" })
.Select(g => {
// 숫자로 읽히는 양은 더하고, 못 읽은 건 개수만 센다
...
});
단위를 키에 넣은 건 의도적이다. 돼지고기 1근과 돼지고기 300g 을 더하면 안 된다. 단위 환산표를 두는 대신 두 줄로 두고 사람이 보게 했다.
양은 문자열이다. 레시피에 적힌 양이 "1", "0.5", "1/2", "1+1/2" 이기도 하고 "약간", "적당량" 이기도 하다. 이걸 읽는 일은 AmountAggregator 라는 작은 정적 클래스로 뺐다. 분수와 대분수는 정규식으로 읽어 숫자로 바꾸고, 못 읽으면 null 을 돌려준다. 합칠 때는 세 갈래다. 둘 다 숫자면 더한다. 한쪽만 숫자면 "2 + 약간"이 된다. 둘 다 숫자가 아니면 "약간 ×2", 이미 "약간 ×2" 였으면 "약간 ×3" 이 된다. 소금을 세 번 쓰는 주라면 소금 칸에 "약간 ×3" 이 찍힌다. 양은 몰라도 몇 번 쓰는지는 보인다.
이 클래스는 자동 생성만 쓰는 게 아니다. 같은 날짜에 같은 재료를 직접 추가하면 새 줄을 만들지 않고 아직 안 산 기존 줄의 양에 더한다. 자동 생성으로 "두부 1모"가 있는 날에 두부 1모를 직접 넣으면 "두부 2모" 한 줄이 된다.
장보기에 들어오는 길이 늘어났다
처음 자동 생성은 목록을 메뉴의 함수로 봤다. 메뉴가 같으면 목록도 같아야 하니, 다시 생성하면 그 날짜의 목록을 지우고 새로 채웠다. 목록이 거의 메뉴에서만 채워지던 때는 이게 맞아 보였다.
길은 하나씩 늘었다.
먼저 이월이다. 장보기는 날짜별로 저장되니 어제 못 산 재료는 오늘 화면에 보이지 않았다. 그래서 오늘 날짜를 열면 지난 14일 동안 체크하지 않은 항목을 "이전에 못 산 항목"으로 따로 보여 주고, 하나씩 또는 한꺼번에 이 날짜로 옮길 수 있게 했다. 옮긴 항목에는 CarriedOverFrom 컬럼에 원래 날짜를 남긴다. 여러 번 옮겨도 처음 날짜를 유지한다. 그리고 이 항목만은 자동 생성의 교체 대상에서 뺐다. 사용자가 직접 골라서 가져온 것이니까. 옮겨온 항목은 자동 생성한 줄과도 합치지 않고, 옮겨온 것끼리만 합친다. 합쳐 버리면 다음 재생성 때 함께 지워지기 때문이다.
그다음은 자주 사는 재료다. 최근 90일 동안 구매 완료로 체크한 날이 이틀 이상인 재료를 칩으로 띄우고, 누르면 바로 목록에 들어간다. 양과 단위는 가장 최근에 산 기록을 따른다.
자동 생성 자체도 바뀌었다. 버튼을 누르면 바로 만들던 것을 미리보기 화면으로 바꿔서, 날짜·끼니·레시피별로 재료를 체크해 넣게 했다. 집에 간장이 있으면 간장만 빼면 된다. 두 개 이상의 메뉴에 들어가는 재료, 소금이나 간장 같은 양념은 위에 따로 모아 한 번에 넣거나 뺄 수 있게 했다.

지난번에 뺀 재료를 기억하면 편할 줄 알았다
미리보기를 만들면서 하나를 더 얹었다. 지난번 자동 생성에서 뺀 재료를 브라우저에 기억해 뒀다가, 다음 미리보기를 열 때 미리 체크를 풀어 두는 기능이다. 매번 간장을 빼는 집이라면 매번 간장을 빼는 수고를 덜 수 있다.
규칙도 나름 신경 썼다. 모든 메뉴에서 뺀 재료만 기억하고, 한 메뉴에서라도 넣은 재료는 기억에서 지웠다. 메뉴를 통째로 뺀 경우는 재료 취향이 아니라 메뉴 선택이니 기억하지 않았다.
그리고 얼마 안 가 통째로 걷어냈다. 재료를 빼는 이유가 하나가 아니었다. 간장은 집에 있어서 뺐다. 지난주에 산 간장이 다음 주에도 남아 있을지는 아무도 모른다. 고수는 싫어서 뺐을 수도 있다. 화면에서 체크를 푸는 동작은 같아도 그 뒤에 있는 이유는 전혀 다르고, 기억하는 쪽은 그 이유를 알 방법이 없다. 결국 미리 빠진 재료 하나를 놓치면 마트에서 간장이 없어 돌아오게 된다. 미리보기는 다시 항상 전부 체크된 상태로 시작한다. 빼는 건 매번 사람이 한다. 몇 번 더 누르는 수고보다 빠뜨린 재료 하나가 더 비쌌다.
행이 어디서 왔는지 몰랐다
다시 햄 이야기로 돌아간다. 들어오는 길은 여럿이 됐는데, 재생성 코드는 여전히 목록이 메뉴에서만 채워진다는 생각으로 움직였다. 이월 항목만 예외로 빼고 그 날짜의 나머지를 전부 지웠다. 직접 넣은 햄도, 자주 사는 재료 칩으로 넣은 재료도, 이미 사서 체크한 대파도 똑같이 "그 날짜의 장보기 행"이었다.
문제는 코드가 아니라 데이터에 있었다. 행 하나만 봐서는 그게 메뉴에서 왔는지 사람이 넣었는지 알 길이 없었다. 이월은 CarriedOverFrom 이라는 표시가 있어서 지킬 수 있었던 거다. 그래서 같은 방식으로 표시를 하나 더 달았다. grocery_lists 에 IsGenerated 컬럼을 추가하고, 자동 생성으로 만든 행에만 true 를 넣는다. 재생성은 이제 이렇게 움직인다.
// 지난 자동 생성 중 아직 안 산 것만 교체
var replaced = existing.Where(g => g.IsGenerated && !g.IsChecked).ToList();
_db.GroceryLists.RemoveRange(replaced);
// 남은 항목에 같은 재료(이름+단위)가 있으면 새로 넣지 않음
var kept = existing.Except(replaced)
.Select(g => (g.Name.Trim(), g.Unit ?? ""))
.ToHashSet();
var added = aggregated.Where(g => !kept.Contains((g.Name.Trim(), g.Unit ?? ""))).ToList();
지우는 건 "자동으로 만들었고 아직 안 산 것"뿐이다. 이미 산 대파는 남는다. 사람이 넣은 햄도 남는다. 두 번째 블록이 하나 더 중요하다. 남은 줄에 이미 있는 재료는 새로 넣지 않는다. 내가 두부를 반 모만 사겠다고 직접 적어 뒀는데 재생성이 "두부 1모"를 한 줄 더 얹으면, 다시 같은 이름 두 줄 문제로 돌아간다. 사람이 정한 양이 이긴다. 안내 문구도 "목록을 만들었습니다"에서 "장보기에 넣었습니다"로 고쳤다.
경계가 하나 더 있었다. 자동 생성한 "두부 1모" 줄에 사람이 두부 1모를 직접 더하면 "두부 2모"가 된다. 이 줄은 어느 쪽 것인가. 여기서는 사람 쪽으로 정했다. 직접 더한 양이 섞인 순간 IsGenerated 를 false 로 내린다. 그대로 두면 다음 재생성 때 사람이 더한 1모까지 같이 지워진다.
마이그레이션은 컬럼 하나를 기본값 false 로 추가할 뿐이다. 그러면 이미 쌓여 있던 행은 전부 "사람이 넣은 것"으로 취급된다. 고친 뒤 처음 재생성을 하면 예전에 자동 생성한 줄도 지워지지 않고, 같은 재료는 새로 들어오지도 않는다. 일부러 이쪽을 골랐다. 틀리더라도 지워야 할 걸 남기는 쪽이, 남겨야 할 걸 지우는 쪽보다 낫다. 남은 줄은 사람이 x 한 번이면 지우지만, 지워진 햄은 사람이 기억해 내야 한다.
테스트는 그 장면을 그대로 옮겼다. GroceryCarryOverTests 에 직접 넣은 두부 반 모, 사서 체크한 대파, 지난 자동 생성으로 들어온 안 산 양파, 직접 넣은 햄을 깔아 놓고 두부·대파·계란이 든 메뉴로 재생성하는 테스트를 넣었다. 남는 건 두부·대파·햄·계란, 새로 들어간 건 계란 하나, 두부는 여전히 반 모여야 통과한다.
레시피에서 담고, 날짜로 찾고
길은 그 뒤에도 하나 더 늘었다. 레시피 상세에서 재료를 장보기로 바로 담는 기능이다. 날짜(오늘·내일·모레·직접 고르기)와 재료를 골라 담는 창이 있고, 재료 줄마다 붙은 + 버튼으로 한 건씩 담을 수도 있다. 한 건 담기는 누르면 바로 ✓ 로 바뀌고 다시 눌리지 않는다. 두 번 눌러 양이 두 배가 되는 일을 막으려고 그랬다. 날짜를 아직 안 골랐으면 오늘로, 밤 9시가 넘었으면 내일로 담는다.
이번에는 처음부터 표시를 생각하고 만들었다. 같은 날짜의 같은 재료(이름+단위)에 양을 합치되, 아직 안 산 줄에만 합친다. 이미 산 재료에 양을 더하면 산 만큼을 다시 사게 되기 때문이다. 옮겨온 줄에도 합치지 않고, 자동 생성 줄에 합쳤다면 그 줄의 IsGenerated 를 내린다. 햄 사건에서 배운 걸 그대로 적용했다. 처음에는 직접 추가만 이미 산 줄에도 양을 더하고 체크를 풀었는데, 두 길이 다르게 움직일 이유가 없어서 나중에 직접 추가도 산 줄에는 합치지 않도록 맞췄다. GroceryAddItemsTests 가 이 규칙들을 하나씩 확인한다.
장보기 쿼리는 전부 "이 사용자의, 이 날짜(또는 날짜 범위)의 행"을 찾는다. 처음엔 UserId 단일 인덱스뿐이라 날짜 하나를 열 때마다 그 사용자의 장보기 기록을 통째로 읽고 걸렀다. 기록이 쌓일수록 그 낭비가 커진다. 그래서 (UserId, Year, Month, Day) 복합 인덱스로 바꿨다. 한 가지 걸린 게 있었다. MySQL 은 외래 키가 쓰고 있는 인덱스를 대신할 인덱스 없이 지우지 못한다. 마이그레이션에서 새 복합 인덱스를 먼저 만들고 옛 단일 인덱스를 나중에 지우는 순서로 맞췄다. 복합 인덱스의 맨 앞이 UserId 라 외래 키는 새 인덱스를 그대로 쓴다.
햄은 이제 사라지지 않는다. 돌이켜 보면 버그는 재생성 코드 한 줄이 아니라, 처음 설계할 때 장보기 목록을 "메뉴에서 계산되는 값"으로 본 데서 왔다. 사람이 손을 대기 시작한 순간 목록은 계산 결과가 아니라 기록이 됐고, 기록은 출처를 남겨야 했다.
장보기 목록은 결국 식단표에서 시작한다. 그리고 그 식단표의 빈칸에 무엇을 올릴지가 처음의 질문이었다. 다음 편은 "오늘 뭐 먹지"에 점수를 매겨 본 이야기다.