<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Hybrid Image Search Demo on ICE-ICE-BEAR-BLOG</title><link>https://ice-ice-bear.github.io/ko/tags/hybrid-image-search-demo/</link><description>Recent content in Hybrid Image Search Demo on ICE-ICE-BEAR-BLOG</description><generator>Hugo -- gohugo.io</generator><language>ko</language><lastBuildDate>Fri, 17 Apr 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://ice-ice-bear.github.io/ko/tags/hybrid-image-search-demo/index.xml" rel="self" type="application/rss+xml"/><item><title>hybrid-image-search-demo 개발 로그 #16 — Lens 프리셋 확장, 프리뷰 썸네일, OTLP 텔레메트리</title><link>https://ice-ice-bear.github.io/ko/posts/2026-04-17-hybrid-search-dev16/</link><pubDate>Fri, 17 Apr 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/ko/posts/2026-04-17-hybrid-search-dev16/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post hybrid-image-search-demo 개발 로그 #16 — Lens 프리셋 확장, 프리뷰 썸네일, OTLP 텔레메트리" /&gt;&lt;h2 id="개요"&gt;개요
&lt;/h2&gt;&lt;p&gt;세 개 커밋, 세 개 주제. Lens 프리셋을 5개 general + 뷰티용 Briese 라이팅 프리셋으로 확장했고, AnglePicker·LensPicker에 hover-preview 썸네일 31장을 붙여 사용자가 프리셋의 실제 결과를 미리 볼 수 있게 만들었다. 마지막으로 프로덕션 FastAPI 백엔드의 trace/metric/log를 모두 Grafana Alloy에 OTLP로 쏘고, Alloy가 다시 Grafana Cloud로 포워딩하게 세팅했다. 이후 실제 프로덕션 장애(auto-fill 톤 이미지가 상세보기에서 안 보이는 현상) 디버깅에 이 텔레메트리를 처음 써 본 세션까지 포함된다. 2개 세션, 커밋 3개, 총 5시간 54분.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-16-hybrid-search-dev15/" &gt;이전 글: hybrid-image-search-demo 개발 로그 #15&lt;/a&gt;&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 A["FastAPI backend (prod)"] --&gt;|"OTLP HTTP :4318"| B["Grafana Alloy (per EC2)"]
 B --&gt; C["Grafana Cloud: Traces (Tempo)"]
 B --&gt; D["Grafana Cloud: Metrics (Prometheus)"]
 B --&gt; E["Grafana Cloud: Logs (Loki)"]
 F["logging.getLogger().error(...)"] --&gt;|"stdlib LogRecord"| A
 G["FastAPI span"] --&gt;|"auto-instrumented"| A
 C -. "trace_id 연결" .-&gt; E&lt;/pre&gt;&lt;h2 id="lens-프리셋-5--뷰티-1"&gt;Lens 프리셋 5 + 뷰티 1
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;c4fb076 feat(gen): expand lens presets to 5 general + beauty w/ Briese lighting&lt;/code&gt;는 백엔드 &lt;code&gt;backend/src/generation/lens_presets.py&lt;/code&gt;를 건드렸다. 이전까지 렌즈 선택은 3개뿐이었고, 그 셋이 모든 생성 시나리오를 커버하기엔 부족하다는 피드백이 누적됐다. 이번 확장은 두 가지를 했다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;General 5개로 확장&lt;/strong&gt; — 24mm (wide), 35mm (street/environmental), 50mm (natural), 85mm (portrait), 135mm (tight). 포토그래피 표준 초점거리 톺아보기 그대로.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Beauty 전용 프리셋 추가 — Briese 라이팅&lt;/strong&gt;. Briese는 광고·뷰티 업계에서 쓰이는 대형 반사형 조명. 초점거리뿐 아니라 조명 스타일을 프롬프트에 함께 주입하는 첫 케이스다. &lt;code&gt;prompt.py&lt;/code&gt;의 &lt;code&gt;build_generation_prompt&lt;/code&gt;가 렌즈 텍스트를 뷰티 카테고리일 때 조명 디렉티브와 조합하도록 확장됐다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;테스트는 &lt;code&gt;backend/tests/test_lens_presets.py&lt;/code&gt; 하나가 추가됐다 — 각 프리셋이 프롬프트 빌더를 통과했을 때 기대 문자열이 나오는지 확인하는 unit test.&lt;/p&gt;
&lt;p&gt;프론트엔드 쪽 &lt;code&gt;frontend/src/components/LensPicker.tsx&lt;/code&gt;는 라디오 그룹의 옵션을 5개로 늘리고 뷰티 프리셋을 별도 그룹으로 묶었다. &lt;code&gt;GeneratedImageDetail.tsx&lt;/code&gt;는 선택된 렌즈 텍스트를 인포 패널에 보여 주도록 했다.&lt;/p&gt;
&lt;h2 id="hover-preview-썸네일-31장"&gt;Hover-Preview 썸네일 31장
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;4b886a9 feat(ui): hover-preview examples for angle/lens pickers&lt;/code&gt;는 파일 31개짜리 커밋이다. 이 중 대부분은 &lt;code&gt;frontend/public/preset-examples/angles/*.jpg&lt;/code&gt;와 &lt;code&gt;lens/*.jpg&lt;/code&gt;에 들어간 실제 예시 이미지들 — bird&amp;rsquo;s-eye-view, close-up-cu, dutch-angle, extreme-close-up-ecu, extreme-long-shot-els, eye-level, high-angle, insert-shot, long-shot-ls, low-angle, master-shot, medium-close-up-mcu 등.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph LR
 A["사용자가 angle/lens 옵션에 hover"] --&gt; B["AnglePicker/LensPicker가 미리보기 이미지 URL 계산"]
 B --&gt; C["/preset-examples/{kind}/{slug}.jpg"]
 C --&gt; D["floating tooltip에 이미지 렌더"]
 D --&gt; E["사용자가 결과를 보고 선택"]&lt;/pre&gt;&lt;p&gt;생성 스크립트 &lt;code&gt;backend/scripts/generate_preset_examples.py&lt;/code&gt;가 이 썸네일들을 일괄 생성했다. 이전 글에서 소개한 것과 같은 이미지 생성 파이프라인을 호출하고, 각 프리셋을 동일한 reference character로 돌려 예시를 만든 뒤 &lt;code&gt;frontend/public/preset-examples/&lt;/code&gt;에 덤프한다. &lt;code&gt;.gitignore&lt;/code&gt;에 원본 비디오/모델 파일 제외 규칙을 추가했다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AnglePicker.tsx&lt;/code&gt;와 &lt;code&gt;LensPicker.tsx&lt;/code&gt;는 hover 시 floating tooltip을 띄우는 공통 패턴을 공유한다. 사용자가 프리셋 이름만 보고 고르게 하지 말고 실제 결과 감각을 미리 주자는 UX 결정이다. 이전까지는 &amp;ldquo;extreme-long-shot (ELS)&amp;ldquo;이라는 약어만 보고 눌러야 했다.&lt;/p&gt;
&lt;h2 id="grafana-otlp-텔레메트리"&gt;Grafana OTLP 텔레메트리
&lt;/h2&gt;&lt;p&gt;가장 무게가 실린 커밋이 &lt;code&gt;7a55e9b feat(telemetry): ship prod logs to Alloy/Grafana Cloud via OTLP&lt;/code&gt;다. 4개 파일만 바뀌었지만 운영 레벨로는 큰 변화.&lt;/p&gt;
&lt;h3 id="요구사항"&gt;요구사항
&lt;/h3&gt;&lt;p&gt;사용자 브리프는 명확했다 — &amp;ldquo;무료 Grafana 계정을 쓰고 있는데, 각 API 로그, 아니면 최소한 에러 있는 API만이라도 붙이고 싶다. 무료 계정 안에서 가능한지 확인해 달라.&amp;rdquo; prod만 수집, 패키지 관리는 전역 &lt;code&gt;pyproject.toml&lt;/code&gt;로, 환경변수 &lt;code&gt;.env&lt;/code&gt;로 prod에서만 활성화되게.&lt;/p&gt;
&lt;h3 id="아키텍처"&gt;아키텍처
&lt;/h3&gt;&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 A["FastAPI app"] --&gt;|"OTLP HTTP"| B["Alloy (localhost:4318)"]
 B --&gt; C["Grafana Cloud OTLP endpoint"]
 C --&gt; D["Tempo / Loki / Prometheus"]
 E["stdlib logging"] --&gt;|"LoggingHandler"| A&lt;/pre&gt;&lt;p&gt;FastAPI 앱은 로컬에서 돌아가는 Alloy 에이전트에 OTLP HTTP(4318)로 쏜다. Alloy는 Grafana Cloud의 OTLP endpoint로 포워딩한다. 이 방식은 앱에 Grafana Cloud 자격증명을 직접 박지 않고, Alloy 설정 파일에만 두게 한다 — prod EC2 이미지를 갈아끼울 때 노출 면적이 줄어든다.&lt;/p&gt;
&lt;h3 id="구현-포인트"&gt;구현 포인트
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;backend/src/telemetry.py&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;_telemetry_enabled&lt;/code&gt; 플래그로 감싼 초기화. 이 플래그는 &lt;code&gt;DEPLOYMENT_ENV&lt;/code&gt; 환경변수가 &lt;code&gt;&amp;quot;prod&amp;quot;&lt;/code&gt;일 때만 true. Traces(OTLPSpanExporter), Metrics(OTLPMetricExporter), Logs(OTLPLogExporter)를 각각 붙이고 &lt;code&gt;FastAPIInstrumentor&lt;/code&gt;, &lt;code&gt;SQLAlchemyInstrumentor&lt;/code&gt;, &lt;code&gt;LoggingInstrumentor&lt;/code&gt;를 활성화.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;stdlib logging → OTLP로&lt;/strong&gt;. 핵심 디테일. 루트 로거에 &lt;code&gt;LoggingHandler&lt;/code&gt;를 달아서 &lt;code&gt;logging.getLogger(...)&lt;/code&gt;로 나오는 모든 로그(uvicorn access, SQLAlchemy, 앱 &lt;code&gt;logger.error&lt;/code&gt;)가 OTLP로 흐르게 했다. Handler는 emit 시점의 active span context를 읽어 trace_id를 LogRecord에 attach한다 — Grafana에서 로그 한 줄 클릭하면 해당 trace로 점프 가능.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;pyproject.toml&lt;/code&gt;에 OpenTelemetry 스택 전역 추가&lt;/strong&gt;. &lt;code&gt;opentelemetry-instrumentation-fastapi&lt;/code&gt;, &lt;code&gt;opentelemetry-instrumentation-sqlalchemy&lt;/code&gt;, &lt;code&gt;opentelemetry-instrumentation-logging&lt;/code&gt;, &lt;code&gt;opentelemetry-exporter-otlp-proto-http&lt;/code&gt;, &lt;code&gt;opentelemetry-exporter-otlp-proto-grpc&lt;/code&gt; 모두 &lt;code&gt;&amp;gt;=0.54b0&lt;/code&gt; / &lt;code&gt;&amp;gt;=1.33.0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;infra/alloy/config.alloy&lt;/code&gt;&lt;/strong&gt; — Alloy 설정. OTLP receiver가 grpc(4317)·http(4318) 양쪽을 열고, batch processor를 거쳐 Grafana Cloud로 forward. 설정은 짧고 단순하다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;infra/alloy/SETUP.md&lt;/code&gt;&lt;/strong&gt; — EC2 인스턴스마다 Alloy를 설치하는 수동 절차. &lt;code&gt;sudo apt install grafana-alloy&lt;/code&gt;, config 파일 배치, systemd 활성화.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="배포와-pm2-주의"&gt;배포와 PM2 주의
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;/deploy-diff&lt;/code&gt; 워크플로우로 dev → prod 순으로 배포했다. Prod에서 실제로 Grafana Cloud 대시보드에 trace가 들어오는지 확인했고, 잘 들어왔다. 하나 남은 함정이 있었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ecosystem.config.js&lt;/code&gt;의 &lt;code&gt;DEPLOYMENT_ENV: process.env.DEPLOYMENT_ENV || &amp;quot;&amp;quot;&lt;/code&gt;는 PM2 daemon의 shell env에 의존한다. Prod EC2가 재부팅되거나 &lt;code&gt;pm2 kill&lt;/code&gt; 후 resurrect되면 PM2 daemon이 로그인 shell 밖에서 시작되므로 &lt;code&gt;DEPLOYMENT_ENV&lt;/code&gt;가 다시 빈 문자열이 된다. 그럼 &lt;code&gt;_telemetry_enabled&lt;/code&gt;가 false가 되어 prod에서 조용히 텔레메트리가 꺼진다. 해결은 systemd에서 PM2를 띄울 때 &lt;code&gt;Environment=DEPLOYMENT_ENV=prod&lt;/code&gt;를 박는 것. 이번 인터벌엔 메모만 남기고 다음에 반영.&lt;/p&gt;
&lt;h2 id="실전-투입--장애-디버깅"&gt;실전 투입 — 장애 디버깅
&lt;/h2&gt;&lt;p&gt;세션 4에서 실제로 이 텔레메트리가 유용했다. 사용자 &lt;a class="link" href="mailto:khk@diffs.studio" &gt;khk@diffs.studio&lt;/a&gt; 이미지가 &amp;ldquo;우주의 신비로운 모습&amp;rdquo; 프롬프트로 4/16 13:20경 생성됐는데 auto-fill 톤 이미지가 상세보기에서 안 보인다는 제보. 원래라면 SSH로 프로덕션 서버에 붙어 로그 grep부터 시작했을 텐데, 이번엔 Grafana Loki에서 바로 &lt;code&gt;{service_name=&amp;quot;hybrid-image-search&amp;quot;} |= &amp;quot;khk@diffs.studio&amp;quot;&lt;/code&gt;를 찍어 해당 생성 로그를 찾았다.&lt;/p&gt;
&lt;p&gt;혼재된 에러들:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;blob:http://...&lt;/code&gt; URL이 insecure connection으로 로드된다는 브라우저 경고 → HTTPS 전환이 아직 안 된 EC2 호스트.&lt;/li&gt;
&lt;li&gt;502 Bad Gateway → 이 역시 HTTPS로 전환하면 nginx upstream 설정과 함께 풀릴 가능성.&lt;/li&gt;
&lt;li&gt;401 에러 → 다른 서버에서, 세션 만료 후 토큰 미갱신.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;추적 패턴은 간결했다. Grafana trace 링크를 따라가면 FastAPI span이 나오고, 거기서 연결된 log record로 점프해 에러 메시지를 읽는다. &amp;ldquo;prod에 접속해서 로그 tail&amp;rdquo; 워크플로우가 &amp;ldquo;Grafana 탭에서 trace 클릭&amp;rdquo; 워크플로우로 바뀐 첫 실전이었다. 수정은 다음 인터벌 과제로 이월.&lt;/p&gt;
&lt;h2 id="커밋-로그"&gt;커밋 로그
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;메시지&lt;/th&gt;
 &lt;th&gt;변경&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;feat(gen): expand lens presets to 5 general + beauty w/ Briese lighting&lt;/td&gt;
 &lt;td&gt;5 files&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;feat(ui): hover-preview examples for angle/lens pickers&lt;/td&gt;
 &lt;td&gt;31 files (다수 이미지)&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;feat(telemetry): ship prod logs to Alloy/Grafana Cloud via OTLP&lt;/td&gt;
 &lt;td&gt;4 files&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="인사이트"&gt;인사이트
&lt;/h2&gt;&lt;p&gt;텔레메트리를 붙이는 작업과 텔레메트리를 처음 쓰는 작업이 같은 인터벌에 겹친 것이 이번의 가장 좋은 신호였다. &amp;ldquo;언젠가 유용할 테니 깔아 두자&amp;quot;라는 투자는 보통 몇 주간 회수되지 않지만, OTLP + Alloy 스택은 배포 당일 바로 사용자 장애에 투입됐다. 두 가지 효과. 첫째, 지금 Grafana 뷰에 뭐가 찍히고 뭐가 안 찍히는지가 명확해졌다 — trace_id가 log와 연결되는 건 잘 되고, 브라우저 쪽 에러는 당연히 안 찍힌다(OTLP는 서버 측만 커버). 둘째, &amp;ldquo;누가 어떤 프롬프트를 언제 어떤 에러로 돌렸나&amp;quot;를 사용자 이메일 한 줄로 찾는 쿼리를 Loki에 그대로 둘 수 있게 됐다 — 다음 지원 티켓에 2초 안에 답할 수 있는 준비. 도구가 도착한 날 그 도구로 문제를 풀 수 있었던 건 프로덕션에 실제 사용자가 있고, 그들의 에러가 부드럽지 않고, 기억력 대신 조회 가능한 로그가 필요하다는 신호다. 그 신호가 맞게 잡힌 인터벌이다.&lt;/p&gt;</description></item><item><title>hybrid-image-search-demo 개발 로그 #15 — 톤 카운트 제거, A/B 네이밍 정리</title><link>https://ice-ice-bear.github.io/ko/posts/2026-04-16-hybrid-search-dev15/</link><pubDate>Thu, 16 Apr 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/ko/posts/2026-04-16-hybrid-search-dev15/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post hybrid-image-search-demo 개발 로그 #15 — 톤 카운트 제거, A/B 네이밍 정리" /&gt;&lt;h2 id="개요"&gt;개요
&lt;/h2&gt;&lt;p&gt;톤 카운트(tone_count) 시스템을 프로젝트 전반에서 완전히 제거하고, 생성 이미지를 A/B 두 벌로 깔끔하게 정리한 회차다. 백엔드 로직, DB 기존 데이터, 프론트엔드 UI까지 한꺼번에 손봐야 해서 커밋이 7개로 늘어났다. 배포 환경 이슈와 앵글/렌즈 전용 재생성 버그도 함께 수정했다.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-15-hybrid-search-dev14/" &gt;이전 글: hybrid-image-search-demo 개발 로그 #14&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="변경-요약"&gt;변경 요약
&lt;/h2&gt;&lt;h3 id="톤-카운트-제거--왜"&gt;톤 카운트 제거 — 왜?
&lt;/h3&gt;&lt;p&gt;기존에는 생성 시 톤(색조) 변형 장수를 &lt;code&gt;tone_count&lt;/code&gt;로 관리했다. 실제 사용해보니 A/B 두 벌이면 충분했고, 톤 장수 개념이 UI와 프롬프트를 불필요하게 복잡하게 만들고 있었다. 이번 회차에서 이를 전면 제거했다.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;flowchart LR
 A["기존: tone_count=N"] --&gt;|"제거"| B["A/B 두 벌 고정"]
 B --&gt; C["프롬프트 단순화"]
 B --&gt; D["UI 라벨 정리"]
 B --&gt; E["DB 마이그레이션"]&lt;/pre&gt;&lt;h3 id="db-마이그레이션-alembic"&gt;DB 마이그레이션 (Alembic)
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;injection_reason&lt;/code&gt; 컬럼에 &lt;code&gt;_tone2&lt;/code&gt;, &lt;code&gt;_tone3&lt;/code&gt; 같은 접미사가 붙어 있던 기존 행들을 strip하는 마이그레이션을 추가했다. &lt;code&gt;app_utils.py&lt;/code&gt;의 파싱 로직도 접미사를 무시하도록 수정했다.&lt;/p&gt;
&lt;h3 id="백엔드-변경"&gt;백엔드 변경
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;app_utils.py&lt;/code&gt; — reason 문자열에 tone_count 접미사 붙이는 로직 제거, 파싱 시 접미사 strip&lt;/li&gt;
&lt;li&gt;&lt;code&gt;routes/generation.py&lt;/code&gt; — tone_count 파라미터 제거&lt;/li&gt;
&lt;li&gt;&lt;code&gt;generation/injection.py&lt;/code&gt; — 톤 비율 관련 로직 제거&lt;/li&gt;
&lt;li&gt;&lt;code&gt;generation/prompt.py&lt;/code&gt; — B 변형의 디테일을 강화하는 프롬프트 개선&lt;/li&gt;
&lt;li&gt;&lt;code&gt;routes/history.py&lt;/code&gt; — 히스토리 조회 시 톤 접미사 호환 처리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;schemas.py&lt;/code&gt; — tone_count 필드 제거&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="프론트엔드-변경"&gt;프론트엔드 변경
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;App.tsx&lt;/code&gt; — 톤 N장 배지 제거, A/B 네이밍으로 통일&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GeneratedImageDetail.tsx&lt;/code&gt; — 동일하게 톤 관련 라벨 제거&lt;/li&gt;
&lt;li&gt;&lt;code&gt;api.ts&lt;/code&gt; — tone_count 파라미터 제거&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="앵글렌즈-전용-재생성-수정"&gt;앵글/렌즈 전용 재생성 수정
&lt;/h3&gt;&lt;p&gt;앵글이나 렌즈만 바꿔서 재생성할 때 프롬프트가 제대로 구성되지 않던 버그를 수정했다. 속성 인젝션 없이 앵글/렌즈만 변경하는 케이스를 명시적으로 처리한다.&lt;/p&gt;
&lt;h3 id="배포-스크립트-수정"&gt;배포 스크립트 수정
&lt;/h3&gt;&lt;p&gt;EC2에서 &lt;code&gt;uv&lt;/code&gt; 바이너리가 &lt;code&gt;~/.local/bin&lt;/code&gt;에 설치되는데, deploy 스크립트의 PATH에 포함되지 않아 실패하던 문제를 수정했다.&lt;/p&gt;
&lt;h2 id="커밋-로그"&gt;커밋 로그
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th style="text-align: center"&gt;순서&lt;/th&gt;
 &lt;th style="text-align: center"&gt;범위&lt;/th&gt;
 &lt;th style="text-align: left"&gt;설명&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;1&lt;/td&gt;
 &lt;td style="text-align: center"&gt;db&lt;/td&gt;
 &lt;td style="text-align: left"&gt;기존 injection_reason 행에서 tone_count 접미사 strip하는 마이그레이션&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;2&lt;/td&gt;
 &lt;td style="text-align: center"&gt;gen&lt;/td&gt;
 &lt;td style="text-align: left"&gt;reason 문자열에 tone_count 접미사 붙이는 로직 제거&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;3&lt;/td&gt;
 &lt;td style="text-align: center"&gt;history&lt;/td&gt;
 &lt;td style="text-align: left"&gt;reason 파싱 시 tone_count 접미사 strip 처리&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;4&lt;/td&gt;
 &lt;td style="text-align: center"&gt;ui&lt;/td&gt;
 &lt;td style="text-align: left"&gt;톤 카운트 배지 제거, A/B 네이밍 적용&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;5&lt;/td&gt;
 &lt;td style="text-align: center"&gt;ui&lt;/td&gt;
 &lt;td style="text-align: left"&gt;남은 &amp;lsquo;톤 N장&amp;rsquo; 라벨을 A/B로 교체&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;6&lt;/td&gt;
 &lt;td style="text-align: center"&gt;deploy&lt;/td&gt;
 &lt;td style="text-align: left"&gt;EC2에서 uv 경로를 PATH에 추가&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td style="text-align: center"&gt;7&lt;/td&gt;
 &lt;td style="text-align: center"&gt;gen&lt;/td&gt;
 &lt;td style="text-align: left"&gt;톤 비율 전면 제거, 앵글/렌즈 전용 재생성 수정, B 변형 디테일 강화&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;h2 id="인사이트"&gt;인사이트
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;점진적 제거가 안전하다&lt;/strong&gt; — tone_count를 한 커밋에서 다 지우지 않고, DB 마이그레이션 → 백엔드 로직 → 프론트엔드 순으로 나눠 진행했다. 각 단계에서 기존 데이터 호환성을 확인할 수 있었다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A/B가 N장보다 낫다&lt;/strong&gt; — 사용자 입장에서 &amp;ldquo;톤 3장&amp;rdquo; 같은 표현보다 &amp;ldquo;A / B&amp;quot;가 직관적이다. 선택지를 줄이는 것이 UX를 개선한다.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;배포 환경과 개발 환경의 PATH 차이&lt;/strong&gt; — 로컬에서는 잘 되는데 EC2에서 실패하는 전형적인 케이스. deploy 스크립트에 PATH를 명시적으로 설정하는 습관이 필요하다.&lt;/li&gt;
&lt;/ul&gt;</description></item><item><title>hybrid-image-search-demo 개발 로그 #14 — 클럭 스큐, S3 우선 ref 캐시, 속성 인식 인젝션</title><link>https://ice-ice-bear.github.io/ko/posts/2026-04-15-hybrid-search-dev14/</link><pubDate>Wed, 15 Apr 2026 00:00:00 +0900</pubDate><guid>https://ice-ice-bear.github.io/ko/posts/2026-04-15-hybrid-search-dev14/</guid><description>&lt;img src="https://ice-ice-bear.github.io/" alt="Featured image of post hybrid-image-search-demo 개발 로그 #14 — 클럭 스큐, S3 우선 ref 캐시, 속성 인식 인젝션" /&gt;&lt;h2 id="개요"&gt;개요
&lt;/h2&gt;&lt;p&gt;짧지만 날카로운 주간. 커밋 5개 모두 프로덕션 보강. Google OAuth 클럭 스큐 로그인 블로커 픽스, PM2용 ecosystem.config.js 호스트 이식성 확보, 레퍼런스 이미지 key 캐시를 로컬 파일시스템에서 S3 기반으로 이관(dev와 prod가 동일 상태 보도록), 속성 인식 모델 자동 인젝션 배선, 페르소나를 3-shot 프롬프트로 나이 추정 포함 재라벨링.&lt;/p&gt;
&lt;p&gt;이전 글: &lt;a class="link" href="https://ice-ice-bear.github.io/posts/2026-04-13-hybrid-search-dev13/" &gt;hybrid-image-search-demo 개발 로그 #13&lt;/a&gt;&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;graph TD
 A["로그인: Invalid token 'used too early'"] --&gt; B["clock_skew_in_seconds=10"]
 C["로컬 fs 기반 ref 캐시"] --&gt; D[S3 기반 ref 캐시]
 E[모델 자동 인젝션: 아무 이미지] --&gt; F[태그 기반 속성 인식 인젝션]
 G[페르소나: 기존 라벨] --&gt; H[3-shot 재라벨 + 나이 추정]&lt;/pre&gt;&lt;hr&gt;
&lt;h2 id="google-oauth-클럭-스큐"&gt;Google OAuth 클럭 스큐
&lt;/h2&gt;&lt;h3 id="배경"&gt;배경
&lt;/h3&gt;&lt;p&gt;로그인 차단 &lt;code&gt;Invalid Google token: Token used too early, 1776217862 &amp;lt; 1776217863. Check that your computer's clock is set correctly.&lt;/code&gt; 서버 시계가 Google보다 ~1초 빠른 상태 — JWT &lt;code&gt;iat&lt;/code&gt;가 서버 관점에서 미래였다.&lt;/p&gt;
&lt;h3 id="해결"&gt;해결
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;backend/src/auth.py&lt;/code&gt;의 &lt;code&gt;id_token.verify_oauth2_token(...)&lt;/code&gt;에 &lt;code&gt;clock_skew_in_seconds=10&lt;/code&gt; 추가:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;id_token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;verify_oauth2_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;google_requests&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;GOOGLE_CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;clock_skew_in_seconds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;즉시 복구. 서버가 자기 시계를 3자의 &lt;code&gt;iat&lt;/code&gt;와 초 단위로 신뢰하면 안 된다 — JWT 검증에서 10초 톨러런스는 표준이며 의미있는 공격 표면을 열지 않는다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="s3-기반-레퍼런스-key-캐시"&gt;S3 기반 레퍼런스 key 캐시
&lt;/h2&gt;&lt;h3 id="배경-1"&gt;배경
&lt;/h3&gt;&lt;p&gt;모델/레퍼런스 이미지 캐시를 로컬 파일시스템에서 구축하고 있었다. 프로덕션에서 S3 마운트 경로가 항상 최신 업로드를 반영하지는 않아서 깨졌고, dev와 prod의 로컬 상태가 divergent했기 때문에도 깨졌다. &amp;ldquo;tone only&amp;rdquo; 모드에서 유저가 재생성하면 경로가 로컬 상태에서 resolve되어 UI가 잘못된 레퍼런스 이미지를 보여줬다.&lt;/p&gt;
&lt;h3 id="해결-1"&gt;해결
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;ce33906 fix(storage): build ref key cache from S3, not local filesystem&lt;/code&gt; — 캐시 구축을 S3 객체 나열로 바꿈. 모든 이미지 retrieval 경로가 S3 key 기준으로 resolve. 과거 생성 이력도 backfill해서 오래된 레코드가 올바른 S3 URL을 가리키게 수정.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="속성-인식-모델-자동-인젝션"&gt;속성 인식 모델 자동 인젝션
&lt;/h2&gt;&lt;h3 id="배경-2"&gt;배경
&lt;/h3&gt;&lt;p&gt;이전 인젝션 로직은 느슨한 조건에 매치되는 &lt;em&gt;아무&lt;/em&gt; 이미지나 끌고 왔다. 비교 모드(&amp;ldquo;tone + angle&amp;rdquo; vs &amp;ldquo;tone only&amp;rdquo;)에서 태그된 속성과 맞지 않는 모델 이미지가 인젝션되기도 했고, 유저는 출력 그리드에서 엉뚱한 레퍼런스를 봤다.&lt;/p&gt;
&lt;h3 id="해결-2"&gt;해결
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;d492ee1 feat(gen): attribute-aware model auto-injection&lt;/code&gt; — 요청된 모델 폴더의 태그된 속성(angle, tone) 기준으로 인젝션. &lt;code&gt;s3://diffs-studio-hybrid-search/.../01. Model&lt;/code&gt; 하위 서브폴더가 속성 그룹으로 취급되며 그룹당 레퍼런스 1개.&lt;/p&gt;
&lt;p&gt;전제: 각 모델 레퍼런스를 재라벨링해서 속성을 신뢰할 수 있게 만들어야 함. 폴더 단위 그룹핑은 라벨이 파일시스템에서 가시적인 스키마라는 뜻이다. DB 컬럼이 아니라서 운영팀이 S3 브라우징만으로 라벨을 감사하고 편집할 수 있다.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="페르소나-재라벨링-3-shot--나이"&gt;페르소나 재라벨링 (3-shot + 나이)
&lt;/h2&gt;&lt;h3 id="배경-3"&gt;배경
&lt;/h3&gt;&lt;p&gt;페르소나 라벨은 이전에 zero-shot 프롬프트로 설정되었고 나이 추정이 없었다. 유저 페이싱 필터가 나이 세분화를 요구했다.&lt;/p&gt;
&lt;h3 id="해결-3"&gt;해결
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;2743eaf chore(labels): re-label personas with 3-shot prompt and age estimates&lt;/code&gt; — 요청당 인컨텍스트 예시 3개와 age-range 필드로 라벨러 재실행. 라벨을 리포에 push해서 모든 서버가 pick up, 인스턴스별 라벨 drift 방지.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id="pm2--tsc-픽스"&gt;PM2 / TSC 픽스
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;95f8bbc fix(deploy): make ecosystem.config.js host-portable&lt;/code&gt; — 하드코딩된 절대 경로 제거로 dev와 prod에서 같은 config 동작. PM2가 어떤 &lt;code&gt;$HOME&lt;/code&gt;에서든 동일하게 부팅.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;6ebab0d fix(ui): drop unused generatingCount state to unblock tsc build&lt;/code&gt; — 최근 정리 후 tsc 빌드를 막던 dead state 변수. 삭제하고 빌드 통과.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 id="커밋-로그"&gt;커밋 로그
&lt;/h2&gt;&lt;table&gt;
 &lt;thead&gt;
 &lt;tr&gt;
 &lt;th&gt;메시지&lt;/th&gt;
 &lt;th&gt;영역&lt;/th&gt;
 &lt;/tr&gt;
 &lt;/thead&gt;
 &lt;tbody&gt;
 &lt;tr&gt;
 &lt;td&gt;fix(deploy): make ecosystem.config.js host-portable&lt;/td&gt;
 &lt;td&gt;PM2&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;fix(storage): build ref key cache from S3, not local filesystem&lt;/td&gt;
 &lt;td&gt;스토리지&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;feat(gen): attribute-aware model auto-injection&lt;/td&gt;
 &lt;td&gt;생성 로직&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;fix(ui): drop unused generatingCount state to unblock tsc build&lt;/td&gt;
 &lt;td&gt;프론트엔드&lt;/td&gt;
 &lt;/tr&gt;
 &lt;tr&gt;
 &lt;td&gt;chore(labels): re-label personas with 3-shot prompt and age estimates&lt;/td&gt;
 &lt;td&gt;라벨링&lt;/td&gt;
 &lt;/tr&gt;
 &lt;/tbody&gt;
&lt;/table&gt;
&lt;hr&gt;
&lt;h2 id="인사이트"&gt;인사이트
&lt;/h2&gt;&lt;p&gt;락인할 만한 패턴 둘. 첫째, &amp;ldquo;source of truth에서 캐시를 빌드&amp;quot;하는 게 &amp;ldquo;캐시를 source of truth와 동기화&amp;quot;하는 것보다 언제나 낫다. ref-key 캐시는 로컬 상태에서 시작해서 나중에 S3와 reconcile하려는 한 취약했다. S3에서 직접 빌드하면 drift 버그 카테고리 하나가 통째로 사라진다. 둘째, 클럭 스큐 픽스는 프로덕션 OAuth 실패가 거의 항상 crypto 이슈가 아니라 분산 시스템 이슈(클럭 동기화, DNS 전파, 키 로테이션)라는 리마인더다 — 10분 로그 읽고 1줄 고치면 끝나는 게 성숙한 스택에서 이 종류 이슈가 느껴져야 하는 모양이다.&lt;/p&gt;</description></item></channel></rss>