서버 없이 자동매매 봇 만들기 — Google Apps Script를 백엔드로 쓰기

🌐 English

자동매매 봇을 만들 때 가장 먼저 부딪히는 건 전략이 아니라 "이걸 어디서 돌리지"입니다. 장중 내내 깨어 있어야 하고, 몇 분에 한 번씩 시세를 확인해야 하고, 새벽에 프로세스가 죽으면 다음 날 매매가 통째로 없습니다. VPS를 하나 빌리면 해결되지만 월 몇 달러가 나가고, 무엇보다 관리할 서버가 하나 늘어납니다. OS 업데이트, 인증서, 재부팅 후 자동 시작, 로그 로테이션.

이 글은 그 서버를 아예 만들지 않고 Google Apps Script(GAS)를 백엔드로 쓴 기록입니다. 결과부터 말하면 비용 0원에 24시간 돌아갑니다. 대신 GAS 특유의 제약이 있고, 그 제약이 설계를 상당 부분 결정합니다.

GAS를 백엔드로 쓴다는 발상

Apps Script는 보통 "스프레드시트 자동화 도구"로 알려져 있지만, 실제로는 세 가지를 공짜로 줍니다.

  • HTTP 엔드포인트 — 웹앱으로 배포하면 doGet/doPost가 공개 URL을 갖습니다
  • 스케줄러 — 시간 기반 트리거로 1분마다 함수를 실행할 수 있습니다
  • 영속 저장소PropertiesService가 키-값 저장소 역할을 합니다

백엔드에 필요한 것이 사실상 이 셋입니다. API 서버, 크론, DB. 거래 파라미터 수십 개와 종목 두어 개를 다루는 봇이라면 RDB가 필요 없습니다. 그래서 이 조합이 성립합니다.

doPost를 REST 엔드포인트로

웹앱으로 배포하면 doPost(e) 하나가 모든 요청을 받습니다. 라우터가 없으니 본문에 action을 실어 보내고 switch로 분기하는 방식이 가장 단순합니다.

function doPost(e) {

  const body = JSON.parse(e.postData.contents);

  const action = body.action;

  const payload = body.payload;

  switch (action) {

    case "getParameters":

      return json(getParameters());

    case "saveParameters":

      return json({ ok: saveParameters(payload) });

    case "startBot":

      return json({ status: startBot() });

    case "stopBot":

      return json({ status: stopBot() });

    default:

      return json({ error: "unknown action" });

  }

}

function json(obj) {

  return ContentService

    .createTextOutput(JSON.stringify(obj))

    .setMimeType(ContentService.MimeType.JSON);

}

여기서 GAS의 첫 번째 함정이 나옵니다. Apps Script 웹앱은 PUT/DELETE를 받지 않고, 커스텀 응답 헤더도 못 답니다. 그래서 REST의 동사 규약을 지키려 애쓰는 대신 처음부터 POST 하나에 action 필드로 가는 편이 낫습니다. RPC에 가까운 모양이 되지만, 클라이언트가 하나뿐이라면 이 단순함이 이득입니다.

두 번째 함정은 리다이렉트입니다. /exec URL은 302로 script.googleusercontent.com에 넘깁니다. fetch는 기본적으로 따라가므로 브라우저에서는 문제가 없지만, 리다이렉트를 자동으로 안 따라가는 클라이언트라면 옵션을 켜야 합니다.

상태를 어디에 둘 것인가 — PropertiesService의 두 얼굴

PropertiesService에는 스코프가 세 가지 있고, 이걸 헷갈리면 조용히 깨집니다.

  • getScriptProperties() — 스크립트 하나에 하나. 누가 실행하든 같은 값
  • getUserProperties()실행 사용자별로 다른 값
  • getDocumentProperties() — 붙어 있는 문서별
function GetPropertyData(sKey) {

  return PropertiesService.getScriptProperties().getProperty(sKey);

}

function SetPropertyData(sKey, sVal) {

  PropertiesService.getScriptProperties().setProperty(sKey, sVal);

}

봇 파라미터(계좌·종목·진입금액)는 사용자 것이라 UserProperties에, 라이브러리가 공유하는 공용 값은 ScriptProperties에 두었습니다. 웹앱을 "나로 실행"이 아니라 "접속한 사용자로 실행"으로 배포하면 사용자마다 자기 파라미터를 갖게 되어, 코드 한 벌로 여러 계정을 지원할 수 있습니다.

