
옵티스랩 리드 생성 플랫폼의 파트너 대시보드를 운영하다가, GUI 자동화 도구 비용이 한도를 뚫을 페이스라는 걸 발견했다.
하루 동안 코드 기반으로 양방향 동기화를 다시 짰고, 결과적으로 비용이 거의 0이 됐다. 그 과정에서 마주친 구조적 시행착오를 기록한다.

대시보드 운영 한 달째, 자동화 도구 사용량 그래프를 보다가 멈췄다.
15분마다 시트 → 웹사이트 동기화를 돌리고 있었는데, 파트너 수가 늘면서 ops 소비량이 가파르게 올라가고 있었다. 계산해 보니 두 달 안에 연간 한도를 뚫을 페이스였다.
"이거 그대로 가면 안 되겠다."
그날 시작한 작업이 이 글의 주제다.
Make 시나리오의 동작 흐름은 단순하다.
Schedule (15분 주기)
파트너 마스터 시트 읽기
파트너 N명 루프
각 파트너마다 리드 마스터 Search Rows
매칭 결과 처리
웹사이트 업데이트
ops 공식으로 풀면 1 + N × (2 + 평균매칭수).
파트너 N명과 평균 매칭 K건 기준으로 회당 ops가 N에 비례해서 증가한다. 15분 주기로 월 2,880회 돌리니까, 파트너가 늘어날수록 한 달 ops가 폭증하는 구조다.
진짜 문제는 비용보다 구조였다.
자동화 도구의 검색 모듈은 파트너 한 명마다 리드 마스터 전체를 다시 검색한다. 파트너가 30명이면 같은 데이터를 30번 읽는다. 50명이 되면 50번이다.
같은 데이터를 N번 읽는 자동화는 스케일에서 작동을 멈춘다. 비용 문제이기 전에 시트 API 한도가 위협받는다.
해법은 분명했다. 시트 데이터를 한 번만 읽고, 메모리 안에서 N명에게 분배하면 된다. 그게 가능한 환경이 Apps Script다.
처음엔 script.google.com 에 접속해서 새 프로젝트를 만들었다. Google Cloud Console에서 API Key까지 미리 발급받아 뒀다. 멋지게 셋업하려는 마음이었다.
결과적으로 시트 접근 권한 처리가 자꾸 막혔다. OAuth 스코프를 추가해 봐도 동작이 안정적이지 않았다.
30분쯤 헤매다 알게 된 사실은 한 줄로 정리된다.
Apps Script는 시트 메뉴 → 확장 프로그램 → Apps Script 로 진입해야 한다.
이렇게 들어가면 그 시트와 바인딩된 프로젝트가 자동 생성된다. 권한 승인 한 번이면 끝나고, 별도 인증 없이 시트에 바로 접근 가능하다.
외부에서 만든 프로젝트는 별개 자산으로 취급되기 때문에 매번 권한이 걸린다. 시트에서 만든 프로젝트는 그 시트의 일부처럼 동작한다.
벨로그 검색해서 이 글 보고 있는 분 있으면, 이거 하나만 기억하셔도 30분 아낀다.
본격적인 구현 전에 시트 구조부터 정리했다.
두 개의 마스터 시트가 있다.
파트너 마스터 — 파트너 정보, 지역, 상태, 매칭 한도 등
리드 마스터 — 사용자가 신청한 리드와 매칭 정보, 진행 상황
매칭은 두 축으로 동작한다. 실력 기반 매칭(각 최대 2명)과 지역 기반 매칭(각 최대 2명).
javascriptfunction isMatchedToPartner(leadRow, partnerId) {
// 실력 매칭: 단일 파트너 ID 정확 일치
// 지역 매칭: 콤마로 분리된 리스트에 포함되는지
// 둘 중 하나라도 매칭되면 true
}
여기서 흥미로운 사실 하나를 발견했다.
파트너 마스터의 "현재 배정 수" 컬럼 값과 실제 매칭 리드 수가 달랐다.
원인은 추적하지 않았지만, 추정컨대 기존 자동화가 카운트만 업데이트하고 매칭이 해제된 케이스를 반영 못 했던 것 같다.
해법은 카운트 컬럼을 신뢰하지 않는 것이다. Apps Script가 매번 실시간으로 리드 마스터를 훑어서 카운트한다.
데이터의 진실은 리드 마스터의 매칭 컬럼에 있고, 파트너 마스터의 "현재 배정 수"는 유도된 값일 뿐이다.
이걸 발견한 게 작업의 부수입이었다. 카운트 불일치는 운영하면서 기분 나쁜 형태로 드러나는 버그라, 미리 잡아서 다행이었다.
전체 흐름은 단순하다.
[Apps Script]
파트너 마스터 한 번 읽기
리드 마스터 한 번 읽기
↓
파트너별로 매칭 리드 필터링 (메모리 안에서)
↓
페이로드 가공
↓
[WordPress REST API]
토큰 인증
파트너별 user_meta 분배
최신순 정렬 + 월별 카운트 계산
관련 캐시 무효화
핵심은 시트 읽기가 단 두 번이라는 점이다.
그다음은 전부 메모리에서 처리한다. N번 읽던 걸 1번으로 줄인 게 비용 절감의 본질이다.
WordPress 쪽은 단일 엔드포인트 하나로 받는다. 토큰 헤더로 인증한 뒤, 파트너별로 user_meta를 분배한다. 그래프 컴포넌트가 사용하는 월별 카운트는 별도 메타 키로 분리해서 동시에 갱신한다.
처음에는 매칭 데이터만 업데이트했더니, 최신 데이터는 들어왔는데 월별 그래프는 안 갱신되는 버그가 있었다. 메타 키 분리 + 동시 갱신으로 해결.

