시리즈: 크레마 카르타 G로 홈 디스플레이 만들기
1편: 오늘의 정보를 이미지 한 장으로 — 서버 (이 글)
2편: 오래된 이북 리더에게 이미지 한 장 맡기기 — 앱
서랍에 크레마 카르타 G가 하나 있다. Android 4.4.2 기반의 e-ink 이북 리더다. 책은 이제 다른 기기로 읽으니 거의 쓸 일이 없었다.
그러다 이런 생각이 들었다. 아침마다 날씨 앱, 캘린더, 학교 급식 앱을 따로 여는 대신, 이 기기에 오늘의 정보를 한 장으로 띄워 두면 어떨까. e-ink는 전원을 거의 안 쓰고, 잠들어도 마지막 화면이 남는다. 벽이나 선반에 두기 딱 좋다.
그래서 저장소 두 개를 만들었다. 목적은 하나고, 역할만 나눴다.
두 저장소 모두 멀티 에이전트 하네스로 개발했다. 요청을 던지면 에이전트가 작업 브랜치에서 구현·리뷰·검증하고 머지하는 방식이다. 서버는 10월 26일부터 31일까지 커밋 76개가 쌓였다.

구조는 단순하다. 서버가 매일 아침 이미지를 그려서 GitHub Pages 저장소에 push한다. 크레마 앱은 아침에 그 주소에서 PNG 하나를 내려받는다. API 서버는 따로 없다. 정적 파일 하나가 두 저장소 사이의 계약이다.

이 글은 왼쪽 상자, 서버 이야기다. 처음엔 "정보 모아서 이미지 하나 만들면 끝"이라고 생각했다. 실제로 시간이 든 건 이미지가 안 나왔을 때를 다루는 부분이었다.
첫 작업은 화면이 아니라 화면이 믿고 읽을 자료였다. collect_info.py가 네이버 오늘 날씨, 마트 휴무일, NEIS 급식을 JSON 하나로 모은다. Python 표준 라이브러리만 쓴다.
출력은 generatedAt, date, weather, mart, schoolMeal, errors다. 한 소스가 실패하면 errors에 남기고 나머지는 계속 모은다. 하나 실패했다고 이미 얻은 정보까지 버리지 않는다.
사소해 보이지만 결과를 바꾸는 함정이 꽤 있었다.
| 소스 | 함정 | 처리 |
| 네이버 주간예보 | today 클래스가 오늘과 내일에 모두 붙을 수 있다 |
실제 날짜 텍스트까지 확인해서 고른다 |
| 네이버 검색 | 보안 차단 페이지가 200으로 내려온다 | 날씨로 받아들이지 않고 수집 오류로 처리. 어제 값으로 대신하지도 않는다 |
| 마트 휴무 | JSON인데 Content-Type이 HTML. 휴무 없는 달엔 dateList 자체가 없다 |
헤더 대신 본문을 파싱하고, 필드 없음을 "휴무 없음"으로 읽는다 |
| NEIS 급식 | Accept: application/json을 보내면 오히려 HTTP 500이 온다 |
브라우저 User-Agent만 보낸다 |
배운 건 응답의 겉모양과 의미를 분리해야 한다는 점이다. 필드가 없다고 장애는 아니고, 200을 받았다고 유효한 정보도 아니다.
10월 27일에 이미지 생성이 붙었다. uv run generate_image.py를 실행하면 수집 JSON과 무작위로 고른 캐릭터 하나를 프롬프트에 넣고 Codex의 $imagegen(GPT Image 2)으로 픽셀아트 대시보드를 그린다.
그런데 화면에 들어가는 정보가 전부 JSON에 있는 건 아니다. Google Calendar 일정은 Codex에 연결된 도구가 직접 조회한다. 수집 JSON에 캘린더 필드를 덧붙이지 않았다.

