AI

크레마 카르타 G로 홈 디스플레이 만들기 (1) — 오늘의 정보를 이미지 한 장으로 (서버)

mingoon.com 2026. 11. 4. 15:09
반응형

시리즈: 크레마 카르타 G로 홈 디스플레이 만들기
1편: 오늘의 정보를 이미지 한 장으로 — 서버 (이 글)
2편: 오래된 이북 리더에게 이미지 한 장 맡기기 — 앱

 

서랍에 크레마 카르타 G가 하나 있다. Android 4.4.2 기반의 e-ink 이북 리더다. 책은 이제 다른 기기로 읽으니 거의 쓸 일이 없었다.

그러다 이런 생각이 들었다. 아침마다 날씨 앱, 캘린더, 학교 급식 앱을 따로 여는 대신, 이 기기에 오늘의 정보를 한 장으로 띄워 두면 어떨까. e-ink는 전원을 거의 안 쓰고, 잠들어도 마지막 화면이 남는다. 벽이나 선반에 두기 딱 좋다.

그래서 저장소 두 개를 만들었다. 목적은 하나고, 역할만 나눴다.

  • crema-home-server: 정보를 모아 e-ink용 이미지 한 장을 만들고 게시한다. (이 글)
  • crema-home-app: 크레마에 설치해서 정해진 시각에 그 이미지를 받아 보여 준다. (2편)

두 저장소 모두 멀티 에이전트 하네스로 개발했다. 요청을 던지면 에이전트가 작업 브랜치에서 구현·리뷰·검증하고 머지하는 방식이다. 서버는 10월 26일부터 31일까지 커밋 76개가 쌓였다.

 


0. 전체 구성

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

전체 구성 — 서버는 이미지를 정해진 주소에 올리는 데까지, 앱은 그 주소를 받아 보여 주는 데까지만 책임진다

이 글은 왼쪽 상자, 서버 이야기다. 처음엔 "정보 모아서 이미지 하나 만들면 끝"이라고 생각했다. 실제로 시간이 든 건 이미지가 안 나왔을 때를 다루는 부분이었다.


1. 첫날은 그림이 아니라 데이터였다

첫 작업은 화면이 아니라 화면이 믿고 읽을 자료였다. 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을 받았다고 유효한 정보도 아니다.


2. 정보 출처는 두 갈래

10월 27일에 이미지 생성이 붙었다. uv run generate_image.py를 실행하면 수집 JSON과 무작위로 고른 캐릭터 하나를 프롬프트에 넣고 Codex의 $imagegen(GPT Image 2)으로 픽셀아트 대시보드를 그린다.

그런데 화면에 들어가는 정보가 전부 JSON에 있는 건 아니다. Google Calendar 일정은 Codex에 연결된 도구가 직접 조회한다. 수집 JSON에 캘린더 필드를 덧붙이지 않았다.

수집 JSON과 캘린더는 경로가 다르다. 캘린더 접근 실패는 "일정 없음"이 아니라 실행 실패다

이렇게 나눠 두면 "수집기 테스트가 통과했으니 캘린더도 괜찮겠지" 하는 착각을 막을 수 있다. 그리고 캘린더 접근 실패와 정상 조회 결과 일정이 없는 날을 구분해야 한다. 권한이 끊겼는데 화면에 "일정 없음"이라고 뜨면, 그건 틀린 정보다.

화면 배치는 평일과 주말이 다르다. 평일엔 날씨·일정·급식, 주말엔 급식 자리에 마트 휴무가 들어간다. 다만 이건 표시 규칙이다. 날씨와 마트는 매일 수집하고, NEIS 조회만 평일에 한다.


3. 원하는 그림과 보장할 수 있는 파일은 다르다

프롬프트에는 꽤 많은 걸 적었다. 흰 배경, 검정 선, 회색 3~5톤, 본문 28px 이상, 제목 60px 이상, 상단에 "0월 0일" 날짜. 생성 후 스스로 다시 보고 요구에서 벗어나면 다시 그리라는 독립 검수 지시도 넣었다.

하지만 이건 어디까지나 요청이다. 코드가 보장하는 것과 프롬프트가 바라는 것을 나눠 적으면 이렇다.

코드가 보장 프롬프트가 요청 (사람이 확인)
실제 PNG인지 검사
투명 영역은 흰 배경에 합성
1448×1072로 보정(NEAREST)
그레이스케일 저장
임시 파일 저장 후 원자 교체
한글 정확도, 폰트, 글자 크기
배치와 3~5톤
캐릭터 정체성
GPT Image 2 사용 (CLI는 --model을 넘기지 않는다)

 

픽셀 크기가 맞아도 글자가 틀릴 수 있다. 파일 검증을 통과해도 읽기 불편할 수 있다. 그래서 왼쪽은 테스트로, 오른쪽은 눈으로 확인한다.

