크롬 확장으로 매매 봇 컨트롤 패널 만들기 (Manifest V3)

🌐 English

백엔드를 Google Apps Script로 올리고 나면 다음 질문이 나옵니다. 이걸 뭘로 조작하지? GAS는 doGet으로 HTML을 뱉을 수 있으니 웹앱 화면을 만들면 됩니다. 실제로 처음엔 그렇게 했습니다. 그런데 몇 주 쓰다 보니 불편이 하나 쌓였습니다. 봇 상태 한 번 보려고 탭을 새로 열고, URL을 찾고, 로딩을 기다립니다. 장중에 이걸 하루 스무 번 합니다.

그래서 조작 화면을 크롬 확장 팝업으로 옮겼습니다. 이 글은 그 과정과, Manifest V3에서 걸린 것들의 기록입니다.

왜 웹 대시보드가 아니라 확장인가

이유는 하나입니다. 항상 한 클릭 거리에 있다는 것.

  • 탭을 열 필요가 없다 — 툴바 아이콘 하나
  • URL을 기억할 필요가 없다 — 배포 URL은 확장 안에 숨는다
  • 어느 사이트를 보고 있든 그대로 뜬다 — 차트를 보다가 바로 조작한다
  • 브라우저가 켜져 있으면 항상 같은 자리에 있다

반대로 포기하는 것도 분명합니다. 화면이 좁습니다. 팝업은 실질적으로 400px 남짓입니다. 그래서 이 선택은 "대시보드가 아니라 리모컨"이라는 성격을 확정합니다. 차트와 통계는 넣지 않고, 지금 상태 확인 + 시작/정지 + 파라미터 수정만 담기로 했습니다. 좁은 화면이 오히려 기능을 골라 줬습니다.

다크 테마 확장 팝업 전체 화면 - 상단 봇 상태 pill, 액션 버튼, 종목 카드

확장 팝업 전체 모습. 폭 400px 안에 상태 pill, 액션 바, 종목 카드, 계좌 설정을 세로로 쌓았습니다.

Manifest V3 — 권한은 적을수록 좋다

MV3의 manifest는 생각보다 짧습니다. 중요한 건 무엇을 넣느냐가 아니라 무엇을 안 넣느냐입니다.

{

  "manifest_version": 3,

  "name": "Stock Trading Bot",

  "description": "Apps Script 기반 자동 주식 매매 봇 컨트롤 패널",

  "version": "3.1",

  "action": {

    "default_icon": {

      "16": "icons/icon16.png",

      "48": "icons/icon48.png",

      "128": "icons/icon128.png"

    },

    "default_popup": "popup.html",

    "default_title": "Stock Trading Bot"

  },

  "permissions": [

    "windows"

  ],

  "host_permissions": [

    "https://script.google.com/*"

  ],

  "icons": {

    "16": "icons/icon16.png",

    "48": "icons/icon48.png",

    "128": "icons/icon128.png"

  }

}

여기서 중요한 세 가지.

permissions가 하나뿐입니다. 외부 링크를 새 창으로 여는 데 windows만 씁니다. storage도, tabs도, activeTab도 없습니다. 팝업 안에서는 localStorage가 그냥 되기 때문에 storage 권한이 필요 없습니다 — 이걸 모르고 습관적으로 넣는 경우가 많습니다.

host_permissions가 한 도메인입니다. <all_urls>가 아니라 https://script.google.com/*. 확장이 접근할 수 있는 곳이 백엔드 하나뿐이라는 뜻이고, 설치 시 사용자에게 뜨는 경고 문구도 그만큼 좁아집니다.

content_scripts가 없습니다. 이 확장은 남의 페이지에 아무것도 주입하지 않습니다. 팝업 안에서만 삽니다. 권한 심사에서 가장 크게 작용하는 부분입니다 — 이 이야기는 다음 글에서 더 다룹니다.

백엔드 호출은 래퍼 하나로

팝업에서 백엔드를 부르는 코드는 파일 하나, 함수 하나입니다.

// api.js — 배포 URL은 여기 한 곳에만 둔다

const WEBAPP_URL = "https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec";

async function callAPI(action, data = {}) {

  const response = await fetch(WEBAPP_URL, {

    method: "POST",

    mode: "cors",

    headers: { "Content-Type": "application/json" },

    body: JSON.stringify({

      action,

      payload: data

    })

  });

  return response.json();

}

