블로그 인사이트

에러 없이 멈춘 크론 백업·while read 루프·Apps Script 메일 알림, 실패를 로그로 남기는 법

크론 백업, 셸 while read 루프, Apps Script 메일 알림이 에러 한 줄 없이 멈췄던 세 가지 사례입니다. 각각 왜 조용했는지와 실패 로그·성공 신호(heartbeat)로 막는 방법을 정리합니다.

혼자 서버와 자동화를 운영하면서 제일 오래 붙잡혀 있던 문제 세 개는 모두 에러를 내지 않았습니다. 매일 돌아야 할 DB 백업은 한 달 넘게 한 번도 안 돌았고, 큐를 처리하는 셸 워커는 몇 건을 쌓아도 한 건만 하고 끝났고, 홈페이지 연락 폼의 알림 메일은 45일 동안 한 통도 안 왔습니다. 세 경우 모두 프로그램은 자기 규칙대로 정상 종료했고, 로그에는 아무것도 없었습니다.

찾는 답부터 적으면 이렇습니다. 실패한 자리에는 이유를 한 줄이라도 로그로 남기고, 아무것도 안 하고 끝나는 분기에도 왜 건너뛰었는지 남기고, 정기 작업은 성공했다는 신호를 따로 남겨서 그 신호가 끊기면 알림이 오게 합니다. 아래는 세 사례가 각각 왜 조용했는지와 어떻게 막는지입니다.

에러 없이 멈춘 세 사례와 각각 막는 장치를 나란히 보여주는 그림

크론 백업이 한 달 동안 안 돌았는데 알림이 없었던 이유

로컬 DB를 실수로 날릴 뻔한 날, 처음으로 백업이 제대로 돌고 있는지 확인하러 갔습니다. 매일 새벽에 돌도록 등록해 둔 백업이 한 달 넘게 한 번도 실행되지 않은 상태였습니다.

스케줄 항목도 있었고 스크립트 파일도 있었습니다. 서버를 한 번 다시 구성하면서 스케줄 설정을 옮겼는데, 스크립트 안에서 쓰는 절대 경로 하나가 바뀐 환경에는 없는 경로였습니다. 그래서 스크립트는 시작하자마자 끝났고, 그 실패는 어디에도 남지 않았습니다. 크론 쪽에서는 시킨 대로 실행했으니 할 일을 한 셈이었습니다.

당시 알림은 “실패하면 알려준다” 방식이었습니다. 이 방식은 실패를 감지하는 코드가 실행돼야 울립니다. 스크립트가 그 지점까지 가지도 못하고 끝나면 신호가 하나도 없고, 신호가 없는 상태가 성공처럼 보입니다.

그래서 감시 방향을 바꿨습니다. 작업이 성공으로 끝나는 자리에서 “마지막 성공 시각”을 남기고, 그 시각이 작업 주기를 넘겨도 갱신되지 않으면 알림을 보냅니다. 이미 있던 잡 실행 기록과 텔레그램 알림 채널에 붙였기 때문에 새로 쓴 코드는 많지 않습니다. 개념으로는 이 한 줄입니다.

// 정기 작업이 성공으로 끝나는 자리에서
heartbeat.success("db-backup", intervalMinutes = 1440)  // 하루 주기

db-backup 은 하루(1440분) 주기라서 그 시간이 지나도록 다음 성공 신호가 없으면 감시 쪽이 알림을 보냅니다. 스크립트가 아예 실행되지 않아도, 중간에 조용히 끝나도 똑같이 잡힙니다. 판정 시간은 주기보다 조금 넉넉하게 잡아야 합니다. 정확히 24시간에 걸면 몇 분 늦게 끝난 날에도 알림이 와서, 알림을 안 보게 됩니다.

외부 서비스로 같은 구조를 만들 수도 있습니다. Healthchecks.io 같은 서비스는 작업이 끝날 때 정해진 주소를 한 번 호출하게 하고, 호출이 끊기면 알려주는 방식입니다. 알림 채널이 이미 있으면 직접 붙이는 쪽이 의존이 하나 덜 늘어납니다. 이 사건과 heartbeat 구성은 한 달 동안 조용히 안 돌던 백업에 더 자세히 적었습니다.