여기가 설계의 핵심 포인트였다.
파트너가 대시보드에서 진행 상황(전화 상담, 방문 상담, 계약 성사)을 체크하고 저장한다. 이 변경 사항이 리드 마스터 시트로도 반영되어야 한다.
처음에는 저장 시 즉시 시트 호출을 고민했다. 하지만 두 가지 문제가 있다.
그래서 큐 패턴으로 갔다.
파트너 저장
↓
WP 내부 큐에 추가
↓
[1시간 후]
↓
Apps Script가 큐 조회
↓
시트 셀 업데이트
↓
큐 클리어
WordPress 쪽은 옵션 테이블에 큐를 쌓고, 별도 REST 엔드포인트 두 개로 조회와 클리어를 분리했다.
Apps Script 쪽은 큐를 조회한 뒤, 리드 ID → 행 번호 인덱스를 만들고, 해당 셀만 업데이트한다. 끝났으면 클리어 호출.
여기서 race condition을 고민했다.
큐를 조회한 직후, 클리어하기 전 사이에 새 변경 사항이 큐에 추가되면?
이론상 해당 변경 사항이 다음 사이클까지 누락된다. 하지만 1시간 주기에 조회-클리어 사이가 1-2초인 점을 감안하면 실제로 충돌이 발생할 확률은 거의 0이다.
발생해도 다음 사이클에 따라잡힌다.
완벽한 해법은 큐 항목별 PROCESSED 마킹 후 삭제인데, 비용 대비 효과가 적어서 단순화 쪽으로 갔다.
완벽한 일관성보다 운영 가능한 안정성을 택하는 결정, 이런 트레이드오프를 의식적으로 내리는 게 양방향 동기화 설계의 핵심이라는 걸 이번에 다시 확인했다.
마지막으로 두 함수를 묶는다.
javascriptfunction syncBoth() {
try { syncSheetToWP(); } catch (e) { log(e); }
try { applyStatusQueue(); } catch (e) { log(e); }
}
try/catch를 따로 둔 이유는 한쪽이 실패해도 다른 쪽은 돌게 하기 위해서다. 정방향이 실패했다고 역방향까지 멈추면 파트너 입력이 시트에 반영 안 된다.
트리거는 Apps Script 콘솔에서 시간 기반 1시간으로 설정. 끝.
| 항목 | Before | After |
|---|---|---|
| 동기화 주기 | 15분 | 1시간 |
| 시트 API 호출 | 회당 N번 (파트너 수에 비례) | 회당 2번 (고정) |
| 자동화 비용 | 한도 위협 페이스 | 무료 한도 내 |
| 카운트 정확도 | 시트 컬럼 의존 (불일치 발생) | 실시간 재계산 |
동기화 주기를 15분에서 1시간으로 늘렸지만, 파트너 체감 지연은 거의 없다.
대시보드는 실시간 트래커가 아니라 진행 관리 도구라서, 1시간 지연이 문제 되는 시나리오가 없다.
실시간성을 포기한 대가로 운영 안정성과 비용 구조를 얻은 셈이다. 이런 결정은 사용자의 실제 사용 패턴을 알아야 내릴 수 있다.
동기화 구조 다 잡고 나니 시간이 남았다. 그동안 미뤄둔 UX 이슈들을 같이 정리했다.
특정 필드가 비어 있을 때, 일반적인 "데이터 없음" 처리 대신 맥락에 맞는 안내 문구가 필요한 경우가 있다. 예를 들어 "미설정 (계약자 정보로 전달)" 같은 안내.
이때 조건문 순서가 중요하다.
if (특정_타입 && 빈값) → 맥락 안내
if (빈값) → 일반 "데이터 없음"
이 순서가 반대로 되면 일반 처리가 먼저 가로채서, 맥락 안내가 절대 보이지 않는다.
PHP뿐 아니라 모든 분기 처리에 적용되는 원칙이다. 구체적인 조건부터, 일반적인 조건은 마지막.
진행 상황 체크박스 세 개가 모두 체크된 리드의 저장 버튼은 비활성화 + "완료" 라벨로 바뀐다. 새로고침 후에도 유지된다.
별것 아닌 디테일인데, 완료된 항목을 또 누를 일이 없다는 시각적 피드백이 운영 효율을 의외로 많이 높인다.
특정 리드의 "신고" 버튼을 누르면 신고 탭으로 이동하면서, 고객명과 연락처가 자동으로 폼에 채워져야 한다.
여기서 두 번 막혔다.
javascriptlocation.hash = '#report'; // ← 아무 일도 안 일어남
hashchange 이벤트 발생을 기대했지만, 같은 페이지 내 hash 변경만으론 이벤트가 트리거되지 않는 케이스가 있었다. 사이드바 메뉴는 hash가 아니라 click 핸들러로 동작하고 있었다.
해법: 사이드바 메뉴를 직접 클릭하는 것처럼 동작시키기.
javascriptdocument.querySelector('[data-pane="report"]').click();
두 번째 — 폼 필드 값이 안 들어감.
탭은 전환됐는데, 폼 필드에 값을 넣어도 안 들어갔다.
javascriptinput.value = '홍길동'; // ← 화면엔 보이는데 폼 제출하면 빈 값
폼 라이브러리가 React 비슷한 상태 관리를 쓰는 게 원인이었다. value 속성을 직접 바꿔도 프레임워크 내부 상태는 안 바뀐다.
해법은 input / change 이벤트를 디스패치해서 프레임워크가 상태를 갱신하게 하기.
javascriptinput.value = '홍길동';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
탭 전환 직후 폼이 DOM에 렌더링되기를 기다리기 위해 setTimeout 짧게 끼우는 것도 필요했다. 렌더 전에 채우려고 하면 아직 input이 없어서 실패한다.
React/Vue 계열 폼 다룰 때 value 직접 할당이 안 먹는 케이스 — 한 번 겪어두면 다음에 빠르게 대처할 수 있다.
리드가 많아지면서 한 페이지에 다 노출하면 스크롤이 끝없이 길어졌다.
10건씩 끊고 페이지네이션 추가.
여기서 빠지기 쉬운 함정 — 페이지네이션을 DOM 단위로만 처리하면, 탭 필터(전체/대기/완료)와 충돌한다. 페이지 2에서 필터를 바꾸면 보일 데이터가 없는 페이지로 빈 화면이 뜨는 식.
해법은 필터링된 결과를 먼저 만들고, 그 위에 페이지네이션을 얹는 구조.
페이지 상태는 필터가 바뀔 때마다 1페이지로 초기화한다.
작은 디테일인데, 운영 화면에서 빈 페이지를 보는 경험이 사용자 신뢰를 크게 깎는다.
대시보드는 파트너 회원만 접근 가능해야 한다. 일반 회원이 들어오면 403, 비로그인 사용자는 로그인 페이지로 리다이렉트.
WordPress는 기본적으로 모든 로그인 사용자에게 관리자 영역 진입을 허용한다. 파트너 회원이 굳이 워드프레스 관리자 화면을 볼 일은 없으니, 관리자가 아닌 사용자는 관리자 영역 접근 시 홈으로 리다이렉트 처리도 함께 넣었다.
권한 분리는 접근 차단과 리다이렉트 두 축으로 생각해야 한다. 차단만 하면 사용자가 "내가 뭘 잘못한 거지?" 헤매고, 리다이렉트만 하면 보안 구멍이 남는다.
작업하면서 막혔던 지점들 — 검색해서 이 글 들어오신 분 있으면 빠르게 찾아가시라고 모았다.
별도 콘솔에서 따로 만들면 시트 바인딩 권한 문제 발생. 시트 → 확장 프로그램 → Apps Script 가 정답.
Apps Script와 WordPress 양쪽에서 서로 다른 키명을 쓰고 있어서 데이터 미반영 버그를 만들었다. 양쪽 키명을 처음부터 통일해놓는 게 디버깅 비용을 크게 줄인다.
Utilities.formatDate()는 기본 영어 locale이라 "AM/PM"으로 찍히고, 받는 쪽 파서가 "오전/오후"만 인식해서 매칭 실패. 한국어 포맷 헬퍼를 Apps Script에 따로 작성.
remaining > 0 ? remaining : 1 같은 안전장치가 원인. 잔여=0, 매칭=5일 때 데이터가 [5, 1]로 들어가서 83%로 그려졌다. 방어 코드가 오히려 진실을 왜곡한 케이스. 안전장치 제거하고 [5, 0] 그대로 사용. 차트 라이브러리는 0을 잘 다룬다.
매칭 데이터만 업데이트하면 그래프용 월별 카운트는 옛날 데이터인 채로 남는다. 유도된 값을 별도로 보관하는 구조라면, 원본 갱신 시 유도값도 함께 갱신하는 책임이 호출자에게 있다.
location.hash 직접 할당으로는 핸들러가 안 돈다. 메뉴 요소를 .click()으로 직접 호출.
value = ... 만으로 안 됨. input / change 이벤트 디스패치로 프레임워크 상태 강제 갱신.
GUI 자동화 도구는 프로토타이핑 단계에서 강력하다.
Make, Zapier, n8n 같은 도구로 빠르게 검증하는 건 좋다.
하지만 N×M 데이터 처리가 들어가는 순간 비용이 급격히 늘어난다. 검색 모듈이 루프 안에서 호출되는 구조라면, 그 시점에 코드 기반 솔루션으로 갈아탈 준비를 해야 한다.
시트는 데이터베이스가 아니다.
시트 API 한도, 동시 쓰기 충돌, 컬럼 카운트 불일치 — DB라면 안 겪을 문제들이 시트에는 있다. 그래도 Apps Script와 함께 쓰면 작은 규모에선 충분한 백엔드가 된다. 시트 안에서 처리하는 Apps Script는 무료 한도가 매우 넉넉하다.
양방향 동기화는 단방향 두 개로 본다.
한쪽이 실패해도 다른 쪽은 돌게. 큐 패턴을 쓰되, race condition을 완벽히 막으려고 시간 쓰지 말고 허용 가능한 누락 시나리오를 먼저 정의한다. 완벽한 일관성보다 운영 가능한 안정성이 대부분의 경우 더 합리적이다.
작아 보이는 디테일이 시간을 먹는다.
조건문 순서, 이벤트 디스패치, 안전장치가 만든 왜곡, 렌더 대기 시점 — 이런 작은 룰들을 모아둔 노트가 결국 개발 속도를 만든다.
기존 자동화 시나리오 비활성화 (Apps Script 24시간 검증 후)
결제 내역 페이지 신규 구축 (같은 양방향 패턴 재사용)
대시보드 페이지 접근 제어 통합
옵티스랩 파트너 시스템이 비로소 SaaS답게 정리됐다.
ops 사용량이 한도 위협에서 무료 한도 내로, 시트 API 호출이 N번에서 2번으로, 카운트 정확도까지 잡혔다. 부수적으로 UX 폴리시와 보안 구멍을 같이 닫았다.
다음 단계는 결제 내역 페이지다. 같은 양방향 동기화 패턴을 한 번 더 쓰면 되니까, 이번에 정리한 게 자산이 될 것 같다.
긴 글 읽어주셔서 감사합니다.