저녁이 제작기 02. Slack 앱 부트스트랩과 서버리스 실행 모델 전환
첫날은 Socket Mode로 열었다
저녁 기록을 Slack 안에서 남기는 앱을 만들기 시작했다. 골격은 Node.js 22 + TypeScript + Slack Bolt(Slack 공식 앱 프레임워크다)로 잡고, 실행 모드는 Socket Mode를 골랐다. Socket Mode는 공개 엔드포인트 없이 WebSocket 연결로 Slack 이벤트를 받는 방식이다. 로컬에서 바로 이벤트를 받을 수 있어 착수가 빨랐다.
그날 /dinner 커맨드와 기록 모달까지 동작했다. 이 선택은 배포를 고민하는 순간 뒤집혔다.
도메인 로직보다 한글 검색을 먼저 만들었다
이름 입력이 이 앱의 거의 유일한 입력이다. 여기가 불편하면 앱 전체가 불편해진다. 그래서 초성 검색(services/hangul.ts)과 쿼티에서 한글로 바꿔주는 변환(services/qwerty-hangul.ts)을 도메인 로직보다 먼저 구현했다.
rla를 치다 만 상태에서도 김…이 나와야 한다. 한영 전환 실수는 상수에 가깝다. 이후 모달을 몇 번 갈아엎는 동안에도 이 두 유틸은 한 번도 바뀌지 않았다.
서버리스에 올리기로 하자 Socket Mode가 무너졌다
Vercel에 올리기로 정하면서 Socket Mode가 성립하지 않게 됐다. 서버리스에는 WebSocket 연결을 붙들고 있을 프로세스가 없다.
HTTPReceiver와 서명 검증으로 갈아탔다. HTTPReceiver는 Slack이 보내는 HTTP 요청을 직접 받는 Bolt 수신기이고, 서명 검증은 그 요청이 정말 Slack에서 온 것인지 확인하는 절차다. 여기에 processBeforeResponse: true를 켰다.
const app = new App({
token: process.env.SLACK_BOT_TOKEN,
receiver: new HTTPReceiver({
signingSecret: process.env.SLACK_SIGNING_SECRET!,
}),
processBeforeResponse: true,
});이 옵션은 핸들러 처리를 끝낸 다음에 응답을 보내게 만든다. 서버리스에서는 응답을 보낸 뒤 후속 작업을 이어갈 수 없으니 이렇게 해야 했다.
| Socket Mode | HTTP 모드 | |
|---|---|---|
| 공개 Request URL | 필요 없음 | 필요 (SSL 검증 대상) |
| 연결 | apps.connections.open으로 WebSocket URL을 받고, URL은 정적이 아니라 주기적으로 갱신된다 | 정적 엔드포인트로 요청을 받는다 |
| 요청 검증 | 해당 없음 | signing secret과 X-Slack-Signature 헤더로 필수 |
| 토큰 | app-level token(xapp-)과 connections:write 스코프가 추가로 필요 | 봇 토큰 |
| 3초 확인 응답 | 적용됨 (envelope ID를 담은 ack) | 적용됨 (HTTP 2xx) |
| 응답 실패 시 | 재연결(refresh_requested)을 앱이 처리 | Slack이 3회 재시도 (즉시, 1분, 5분) |
| 배포 | 공개 Slack Marketplace 불가 | 제한 없음 |
3초 확인 응답 제한은 Socket Mode에서도 그대로였다. 로컬 프로세스로 돌릴 때는 확인 응답만 먼저 보내고 처리를 뒤에 이어갈 수 있었을 뿐이다. 그 길이 막히자 핸들러 전체가 3초 예산 안에 끝나야 했다. 이후의 성능 작업은 전부 이 제약에서 나왔다.
저장소 세 곳을 거쳐 결국 외부 저장소를 없앴다
기록을 어디에 둘 것인가를 놓고 세 곳을 거쳤다. 처음엔 파일에 썼고, 다음은 Upstash Redis(서버리스용 관리형 Redis)로 옮겼다. 결국 외부 저장소 없이 가기로 했다.
사내 저녁 기록 수준의 데이터에 외부 의존성과 운영 비용을 붙일 이유가 없었다. 그래서 기록을 데일리 메시지 자신의 metadata에 넣었다. Slack 메시지 metadata는 메시지에 딸려 저장되는 구조화된 데이터 칸이다. 기록을 읽을 때는 conversations.history로 그 메시지를 되읽고, 갱신할 때는 chat.update로 메시지를 다시 쓴다.
event_payload는 객체를 원소로 갖는 배열을 받지 못한다. 그래서 기록 배열은 JSON 문자열 하나로 직렬화해서 넣었다.
인프라가 사라졌다. 대신 메시지를 지우면 그날 기록도 사라지고, 월별 집계처럼 메시지를 가로지르는 질의를 할 수 없다. 저장소 경계는 services/store.ts 하나뿐이라, 집계 요구가 생기면 이 파일만 교체한다. 위험을 알고 받은 트레이드오프다.
users.list를 요청 경로에서 뺐다
users.list가 이 워크스페이스에서 1.7초쯤 걸렸다. 3초 예산의 절반을 잡아먹는다.
사용자 사전을 프로세스 메모리에 캐싱하고, 비어 있으면 빈 목록으로 즉시 확인 응답을 보낸 다음 백그라운드로 채우게 바꿨다. 렌더링용 이름 해석(buildNameResolver)은 사전 로드를 유발할 수 있어 제거했다.
첫 요청만 후보가 비고 다음부터는 정상이다. 과거 companionIds 기록은 ID가 그대로 보이는데, 운영 데이터가 없어 실제 영향은 없다.
모달 입력에서 가장 많이 되돌아갔다
| 시도 | 결과 |
|---|---|
| 먹은 사람 단건 추가·삭제 | 되돌림. 여러 명을 한 번에 넣는 게 실제 사용 패턴 |
static_select로 한글 지연 제거 | 되돌림. 명단이 커지면 후보를 다 실을 수 없다 |
| Slack 기본 유저 선택기 + 채널 밖 사용자 차단 | 둘 다 되돌림. 초성·쿼티 검색을 잃는 대가가 더 컸다 |
출처 칸 focus_on_load | 되돌림. 모달을 열자마자 출처 칸이 포커스를 가져가 이름 입력이 밀렸다 |
| 기록을 한 블록에 모으고 삭제를 모달로 | 되돌림. 줄별 삭제가 직관적 |
세 칸 모두 multi_external_select로 통일했다. external_select는 사용자가 타이핑할 때마다 앱에 후보를 물어보는 Block Kit(Slack 메시지와 모달 UI를 구성하는 블록 체계다) 입력 요소다. 후보는 option_groups로 묶어 보냈다. 그룹 제목을 달아 후보를 나눠 보내는 형식이다.
삭제는 줄 우측 overflow 메뉴로 옮겼다. 검색 가능성이 Slack 기본 위젯의 편의보다 우선한다.
자동완성 지연은 후보를 미리 실어 보내 없앴다
타이핑마다 도는 app.options 핸들러가 3초 예산 안에서 네트워크를 타면 안 된다. 그래서 모달을 열 때 후보를 미리 받아두고, 그 후보를 모달 private_metadata에 실어 보냈다. private_metadata는 모달에 딸려 다니는 문자열 보관 칸이다.
타이핑 응답이 메모리 조회 수준으로 내려왔다.
콜드 스타트는 cron으로 미리 띄워 피했다
저녁 시간대의 첫 /저녁이가 콜드 스타트를 맞으면 3초를 넘긴다. /cron/warm을 18:50부터 21:00 KST까지 3분 간격으로 호출해 인스턴스를 미리 띄우고 캐시도 미리 채워두게 했다. 데일리 메시지는 /cron/daily가 주중 19시에 게시한다. 스케줄은 vercel.json에 두고 UTC 기준으로 적었다.
실제 사용 시각에는 이미 떠 있는 인스턴스와 채워진 캐시를 만난다. 그 시간대 밖의 첫 호출은 여전히 느리다. 시간대를 넓히는 건 비용 문제라 지금은 둔다.
아이콘은 다시 그리고 처음 것으로 돌아갔다
초승달·그릇·밥·젓가락을 벡터로 다시 그렸다. 레이어 순서도 그릇에서 밥, 젓가락으로 맞췄다. 그러고 결국 최초 버전으로 되돌렸다.
사용자에게 보이는 가치가 아이콘 완성도에 있지 않았다. 다음엔 여기서 멈추는 지점을 먼저 정한다.
이름과 커맨드를 확정하고 스코프를 늘렸다
표시 이름을 dinner2에서 저녁이로 바꾸고, 커맨드는 /저녁이로 정했다. 공개 채널 명단을 조회하려고 channels:read 스코프를 추가했다. 스코프를 늘렸으니 워크스페이스에 다시 설치해야 했다.
typescript@7이 Vercel 빌드를 죽였다
typescript@7은 exports["."]가 lib/version.cjs라 ts.sys와 ts.readConfigFile을 내보내지 않는다. 그런데 Vercel 빌더가 이걸 호출한다. 빌드가 죽었다.
빌드와 타입체크는 TS 5.9.3으로 고정했다. TS7은 typescript7 alias로 병행 설치해 typecheck:next 전용으로만 쓴다. node_modules/.bin/tsc가 alias 쪽으로 연결되므로 스크립트에 경로를 명시했다.
알면서 남겨둔 것
| 항목 | 이유 |
|---|---|
| 동시 제출 시 나중 것이 먼저 것을 덮어씀 | 수백 ms 안에 겹칠 일이 드물다. 문제되면 store.ts만 교체 |
| 메시지를 지우면 그날 기록 소실, 월별 집계 불가 | 외부 저장소를 안 두기로 한 선택의 대가 |
단일 채널 전제 (DINNER_CHANNEL_ID 하나) | 다중 채널 요구가 아직 없다 |
| 하루 기록이 메시지 크기 한도를 넘으면 곤란 | 20건 내외로는 문제없다 |
| 단일 워크스페이스 전제 | OAuth 설치 저장소 없이 토큰 한 쌍을 환경변수로 받는 구성 |
손댈 후보는 콜드 스타트와 집계 쪽에 남았다
인스턴스를 미리 띄워두지 않는 시간대의 첫 호출은 콜드 스타트가 아직 그대로다. 과거 companionIds 기록의 ID 노출도 운영 데이터가 생기면 손대야 한다. 월별 집계가 필요해지는 시점을 판단하고 store.ts 교체 범위를 계산하는 일도 남아 있다.