텔레그램 알림 이중화 — 사용자용과 개발자용을 갈라 놓기

🌐 English

봇에 텔레그램 알림을 붙이는 건 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에서 어떻게 보내는지 다룹니다.

※ 이 글은 개발 기록이며 투자 권유가 아닙니다. 자동매매는 시스템 오류·네트워크 장애로 손실이 발생할 수 있으며, 투자 판단과 결과는 본인 책임입니다.

댓글

이 블로그의 인기 게시물

한국투자증권 KIS API로 실시간 시세 받기 (WebSocket 실전)

파이썬으로 업비트 API 연동하기 — 시세 조회부터 주문까지 기초

Go로 자동매매 신호봇 프레임워크 설계하기