주의할 점 두 가지. 값은 문자열만 저장됩니다. 숫자·불리언·객체는 직접 직렬화해야 하고, 읽을 때 "OFF" 같은 문자열과 null을 구분해야 합니다. 그리고 프로퍼티 하나당 9KB, 전체 500KB 제한이 있습니다. 체결 이력 같은 걸 여기 쌓으면 안 됩니다 — 그건 스프레드시트나 외부 저장소로 보내야 합니다.

계좌 설정 화면 - 거래모드, 계좌번호, 진입금액, 주문 갭 등 파라미터

PropertiesService에 저장되는 파라미터들. 화면의 입력 필드 하나가 프로퍼티 키 하나에 대응합니다.

매매 루프는 트리거가 돌린다

봇의 심장은 시간 기반 트리거입니다. "시작" 버튼은 사실 트리거를 만드는 함수이고, "정지"는 트리거를 지우는 함수입니다.

function startBot() {

  const props = PropertiesService.getUserProperties();

  // 이미 돌고 있으면 트리거를 또 만들지 않는다 (중복 실행 방지)

  const running = props.getProperty("RunBot");

  if (running == null) {

    props.setProperty('RunBot', "OFF");

  } else if (running !== "OFF") {

    return 'ON';

  }

  const trigger = ScriptApp.newTrigger("RunBotFunction")

    .timeBased()

    .everyMinutes(1)

    .create();

  // 트리거의 uniqueId 를 상태값으로 그대로 쓴다

  props.setProperty('RunBot', trigger.getUniqueId());

  return 'ON';

}

function stopBot() {

  const props = PropertiesService.getUserProperties();

  const triggers = ScriptApp.getProjectTriggers();

  for (let i = 0; i < triggers.length; i++) {

    try {

      ScriptApp.deleteTrigger(triggers[i]);

    } catch (e) {

      Logger.log('deleteTrigger 실패: ' + e.message);

    }

  }

  props.setProperty('RunBot', "OFF");

  return 'OFF';

}

여기 작은 설계 결정이 하나 숨어 있습니다. RunBot 프로퍼티에 "ON"이 아니라 트리거의 uniqueId를 저장합니다. 이러면 "봇이 켜져 있다"와 "실제로 트리거가 존재한다"가 같은 사실 하나가 됩니다. 플래그를 따로 두면 트리거는 지워졌는데 플래그만 ON으로 남는 상태가 반드시 생깁니다.

stopBotgetProjectTriggers()를 전부 도는 것도 의도적입니다. 트리거는 예상보다 쉽게 유실되고 중복 생성됩니다 — 배포를 바꾸거나 실행이 실패하면 흔적이 남습니다. 정지는 확실해야 하므로 개별 ID를 찾아 지우는 대신 전부 지웁니다.

GAS의 제약과 우회법

이 구조를 택하면 반드시 만나는 벽이 있습니다.

1) 실행 시간 6분

무료 계정은 한 번 실행에 6분(Workspace 계정은 30분)입니다. 넘으면 그냥 죽습니다. 그래서 1분 트리거 + 매 실행은 짧게 구조로 갑니다. 한 번 깨어나서 "지금 해야 할 일 한 조각"만 하고 끝냅니다. 루프를 길게 돌리는 대신 상태를 프로퍼티에 남기고 다음 실행이 이어받는 방식입니다. 사실상 상태 머신을 손으로 쓰는 셈이고, 이게 서버리스의 값입니다.

2) 트리거 최소 간격 1분

초 단위 매매는 불가능합니다. 이 제약이 전략을 고릅니다. 스캘핑은 애초에 후보가 아니고, 그리드·리밸런싱·갈아타기처럼 분 단위로 충분한 전략만 이 구조에 맞습니다. 반대로 말하면, 전략이 분 단위로 충분하다면 서버를 살 이유가 없습니다.

3) UrlFetchApp 일일 쿼터

외부 HTTP 호출은 무료 계정 기준 하루 20,000회입니다. 넉넉해 보이지만 계산해 보면 빠듯합니다. 1분마다 실행 × 장중 390분 = 390회, 여기에 매 실행 시세 조회 2건 + 잔고 1건이면 하루 1,500회 안팎. 종목을 늘리거나 주기를 줄이면 금방 다가옵니다. 그래서 같은 주기 안의 중복 호출을 접는 것이 중요합니다.

