달력 칸을 눌러 메뉴를 추가하려고 하면 레시피 목록이 이름순으로 쭉 펼쳐졌다. 자주 먹는 메뉴든 한 번도 안 만든 메뉴든 가나다순으로 똑같이 한 줄씩이었다. 매번 같은 걸 고르면서도 매번 스크롤해서 찾아야 했다.

"오늘 뭐 먹지"라는 질문에 앱이 할 수 있는 가장 작은 대답은, 자주 고르는 것을 위에 올려 두는 것이었다.

즐겨찾기는 100점, 한 번 먹을 때마다 1점

처음 손댄 곳은 월간 메뉴의 레시피 선택 창이다. 먼저 레시피에 별표를 다는 즐겨찾기를 만들고, 선택 창에서 즐겨찾기를 위로 올리는 정렬을 기본값으로 바꿨다. 그런데 써 보니 즐겨찾기 묶음 안에서는 다시 이름순이었다. 별표가 늘어나면 결국 또 찾아야 했다.

그래서 사용 빈도를 넣었다. 새 테이블은 필요 없었다. 식단 기록(MonthlyMenus)에 이미 날짜마다 어떤 레시피를 올렸는지가 남아 있으니, 레시피별로 세기만 하면 된다. 선택 창의 목록에 usageCount 를 실어 보내고, 정렬은 화면 쪽 Alpine.js 에서 한다.

var sa = (a.isFavorite ? 100 : 0) + (a.isSubscribed ? 10 : 0) + (a.usageCount || 0);
var sb = (b.isFavorite ? 100 : 0) + (b.isSubscribed ? 10 : 0) + (b.usageCount || 0);

처음 공식은 즐겨찾기 ? 100 : 0 더하기 사용 횟수였다. 구독 10점은 나중에 작성자 구독 기능이 생기면서 끼어들었다. 100이라는 숫자에 깊은 계산이 있지는 않다. 한 레시피를 백 번 넘게 먹기 전까지는 즐겨찾기가 항상 이긴다는 뜻이고, 그 정도면 충분했다. 점수가 같으면 이름순으로 떨어진다.

정렬만으로는 부족한 순간도 있다. "오늘은 국물 있는 걸로"처럼 범위를 먼저 좁히고 싶을 때다. 레시피에는 처음부터 종류·상황·재료·방법 네 갈래 분류가 있었고, 선택 창에 이 네 차원을 칩으로 고르는 필터 패널을 붙였다. 차원마다 하나씩 고를 수 있고, 출처(내 것·공용·즐겨찾기)와 검색어까지 전부 AND 로 묶인다. 필터로 줄이고, 추천 점수로 세운다. 선택 창은 이 두 가지로 꽤 쓸 만해졌다.

먹던 것만 권하는 추천은 추천이 아니다

선택 창의 점수는 이미 메뉴를 고르러 온 사람을 위한 것이다. 홈 화면에 "오늘의 추천"을 띄우는 건 다른 문제였다. 같은 공식을 쓰면 매일 가장 자주 먹은 레시피가 1등이다. 어제 먹은 걸 오늘 또 권하는 추천은 아무 도움이 안 된다.

그래서 홈 추천은 RecommendationService 로 따로 만들고, 점수에 감점과 가산을 넣었다.

double score = 0;
if (fav) score += 50;
if (uses > 0) score += 8.0 * Math.Log2(1 + uses);   // 사용빈도 (로그 감쇠)
if (daysSince is null)
    score += 15;                                     // 안 만들어 본 레시피
else if (daysSince.Value < 30)
    score -= (30 - daysSince.Value) * 3.0;           // 최근에 만들수록 강한 감점
score += Jitter(c.Id, today);

사용 횟수에 로그를 씌운 건 열 번 먹은 레시피와 서른 번 먹은 레시피의 차이를 줄이기 위해서다. 1회면 8점, 3회면 16점, 7회면 24점이다. 많이 먹을수록 오르긴 하지만 점점 둔해진다. 대신 최근 30일 안에 먹은 레시피는 날짜가 가까울수록 깎인다. 어제 먹었으면 87점이 빠지니 즐겨찾기 50점으로도 버티지 못한다. 한 번도 안 만든 레시피에는 15점을 얹어서, 보관만 해 두고 잊은 레시피가 가끔 올라오게 했다.

