본문으로 건너뛰기

저녁이 제작기 03. 메뉴 누적 별점 도입

·15 min read·3 / 3

별점을 붙이다가 집계 단위를 통째로 올렸다

식대 기록에 별점을 붙였다. 처음에는 "기록 한 건에 별점 하나"로 만들었다. 그런데 사용자가 원한 것은 메뉴의 누적 평점이었다. 그걸 뒤늦게 확인하고 집계 단위를 통째로 올렸다.

UI는 세 번 갈아엎었다. 그중 두 번은 글로 설명해서는 결론이 나지 않았다.

"DB 필요합니다"를 10분 만에 철회했다

요구를 듣자마자 범위를 갈랐다. 별점 수집은 지금 구조로 되고, "별점 높은 순 추천"은 메시지를 가로지르는 집계라 외부 저장소가 필요하다고 했다. 근거는 store.ts 맨 위 주석이었다. "월별 집계처럼 메시지를 가로지르는 질의도 불가능하다."

곧 틀렸다고 정정했다. 이미 scanSources()가 채널 히스토리를 200건까지 훑어 가게별 통계를 만들고 있었고, /메뉴가 그걸 쓰고 있었다.

주석을 코드보다 먼저 믿은 탓이었다. 그 주석은 그 파일이 하는 일을 설명한 것이지, 프로젝트가 할 수 있는 일의 한계가 아니었다. 제약을 선언하기 전에 그 제약을 우회하는 코드가 이미 있는지부터 봤어야 했다.

정정하고 나니 추천 기능에 DB가 필요 없어졌다. 집계 루프에 몇 줄 더하고 정렬 기준만 바꾸면 됐다.

만든 건 한 끼니 평균, 원한 건 메뉴 누적이었다

구현을 끝내고 배포 직전에 요구가 다시 들어왔다. "각 메뉴들은 총 평점이 반영돼야 한다." 내가 만든 것은 한 끼니의 평균이었다. 오늘 가츠시에 2명이 별점을 주면 ★ 3.5 (2)로 보이는 식이다. 원한 것은 가츠시를 지난 3개월간 먹은 모든 기록의 누적이었다.

scanSources()에서 ratings를 함께 합산해 SourceStat.rating을 만들었다. 렌더링은 그 값을 넘겨받아 그리기만 하게 했다. 집계가 하루 단위에서 전체 기간으로 올라갔다.

처음에 범위를 "별점 수집 vs 추천 시스템"으로 갈랐는데, 그 경계가 사용자 머릿속의 경계와 달랐다. 사용자에게 "별점을 준다"는 말에는 처음부터 "그게 가게 점수에 쌓인다"가 포함돼 있었다. 범위를 가를 때 내가 고른 축이 사용자의 축과 같은지 확인하는 절차는 아직 없다.

방금 누른 별점이 총점에 안 잡혔다

총점은 히스토리에서 집계한다. 그런데 집계 시점에 내 별점은 아직 Slack에 올라가 있지 않다. 읽고, 고치고, 쓰는 순서라 쓰기 전에 읽은 값으로 총점을 그린다. 누른 사람 눈에는 숫자가 그대로였다.

반영이 한 박자 늦으면 사용자는 "안 먹혔나" 하고 다시 누른다. 그러면 덮어쓴다.

그래서 scanSources가 pending을 받게 했다. 스캔 중 그 ts(Slack이 채널 안에서 메시지를 식별하는 타임스탬프 값이다)의 메시지를 만나면 저장된 기록 대신 손에 든 기록을 센다. 이제 5점짜리 가게에 1점을 주면 그 자리에서 ★ 3.0 (2)로 바뀐다.

총점이 바뀌면 block_id도 바뀌게 했다

Slack 메시지는 모두에게 하나다. 그래서 "내가 준 별점"을 initial_option(선택 요소에 미리 골라진 상태로 보일 값을 지정하는 속성이다)으로 띄울 방법이 없었다. 대신 드롭다운 placeholder(아무것도 고르지 않았을 때 보이는 안내 문구다)에 총점만 내보냈다.