// 접근토큰은 발급일자를 프로퍼티에 두고 하루 한 번만 재발급한다.

// 매 실행마다 토큰을 새로 받으면 그것만으로 하루 수백 회를 쓴다.

function getAccessToken() {

  const props = PropertiesService.getUserProperties();

  const today = Utilities.formatDate(new Date(), 'Asia/Seoul', 'yyyyMMdd');

  if (props.getProperty('TokenDate') === today) {

    return props.getProperty('AccessToken');

  }

  const res = UrlFetchApp.fetch(TOKEN_URI, {

    method: 'post',

    contentType: 'application/json',

    payload: JSON.stringify({

      grant_type: 'client_credentials',

      appkey:    props.getProperty('APIKey'),

      appsecret: props.getProperty('APISecret')

    })

  });

  const token = JSON.parse(res.getContentText()).access_token;

  props.setProperty('AccessToken', token);

  props.setProperty('TokenDate', today);

  return token;

}

4) 증권사 API 쪽 제약도 같이 온다

한국투자증권 REST API는 접근토큰의 유효기간이 있고, 초당 호출 수 제한이 있습니다. GAS의 쿼터와 증권사의 유량 제한은 별개라서 둘 다 지켜야 합니다. 토큰을 캐시하는 위 코드가 사실상 양쪽을 동시에 아껴 줍니다.

언제 이 구조가 맞고, 언제 아닌가

맞는 경우

  • 분 단위 이상의 주기로 충분한 전략 (그리드, 리밸런싱, 갈아타기, 정기 매수)
  • 다루는 상태가 작다 — 파라미터 수십 개, 종목 몇 개
  • 혼자 또는 소수가 쓰고, 서버를 관리할 생각이 없다
  • 비용을 0으로 두고 싶다

맞지 않는 경우

  • 초 단위 반응이 필요하다 — 트리거 최소 간격이 1분입니다
  • 실시간 시세 스트리밍(WebSocket)이 필요하다 — GAS는 상시 연결을 유지하지 못합니다
  • 체결 이력·백테스트 데이터처럼 쌓이는 데이터가 많다 — 프로퍼티 500KB로는 어림없습니다
  • 실행 실패를 정밀하게 재시도·추적해야 한다 — 트리거 실패는 조용히 지나갑니다

마지막 항목이 실무에서 제일 아픕니다. 트리거가 한 번 건너뛰어도 아무도 알려주지 않습니다. 그래서 매 실행마다 마지막 실행 시각을 프로퍼티에 남기고, 화면에서 그 값이 몇 분 전인지 보이게 해 두는 편이 좋습니다. "봇이 켜져 있다"가 아니라 "마지막으로 실제로 돈 시각"을 보여 주는 것이 훨씬 정직한 상태 표시입니다.

정리

  • 백엔드에 필요한 HTTP · 스케줄러 · 저장소 셋을 GAS가 무료로 준다
  • 라우터가 없으니 POST + action 스위치가 가장 단순한 답이다
  • 봇 ON/OFF는 플래그가 아니라 트리거 uniqueId 자체를 상태로 쓰면 어긋나지 않는다
  • 6분 제한 때문에 긴 루프 대신 짧은 실행 + 프로퍼티에 남긴 상태로 이어 간다
  • 일 20,000회 쿼터는 토큰 캐시만 해도 크게 아낀다
  • 초 단위·스트리밍·대용량이 필요하면 이 구조는 맞지 않는다 — 그때는 서버를 사는 게 맞다

다음 글에서는 이 백엔드를 조작하는 크롬 확장 컨트롤 패널을 어떻게 만들었는지 다룹니다.

※ 이 글은 개발 기록이며 투자 권유가 아닙니다. 자동매매는 시스템 오류·네트워크 장애·예상치 못한 시장 상황으로 손실이 발생할 수 있으며, 투자 판단과 그 결과는 전적으로 본인 책임입니다. 실계좌 적용 전 반드시 소액 또는 모의투자로 충분히 검증하시기 바랍니다.

댓글

이 블로그의 인기 게시물

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

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

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