본문으로 건너뛰기

저녁이 제작기 04. 별점 노출 방식 검토와 동시 평가 잠금 도입

·10 min read·4 / 4

커맨드 이름을 바꾸자 첫 BREAKING 릴리스가 됐다

버저닝은 standard-version(커밋 메시지 규칙을 읽어 버전 번호를 올리고 CHANGELOG를 자동으로 만들어주는 도구다)으로 붙였다. v0.1.0을 낼 때 세운 전제는 "사용자는 Slack 버튼만 누르니 breaking change는 사실상 없다, 0.x로 충분하다"였다.

그런데 /메뉴를 /메뉴판으로 바꾸면 사용자 손에 익은 입력이 그대로 끊긴다. 옛 커맨드를 치면 아무 일도 안 일어난다.

v0.2.0을 BREAKING CHANGE 푸터(커밋 메시지 끝에 붙여 호환되지 않는 변경임을 알리는 Conventional Commits 표기다)와 함께 냈다. CHANGELOG 맨 위에 "기존 /메뉴는 더 이상 동작하지 않는다, 매니페스트를 다시 올려야 한다"가 들어갔다. 매니페스트는 Slack 앱의 커맨드·권한 같은 설정을 담은 파일이다.

이 앱의 공개 API는 함수 시그니처가 아니라 슬래시 커맨드 이름이다. API를 바꿨으면 깨졌다고 적는 게 맞다. 0.x라 버전 숫자로는 minor지만, 고지는 별개다.

전제도 고쳐 적었다. breaking은 드물 뿐 없지 않다. 커맨드 이름이나 버튼 동작처럼 사용자가 외워서 쓰는 것을 바꾸면 그게 breaking이다.

안 먹은 사람에게도 드롭다운이 보였다

"같이 먹은 사람이 아닌데 드롭다운 나오는거 자체도 이상한데"라는 말이 나왔다.

자격 검사(canRate)가 이미 있어서, 안 먹은 사람이 눌러도 저장되지 않고 거절 안내만 받는다. 문제는 드롭다운이 보이는 것 자체였다. 채널 메시지는 모두에게 하나라서 보는 사람에 따라 블록을 숨길 수 없다.

말로 고르지 않고 테스트 채널에 네 안을 실제로 올렸다. UI 선택지는 설명하지 않고 띄워서 본다.

안모양안 먹은 사람에게
A. 현행기록 줄마다 드롭다운보임, 누르면 거절
B. 버튼 + 모달줄 끝에 총점 버튼, 누르면 모달버튼은 보임
C. ephemeral채널엔 총점 글자만, 동행자에게만 드롭다운안 보임
D. DMC와 같되 봇 DM으로안 보임

ephemeral은 채널 안에서 특정 사용자에게만 보이는 Slack 메시지다.

C를 골랐다. 세부도 정했다.

  • 놓치면 다시 받을 방법은 없다. ephemeral은 새로고침하면 사라진다
  • 대상은 Slack 계정이 있는 동행자 전원, 작성자 포함
  • 고른 뒤 "★ 남겼어요"로 바꾸고, 다시 고를 수 있게

im:write가 없다고 적었는데 DM은 나갔다

표를 만들 때 D의 단점으로 "현재 매니페스트에 im:write가 없다"를 적었다. im:write와 im:read는 Slack 앱의 권한 스코프다.

미리보기를 올려보니 지금 권한 그대로 DM이 나갔다. 진짜 막힌 곳은 따로 있었다. im:read가 없어서 보낸 미리보기 DM을 봇이 지울 수 없었다. 사용자가 직접 숨겨야 했다.

미리보기를 띄운 덕에 im:write 제약이 틀렸다는 걸 우연히 확인했다. 매니페스트를 보고 추론한 제약은 한 번 호출해 보기 전까지는 가설이다.

C로 가려다 현행 유지로 멈췄다

C로 구현을 시작하려던 참에 "아니다 일단 현황 유지"로 방향이 바뀌었다. 코드는 건드리지 않고 멈췄다. 테스트 채널의 A~C 미리보기는 지웠다.