이렇게 나눠 두면 "수집기 테스트가 통과했으니 캘린더도 괜찮겠지" 하는 착각을 막을 수 있다. 그리고 캘린더 접근 실패와 정상 조회 결과 일정이 없는 날을 구분해야 한다. 권한이 끊겼는데 화면에 "일정 없음"이라고 뜨면, 그건 틀린 정보다.
화면 배치는 평일과 주말이 다르다. 평일엔 날씨·일정·급식, 주말엔 급식 자리에 마트 휴무가 들어간다. 다만 이건 표시 규칙이다. 날씨와 마트는 매일 수집하고, NEIS 조회만 평일에 한다.
프롬프트에는 꽤 많은 걸 적었다. 흰 배경, 검정 선, 회색 3~5톤, 본문 28px 이상, 제목 60px 이상, 상단에 "0월 0일" 날짜. 생성 후 스스로 다시 보고 요구에서 벗어나면 다시 그리라는 독립 검수 지시도 넣었다.
하지만 이건 어디까지나 요청이다. 코드가 보장하는 것과 프롬프트가 바라는 것을 나눠 적으면 이렇다.
| 코드가 보장 | 프롬프트가 요청 (사람이 확인) |
| 실제 PNG인지 검사 투명 영역은 흰 배경에 합성 1448×1072로 보정(NEAREST) 그레이스케일 저장 임시 파일 저장 후 원자 교체 |
한글 정확도, 폰트, 글자 크기 배치와 3~5톤 캐릭터 정체성 GPT Image 2 사용 (CLI는 --model을 넘기지 않는다) |
픽셀 크기가 맞아도 글자가 틀릴 수 있다. 파일 검증을 통과해도 읽기 불편할 수 있다. 그래서 왼쪽은 테스트로, 오른쪽은 눈으로 확인한다.
10월 28일엔 화면 상단에 기준 날짜를 넣는 요청이 더해졌다. 오래 떠 있는 화면에서는 "오늘처럼 보이는 그림"보다 "언제 정보인지 알 수 있는 그림"이 더 중요하다.


10월 27일 하루의 커밋 대부분이 이 문제였다. codex exec가 종료 코드 0으로 끝났는데 결과 이미지가 없다. 프로세스가 끝났다는 것과 결과물이 준비됐다는 것은 다른 사실이었다.
처음엔 에이전트가 생성한 원본을 직접 raw.png로 복사하게 했다. 그런데 이 복사 단계에 Linux 샌드박스(bwrap) 문제와 MCP 도구 승인 문제가 끼어들었다. 복사를 더 잘 지시하는 대신, 복사하는 주체를 바꿨다.
지금은 Codex가 최종 응답으로 원본의 절대경로를 담은 JSON을 돌려준다. --output-schema로 구조를 정하고 --output-last-message로 result.json에 저장한다. 호스트 Python이 그걸 읽고, 원본을 검사한 뒤 직접 가져온다.

검사 조건이 꽤 깐깐하다. 원본은 CODEX_HOME/generated_images 아래의 일반 파일이어야 하고, 경로에 심볼릭 링크가 있으면 거부한다. 실행 시작보다 오래된 파일도 거부한다. 디렉터리에서 "가장 최신 이미지"를 추측해서 고르는 방식은 일부러 쓰지 않았다. 다른 실행의 산출물을 오늘 결과로 조용히 채택하느니 명시적으로 실패하는 게 낫다.
실패 진단도 "파일을 찾지 못했다" 한 줄에서 나아갔다. stdout·stderr 끝부분, 실행 디렉터리 파일 목록, 종료 코드를 로그에 남기고 프롬프트와 비밀값은 가린다. 그리고 비슷해 보이는 오류 세 가지를 구분했다.
bwrap 오류: 호스트의 샌드박스 실행 조건 문제MCP tool call requires approval, but approval policy is never: 도구 승인 거부셋을 같은 "권한 문제"로 묶으면 danger-full-access로 다 열어 버리고 싶어진다. 기본 workspace-write를 유지하고, 실패한 도구와 로그부터 보는 게 이 프로젝트의 결론이었다.
같은 날 생성한 이미지를 별도 Git 저장소(GitHub Pages)로 넘기는 게시 흐름이 붙었다. TARGET_GIT_DIR을 설정하지 않으면 게시만 건너뛴다.
설정했다면 비용이 드는 생성 전에 대상부터 검사한다. 서버 저장소와 다른 Git 루트인지, 미추적 파일까지 깨끗한지, upstream이 있는지, 미게시 커밋이 없는지 본다. 생성 후엔 다시 검사하고 pull --ff-only → images/today.png만 복사 → 그 경로만 commit → 일반 push 순서로 간다. force push나 자동 stash·reset으로 주변을 치우지 않는다.
10월 28일엔 재미있는 오탐이 있었다. 미게시 커밋을 로컬 upstream 추적 ref와 비교했는데, push URL을 따로 설정한 환경에서는 이 ref가 최신 원격이 아니었다. 이미 올라간 커밋을 "아직 안 올라갔다"며 거부한 것이다. 지금은 확인한 원격 URL과 브랜치를 직접 fetch해서 비교한다.
하나 더. Git diff가 없으면 commit과 push를 생략한다. 그런데 Git의 바이트 변경과 앱의 픽셀 변경은 다르다. PNG 바이트만 바뀌고 그림이 같다면 게시는 되지만 앱은 같은 화면으로 본다. 이 이야기는 2편에서 이어진다.
매일 아침 결과를 휴대폰으로 보고 싶어서 Telegram 알림을 넣었다. 처음엔 텍스트였다가 곧 sendPhoto로 바꿔 이미지 자체를 보내게 했다. 이건 JSON이 아니라 multipart 업로드라 경계 문자열 하나 틀리면 실패한다. 정규화 직후 파일을 그대로 올려서 게시본과 같은 바이트를 보낸다. 생성·게시가 실패하면 sendMessage로 실패 알림을 보낸다.