이게 전부입니다. 백엔드가 POST 하나에 action 필드로 받게 설계돼 있으니(앞 글 참고) 프론트도 그만큼 얇아집니다. 호출부는 이렇게 읽힙니다.

const params = await callAPI('getParameters');

await callAPI('saveParameters', formData);

await callAPI('startBot');

await callAPI('stopBot');

래퍼를 하나로 유지해서 얻는 실질적인 이득은 배포 URL이 코드 한 곳에만 있다는 것입니다. GAS는 재배포할 때마다 URL이 바뀔 수 있는데, 그때 고칠 곳이 한 줄입니다.

한 가지 주의. GAS 웹앱은 preflight를 잘 견디지 못합니다. 커스텀 헤더를 붙이거나 인증 헤더를 넣으면 OPTIONS 요청이 먼저 나가고 그게 막히는 일이 생깁니다. 위 코드가 Content-Type: application/json만 쓰는 이유입니다. 인증이 필요하면 헤더가 아니라 본문에 토큰을 실어 보내는 편이 마찰이 적습니다.

테마는 깜빡이면 안 된다

다크/라이트 토글을 붙일 때 흔히 나오는 버그가 흰 화면이 한 번 번쩍하는 것입니다. DOMContentLoaded에서 테마를 적용하면 이미 늦습니다 — 그 전에 기본 스타일로 한 프레임이 그려집니다. 팝업은 열릴 때마다 새로 그려지므로 이게 매번 보입니다.

해법은 간단합니다. 스크립트를 즉시 실행 함수로 감싸 파싱 시점에 바로 속성을 박아 넣습니다.

// popup.js 맨 위 — DOMContentLoaded 를 기다리지 않는다

(function () {

  const saved = localStorage.getItem('onstock-theme') || 'dark';

  document.documentElement.setAttribute('data-theme', saved);

})();

document.addEventListener('DOMContentLoaded', () => {

  const themeBtn = document.getElementById('themeToggle');

  if (themeBtn) {

    themeBtn.addEventListener('click', () => {

      const cur  = document.documentElement.getAttribute('data-theme');

      const next = cur === 'dark' ? 'light' : 'dark';

      document.documentElement.setAttribute('data-theme', next);

      localStorage.setItem('onstock-theme', next);

    });

  }

});

CSS 쪽은 data-theme 속성으로 변수만 갈아 끼웁니다.

:root {

  --bg:   #0e1116;

  --card: #161b22;

  --text: #e6edf3;

}

:root[data-theme="light"] {

  --bg:   #f6f8fa;

  --card: #ffffff;

  --text: #1f2328;

}

body { background: var(--bg); color: var(--text); }

선택 상태는 localStorage에 남습니다. 앞서 말했듯 팝업 안에서는 storage 권한 없이 그냥 동작합니다. 다만 이 값은 브라우저 프로필에만 남으므로 기기를 옮기면 초기화됩니다. 테마 같은 취향값에는 그게 맞고, 계좌 파라미터처럼 진짜 상태는 백엔드에 둬야 합니다. 이 경계를 흐리면 "이 PC에서만 설정이 다른" 상태가 생깁니다.

라이트 테마 확장 팝업 전체 화면

같은 화면의 라이트 테마. CSS 변수만 갈아 끼우므로 마크업은 한 벌입니다.

봇 상태 pill을 실시간으로 맞추기

상단에 봇이 도는지 보여 주는 pill이 있습니다. ON이면 녹색으로 점멸, OFF면 빨강입니다. 문제는 이 값을 갱신하는 경로가 여럿이라는 것입니다. 팝업이 처음 열릴 때, 시작/정지를 눌렀을 때, 설정을 저장하고 응답을 받았을 때 — 각 자리에서 pill을 직접 고치면 한 군데를 빠뜨리는 순간 화면이 거짓말을 합니다.

그래서 방향을 뒤집었습니다. pill은 아무도 직접 건드리지 않습니다. 값이 표시되는 #RunBot 엘리먼트만 갱신하고, pill은 그 텍스트를 감시해서 따라갑니다.

const pill   = document.getElementById('botPill');

const target = document.getElementById('RunBot');

if (pill && target) {

  const sync = () => {

    const on = (target.textContent || '').trim() === 'ON';

    pill.classList.toggle('on', on);

    pill.classList.toggle('off', !on);

  };

  sync();                       // 최초 1회

  new MutationObserver(sync)

    .observe(target, { childList: true, characterData: true, subtree: true });

}

