AI 에이전트 만들기, 제가 매일 돌리는 에이전트 하나를 그대로 뜯어봤습니다 ⏰

“AI 에이전트 만들기”로 검색하면 도구 비교글이 대부분입니다. 어떤 프레임워크가 좋은지, 어떤 모델이 똑똑한지. 근데 제가 8월 초부터 매일 새벽 6시에 혼자 깨어나 맥미니 상태를 점검하고 텔레그램으로 이상이 있을 때만 알려주는 놈을 굴려보니, 정작 시간 대부분을 쓴 건 지능이 아니라 “정해진 시간에 정말로 깨어나는가”였습니다.
에이전트는 사실 조각 세 개뿐입니다
거창하게 들리지만 뜯어보면 단순합니다. 트리거, 도구, 산출물. 이 셋을 코드로 엮으면 그게 에이전트입니다.
제가 굴리는 맥미니 헬스체크 에이전트를 예로 들면 이렇습니다.
트리거는 macOS의 launchd입니다. 매일 06:00에 딱 한 번 깨어나도록 예약해뒀습니다. 그날그날 사람이 시켜서 도는 게 아니라, 시간이 되면 스스로 시작합니다.
도구는 딱 여섯 개만 허용했습니다. 파일을 읽고(Read), 패턴으로 찾고(Glob·Grep), 명령을 실행하고(Bash), 결과를 쓰는(Write·Edit) 것까지. 인터넷 검색이나 외부 API 호출은 이 에이전트 역할에 아예 안 줬습니다. 헬스체크는 이 맥미니 안에서 끝나는 일이라 그럴 필요가 없거든요. 도구를 다 열어주지 않고 딱 필요한 만큼만 화이트리스트로 묶어두는 게, 나중에 엉뚱한 짓을 할 여지를 원천적으로 줄이는 방법이었습니다.
산출물은 정해진 형식의 보고서 한 장입니다. launchctl list로 등록된 예약 작업이 몇 개 살아 있는지, 최근 로그에 에러가 몇 건 쌓였는지, 디스크·메모리·네트워크 상태가 어떤지 훑고, 이상이 없으면 한눈에 요약 한 줄, 이상이 있으면 위험도별로 나눠 적습니다. 그리고 끝나면 로그 파일에 “오늘 실행함, 이슈 N건”을 한 줄 남깁니다.
여기서 하나 더 신경 쓴 규칙이 있습니다. 로그에 에러 흔적이 있어도 바로 이슈로 올리지 않습니다. 로그는 그 순간의 코드를 가리키지, 지금 코드를 가리키지 않으니까요. 그래서 에러가 난 소스 파일의 마지막 수정 시각과 로그의 마지막 기록 시각을 대조해서, 소스가 로그보다 나중에 바뀌었으면 실제로 코드를 열어 확인한 뒤에만 이슈로 씁니다. 실제로 8월 11일 보고서에서 이 규칙 덕분에 이미 고쳐진 버그 두 건을 이슈로 올리지 않고 걸러낸 적이 있습니다.
새벽마다 조용히 멈춰 있었습니다 🤯

