요즘 내 아침은 지하철에서 시작한다. 폰으로 메신저를 열면 밤사이 도착한 보고가 쌓여 있고, 나는 그중 하나에 답장을 쓴다. "그 글 영문판은 색인에서 빼줘." 내가 회사에 도착할 때쯤 진행 상황이 오고, 점심때 결과가 온다.
그 사이 나는 코드를 한 줄도 쓰지 않았다. 노트북을 열지도 않았다.
이 연재는 그 상태를 처음부터 만드는 과정이다. 첫 편은 가장 작은 조각 하나 — 메신저로 말을 걸면 답하는 AI다. 오늘 만들 것은 100줄이 안 되고, 다 만들면 당신 폰에서 실제로 답이 온다.
왜 하필 메신저인가
앱을 만들 수도 있고, 웹 화면을 붙일 수도 있다. 그런데 나는 둘 다 해봤고 결국 메신저로 돌아왔다. 이유는 셋이다.
첫째, 이미 깔려 있다. 새 앱을 설치하게 만드는 순간 그 도구는 하루에 한 번 열릴까 말까다. 메신저는 어차피 하루에 수십 번 연다.
둘째, 알림이 공짜다. 푸시 알림을 직접 구현하려면 인증서니 토큰이니 붙일 게 많다. 메신저는 그게 이미 되어 있다. 서버가 말을 걸면 폰이 울린다.
셋째, 대화가 기록으로 남는다. 내가 뭘 시켰고 뭐라고 답했는지가 스크롤만 올리면 다 있다. 나중에 "그때 그거 어떻게 했더라"를 찾을 때 이게 생각보다 크다.
텔레그램을 고른 건 봇 만들기가 가장 덜 귀찮아서다. 심사도 없고, 계정도 필요 없고, 대화 상대에게 메시지를 보내는 API가 공개돼 있다.
만들기
Node가 깔린 리눅스 한 대를 전제한다. 노트북에서 해도 돌아가지만, 노트북을 닫으면 봇도 같이 잔다. 그래서 나는 상시 켜져 있는 서버 한 대를 쓴다.
1. 봇 만들기
텔레그램에서 @BotFather를 찾아 대화를 연다. /newbot을 치고 이름을 정하면 토큰을 하나 준다. 이게 봇의 비밀번호다.
이 토큰을 코드에 직접 쓰지 않는다. 나중에 저장소에 올리거나 스크린샷을 찍을 때 그대로 새어나간다. 파일로 뺀다.
mkdir -p ~/office/bot && cd ~/office/bot
npm init -y
.env 파일을 만들고:
BOT_TOKEN=<봇파더가 준 토큰>
ANTHROPIC_API_KEY=<AI 제공사 키>
ALLOWED_CHAT_ID=<당신의 chat id>
ALLOWED_CHAT_ID는 잠시 뒤에 채운다. 지금은 비워두자.
2. 메시지를 받아오기
텔레그램에서 메시지를 가져오는 방법은 둘이다. 웹훅(텔레그램이 내 서버를 호출)과 롱폴링(내가 텔레그램에 물어봄). 웹훅은 공개 주소와 인증서가 필요하고, 롱폴링은 아무 데서나 된다.
처음에는 롱폴링으로 시작하라. 나도 그랬고, 아직도 그렇게 쓰고 있다.
// bot.js
const API = `https://api.telegram.org/bot${process.env.BOT_TOKEN}`;
async function getUpdates(offset) {
// timeout=30 → 새 메시지가 없으면 30초까지 기다렸다 응답한다.
// 이게 롱폴링이다. 1초마다 두드리는 것보다 훨씬 조용하고 빠르다.
const res = await fetch(`${API}/getUpdates?timeout=30&offset=${offset}`);
const data = await res.json();
return data.result || [];
}
3. 답을 보내기 — 여기서 대부분 막힌다
메시지 보내기는 한 줄이면 될 것 같다. 실제로는 두 가지에 걸린다.
하나, 4,096자 제한. AI 답변은 이 길이를 자주 넘는다. 넘으면 텔레그램이 통째로 거부한다. 잘라 보내야 하는데, 아무 데서나 자르면 코드 블록 한가운데가 끊겨서 화면이 깨진다.
둘, 마크다운 파싱 오류. 텔레그램의 마크다운은 우리가 아는 마크다운과 다르다. AI가 *나 _를 자연스럽게 쓰면 텔레그램이 "문법이 안 맞다"며 400을 뱉고 메시지를 아예 안 보낸다. 나는 이걸 몰라서 한동안 "봇이 가끔 침묵한다"고만 생각했다.
두 문제를 같이 처리하면 이렇게 된다.
const LIMIT = 3900; // 4096에서 여유를 둔다
function splitMessage(text) {
// 줄 단위로 쌓다가 넘치면 끊는다. 코드 블록을 한가운데서 자르지 않기 위해서다.
const chunks = [];
let buf = '';
for (const line of text.split('\n')) {
if ((buf + line).length > LIMIT) {
chunks.push(buf);
buf = '';
}
buf += line + '\n';
}
if (buf.trim()) chunks.push(buf);
return chunks;
}
async function sendMessage(chatId, text) {
for (const chunk of splitMessage(text)) {
const res = await fetch(`${API}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text: chunk, parse_mode: 'Markdown' }),
});
// 파싱 실패(400)면 서식을 버리고 평문으로 다시 보낸다.
// 서식이 깨지는 것보다 메시지가 안 오는 게 훨씬 나쁘다.
if (!res.ok) {
await fetch(`${API}/sendMessage`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: chatId, text: chunk }),
});
}
}
}
마지막 그 폴백 한 조각이 이 글에서 제일 값어치 있는 부분이다. 서식은 포기해도 되지만 침묵은 안 된다.
4. AI에 물어보기
async function ask(question) {
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': process.env.ANTHROPIC_API_KEY,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
},
body: JSON.stringify({
model: '<사용할 모델명>',
max_tokens: 2000,
messages: [{ role: 'user', content: question }],
}),
});
const data = await res.json();
return data.content?.[0]?.text ?? '(답을 받지 못했습니다)';
}
모델명을 자리표시자로 둔 이유가 있다. 모델은 생각보다 자주 사라진다. 나는 이미지 생성 모델이 조용히 폐기돼서 블로그 이미지가 며칠 동안 엉뚱하게 나간 적이 있다. 모델명은 코드 안쪽이 아니라 설정 한 곳에 두고, 바뀌면 거기만 고치게 해두자.
5. 이어 붙이기
async function main() {
let offset = 0;
console.log('봇 시작');
while (true) {
try {
const updates = await getUpdates(offset);
for (const u of updates) {
offset = u.update_id + 1; // 이걸 빠뜨리면 같은 메시지를 영원히 다시 받는다
const msg = u.message;
if (!msg?.text) continue;
// 내 방에서 온 것만 처리한다. 봇 주소를 아는 사람은 누구나 말을 걸 수 있다.
if (String(msg.chat.id) !== process.env.ALLOWED_CHAT_ID) {
console.warn('허용되지 않은 chat id:', msg.chat.id);
continue;
}
const answer = await ask(msg.text);
await sendMessage(msg.chat.id, answer);
}
} catch (e) {
console.error('루프 오류:', e.message);
await new Promise(r => setTimeout(r, 5000)); // 죽지 말고 쉬었다 다시
}
}
}
main();
ALLOWED_CHAT_ID를 채우려면 일단 그 검사를 잠깐 주석 처리하고 봇에게 아무 말이나 걸어보면 된다. 콘솔에 찍히는 msg.chat.id가 당신 번호다. .env에 넣고 검사를 되살린다.
node --env-file=.env bot.js
폰에서 봇에게 말을 걸어보자. 답이 오면 1편은 끝이다.
함정
① 웹훅과 롱폴링은 같이 못 쓴다. 전에 웹훅을 설정해둔 적이 있으면 롱폴링이 조용히 아무것도 못 받는다. 오류도 안 난다. 폴링을 시작하기 전에 웹훅을 지운다.
curl "https://api.telegram.org/bot<토큰>/deleteWebhook"
② 터미널을 닫으면 봇도 죽는다. 서버에 올렸다고 끝이 아니다. 접속을 끊는 순간 프로세스가 같이 죽는다. 이건 다음 편에서 제대로 다룬다. 지금은 "서버에 있다 ≠ 계속 돌아간다"만 기억하면 된다.
③ 봇 주소는 공개된 문이다. ALLOWED_CHAT_ID 검사를 생략하지 말자. 봇 이름은 검색되고, 누구나 말을 걸 수 있고, 당신의 AI 요금으로 답이 나간다.
④ 조용한 실패가 제일 비싸다. 이 편의 코드에는 console.warn과 console.error가 여러 개 있다. 지우고 싶어질 텐데 지우지 말자. 내가 겪은 사고는 대부분 "실패했는데 실패한 줄 몰랐던" 종류였다. 봇이 답을 안 하는데 이유가 로그에 없으면, 그날 저녁은 그것만 붙잡게 된다.
됐는지 확인하는 법
- 폰에서 아무 말이나 걸면 답이 온다
- 길게 답할 만한 것을 물어본다("파이썬으로 CSV 읽는 코드 예제 길게 써줘"). 잘리지 않고 여러 통으로 나뉘어 온다
- 답 안에
*나 백틱이 섞여 있어도 도착한다(400 폴백이 작동한다는 뜻) - 다른 계정으로 봇에게 말을 걸면 답하지 않는다
- 봇을 껐다 켜도 이전 메시지가 다시 밀려오지 않는다
이 글의 코드는 어디까지 확인했나
기사에 싣기 전에 깨끗한 환경에서 돌려봤다. Node 22 기준이다.
- 분할 로직: 짧은 글은 한 조각, 긴 글은 여러 조각, 모든 조각이 4,096자 미만, 내용 손실 없음, 줄 중간에서 안 끊김 — 통과
- 텔레그램 엔드포인트:
getUpdates·sendMessage·deleteWebhook모두 도달 확인(잘못된 토큰이면 404가 온다) --env-file플래그: 환경변수가 실제로 로드되는 것 확인
다만 실제 봇 토큰이 있어야 하는 왕복(내 폰으로 메시지가 오는 것)은 당신 환경에서 확인해야 한다. 그건 내 계정으로 대신해줄 수 없는 부분이다.
왜 이 설계인가
메신저 봇 하나를 만드는 데 이렇게까지 할 일인가 싶을 수 있다. 그런데 나는 이걸 앱으로 먼저 만들어봤고, 실패했다.
1년 반쯤 전에 나는 여러 AI가 서로 협의하는 데스크톱 앱을 만들었다. 화면도 있고 기능도 있었는데, 노트북을 닫으면 멈췄다. 출근길에 뭔가 떠올라도 할 수 있는 게 없었다. 그때 만든 건 상주하는 비서가 아니라 내가 켜줘야 켜지는 프로그램이었다.
지금 이 봇은 그 앱보다 기능이 훨씬 적다. 대신 꺼지지 않고, 어디서나 닿는다. 그 차이가 나머지 전부를 만들었다.
그 앱이 어떻게 됐는지는 다른 연재에 적어뒀다 — 내가 꾼 첫 번째 꿈 ① 여러 AI에게 회의를 시키고 싶었다.
다음 편 — 봇이 답은 하는데 어제 한 얘기를 기억하지 못한다. 대화를 통째로 다시 넣지 않고 기억을 갖게 하는 방법을 다룬다.