보류한 이유는 따로 남지 않았다. 다만 C의 대가가 작지 않은 건 사실이다. 놓친 별점을 다시 받을 길이 없어 평가 수 자체가 줄 수 있다. 지금 A의 문제는 "어색하다"이지 "잘못 저장된다"가 아니다.

C를 검토하면서 걸리는 지점이 두 군데 보였다.

  • ephemeral 메시지에서 들어온 액션의 message ts(Slack이 채널 안에서 메시지를 식별하는 타임스탬프 값이다)는 데일리 메시지의 ts가 아니다. 데일리 메시지는 매일 오후 7시에 채널로 올라와 그날 기록이 쌓이는 메시지다. 지금은 액션이 달린 메시지에서 기록을 찾으므로, C로 가려면 데일리 ts를 option value에 함께 실어야 했다.
  • 옛 데일리 메시지의 드롭다운(value recordId:score)도 계속 눌린다. 여기에 respond(replace_original)(액션 응답으로 원래 메시지를 바꿔 쓰는 방식이다)을 쓰면 채널 메시지가 통째로 덮인다. 둘을 구분해야 했다.

동시에 누른 별점 하나가 사라질 수 있었다

별점 저장은 동시에 평가하면 나중 것이 먼저 것을 덮어쓰는 상태였다. 기존 addRecord와 같은 수준이라고 보고 허용하던 문제다. addRecord 주석도 "같은 수백 ms 안에 겹칠 일이 드물어 허용한다"였다.

그런데 기록 추가와 별점은 겹칠 확률이 다르다. 기록은 한 끼에 한 사람이 남기지만, 별점은 한 메시지를 보고 동행자 여럿이 비슷한 때에 누르는 구조다. "드물다"는 전제가 별점에선 약하다.

withDailyLock을 두어 같은 메시지(채널:ts)의 읽기 → 변경 → 저장을 한 프로세스 안에서 줄 세웠다. 평가, 기록 추가, 삭제, cron(정해진 시각마다 도는 예약 작업이다)의 컨텍스트 갱신이 모두 같은 줄에 선다.

잠금 범위에서는 두 가지를 의식적으로 골랐다.

  • chat.update(이미 올라간 메시지 내용을 고쳐 쓰는 Slack API다)만 잠그지 않고 읽기부터 묶었다. 저장만 줄 세우면 미리 읽어둔 옛 목록이 순서대로 덮어쓸 뿐 유실은 그대로다.
  • cron 갱신은 느린 조회(채널 멤버, 메뉴 목록)를 잠금 밖에서 먼저 하고, 잠금 안에서는 메시지 읽기·쓰기만 한다. 잠금을 쥔 시간을 줄여 평가 요청이 덜 기다린다.

테스트로는 두 사람이 같은 기록에, 또 서로 다른 기록에 동시에 4점을 주는 경우를 돌렸다. 둘 다 남고 총점이 ★ 4.0 (2)인지 확인했다. 앞 작업이 실패해도 뒤 요청이 막히지 않는지도 봤다.

이 잠금은 같은 인스턴스 안에서만 유효하다. Vercel이 요청을 다른 인스턴스로 나누면 그 사이의 덮어쓰기는 여전히 가능하다. 막으려면 원자적 갱신(읽기·변경·저장을 중간에 끼어들 틈 없이 한 번에 처리하는 갱신)을 지원하는 공유 저장소가 필요하고, 그건 "Slack 메시지가 곧 저장소"라는 지금 구조를 버리는 일이다.

대기 요청이 길게 쌓이면 Slack의 3초 응답 제한을 넘길 수도 있다. Slack은 버튼 같은 상호작용 요청에 3초 안에 확인 응답을 요구한다.

세 가지는 알면서 그대로 뒀다

항목이유
안 먹은 사람에게도 드롭다운이 보임C안 보류. 저장은 막혀 있어 "어색함"에 그친다
인스턴스 간 동시 평가는 여전히 덮어쓸 수 있음막으려면 외부 저장소가 필요하다. 지금 구조를 버리는 비용이 더 크다
미리보기 DM이 봇 DM에 남음im:read가 없어 봇이 못 지운다