메일 대량 발송 자동화 — 시트 명단으로 개인화 메일 보내기
시트에 정리한 명단으로 이름·금액이 들어간 개인화 메일을 한 번에 보내는 메일 머지를 앱스 스크립트로 만듭니다. 일일 발송 한도, 테스트 발송, 발송 기록 남기기, 권한 승인 화면까지 다룹니다.
메일 머지, 왜 테스트 발송부터인가
3장에서는 중복과 빈칸을 정리해 믿고 쓸 수 있는 명단 시트를 만들었습니다. 이번에는 그 명단으로 한 사람 한 사람에게 이름과 금액이 들어간 메일을 보냅니다. 이렇게 시트 한 줄을 메일 한 통으로 바꾸는 방식을 메일 머지(mail merge)라고 부릅니다.
메일은 보내고 나면 취소할 수 없습니다. 그래서 이 장의 코드는 기능보다 안전장치를 먼저 갖췄습니다. 처음에는 내 주소로만 2통을 보내고, 확인이 끝난 뒤에 실제 발송으로 바꿉니다.
시트 구성과 흐름
예제는 동호회 회비 안내 메일입니다. 시트는 네 개 열로 짜고, 첫 줄은 제목 행입니다.
| 열 | 내용 | 채우는 사람 |
|---|---|---|
| A | 이름 | 직접 입력 |
| B | 이메일 | 직접 입력 |
| C | 미납 금액 | 직접 입력 |
| D | 발송 시각 | 스크립트가 자동 기록 |
D열이 이 장의 핵심입니다. 메일을 보낸 행에는 발송 시각이 남고, 다음 실행 때 D열이 차 있는 행은 건너뜁니다. 중간에 멈춰도, 같은 버튼을 다시 눌러도 같은 사람에게 두 번 가지 않습니다.
개인 정보는 가짜로 바꿔서 AI에게
AI에게 코드를 부탁할 때 실제 명단을 붙여 넣을 필요는 없습니다. 열 구성만 알려 주면 되니 이름·주소·금액을 모두 지어낸 예시 3줄로 충분합니다. 회원 명단이 외부 서비스로 나가는 일은 처음부터 만들지 않는 편이 낫습니다.
요청 프롬프트와 코드
AI에게 보낼 요청
Claude나 ChatGPT에 아래 문장을 그대로 붙여 넣습니다.
구글 시트 앱스 스크립트로 메일 머지를 만들어 줘. 시트 구성은 A열 이름, B열 이메일, C열 미납 금액, D열 발송 시각이고 1행은 제목이야. 예시 데이터는 아래 3줄이야(전부 가짜). 김철수 / kim@a.com / 30000 이영희 / lee@b.com / 45000 박민수 / park@c.com / 28000 조건: (1) 제목과 본문에 {이름}, {금액}을 넣어 개인화하고 금액은 천 단위 쉼표를 붙여 줘. (2) TEST_MODE가 true이면 받는 사람을 내 메일로 바꾸고 2통만 보내 줘. (3) 실제 발송 때는 D열에 발송 시각을 기록하고, D열이 차 있는 행과 이메일이 빈 행은 건너뛰어 줘. (4) 오늘 남은 발송 한도가 0이 되면 멈추고 로그에 남겨 줘. 코드에 줄마다 한글 주석도 달아 줘.
받은 코드
아래가 받은 코드입니다. 제목과 본문 문구는 본인 상황에 맞게 바꿔 쓰세요.
// A열=이름, B열=이메일, C열=미납 금액, D열=발송 시각(스크립트가 채움)
const TEST_MODE = true; // true면 내 메일로만 보냄
const SUBJECT = '[안내] {이름}님, 이번 달 회비 안내드립니다';
const BODY =
'{이름}님 안녕하세요.\n\n' +
'이번 달 회비 {금액}원이 아직 확인되지 않아 안내드립니다.\n' +
'이미 납부하셨다면 이 메일은 무시해 주세요.\n\n감사합니다.';
function sendMails() {
const sheet = SpreadsheetApp.getActiveSheet();
const rows = sheet.getDataRange().getValues();
const me = Session.getActiveUser().getEmail();
let quota = MailApp.getRemainingDailyQuota();
let sent = 0;
for (let i = 1; i < rows.length; i++) {
const [name, email, amount, sentAt] = rows[i];
if (!email || sentAt) continue; // 주소 없거나 이미 보낸 행은 건너뜀
if (quota <= 0) { Logger.log('오늘 발송 한도 소진, ' + (i + 1) + '행에서 멈춤'); break; }
const fill = text => text
.replace(/{이름}/g, name)
.replace(/{금액}/g, Number(amount).toLocaleString('ko-KR'));
MailApp.sendEmail({
to: TEST_MODE ? me : email,
subject: fill(SUBJECT),
body: fill(BODY),
});
if (!TEST_MODE) {
sheet.getRange(i + 1, 4).setValue(new Date()); // 발송 기록 → 다시 돌려도 중복 발송 없음
}
quota--; sent++;
if (TEST_MODE && sent >= 2) break; // 테스트는 2통만
}
Logger.log('발송 ' + sent + '통, 남은 한도 ' + quota);
}
읽어 둘 곳은 세 군데입니다. to: TEST_MODE ? me : email은 테스트 중에 받는 사람을 내 주소로 바꾸는 줄입니다. if (!email || sentAt) continue는 중복 발송을 막는 줄이고, MailApp.getRemainingDailyQuota()는 오늘 더 보낼 수 있는 수를 알려 주는 함수입니다.
테스트 발송에서 실제 발송까지
1단계: 내 메일로 2통만
시트에 확장 프로그램 > Apps Script로 들어가 코드를 붙여 넣고 저장합니다. TEST_MODE가 true인 상태에서 상단의 실행 버튼으로 sendMails를 돌립니다. 처음 실행하면 권한 승인 화면이 뜨는데, 절차는 아래 절에서 설명합니다.
저희가 모의 시트로 돌려 본 결과는 이렇습니다. 명단에 사람이 여럿 있어도 받은 메일은 내 주소로 2통뿐이었고, 제목에는 각각 김철수님·이영희님이 들어갔으며 본문 금액은 30,000원·45,000원으로 찍혔습니다. 로그에는 '발송 2통, 남은 한도 1'이 남았습니다. 이 단계에서는 D열이 비어 있는 것이 정상입니다.
받은 메일에서 이름·금액·줄바꿈을 눈으로 확인하세요. 문구를 고치고 싶으면 코드 위쪽 SUBJECT와 BODY만 바꾸면 됩니다.
2단계: false로 바꿔 실제 발송
확인이 끝났으면 첫 줄을 const TEST_MODE = false;로 바꾸고 저장한 뒤 다시 실행합니다. 이번에는 B열 주소로 실제 메일이 나가고 각 행의 D열에 발송 시각이 찍힙니다.
한도에 걸렸을 때의 동작
한도 소진 상황은 모의 실행에서 한도를 3으로 낮춰 확인했습니다. 첫 실행에서는 3통을 보낸 뒤 '오늘 발송 한도 소진, 6행에서 멈춤'을 로그에 남기고 끝났습니다. 한도가 다시 열린 뒤 같은 함수를 한 번 더 돌리자 D열이 비어 있던 나머지 1통만 나갔습니다. 이미 보낸 사람에게는 다시 가지 않았습니다.
멈춘 행을 사람이 기억해 둘 필요가 없다는 뜻입니다. 다음 날 같은 버튼을 누르면 이어서 갑니다.
일일 발송 한도 알아 두기
계정별 할당량
구글 공식 할당량 문서(2026년 10월 기준)에서 하루 메일 수신자 수 한도는 개인 계정 100명, Google Workspace 1,500명입니다.
| 항목 | 개인 계정 | Google Workspace |
|---|---|---|
| 하루 수신자 수 | 100 | 1,500 |
| 메시지 한 통당 수신자 수 | 50 | 50 |
| 메시지 한 통당 첨부 개수 | 250 | 250 |
| 메일 본문 크기 | 200KB | 400KB |
한도는 달력 기준 자정이 아니라 첫 요청 후 24시간이 지나야 리셋됩니다. 구글은 이 수치가 사전 고지 없이 바뀔 수 있다고 밝히고 있어, 큰 발송 전에는 위 코드의 로그로 남은 한도를 직접 확인하는 편이 안전합니다.
할당량은 메일 통수가 아니라 수신자 수로 셉니다. 한 통에 3명을 넣어도 3으로 계산됩니다. 이 장의 코드는 한 사람에게 한 통씩 보내므로 둘이 같습니다.
한도 오류가 났을 때의 대화
한도를 모르고 160명짜리 명단을 개인 계정으로 돌리면 100통쯤에서 이런 오류가 뜹니다. 위 코드는 이를 미리 막지만, 다른 AI가 만든 코드에는 한도 확인이 빠진 경우가 많습니다. 그때는 오류 문구 그대로 물어보세요.
메일 발송 스크립트를 돌렸더니 중간에 아래 오류가 나면서 멈췄어. Exception: Service invoked too many times for one day: email. 시트에는 발송 기록이 안 남아서 어디까지 보냈는지 모르겠어. 남은 한도를 확인해서 0이면 멈추고, 보낸 행에는 D열에 발송 시각을 기록하게 고쳐 줘.
AI의 답은 대체로 이렇게 정리됩니다. 원인은 그날 보낼 수 있는 수신자 수를 다 써서 sendEmail이 예외를 던진 것입니다. 고친 부분은 getRemainingDailyQuota()로 시작 전에 남은 한도를 읽고, 0이 되면 break로 빠져나가는 것, 그리고 발송 직후 D열에 시각을 쓰는 것입니다. 이미 오류가 난 날이라면 코드를 고쳐도 한도는 그대로이니 24시간을 기다린 뒤 다시 실행하면 됩니다.
MailApp과 GmailApp, 권한 승인
어느 서비스를 쓸까
앱스 스크립트에서 메일을 보내는 길은 MailApp과 GmailApp 두 가지입니다. 이 장은 MailApp을 썼습니다. 구글 공식 설명에 따르면 MailApp은 Gmail 받은편지함에 접근하지 못하고, 그만큼 재승인 요청도 적습니다. 요청하는 권한 범위는 메일 발송 하나(script.send_mail)입니다.
보내기만 하는 일이면 MailApp으로 충분합니다. 받은편지함을 읽거나 스레드를 다루는 일까지 필요해지면 그때 GmailApp을 검토하세요. 두 서비스의 세부 차이는 공식 레퍼런스에서 확인하는 것이 정확합니다.
권한 승인 화면 따라가기
처음 실행하면 구글이 권한을 승인하라고 묻습니다. 스크립트가 내 메일 계정으로 메일을 보내도 되는지 확인하는 화면입니다. 절차는 다음과 같습니다.
- 권한 검토를 눌러 본인 계정을 고릅니다.
- 확인되지 않은 앱이라는 경고가 뜨면 고급을 누릅니다.
- (프로젝트 이름)(안전하지 않음)으로 이동을 누릅니다.
- 요청하는 권한 목록을 읽고 허용을 누릅니다.
- 승인이 끝나면 함수를 한 번 더 실행해야 실제로 돌아갑니다.
경고가 무섭게 보이지만, 내가 직접 만든 스크립트라 구글이 검증하지 않았다는 뜻입니다. 이 절차는 본인이 만든(또는 코드를 직접 읽어 본) 스크립트에서만 진행하세요. 출처를 모르는 코드에서 같은 화면이 나오면 멈추는 것이 맞습니다. 권한 목록에 메일 발송 외에 낯선 항목이 있으면 AI에게 "이 권한이 왜 필요한가"를 먼저 물어보세요. 화면의 한글 문구는 구글 계정 설정에 따라 조금 다를 수 있습니다.
광고성 메일이라면
회비 안내처럼 업무 안내 메일은 문제가 적지만, 제품이나 서비스를 알리는 광고성 메일이라면 정보통신망법의 광고성 정보 전송 규정이 적용됩니다. 제목 앞의 (광고) 표기, 수신 동의, 수신거부 안내 같은 의무가 있으니 발송 전에 법제처와 KISA 안내서에서 현재 기준을 확인하세요. 이 시리즈는 법률 자문이 아니고, 조문 해석은 직접 확인하셔야 합니다.
정리하며
메일 머지는 시트 한 줄을 메일 한 통으로 바꾸는 일이고, 사고를 막는 장치는 세 가지입니다. TEST_MODE로 내 메일에 2통만 먼저 보내고, D열 발송 시각으로 중복을 막고, 남은 한도가 0이면 멈춥니다. 개인 계정 100명, Workspace 1,500명이라는 하루 한도 안에서 이어서 보내면 됩니다.
다음 챕터에서는 '파일 이름 일괄 변경 — 파이썬으로 PC 폴더, 앱스 스크립트로 드라이브'를 다루겠습니다.