while read 루프가 첫 줄만 처리하고 정상 종료하는 이유

두 번째는 크론으로 도는 셸 워커였습니다. 큐에 쌓인 항목을 한 줄씩 읽어 각 항목마다 AI CLI(Codex)를 부르는 while read 루프였는데, 큐에 몇 건을 쌓아도 늘 첫 건만 처리하고 끝났습니다. 에러는 없었고 종료 코드도 0이었습니다. 밤새 돌라고 둔 워커가 아침에 일감 하나만 끝내 둔 걸 보고서야 알았습니다.

원인은 stdin입니다. while read 는 stdin에서 한 줄씩 읽는데, 루프 안에서 부른 명령도 따로 지정하지 않으면 같은 stdin을 물려받습니다. ssh나 대화형 CLI, AI CLI처럼 입력을 읽을 수 있는 명령은 남은 줄을 끝까지 읽어 버립니다. 다음 바퀴에서 read 가 읽을 게 없으니 루프는 정상 종료합니다.

cat 하나로 똑같이 재현됩니다.

printf 'a\nb\nc\n' > queue.txt

while read line; do
  echo "처리 시작: $line"
  cat > /dev/null      # stdin을 통째로 읽어 버림
done < queue.txt

“처리 시작: a” 만 찍히고 끝납니다. cat </dev/null > /dev/null 로 바꾸면 a, b, c가 모두 찍힙니다.

고치는 방법은 셋이고, 모두 명령이 루프의 입력을 못 읽게 하는 방식입니다.

# 방법 1: 명령의 stdin을 막는다
while read host; do
  ssh "$host" 'uptime' </dev/null
done < hosts.txt

# 방법 2: ssh는 아예 -n 옵션이 있다
while read host; do
  ssh -n "$host" 'uptime'
done < hosts.txt

# 방법 3: 목록을 별도 FD로 읽는다
while read -u 3 host; do
  ssh "$host" 'uptime'
done 3< hosts.txt

입력을 먹는 명령이 하나뿐이면 방법 1이 제일 단순합니다. 실제 워커도 Codex 호출 뒤에 </dev/null 하나를 붙여서 고쳤습니다. 루프 안에서 부르는 명령이 여러 개라 어느 쪽이 범인인지 모르겠으면 방법 3으로 루프 입력을 아예 다른 번호에 두는 편이 안전합니다.

의심할 신호는 “에러 없이, 항상 정확히 한 바퀴”입니다. 이 증상이면 큐를 만드는 쪽보다 루프 안에서 부르는 명령부터 보는 게 빠릅니다. 원인과 해법 비교는 셸 while read 루프가 한 건만 처리하고 멈추는 이유에 따로 정리했습니다.

Apps Script MailApp 알림 메일이 에러 없이 안 오는 이유

세 번째는 홈페이지 연락 폼입니다. 폼 내용은 Google Apps Script로 구글 시트에 쌓고, 문의가 들어오면 MailApp으로 알림 메일을 받게 해 두었습니다. 시트에는 문의가 잘 쌓였는데 메일은 오지 않았습니다. 권한 요청 창도, 에러도 없었습니다.

처음에 원인을 못 좁힌 건 제 코드 때문이었습니다. 메일 보내는 부분을 try/catch로 감싸고 catch에서 아무 로그도 안 남겼고, 받는 주소가 비어 있으면 조용히 return 하게 짜 두었습니다. 폼 수집이 메일 때문에 깨지지 않게 하려고 넣은 방어 두 개가 겹쳐서, 왜 메일이 안 가는지 알 단서가 하나도 안 남았습니다. 그때는 console.error 로 로그를 남기게 바꿔 두고, 원인은 못 찾은 채 미뤄 두었습니다.

원인은 6월 12일부터 7월 27일까지, 45일 뒤에 찾았습니다. 같은 구조(Apps Script + MailApp)로 만든 다른 사이트에서는 메일이 잘 오고 있었고, 두 코드를 나란히 놓고 보니 다른 건 한 줄이었습니다. 잘 되는 쪽은 받는 주소를 코드에 적어 두었고, 제 쪽은 주소를 비워 두고 Session.getEffectiveUser().getEmail() 로 실행하는 사람 주소를 찾게 해 두었습니다. 익명으로 호출할 수 있게 열어 둔 웹앱에서는 이 값이 빈 문자열로 돌아왔습니다. 그래서 “받을 주소 없음” 분기로 들어가 메일을 건너뛰고 정상 종료했습니다. 6월에 처음 세운 가설이 이것이었는데, 그때는 증명할 방법이 없어서 묻혀 있었습니다. Session 클래스가 어떤 계정을 돌려주는지는 Apps Script 공식 문서에서 볼 수 있습니다.