트리거·도구·산출물, 이 구조 자체는 사실 일찍 짰습니다. 문제는 실제로 돌려보니 새벽 6시 로그가 며칠씩 텅 비어 있었다는 것이었습니다. 에러가 난 것도 아니고, 그냥 아무 일도 없었던 것처럼 조용했습니다.
원인은 launchd가 작업을 돌리는 세션 자체에 있었습니다. macOS에서 로그인 화면 뒤의 GUI 세션(Aqua)에서 실행되면, Claude CLI가 API 응답을 못 받고 그대로 멈춰버리는 문제가 있었습니다. 터미널을 직접 열어서 같은 명령을 치면 멀쩡히 도는데, launchd가 조용히 백그라운드로 돌리면 응답 없이 계속 대기 상태로만 남는 겁니다. 로그에는 에러 한 줄 안 남으니 겉으로는 “그냥 실행이 안 된 날”로 보였습니다.
문제는 에이전트가 똑똑한지가 아니라, 예정된 시간에 정말로 깨어나는지였습니다.
해결은 실행 경로를 바꾸는 것이었습니다. launchd가 직접 Claude CLI를 부르는 대신, 같은 컴퓨터에 SSH로 접속해서(ssh localhost) 그 세션 안에서 CLI를 실행하게 했습니다. SSH 세션은 GUI 세션이 아니라서, 같은 컴퓨터인데도 멈추지 않고 끝까지 완주합니다. 지금은 launchd → SSH-localhost 래퍼 스크립트 → Claude CLI 순서로 한 단계를 더 거칩니다. 우회로를 하나 더 놓은 셈인데, 이거 하나로 새벽마다 멈춰 있던 문제가 없어졌습니다.
겹쳐 돌지 않게, 너무 오래 돌지 않게
걸린 게 하나 더 있었습니다. 실행 중에 같은 역할이 또 예약돼서 겹쳐 돌면 어떻게 되느냐는 문제였습니다. 지금은 실행 시작할 때 락 디렉토리를 하나 만들어서(디렉토리 생성은 원자적이라 두 프로세스가 동시에 성공할 수 없습니다), 이미 만들어져 있으면 새 실행이 기다리게 했습니다. 다만 무한정 기다리면 안 되니 30분을 기다려도 안 풀리면 그 실행은 건너뛰고, 락을 쥔 프로세스가 이미 죽어 있으면(PID 확인) 오래된 락으로 보고 바로 정리합니다.
그리고 혹시 어딘가에서 멈춰버릴 경우를 대비해 90분짜리 워치독을 같이 겁니다. 90분이 지나도 안 끝나면 강제 종료합니다. 매일 새벽에 도는 작업이 하루 종일 켜져 있는 걸 막는 최소한의 안전장치입니다.
따라 하기
같은 구조로 매일 도는 에이전트를 만들고 싶다면, 실제로 거친 순서 그대로입니다.
1. 트리거 등록 — 언제 깨어날지부터 정하기
# ~/Library/LaunchAgents/com.<계정명>.<routine이름>.plist
<key>StartCalendarInterval</key>
<dict>
<key>Hour</key><integer>6</integer>
<key>Minute</key><integer>0</integer>
</dict>
- 코드보다 이걸 먼저 정합니다. 언제 깨어날지가 없으면 나머지는 다 의미가 없습니다.
2. 예약 시간까지 기다리지 말고 지금 강제 실행
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.<계정명>.<routine이름>.plist
launchctl kickstart gui/$(id -u)/com.<계정명>.<routine이름>
- 06:00까지 기다렸다가 확인하면 하루에 한 번밖에 못 고칩니다. 지금 강제로 한 번 깨워서 결과부터 봅니다.
3. 도구는 필요한 만큼만 화이트리스트로
claude -p "$(cat prompt.md)" \
--allowedTools "Bash,Read,Write,Edit,Glob,Grep" \
--model sonnet \
--permission-mode bypassPermissions
- 역할에 필요 없는 도구(웹 검색·외부 API)는 아예 목록에서 뺍니다. 화이트리스트에 없으면 못 씁니다.
4. GUI 세션에서 멈추면, SSH-localhost로 우회
ssh -o BatchMode=yes -o StrictHostKeyChecking=no localhost \
"bash '<래퍼 스크립트 경로>' '<prompt 파일>' '<allowedTools>' '<model>'"
launchd가 직접 CLI를 부르는데 로그도 없이 멈춰 있다면, 이게 원인일 확률이 높습니다. GUI가 아닌 세션에서 한 번 더 감싸서 실행합니다.
5. 중복 실행 방지 락
mkdir "$LOCK_DIR" 2>/dev/null || {
# 이미 누가 락을 쥐고 있으면: PID가 살아 있는지 확인 후 대기, 죽어 있으면 정리
}
mkdir은 이미 존재하는 디렉토리를 다시 만들 수 없다는 성질을 그대로 락으로 씁니다. 별도 락 파일 라이브러리 없이도 됩니다.
배운 것

에이전트를 만든다고 하면 프롬프트를 얼마나 잘 짜는지부터 떠올리기 쉽습니다. 근데 제가 한 달 넘게 매일 돌려보고 나서 남은 건, 프롬프트보다 “예정된 시간에 정말로 깨어나는가”, “겹쳐 돌지는 않는가”, “멈추면 알아챌 수 있는가”였습니다. 지능은 그다음 문제였습니다.
오늘도 한 걸음. 🐴
이 글은 산업 구조와 만드는 과정을 정리한 개인 기록입니다.
특정 종목의 매수·매도 추천이 아니며, 투자 판단과 그 결과는 독자 본인에게 있습니다.