Google Apps Script로 시트의 연락처를 Brevo에 가져오는 방법
Google Sheet의 연락처를 자동으로 Brevo에 푸시합니다. API 키 저장, 편집 시 실행, 시간 기반 트리거, 사용자 정의 메뉴, 오류 처리가 포함된 완전한 Apps Script. 서버가 필요 없습니다.
팀이 이미 Google Sheet에서 살고 있다면(영업 리드, 이벤트 등록, 파트너 연락처 리스트), 그 데이터를 Brevo로 가져오는 것은 CSV를 내보내고 손으로 다시 가져오는 작업이 필요하지 않습니다. Google Apps Script는 시트를 Brevo의 API에 직접 연결할 수 있게 합니다. 스크립트는 Google의 인프라 내부에서 실행되므로 호스팅하거나 배포하거나 돌볼 것이 없습니다.
이 가이드는 작동하는 스크립트를 살펴봅니다. 시트의 사용자 정의 Sync to Brevo 메뉴 항목, 자동 시간별 트리거, 안전한 API 키 저장, 배치 처리, 그리고 무엇이 일어났는지 알 수 있도록 약간의 구조화된 로깅입니다.
필요한 것
- 연락처가 있는 Google Sheet (행당 하나의 연락처, 헤더 행 먼저)
- Brevo 계정과 API 키 (Settings → SMTP & API → API Keys)
- 연락처를 추가할 Brevo 리스트의 숫자 ID
그게 전부입니다. npm 없음, Python 없음, 서버 없음.
시트 레이아웃
이 가이드의 스크립트는 헤더 행 다음에 행당 연락처 하나를 기대합니다. 열은 헤더 이름으로 Brevo 속성에 매핑됩니다.
| firstName | lastName | company | city | |
|---|---|---|---|---|
| [email protected] | Jane | Doe | Acme | Berlin |
| [email protected] | John | Smith | Globex | Paris |
email은 필수이며 대소문자를 구분하지 않고 일치합니다. 다른 모든 것은 연락처 속성으로 Brevo에 전송됩니다. 사용자 정의 속성(표준의 FIRSTNAME, LASTNAME 외의 모든 것)은 Brevo 계정에 먼저 존재해야 합니다. Contacts → Settings → Contact attributes 아래에서 정의하거나 Brevo API를 통해 정의하세요.
Apps Script 편집기 열기
시트에서 Extensions → Apps Script 를 선택합니다. 빈 Code.gs로 새 탭이 열립니다. 내용을 아래 스크립트로 교체합니다.
전체 스크립트
const BREVO_API_BASE = 'https://api.brevo.com/v3';const BREVO_LIST_ID = 42; // <- the Brevo list to import contacts intoconst SHEET_NAME = 'Contacts'; // <- name of the sheet tab to readconst BATCH_SIZE = 1000; // contacts per import call
/** * Adds a "Brevo" menu to the Sheet so users can run the sync from the UI. * Triggered automatically when the Sheet opens. */function onOpen() { SpreadsheetApp.getUi() .createMenu('Brevo') .addItem('Sync sheet to Brevo', 'syncSheetToBrevo') .addItem('Configure API key', 'configureApiKey') .addToUi();}
/** * Reads every contact row from the sheet, batches them, and sends each batch * to Brevo's import endpoint. Returns a summary string for logging. */function syncSheetToBrevo() { const apiKey = getApiKey_(); if (!apiKey) { SpreadsheetApp.getUi().alert( 'No Brevo API key configured. Run "Configure API key" first.' ); return; }
const contacts = readContactsFromSheet_(); if (contacts.length === 0) { SpreadsheetApp.getUi().alert('No contacts found in the sheet.'); return; }
const batches = chunk_(contacts, BATCH_SIZE); const results = [];
for (let i = 0; i < batches.length; i++) { const result = importBatchToBrevo_(apiKey, batches[i]); results.push(result); Logger.log( `Batch ${i + 1}/${batches.length}: ${result.ok ? 'ok' : 'FAILED'} ` + `(processId=${result.processId || '-'}, status=${result.status})` ); }
const summary = `Sent ${contacts.length} contacts in ${batches.length} batch(es). ` + `Successful: ${results.filter(r => r.ok).length}/${results.length}.`; Logger.log(summary); SpreadsheetApp.getActiveSpreadsheet().toast(summary, 'Brevo sync', 5); return summary;}
/** * Reads the active spreadsheet's "Contacts" tab into an array of * { email, attributes } objects shaped for Brevo's jsonBody. */function readContactsFromSheet_() { const sheet = SpreadsheetApp .getActiveSpreadsheet() .getSheetByName(SHEET_NAME); if (!sheet) { throw new Error(`Sheet tab "${SHEET_NAME}" not found`); }
const range = sheet.getDataRange().getValues(); if (range.length < 2) return [];
const headers = range[0].map(String); const emailColumn = headers.findIndex(h => h.toLowerCase() === 'email'); if (emailColumn === -1) { throw new Error('Sheet must have an "email" column'); }
const contacts = []; for (let i = 1; i < range.length; i++) { const row = range[i]; const email = String(row[emailColumn] || '').trim().toLowerCase(); if (!email || !email.includes('@')) continue; // skip invalid
const attributes = {}; for (let c = 0; c < headers.length; c++) { if (c === emailColumn) continue; const value = row[c]; if (value === '' || value === null) continue; // Brevo convention: ATTRIBUTES ARE UPPERCASE attributes[headers[c].toUpperCase()] = value; } contacts.push({ email, attributes }); } return contacts;}
/** * POSTs a batch of contacts to Brevo's import endpoint. * Returns { ok, status, processId, error }. */function importBatchToBrevo_(apiKey, contacts) { const payload = { jsonBody: contacts, listIds: [BREVO_LIST_ID], updateExistingContacts: true, emptyContactsAttributes: false, };
const response = UrlFetchApp.fetch(`${BREVO_API_BASE}/contacts/import`, { method: 'post', contentType: 'application/json', headers: { 'api-key': apiKey, 'accept': 'application/json', }, payload: JSON.stringify(payload), muteHttpExceptions: true, // we'll inspect status ourselves });
const status = response.getResponseCode(); const body = response.getContentText();
if (status === 202) { const json = JSON.parse(body); return { ok: true, status, processId: json.processId }; } return { ok: false, status, error: body };}
/** * Stores the Brevo API key in script properties — encrypted at rest by Google * and not visible in the source code or to viewers of the sheet. */function configureApiKey() { const ui = SpreadsheetApp.getUi(); const response = ui.prompt( 'Brevo API key', 'Paste your Brevo API key (xkeysib-...). It will be stored in Script Properties.', ui.ButtonSet.OK_CANCEL ); if (response.getSelectedButton() !== ui.Button.OK) return; const key = response.getResponseText().trim(); if (!key.startsWith('xkeysib-')) { ui.alert('That doesn\'t look like a Brevo API key (should start with xkeysib-).'); return; } PropertiesService.getScriptProperties().setProperty('BREVO_API_KEY', key); ui.alert('API key saved.');}
function getApiKey_() { return PropertiesService.getScriptProperties().getProperty('BREVO_API_KEY');}
function chunk_(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out;}전체가 그것입니다. 저장하고 (⌘S / Ctrl+S), 프로젝트 이름을 “Brevo sync” 같은 것으로 지정하고, 시트로 돌아갑니다.
첫 실행
시트를 다시 로드합니다. 새로운 Brevo 메뉴가 상단에 나타납니다.
- Brevo → Configure API key 를 클릭하고
xkeysib-...키를 붙여넣고 OK를 클릭합니다. - Brevo → Sync sheet to Brevo 를 클릭합니다. Google이 처음으로 권한을 요청합니다.
- “스프레드시트 보기 및 관리”: 행을 읽는 데 필요
- “외부 서비스에 연결”: api.brevo.com을 호출하는 데 필요
- 승인합니다. 스크립트가 실행됩니다. 오른쪽 하단의 녹색 토스트가 얼마나 많은 연락처가 나갔는지 알려줍니다.
실패하면 Extensions → Apps Script → View → Logs 를 클릭하여 Brevo의 배치별 상태 코드를 확인하세요. 가장 일반적인 실패는 누락된 사용자 정의 속성으로 인한 400입니다. 아래의 문제 해결 섹션을 참조하세요.
스케줄로 실행
Apps Script 편집기에서: Triggers (왼쪽 사이드바의 시계 아이콘) → Add Trigger.
- 함수 선택:
syncSheetToBrevo - 이벤트 소스: 시간 기반(Time-driven)
- 유형: 시간 타이머(또는 하루에 한 번 동기화하는 일 타이머)
- 간격: 매시간(또는 적절한 것)
저장합니다. Google은 그 빈도로 영원히 함수를 실행하며, 서버 없음, cron 없음, 유지 관리 없음입니다.
모든 셀 변경이 동기화를 트리거하길 원한다면 From spreadsheet → On edit 를 사용할 수도 있습니다. 그것에 주의하세요. 외관상의 편집조차 트리거를 발동시키며, 바쁜 시트에서 Apps Script의 일일 할당량에 빠르게 도달할 수 있습니다. 시간별 시간 트리거가 거의 항상 정답입니다.
알아둘 Apps Script 할당량
무료 Apps Script 계층에는 존중할 가치가 있는 제한이 있습니다.
| 제한 | 값 (무료 계층) |
|---|---|
| 하루 총 실행 시간 | 90분 |
| 단일 실행 시간 | 6분 |
하루 UrlFetchApp 호출 | 20,000 |
UrlFetchApp 페이로드 크기 | 50 MB |
UrlFetchApp 헤더 크기 | 8 KB |
| 사용자별 스크립트별 트리거 | 20 |
일반적인 연락처 동기화의 경우(몇 천 개의 연락처, 시간별), 이 중 어느 것에도 가깝지 않습니다. 주의해야 할 유일한 것은 6분 단일 실행 입니다. 한 번에 수십만 개의 연락처를 동기화하는 경우, 더 작은 청크로 일괄 처리하세요(위 스크립트는 이미 BATCH_SIZE를 통해 이를 수행합니다).
가져오기 비동기적으로 처리
Brevo의 가져오기 엔드포인트는 비동기적입니다. 즉시 processId를 받고, 실제 가져오기는 서버 측에서 실행됩니다. 대부분의 시트 동기화의 경우 이것으로 충분합니다. 발사하고 잊으면 되며, 각 배치가 완료될 때 Brevo가 요약을 이메일로 보냅니다.
가져오기가 정말로 완료될 때까지 블로킹 하고 싶다면, 프로세스 상태 엔드포인트를 폴링하세요.
function waitForImport_(apiKey, processId, timeoutMs = 5 * 60 * 1000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const resp = UrlFetchApp.fetch(`${BREVO_API_BASE}/processes/${processId}`, { headers: { 'api-key': apiKey }, muteHttpExceptions: true, }); if (resp.getResponseCode() === 200) { const status = JSON.parse(resp.getContentText()).status; if (status === 'completed' || status === 'failed') return status; } Utilities.sleep(5000); // 5s between checks } return 'timeout';}Utilities.sleep은 블로킹 대기의 Apps Script 동등 솔루션입니다. 너무 오래 자지 마세요. 실행당 총 6분이 있습니다.
알림 웹훅 추가
폴링보다 깔끔한 패턴: Apps Script를 Web App 으로 배포하고 그 URL을 notifyUrl로 전달합니다. 가져오기가 완료되면 Brevo가 거기로 POST합니다.
// Add to Code.gsfunction doPost(e) { const payload = JSON.parse(e.postData.contents); Logger.log(`Brevo import ${payload.processId} finished: ${payload.status}`); // optionally: write the result back to a "Sync log" tab in the sheet return ContentService.createTextOutput('ok');}배포: Deploy → New deployment → Web app, “Who has access”를 Anyone 으로 설정하고, 결과 URL을 복사하고, 가져오기 페이로드에서 notifyUrl로 전달합니다.
payload.notifyUrl = 'https://script.google.com/macros/s/AKfy.../exec';이제 Brevo는 결과를 시트의 자체 스크립트로 다시 게시합니다. 외부 인프라 없이 루프를 닫습니다.
문제 해결
400 Bad Request with error: "Attribute X not found": 시트의 열이 Brevo가 모르는 속성에 매핑됩니다. 시트 열 이름을 기존 속성과 일치하도록 변경하거나 Brevo에서 속성을 만드세요(Contacts → Settings → Contact attributes).
401 Unauthorized: API 키가 잘못되었거나 만료되었습니다. Configure API key 를 다시 실행하고 Brevo의 대시보드에서 새 키를 붙여넣으세요.
429 Too Many Requests: Brevo의 속도 제한에 도달했습니다. 가져오기 엔드포인트는 분당 약 30회 호출을 허용합니다. 적극적으로 일괄 처리하는 경우 루프의 배치 사이에 Utilities.sleep(2000)을 추가하세요.
스크립트가 스케줄로 조용히 실행되지 않습니다: Apps Script 편집기에서 Triggers 를 확인하세요. 트리거가 반복적으로 실패하면 Google이 비활성화합니다. 트리거를 클릭하여 실패 이유를 확인하세요. 보통 다시 권한을 부여할 수 있는 권한 문제입니다.
Brevo 메뉴가 나타나지 않음: onOpen은 시트를 처음부터 (다시) 열 때만 실행됩니다. 브라우저 탭을 다시 로드하세요.
권한 팝업이 계속 다시 나타남: 스크립트의 범위를 편집했을 가능성이 있습니다(새 Google 서비스 추가). Apps Script는 필요한 권한이 변경될 때마다 권한 부여를 다시 요청합니다. 편집기에서 함수를 한 번 실행하여 프롬프트를 트리거하고 승인하세요.
이것이 Zapier와 친구들을 이기는 이유
Apps Script는 무료이고, Google의 인프라 내부에 살며, 시트의 데이터에 직접 접근할 수 있습니다. 행별 이벤트 발생 없음, 작업당 가격 없음, Google의 할당량 외의 속도 제한 없음(이런 종류의 작업에는 충분합니다). 단점: 작은 코드 조각을 작성하고 유지하는 데 전념하고 있습니다. 연락처 동기화의 경우 약 100줄이며 기본적으로 진행 중인 유지 관리가 없습니다.
이것을 일별 트리거와 영업팀이 이미 업데이트하는 시트와 짝지으면, 반복 작업이 없는 Brevo로의 연락처 파이프라인이 있습니다.