건너뛰는 분기와 catch에 이유를 남기는 형태는 이 정도면 됩니다. 실행 기록에 “받는 주소 없음, 메일 건너뜀” 같은 줄이 남아 있으면 원인을 좁히는 데서 시작할 수 있습니다.

function notify(to, subject, body) {
  if (!to) {
    console.warn('받는 주소 없음, 메일 건너뜀');
    return;
  }
  try {
    MailApp.sendEmail(to, subject, body);
  } catch (e) {
    console.error('메일 발송 실패', e);
  }
}

메일은 부가 기능이라 실패해도 폼 수집을 막지 않게 짠 것(best-effort)은 그대로 두고, 실패했다는 사실과 이유만 로그로 남기게 바꾸면 됩니다. 부가 기능이라 실행을 계속하는 것과 실패를 아무 데도 안 남기는 것은 다른 일입니다. Apps Script에서 console 로 남긴 로그는 편집기의 실행 기록에서 실행 단위로 볼 수 있습니다.

하나 더 알아 둘 것이 있습니다. “시트에 행이 추가되면 알림” 같은 구글 시트 자체 알림 규칙은 사람이 직접 편집하거나 구글 폼으로 제출된 경우에만 울리고, 스크립트가 추가한 행에는 울리지 않습니다. 이 구조에서 메일을 받으려면 스크립트에서 직접 보내야 합니다.

조용한 실패를 막는 네 가지 점검

세 사건에서 나온 점검을 막는 경우별로 묶으면 이렇습니다. 마지막 줄은 세 사건 밖에서 보탠 항목입니다.

점검막는 경우이 글의 사례
catch에서 로그 남기기예외를 삼켜서 이유가 사라짐메일 발송 실패
건너뛰는 분기에도 로그할 일이 없다고 판단하고 조용히 종료받는 주소 빈 문자열
성공 신호(heartbeat)실행 자체가 안 되거나 중간에 끝남한 달 안 돈 백업
처리 건수 확인일부만 하고 정상 종료한 건만 도는 루프

한 건만 도는 루프는 heartbeat로도 잡히지 않습니다. 루프가 매번 정상 종료했기 때문입니다. 이런 작업은 “몇 건 들어와서 몇 건 처리했는지”를 로그에 같이 남겨 두면 넣은 수와 처리한 수가 다를 때 눈에 띕니다.

자주 묻는 질문

셸 while read 루프가 첫 바퀴만 돌고 끝나는 이유가 뭔가요?

루프 본문에서 ssh나 일부 CLI처럼 stdin을 읽는 명령을 부르면, 그 명령이 read가 읽어야 할 나머지 목록을 통째로 삼켜버립니다. 그래서 read가 더 읽을 게 없어 루프가 첫 바퀴에서 정상 종료합니다. 죽은 게 아니라 정상 종료라 에러도 없습니다.

while read 루프 안에서 ssh 때문에 목록이 잘리는 걸 어떻게 막나요?

명령의 stdin을 루프의 stdin과 분리하면 됩니다. 명령 뒤에 </dev/null을 붙이거나, ssh라면 -n 옵션을 쓰거나, exec 3<로 목록을 별도 파일 디스크립터에서 읽게 하면 명령이 목록을 삼키지 못합니다.

best-effort 부가기능인데도 실패 로그를 남겨야 하나요?

남겨야 합니다. 부가기능이라 실패해도 본류를 막지 않는 건 맞지만, 실패를 아무 데도 안 남기면 나중에 왜 안 됐는지 추적할 단서가 사라집니다. 실행을 계속하되 실패는 반드시 로그로 남기는 것이 best-effort의 올바른 구현입니다.

#에러 핸들링#로깅#셸 스크립트#1인 개발#모니터링