1인 회사 시스템 build

조용한 실패가 제일 비싸다 — 에러 핸들링

고장이 아니라 각자 규칙대로 정상 동작해서 소리 없이 멈추는 실패 세 가지와, best-effort 부가기능도 실패를 반드시 로그로 남기는 방어법을 정리합니다.


지난 몇 달 동안 저를 가장 오래 붙잡았던 버그들은 하나같이 화면에 빨간 글씨를 띄우지 않았습니다. 프로그램은 멈추지 않았고, 로그에도 아무 흔적이 없었습니다. 그냥 해야 할 일을 조용히 안 하고 있었습니다. 고장 난 게 아니라, 각자 자기 규칙대로 정상 동작한 결과였습니다. 그래서 더 비쌌습니다. 소리를 내며 죽는 실패는 5분이면 잡지만, 소리 없이 멈춘 실패는 한 달 뒤에야 눈치챕니다.

한 달간 한 번도 안 돈 백업

가장 뼈아팠던 건 DB 백업이었습니다. 매일 한 번 돌기로 해둔 백업이 한 달 내내 단 한 번도 실행되지 않았습니다. 그런데 저는 그 사실을 한 달 동안 몰랐습니다. 에러도, 알림도 없었으니까요.

크론 자체는 정상이었습니다. 예정된 시각에 잘 실행됐습니다. 문제는 백업 명령이 안에서 실패했는데, 그 실패가 어디에도 로그로 남지 않았다는 점입니다. 크론은 “나는 시켰다”는 입장이었고, 스크립트는 실패한 채로 조용히 끝났습니다. 성공했다는 증거도, 실패했다는 증거도 없으니 아무도 이상을 느낄 수 없었습니다.

여기서 배운 건 두 가지입니다. 하나, 실패는 반드시 눈에 보이는 곳에 남겨야 합니다. 둘, 성공했다는 신호(heartbeat)를 따로 남겨야 합니다. “매일 성공 기록이 찍혀야 정상”이라는 기준을 세워두면, 기록이 없다는 사실 자체가 알림이 됩니다. ‘아무 일도 안 일어남’을 감지하는 장치가 없으면, 아무 일도 안 일어나는 상태는 영원히 정상처럼 보입니다.

첫 바퀴에서 끝나버린 루프

두 번째는 셸 스크립트였습니다. 서버 목록을 한 줄씩 읽어 각 서버에 ssh로 접속하는, 흔한 while read 루프였습니다. 그런데 이 루프가 목록의 첫 줄만 처리하고 끝나버렸습니다.

원인은 stdin이었습니다. while read는 stdin에서 목록을 한 줄씩 읽습니다. 그런데 루프 본문에서 ssh처럼 stdin을 읽는 명령을 부르면, ssh가 남은 목록을 통째로 삼켜버립니다. read 입장에서는 더 읽을 게 없으니 루프가 첫 바퀴에서 끝납니다. 죽은 게 아니라 정상 종료라, 에러가 없습니다. 이게 이 실패의 가장 고약한 부분이었습니다.

해법은 명령의 stdin을 루프의 stdin과 떼어놓는 것이었습니다.

# 방법 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

셋 다 결국 “명령이 내 목록을 먹지 못하게 한다”는 같은 이야기입니다.

방어하려던 코드가 단서를 죽였다

세 번째는 홈페이지 연락 폼이었습니다. 문의가 들어오면 메일로 알림을 받으려고 Google Apps Script의 MailApp을 붙였는데, 메일이 오지 않았습니다. 권한 요청 창도, 에러도 없었습니다.

코드를 열어보고 나서야 이해했습니다. 메일 보내는 부분이 try/catch로 감싸여 있었는데, catch가 실패를 아무 로그도 없이 삼키고 있었습니다. 게다가 수신 주소가 비어 있으면 조용히 return 하도록 짜여 있었습니다. 두 방어 장치가 겹쳐서, 왜 메일이 안 오는지 알아낼 단서를 전멸시킨 겁니다. 잘못되지 않게 하려고 넣은 코드가, 잘못됐을 때 아무것도 말해주지 않는 코드가 되어 있었습니다.

빈 catch는 그래서 위험합니다. 예외를 잡아 삼키는 순간, 그 실패는 이 세상에서 사라집니다. 잡을 거라면 최소한 로그는 남겨야 합니다. 조용히 return 하는 분기도 마찬가지로, “왜 아무 일도 안 했는지”를 한 줄이라도 남겨두면 디버깅은 완전히 다른 난이도가 됩니다.

정리하며

세 사건의 공통점은 명확합니다. 아무것도 고장 나지 않았고, 모두가 자기 규칙대로 정상 동작했으며, 그래서 소리가 나지 않았습니다. 제가 지금 지키려는 기준은 네 가지입니다.

  • best-effort 부가기능이라도 실패는 반드시 로그로 남긴다.
  • 빈 catch를 금지한다. 삼킬 거면 최소한 한 줄이라도 기록한다.
  • 성공했다는 신호(heartbeat)를 따로 남긴다.
  • ‘아무 일도 안 일어남’을 감지하는 알림을 둔다.

소리 내며 죽는 실패는 오히려 친절합니다. 진짜 비싼 건 아무 말 없이 멈춰 있는 쪽입니다.


이런 1인 개발 회고와 시스템 운영 기록을 이어서 받아보고 싶으시다면, 뉴스레터를 구독해 주세요. 새 글이 올라올 때 조용히 한 통 보내드립니다.

자주 묻는 질문

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

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

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

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

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

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

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