마지막 줄의 Jitter 는 레시피 번호와 날짜로 만든 0~4.99점짜리 흔들림이다. 점수가 비슷한 후보들이 매일 같은 순서로 서지 않게 하되, 같은 날 안에서는 새로고침해도 결과가 바뀌지 않는다. 난수를 쓰지 않은 이유가 이것이다. 아침에 본 추천이 점심에 바뀌어 있으면 믿음이 가지 않는다.

점수순으로 줄을 세운 다음에는 다양성을 한 번 더 걸렀다. 종류(밥·찌개·반찬 같은 CategoryType)당 하나씩만 먼저 뽑고, 자리가 남으면 그때 점수순으로 채운다. 위 점수만으로 네 개를 뽑으면 찌개 세 개에 국 하나가 나오는 날이 있었다. 카드마다 붙는 이유 라벨("즐겨찾기 · 아직 안 만든", "오랜만에 어때요?", "자주 만든 메뉴")도 같은 값에서 나온다. 마지막으로 먹은 지 21일이 넘었으면 "오랜만에 어때요?"가 된다.

홈 화면 오늘의 추천. 밥, 김치, 양식처럼 종류가 겹치지 않게 뽑히고 카드마다 추천 이유가 붙는다

레시피 상세 아래의 "비슷한 레시피"도 같은 서비스에 들어갔다. 이쪽은 사용 이력이 아니라 레시피끼리의 거리다. 네 갈래 분류가 하나 맞을 때마다 3점, 겹치는 태그 하나에 2점, 겹치는 재료 하나에 1점. 재료 비교는 비싸서 분류·태그 점수로 상위 몇십 개만 남긴 뒤 그 후보들의 재료만 읽는다.

추천은 데이터가 있어야 돌아간다. 가입한 지 얼마 안 된 사람의 보관함에는 레시피가 몇 개 없으니, 관리자가 등록하는 공용 레시피를 시드로 이백 개 넘게 넣어 두었다. 오늘의 추천과 비슷한 레시피의 후보는 내 레시피와 다른 사람이 공개한 레시피, 그리고 이 공용 레시피 전부다.

제철과 이웃의 식탁

나중에 "이달의 추천"을 더했다. 오늘의 추천이 내 기록을 보는 쪽이라면, 이달의 추천은 바깥을 본다. 하나는 계절이고 하나는 다른 사람들의 식단이다.

계절은 월별 제철 재료 사전으로 처리했다. 10월이면 고구마·버섯·대하·꽃게·전어·고등어·밤·감 같은 것들이다. 마늘·양파·고추처럼 거의 모든 요리에 들어가는 재료는 일부러 뺐다. 넣는 순간 모든 레시피가 제철이 된다. 매칭은 근거의 세기로 나눴다. 제목에 들어 있으면 3, 태그나 재료 분류에 있으면 2, 재료 목록에만 있으면 1이다. 여기서 한국어가 발목을 잡았다. "양배추"는 배추가 아니고, "감자"는 감이 아니다. 그래서 한 글자 재료(굴·무·배·감·밤·쑥·귤)는 정확히 같은 이름이거나 "생굴"처럼 '생'이 붙은 경우만 인정하고, 양배추처럼 끝 글자가 겹치는 말은 따로 막았다.

이웃의 식탁은 이번 달과 지난달에 그 레시피를 자기 식단에 올린 사람 수다. 횟수가 아니라 사람 수를 셌고, 내 기록은 뺐다. 내가 많이 먹은 레시피가 "인기"로 다시 나에게 돌아오면 그건 추천이 아니라 거울이다. 제철 근거 점수에 인원 수의 로그, 즐겨찾기 수와 평점을 보조로 더하고, 종류당 하나·같은 제철 재료는 두 개까지로 나눠 뽑는다.

화면을 열 때마다 레시피 전체를

추천이 하나씩 늘어나는 동안 홈과 월간 메뉴가 조금씩 무거워지고 있었다. 결정적으로 이상했던 건 월간 메뉴에서 '레시피 보기'를 누르면 상세 화면이 몇 초씩 뜨지 않는 것이었다. 상세 화면 자체는 빨랐다. 다른 곳에서 들어가면 바로 열렸다.

