텔레그램 알림 이중화 — 사용자용과 개발자용을 갈라 놓기
봇에 텔레그램 알림을 붙이는 건 30분이면 끝납니다. 봇 토큰 받고, sendMessage 부르고, 끝. 문제는 그 다음입니다. 알림이 하나뿐이면 금방 못 쓰게 됩니다.
매수 체결, 매도 체결, 갈아타기 완료 — 여기까지는 좋습니다. 그런데 API 토큰 만료 경고, 주문 거부 응답, 예외 스택, 트리거 실패도 같은 방으로 들어옵니다. 사용자는 자기가 이해할 수 없는 메시지에 묻혀 정작 중요한 체결 알림을 놓칩니다. 그러다 알림을 꺼 버립니다.
이 글은 알림을 사용자 채널과 개발자 채널로 나눈 기록입니다. 함수 두 개로 갈라지는 단순한 구조지만, 그 경계를 어디에 긋느냐가 실제로는 운영 전체를 좌우했습니다.
둘로 나눈 기준
기준은 "이 메시지를 받고 사용자가 할 수 있는 일이 있는가" 하나입니다.
- 사용자 채널 — 매수·매도 체결, 갈아타기, 청산, 봇 시작/정지. 계좌에 실제로 일어난 일. 사용자가 보고 판단할 수 있습니다
- 개발자 채널 — API 오류, 예외, 파라미터 이상, 재시도 실패. 코드가 고쳐야 하는 일. 사용자가 받아 봐야 불안하기만 합니다
애매한 것들이 있습니다. "주문이 거부됐다"는 어느 쪽일까요. 둘 다입니다. 사용자에게는 "매수가 안 됐습니다"로, 개발자에게는 거부 코드와 응답 원문으로 갑니다. 같은 사건을 다른 언어로 두 번 보내는 것이지, 한쪽으로 몰아 버리면 안 됩니다.
토큰과 chat_id를 어느 스코프에 둘 것인가
이 설계에서 가장 중요한 결정입니다. 두 채널은 봇 토큰부터 다릅니다.
// 사용자 채널 — 봇 토큰은 공용, chat_id 는 사용자마다 다르다
function SendBOTUser(body) {
if (getchatIdTel() == null || getchatIdTel() == "0") {
return; // 등록 안 했으면 조용히 끝
}
var formattedDate = Utilities.formatDate(new Date(), "GMT+9",
"yyyy:MM:dd_hh:mm:ss");
const botToken = PropertiesService.getScriptProperties()
.getProperty("BOT_TOKEN");
var message = "주식_Bot(" + formattedDate + ")[" + who() + "]\n" + body;
var url = "https://api.telegram.org/bot" + botToken + "/sendMessage";
var payload = {
chat_id: getchatIdTel(), // ← UserProperties 에서
text: message
};
...
}
// 개발자 채널 — 봇 토큰도 chat_id 도 전부 공용(고정)
function SendBOTDev(body) {
var formattedDate = Utilities.formatDate(new Date(), "GMT+9",
"yyyy:MM:dd_hh:mm:ss");
const devToken = PropertiesService.getScriptProperties()
.getProperty("DEV_BOT_TOKEN");
const devchatid = PropertiesService.getScriptProperties()
.getProperty("DEV_BOT_CHATID");
var message = "주식_Bot(" + formattedDate + ")[" + who() + "]\n" + body;
var url = "https://api.telegram.org/bot" + devToken + "/sendMessage";
var payload = {
chat_id: devchatid, // ← ScriptProperties 에 고정
text: message
};
...
}
차이를 보면 구조가 드러납니다.
사용자 chat_id 는 UserProperties에 있습니다. 사용자마다 다른 값이어야 하기 때문입니다. 앞 글에서 다룬 것처럼 웹앱을 "접속한 사용자로 실행"으로 배포하면 이 스코프가 자동으로 갈립니다. 코드에 사용자 구분 로직이 한 줄도 없는데 각자 자기 알림을 받습니다.
개발자 chat_id 는 ScriptProperties에 고정입니다. 누가 쓰든 오류는 나에게 와야 하니까요.
봇 토큰이 아예 다른 것도 의도적입니다. 같은 봇으로 보내면 사용자가 그 봇을 차단하는 순간 개발자 알림도 같이 막힐 수 있고, 무엇보다 사용자가 개발자 채팅방을 찾아낼 여지가 생깁니다. 분리해 두면 그 경로가 없습니다.
chat_id 는 어떻게 얻는가
텔레그램 봇은 먼저 말을 걸 수 없습니다. 사용자가 봇에게 메시지를 한 번 보내야 chat_id가 생깁니다. 그래서 등록 절차가 필요한데, "chat_id를 알아내서 입력하세요"라고 하면 대부분 포기합니다.
대신 이렇게 했습니다. 사용자에게 자기 아이디를 봇에게 보내라고 하고, 서버가 그걸 찾아 매칭합니다.
function TelBOTChatID(userid) {
const botToken = PropertiesService.getScriptProperties()
.getProperty("BOT_TOKEN");
const url = "https://api.telegram.org/bot" + botToken
+ "/getUpdates?limit=10";
try {
const response = UrlFetchApp.fetch(url);
const data = JSON.parse(response.getContentText());
if (!data.ok) {
Logger.log("TelBOTChatID API 호출 실패");
return;
}
const updates = data.result;
let isFound = false;
// 최신 메시지부터 거꾸로 — 같은 사람이 여러 번 보냈으면 가장 최근 것
for (let i = updates.length - 1; i >= 0; i--) {
const msg = updates[i].message;
if (msg && msg.text && msg.text.includes(userid)) {
setchatIdTel(msg.chat.id.toString());
isFound = true;
break;
}
}
if (!isFound) {
Logger.log("TelBOTChatID [" + userid + "] 사용자를 찾을 수 없습니다.");
}
} catch (e) {
Logger.log("TelBOTChatID 오류 발생: " + e.toString());
}
}
사용자는 텔레그램에서 봇에게 자기 아이디를 한 번 보내고, 화면에서 "텔레그램알림등록" 버튼만 누르면 됩니다. 나머지는 서버가 합니다.
세 가지 주의점이 있습니다.
getUpdates 는 최근 것만 줍니다. 여기서는 limit=10입니다. 사용자가 메시지를 보내고 한참 뒤에 버튼을 누르면, 그 사이 다른 사람들의 메시지에 밀려 못 찾습니다. 그래서 안내에 "보낸 직후에 누르세요"가 들어가야 합니다.
역순 탐색인 이유는 같은 사람이 여러 번 보냈을 때 가장 최근 것을 쓰기 위해서입니다. 기기를 바꾸면 chat_id가 바뀔 수 있는데, 정순으로 찾으면 옛날 것을 잡습니다.
getUpdates 와 웹훅은 함께 못 씁니다. 웹훅을 걸어 두면 getUpdates가 빈 배열을 돌려줍니다. 이 방식을 쓰려면 웹훅을 쓰지 않아야 합니다.
알림이 실패해도 매매는 멈추지 않아야 한다
가장 중요한 원칙입니다. 알림은 부수 작업이지 매매의 일부가 아닙니다. 텔레그램 API가 죽어도 주문은 나가야 합니다.
그래서 두 함수 모두 전송을 try/catch로 감싸고, 실패하면 로그로 떨어뜨린 뒤 그냥 돌아갑니다.
try {
var response = UrlFetchApp.fetch(url, options);
Utilities.sleep(50);
Logger.log(response.getContentText());
} catch (e) {
Logger.log("An error occurred in SendBOT: " + e.message);
return; // 예외를 위로 올리지 않는다
}
여기서 예외를 다시 던지지 않는 것이 핵심입니다. 던지면 알림 실패가 매매 루프를 타고 올라가 그 주기를 통째로 죽입니다. "알려 주지 못한 것"과 "매매하지 못한 것"은 심각도가 전혀 다릅니다.
대신 로그에는 반드시 남깁니다. 조용히 catch {}로 삼키면 "알림이 안 온다"는 신고를 받았을 때 확인할 방법이 없습니다. Apps Script의 실행 기록에 남아 있으면 최소한 보내려 시도는 했는지 알 수 있습니다.
Utilities.sleep(50) 한 줄도 이유가 있습니다. 텔레그램은 초당 전송량에 제한이 있고, 봇이 한 주기에 여러 건을 보낼 수 있습니다. 50ms는 매매를 지연시키지 않으면서 연속 전송만 살짝 벌려 주는 정도입니다.
사용자 채널은 "등록 안 했으면 조용히"
SendBOTUser의 첫 줄을 다시 봅니다.
if (getchatIdTel() == null || getchatIdTel() == "0") {
return;
}
등록하지 않은 사용자에게는 아무 일도 일어나지 않습니다. 오류도 아니고 경고도 아닙니다. 알림은 선택 기능이므로 안 쓰는 것이 정상 상태여야 합니다.
null과 문자열 "0"을 둘 다 보는 것은 PropertiesService가 문자열만 저장하기 때문입니다. 초기화 코드가 0을 넣으면 읽을 때 "0"이 됩니다. 이 저장소를 쓸 때 반복해서 걸리는 지점입니다.
서비스로 배포하면 이 분리가 필수가 된다
혼자 쓰는 봇이라면 알림 하나로도 삽니다. 그런데 다른 사람이 쓰기 시작하면 상황이 완전히 달라집니다.
첫째, 개발자 알림이 사용자에게 가면 안 됩니다. 예외 메시지에는 계좌번호 일부, 종목, 응답 원문이 섞여 들어갑니다. 다중 사용자 환경에서 A의 오류 메시지가 B에게 가면 그건 개인정보 유출입니다. 채널이 갈려 있으면 이 사고가 구조적으로 안 납니다.
둘째, 사용자 알림이 개발자에게 오면 못 씁니다. 사용자가 열 명이면 체결 알림이 하루 수백 건입니다. 그 안에서 진짜 오류를 찾을 수 없습니다.
셋째, 사용자마다 알림을 끄고 켤 수 있어야 합니다. 개발자 채널은 항상 켜져 있어야 하고요. 스코프가 갈려 있으니 이것도 자연스럽게 됩니다.
메시지 앞에 붙는 [who()] 표시도 여기서 값을 합니다. 개발자 채널로 온 오류가 누구 계정에서 났는지를 알려 줍니다 — 사용자 채널에는 자기 것만 오니 사실 필요 없지만, 두 함수가 같은 포맷을 쓰게 두어 형식을 하나로 유지했습니다.
정리
- 기준은 하나 — "받아서 사용자가 할 수 있는 일이 있는가"
- 애매한 사건은 양쪽에 다른 언어로 보낸다. 한쪽으로 몰지 않는다
- 사용자 chat_id 는
UserProperties, 개발자 chat_id 는ScriptProperties— 스코프가 사용자 분리를 대신해 준다 - 봇 토큰까지 나눠야 사용자가 개발자 채널에 닿을 경로가 없다
- chat_id 등록은 사용자가 봇에 말을 걸고 서버가
getUpdates에서 찾아 매칭 (웹훅과 병행 불가) - 전송 실패는 로그로 떨구고 예외를 올리지 않는다 — 알림 때문에 매매가 멈추면 안 된다
- 다중 사용자에서는 이 분리가 개인정보 사고를 구조적으로 막는 장치가 된다
다음 글에서는 텔레그램과 병행해서 쓰는 FCM 푸시를 GAS에서 어떻게 보내는지 다룹니다.
※ 이 글은 개발 기록이며 투자 권유가 아닙니다. 자동매매는 시스템 오류·네트워크 장애로 손실이 발생할 수 있으며, 투자 판단과 결과는 본인 책임입니다.
댓글
댓글 쓰기