10월 28일엔 화면 상단에 기준 날짜를 넣는 요청이 더해졌다. 오래 떠 있는 화면에서는 "오늘처럼 보이는 그림"보다 "언제 정보인지 알 수 있는 그림"이 더 중요하다.

 

 


4. 종료 코드 0인데 이미지가 없다

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: 도구 승인 거부
  • 오래된 Codex CLI: 모델과의 버전 호환 문제 → 업그레이드 힌트 출력

셋을 같은 "권한 문제"로 묶으면 danger-full-access로 다 열어 버리고 싶어진다. 기본 workspace-write를 유지하고, 실패한 도구와 로그부터 보는 게 이 프로젝트의 결론이었다.


5. 만드는 것과 게시하는 것도 다르다

같은 날 생성한 이미지를 별도 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편에서 이어진다.


6. 사진 알림 뒤에도 게시는 실패할 수 있다

매일 아침 결과를 휴대폰으로 보고 싶어서 Telegram 알림을 넣었다. 처음엔 텍스트였다가 곧 sendPhoto로 바꿔 이미지 자체를 보내게 했다. 이건 JSON이 아니라 multipart 업로드라 경계 문자열 하나 틀리면 실패한다. 정규화 직후 파일을 그대로 올려서 게시본과 같은 바이트를 보낸다. 생성·게시가 실패하면 sendMessage로 실패 알림을 보낸다.

생성·알림·게시의 순서 — 알림은 게시보다 먼저 가므로, 사진이 왔다고 게시까지 성공한 건 아니다

원칙은 두 가지다.

  • 알림 실패는 경고로만 남긴다. Telegram 장애가 정상 게시를 막으면 안 된다. 원래 종료 코드도 바꾸지 않는다.
  • 토큰은 어디에도 새지 않게. Codex 자식 프로세스 환경에서 제거하고 로그에서도 가린다. Bot API는 URL 경로에 토큰이 들어가서, HTTP 예외 원문을 그대로 찍으면 토큰이 로그에 남는다.

 

 


7. 급식이 없다고 오늘 화면까지 없어야 할까

10월 31일, NEIS가 INFO-200(해당 데이터 없음)을 돌려주면서 이미지 생성 전체가 멈췄다. 급식 하나 때문에 날씨와 일정까지 못 보는 건 이상하다. 모든 정보가 똑같이 필수일 필요는 없었다.

  • 주말에는 NEIS 조회 자체를 건너뛴다.
  • INFO-200은 정상적인 "빈 급식"으로 처리한다.
  • 실제 조회 오류나 키 누락은 경고를 남기고 이미지 생성은 계속한다.
  • 평일에 급식이 비어 있으면 정확히 "급식 정보 없음"이라고 표시한다. 휴교나 급식 미실시로 추측하지 않는다.

중요한 건 허용 범위를 급식에만 열었다는 점이다. 날씨·마트 수집 오류나 캘린더 연결 실패는 여전히 실행 실패다. 빈칸의 이유를 지어내지 않는 것, 그리고 느슨하게 할 곳만 느슨하게 하는 것.


8. cron 한 줄보다 먼저 있어야 할 것

마지막은 매일 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의 버전, 로그인 상태, 도구 승인도 같은 환경에서 확인해야 한다. 대화형 터미널에서 된다고 무인 실행이 준비된 건 아니다.


9. 정리

현재 scripts/check.sh는 컴파일 검사와 테스트 202개를 돌린다. 파일 보존, 인계 거부, 선택적 급식, 알림 순서, 게시 실패 같은 경계를 다룬다. Git 게시 테스트는 격리된 로컬 bare 저장소를 쓴다.

며칠 동안 늘어난 건 이미지 생성 기능이 아니었다. 대부분은 이런 질문에 대한 답이었다.

  • 파일은 누가 가져오는가?
  • 어떤 정보가 필수이고 어떤 정보는 빠져도 되는가?
  • 알림은 어디까지의 성공을 뜻하는가?
  • 실패하면 무엇을 남겨 두는가?

작은 자동화의 신뢰성은 실패를 없애는 데서보다, 실패 뒤에 무엇을 남길지 정하는 데서 생겼다.


다음 편에서는 이 이미지를 받아 보여 주는 크레마 앱 이야기를 한다. Android 4.4.2에서 HTTPS를 받고, 아침 90분만 깨어 있게 만들고, "받은 이미지"와 "보여 준 이미지"를 구분하는 이야기다.

 

다음 글: 크레마 카르타 G로 홈 디스플레이 만들기 (2) — 앱

 

 

※ 이 글은 AI의 도움을 받아 작성했고, 작성자가 내용을 직접 검토한 뒤 올렸다.