원인은 눈에 보이지 않는 곳에 있었다. 메뉴 추가 창은 평소에 숨겨져 있을 뿐 페이지에 이미 들어 있었고, 그 안의 레시피 선택 목록이 숨긴 채로 전부 그려지고 있었다. 행마다 썸네일 이미지가 하나씩 있으니, 달력을 열 때마다 이미지 요청이 천 개 넘게 나갔다. '레시피 보기'를 누르면 그 요청들 뒤에 줄을 서야 했다.

<template x-for="r in (addOpen && addStep === 3 ? filteredChoices : [])" :key="r.id">

고친 건 이 한 줄과 이미지의 loading="lazy" 다. 레시피를 고르는 ③ 단계가 열려 있을 때만 목록을 그린다. 그러고 나니 같은 질문이 서버 쪽에도 남았다. 열지도 않는 창의 목록을 왜 매번 만들어서 HTML 에 넣고 있나. 달력 페이지마다 볼 수 있는 레시피 전체와 사용 횟수를 계산해 넣던 것을 GET /Menu/RecipeChoices 로 떼어 냈다. 창을 처음 열 때 한 번 받아 오고, 그 페이지에 있는 동안은 재사용한다.

같은 눈으로 다른 곳을 훑었다. 검색창의 추천 검색어는 글자를 칠 때마다 레시피 목록용 쿼리를 그대로 불렀다. 평점 서브쿼리까지 붙은 전체 목록을 받아 메모리에서 50개만 자르고 있었다. 단어에 필요한 제목·분류·태그 열만 골라 SQL 에서 인기순 50개로 자르는 전용 쿼리로 바꿨다.

홈의 오늘의 추천과 상세의 비슷한 레시피는 요청마다 볼 수 있는 레시피를 전부 읽고, 오늘의 추천은 내 식단 기록까지 한 줄씩 다 가져와 메모리에서 세고 있었다. 기록 쪽은 집계를 SQL 로 내렸다.

.Where(m => m.UserId == userId)
.GroupBy(m => m.RecipeId)
.Select(g => new
{
    RecipeId = g.Key,
    Count = g.Count(),
    LastKey = g.Max(m => m.Year * 10000 + m.Month * 100 + m.Day)
})

년·월·일을 정수 하나로 묶어 Max 를 구하면 마지막으로 먹은 날까지 한 번에 나온다. 후보 레시피는 둘로 갈랐다. 공개·공용 레시피는 누가 보든 같은 목록이니 요약만 IMemoryCache 에 10분 동안 두고 두 추천이 함께 쓴다. 본인 비공개 레시피만 요청마다 읽는다. 레시피를 만들거나 고치거나 지우거나 공개 여부를 바꾸면 캐시를 비운다.

전체를 눌렀더니 멈췄다

마지막 하나는 브라우저 안에서 났다. 선택 창 ③ 단계에서 종류를 '전체'로 고르면 화면이 1~2초 굳었다. 로컬에서 레시피를 2,000개 정도로 채워 재 보니 '전체'를 누르고 목록이 뜰 때까지 2.6초였다.

느린 이유는 세 가지가 겹쳐 있었다. 2,000개 행을 한 번에 그렸다. 필터·정렬 결과를 내는 게터가 렌더링할 때마다 처음부터 다시 계산했다. 그리고 한글 정렬에 localeCompare(…, 'ko') 를 썼는데, 이건 비교할 때마다 비용이 든다.

처음 60개만 그리고, 목록 끝 근처까지 내리면 60개씩 이어 그리게 했다. 검색어·필터·정렬이 바뀌면 다시 처음부터다. 필터 결과는 입력 조건을 이어 붙인 키가 같으면 지난 결과를 그대로 돌려준다. 이 메모를 Alpine 의 반응형 상태 밖에 둔 게 요점이었다. 상태 안에 두면 게터가 메모를 고칠 때마다 다시 렌더링을 부른다. 한글 정렬은 Intl.Collator('ko') 를 하나 만들어 계속 쓴다. 같은 2,000개 조건에서 33ms 가 나왔다.

돌아보면 추천 공식은 처음 세운 모양에서 크게 바뀌지 않았다. 바뀐 건 그 공식을 언제, 무엇을 들고 계산하느냐였다. 점수는 몇 줄이면 되지만, 그 몇 줄이 레시피 전체를 매번 끌어안고 있으면 "오늘 뭐 먹지"에 대답하기 전에 화면부터 멈춘다.

다음 편은 레시피에 붙는 사진 이야기다. 폰으로 찍은 큰 사진 한 장이 서랍에 들어오기까지를 따라가 본다.