그런데 chat.update(이미 올라간 메시지 내용을 고쳐 쓰는 Slack API다)로 블록을 갈아끼워도, Slack 클라이언트가 같은 block_id(메시지를 이루는 블록마다 붙는 식별자다)를 보고 "아까 고른 값"을 남길 수 있다. 그러면 투표한 사람만 총점 대신 자기 점수를 계속 본다. 다른 사람 눈에는 올라간 점수가 정작 올린 사람에게만 안 보인다.

block_id를 rate:{id}:{평균}_{표본수}로 바꿔, 총점이 변하면 id도 변하게 했다. 관찰로 확인한 문제가 아니라 추론으로 막은 것이다. 비용이 거의 없어 질렀다. 기록을 찾을 때는 block_id가 아니라 옵션 value를 쓰므로, 매번 달라져도 잃는 게 없다.

UI는 말로는 세 번 다 실패했다

Block Kit(Slack 메시지 UI를 구성하는 블록 체계다)에서 기록마다 드롭다운을 달면 section 블록(텍스트와 옆에 붙는 요소 하나를 담는 기본 블록이다)을 쪼개야 하고, 그러면 블록 사이 간격이 벌어진다. 이 비용을 미리 글로 설명하고 동의를 받았다. 막상 띄우니 "이건 왜이렇게 간격이 넓냐"는 말이 돌아왔다.

설명을 그만두고 변형들을 실제 Slack 채널에 올려 나란히 보게 했다. 세 라운드를 돌았다.

라운드올린 것결론
1A(드롭다운) / D(오버플로 메뉴) / E(한 덩어리+모달)A 유지
2F(두 줄) / G(한 덩어리) / H(context 분리)전부 기각, A로 복귀
3M1(뱃지) / M2(순위) / M3(코드블록) / M4(1등 강조)M2 채택

Block Kit에는 간격이나 크기를 조절하는 속성이 없다. padding도 height도 못 준다. 조절할 수 있는 것은 어떤 블록·어떤 요소를 쓰느냐뿐이고, 그 차이는 렌더링을 봐야만 판단된다. 그래서 설명 대신 띄우는 쪽으로 갔다.

2라운드에서 "줄에 오늘 평균도 같이 띄우기"를 넣었다가 바로 뺐다. 숫자 둘이 줄 양 끝에 흩어져 서로를 가렸다. 올려보지 않았으면 몰랐을 것이다.

메뉴판에서 숫자가 주인공이 되자 뱃지를 버렸다

메뉴판은 가게 이름을 인라인 코드(알약)로 쭉 늘어놓는 형태였다. 당시 주석에 남긴 근거는 "줄마다 숫자가 붙으면 목록이 표가 되어 훑기 어려워진다"였다.

이번에 그 주석의 전제가 깨졌다. 메뉴판의 목적이 "먹어본 것 훑기"에서 별점순 추천으로 옮겨갔다. 그러면 숫자가 주인공이다. 알약을 늘어놓으면 순위가 보이지 않고 점수가 이름에 묻힌다.

그래서 순위 목록(1. 이름 ★4.0 (5) 결제수단)으로 바꿨다. 먹은 횟수는 빼고 결제 수단을 쓰인 대로 전부 적었다. 하나로 줄이지 않은 것은 같은 가게를 식대카드로도 배달로도 먹었다면 둘 다 사실이기 때문이다.

"왜 카카오?"에 답할 이유가 없었다

가게 이름에 지도 링크를 걸자는 요구에 반사적으로 카카오맵을 제안했다. 그러자 되물음이 왔다. "근데 카카오맵으로 하는 이유는???"

특별한 이유가 없었다. 국내 서비스라 반사적으로 고른 것이었다. 따져보니 나중에 붙일 "추천 + 음식점 정보"에서 결국 보게 될 리뷰·메뉴·영업시간이 국내에서 가장 두꺼운 곳은 네이버였다. 그래서 네이버로 바꿨다.