MutationObserver가 텍스트 변화를 잡아 클래스를 맞춥니다. 이제 상태를 바꾸는 코드가 몇 개든 상관없습니다 — #RunBot의 텍스트만 정확하면 pill은 자동으로 맞습니다. 판정 지점이 하나가 되는 구조이고, 이런 화면에서는 이게 반복 코드보다 훨씬 안전합니다.

같은 원리를 시작/정지 버튼에도 적용했습니다. 버튼 두 개를 두는 대신 토글 하나로 합치고, 누를 때 현재 상태를 다시 물어보고 방향을 정합니다.

btn.addEventListener('click', async () => {

  // 화면에 보이는 값이 아니라 백엔드에 다시 묻는다.

  // 다른 기기에서 이미 정지시켰을 수 있다.

  const params    = await callAPI('getParameters');

  const isRunning = (params.RunBot && params.RunBot !== 'OFF');

  const res = isRunning ? await callAPI('stopBot')

                        : await callAPI('startBot');

  log(isRunning ? '봇 정지 요청' : '봇 시작 요청');

  await reload();

});

버튼을 누른 시점에 화면의 값을 믿지 않고 다시 조회하는 것이 핵심입니다. 팝업은 열어 둔 채 몇 분이 지날 수 있고, 그 사이 다른 기기에서 상태가 바뀌었을 수 있습니다. 이걸 안 하면 "정지를 눌렀는데 시작됐다"가 실제로 일어납니다.

MV3에서 걸렸던 것 — 인라인 스크립트 금지

기존 웹앱 화면을 확장으로 옮길 때 가장 크게 걸린 것이 CSP였습니다. MV3는 인라인 <script>onclick="" 속성을 전부 막습니다. 웹앱 HTML에는 그런 코드가 곳곳에 있었고, 붙여넣자마자 조용히 아무것도 동작하지 않았습니다.

해결은 기계적입니다. 인라인 스크립트를 전부 걷어내 popup.js 한 파일로 모으고, onclick 속성은 addEventListener로 바꿉니다. 지루하지만 결과적으로 마크업과 동작이 분리되어 코드가 나아졌습니다.

옮기면서 지킨 원칙이 하나 있습니다. 입력 필드의 name 속성을 하나도 바꾸지 않았습니다. 백엔드는 폼 데이터를 name 기준으로 프로퍼티에 매핑하므로, 여기를 손대면 서버 쪽 파싱과 저장된 값이 전부 어긋납니다. UI는 전면 개편하면서 계약은 그대로 둔 셈이고, 덕분에 백엔드는 한 줄도 고치지 않았습니다.

비밀번호 필드라는 최소한의 예의

계좌 설정 화면에는 API 키와 시크릿이 들어갑니다. 이 둘은 type="password"로 두었습니다.

<input type="password" name="APIKey"    autocomplete="off" />

<input type="password" name="APISecret" autocomplete="off" />

암호화가 아닙니다. 화면 공유나 어깨너머로 새는 것을 막을 뿐입니다. 그래도 팝업은 언제 어디서 열릴지 모르는 화면이라 이 정도는 기본값이어야 합니다. 실제 보관은 백엔드의 사용자별 프로퍼티에서 이뤄지고, 확장은 값을 들고 있지 않습니다.

정리

  • 확장 팝업의 값은 "항상 한 클릭 거리" — 대신 400px라는 제약이 기능을 골라 준다
  • MV3 권한은 안 넣는 것이 실력 — 팝업 안에서는 storage 없이 localStorage가 동작한다
  • 백엔드 호출은 래퍼 하나로 — 배포 URL이 한 줄에만 있으면 재배포가 두렵지 않다
  • 테마는 파싱 시점에 즉시 적용해야 깜빡이지 않는다
  • 상태 표시는 직접 고치지 말고 MutationObserver로 따라가게 — 판정 지점을 하나로
  • 토글은 누를 때 백엔드에 다시 물어본다 — 화면의 값은 오래됐을 수 있다
  • MV3로 옮길 때 인라인 스크립트를 전부 걷어내되, name 계약은 건드리지 않는다

다음 글에서는 이 확장을 크롬 웹스토어에 실제로 올리고 심사를 통과한 과정을 다룹니다.

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

댓글

이 블로그의 인기 게시물

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

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

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