원칙은 두 가지다.

10월 31일, NEIS가 INFO-200(해당 데이터 없음)을 돌려주면서 이미지 생성 전체가 멈췄다. 급식 하나 때문에 날씨와 일정까지 못 보는 건 이상하다. 모든 정보가 똑같이 필수일 필요는 없었다.
INFO-200은 정상적인 "빈 급식"으로 처리한다.중요한 건 허용 범위를 급식에만 열었다는 점이다. 날씨·마트 수집 오류나 캘린더 연결 실패는 여전히 실행 실패다. 빈칸의 이유를 지어내지 않는 것, 그리고 느슨하게 할 곳만 느슨하게 하는 것.
마지막은 매일 06:00에 도는 cron이다. README의 예시는 이렇게 생겼다(경로는 바꿨다).
0 6 * * * (cd ~/crema-home-server && /usr/bin/flock -n ~/logs/crema-home.lock \
uv run generate_image.py --env-file .env --out out/display.png) >> ~/logs/crema-home.log 2>&1
이 한 줄이 처음부터 돌지는 않았다. 잠금 파일과 로그 파일의 상위 디렉터리가 없으면 Python이 뜨기도 전에 flock이나 셸 리다이렉션에서 실패한다. 그래서 디렉터리를 먼저 만들고, cron과 같은 사용자·PATH로 수동 실행해 보는 단계를 안내에 넣었다.
실행 사용자, 시간대(Asia/Seoul), PATH에 잡히는 codex의 버전, 로그인 상태, 도구 승인도 같은 환경에서 확인해야 한다. 대화형 터미널에서 된다고 무인 실행이 준비된 건 아니다.
현재 scripts/check.sh는 컴파일 검사와 테스트 202개를 돌린다. 파일 보존, 인계 거부, 선택적 급식, 알림 순서, 게시 실패 같은 경계를 다룬다. Git 게시 테스트는 격리된 로컬 bare 저장소를 쓴다.
며칠 동안 늘어난 건 이미지 생성 기능이 아니었다. 대부분은 이런 질문에 대한 답이었다.
작은 자동화의 신뢰성은 실패를 없애는 데서보다, 실패 뒤에 무엇을 남길지 정하는 데서 생겼다.
다음 편에서는 이 이미지를 받아 보여 주는 크레마 앱 이야기를 한다. Android 4.4.2에서 HTTPS를 받고, 아침 90분만 깨어 있게 만들고, "받은 이미지"와 "보여 준 이미지"를 구분하는 이야기다.
다음 글: 크레마 카르타 G로 홈 디스플레이 만들기 (2) — 앱
※ 이 글은 AI의 도움을 받아 작성했고, 작성자가 내용을 직접 검토한 뒤 올렸다.
| 크레마 카르타 G로 홈 디스플레이 만들기 (2) — 오래된 이북 리더에게 이미지 한 장 맡기기 (앱) (0) | 2026.11.04 |
|---|---|
| 날씨 시계를 Codex·Claude 사용량 모니터로 바꾸기 — 1편: 하드웨어와 화면 (0) | 2026.11.03 |
| 날씨 시계를 Codex·Claude 사용량 모니터로 바꾸기 — 2편: MQTT TLS와 안정적인 운영 (0) | 2026.11.03 |
| 토스증권 Open API로 나만의 포트폴리오 리포트 만들기 (2) — Claude Code·Codex CLI로 종목 AI 분석 붙이기 (0) | 2026.10.28 |
| 토스증권 Open API로 나만의 포트폴리오 리포트 만들기 (1) — 매일 쌓고, 과거는 복원하고 (0) | 2026.10.28 |
댓글 영역