
버튼이 64바이트면, 제품은 상태머신이 된다: 텔레그램 봇 워크플로우 설계 메모
밤에 이런 일이 한 번 생기면, 그 다음부터는 “대화형 UX”라는 말을 쉽게 못 쓰게 된다.
버튼 하나를 눌렀다.
아무 반응이 없었다.
사용자는 한 번 더 눌렀다(당연하다).
그 사이에 백엔드는 두 번 실행됐다.
되돌리려고 대화 기록을 열어봤는데, 정작 중요한 중간 메시지는 안 보인다.
누가 언제 어떤 버튼을 눌렀는지도, 채널 히스토리만으론 깔끔하게 안 잡힌다.
그때 깨닫는다. 텔레그램 봇 UX는 “대화”가 아니라 상태머신이다. 그리고 이 상태머신은 채널의 제약을 그대로 몸에 새긴다.
Vercel Chat SDK의 Telegram adapter 체계를 보면 그 제약이 문서에 아예 박혀 있다. inline keyboard의 callback_data는 64바이트 제한. Telegram adapter는 히스토리를 ‘진실’로 쓰기 어렵게 만드는 제약이 있다(결국 캐시/저장소 쪽으로 설계가 기운다). modals/ephemeral은 없다.
이 글은 그 제약을 억지로 덮는 요령이 아니라, 제약을 전제로 워크플로우 UI를 설계하는 메모다.
64바이트가 UI를 바꾸는 방식
64바이트 제한은 “문자 수 제한”처럼 보이지만, 실제로는 제품 설계 제한이다.
왜냐면 callback_data는 버튼을 눌렀을 때 서버로 넘어오는 유일한 단서에 가까워지기 때문이다. 이 단서가 길게 못 가면, 결국 상태를 짧게 표현해야 하고, 짧게 표현하려면 상태를 축약해야 한다.
여기서 흔히 하는 실수는 callback_data에 모든 걸 쑤셔 넣는 거다.
- user_id 넣고
- action 넣고
- payload 넣고
- timestamp 넣고
- 서명까지 넣고
그리고 어느 날 64바이트를 넘기면서, UX가 무너진다.
내 결론은 단순하다. callback_data는 “데이터”가 아니라 포인터여야 한다.
- 버튼에 붙는 건 짧은 토큰 하나(예:
a9X3pQ같은) - 실제 상태/입력/검증은 서버 쪽 저장소(혹은 캐시+영속 DB)에 둔다
이렇게 해야 버튼이 ‘상태의 스냅샷’이 아니라 ‘상태의 문’이 된다.
그리고 한 가지를 더 추가한다. 토큰은 영원하면 안 된다.
- 만료를 둔다(예: 10분)
- 재사용을 막는다(한 번 쓰면 소진)
이게 없으면 “버튼 두 번 눌림”이 버그가 아니라 기능이 된다.
히스토리가 없으면 ‘캐시’가 사실상 DB가 된다
Telegram adapter는 히스토리를 ‘진실’로 쓰기 어렵게 만드는 제약이 있다(결국 캐시/저장소 쪽으로 설계가 기운다).
“대화 기록이 곧 진실”이라는 가정이 깨진다.
그럼 무엇이 진실이 되냐. 결국 내가 가진 캐시/저장소가 된다. 안 그러면 재현이 안 된다.
여기서 중요한 건 “로그를 많이 남기자”가 아니라, 상태머신을 돌리는 데 필요한 최소한을 남기는 것이다.
내가 보통 최소로 잡는 건 이 다섯 줄이다.
- conversation_id(채널+스레드/유저를 합친 내부 키)
- current_state(지금 단계)
- last_action(마지막으로 눌린 버튼/명령)
- evidence_ref(티켓/PR 같은 외부 근거 1개)
- created_at / expires_at
근거를 하나로 안 묶으면 “그 버튼 눌렀던 건 어느 건데요?”에서 바로 길을 잃는다.
이 다섯 줄이 있으면, “왜 두 번 실행됐지?” “사용자가 어디까지 갔지?”를 설명할 수 있다.
그리고 캐시 의존이라면 더 강하게 정해야 한다.
- 캐시 미스는 ‘오류’가 아니라 ‘새로 시작’이 되어버린다
그래서 캐시가 깨져도 복구 가능한 설계를 해야 한다. 최소한 이렇게 말할 수 있어야 한다: “이 작업은 만료되었습니다. /start로 다시 시작해 주세요.”
모달/에페메랄이 없으면, 실수 비용을 줄이는 쪽으로 설계가 기운다
모달/ephemeral이 없다는 건 “작게 안내하고 조용히 사라지게 하기”가 어렵다는 뜻이다.
즉, 메시지로 때우면 채널이 지저분해지고, 안 띄우면 사용자는 불안해진다.
그래서 텔레그램 워크플로우는 보통 다음 둘 중 하나로 수렴한다.
- 확인(Confirm) 단계가 늘어난다
- 되돌리기(Undo) 버튼이 상시화된다
둘 다 UX를 ‘느리게’ 만든다. 하지만 느려지는 게 목적이 아니다. “실수 한 번”의 비용이 커지기 때문에, 천천히 가는 쪽이 전체 비용을 줄인다.
내가 자주 쓰는 패턴은 이거다.
- 위험한 버튼(삭제/발송/확정)은 항상 2단계
- 2단계에서만 evidence_ref가 확정됨
- 확정 이후 60초 동안 Undo 가능(그리고 Undo도 토큰 1회성)
모달이 없을수록, 버튼은 ‘명령’이 아니라 ‘상태 전이’가 된다.
이거 안 하면 꼭 터진다(그리고 대개 밤에 터진다)
여기부터는 체크리스트라기보다, 구현할 때 “안 하면 꼭 터지는” 것들.
- callback_data는 토큰 하나만(1회용 + 만료).
- 버튼은 멱등하게(같은 토큰 2번이면 2번째는 무시).
- 실패는 숨기지 말고 다음 행동 버튼을 즉시 보여주기(재시도/사람 호출/처음으로).
텔레그램에 ‘워크플로우’를 올릴지, 알림으로 남길지
결국 판단은 기능이 아니라 위험/운영 비용의 문제다.
- 이 작업은 ‘두 번 눌려도’ 안전한가? (아니면 토큰/멱등이 필수)
- 캐시가 깨졌을 때 “만료/재시작” UX를 감당할 수 있나?
- 모달 없이도 “확인/되돌리기” 흐름을 설계할 수 있나?
- evidence_ref를 1개로 고정할 수 있나? (여러 링크/여러 티켓으로 흩어지면 운영이 망가진다)
- 채널이 바뀌어도 사용자가 오해하지 않게 ‘제약’을 화면에 박을 수 있나?
두세 개에서 망설여지면, 텔레그램은 ‘완성 채널’이 아니라 알림 채널부터 시작하자. 나중에 올려도 된다.
참고자료
- Vercel Changelog — Chat SDK adds Telegram adapter support
- https://vercel.com/changelog/chat-sdk-adds-telegram-adapter-support
실전 적용
텔레그램 봇을 “대화형 서비스”로 상상하면 초반엔 편한데, 운영 한 번만 해보면 바로 현실이 온다. 버튼은 한 번 더 눌리고, 중간 메시지는 흩어지고, 사람들은 “그거 내가 누른 건데 왜 또 됐지?”를 묻는다. 그래서 실전에서는 기능을 늘리기보다, 워크플로우가 망가질 때 어디에서 멈추고 어디로 돌아갈지부터 먼저 정한다. 아래는 내가 새 봇을 붙일 때 초반에 꼭 박아두는 것들이다.
- 토큰 발급 시점과 만료를 UI에서 드러낸다: 버튼 옆에 “10분 유효” 같은 표시를 두면 재시도/중복 클릭이 줄어든다.
- “처리 중”을 메시지로 남기지 말고 상태로 남긴다: 채팅에 스팸처럼 쌓이는 대신, 같은 메시지를 수정(edit)하거나 한 줄 상태를 유지한다.
- 단계 전환은 최대한 ‘덮어쓰기’로: 새 메시지를 계속 뿌리면 사용자는 어느 버튼이 최신인지 헷갈린다.
- 확정 버튼은 항상 한 번 더 질문을 붙인다: 모달이 없으니 “확정/취소” 두 버튼으로라도 마지막 턱을 만든다.
- 근거(evidence_ref)는 사람이 고르지 않게 한다: 사용자가 티켓/PR을 입력하게 두면 절반은 틀리고, 그 다음부터 추적이 안 된다.
- 캐시/저장소 장애를 ‘예외’가 아니라 ‘일상’으로 취급한다: 캐시가 비면 조용히 새로 만들지 말고 “만료됨, 다시 시작”으로 끊어준다.
흔한 함정은 두 가지다. 둘 다 처음엔 “유저가 편하겠지”로 시작한다.
- 함정 1) 같은 버튼을 두 번 눌렀을 때도 그냥 흘려보내기: 결국 중복 실행이 습관이 되고, 그게 곧 비용이 된다.
- 함정 2) 대화 히스토리를 사실처럼 믿기: 나중에 분쟁이 나면 “어느 버튼이었죠?”에서 바로 길을 잃는다.
댓글
댓글 쓰기