가게명과 음식명을 가리지 않고 전부 링크를 걸었다. source 한 칸에 둘이 섞여 들어오게 설계돼 있어, 저장된 글자만으로는 구분할 수 없다. "마라탕"을 검색하면 근처 마라탕집이 나오니 완전히 헛되지는 않다.

부작용이 하나 있었다. URL 인코딩된 한글은 한 자가 아홉 자를 먹는다. 목록을 개수로 자르면 긴 이름이 섞일 때 section 3000자 한도를 넘는다. 그래서 개수로 자르던 것을 글자 수 예산으로 바꿨다.

리뷰를 맡긴 서브에이전트 셋이 모두 빈 답을 돌려줬다

작성과 리뷰는 분리해야 한다는 원칙에 따라 서브에이전트(별도 컨텍스트에서 돌아가는 보조 AI 에이전트다)에 리뷰를 맡겼다. 세 번 모두 본문 없이 돌아왔다. Done., Acknowledged., .이 전부였다. 토큰은 10만 개씩 썼다.

결국 직접 봤고, 결함 둘을 찾아 고쳤다.

  • updateDailyMessage의 logger가 호출부 4곳에서 모두 생략돼 있었다. 총점 조회 실패가 완전히 조용히 넘어갔다. catch를 둔 목적 자체가 무효였다.
  • 같은 메시지를 두 번 읽고 있었다. 거절할 때만 읽도록 바꿨더니, 별점 클릭 한 번에 도는 conversations.history(채널의 메시지 목록을 읽어오는 Slack API다)가 3회에서 2회로 줄었다.

독립적인 리뷰는 받지 못했다. 작성자 자기검토의 한계가 그대로 남아 있다.

커맨드 이름이 세 곳에 흩어져 있었다

/메뉴를 /메뉴판으로 바꾸고 배포했더니 {reason} 오류가 발생해 */메뉴판*에 실패했습니다.가 돌아왔다.

이름은 Slack 앱 콘솔, slack-manifest.template.json, 환경변수 MENU_COMMAND 세 곳에 있었다. 콘솔과 매니페스트만 고치고 Vercel 환경변수를 빠뜨렸다. app.command(env.menuCommand, ...)는 문자열 정확히 일치로 등록되므로 핸들러가 아예 안 걸린다. 이 배포 구성에서는 로그에 아무것도 남지 않았다.

환경변수를 넣고 재배포했다. 템플릿도 /메뉴판으로 맞췄다. 생성물인 slack-manifest.json만 고치면 다음 pnpm manifest 때 되돌아가기 때문이다.

셋이 어긋났을 때 조용히 죽는 구조는 그대로다.

알면서 남겨둔 것

항목이유
내가 몇 점 줬는지 나중에 확인 불가메시지는 모두에게 하나라 "내 점수"를 띄울 자리가 없다. ephemeral은 한 번 뜨고 사라진다
동행자가 전부 직접 입력이면 아무도 평가 못 함Slack 계정이 없어 누른 사람과 대조할 방법이 없다. 막으려면 저장 구조를 바꿔야 한다
기록 작성자가 자기를 동행자에 안 넣으면 평가 불가제출자를 저장하지 않는다. 저장 구조를 안 건드리는 쪽을 택했다
표시 가능한 기록 60건 → 47건섹션을 쪼갠 대가. 50블록 한도
목록 간격이 벌어짐같은 대가. Block Kit에 간격을 조절하는 속성이 없어 조절 불가
pending 메시지가 히스토리 200건 밖이면 집계 누락당일 메시지가 거기 밀릴 일이 없다
메시지 갱신마다 히스토리 스캔 1회 추가ack() 뒤라 3초 예산 밖. 호출 비용은 받아들인다
한글 표기와 영문 표기를 다른 가게로 셈normalizeSource는 대소문자·공백만 본다. 음역은 못 한다
동시 평가 시 나중 것이 먼저 것을 덮어씀기존 addRecord·deleteRecords와 같은 수준. 여기서 새로 생긴 문제가 아니다

ephemeral은 누른 사람에게만 잠깐 보이는 Slack 메시지다. ack()는 Slack 요청을 받았다고 3초 안에 확인 응답을 보내는 Bolt 함수다.