버튼이 64바이트면, 제품은 상태머신이 된다: 텔레그램 봇 워크플로우 설계 메모

thumbnail

버튼이 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) 대화 히스토리를 사실처럼 믿기: 나중에 분쟁이 나면 “어느 버튼이었죠?”에서 바로 길을 잃는다.

댓글