<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Shane's planet</title>
    <link>https://shanepark.tistory.com/</link>
    <description>Shane's planet</description>
    <language>ko</language>
    <pubDate>Tue, 14 Apr 2026 15:37:25 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Shane Park</managingEditor>
    <item>
      <title>SearXNG 소개 및 OpenClaw 연동</title>
      <link>https://shanepark.tistory.com/564</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;최근 OpenClaw를 사용하면서 가장 아쉬운 부분은 웹검색이었다. 에이전트가 최신 웹을 보게 하려면 결국 &lt;code&gt;web_search&lt;/code&gt; 품질이 받쳐줘야 하는데, 기본 후보로 많이 거론되는 Brave Search는 편한 대신 유료 API이고 사용량 제한도 신경 써야 한다. 그래서 Brave Search fallback으로 DuckDuckGo가 보통 사용된다.&lt;/p&gt;
&lt;p&gt;처음에는 Playwright로 여러 사이트를 방문하며 하는 식으로 기본 검색을 사용하도록 설정도 해봤는데 실제로 어느 정도 결과는 가져오지만, 여러 소스를 돌며 검색하기에는 일관성이 부족했고 속도도 아쉬웠다. 무엇보다 브라우저 화면을 그대로 읽다 보니 LLM이 바로 소비하기 좋은 형태가 아니어서 토큰도 더 쓰게 된다. 결국 내가 원한 것은 브라우저 자동화가 아니라, OpenClaw가 안정적으로 붙을 수 있는 검색 레이어였다.&lt;/p&gt;
&lt;p&gt;그래서 여러가지 테스트 끝에 결정하고 지금 사용중인게 SearXNG다. 이번 글에서는 SearXNG에 대한 나의 생각과 현재 쓰는 설정을 정리해본다.&lt;/p&gt;
&lt;h2&gt;SearXNG&lt;/h2&gt;
&lt;h3&gt;web_search&lt;/h3&gt;
&lt;p&gt;OpenClaw 공식 문서를 보면 &lt;code&gt;web_search&lt;/code&gt;는 브라우저 자동화가 아니라 가벼운 HTTP 검색 도구라고 되어있다. 반면 web_fetch는 이미 알고 있는 URL을 읽는 용도이고, JS가 많은 페이지나 로그인 흐름은 별도의 Browser 도구로 처리하라고 안내한다. 결국 검색 provider는 “무엇을 읽을지 찾는 단계”를 맡고, 그 뒤의 fetch나 browser가 실제 읽기와 상호작용을 담당하는 셈이다.&lt;/p&gt;
&lt;p&gt;그래서 OpenClaw에서 검색 provider는 옵션이 아니라 기반에 가깝다. 검색이 약하면 최신 웹 접근 전체가 흔들린다. 최근 OpenClaw를 만지면서 가장 먼저 체감한 한계도 바로 이 부분이었다. 기본설정상의 web_search는 검색 기능이 아쉽다.&lt;/p&gt;
&lt;h3&gt;JSON&lt;/h3&gt;
&lt;p&gt;일반 검색 결과로 보여지는 HTML은 기본적으로 사람을 위한 형식이다. 카드, 광고, 탭, 추천 검색어, 불필요한 마크업이 함께 섞여 있기 때문에 브라우저로 보기에는 편해도 에이전트가 바로 이해하기에는 비효율적이다.&lt;/p&gt;
&lt;p&gt;반면 OpenClaw 같은 에이전트는 구조화된 결과를 더 잘 다룬다. 제목, URL, snippet 같은 정보가 JSON으로 정리되어 있으면 어떤 링크를 볼지 고르기도 쉽고, 불필요한 화면 요소를 읽지 않아도 되니 토큰도 아낄 수 있다. 내가 Playwright 대신 별도 검색 레이어를 찾게 된 가장 큰 이유도 바로 이 점이었다.&lt;/p&gt;
&lt;p&gt;SearXNG는 검색 자체를 대신 만들어주는 단일 검색엔진이라기보다, 여러 검색 서비스를 한 인터페이스로 묶어주는 메타서치 엔진에 가깝다. 공식 문서도 최대 250개의 검색 서비스를 집계할 수 있다고 설명한다.&lt;/p&gt;
&lt;p&gt;개인적으로는 이 점이 핵심이었다. 특정 벤더 하나에 전적으로 의존하기보다, 어떤 엔진을 살리고 뺄지, 어떤 언어를 기본으로 둘지, 어떤 탭만 남길지 내가 직접 정할 수 있다. 검색 품질이 자동으로 좋아진다기보다 검색 경로에 대한 제어권을 돌려받는 느낌에 가깝다.&lt;/p&gt;
&lt;p&gt;또 OpenClaw는 SearXNG를 단순 HTML 스크래핑이 아니라 네이티브 JSON API로 붙인다. 검색을 위해 다시 브라우저를 돌리는 구조가 아니라, 비교적 일정한 형태의 검색 결과를 받을 수 있다는 점에서 궁합이 괜찮았다.&lt;/p&gt;
&lt;h3&gt;비용&lt;/h3&gt;
&lt;p&gt;공식 문서 기준 OpenClaw는 Brave Search, DuckDuckGo, Exa, Firecrawl, Gemini, Perplexity, SearXNG 등을 지원한다. 이 중 Brave Search는 가장 무난해 보였지만, 2026년 4월 10일 기준 Brave Search API 페이지는 월 5달러 무료 크레딧과 이후 1,000건당 5달러 가격 정책을 안내하고 있다.&lt;/p&gt;
&lt;p&gt;취미나 테스트 단계에서는 크게 부담이 아닐 수도 있다. 다만 개인적으로는 검색 레이어에 계속 외부 과금 구조를 얹는 것이 마음에 들지 않았다. 특히 OpenClaw처럼 이것저것 붙여보며 자주 실험할 때는 더 그랬다.&lt;/p&gt;
&lt;p&gt;물론 SearXNG를 마냥 공짜라고만 보면 곤란하다. 공식 문서도 공개 인스턴스는 관리자 신뢰 문제가 있고, 보호가 약하면 CAPTCHA나 IP ban 때문에 결과가 줄 수 있다고 경고한다. 결국 SearXNG는 외부 API 비용을 운영 복잡도로 바꾸는 선택에 가깝다. 그래도 개인 서버에서 통제 가능한 검색 레이어를 갖고 싶다면 충분히 매력적이라고 생각했다. 설정과정이 다소복잡하다는 단점이 있지만 Claude Code나 Codex의 도움을 받으면 된다.&lt;/p&gt;
&lt;h2&gt;로컬테스트&lt;/h2&gt;
&lt;p&gt;OpenClaw에 바로 붙이기 전에 먼저 로컬에서 단독으로 띄워서 테스트했다. 브라우저에서 localhost:8888로 접속해 검색 결과가 의도대로 나오는지 확인해보는 편이 훨씬 마음이 편했다.&lt;/p&gt;
&lt;h3&gt;compose.yaml&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  searxng:
    image: docker.io/searxng/searxng:latest
    container_name: searxng-test
    restart: unless-stopped
    ports:
      - &amp;quot;127.0.0.1:8888:8080&amp;quot;
    volumes:
      - ./config:/etc/searxng
      - ./data:/var/cache/searxng
    healthcheck:
      test: [&amp;quot;CMD-SHELL&amp;quot;, &amp;quot;wget -qO- http://127.0.0.1:8080/ &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 || exit 1&amp;quot;]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 20s&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;구성 자체는 복잡하지 않다. 설정 디렉터리를 바인드해서 &lt;code&gt;settings.yml&lt;/code&gt;만 직접 관리하고, 캐시 디렉터리를 분리해두는 정도면 테스트용으로 충분했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/searxng.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/searxng.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;도커를 띄우면 브라우저에서 localhost:8888 에 직접 접속해서 직접 검색을 해볼 수 있다. &lt;/p&gt;
&lt;p&gt;이 단계에서 아래의 설정값에 맞게 탭 구성이 의도대로 줄었는지 지정된 검색엔진들이 쓰였는지, Naver 결과가 실제로 잡히는지, 응답 속도는 괜찮은지 등을 확인하고 조율할 수 있다. OpenClaw 통합 전에 먼저 커스텀을 충분히 해두는걸 추천한다.&lt;/p&gt;
&lt;p&gt;Google News에 구문 분석 오류가 떠있는데 이런 부분도 확인하며 수정해줘야 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;settings.yml&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;settings.yml&lt;/code&gt;에서는 기본 엔진을 그대로 다 열어두지 않고, 필요한 것만 남기는 방향으로 정리했다. 여기서 핵심은 &lt;code&gt;use_default_settings&lt;/code&gt;로 upstream 기본값을 상속받고, 필요한 override만 얹는 방식이다. 개인적으로는 이게 설정 파일을 가볍게 유지하는 가장 좋은 방법이었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;# Keep this file limited to intentional overrides from upstream defaults.
use_default_settings:
  engines:
    # Use an allowlist so removed engines stay gone across upgrades.
    # Brave is intentionally excluded because it was producing rate-limit noise.
    # Wikipedia/Wikidata and the broader wiki-heavy set are intentionally excluded
    # because they were slow enough to become bottlenecks and were not returning
    # especially useful results for this instance.
    keep_only:
      - duckduckgo
      - duckduckgo images
      - duckduckgo news
      - duckduckgo videos
      - google
      - google images
      - google news
      - google videos
      - naver
      - naver images
      - naver news
      - naver videos

search:
  autocomplete: &amp;quot;&amp;quot;
  languages:
    - ko
    - en
  default_lang: &amp;quot;ko&amp;quot;
  formats:
    - html
    - json

server:
  secret_key: &amp;quot;CHANGE_ME&amp;quot;
  # GET makes browser navigation and tab handling nicer than POST for personal use.
  method: &amp;quot;GET&amp;quot;

ui:
  default_locale: &amp;quot;ko&amp;quot;

# Keep only tabs that still have engines in the allowlist.
categories_as_tabs:
  general:
  images:
  news:
  videos:

engines:
  # Use a custom XPath parser for Naver web because this instance wants Naver enabled
  # as a primary Korean source and tuned to the current page structure.
  - name: naver
    engine: xpath
    paging: false
    search_url: https://search.naver.com/search.naver?where=web&amp;amp;query={query}
    results_xpath: # 여기는 당시의 페이지 구조에 맞게 별도 조정
    url_xpath: # 여기는 당시의 페이지 구조에 맞게 별도 조정
    disabled: false

  # Enable Naver verticals by default.
  - name: naver images
    disabled: false

  - name: naver news
    disabled: false

  - name: naver videos
    disabled: false&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 특히 중요한 건 &lt;code&gt;search.formats&lt;/code&gt;의 json이다. SearXNG 공식 Search API 문서에 따르면 JSON으로 결과를 받으려면 해당 포맷이 활성화되어 있어야 하고, 꺼져 있으면 403 Forbidden이 난다. OpenClaw에서 SearXNG를 provider로 붙일 생각이라면 사실상 미리 넣어두는 편이 안전하다.&lt;/p&gt;
&lt;p&gt;기본 설정에서 naver는 disabled 되어 있다. 이번 용도에서는 한국어와 영어 웹 검색이 중심이라 Google, DuckDuckGo, Naver 정도만 남기는 편이 더 낫다고 판단했다. languages와 default_lang를 ko, en, ko로 둔 것도 같은 이유다. 탭 역시 general, images, news, videos 정도만 남겨서 UI를 단순하게 유지했다.&lt;/p&gt;
&lt;p&gt;추가로 나는 Naver 웹 검색을 기본 엔진으로 쓰기 위해 현재 페이지 구조에 맞춘 xpath 엔진 설정도 따로 넣었다. 손이 가는 부분이지만, 한국어 검색 품질을 챙기려면 의미가 있다. 반면 Brave Search는 rate-limit noise가 있었고, Wikipedia, Wikidata처럼 상대적으로 느리거나 이번 용도에서 효율이 낮다고 느낀 쪽은 과감히 제외했다.&lt;/p&gt;
&lt;h2&gt;OpenClaw 통합&lt;/h2&gt;
&lt;h3&gt;버전&lt;/h3&gt;
&lt;p&gt;OpenClaw 공식 문서의 SearXNG Search 페이지를 보면 설정 자체는 매우 단순하다. SearXNG 인스턴스를 하나 띄우고, &lt;code&gt;openclaw configure --section web&lt;/code&gt;에서 provider를 searxng로 고르거나 SEARXNG_BASE_URL만 지정하면 될 것처럼 보인다.&lt;/p&gt;
&lt;p&gt;그런데 내 환경에서는 처음에 이 방식이 바로 동작하지 않았다. 확인해보니 2026년 4월 1일 공개된 OpenClaw 2026.4.1 릴리스에서야 web_search용 bundled SearXNG provider plugin이 추가되었다. 즉, 문서만 보면 간단해 보이지만 실제로는 설치된 OpenClaw 버전이 충분히 최신이어야 했다.&lt;/p&gt;
&lt;h3&gt;compose&lt;/h3&gt;
&lt;p&gt;OpenClaw를 업데이트한 뒤에는 같은 compose 안에 SearXNG 서비스를 추가하는 방식으로 붙였다. 이번 구성에서는 OpenClaw와 SearXNG가 같은 네트워크에서 통신하므로, 테스트용 단독 인스턴스처럼 굳이 호스트 포트를 열지 않았다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  searxng:
    image: docker.io/searxng/searxng:latest
    restart: unless-stopped
    volumes:
      - ./searxng/config:/etc/searxng
      - ./searxng/data:/var/cache/searxng
    healthcheck:
      test: [&amp;quot;CMD-SHELL&amp;quot;, &amp;quot;wget -qO- http://127.0.0.1:8080/ &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 || exit 1&amp;quot;]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 20s&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;구성 자체는 복잡하지 않다. 핵심은 OpenClaw 옆에 SearXNG 서비스를 하나 더 두고, 설정 디렉터리와 캐시 디렉터리를 분리하는 정도다. 직접 포트를 외부에 노출하지 않아도 되니 compose가 조금 더 깔끔해졌다.&lt;/p&gt;
&lt;h3&gt;provider&lt;/h3&gt;
&lt;p&gt;공식 문서 기준 SearXNG는 auto-detection order가 200이다. 그래서 다른 provider 키가 이미 들어 있는 환경이라면 자동 선택을 기대하기 어렵다. 실제로 붙일 때는 provider를 명시적으로 searxng로 잡는 편이 더 안전하다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;.openclaw/openclaw.json&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt; &amp;quot;plugins&amp;quot;: {
    &amp;quot;entries&amp;quot;: {
      &amp;quot;browser&amp;quot;: {
        &amp;quot;enabled&amp;quot;: true
      },
      &amp;quot;searxng&amp;quot;: {
        &amp;quot;enabled&amp;quot;: true,
        &amp;quot;config&amp;quot;: {
          &amp;quot;webSearch&amp;quot;: {
            &amp;quot;baseUrl&amp;quot;: &amp;quot;http://searxng:8080&amp;quot;,
            &amp;quot;categories&amp;quot;: &amp;quot;general,news&amp;quot;,
            &amp;quot;language&amp;quot;: &amp;quot;ko&amp;quot;
          }
        }
      },
      &amp;quot;openai&amp;quot;: {
        &amp;quot;enabled&amp;quot;: true
      }
    }
  },
...
 &amp;quot;tools&amp;quot;: {
    &amp;quot;web&amp;quot;: {
      &amp;quot;search&amp;quot;: {
        &amp;quot;enabled&amp;quot;: true,
        &amp;quot;provider&amp;quot;: &amp;quot;searxng&amp;quot;,
        &amp;quot;openaiCodex&amp;quot;: {
          &amp;quot;enabled&amp;quot;: false,
          &amp;quot;mode&amp;quot;: &amp;quot;cached&amp;quot;
        }
      }&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Codex의 기본 검색엔진도 사용해봤는데, 실시간성이 많이 떨어져서 내 기준에서는 써먹을 수가 없었다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;plugins/searxng/openclaw.plugin.json이나 plugins/searxng/src/searxng-web-search-provider.js 같은 파일을 직접 만들어 넣지 않아도 된다는 점이 좋았다. 2026.4.1 버전 이전이라면 꽤 귀찮았을 일을 버전 업데이트 한 번으로 넘긴 셈이다.&lt;/p&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;에이전트에게 검색 능력은 정말 중요하다. 최근 OpenClaw를 만져보면서 특히 그 부분을 크게 느꼈고, 그래서 여러 우회 방법을 찾다가 SearXNG까지 오게 되었다. 지금 단계 기준으로는 Brave Search 같은 상용 검색 API 의존도를 낮추면서도, 브라우저 자동화보다 더 다루기 쉬운 검색 레이어를 만들고 싶을 때 가장 균형이 좋은 선택지라고 생각한다.&lt;/p&gt;
&lt;p&gt;다만 한번 설정해 두면 끝나는게 아니고 검색엔진을 계속해서 커스텀 하며 내 입맛에 맞춰야하고, 검색 워크플로에 문제가 생겼을 때는 스스로 해결해야 한다는 점은 장점이기도 하고 단점이기도 하겠다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.openclaw.ai/tools/web&quot;&gt;https://docs.openclaw.ai/tools/web&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.openclaw.ai/tools/searxng-search&quot;&gt;https://docs.openclaw.ai/tools/searxng-search&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/openclaw/openclaw/releases/tag/v2026.4.1&quot;&gt;https://github.com/openclaw/openclaw/releases/tag/v2026.4.1&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.searxng.org/&quot;&gt;https://docs.searxng.org/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.searxng.org/dev/search_api.html&quot;&gt;https://docs.searxng.org/dev/search_api.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.searxng.org/own-instance.html&quot;&gt;https://docs.searxng.org/own-instance.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/searxng/searxng&quot;&gt;https://github.com/searxng/searxng&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://brave.com/search/api/&quot;&gt;https://brave.com/search/api/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Data/LLM</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/564</guid>
      <comments>https://shanepark.tistory.com/564#entry564comment</comments>
      <pubDate>Fri, 10 Apr 2026 15:55:13 +0900</pubDate>
    </item>
    <item>
      <title>gemma4 출시 소식 및 사용 후기</title>
      <link>https://shanepark.tistory.com/563</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://shanepark.tistory.com/556&quot;&gt;무료 Gemini 2.5 API에서 Gemma 3로의 강제 이주기&lt;/a&gt;에서 Gemini 무료 티어가 대폭 축소되면서 Gemma 3로 이주했던 경험을 기록했다. 무료로 사용할 수 있는 상용 LLM API 의 선택 범위가 너무 좁다보니 성능손해를 보면서도 선택했고, 당시 추론 능력 차이로 인해 프롬프트 엔지니어링에 꽤 시간을 쏟아야 해 아쉬웠는데, 오늘은 Google의 Gemma 4 출시 소식을 들었다.&lt;/p&gt;
&lt;p&gt;Gemma 4는 Gemini 3와 동일한 연구 기반으로 만들어진 오픈웨이트 모델로, 31B Dense와 26B MoE를 포함한 네 가지 크기로 제공되며 Apache 2.0 라이선스로 공개되었다. 특히 Gemma 3 대비 추론 능력이 크게 향상되었다는 점이 눈에 띄었다. 바로 기존 프로젝트에 모델명만 바꿔 테스트해보니 Gemma 3 때와는 결과가 차원이 달랐고, API를 Gemma 4로 전환하는 정도를 넘어 느낀바가 많았기에 글을 정리해본다.&lt;/p&gt;
&lt;h2&gt;테스트&lt;/h2&gt;
&lt;h3&gt;Rate Limit&lt;/h3&gt;
&lt;p&gt;google AI studio에 접속해 Rate Limit 을 확인해보니 Gemma 4도 추가가 되어 있었다. 경량모델답게 Gemma3 때와 동일한 매우 관대한 정책을 취하고 있다. gemini api를 쓰다가 한번 호되게 당한적이 있기 때문에 Rate Limit이 계속 유지될거라는 기대는 하지 않지만 그래도 그때와 다르게 Gemma는 경량모델이며 공개된 모델이기 때문에 전환에 부담이 없다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/gemma4.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://aistudio.google.com/app/rate-limit&quot;&gt;https://aistudio.google.com/app/rate-limit&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;RPM 30, TPM 16K 로 무료 Gemini API 에서는 Gemma4의 256K Context window를 모두 사용해볼 수는 없다.&lt;/p&gt;
&lt;p&gt;가볍고 단순한 작업을 돌려보기엔 충분하며 모델 크기별로 쿼터가 별도로 돌기 때문에 26B, 31B를 번갈아가며 사용해도 된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;gemma3 API 사용프로젝트&lt;/h3&gt;
&lt;p&gt;gemma3 API를 사용하는 프로젝트에서 그대로 gemma4 로 변경하여 테스트코드들을 돌려보았다. 단순하게 &lt;code&gt;gemma-3-27b-it&lt;/code&gt; 를 &lt;code&gt;gemma-4-31b-it&lt;/code&gt; 로 변경하는 것 만으로 즉시 모델이 변경되기 때문에 매우 편하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;private fun makeService(): ScheduleTimeParsingService {
        val timeout = Duration.ofMinutes(2)
        val httpClient = HttpClient.create()
            .responseTimeout(timeout)
        val requestFactory = ReactorClientHttpRequestFactory(httpClient).apply {
            setConnectTimeout(timeout)
            setReadTimeout(timeout)
        }
        val connector = ReactorClientHttpConnector(httpClient)

        val openapi = OpenAiApi
            .builder()
            .apiKey(apiKey)
            .baseUrl(&amp;quot;https://generativelanguage.googleapis.com/v1beta/openai/&amp;quot;)
            .completionsPath(&amp;quot;/chat/completions&amp;quot;)
            .restClientBuilder(
                RestClient.builder()
                    .requestFactory(requestFactory)
            )
            .webClientBuilder(
                WebClient.builder()
                    .clientConnector(connector)
            )
            .build()

        val chatOption = OpenAiChatOptions
            .builder()
            .model(&amp;quot;gemma-4-31b-it&amp;quot;)
//            .model(&amp;quot;gemma-3-27b-it&amp;quot;)
            .temperature(0.0)
            .build()

        val chatModel = OpenAiChatModel
            .builder()
            .openAiApi(openapi)
            .defaultOptions(chatOption)
            .build()

        val service = ScheduleTimeParsingService(
            chatModel = chatModel,
            jsonMapper = jsr310JsonMapper()
        )
        return service
    }&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;주의사항&lt;/h3&gt;
&lt;p&gt;다만 추론능력이 생기며 처리시간이 좀 더 걸리기 때문에 timeout은 기존보다 늘려줘야 했다. 테스트 코드를 돌려 보았는데 응답에 10초가 넘게 걸리면서 타임아웃 계속해서 발생했다.&lt;/p&gt;
&lt;p&gt;또한 응답에 추론블럭이 포함되기때문에 기존의 데이터 정규화 관련 코드는 수정이 좀 필요했다. 그래도 추론블럭 안의 텍스트를 보면 추론 성능이 좋아진게 눈에 띄어서 성능을 기대해 보아도 괜찮겠다는 생각이 벌써부터 들었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;15:56:26.284 [Test worker] WARN com.tistory.shanepark.dutypark.schedule.timeparsing.service.ScheduleTimeParsingService -- Failed to parse JSON: &amp;lt;thought&amp;gt;*   Input: `{&amp;quot;date&amp;quot;:&amp;quot;2025-02-28&amp;quot;,&amp;quot;content&amp;quot;:&amp;quot;2:50 산본제일 진료&amp;quot;}`
    *   Task: Extract time from Korean schedule text.
    *   Output: JSON only.

    *   Content: &amp;quot;2:50 산본제일 진료&amp;quot;
    *   Time marker: `2:50` (contains `:`)
    *   Hour: 2
    *   Minute: 50
    *   Date: 2025-02-28

    *   Rule: &amp;quot;Default hours: 1-6 = PM, 7-11 = AM (but consider activity context)&amp;quot;
    *   Hour is 2.
    *   Activity: &amp;quot;산본제일 진료&amp;quot; (Medical treatment/clinic visit).
    *   Medical clinics usually operate during the day/afternoon, not at 2 AM.
    *   Therefore, 2:50 should be interpreted as PM (14:50).

    *   `result`: true (Normal case)
    *   `hasTime`: true (Time marker `2:50` found)
    *   `startDateTime`: &amp;quot;2025-02-28T14:50:00&amp;quot;
    *   `endDateTime`: &amp;quot;2025-02-28T14:50:00&amp;quot; (No range specified)
    *   `content`: &amp;quot;산본제일 진료&amp;quot; (Strip the time expression &amp;quot;2:50&amp;quot;)

    *   Is it a logical error? No.
    *   Is it an impossible hour? No.
    *   Is it multiple times? No.
    *   Is it a relative time? No.

{&amp;quot;result&amp;quot;:true,&amp;quot;hasTime&amp;quot;:true,&amp;quot;startDateTime&amp;quot;:&amp;quot;2025-02-28T14:50:00&amp;quot;,&amp;quot;endDateTime&amp;quot;:&amp;quot;2025-02-28T14:50:00&amp;quot;,&amp;quot;content&amp;quot;:&amp;quot;산본제일 진료&amp;quot;}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;여러가지 합리적인 추론을 단계적으로 해간다. Gemma3는 못했던 것이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/gemma4.assets/3.webp&quot; alt=&quot;3&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;추론으로 인해 각 테스트가 10초 이상 걸렸지만, 최종 테스트는 문제 없이 통과했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;Gemini 2.5에서 Gemma3 로 넘어갈때 깨졌던 테스트코드로 마주했던 수많은 빨간불이 기억난다. gemma4는 그때를 비웃기라도 하듯 한번에 모두 파란불이 들어왔다. 과거에 Gemma3 때문에 수없이 깎아냈던 프롬프트 엔지니어링을 생각하면 상당히 감동스러운 순간이다.&lt;/p&gt;
&lt;p&gt;추가로 추론으로 인해 응답이 늦어지다보니 RPM / TPM 이 이전처럼 빠듯하게 돌아가지는 않아서 좋은점도 있다. 병렬로 돌리지 않는다면 딱히 limit에 걸릴 것 같지는 않다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/gemma4.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Rate limit에 여유가 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;Ollama&lt;/h3&gt;
&lt;p&gt;ollama 및 Hugging Face 에도 공개 되어있어 로컬에서 돌려볼 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ollama run gemma4:e4b&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;ollama 최신 버전이 필요하다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;로컬 LLM에도 관심이 많아 ollma 에서 바로 돌려보았다. &lt;/p&gt;
&lt;p&gt;일단 m5 pro 24GB 맥북에서 31b 를 시도해봤는데 어떻게든 돌아가긴 하는데 tps가 터무니없이 안나온다. 적어도 48G 이상의 통합메모리가 있어야 쓸만할 것 같다. 그래서 e4b 로 다시 시도해보았는데 여기에서 &lt;code&gt;E&lt;/code&gt; 는 &lt;code&gt;Effective Parameters&lt;/code&gt; 를 뜻한다. ollama에서의 모델 사이즈도 26b의 18GB의 절반인 9.6GB 에 달하기 때문에 4b라고 무시할건 아니다.&lt;/p&gt;
&lt;p&gt;e4b로 변경하자 m5 pro 24GB 맥북에서 일반 상용 API 들 쓰는 것 이상의 좋은 TPS가 나왔으며 Memory Pressure 도 낮은 수준이 유지되었다.&lt;/p&gt;
&lt;p&gt;TPS 뿐만 아니라 응답 품질도 굉장히 만족스러웠다. 이정도 성능이 로컬에서 이정도로 가볍고 빠르게 돌아간다는게 믿기지 않는다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/gemma4.assets/4.webp&quot; alt=&quot;4&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://ollama.com/library/gemma4&quot;&gt;https://ollama.com/library/gemma4&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;벤치마크 결과도 Gemma3 와 비교하는게 실례일 정도로 크게 차이가 났다. Gemma4 E2B 모델조차 Gemma 3 27B 모델을 압도한다.&lt;/p&gt;
&lt;p&gt;최근에 qwen3.5에서 큰 감동을 받았었는데 앞으로 로컬 모델로 qwen과 함께 애용하게 될 듯 하다. 이정도면 무료 API 사용 못하게 되더라도 그냥 로컬에서 돌려버리면 그만이겠다.&lt;/p&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;AI시대가 왔다고 하지만 진짜 생활에서는 프론티어모델 못지않게 경량모델들의 성능이 더 중요할거라고 생각한다. 개인 핸드폰, 노트북을 넘어 조금만 지나면 청소기, 세탁기, 건조기, 식기세척기, 로봇청소기 등의 가전들이 스스로 생각하고 진짜 AI가 될날이 아주 가까워졌다. &lt;/p&gt;
&lt;p&gt;실생활에 인공지능을 접목시키고 싶은 분야가 너무 많았는데 앞으로의 일들이 정말 기대된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.google/innovation-and-ai/technology/developers-tools/gemma-4/&quot;&gt;https://blog.google/innovation-and-ai/technology/developers-tools/gemma-4/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://x.com/JeffDean/status/2039736943693668800&quot;&gt;https://x.com/JeffDean/status/2039736943693668800&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://huggingface.co/blog/gemma4&quot;&gt;https://huggingface.co/blog/gemma4&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://ollama.com/library/gemma4&quot;&gt;https://ollama.com/library/gemma4&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Data/LLM</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/563</guid>
      <comments>https://shanepark.tistory.com/563#entry563comment</comments>
      <pubDate>Fri, 3 Apr 2026 23:11:09 +0900</pubDate>
    </item>
    <item>
      <title>우분투에 Openclaw 설치하기 및 후기</title>
      <link>https://shanepark.tistory.com/562</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;2026년 1월 몰트북 사태와 함께 OpenClaw(a.k.a Clawbot/Moltbot.. 이름을 자주도 바꿨다) 가 바이럴되며 맥미니가 품절되는 사건이 있었다. 개인적인 생각으로는 아무리 통합 메모리라 한들 맥미니로 대형 로컬모델을 돌리는건 무리가 있으니.. 어차피 API 연결해서 상용모델 사용할거라면 굳이 비싼 돈 들여 장만할 필요 없이 적당한 클라우드 인스턴스에 우분투 설치해서 하면 되지 않나 생각했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;물론 홈 서버로서의 맥미니의 역할은 개인적으로 매우 높게 평가한다.&lt;/p&gt;
&lt;p&gt;지금 홈 서버로 사용하고 있는 10년차 노트북이 있는데, SSD도 새로 달아주고 램도 추가해주면서 서버로서의 임무를 오래 부여해 오고 있다. 이 컴퓨터의 수명이 다한다면 다음 홈서버로는 맥 미니를 생각하고 있으며 전력 소모나 발열관리등을 생각했을때는 지금이라도 바꾸고싶은 마음이 있다. 맥미니가 공짜로 생긴다면 당장이라도 바꿀것이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;마침 NHN Cloud 에 사용하지 않고 있는 크레딧이 꽤 있다보니 클라우드에 한번 설치해보고 GPT의 Agent mode 와는 어떤 차별점이 있으며 어느정도의 활용 가능성이 있을지를 테스트해보기로 했다. &lt;/p&gt;
&lt;h2&gt;설치&lt;/h2&gt;
&lt;p&gt;시스템 기본 도구 설치&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt update &amp;amp;&amp;amp; sudo apt upgrade -y
sudo apt install git curl build-essential -y&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Node 설치 (v22.16.0 이상 필요, Node 24 권장)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
source ~/.bashrc
nvm install 24 # Node.js 24 버전 설치
node -v # 설치된 버전 확인 (v24.x.x가 나와야 함)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;OpenClaw 설치&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;npm install -g openclaw@latest
# or: pnpm add -g openclaw@latest

#설치 잘 된 것 확인
openclaw --version # OpenClaw 2026.3.23-2 (7ffe7e4)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;데몬 설치&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;openclaw onboard --install-daemon&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;동의&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Setup mode 선택&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/3.webp&quot; alt=&quot;3&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Model 제공자 선택&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;ChatGPT OAuth 를 사용할건데, 지금 시점에서는 최고의 선택일거라고 생각된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/4.webp&quot; alt=&quot;4&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;브라우저에서 표시된 url만 붙여넣으면 되어 편하다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이 때 localhost:1455 로 리다이렉트가 되는데 당황하지 말고 url의 callback 쿼리파라미터에 적힌 텍스트 혹은 전체 주소를 복사해서 ssh 접속한 터미널의 &lt;code&gt;Paste the authorization code (or full redirect URL):&lt;/code&gt; 에 그대로 붙여넣기만 해주면 된다.&lt;/p&gt;
&lt;p&gt;이후 GPT 모델까지 선택 하고 나면 채널선택을 한다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/5.webp&quot; alt=&quot;5&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;텔레그램이 가장 많이 추천된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/6.webp&quot; alt=&quot;6&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;첫번째꺼가 쉽다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;그러면 토큰을 입력하라고 하는데, 텔레그램앱에서 BotFather 를 검색해 대화를 시작하고 &lt;code&gt;/newbot&lt;/code&gt; 명령어를 입력하면 된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/7.webp&quot; alt=&quot;7&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/8.webp&quot; alt=&quot;8&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;봇 이름을 지정하는데 자꾸 다 안 된단다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;대화방에 텔레그램 봇 토큰을 주면 그걸 복사해서 붙여넣기 해주면 된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/9.webp&quot; alt=&quot;9&quot;&gt;&lt;/p&gt;
&lt;p&gt;그다음은 Search Provider 를 고르는데 다른 브라우저들은 API key를 요구하는게 많으니 DuckDuckGo를 이용한다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/10.webp&quot; alt=&quot;10&quot;&gt;&lt;/p&gt;
&lt;p&gt;그다음은 skills 설정을 하라고 하는데 추천이라고 하니 해준다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/11.webp&quot; alt=&quot;11&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;적당히 필요할법한 스킬들을 추가한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/12.webp&quot; alt=&quot;12&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Skill 설치를 위한 node manager는 pnpm으로 설치했다.&lt;/p&gt;
&lt;p&gt;대신 pnpm이 없으면 설치 해줘야한다. &lt;code&gt;npm install -g pnpm&lt;/code&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/13.webp&quot; alt=&quot;13&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;필요한 hook 을 고른다 &lt;code&gt;command-logger&lt;/code&gt; 와 &lt;code&gt;session-memory&lt;/code&gt;는 하는편이 좋아보인다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/20.webp&quot; alt=&quot;20&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이제 bot을 실행한다. TUI 에서 그냥 바로 실행하면 된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/15.webp&quot; alt=&quot;15&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;봇이 실행되었다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이제 텔레그램에서 방금 등록한 bot을 찾아 &lt;code&gt;/start&lt;/code&gt; 해준다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/16.webp&quot; alt=&quot;16&quot;&gt;&lt;/p&gt;
&lt;p&gt;그런데 텔레그램 토큰입력과는 별개로 추가로 페어링이 필요하다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/17.webp&quot; alt=&quot;17&quot;&gt;&lt;/p&gt;
&lt;p&gt;이제 이 페어링 코드를 이용해 아래와 같이 등록할 수도 있지만.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;openclaw pairing approve telegram &amp;lt;페어링코드&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;우리에겐 똑똑한 bot이 생겼다. 그냥 터미널에 떠있는 대화창에 해당 페어링 코드로 등록해달라고 한마디 해주면 알아서 해준다.&lt;/p&gt;
&lt;p&gt;Approved 된 후에는 이제 대화가 가능해진다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/18.webp&quot; alt=&quot;18&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;연결 완료&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이제 편하게 telegram을 이용해서 내 개인 비서에게 일을 시키면 된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/openclaw.assets/19.webp&quot; alt=&quot;19&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;요청시 즉각 typing 중이라는 표시가 되며 금방 응답이 온다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;사용 후기&lt;/h2&gt;
&lt;p&gt;기대보다 활용도가 정말 많고 딥해서 정말 인상적이었다. 카카오덕분에 저렴하게 구매한 ChatGPT Pro 플랜으로 토큰도 맘껏 사용할 수 있다.&lt;/p&gt;
&lt;p&gt;웹에서 사용하는 LLM은 대단하지만 한계가 있다. ChatGPT나 Gemini, Claude 같은 채팅형 LLM 서비스 그 자체는 단지 사용자의 질문에 대답을 해 주는 것 이상으로 할 수 있는게 없다. 거기에서 이제 에이전트로 발전한게 Claude Code, Codex 같은 건데 코딩이라는 한정된 사용 목적 안에서만 활동한다.&lt;/p&gt;
&lt;p&gt;물론 Claude Code 나 Codex 에도 권한을 충분히 부여하면 그 이상의 에이전트로서의 역할을 충분히 해낼 수 있지만 ClawBot은 거기에서 한단계 더 한계를 뛰어넘었다. 책상에 앉아 공부만 잘하던 범생이 친구가 그 비상한 머리를 가지고 벌떡 일어나 세상 밖으로 나온 느낌이다. &lt;/p&gt;
&lt;p&gt;물론 보안 문제로 당장 실현되긴 어렵겠지만 서버개발자의 새벽, 주말 장애 대응을 대신해주는것도 충분히 가능할거라 생각된다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;사람들이 말하는거 보니 호들갑이던데?&lt;/li&gt;
&lt;li&gt;보안때문에 어차피 아무데도 못 쓰게 될 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;위와 같은 평가를 내리고 설치할 생각을 안할 수 있다고 생각한다. 충분히 그럴 수 있다. &lt;/p&gt;
&lt;p&gt;하지만 클라우드에 작은 인스턴스 하나 띄우고, codex oauth 로 연결 하면 추가 비용 거의 없이 사용을 해 볼 수 있다. 설치 방법도 어렵지 않다 꼭 해보기를 추천한다. 내가 가진 경험과 직관만으로 아직 경험해보지 못한 세상을 상상하는건 분명한 한계가 있다.&lt;/p&gt;
&lt;p&gt;개인적으로도 이것저것 재밌는 활용 방안이 많아서 즐겁다. 관심 미국주식들의 간밤 동향 및 상승/하락 이유에 대해 요약을 해주는 등 자동화가 어렵다고 여겨왔던 필드에서 자동화가 가능해지는게 굉장히 많다. 복잡한 설정도 필요없이 필요한것만 요청하면 뒤에서 일어나는 일들은 알아서 처리해준다. 사람이 하던 일을 상당부분 대신해주는데 여기에 실시간과 자율성이 부여된다. &lt;/p&gt;
&lt;p&gt;그야말로 개인 비서다. 개인 비서를 가져 본 경험은 오직 극소수의 사람들만이 있을것이다. &lt;/p&gt;
&lt;p&gt;ChatGPT의 Agent 기능을 개인적으로 좋아했었는데 OpenClaw를 써보니 앞으로 한동안은 해당 기능을 전혀 사용할 일이 없을 것 같다. Apple의 Siri가 스스로 되고자 했던게 이런게 아니었을까 싶다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/openclaw/openclaw&quot;&gt;https://github.com/openclaw/openclaw&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Data/LLM</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/562</guid>
      <comments>https://shanepark.tistory.com/562#entry562comment</comments>
      <pubDate>Thu, 26 Mar 2026 01:39:23 +0900</pubDate>
    </item>
    <item>
      <title>Codex 서브에이전트 최대 스레드 수 늘리기</title>
      <link>https://shanepark.tistory.com/561</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;codex&lt;/code&gt;에 서브에이전트가 생기면서 병렬 작업이 가능해졌다. 기본값은 최대 6개 스레드인데, 큰 작업을 여러 에이전트로 쪼개다 보면 금방 한계에 닿는다. 클로드코드에서는 별도의 제한이 없었는데, 기본적으로 병렬 상한이 10개로 순차 배치 처리한다고 알려져있지만 내 경험으로는 20개이상을 동시에 요청해도 동시에 돌아가는걸 확인했었다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/codex-subagents-max.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;codex 에서 서브에이전트 20개를 요청하자 6개까지 생성하고 14개는 생성에 실패한 상태&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;최근 출시한 M5 맥북 프로의 경우에는 CPU가 무려 15코어부터 시작하는데 서브에이전트 6개 제한은 많이 아쉽다.&lt;/p&gt;
&lt;p&gt;이 경우 수치를 늘리고싶다면 &lt;code&gt;config.toml&lt;/code&gt;에서 직접 조정할 수 있다.&lt;/p&gt;
&lt;h2&gt;설정 방법&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;~/.codex/config.toml&lt;/code&gt;에 아래 항목을 추가하면 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-toml&quot;&gt;[agents]
max_threads = 25   # 동시에 열 수 있는 에이전트 스레드 상한 (기본값: 6)
max_depth = 1      # 에이전트 중첩 깊이 (기본값: 1)
# job_max_runtime_seconds 는 서브에이전트의 타임아웃이고 기본값은 1800초&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;파일이 없다면 새로 만들면 되는데 내 경우는 이미 파일이 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;mkdir -p ~/.codex
vi ~/.codex/config.toml&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;프로젝트마다 다른 설정을 적용하고 싶다면 레포 루트의 &lt;code&gt;.codex/config.toml&lt;/code&gt;에 동일하게 작성하면 된다. 프로젝트 설정이 글로벌 설정보다 우선한다.&lt;/p&gt;
&lt;p&gt;Codex cli 와 Codex App 모두 동일한 &lt;code&gt;~/.codex/config.toml&lt;/code&gt;을 공유한다. macOS 앱 공식 문서에도 &amp;quot;앱의 에이전트는 CLI 및 IDE 익스텐션과 동일한 설정을 상속한다&amp;quot;고 명시되어 있다. 즉, &lt;code&gt;config.toml&lt;/code&gt;을 한 번만 수정하면 CLI와 앱 모두에 적용된다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;max_depth&lt;/code&gt;는 에이전트가 또 다른 에이전트를 낳을 수 있는 중첩 깊이를 제한한다. 기본값 1은 메인 에이전트가 자식을 스폰하되, 그 자식은 다시 에이전트를 낳지 못하도록 막는 설정이다. 이 값을 높이면 재귀적인 위임이 가능해지지만, 토큰 소비와 예측 불가능한 fan-out이 급격히 늘어날 수 있어 특별한 이유가 없다면 기본값을 유지하는 편이 낫다&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/codex-subagents-max.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;설정을 변경한 후 20개의 서브에이전트가 동시에 실행되는게 확인된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;설정 자체는 단순하다. 다만 스레드를 늘릴수록 토큰 소비도 함께 늘어나니, 서브에이전트를 적극적으로 사용하는 상황에서는 &lt;code&gt;/fast&lt;/code&gt; 모드를 잠깐 꺼두는걸 추천한다.&lt;/p&gt;
&lt;p&gt;Codex Pro 플랜에서는 10시간씩 중단 없이 밤새 작업을 시켜도 주간 리밋을 아직 다 써본적이 없었는데, 서브에이전트 지원을 시작했으니 주간리밋도 알뜰하게 다 채워 쓸 수 있겠다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.openai.com/codex/subagents&quot;&gt;https://developers.openai.com/codex/subagents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.openai.com/codex/config-sample&quot;&gt;https://developers.openai.com/codex/config-sample&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.openai.com/codex/app/settings&quot;&gt;https://developers.openai.com/codex/app/settings&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Data/LLM</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/561</guid>
      <comments>https://shanepark.tistory.com/561#entry561comment</comments>
      <pubDate>Fri, 20 Mar 2026 12:26:40 +0900</pubDate>
    </item>
    <item>
      <title>Codex 서브에이전트 도입 소식 및 활용팁</title>
      <link>https://shanepark.tistory.com/560</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;최근 육아휴직에 들어가면서 개인 용도로만 월 200달러짜리 &lt;code&gt;Claude Code Max x20&lt;/code&gt;를 유지하는 것이 부담스러워졌다.&lt;/p&gt;
&lt;p&gt;그동안 &lt;code&gt;Claude Code&lt;/code&gt;를 메인으로 쓰면서 ChatGPT Team 플랜을 사용했었고  &lt;code&gt;codex&lt;/code&gt;의 깊은 코드 이해도와 복잡한 태스크 수행 능력을 개인적으로 높이 사고 있었다. 그런데 마침 2월 초 카카오톡 선물하기에서 ChatGPT Pro 1개월 이용권이 29,000원에 풀리는 대란이 있었고, 정가 월 200달러짜리 상품을 1인당 5개까지 살 수 있었기에 망설임 없이 5개를 구매했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/codex-subagent.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Claude Code Max x20 요금제는 조금 타이트하게 썼다 하면 5시간 리밋, 주간 리밋 모두 금방 금방 다 써버리곤 했었는데 ChatGPT Pro는 밤새 에이전트 혼자 작업하게끔 돌려도 사용량이 여유로웠다. 둘 다 정식 가격은 $200이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;그렇게 비용절감 목적으로 &lt;code&gt;codex&lt;/code&gt;로 메인을 바꿨는데, gpt-5.3-codex 에 이어 gpt-5.4 까지 연달아 기대 이상의 성능을 보여줘서 아주 만족스러웠다. 최근에는 macOS 앱도 나왔는데 사용량 2배를 주는 이벤트때문에 사용해봤다가 기대이상으로 좋아서 정착했다. 여러 작업을 동시에 돌릴 때 터미널 창을 여러 개 열어야 했던 불편함이 해소됐고, worktree 내장 지원 덕분에 같은 레포에서 여러 스레드를 격리된 환경으로 돌릴 수 있게 됐다. 다만 &lt;code&gt;Claude Code&lt;/code&gt;에서 유용하게 쓰던 서브에이전트 기능이 없다는 점은 계속 아쉬웠는데, 드디어 그 공백이 채워졌다.&lt;/p&gt;
&lt;h2&gt;서브에이전트의 이점&lt;/h2&gt;
&lt;p&gt;코딩 에이전트를 오래 쓰다 보면 context window의 중요성을 금방 체감하게 된다. 탐색 로그, 테스트 결과, 스택 트레이스처럼 작업 과정에서 쏟아지는 중간 결과물이 메인 대화에 쌓이기 시작하면, 유용한 정보가 노이즈에 묻히고 시간이 지날수록 모델 성능이 눈에 띄게 떨어진다. 공식 문서에서는 이를 &lt;strong&gt;context pollution&lt;/strong&gt;과 &lt;strong&gt;context rot&lt;/strong&gt;이라고 부른다.&lt;/p&gt;
&lt;p&gt;서브에이전트 워크플로우는 이 문제를 구조적으로 해결한다. 노이즈가 많은 탐색·테스트·분석 작업은 별도 에이전트에 위임하고, 서브에이전트는 날것의 중간 결과물 대신 요약본만 메인 스레드로 돌려준다. 메인 에이전트는 요구사항과 최종 결정에만 집중할 수 있는 셈이다. 작업을 독립적으로 병렬 실행하니 시간도 아낄 수 있고, 덩어리가 큰 태스크를 다루기 수월해진다는 것도 이점이다.&lt;/p&gt;
&lt;h2&gt;트리거 방법&lt;/h2&gt;
&lt;p&gt;서브에이전트는 자동으로 되지는 않는다. 프롬프트에 명시적으로 요청해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;이 브랜치를 병렬 서브에이전트로 리뷰해줘.
보안 리스크, 테스트 누락, 유지보수성 각각 하나씩 에이전트를 만들고,
모두 완료되면 카테고리별로 파일 참조와 함께 결과를 요약해줘.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;spawn two agents&lt;/code&gt;, &lt;code&gt;delegate this work in parallel&lt;/code&gt;, &lt;code&gt;use one agent per point&lt;/code&gt; 같은 표현이 자연스럽게 트리거로 작동한다. 실행 중인 에이전트 스레드는 &lt;code&gt;/agent&lt;/code&gt; 커맨드로 전환하며 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/codex-subagent.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;codex 에도 서브에이전트가 들어온 감격적인 순간&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;다만 클로드코드에서 주로 했던 방법과 동일하게 &lt;code&gt;AGENTS.md&lt;/code&gt;에 서브에이전트 활용 원칙을 넣어두니, 대화에서 매번 &amp;quot;서브에이전트를 써라&amp;quot;고 직접 지시하지 않아도 &lt;code&gt;codex&lt;/code&gt;가 필요한 시점에 알아서 서브에이전트를 활용하는게 확인되었다. 특히 독립적인 탐색, 검증, 검색처럼 병렬로 돌릴 수 있는 작업에서 효과가 좋아서 컨텍스트 파일에 서브에이전트 활용 관련해서는 꼭 적어두는걸 추천한다.&lt;/p&gt;
&lt;p&gt;예를 들어 아래처럼 적어두고 테스트 해 보았는데 원하는대로 잘 동작했다&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-md&quot;&gt;## 6. Sub-Agent Use

- Consider sub-agent use on every non-trivial task.
- Use sub-agents proactively when independent research, search, verification, or disjoint implementation work can run in parallel within the current checklist item and clearly help.
- Do not use sub-agents for sequential steps, overlapping file edits, or tightly coupled refactors.
- When spawning sub-agents, use the same model as the main agent. Do not override the sub-agent model unless the user explicitly asks for a different one.
- The main agent remains responsible for planning, integration, final verification, and user communication.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이런 식으로 규칙을 미리 적어두면 메인 에이전트가 계획과 통합을 맡고, 독립적인 조사나 검증만 서브에이전트에 위임하는 패턴이 자연스럽게 자리잡는다. 서브에이전트를 무조건 많이 만드는 것이 아니라, 병렬화 이점이 분명한 경우에만 쓰도록 제한하는 점도 중요하다.&lt;/p&gt;
&lt;p&gt;스스로 생성한 서브에이전트는 &lt;code&gt;gpt-5.4-mini&lt;/code&gt;를 호출하는걸 확인했는데, 성능이 Sonnet 4.6과 거의 동일하기때문에 충분히 쓸만하다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/codex-subagent.assets/3.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Uses GPT-5.2-Mini&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;하지만 사용량도 넉넉하게 남은 편이고 왠만하면 더 나은 모델을 사용하고 싶어서 한 줄을 추가했다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt; When spawning sub-agents, use the same model as the main agent. Do not override the sub-agent model unless the user explicitly asks for a different one.&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/codex-subagent.assets/4.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;same model 사용에 대한 지침을 넣으니 Uses 까지만 써있고 사용중인 모델이 별도로 표시되지 않음.&lt;/p&gt;
&lt;p&gt;물론 GPT-5.4를 사용하라고 명시할 수 있으나 추후에 새로운 모델이 나와서 바꿨을때도 알아서 사용하려면, 메인 에이전트의 모델을 그대로 사용하라는 지침을 작성하는 편이 낫다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/codex-subagent.assets/5.webp&quot; alt=&quot;5&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Codex Version 26.318.11754 (1100) 에서는 이제 same model 지침을 두어도 모델명이 정확히 표시되는게 확인된다. 역시 사용중인 모델이 별도로 표시되지 않았던건 버그였던 모양이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;모델 품질 차이에 민감한 작업이라면 이 한 줄도 꽤 유용하다.&lt;/p&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Claude Code&lt;/code&gt;에서만 쓰던 서브에이전트가 &lt;code&gt;codex&lt;/code&gt;에도 들어왔다. 카카오 대란으로 확보한 Pro 플랜 덕분에 당분간은 모든 모델을 넉넉하게 쓸 수 있어 더욱 반가운 소식이다. 두 도구가 서로의 장점을 빠르게 흡수하며 경쟁하는 구도가 되니, 사용자 입장에서는 더없이 반가운 상황이다. &lt;/p&gt;
&lt;p&gt;Claude Code만 써본 사용자라면 codex도 한번 사용해보길 권한다. $20 Plus 플랜에서도 제법 많은 사용량을 제공해주기 때문에 테스트해보기에는 충분하다. 개인적으로는 복직 후에는 Claude, Codex 모두 병행하여 사용할 생각이다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://developers.openai.com/codex/subagents&quot;&gt;https://developers.openai.com/codex/subagents&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Data/LLM</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/560</guid>
      <comments>https://shanepark.tistory.com/560#entry560comment</comments>
      <pubDate>Wed, 18 Mar 2026 14:05:43 +0900</pubDate>
    </item>
    <item>
      <title>무료 TTS 서비스 추천. 클로바 vs Azure</title>
      <link>https://shanepark.tistory.com/559</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;아이를 위한 어린이용 단어장을 만들면서 한국어와 영어 음성을 넣어야 할 일이 생겼다. 무료이거나 비용이 거의 들지 않으면서, 라이선스 문제 없이 상업적으로도 사용 가능한 TTS 서비스가 필요했다. 여러 서비스를 직접 비교해본 끝에 결론부터 말하면 Microsoft Azure Text-to-Speech를 선택했다.&lt;/p&gt;
&lt;h2&gt;비교해본 서비스들&lt;/h2&gt;
&lt;p&gt;TTS 서비스를 고를 때 가장 중요하게 본 기준은 세 가지였다. 음성 품질, 무료 사용량, 그리고 라이선스.&lt;/p&gt;
&lt;h3&gt;네이버 클로바더빙&lt;/h3&gt;
&lt;p&gt;한국어 음성 품질만 놓고 보면 클로바더빙이 꽤 괜찮았다. 자연스러운 한국어 발음과 다양한 목소리를 제공하고 있어서 첫인상은 좋았다. 다만 무료 사용 시 반드시 출처를 표기해야 하고, 상업 콘텐츠 제작에는 사용할 수 없다는 제약이 있다. 당장 수익화 계획이 없다고 해도 라이선스 제약이 있는 서비스를 선택하는 부담스러운 일이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;※ 무료 서비스는 콘텐츠로 인한 수익이 발생하지 않는 채널 게시 용도로만 사용할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://help.naver.com/service/23823/contents/12463?lang=ko&amp;amp;osType=COMMONOS&quot;&gt;무료 사용 허용 범위 안내&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;Google Cloud TTS&lt;/h3&gt;
&lt;p&gt;Google Cloud TTS는 무료 제공량이 넉넉한 편이다. 월 100만 자(Neural/WaveNet)까지 무료로 사용할 수 있다. 반면 셋업 과정이 불필요하게 복잡했다. 프로젝트 생성, 서비스 계정 설정, 인증 키 파일 관리 등 실제로 API를 호출하기까지 거쳐야 할 단계가 많다. 하다보니 너무 불편했다.&lt;/p&gt;
&lt;h3&gt;Azure Text-to-Speech&lt;/h3&gt;
&lt;p&gt;Azure는 셋업도 간결하고 무료 티어 설정도 명확했다. 리소스를 생성할 때 가격 계층에서 &lt;code&gt;Free F0&lt;/code&gt;를 선택하면 그걸로 끝이다. 과금에 대한 걱정을 할 필요가 전혀 없다. 월 50만 자까지 무료로 제공되는데, 단어장 수천 개를 처리하기에 충분한 양이다. 음성 품질도 좋고, 특히 어린이 목소리 옵션이 만족스러웠다.&lt;/p&gt;
&lt;h2&gt;Azure TTS 시작하기&lt;/h2&gt;
&lt;h3&gt;리소스 생성&lt;/h3&gt;
&lt;p&gt;&lt;a href=&quot;https://portal.azure.com/&quot;&gt;Azure Portal&lt;/a&gt;에 접속해서 상단 검색창에 &lt;code&gt;Speech&lt;/code&gt;를 입력한 뒤 Speech Services를 선택한다. 리소스를 만들 때 중요한 것은 가격 계층에서 반드시 &lt;strong&gt;Free F0&lt;/strong&gt;를 선택하는 것이다. 이렇게 하면 유료 전환 없이 무료 범위 안에서만 사용하게 된다.&lt;/p&gt;
&lt;p&gt;리소스가 생성되면 왼쪽 메뉴에서 &lt;code&gt;Keys and Endpoint&lt;/code&gt;로 들어가면 API 키 두 개와 엔드포인트가 이미 만들어져 있다. 별도로 키를 생성하거나 서비스 계정을 설정할 필요 없이 바로 사용할 수 있다.&lt;/p&gt;
&lt;h3&gt;Speech Studio에서 테스트&lt;/h3&gt;
&lt;p&gt;코딩 없이 음성을 확인해보고 싶다면 &lt;a href=&quot;https://speech.microsoft.com/&quot;&gt;Speech Studio&lt;/a&gt;에 접속하면 된다. Audio Content Creation 메뉴에서 언어와 보이스를 선택하고 텍스트를 입력하면 바로 들어볼 수 있다. MP3 파일로 내보내기도 가능하다.&lt;/p&gt;
&lt;h3&gt;추천 보이스&lt;/h3&gt;
&lt;p&gt;어린이용 콘텐츠에 적합한 보이스를 찾느라 여러 가지를 들어봤는데, 최종적으로 선택한 것은 다음 두 가지다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;한국어: &lt;code&gt;ko-KR-SeoHyeonNeural&lt;/code&gt; — 자연스러운 어린이 목소리&lt;/li&gt;
&lt;li&gt;영어: &lt;code&gt;en-GB-MaisieNeural&lt;/code&gt; — 영국 영어 어린이 목소리&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;둘 다 발음이 또렷하고 톤이 부드러워서 단어장 용도로 잘 맞았다.&lt;/p&gt;
&lt;h3&gt;API 연동&lt;/h3&gt;
&lt;p&gt;수천 개의 단어를 하나씩 Speech Studio에서 만들 수는 없으니 API를 사용했다. Python SDK를 설치하고 키와 리전만 넣으면 바로 동작한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip install azure-cognitiveservices-speech
import azure.cognitiveservices.speech as speechsdk

speech_config = speechsdk.SpeechConfig(
    subscription=&amp;quot;YOUR_KEY&amp;quot;,
    region=&amp;quot;koreacentral&amp;quot;
)
speech_config.speech_synthesis_voice_name = &amp;quot;ko-KR-SeoHyeonNeural&amp;quot;

audio_config = speechsdk.audio.AudioOutputConfig(filename=&amp;quot;apple.mp3&amp;quot;)
synthesizer = speechsdk.SpeechSynthesizer(
    speech_config=speech_config,
    audio_config=audio_config
)
synthesizer.speak_text_async(&amp;quot;사과&amp;quot;).get()&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;실제로 수백 개의 단어를 대상으로 음성 파일을 생성해봤는데, API 요청을 빠른 간격으로 보내도 별다른 문제 없이 모두 정상적으로 생성되었다. 무료 티어 사용량 안에서도 넉넉하게 처리할 수 있었다.&lt;/p&gt;
&lt;h2&gt;라이선스&lt;/h2&gt;
&lt;p&gt;Azure TTS의 라이선스가 깔끔한 편이다. &lt;a href=&quot;https://learn.microsoft.com/en-us/legal/ai-code-of-conduct&quot;&gt;Microsoft Enterprise AI Services Code of Conduct&lt;/a&gt;를 보면, 이 규정은 무료든 유료든 Microsoft AI 서비스의 모든 고객에게 동일하게 적용된다. 문서에 상업적 이용을 금지하는 조항은 없으며, 오히려 결과물에 대한 권리와 책임이 고객에게 있음을 명시하고 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;아래의 질문 답변도 무료티어의 상업적 활용에 대해 명확하게 설명해준다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/answers/questions/1070009/usage-policy-limitation-for-free-tier&quot;&gt;https://learn.microsoft.com/en-us/answers/questions/1070009/usage-policy-limitation-for-free-tier&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;비교 정리&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;Azure TTS&lt;/th&gt;
&lt;th&gt;Google Cloud TTS&lt;/th&gt;
&lt;th&gt;클로바더빙&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;무료 사용량&lt;/td&gt;
&lt;td&gt;월 50만 자&lt;/td&gt;
&lt;td&gt;월 100만 자&lt;/td&gt;
&lt;td&gt;제한적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;셋업 난이도&lt;/td&gt;
&lt;td&gt;간단&lt;/td&gt;
&lt;td&gt;복잡&lt;/td&gt;
&lt;td&gt;간단&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;음성 품질&lt;/td&gt;
&lt;td&gt;좋음&lt;/td&gt;
&lt;td&gt;좋음&lt;/td&gt;
&lt;td&gt;좋음 (한국어)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;상업적 이용&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;가능&lt;/td&gt;
&lt;td&gt;불가 (무료)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;출처 표기&lt;/td&gt;
&lt;td&gt;불필요&lt;/td&gt;
&lt;td&gt;불필요&lt;/td&gt;
&lt;td&gt;필수 (무료)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;TTS 서비스를 고를 때 음성 품질만 보면 상당히 상향평준화가 되어 있다. 결국 차이를 만드는 것은 셋업의 간편함, 과금 구조의 명확함, 그리고 라이선스의 깔끔함이다. Azure TTS는 이 세 가지를 모두 만족시켜주기에 추천한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/answers/questions/1192398/can-i-use-azure-text-to-speech-for-commercial-usag&quot;&gt;https://learn.microsoft.com/en-us/answers/questions/1192398/can-i-use-azure-text-to-speech-for-commercial-usag&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://learn.microsoft.com/en-us/answers/questions/1070009/usage-policy-limitation-for-free-tier&quot;&gt;https://learn.microsoft.com/en-us/answers/questions/1070009/usage-policy-limitation-for-free-tier&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://help.naver.com/service/23823/contents/12463?lang=ko&amp;amp;osType=COMMONOS&quot;&gt;https://help.naver.com/service/23823/contents/12463?lang=ko&amp;amp;osType=COMMONOS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Develop Tools</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/559</guid>
      <comments>https://shanepark.tistory.com/559#entry559comment</comments>
      <pubDate>Wed, 11 Feb 2026 23:58:12 +0900</pubDate>
    </item>
    <item>
      <title>Playwright MCP 멀티 에이전트 경합 문제해결</title>
      <link>https://shanepark.tistory.com/558</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;이전에 &lt;a href=&quot;https://shanepark.tistory.com/555&quot;&gt;Playwright MCP를 활용해 LLM이 스스로 UI 수정하게 하기&lt;/a&gt; 글에서 LLM의 셀프 피드백 루프를 통한 디자인 요소 자동 조절에 대해 다루었다. Claude Code나 Codex 같은 도구가 스스로 UI를 확인하고, 클릭하고, 스크린샷을 찍는 작업이 가능해지면서 사용자가 굳이 코드 변경때마다 눈으로 확인 할 필요 없이 스스로 답이 나올 때 까지 변경을 하게 되었고 그 만족감이 대단하다.&lt;/p&gt;
&lt;p&gt;최근에는 Chrome extension 등이 나와서 더 강력하게 브라우저를 제어할 수 있게 되었지만 애초에 Playwright MCP와 Chrome의 extension은 서로 결이 다르기떄문에 Playwright MCP가 대체될 것이라고 생각하지 않는다.&lt;/p&gt;
&lt;p&gt;그런데 Playwright MCP 를 여러개의 에이전트가 동시에 호출하여 이용하는 순간, 예상치 못한 문제가 발생한다. 브라우저 탭이 끝없이 늘어나고, 어느 에이전트도 제대로 된 결과를 얻지 못하는 교착 상태에 빠져버리게 된다.&lt;/p&gt;
&lt;p&gt;직접 경험해본 사람들은 모두 공감할텐데 매우 짜증난다.&lt;/p&gt;
&lt;h2&gt;문제&lt;/h2&gt;
&lt;p&gt;Claude Code를 여러 터미널에서 동시에 실행하거나, Task 도구로 서브에이전트를 생성하면 각 에이전트가 독립적으로 Playwright MCP를 호출한다. 문제는 이들이 &lt;strong&gt;같은 MCP 서버 인스턴스&lt;/strong&gt;를 공유한다는 점이다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;에이전트 A: browser_navigate → localhost:5173
에이전트 B: browser_navigate → localhost:5173/settings
에이전트 A: browser_snapshot → ???&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;에이전트 A가 스냅샷을 찍으려는 순간, 브라우저는 이미 에이전트 B가 이동시킨 &lt;code&gt;/settings&lt;/code&gt; 페이지를 보여준다. 결과적으로 A는 엉뚱한 화면을 받게 되고, 이후 작업이 꼬이기 시작한다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/microsoft/playwright-mcp/issues/893&quot;&gt;microsoft/playwright-mcp#893&lt;/a&gt; 에서 누군가가 여기에 문제제기를 했지만 그건 playwright MCP 의 문제가 아니라며 묵살당했다. 이슈에서는 다중 MCP 서버 설정이 해결책으로 제시되었는데 그것도 제법 괜찮은 아이디어같다.&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;하지만 복잡한 해결책 대신, 가장 단순한 방법을 시도해보았다. 한 번에 하나의 에이전트만 Playwright를 사용하도록 파일 락을 거는 것이다.&lt;/p&gt;
&lt;p&gt;핵심 아이디어는 다음과 같다:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Playwright 사용 전에 락 파일(&lt;code&gt;.playwright.lock&lt;/code&gt;)을 확인한다&lt;/li&gt;
&lt;li&gt;락이 없으면 획득하고, 있으면 사용을 포기하거나 대기한다&lt;/li&gt;
&lt;li&gt;작업이 끝나면 락을 해제한다&lt;/li&gt;
&lt;li&gt;비정상 종료에 대비해 10분 후 락이 자동 만료된다&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;락 스크립트 구현&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;scripts/playwright-lock.sh&lt;/code&gt; 파일을 생성했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;#!/bin/bash
#
# Playwright Lock Manager
# Prevents multiple Claude Code agents from using Playwright simultaneously

LOCK_FILE=&amp;quot;.playwright.lock&amp;quot;
LOCK_TIMEOUT=600  # 10 minutes in seconds

SCRIPT_DIR=&amp;quot;$(cd &amp;quot;$(dirname &amp;quot;${BASH_SOURCE[0]}&amp;quot;)&amp;quot; &amp;amp;&amp;amp; pwd)&amp;quot;
PROJECT_ROOT=&amp;quot;$(dirname &amp;quot;$SCRIPT_DIR&amp;quot;)&amp;quot;
LOCK_PATH=&amp;quot;$PROJECT_ROOT/$LOCK_FILE&amp;quot;

get_current_timestamp() {
    date +%s
}

get_lock_timestamp() {
    if [[ -f &amp;quot;$LOCK_PATH&amp;quot; ]]; then
        cat &amp;quot;$LOCK_PATH&amp;quot; 2&amp;gt;/dev/null || echo &amp;quot;0&amp;quot;
    else
        echo &amp;quot;0&amp;quot;
    fi
}

is_lock_expired() {
    local lock_time=$(get_lock_timestamp)
    local current_time=$(get_current_timestamp)
    local age=$((current_time - lock_time))
    [[ $age -gt $LOCK_TIMEOUT ]]
}

cmd_check() {
    if [[ ! -f &amp;quot;$LOCK_PATH&amp;quot; ]]; then
        echo &amp;quot;AVAILABLE: Playwright is available.&amp;quot;
        exit 0
    fi

    if is_lock_expired; then
        echo &amp;quot;AVAILABLE: Lock expired. Playwright is available.&amp;quot;
        exit 0
    else
        echo &amp;quot;LOCKED: Playwright is in use by another agent.&amp;quot;
        exit 1
    fi
}

cmd_acquire() {
    if [[ -f &amp;quot;$LOCK_PATH&amp;quot; ]] &amp;amp;&amp;amp; ! is_lock_expired; then
        echo &amp;quot;FAILED: Cannot acquire lock. Playwright is in use.&amp;quot;
        exit 1
    fi

    [[ -f &amp;quot;$LOCK_PATH&amp;quot; ]] &amp;amp;&amp;amp; rm -f &amp;quot;$LOCK_PATH&amp;quot;
    get_current_timestamp &amp;gt; &amp;quot;$LOCK_PATH&amp;quot;
    echo &amp;quot;SUCCESS: Playwright lock acquired.&amp;quot;
    exit 0
}

cmd_release() {
    rm -f &amp;quot;$LOCK_PATH&amp;quot;
    echo &amp;quot;SUCCESS: Playwright lock released.&amp;quot;
    exit 0
}

case &amp;quot;${1:-}&amp;quot; in
    check)   cmd_check ;;
    acquire) cmd_acquire ;;
    release) cmd_release ;;
    *)       echo &amp;quot;Usage: $0 {check|acquire|release}&amp;quot; &amp;amp;&amp;amp; exit 1 ;;
esac&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;락 파일에는 타임스탬프만 저장된다. 단순하지만 만료 시간 계산이 가능하고, 비정상 종료 시에도 10분 후 자동 복구된다.&lt;/p&gt;
&lt;h3&gt;사용 방법&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 1. Playwright 사용 가능 여부 확인
./scripts/playwright-lock.sh check

# 2. 락 획득 (사용 가능할 때만)
./scripts/playwright-lock.sh acquire

# 3. Playwright 작업 수행
# browser_navigate, browser_snapshot, browser_click 등

# 4. 작업 완료 후 락 해제 (필수!)
./scripts/playwright-lock.sh release&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;AI 에이전트에게 규칙 알려주기&lt;/h3&gt;
&lt;p&gt;스크립트만으로는 부족하다. AI 에이전트가 이 규칙을 알고 따라야 한다. &lt;code&gt;CLAUDE.md&lt;/code&gt;에 다음과 같은 지침을 추가했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;## Playwright Lock System (CRITICAL for Multi-Agent)

When multiple Claude Code agents run concurrently, they may conflict
over Playwright browser control. A lock system prevents deadlocks.

**Lock file**: `.playwright.lock` in project root (auto-expires after 10 minutes)

### Required Workflow

**BEFORE using any Playwright tool (`browser_*`):**
1. Check: `./scripts/playwright-lock.sh check`
2. If AVAILABLE: `./scripts/playwright-lock.sh acquire`
3. If LOCKED: Skip Playwright or ask user

**AFTER finishing all Playwright operations:**
- ALWAYS release: `./scripts/playwright-lock.sh release`
- Release even if errors occurred&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Claude Code는 &lt;code&gt;CLAUDE.md&lt;/code&gt;를 프로젝트 컨텍스트로 인식하기 때문에, Playwright 도구를 호출하기 전에 락 확인 절차를 거치게 된다.&lt;/p&gt;
&lt;h3&gt;충돌 시 대응&lt;/h3&gt;
&lt;p&gt;락이 이미 잡혀 있을 때 에이전트가 어떻게 행동할지도 명시했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;### Handling Lock Conflicts

If `check` returns `LOCKED`:

1. **Preferred**: Skip Playwright for this task if not critical
2. **Alternative**: Ask the user:
   &amp;gt; &amp;quot;Playwright is currently locked by another agent. Would you like me to:
   &amp;gt; 1. Skip visual testing for this task
   &amp;gt; 2. Wait for the lock to be released
   &amp;gt; 3. Force release the lock (only if the other agent crashed)&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;대부분의 경우 Playwright는 &amp;quot;있으면 좋지만 필수는 아닌&amp;quot; 도구다. 코드 변경이나 로직 수정은 Playwright 없이도 가능하다. 락 충돌 시 건너뛰는 것이 가장 실용적인 선택이다.&lt;/p&gt;
&lt;p&gt;race condition 가능성이 전혀 없는 건 아니지만 그래도 &lt;code&gt;acquire&lt;/code&gt; 한번 더 체크하기도 하고 그정도 짧은 시간에 여러개의 에이전트가 동시에 lock을 요청하는 일은 매우 드물기 때문에 복잡도를 늘리지 않는 상황에서 이정도면 나쁘지 않다고 판단했다. &lt;/p&gt;
&lt;p&gt;에이전트가 락 해제를 깜빡하면 10분간 다른 에이전트가 대기해야 하는데, 지금까지 경험으로는 적절히 락을 해제하였고, 자동 만료 덕분에 영구 교착은 발생하지 않는다.&lt;/p&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;Playwright MCP의 멀티 에이전트 경합 문제는 꽤 답답한 경험이었다. 브라우저 탭이 끝없이 늘어나고, 에이전트들이 서로의 작업을 방해하는 모습을 보면서 여러 해결책을 고민했다. 결국 가장 단순한 방법인 파일 락을 선택했고, 지금까지 교착 상태 없이 잘 동작하고 있다. 복잡한 문제에 단순한 해결책이 통할 때가 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/microsoft/playwright-mcp/issues/893&quot;&gt;microsoft/playwright-mcp#893 - Parallel agent execution causes tab conflicts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/microsoft/playwright-mcp&quot;&gt;Playwright MCP GitHub Repository&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Data/LLM</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/558</guid>
      <comments>https://shanepark.tistory.com/558#entry558comment</comments>
      <pubDate>Wed, 21 Jan 2026 23:56:15 +0900</pubDate>
    </item>
    <item>
      <title>Spring Boot 4 마이그레이션 후 Flyway가 작동하지 않을 때</title>
      <link>https://shanepark.tistory.com/557</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;Spring Boot 4로 마이그레이션한 뒤 Flyway가 조용히 작동하지 않는 문제가 있었다. 애플리케이션은 정상 실행되고 DB 조회도 문제없어서, 새로운 마이그레이션 SQL을 추가하기 전까지는 눈치채지 못했다. 원인은 Spring Boot 4의 모듈화 정책 변경이었다.&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;Spring Boot 4부터는 &lt;code&gt;flyway-core&lt;/code&gt; 의존성만으로는 자동 설정이 되지 않는다. 명시적으로 starter를 사용해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-kotlin&quot;&gt;// 변경 전 (Spring Boot 4에서 작동 안 함)
implementation(&amp;quot;org.flywaydb:flyway-core&amp;quot;)
implementation(&amp;quot;org.flywaydb:flyway-mysql&amp;quot;)

// 변경 후 (정상 작동)
implementation(&amp;quot;org.springframework.boot:spring-boot-starter-flyway&amp;quot;)
implementation(&amp;quot;org.flywaydb:flyway-mysql&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Liquibase를 사용하는 경우에도 마찬가지로 &lt;code&gt;spring-boot-starter-liquibase&lt;/code&gt;로 변경해야 한다.&lt;/p&gt;
&lt;h3&gt;원인&lt;/h3&gt;
&lt;p&gt;Spring Boot 4의 가장 큰 변화는 코드베이스의 모듈화다. 기존에는 auto-configuration이 하나의 큰 jar로 제공됐지만, 이제는 작고 집중된 모듈들로 분리되었다. 이로 인해 third-party 의존성만 추가하면 자동 설정이 됐던 것들이, 이제는 명시적으로 starter를 사용해야 하는 경우가 생겼다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide#module-dependencies&quot;&gt;마이그레이션 가이드&lt;/a&gt;에도 이 내용이 나와 있다:&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;For instance, if you are using Flyway or Liquibase you used to only have the relevant third-party dependency. You now need to replace that with spring-boot-starter-flyway or spring-boot-starter-liquibase, respectively.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;문제는 Flyway가 작동하지 않아도 애플리케이션이 정상 실행된다는 점이다. 기존 테이블과 데이터는 그대로 있으니 조회도 잘 되고, 에러 로그도 없다. 새 마이그레이션 파일을 추가하고 그게 적용되지 않는 걸 확인하기 전까지는 알아채기 어렵다.&lt;/p&gt;
&lt;h3&gt;Spring Boot 4 주요 변경점&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Spring Framework 7 기반, Jakarta EE 11 (Servlet 6.1) 요구&lt;/li&gt;
&lt;li&gt;Java 17~25 지원 (Java 25 first-class 지원)&lt;/li&gt;
&lt;li&gt;Jackson 3 마이그레이션 (&lt;code&gt;com.fasterxml.jackson&lt;/code&gt; → &lt;code&gt;tools.jackson&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;JSpecify null-safety annotations 적용&lt;/li&gt;
&lt;li&gt;&lt;code&gt;spring-boot-starter-web&lt;/code&gt; → &lt;code&gt;spring-boot-starter-webmvc&lt;/code&gt; 변경&lt;/li&gt;
&lt;li&gt;테스트 인프라도 별도 starter 필요 (&lt;code&gt;spring-boot-starter-*-test&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;마이그레이션 경험&lt;/h3&gt;
&lt;p&gt;이번 마이그레이션은 Claude Code에게 &lt;a href=&quot;https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide&quot;&gt;마이그레이션 가이드 문서&lt;/a&gt;를 통째로 전달하고 맡겼다. 서브에이전트를 여러 개 생성하며 의존성 변경, 패키지 이동, deprecated API 대응 등을 스스로 처리했고, 테스트도 직접 돌려가며 확인했다. 전체 작업에 3~4시간 정도 걸렸는데, 이전에 1→2나 2→3 마이그레이션 때 며칠씩 걸렸던 것에 비하면 훨씬 수월했다.&lt;/p&gt;
&lt;p&gt;다만 Flyway 문제처럼 조용히 실패하는 케이스는 AI가 잡아내지 못했다. 테스트가 통과하고 애플리케이션이 정상 실행되니 문제가 없다고 판단한 모양이다. 결국 이런 부분은 사람이 직접 확인해야 할 영역인 것 같다.&lt;/p&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;Spring Boot 4 마이그레이션 후 Flyway가 작동하지 않는다면, &lt;code&gt;flyway-core&lt;/code&gt; 대신 &lt;code&gt;spring-boot-starter-flyway&lt;/code&gt;를 사용하고 있는지 확인해보자.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide&quot;&gt;Spring Boot 4.0 Migration Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://spring.io/blog/2025/11/20/spring-boot-4-0-0-available-now/&quot;&gt;Spring Boot 4.0.0 Release&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Programming/JPA &amp;frasl;  Spring</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/557</guid>
      <comments>https://shanepark.tistory.com/557#entry557comment</comments>
      <pubDate>Wed, 14 Jan 2026 16:34:47 +0900</pubDate>
    </item>
    <item>
      <title>무료 Gemini 2.5 API에서 Gemma 3로의 강제 이주기</title>
      <link>https://shanepark.tistory.com/556</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;텍스트로부터 시간 정보를 추출하고 정규화하는 용도로 Gemini API를 사용해왔다. 그런데 &lt;code&gt;gemini-2.0-flash&lt;/code&gt;를 무료로 넉넉하게 쓰던 게 어느 날 갑자기 막혔다. 찾아보니 2025년 12월 초에 Google이 무료 티어를 대폭 축소한 모양이다. 확인해보니 다수의 429 로그가 찍혀있었다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/gemma3.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;p&gt;그래서 부랴부랴 확인해보니 &lt;code&gt;gemini-2.5-flash-lite&lt;/code&gt;가 그나마 무료로 사용 가능하지만 RPM 10, RPD 20이라는 수치는 도저히 쓸 수 없는 수준이었다. &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/gemma3.assets/3.webp&quot; alt=&quot;3&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;gemini-2.5-flash-lite 사용 시 곧바로 limit에 다다른다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;결국 Gemma 3로 갈아타게 되었고, 프롬프트 엔지니어링을 거치니 어느 정도 기존과 동일하게 동작하게 되어 이 후기 글을 작성한다.&lt;/p&gt;
&lt;p&gt;프론티어 LLM의 성능이 거침없이 올라가는 상황에서 프롬프트의 중요성이 점점 낮아진다고 생각해왔다. 그러나 이번 경험이 그 생각을 재고하게 만들었다. 특히 저전력 저성능 디바이스에 탑재되는 경량 모델이 늘어날 미래를 고려하면, 프롬프트 엔지니어링의 가치는 오히려 다시 올라갈 수도 있겠다는 생각이 들었다. &lt;/p&gt;
&lt;p&gt;지금까지처럼 말로만 AI가 아닌 진짜 AI 세탁기, AI 냉장고, AI 청소기 등이 경량 LLM과 함께 보편화될 것이다.&lt;/p&gt;
&lt;h2&gt;Gemini 무료 티어 중단&lt;/h2&gt;
&lt;p&gt;Google AI Studio의 무료 티어는 한때 개발자들에게 엄청 관대한 편이었다.  &lt;a href=&quot;https://shanepark.tistory.com/538&quot;&gt;[Spring Boot] Spring AI 활용해 LLM과 연동하기&lt;/a&gt; 글에서 언급했던 것처럼 RPM 30, RPD 1500 이라는 꽤나 넉넉한 밴드를 제공했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/gemma3.assets/4.webp&quot; alt=&quot;4&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;과거의 넉넉했던 rate limits&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;그러나 2025년 12월 7일을 기점으로 상황이 달라졌다. &lt;code&gt;gemini-2.5-pro&lt;/code&gt;는 무료 티어에서 사실상 제거되었고, &lt;code&gt;gemini-2.5-flash&lt;/code&gt;의 일일 요청 수는 250 RPD에서 20 RPD로 92% 이상 삭감되었다. 예고 없이 진행된 변경이라 많은 개발자들이 갑작스러운 429 에러를 마주하고 나서야 상황을 파악했다.&lt;/p&gt;
&lt;p&gt;현재 무료로 쓸 수 있는 옵션의 실제 사용량 제한은 다음과 같다. 사실상 테스트용으로도 못쓴다고 봐야한다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;RPM&lt;/th&gt;
&lt;th&gt;TPM&lt;/th&gt;
&lt;th&gt;RPD&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;gemini-2.5-flash&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;250K&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemini-2.5-flash-lite&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;250K&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;Gemma 3&lt;/h2&gt;
&lt;p&gt;그래서 여러 가지 대안을 찾아보고 비교해본 결과 Gemma 3 가 최종 후보가 되었다.&lt;/p&gt;
&lt;p&gt;Gemma 3는 Google이 2025년 3월에 공개한 오픈웨이트 모델이다. Gemini와 동일한 연구 기술을 기반으로 만들어졌지만, 오픈 웨이트로 공개되어 누구나 자유롭게 사용할 수 있다. 27B 버전의 주요 스펙은 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;파라미터&lt;/strong&gt;: 27B&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;컨텍스트 윈도우&lt;/strong&gt;: 128K&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;멀티모달&lt;/strong&gt;: 텍스트와 이미지 입력 지원&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;다국어 지원&lt;/strong&gt;: 140개 이상 언어&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;128K 컨텍스트 윈도우는 긴 문서나 대량의 데이터를 한 번에 처리할 수 있어 실용적이다. 다만 Gemini 계열과 비교하면 추론 능력에서 확연한 차이가 느껴진다. &lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;모델&lt;/th&gt;
&lt;th&gt;RPM&lt;/th&gt;
&lt;th&gt;TPM&lt;/th&gt;
&lt;th&gt;RPD&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;gemma-3-12b&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;15K&lt;/td&gt;
&lt;td&gt;14.4K&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gemma-3-27b&lt;/td&gt;
&lt;td&gt;30&lt;/td&gt;
&lt;td&gt;15K&lt;/td&gt;
&lt;td&gt;14.4K&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;그래도 무료 사용량이 상당히 넉넉하고 기존의 gemini 사용 코드에서 모델명만 &lt;code&gt;gemma-3-27b-it&lt;/code&gt; 로 바꾸면 별다른 설정이나 코드 변경 없이 바로 사용할 수 있다는 큰 장점이 있어 선택했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/gemma3.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;테스트 환경에서 limit 에 닿을 정도로 요청을 보내보니 어느정도 괜찮았다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;추론 능력의 한계와 프롬프트 엔지니어링&lt;/h2&gt;
&lt;p&gt;그러나 Gemma 3로 전환하고 기존 프롬프트 테스트 코드를 돌려보니 바로 실패했다. 다양한 조건의 텍스트를 정규화할 때 의도한 결과가 나오지 않는 케이스가 속출했다. Gemini에서는 별다른 설명 없이도 맥락을 파악하던 것들이 Gemma 3에서는 통하지 않았다.&lt;/p&gt;
&lt;p&gt;결국 프롬프트를 대폭 수정해야 했다. 핵심적인 변화는 두 가지였다.&lt;/p&gt;
&lt;p&gt;첫째, 프롬프트가 훨씬 상세하고 길어져야 했다. &amp;quot;이런 건 당연히 이렇게 처리하겠지&amp;quot;라는 암묵적 기대가 통하지 않았다. 모든 조건과 예외 상황을 명시적으로 기술해야 했다.&lt;/p&gt;
&lt;p&gt;둘째, Few-shot prompting이 다시 필요해졌다. GPT-3~4 시절에 쓰던 기법이다. 입력과 출력의 예시를 여러 개 제공해서 패턴을 학습시키는 방식인데, 최신 모델에서는 잘 쓰지 않게 된 기법이 다시 효과를 발휘했다.&lt;/p&gt;
&lt;h2&gt;Claude Code로 프롬프트 자동 최적화&lt;/h2&gt;
&lt;p&gt;프롬프트를 일일이 수정하고 테스트하는 과정이 번거로워서 Claude Code에게 맡겨보았다. 테스트 코드를 통과할 때까지 프롬프트를 스스로 수정하고 실행하는 피드백 루프를 구성했다. 한 가지 주의할 점은 테스트 코드에 과적합된 프롬프트를 만들지 않도록 가이드라인을 줘야 한다는 것이다. 실제로 초반에는 특정 테스트 케이스에만 맞춘 프롬프트를 생성하길래 그러지 말라고 지시해야 했다.&lt;/p&gt;
&lt;p&gt;한참 동안 스스로 반복하더니 결국 모든 테스트를 통과하는 프롬프트가 완성되었다. 원래 프롬프트보다야 조금 길어졌지만, 실제 서비스에 반영하니 기존과 동일하게 잘 동작하는 것이 확인되었다.&lt;/p&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;Gemma 3의 추론 능력은 Gemini에 비해 확실히 떨어진다. 하지만 프롬프트 엔지니어링을 통해 실용적인 수준까지 끌어올릴 수 있었다. 프론티어 모델의 성능이 올라갈수록 프롬프트의 중요성은 낮아지겠지만, 경량 모델을 다뤄야 하는 상황은 앞으로도 계속 있을 것이다. Few-shot prompting 같은 고전적인 기법들이 여전히 유효하다는 점을 기억해둘 필요가 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/cheahjs/free-llm-api-resources&quot;&gt;https://github.com/cheahjs/free-llm-api-resources&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://huggingface.co/google/gemma-3-27b-it&quot;&gt;https://huggingface.co/google/gemma-3-27b-it&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Data/LLM</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/556</guid>
      <comments>https://shanepark.tistory.com/556#entry556comment</comments>
      <pubDate>Mon, 12 Jan 2026 16:37:23 +0900</pubDate>
    </item>
    <item>
      <title>Playwright MCP를 활용해 LLM이 스스로 UI 수정하게 하기</title>
      <link>https://shanepark.tistory.com/555</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;LLM에게 UI 수정을 요청할 때마다 브라우저를 새로고침하며 결과를 확인하고, 다시 수정 요청을 하는 과정이 반복되곤 한다. 코드는 잘 생성해주지만, 실제로 의도한 대로 동작하는지는 직접 확인해야 하는 번거로움이 있다. 가끔 여러번의 수정 요청에도 제대로 처리가 되지 않으면 잘 안된 부분에 대해 브라우저의 devtools에서 현 상황을 보여주거나 스크린샷을 찍어서 직접 LLM에게 건네기도 한다. 해결에는 큰 도움이 되지만 여간 번거로운 일이 아니다.&lt;/p&gt;
&lt;p&gt;Playwright MCP를 활용하면 이런 수작업을 LLM이 스스로 처리하도록 만들 수 있다. AI가 직접 브라우저를 제어하며 수정 사항을 적용하고, 스크린샷을 찍어 확인하고, 문제가 있으면 다시 수정하는 과정을 자동으로 수행한다.&lt;/p&gt;
&lt;h2&gt;Playwright&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://github.com/microsoft/playwright-mcp&quot;&gt;https://github.com/microsoft/playwright-mcp&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;Playwright는 Microsoft에서 개발한 브라우저 자동화 도구다. Chromium, Firefox, WebKit을 모두 지원하며, 헤드리스 모드로도 실행 가능하다. 테스트 자동화에 주로 사용되지만, MCP와 결합하면 LLM이 직접 브라우저를 제어할 수 있게 된다.&lt;/p&gt;
&lt;p&gt;Playwright MCP를 통해 LLM은 웹페이지를 방문하고, 요소를 클릭하고, 스크린샷을 찍으며, JavaScript 코드를 실행할 수 있다. 이는 단순히 코드를 생성하는 것을 넘어, 실제로 브라우저에서 결과를 확인하고 수정하는 반복 작업까지 가능하게 만든다.&lt;/p&gt;
&lt;h2&gt;설치&lt;/h2&gt;
&lt;p&gt;Codex와 Claude Code 모두에서 Playwright MCP를 사용할 수 있다. 설치 방법은 간단하다.&lt;/p&gt;
&lt;h3&gt;Codex&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;codex mcp add playwright npx &amp;quot;@playwright/mcp@latest&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/playwright-mcp.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Added global MCP server &amp;#39;playwright&amp;#39;.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;codex 실행 후 &lt;code&gt;/mcp&lt;/code&gt; 를 입력해본다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/playwright-mcp.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;정상적으로 MCP가 등록되었음&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;쿼리를 날려 동작을 테스트해본다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;관리 페이지에서 스크롤시에 sticky 되어야 하는 부분(다중선택 모드, 이미지 상세 정보)이 고정되지 않고 함께 스크롤 되는 문제가 있는데 그걸 수정해주고, playwright mcp 를 활용해서 정상적으로 적용되었는지 확인 해줘&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/playwright-mcp.assets/3.webp&quot; alt=&quot;3&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;스스로 페이지 방문 하고 버튼도 클릭해가며 스크린샷도 찍어 확인한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;최종 완료 후 로그를 확인해보니 LLM은 다음과 같은 순서로 작업을 진행했다&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;페이지 접속: &lt;code&gt;browser_navigate&lt;/code&gt;로 관리 페이지 접속&lt;/li&gt;
&lt;li&gt;현재 상태 파악: &lt;code&gt;browser_evaluate&lt;/code&gt;로 DOM 구조와 CSS 속성 확인&lt;/li&gt;
&lt;li&gt;문제 요소 식별: sticky가 적용되어야 할 요소들의 선택자 파악&lt;/li&gt;
&lt;li&gt;코드 수정 시도: &lt;code&gt;browser_run_code&lt;/code&gt;로 CSS 수정 적용&lt;/li&gt;
&lt;li&gt;스크롤 테스트: 페이지를 스크롤하며 동작 확인&lt;/li&gt;
&lt;li&gt;스크린샷 캡처: 수정 전후 상태를 시각적으로 기록&lt;/li&gt;
&lt;li&gt;추가 수정: &lt;code&gt;browser_run_code&lt;/code&gt;와 &lt;code&gt;browser_evaluate&lt;/code&gt;를 여러 번 반복하며 미세 조정한다. LLM은 첫 시도에서 완벽하게 해결하지 못했지만, 스스로 문제를 파악하고 다시 수정하는 과정을 거쳤다. z-index 문제인지, position 속성 문제인지, 아니면 부모 요소의 overflow 설정 때문인지 하나씩 검증하며 해결책을 찾아갔다.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;Claude Code&lt;/h3&gt;
&lt;p&gt;클로드코드에서는 아래의 명령어로 mcp를 추가할 수 있으며 그 외 동작 및 사용법은 Codex와 동일하다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Local scope
claude mcp add playwright npx @playwright/mcp@latest

# User scope
claude mcp add playwright --scope user -- npx -y @playwright/mcp@latest&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;다양한 활용 시나리오&lt;/h2&gt;
&lt;p&gt;Playwright MCP의 활용 범위는 생각보다 넓다. 몇 가지 유용한 시나리오를 소개한다.&lt;/p&gt;
&lt;p&gt;반응형 디자인 검증&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;모바일, 태블릿, 데스크톱 해상도에서 네비게이션 메뉴가&lt;br&gt;올바르게 표시되는지 확인하고 문제가 있다면 수정해줘&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;LLM이 viewport 크기를 변경하며 각 해상도에서 레이아웃을 점검하고 미디어 쿼리를 조정한다.&lt;/p&gt;
&lt;p&gt;접근성 개선&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;페이지의 모든 이미지에 적절한 alt 텍스트가 있는지 확인하고,&lt;br&gt;없다면 이미지 내용을 분석해서 추가해줘&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이미지를 하나씩 확인하며 누락된 alt 속성을 찾아 적절한 설명을 추가한다.&lt;/p&gt;
&lt;h3&gt;폼 유효성 검증&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;회원가입 폼의 모든 필드를 테스트하고,&lt;br&gt;에러 메시지가 제대로 표시되는지 확인해줘&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;다양한 입력값으로 폼을 테스트하며 유효성 검증 로직의 누락이나 오류를 찾아낸다.&lt;/p&gt;
&lt;h3&gt;성능 최적화&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;페이지 로딩 시 레이아웃 시프트가 발생하는 요소를 찾아서 수정해줘&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;페이지 로딩 과정을 관찰하며 CLS(Cumulative Layout Shift)를 유발하는 요소를 식별하고 개선한다.&lt;/p&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;Playwright MCP 의 도입으로 가장 큰 장점은 반복적인 확인 작업에서 해방된다는 점이다. 코드를 수정하고, 브라우저를 새로고침하고, 결과를 확인하는 사이클을 LLM이 대신 수행한다. 특히 여러 브라우저나 해상도에서 테스트해야 할 때 시간을 크게 절약할 수 있다.&lt;/p&gt;
&lt;p&gt;다만 멀티모달 + 긴 LLM 사용시간으로 인해 많은 토큰을 사용하는건 감안해야 한다.&lt;/p&gt;
&lt;p&gt;그 외 개발에 유용한 mcp 추천&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# Context7
claude mcp add --transport http context7 --scope user -- https://mcp.context7.com/mcp
codex mcp add context7 -- npx -y @upstash/context7-mcp&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Data/LLM</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/555</guid>
      <comments>https://shanepark.tistory.com/555#entry555comment</comments>
      <pubDate>Thu, 20 Nov 2025 14:55:55 +0900</pubDate>
    </item>
    <item>
      <title>Git Worktree 를 활용한 Claude Code 병렬 실행</title>
      <link>https://shanepark.tistory.com/554</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; 파일에 스펙을 명확히 정의해두고 , &lt;code&gt;PLAN.md&lt;/code&gt; 파일을 생성해 수행할 체크리스트를 작성해두어 컨텍스트를 유지하게끔 하며 Claude Code나 Codex-cli 로 모노레포에 함꼐 들어있는 프론트엔드와 백엔드를 번갈아가며 개발을 진행하곤 했다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;다양한 AI AGENT 에서 활용하려면 &lt;code&gt;CLAUDE.md&lt;/code&gt;, &lt;code&gt;GEMINI.md&lt;/code&gt; 등 심볼릭링크를 생성해둬야 한다. 혹은 해당 파일명으로 텍스트 파일을 생성하고 내용에 &lt;code&gt;@AGENTS.md&lt;/code&gt; 만 작성해둬도 알아서 추적 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;그런데 개발할 스펙이 이미 명확하게 정의되어 있고 프론트와 백엔드를 서로 독립적으로 개발할 수 있다면 굳이 순차적으로 번갈아가며 개발할 필요가 있을까 하는 생각이 들었다. 그래서 여러 개발자들로부터 적극 추천되었던 Git worktree를 활용해 Claude Code를 병렬로 실행하는 방식을 시도해봤는데, 개발 속도가 눈에 띄게 향상되어 상당히 인상적이다.&lt;/p&gt;
&lt;p&gt;물론 Claude Code가 sub agent를 생성해서 병렬 작업을 수행할 수도 있지만, 이는 하나의 작업 공간에서 여러 작업을 동시에 처리하는 방식이다.&lt;/p&gt;
&lt;p&gt;반면 Git worktree를 활용하면 물리적으로 분리된 디렉토리에서 각각 독립적인 Claude Code 인스턴스를 실행할 수 있다. 이는 완전히 별개의 agent를 각자의 작업 공간에서 별개의 브랜치로 실행하는 것이므로, 모노레포에서도 서로 간섭 없이 깔끔하게 병렬 작업이 가능하다.&lt;/p&gt;
&lt;h2&gt;Claude Code의 Sub Agent&lt;/h2&gt;
&lt;p&gt;Claude Code는 필요에 따라 sub agent를 생성하여 작업을 분산 처리할 수 있다. Sub agent는 메인 agent가 생성하는 하위 작업자로, 독립적으로 특정 작업을 수행한 후 결과를 메인 agent에게 전달한다.&lt;/p&gt;
&lt;h3&gt;Sub Agent 사용법&lt;/h3&gt;
&lt;p&gt;생각보다 간단하다. Claude Code와 대화 중에 명시적으로 요청하면 된다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# &amp;quot;프론트엔드와 백엔드 코드를 sub agent를 만들어서 병렬로 작성해줘&amp;quot;
# &amp;quot;이 작업을 3개의 sub agent로 나눠서 동시에 처리해줘&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/LLM/claude_code_with_git_worktree.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;알아서 3개의 서브 에이전트를 병렬로 실행하고 있는 상황. 작업시간이 확실히 줄어든다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;Sub Agent 활용&lt;/h3&gt;
&lt;p&gt;Sub agent는 하나의 작업 디렉토리 내에서 여러 파일을 동시에 생성하거나 수정할 때 유용하다. 예를 들어:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;여러 컴포넌트를 동시에 생성&lt;/li&gt;
&lt;li&gt;다수의 API 엔드포인트를 병렬로 구현&lt;/li&gt;
&lt;li&gt;여러 테스트 파일을 동시에 작성&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;다만 sub agent는 같은 작업 공간을 공유하므로, 서로 다른 브랜치에서 작업하거나 완전히 독립적인 환경이 필요한 경우에는 한계가 있다. 이런 상황에서 Git worktree가 필요하다.&lt;/p&gt;
&lt;h2&gt;Git Worktree&lt;/h2&gt;
&lt;p&gt;Git worktree는 하나의 저장소에서 여러 개의 작업 디렉토리를 동시에 운영할 수 있게 해주는 기능이다. 새로 clone을 받을 필요 없이 최소한의 디스크 공간만 사용하여 독립적인 작업 환경을 구성할 수 있다는 점이 가장 큰 장점이다.&lt;/p&gt;
&lt;p&gt;각 worktree는 &lt;code&gt;.git&lt;/code&gt; 파일을 통해 메인 저장소의 &lt;code&gt;.git&lt;/code&gt; 디렉토리를 참조한다. 전체 저장소를 다시 clone 받으면 Git 히스토리까지 모두 복사되지만, worktree는 작업 파일만 체크아웃하고 Git 데이터는 공유한다. 덕분에 clone 대비 상당한 디스크 공간을 절약할 수 있다.&lt;/p&gt;
&lt;h3&gt;Worktree 생성&lt;/h3&gt;
&lt;p&gt;Worktree를 생성할 때는 보통 새로운 브랜치도 함께 생성한다. &lt;code&gt;-b&lt;/code&gt; 옵션을 사용하면 브랜치 생성과 worktree 추가를 한 번에 할 수 있다&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 새 브랜치와 함께 worktree 생성 (권장)
git worktree add -b feature/frontend ../myproject-frontend

# 백엔드용 새 브랜치와 worktree 생성
git worktree add -b feature/backend ../myproject-backend

# 기존 브랜치를 사용하는 경우 (브랜치가 이미 존재할 때)
git worktree add ../myproject-hotfix hotfix/critical-bug

# 현재 worktree 목록 확인
git worktree list&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-b&lt;/code&gt; 옵션을 사용하면 현재 HEAD를 기준으로 새 브랜치를 생성하면서 동시에 worktree를 만든다. 이렇게 하면 각 작업이 독립적인 브랜치에서 진행되므로 나중에 머지할 때도 깔끔하다.&lt;/p&gt;
&lt;p&gt;만약 특정 브랜치를 기준으로 새 브랜치를 만들고 싶다면&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# main 브랜치를 기준으로 새 브랜치 생성
git worktree add -b feature/new-feature ../project-new-feature origin/main&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Worktree vs Sub Agent&lt;/h3&gt;
&lt;p&gt;두 방식의 차이를 정리하면 다음과 같다:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sub Agent 방식&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;하나의 작업 디렉토리에서 동작&lt;/li&gt;
&lt;li&gt;같은 브랜치에서 여러 작업 동시 수행&lt;/li&gt;
&lt;li&gt;메인 agent가 전체 조율&lt;/li&gt;
&lt;li&gt;파일 간 의존성이 있는 작업에 적합&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Worktree 방식&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;물리적으로 분리된 디렉토리&lt;/li&gt;
&lt;li&gt;각각 다른 브랜치에서 작업&lt;/li&gt;
&lt;li&gt;완전히 독립적인 Claude Code 인스턴스&lt;/li&gt;
&lt;li&gt;모듈 간 독립성이 높은 작업에 적합&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Claude Code와 Worktree 조합&lt;/h2&gt;
&lt;p&gt;먼저 프로젝트 루트에 &lt;code&gt;AGENTS.md&lt;/code&gt; 파일로 전체 스펙을 정의한다. 다음은 간략한 예시다&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-markdown&quot;&gt;## 공통 규약
- API 응답 형식: { success: boolean, data?: any, error?: string }
- 인증: Bearer Token (JWT)
- Base URL: /api/v1

## Data Models
- User: { id, email, name, role, createdAt }
- Post: { id, title, content, authorId, createdAt }

## Frontend Requirements
- React 18 기반 SPA
- 사용자 인증 UI
- 게시물 CRUD 화면

## Backend Requirements  
- Node.js + Express
- JWT 기반 인증 미들웨어
- RESTful API (users, posts)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Worktree 사용 예시&lt;/h3&gt;
&lt;p&gt;이제 각 부분을 독립적인 worktree에서 개발한다. 개발에 앞서 PLAN 을 작성하게 한 뒤 &lt;code&gt;PLAN.md&lt;/code&gt; 파일을 생성해두면 더 좋다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 인증 기능을 위한 새 브랜치와 worktree 생성
git worktree add -b feature/authentication ../project-auth

# 대시보드를 위한 새 브랜치와 worktree 생성 
git worktree add -b feature/dashboard ../project-dashboard

# 각각에서 Claude Code 실행
cd ../project-auth
claude
# &amp;quot;JWT 기반 인증 시스템을 구현해줘. 로그인, 로그아웃, 토큰 갱신 포함&amp;quot;

cd ../project-dashboard
claude
# &amp;quot;관리자 대시보드를 만들어줘. 사용자 통계와 최근 활동 표시&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 기능이 서로 독립적이기 때문에 동시 개발이 가능하다.&lt;/p&gt;
&lt;p&gt;서로 다른 모듈의 테스트 코드를 동시에 작성하는 등의 경우에는 worktree 까지 쓸 필요 없이 subagent 활용만으로 충분하다.&lt;/p&gt;
&lt;h2&gt;고려사항&lt;/h2&gt;
&lt;h3&gt;토큰 사용량&lt;/h3&gt;
&lt;p&gt;당연한 이야기지만 동시에 실행하는 Claude Code agent 수에 비례해서 토큰 사용량이 증가한다. 무턱대고 사용했다가는 순식간에 limit에 다다르게 된다. 지금까지는 클로드 Pro 플랜을 사용하며 여러가지 코드 에이전트를 돌려막기로 버텨왔지만 다중 agent를 사용하면서는 결국 MAXx20 플랜을 구독할 수 밖에 없었다.&lt;/p&gt;
&lt;h3&gt;브랜치 관리&lt;/h3&gt;
&lt;p&gt;같은 브랜치를 여러 worktree에서 체크아웃할 수 없다. Git이 자동으로 이를 방지하므로, 각 worktree는 다른 브랜치를 사용해야 한다:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 이미 사용 중인 브랜치를 체크아웃하려고 하면 오류 발생
git worktree add ../another-dir main
# fatal: &amp;#39;main&amp;#39; is already checked out at &amp;#39;/original/path&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;파일 충돌 방지&lt;/h3&gt;
&lt;p&gt;각 Claude Code 인스턴스가 독립적으로 작업하므로 나중에 merge 할 때 귀찮은 충돌 관리를 피하려면 같은 파일을 수정하는 일이 없도록 작업 범위를 명확히 구분하는 것이 중요하다. &lt;code&gt;PLAN.md&lt;/code&gt;에서 각 모듈의 책임과 파일 구조를 명확히 정의해두면 도움이 된다.&lt;/p&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;Git worktree를 활용하면 물리적으로 분리된 환경에서 여러 Claude Code를 동시에 실행할 수 있고, 이는 개발 속도를 획기적으로 향상시킨다. 특히 모노레포 환경이나 독립적인 모듈을 동시에 개발해야 하는 상황에서 상당한 시간 절약을 할 수 있다. 토큰보다는 개발자의 시간이 훨씬 비싸다.&lt;/p&gt;</description>
      <category>Data/LLM</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/554</guid>
      <comments>https://shanepark.tistory.com/554#entry554comment</comments>
      <pubDate>Wed, 19 Nov 2025 22:56:49 +0900</pubDate>
    </item>
    <item>
      <title>[Ubuntu] 멀쩡하던 한글 입력기가 갑자기 문제라면</title>
      <link>https://shanepark.tistory.com/553</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;Ubuntu를 쓰다보면 습관적으로 &lt;code&gt;sudo apt update&lt;/code&gt; 와 &lt;code&gt;sudo apt upgrade&lt;/code&gt; 를 입력하고는 한다.&lt;br&gt;그런데, 출근하고 보니 IntelliJ에서 한글 입력이 엉망이 되는 기묘한 현상이 시작되었다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;p&gt;의도한 텍스트: &lt;code&gt;한글 띄어쓰기가 이상하게 됩니다.&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;입력된 텍스트: &lt;code&gt;한 글띄어쓰기 가이상하 게됩니다.&lt;/code&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;잘 알려진 한글 끝 글자 이슈인데, KIME 한글 입력기를 사용하고부터는 좀처럼 겪지 않았던 문제다. &lt;/p&gt;
&lt;p&gt;사실 이게 전에도 한번 이런 일이 있었는데, 그때는 처음이라 해결하느라 너무 고생했었다. 이번에는 같은 문제를 다시 겪기도 했으니 글로 정리해두어 다음 번에 같은 상황이 왔을 때 낭비하는 시간을 줄이고자 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;환경 요약&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;OS&lt;/strong&gt;: Ubuntu 22.04 LTS&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;GPU&lt;/strong&gt;: NVIDIA GeForce GTX 1650 Ti (모바일)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;세션 타입&lt;/strong&gt;: Wayland (&lt;code&gt;echo $XDG_SESSION_TYPE → wayland&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;입력기&lt;/strong&gt;: KIME&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IDE&lt;/strong&gt;: IntelliJ IDEA (모든 버전에서 재현)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;문제&lt;/h2&gt;
&lt;p&gt;이상하게도 IntelliJ에서만 띄어쓰기와 자모 결합이 비정상적으로 작동했다. Chrome, Firefox, Slack, Terminal 등 다른 애플리케이션은 문제 없다.&lt;/p&gt;
&lt;p&gt;입력기를 바꿔보고, 인텔리제이 버전도 여러가지 받아서 확인해보았지만 모두 해결되지 않았다. &lt;/p&gt;
&lt;p&gt;마침 오늘 아침에도 &lt;code&gt;apt upgrade&lt;/code&gt;로 몇몇 패키지가 업데이트 되었기에 거기부터 문제를 추척해보기로 한다.&lt;/p&gt;
&lt;h2&gt;원인 추적&lt;/h2&gt;
&lt;p&gt;아래의 명령어를 입력하여 apt history 를 확인할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cat /var/log/apt/history.log&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;오늘 오전&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Start-Date: 2025-10-22  09:23:26
Commandline: apt upgrade
Requested-By: shane (1000)
Upgrade: google-chrome-stable:amd64 (141.0.7390.107-1, 141.0.7390.122-1), distro-info-data:amd64 (0.52ubuntu0.9, 0.52ubuntu0.11)
End-Date: 2025-10-22  09:23:33&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Chrome 과 distro-info-data 가 업그레이드 되었다. 여기에는 의심할 만한 게 없다.&lt;/p&gt;
&lt;p&gt;하루 전 히스토리를 확인해본다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Start-Date: 2025-10-21  09:41:20
Commandline: /usr/bin/unattended-upgrade
Install: nvidia-firmware-570-570.195.03:amd64 (570.195.03-0ubuntu0.22.04.1, automatic), libnvidia-egl-wayland1:amd64 (1:1.1.9-1.1, automatic), libnvidia-egl-wayland1:i386 (1:1.1.9-1.1, automatic)
Upgrade: libnvidia-fbc1-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-fbc1-570:i386 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-gl-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-gl-570:i386 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-extra-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), nvidia-compute-utils-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), nvidia-dkms-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), nvidia-driver-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-encode-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-encode-570:i386 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), nvidia-utils-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), xserver-xorg-video-nvidia-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-decode-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-decode-570:i386 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), nvidia-kernel-common-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-cfg1-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), nvidia-kernel-source-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-compute-570:amd64 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1), libnvidia-compute-570:i386 (570.172.08-0ubuntu1, 570.195.03-0ubuntu0.22.04.1)
Error: Sub-process /usr/bin/dpkg returned an error code (1)
End-Date: 2025-10-21  09:41:38

Start-Date: 2025-10-21  10:22:12
Commandline: apt --fix-broken install
Requested-By: shane (1000)
Install: nvidia-driver-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), libnvidia-gl-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), libnvidia-decode-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), libnvidia-fbc1-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), nvidia-kernel-source-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), libnvidia-cfg1-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), libnvidia-extra-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), nvidia-compute-utils-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), libnvidia-encode-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), nvidia-kernel-common-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), libnvidia-compute-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), xserver-xorg-video-nvidia-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), nvidia-utils-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), nvidia-firmware-570-server-570.195.03:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), nvidia-dkms-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic), libnvidia-common-570-server:amd64 (570.195.03-0ubuntu0.22.04.2, automatic)
Remove: libnvidia-common-570:amd64 (570.133.20-0ubuntu1), libnvidia-fbc1-570:amd64 (570.195.03-0ubuntu0.22.04.1), libnvidia-fbc1-570:i386 (570.195.03-0ubuntu0.22.04.1), libnvidia-gl-570:amd64 (570.195.03-0ubuntu0.22.04.1), libnvidia-gl-570:i386 (570.195.03-0ubuntu0.22.04.1), libnvidia-extra-570:amd64 (570.195.03-0ubuntu0.22.04.1), nvidia-compute-utils-570:amd64 (570.195.03-0ubuntu0.22.04.1), nvidia-dkms-570:amd64 (570.195.03-0ubuntu0.22.04.1), nvidia-driver-570:amd64 (570.195.03-0ubuntu0.22.04.1), libnvidia-encode-570:amd64 (570.195.03-0ubuntu0.22.04.1), libnvidia-encode-570:i386 (570.195.03-0ubuntu0.22.04.1), nvidia-utils-570:amd64 (570.195.03-0ubuntu0.22.04.1), nvidia-firmware-570-570.195.03:amd64 (570.195.03-0ubuntu0.22.04.1), xserver-xorg-video-nvidia-570:amd64 (570.195.03-0ubuntu0.22.04.1), libnvidia-decode-570:amd64 (570.172.08-0ubuntu1), libnvidia-decode-570:i386 (570.172.08-0ubuntu1), nvidia-kernel-common-570:amd64 (570.195.03-0ubuntu0.22.04.1), libnvidia-cfg1-570:amd64 (570.195.03-0ubuntu0.22.04.1), nvidia-kernel-source-570:amd64 (570.195.03-0ubuntu0.22.04.1), libnvidia-compute-570:amd64 (570.195.03-0ubuntu0.22.04.1), libnvidia-compute-570:i386 (570.195.03-0ubuntu0.22.04.1)
End-Date: 2025-10-21  10:23:50

Start-Date: 2025-10-21  11:22:51
Commandline: apt autoremove
Requested-By: shane (1000)
Remove: libxcb-present0:i386 (1.14-3ubuntu3), libxcb-dri3-0:i386 (1.14-3ubuntu3), libnvidia-egl-wayland1:i386 (1:1.1.9-1.1), nvidia-firmware-570-570.172.08:amd64 (570.172.08-0ubuntu1), nvidia-modprobe:amd64 (575.57.08-0ubuntu1)
End-Date: 2025-10-21  11:22:51&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;그렇다 전날에는 많은 일들이 있었다. 이걸 보자마자 확실한 용의자로 특정하고 즉시 검거했다.&lt;/p&gt;
&lt;h2&gt;원인 분석&lt;/h2&gt;
&lt;p&gt;자동 업데이트에 실패하고, &lt;code&gt;apt --fix-broken install&lt;/code&gt; 과정에서 서버용 그래픽카드 드라이버가 데스크톱용 드라이버를 대체해버렸다.&lt;/p&gt;
&lt;p&gt;문제는, 그래픽카드 드라이버가 Wayland 기반 입력기 클라이언트 창 렌더링에 관여하는데, 서버용 드라이버는 GUI 용도가 아니라서 그런지 아니면 새로 배포되는 버전이 문제였는지.. 어쨌든 이슈가 있던 모양이다.&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;h3&gt;1) 현재 상태 점검&lt;/h3&gt;
&lt;p&gt;먼저 현 상황을 확인했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nvidia-smi
echo $XDG_SESSION_TYPE&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;Wed Oct 22 09:49:36 2025 
 +-----------------------------------------------------------------------------------------+
 | NVIDIA-SMI 570.195.03 Driver Version: 570.195.03 CUDA Version: 12.8 |
 |-----------------------------------------+------------------------+----------------------+
 | GPU Name Persistence-M | Bus-Id Disp.A | Volatile Uncorr. ECC |
 | Fan Temp Perf Pwr:Usage/Cap | Memory-Usage | GPU-Util Compute M. |
 | | | MIG M. |
 |=========================================+========================+======================|
 | 0 NVIDIA GeForce GTX 1650 Ti Off | 00000000:01:00.0 Off | N/A |
 | N/A 57C P8 4W / 50W | 5MiB / 4096MiB | 0% Default |
 | | | N/A |
 +-----------------------------------------+------------------------+----------------------+

 +-----------------------------------------------------------------------------------------+
 | Processes: |
 | GPU GI CI PID Type Process name GPU Memory |
 | ID ID Usage |
 |=========================================================================================|
 | 0 N/A N/A 17686 G /usr/bin/gnome-shell 1MiB |
 +-----------------------------------------------------------------------------------------+


 $ echo $XDG_SESSION_TYPE
 wayland&lt;/code&gt;&lt;/pre&gt;&lt;h3&gt;2) 일반 드라이버로 복귀&lt;/h3&gt;
&lt;p&gt;설치된 서버 드라이버를 지우고 일반 버전으로 되돌렸다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt install -y nvidia-driver-570
sudo apt remove -y nvidia-driver-570-server
sudo reboot&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;재부팅 후 &lt;code&gt;nvidia-smi&lt;/code&gt;로 재확인:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ nvidia-smi
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 570.172.08             Driver Version: 570.172.08     CUDA Version: 12.8     |
|-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA GeForce GTX 1650 Ti     Off |   00000000:01:00.0 Off |                  N/A |
| N/A   51C    P8              4W /   50W |       8MiB /   4096MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|    0   N/A  N/A            2611      G   /usr/lib/xorg/Xorg                        4MiB |
+-----------------------------------------------------------------------------------------+
&lt;/code&gt;&lt;/pre&gt;&lt;h2&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;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;문제 상태&lt;/td&gt;
&lt;td&gt;570.195.03 (server)&lt;/td&gt;
&lt;td&gt;Wayland&lt;/td&gt;
&lt;td&gt;&lt;code&gt;한 글띄어쓰기 가이상하 게됩니다.&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;해결 상태&lt;/td&gt;
&lt;td&gt;570.172.08 (일반)&lt;/td&gt;
&lt;td&gt;X11 (Xorg)&lt;/td&gt;
&lt;td&gt;한글 입력 정상&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;재부팅 후 인텔리제이에서도 문제 없이 한글 입력이 된다. &lt;/p&gt;
&lt;p&gt;글을 정리하고 보니 드라이버 문제 라기 보다는 wayland 문제였던 것 같아 테스트를 위해 로그아웃 후, wayland 로 부팅을 해보니 한글이 입력이 되지 않는다. wayland로 갔다가 다시 x11로 와도 문제는 로그아웃 만으로는 해결이 안되는데, 재부팅 까지 해야 해결되니 굳이 테스트하지 말 것.&lt;/p&gt;
&lt;p&gt;찾아보니 우분투같은 gnome은 wayland 입력을 ibus를 내장해서 하기 때문에 kime를 사용할수 없다고 한다. &lt;/p&gt;
&lt;p&gt;추가로, wayland 로 테스트를 할 때 그래픽 문제까지 발생하였기 때문에 앞으로 한글입력기 문제가 아니어도 계속 x11을 써야겠다.&lt;/p&gt;
&lt;p&gt;끝&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/Riey/kime/issues/604&quot;&gt;https://github.com/Riey/kime/issues/604&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>IT &amp;frasl;  Computer/Linux</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/553</guid>
      <comments>https://shanepark.tistory.com/553#entry553comment</comments>
      <pubDate>Wed, 22 Oct 2025 12:35:33 +0900</pubDate>
    </item>
    <item>
      <title>Claude Code 사용량 확인 기능 추가 소식</title>
      <link>https://shanepark.tistory.com/552</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;요즘에는 &lt;code&gt;codex-cli&lt;/code&gt;와 &lt;code&gt;Claude Code&lt;/code&gt;를 번갈아가며 쓰고 있다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AGENTS.md&lt;/code&gt; 파일을 만들고 &lt;code&gt;ln -s AGENTS.md CLAUDE.md&lt;/code&gt; 명령어로 심볼릭 링크를 만들면 두 코딩 에이전트가 같은 컨텍스트 파일을 공유한다. 둘 다 성능이 매우 훌륭해 상황에 따라 골라 쓰기만 하면 된다.&lt;/p&gt;
&lt;p&gt;Claude Code는 5h limit이 빡빡해서 주간 리밋에 걸리는 일은 거의 없고, Codex는 5h limit은 넉넉하지만 Weekly limit에 쉽게 닿는 구조다. 그래서 평소에는 &lt;code&gt;Claude Code Sonnet 4.5&lt;/code&gt;를 메인으로 사용하다가 5시간 제한에 걸리거나 Sonnet으로 풀기 어려운 문제가 있으면 &lt;code&gt;codex-cli&lt;/code&gt;에서 &lt;code&gt;gpt-5-codex-high&lt;/code&gt; 모델을 꺼낸다. 월 &lt;code&gt;$20 + $20 = $40&lt;/code&gt;만 지불하면 누릴 수 있는 가성비 조합이다.&lt;/p&gt;
&lt;h2&gt;사용량 확인&lt;/h2&gt;
&lt;p&gt;Codex는 &lt;code&gt;codex-cli 0.40&lt;/code&gt;에서 도입된 사용량 모니터링 기능 덕분에 &lt;code&gt;/status&lt;/code&gt; 한 줄로 5h / Weekly limit을 쉽게 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/claude-usage.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;p&gt;반면 Claude Code에는 그동안 유사한 기능이 없어 &lt;code&gt;npx ccusage@latest&lt;/code&gt;를 주기적으로 실행하며 5h 리밋이 언제 다가오는지 추측만 할 수 있었다(5h 기준 약 $5 근처에서 걸림). 5h context window가 언제 종료되는지도 정확히 알 수 없어 항상 답답했는데, 어느 날 &lt;code&gt;/status&lt;/code&gt;를 습관처럼 입력한 순간 마침 세션이 Claude Code였고 새로 추가된 &lt;code&gt;/usage&lt;/code&gt; 커맨드를 발견했다. 기다리던 기능을 마침내 만나게 된 셈이다.&lt;/p&gt;
&lt;p&gt;소소하지만 스트레스를 크게 줄여주는 업데이트다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/claude-usage.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;code&gt;/status&lt;/code&gt; 입력 후 Tab을 두 번 누르거나 &lt;code&gt;/usage&lt;/code&gt;를 바로 입력하면 현재 플랜 기준 사용량과 리셋 시간이 요약된다.&lt;br&gt;Codex는 최소한 한 번은 프롬프트를 보내야 리밋 정보가 나오지만, Claude는 세션이 열리면 바로 윈도우가 시작돼 곧바로 데이터를 볼 수 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;a href=&quot;https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md&quot;&gt;CHANGELOG.md&lt;/a&gt;를 확인해보면 &lt;code&gt;/usage&lt;/code&gt; 커맨드는 &lt;code&gt;Claude Code 2.0.0&lt;/code&gt;에서 추가되었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Claude Code&lt;/code&gt;는 기본적으로 자동 업데이트지만 업데이트가 안 된 상태면 &lt;code&gt;/usage&lt;/code&gt;가 없을 수도 있다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;현재 버전 확인.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;claude --version
# 예) 2.0.13 (Claude Code)&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;li&gt;&lt;p&gt;업데이트 방법.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 수동 업데이트
claude update

# 자동 업데이트 끄고 싶을 때
export DISABLE_AUTOUPDATER=1&lt;/code&gt;&lt;/pre&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Sonnet 4.5&lt;/code&gt;가 나오면서 체급이 확 올라왔다. Anthropic 발표에 따르면 SWE-bench Verified 같은 코드 벤치에서 강한 성능을 보였고, 여러 영역에서 이전 최상위였던 &lt;code&gt;Opus 4.1&lt;/code&gt;을 앞서는 결과가 나왔다. 실제 사용에서도 체감이 크다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;codex-cli&lt;/code&gt;의 성능도 꾸준히 올라 서로 엎치락뒤치락하며 경쟁하는 구도가 되니 사용자 입장에서는 더없이 만족스럽다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md&quot;&gt;https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://anthropic.mintlify.app/en/docs/claude-code/setup&quot;&gt;https://anthropic.mintlify.app/en/docs/claude-code/setup&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://www.anthropic.com/news/claude-sonnet-4-5&quot;&gt;https://www.anthropic.com/news/claude-sonnet-4-5&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Develop Tools</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/552</guid>
      <comments>https://shanepark.tistory.com/552#entry552comment</comments>
      <pubDate>Fri, 10 Oct 2025 14:26:17 +0900</pubDate>
    </item>
    <item>
      <title>클로드 코드 vs 커서 비교 사용기</title>
      <link>https://shanepark.tistory.com/551</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Intro&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Claude Code&lt;/code&gt;와 &lt;code&gt;Cursor&lt;/code&gt;는 현재 개발자들 사이에서 가장 뜨거운 관심을 받는 AI 코딩 에이전트다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아쉽게도 아무리 찾아봐도 이 둘의 상세한 사용기를 다룬 글이 별로 없었다. X(트위터)에서 Claude 구독을 시작하며 Cursor를 해지했다는 개발자들의 후기가 종종 올라오긴 했지만, 구체적인 이유는 잘 설명되지 않았다. 그래서 직접 둘 다 써보고 비교 글을 작성해보기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통은 Cursor에서 Claude Code로 갈아타는 케이스가 많을 텐데, 나는 반대로 Claude Code에서 Cursor로 넘어간 케이스다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code를 만족하며 썼지만, $20짜리 Pro 플랜은 업무용으로 쓰기엔 리밋에 너무 자주 걸렸다. MAX 플랜은 금액이 부담스러운 상황에서, 마침 Cursor CLI가 출시되기도 해서 한 번 써보기로 했다. 회사에서 지원해주는 Cursor Team Plan($40)에 참여한 것도 그 계기였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Cursor CLI&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cursor 사용을 시작할 때만 해도 Cursor CLI만 사용할 예정이었지만 CLI는 후술할 여러가지 이유로 거의 사용하지 못했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Team Plan에서는 월 총 500회 사용량 제한이 있다. 그런데 버그인지 Cursor CLI를 쓰면 토큰 수에 비해 사용량이 터무니없이 빠르게 소모됐다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/cursor_vs_claude_code.assets/1.webp&quot; alt=&quot;1&quot; /&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/cursor_vs_claude_code.assets/3.webp&quot; alt=&quot;3&quot; /&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맨 위의 1, 1, 1, 1, 1 , 2를 제외한 모든 소수점 있는 요청들이 Cursor CLI로 보낸 요청이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;683.7K 토큰짜리 요청이 무려 19.9 request 를 사용했다. 위의 커서 IDE 에서 보낸 요청을 보면 1M 짜리도 1회만 차감할 뿐인데 말이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨텍스트가 조금만 늘어나면 맛이 가고 응답이 안 나온다. 사용량 조회를 해보면 말도 안 되는 MAX 딱지가 붙어 저 상태로 고정돼 있다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분명한 버그라 Cursor 팀에 문의도 남겼는데, &quot;미안하다&quot;고 $10 크레딧을 넣어준 후로 일주일 넘도록 아무런 소식이 없다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/cursor_vs_claude_code.assets/2.webp&quot; alt=&quot;2&quot; /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비용은 차치하더라도, Claude Code와의 비교를 위해 그리고 개인적으로 터미널 환경을 선호해서 끝까지 CLI를 고집하고 싶었지만, 문제가 너무 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;토큰 수가 조금만 많아지면 응답이 안 오는 일이 비일비재하고, 차라리 응답이 안 오면 다행이지 혼자 주저리주저리 하며 토큰만 한참 동안 소모하다가 아무런 액션이 없을 때가 많다. 나름 Claude Code와 비슷한 기능을 구현하려고 UI/UX는 흡사하게 해놓은 것 같은데, 핵심 기능이 엉망이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한동안 Beta 딱지를 떼기는 힘들 것 같다. Cursor CLI는 지금 기준으로는 도저히 써먹을 만한 게 아니라는 결론을 내렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제부터는 Cursor CLI가 아닌 IDE 기반의 Cursor 에디터와 Claude Code가 비교 대상이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;비교기&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;커밋&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커밋 작업은 프로젝트의 전체적인 맥락과 코드 흐름, 작업 내용을 어느 정도 이해하고 있는지 알아보기 좋은 지표라고 생각한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Claude Code 의 커밋&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;커밋해줘&quot;라고 한마디만 하면 정말 알아서 다 해준다. 최근 커밋들을 조회해서 커밋 메시지 컨벤션을 확인하고, 최종 커밋으로부터의 변경사항들을 체크한 뒤 적절한 커밋 메시지를 작성한다. 커밋 컨벤션을 지키면서 최대한 자세히 쓰고, 마지막에는 본인이 같이 코드에 참여했다고 Co-Authored-By에 자기 이름까지 껴넣는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 놀라운 건, 디버깅 중에 실수로 남겨둔 &lt;code&gt;System.err.println()&lt;/code&gt; 같은 커밋에 포함할 의도가 없는 코드가 있으면 따로 말하지 않아도 알아서 스테이지에 넣지 않고 커밋해준다는 점이다. 사소한 수정 하나하나도 허투루 하지 않고 확인하며 커밋한다는 증거다. 커밋에서의 이런 디테일은 항상 인상 깊다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Cursor의 커밋&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Cursor&lt;/code&gt;에서도 커밋을 시켜봤는데, 컨벤션이고 뭐고 무시하고 자기 멋대로 한다. 당연히 &quot;최근 깃 히스토리 보고 컨벤션 확인해 비슷하게 메시지 입력하고, 구현한 걸 어떻게 표현할지 설명해&quot;라고 구체적으로 지시하면 따르긴 한다. 그런데 그렇게 말 안 해주면 절대 안 한다. Claude Code에서는 &quot;커밋해줘&quot; 한마디로 다 알아서 했던 것들인데 말이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더 충격적인 건 그 후에 발생했다. 커밋 후 대화를 유지한 상태에서 다른 작업을 추가로 시키니 코드를 열심히 변경하더니, 자기 멋대로 커밋까지 해버렸다. 수정 내용에 대한 검토나 테스트는 신경 쓰지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이걸 니 맘대로 커밋하냐고 뭐라 한마디 했더니 다짜고짜 미안하다며 &lt;code&gt;git reset&lt;/code&gt;을 해버린다. &lt;code&gt;--soft&lt;/code&gt;나 &lt;code&gt;--mixed&lt;/code&gt;면 말도 안한다. &lt;code&gt;--hard&lt;/code&gt; 붙여서 그냥 다 날려버렸다. 좀 뭐라 그랬다고 삐졌나? 이때 진짜 얘는 뭐하자는건가 싶었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;코드 되돌리기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막 프롬프트로부터의 코드 변경사항이 마음에 들지 않았을 때, 마지막 커밋 이후에 작업한 내용이 남아있는 상태에서 코드를 되돌리자고 할 때의 차이도 컸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code에서는 마지막 프롬프트에서 추가한 작업들만 되돌렸지만, Cursor에서는 어쩌라고 하며 &lt;code&gt;git restore&lt;/code&gt;를 수행해버린다. 작업 내용 날려먹고선 뻔뻔하게 &quot;깔끔해졌어요&quot;라고 뿌듯해하는 건 덤이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Claude-4-Sonnet-Thinking&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cursor에서의 claude-4-sonnet 모델은 Claude Code에서의 Sonnet4와 비교했을때 같은 모델이 맞나 싶을 정도로 체급 자체가 부족한 느낌이었는다. 그러나 구세주가 나타났으니 &lt;b&gt;Thinking&lt;/b&gt; 모델로 바꾸니 &quot;나 적어도 모델은 같은건 맞아요&quot;라는걸 이해하고 받아들일 수 있을 정도는 됐다. 아마 Claude Code 에서는 Cursor에서 말하는 Thinking 모델이 기본 모델인 듯 하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히도 근본적으로 해결되지 못하는 문제들은 차치하더라도 Thinking 모델을 붙여놓고 쓰면 그래도 Claude Code 쓰던 경험의 80% 이상은 커버가 되었다. 쓸만 하는 이야기다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 여전히 토큰 사이즈와 무관하게 월 요청 횟수 제한 기반으로 과금되는 시스템이라, 어떻게든 응답을 한 번에 뱉으려는 경향이 있다. 잔걸음으로 코딩하기는 힘들고 아깝다. Cursor에서는 한 번에 최대한 충분한 컨텍스트를 밀어넣고 작업이 잘 되기를 바라는 '기도 메타'가 주류인 이유다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI와의 협업에서 짧은 호흡으로 목표를 Align하며 차근차근 쌓아가고, 잦은 커밋으로 안정적으로 진행하는 내 스타일로는 사용량이 x2인 Thinking 모델을 쓰면 일주일 만에 리퀘스트를 다 소모한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;다중 모델 지원&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cursor는 여러 벤더의 다양한 모델을 지원하는 구조라, 사용자 수요에 맞춰 모델을 선택할 수 있다는 점이 얼핏 장점처럼 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 iOS와 안드로이드에 비유하면 이해하기 쉽다. iOS는 애플의 한정된 기기에서만 작동하기에 메모리, 배터리, 성능 최적화에서 큰 이점을 가진다. 관리 포인트가 늘어나는 건 피곤한 일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Sonnet, GPT-5 등 여러 모델을 번갈아가며 사용해봤지만 결국 Sonnet Thinking만 고정하고 사용하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cursor를 쓰면서도 결국 대부분의 사용자는 한가지 주력 모델만 사용하게 되기 때문에 여러 모델 중 선택할 수 있다는게 굳이 장점이 될 수 있을지 모르겠다. 한곳에 의지하다가는 사업이 불확실성에 대응하기 힘들어 여러 모델을 사용할 수 있게 했을텐데 고객의 편의보다는 어쩔 수 없는 생존 전략일 것이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Claude Code 강점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude Code에서는 컨텍스트 확대가 필요하면 알아서 코드베이스를 찾아 시간을 추가로 할애한다. 될 때까지 말이다. 사용자가 토큰을 많이 쓰는 데 개의치 않으므로, 수익 모델상 토큰을 아껴야 하는 Cursor는 꿈도 못 꿀 일이다. 신규나 작은 프로젝트에서는 차이가 적지만, 코드베이스가 큰 레거시 프로젝트에서는 체감이 크다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에 배포할 때도 Claude Code 하나만 딸랑 설치해두고 채팅 몇 번 하면 필요한 세팅 아주 쉽게 싹다 할 수 있다. 에러가 발생해도 스스로 알아서 대응해주기 때문에 에러메시지 읽고 대응하고, 모르는건 LLM에 물어보고 하라는대로 다시 복사해서 실행하고 하는 등의 번거로운 작업이 아주 간소화 된다. 터미널을 종료해도 나중에 &lt;code&gt;/resume&lt;/code&gt;으로 이어갈 수 있다는 것도 큰 장점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;psql 같은 걸로 스스로 테이블도 뭐있는지 확인하고 쿼리도 직접 날려보면서 확인한다. Solr 같은 검색엔진도 주소만 알려주면 curl로 스스로 쿼리 날려보며 작업한다. 이런 주도적인 모습이 굉장히 인상깊다. docker 컨테이너들도 알아서 오케스트레이션 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비슷한 걸 Cursor에 시키니 할 수는 있지만, 왠만하면 안 하려 한다. 터미널 명령어로 간단히 확인할 걸 굳이 Python 코드로 작성하는 경향이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Claude는 필요할 때 스스로 서브 에이전트를 생성해 병렬 작업하는 점도 놀랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 IDE를 안 가린다는 게 큰 장점이자 단점이다. macOS나 Linux 사용자에게는 최고지만, Windows 유저들은 터미널 환경이 불편할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;결론&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;cursor를 쓰며 달라진 점들&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;AI와 싸우는 일이 늘었다&lt;/b&gt;: Claude Code에서는 알아서 하던 것들을 Cursor에게 구구절절 일일이 설명해야 한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;타이핑이 늘었다&lt;/b&gt;: 위의 이유로 컨텍스트를 주구절절 설명하느라 타이핑 양이 늘었다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Rule 설정의 필요성&lt;/b&gt;: Cursor에서 rule을 추가해야 잘 쓸 수 있다는 게 이해가 가지만, Claude Code 사용자 입장에서는 코드에 녹아든 내용들을 다시 설명해야 한다는 게 어렵다.&lt;/li&gt;
&lt;li&gt;Claude 사용할 때는 IntelliJ IDEA만 썼는데, Cursor 때문에 두 IDE에 큰 프로젝트를 동시에 띄우니 리소스 사용이 장난 아니다. 컴퓨터가 버거워하는 게 느껴진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;새롭게 깨달은 사실들&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;5 hours window limit의 관대함&lt;/b&gt;: Claude Code $20 요금제의 5시간 제한이 사실 엄청 후한 거였다는걸 깨달았다. context만 한번씩 clear 혹은 compact 해주면 그래도 나름 꽤 쓴다. 요금제를 따지고 보면 가성비면에서 사실 MAX 플랜이랑 비교해도 Claude 압승이다. Cursor에서 Opus 모델을 계속 쓴다면 사용요금이 과연 $100~$200 에서 끝날 수 있을까?&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Auto Accept&lt;/b&gt;: Claude Code에서는 auto accept를 쓰면 땡이라서, Cursor 사용자들의 &lt;code&gt;눈으로 보고 판단해야 해서 에디터가 필요하다&lt;/code&gt;는 주장을 납득할 수 없었지만 이제는 이해할 수 있게 됐다. Cursor의 결과물은 auto accept 했다가는 다시 고치는데 시간이 더 걸릴 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그 외 차이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집에서는 Mac, 회사에서는 Ubuntu OS를 사용하니 터미널 환경이 훨씬 편하고 기능이 강력하다. Windows 를 썼다면 Cursor를 썼을지도 모르겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 애초에 Cursor와 Claude Code 는 1:1로 비교할 만한 비교군이 아니라고 생각한다. 프레임워크와 라이브러리의 차이, 즉 &lt;b&gt;Inversion Of Control&lt;/b&gt;을 생각해보면 된다. Cursor를 썼을 때는 Cursor의 도움을 받아 내가 주도적으로 코딩을 했다면 Claude Code는 Claude가 스스로 알아서 하고 난 그걸 매니징할 뿐이다. 전체적인 주도권을 쥐고 코딩하고 싶다면 Cursor가 더 손에 맞을 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;솔직히 말해 작성하는 &lt;b&gt;코드 퀄리티&lt;/b&gt; 자체는 크게 차이가 없었다. 하지만 전체적인 맥락을 이해하고 작성된 코드인지 아닌지에 따라 그 방향이나 소모되는 시간의 차이가 크게 달라진다. 아무리 좋은 코드도 적절한 때와 장소가 아니라면 추가로 청소해야 할 대상일 뿐이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한동안 사용해보니 내겐 알잘딱깔센 Claude Code가 더 잘 맞았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 여러 차이들이 존재하지만, 각자의 환경과 필요에 따라 더 적합한 도구가 다를 수 있다. Cursor도 분명히 장점이 있고, 특히 에디터와 긴밀하게 통합된 환경을 선호하는 사람들에게는 좋은 선택이 될 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;터미널 환경에 익숙하고 Claude Code를 먼저 써본 입장에서는 AI가 더 주도적으로 알아서 센스있고 섬세하게 작업을 해줘서 훨씬 편리했다는게 솔직한 소감일 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;너무 Claude Code 편만 들어서 쓴 것 같지만, Cursor도 잘 쓰고 있다. 오늘 소감일 뿐, 한두 달 후엔 어떻게 될지 모른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 자체 모델을 보유한 Anthropic 앞에서 Cursor가 살아남기 힘들 거라는 게 내 의견이다. 급하게 낸 엉망인 Cursor CLI의 완성도가 그들의 위기감을 보여준다. 끝&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고할만한 다른 사용자의 후기들&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.reddit.com/r/ChatGPTCoding/comments/1l8w2h4/difference_between_using_cursor_and_claude_code/&quot;&gt;https://www.reddit.com/r/ChatGPTCoding/comments/1l8w2h4/difference_between_using_cursor_and_claude_code/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://blog.naver.com/khjkhj2804/223927854451&quot;&gt;https://blog.naver.com/khjkhj2804/223927854451&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://news.hada.io/topic?id=22375&quot;&gt;https://news.hada.io/topic?id=22375&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Data/LLM</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/551</guid>
      <comments>https://shanepark.tistory.com/551#entry551comment</comments>
      <pubDate>Mon, 15 Sep 2025 23:20:37 +0900</pubDate>
    </item>
    <item>
      <title>세번째 LeetCode 티셔츠</title>
      <link>https://shanepark.tistory.com/550</link>
      <description>&lt;h2&gt;지난 두 번의 리트코드 티셔츠&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://shanepark.tistory.com/452&quot;&gt;Leetcode 1년, 드디어 리트코드 티셔츠&lt;/a&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;2023.01.25&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;첫 번째 티셔츠를 받기까지는 정말 어려운 도전이라고 생각했다. 매일같이 포인트를 확인하며, 드디어 6,000 포인트를 다 모았을 때는 두근거림과 설렘이 교차했다. 처음엔 너무 마음에 들어서 회사에도 입고 간 적도 있었다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://shanepark.tistory.com/507&quot;&gt;두번째 LeetCode 티셔츠&lt;/a&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;2024.03.27&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;두 번째 티셔츠는 약 14개월 만에 받았다. 이번엔 열심히 챙겨 모은 게 아니라, 그냥 하다 보니 어느새 포인트가 쌓여 있었다. 심지어 6,000포인트를 다 모은 뒤에도 며칠 동안 모르고 지나쳤다가 늦게 신청했다. 돈 한 푼 안 썼는데도 여전히 무료로 집 앞까지 배송해주는 게 참 고마웠다.&lt;/p&gt;
&lt;h2&gt;세번째 티셔츠&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;2025.08.27&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/ps/leetcode3.assets/3.webp&quot; alt=&quot;3&quot;&gt;&lt;/p&gt;
&lt;p&gt;지난 겨울쯤, 리트코드 후디가 7,200 포인트에 추가된 적이 있었다. 아쉽게도 그때는 충분한 포인트가 없어서 신청할 수 없었는데, 얼마 뒤 다시 확인해보니 7,200 포인트짜리 후디는 사라지고 기존의 티셔츠가 7,200 포인트로 인상돼 있었다.&lt;/p&gt;
&lt;p&gt;나는 이미 몇 번 받아봤으니 크게 개의치 않았지만, 첫 티셔츠를 위해 6,000포인트만 바라보며 달려온 이들에게는 꽤 큰 시련이었을 것이다. 1,200포인트를 더 모으는 데만 부지런히 3개월은 걸릴 테니까.&lt;/p&gt;
&lt;p&gt;어찌 됐든 매일 문제 풀이를 하다 보니 또다시 포인트가 충분히 쌓여있었고, 이번에야말로 블랙 컨슈머로 차단당하는 건 아닌가 하는 약간의 걱정과 함께 세 번째 티셔츠를 주문했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/ps/leetcode3.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;세 번의 주문 기록&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/ps/leetcode3.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;그들은 몇 년 전에도 친절했는데, 지금은 그때보다도 더 사려 깊고 친절하다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이번에도 걱정과는 달리, 오히려 이전보다 빠른 프로세스로 바로 발송해 주었고, 중국에서 출발한 패키지는 정확히 열흘 만에 집에 도착했다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/ps/leetcode3.assets/4.webp&quot; alt=&quot;4&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;10 일간의 여정&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/ps/leetcode3.assets/8.webp&quot; alt=&quot;8&quot;&gt;&lt;/p&gt;
&lt;p&gt;티셔츠와 스티커 구성은 세 번 모두 동일했다. 옷의 품질은 여전히 좋아서, 이전 글을 찾아보니 그때도 “퀄리티가 좋다”고 감탄했던 기록이 있었다. 다만 기존 티셔츠들이 집에서 잠옷으로 너무 자주 입다 보니 낡아서, 새 옷이 훨씬 좋아 보인 것뿐이었다.&lt;/p&gt;
&lt;p&gt;예전에는 스티커를 홈서버용 노트북에 덕지덕지 붙였는데, 이번에는 딸아이가 가져가더니 집안 곳곳에 붙여버렸다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/ps/leetcode3.assets/5.webp&quot; alt=&quot;5&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;두 번째 티셔츠를 받았을 때 약속했던 프리미엄 구독은 이번에야 지켰다. 금액이 솔직히 꽤 부담스럽지만, 고마운 마음을 담아 결제했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;예전 글을 쓸 때도 그랬는데, 이번에도 다른 사람들의 후기가 궁금해 검색해 보니 여전히 거의 나오지 않는다. 이렇게 좋은 기회를 혼자만 누리기는 아쉽다. 더 많은 사람들이 티셔츠를 받아가면 좋겠다.&lt;/p&gt;
&lt;h2&gt;오래전 기억&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/ps/leetcode3.assets/9.webp&quot; alt=&quot;9&quot;&gt;&lt;/p&gt;
&lt;p&gt;호주 브리즈번에서 카페에 근무하던 시절, 퇴근하면 옆 아케이드 센터에 자주 갔다. 그곳에서 건물을 쌓는 게임을 즐겨 했는데, 매번 같은 게임만 하다 보니 손에 익어서 티켓이 제법 쌓였고, 브리즈번을 떠날 때쯤 인스탁스 미니 카메라를 경품으로 받을 수 있었다.&lt;/p&gt;
&lt;p&gt;그 아케이드는 개장한 지 오래되지 않아, 그런 고가 경품이 잘 나오지 않았던 모양이다. 매니저가 내게 인증샷을 찍어 페이스북에 올려도 되겠냐고 물었다.&lt;/p&gt;
&lt;p&gt;“곧 서른인데, 이 나이에 맨날 오락실 왔다고 동네방네 소문낼 수는 없다”며 웃어넘기고 상품만 받아 나왔다. 그런데 시간이 지나고 나니 왜 그땐 그걸 창피하게 생각했을까 하는 아쉬움이 종종 든다. 아마 그 시절의 나라면 이런 블로그 글도 쓰지 않았을 것이다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/ps/leetcode3.assets/7.webp&quot; alt=&quot;7&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;그 후로 많은 친구들이 상품을 받았다며 이렇게 인증샷이 올라왔다.&lt;/p&gt;
&lt;p&gt;가게 입장에서는 최고의 홍보 수단이었을 테고, 이용자는 좋은 추억을 남겼으니 서로에게 윈윈이었다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.facebook.com/KingpinChermside/&quot;&gt;https://www.facebook.com/KingpinChermside/&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;마치며&lt;/h2&gt;
&lt;p&gt;요즘도 매일 문제 풀이를 하고 있지만, 알고리즘 실력은 예전 같지 않다. 한 문제에 몇 시간이고 매달릴 집중력이 이제는 없다.&lt;/p&gt;
&lt;p&gt;리트코드는 내게 자기계발이나 공부라기보다는, 퇴근 후 가볍게 오락실 들르는 정도의 의미다. 만약 공부라고 생각했다면, 이렇게 4년 가까이 매일 하진 못했을 것이다. 작은 보상이 습관을 만들고, 그 습관이 나를 오랫동안 한 곳에 머물게 했다.&lt;/p&gt;
&lt;p&gt;그저 누군가 리트코드를 즐기며 문제 풀이를 하고, 티셔츠를 받았다는 소식을 전한다면 정말 반가울 것 같다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/ps/leetcode3.assets/6.webp&quot; alt=&quot;6&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;조만간 쌍둥이가 태어나는데, 옷 두 벌을 더 추가하려면 3년은 걸린다. 그때까지도 리트코드가 지금처럼 관대할지는 두고 볼 일이다.&lt;/p&gt;
&lt;p&gt;곰돌이 탈은 나노바나나가 씌워줬다. 조명까지 알아서 계산해 그림자도 그려줬다. 정말 인상적이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;</description>
      <category>Development/DevLife</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/550</guid>
      <comments>https://shanepark.tistory.com/550#entry550comment</comments>
      <pubDate>Sat, 13 Sep 2025 00:46:21 +0900</pubDate>
    </item>
    <item>
      <title>Chrome uBlock Origin 막힘 해결</title>
      <link>https://shanepark.tistory.com/549</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;strong&gt;2025.09.03 추가&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Chrome 139 버전까지는 설정 바꿔가면서 어떻게든 썼지만 140부터는 설정변경으로도 활성화가 안된다.&lt;/p&gt;
&lt;p&gt;이제는 어쩔 수 없이 크롬에서는 Ublock Origin Lite를 사용해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://chromewebstore.google.com/detail/ublock-origin-lite/ddkjiahejlhfcafbddmgiahcphecmpfh&quot;&gt;https://chromewebstore.google.com/detail/ublock-origin-lite/ddkjiahejlhfcafbddmgiahcphecmpfh&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;다만 강력했던 이전의 광고차단 기능이 다소 꺾였으니, 필요한 상황에서는 Firefox 등을 대안으로 사용해야 한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;...&lt;/p&gt;
&lt;p&gt;Chrome 에서 uBlock을 완전 멈춰세웠다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;This extension is no longer available because it doesn&amp;#39;t follow best practices for Chrome extensions&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이라고 뜨며 2025년 3월부터 못쓰게 하긴 했었지만, 그래도 크롬에서 제거하라는 제안을 무시하고 Extension 목록에서 사용함으로 체크하면 계속 쓸 수는 있었는데 이번에는 그렇게도 사용 못하게 해버렸다. 사용 체크가 안된다.&lt;/p&gt;
&lt;p&gt;그래도 아직은 사용을 유지하도록 하는 방법이 있다.&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;아래의 텍스트를 Chrome 주소창에 치고 설정에 들어가서 사용함으로 변경한다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;chrome://flags/#temporary-unexpire-flags-m137                     [Enabled]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;이후 크롬을 재 시작하고 아래의 옵션들도 각각 설정해준다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;chrome://flags/#extension-manifest-v2-deprecation-warning         [Disabled]
chrome://flags/#extension-manifest-v2-deprecation-disabled        [Disabled]
chrome://flags/#extension-manifest-v2-deprecation-unsupported     [Disabled]
chrome://flags/#allow-legacy-mv2-extensions                       [Enabled]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;잘 이해가 안된다면 아래의 동영상 자료를 참고하면 된다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://www.reddit.com/r/uBlockOrigin/comments/1lx59m0/restoring_access_to_ubo_on_chrome_138_using_flags/&quot;&gt;https://www.reddit.com/r/uBlockOrigin/comments/1lx59m0/restoring_access_to_ubo_on_chrome_138_using_flags/&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;끝&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://www.reddit.com/r/uBlockOrigin/comments/1lwztf1/ublockorigin_fully_disabled_on_chrome_now/&quot;&gt;https://www.reddit.com/r/uBlockOrigin/comments/1lwztf1/ublockorigin_fully_disabled_on_chrome_now/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>IT &amp;frasl;  Computer/News</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/549</guid>
      <comments>https://shanepark.tistory.com/549#entry549comment</comments>
      <pubDate>Sat, 12 Jul 2025 22:17:44 +0900</pubDate>
    </item>
    <item>
      <title>Tomcat FileCountLimitExceededException 해결</title>
      <link>https://shanepark.tistory.com/548</link>
      <description>&lt;h2&gt;문제&lt;/h2&gt;
&lt;p&gt;새로운 서버에 애플리케이션을 배포하는 과정에서 예상하지 못한 오류가 발생했다. 로컬 머신과 개발 환경에선 전혀 문제가 없었고 지금까지 여러번 배포하며 같은 문제가 발생한 적이 없었는데 &lt;code&gt;FileCountLimitExceededException&lt;/code&gt; 라는 처음 보는 에러가 발생했다.&lt;/p&gt;
&lt;p&gt;POST 요청으로 multipart/form-data 를 사용해 데이터를 추가하는 엔드포인트였고, 이 폼은 몇개의 텍스트 필드와 파일 필드로 구성되어 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Failed to parse multipart servlet request; nested exception is org.apache.tomcat.util.http.fileupload.impl.FileCountLimitExceededException: attachment&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;전체 스택 트레이스는 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;adm  | AIP :07-01 09:39:25[o.s.b.w.s.s.ErrorPageFilter   ]ERROR- Forwarding to error page from request [/collection/] due to exception [Failed to parse multipart servlet request; nested exception is org.apache.tomcat.util.http.fileupload.impl.FileCountLimitExceededException: attachment]
adm  | org.springframework.web.multipart.MultipartException: Failed to parse multipart servlet request; nested exception is org.apache.tomcat.util.http.fileupload.impl.FileCountLimitExceededException: attachment
adm  |     at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.handleParseFailure(StandardMultipartHttpServletRequest.java:124)
adm  |     at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.parseRequest(StandardMultipartHttpServletRequest.java:115)
adm  |     at org.springframework.web.multipart.support.StandardMultipartHttpServletRequest.&amp;lt;init&amp;gt;(StandardMultipartHttpServletRequest.java:88)
adm  |     at org.springframework.web.multipart.support.StandardServletMultipartResolver.resolveMultipart(StandardServletMultipartResolver.java:122)
adm  |     at org.springframework.web.servlet.DispatcherServlet.checkMultipart(DispatcherServlet.java:1205)
adm  |     at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1039)
adm  |     at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963)
adm  |     at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
adm  |     at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)
adm  |     at javax.servlet.http.HttpServlet.service(HttpServlet.java:555)
adm  |     at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
adm  |     at javax.servlet.http.HttpServlet.service(HttpServlet.java:623)
... 이하생략&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;스택트레이스를 보면 파일을 너무 많이 첨부했다는 소리같이 보여 함정에 빠질 수 있다.&lt;/p&gt;
&lt;p&gt;파일 첨부가 가능한 form이긴 했지만, 이번 요청에 딱히 파일데이터를 포함하진 않았다. 에러가 발생한 상황의 Request payload는 아래와 같다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/todayError/Tomcat-FileCountLimitExceededException.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;관련 이슈를 검색해보니 검색 결과가 매우 드물다. 대 AI의 시대에서 모두가 LLM에 질문을 던지지만, 학습되지 않은 정보에 대해 GPT는 응답을 해줄 수 없다. 비교적 최근의 문제라는건데 이때는 새로 패치가 적용된 부분들을 파보아야 한다.&lt;/p&gt;
&lt;p&gt;Dockerfile을 확인하니 &lt;code&gt;FROM tomcat:9-jdk8&lt;/code&gt; 라고 기입되어 있다. 마이너 버전까지 기입되어있지 않기 때문에 새로 생성한 컨테이너의 톰캣 버전을 확인해본다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;catalina.sh version&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/todayError/Tomcat-FileCountLimitExceededException.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;p&gt; &lt;code&gt;Tomcat 9.0.106&lt;/code&gt; 라고 나온다. 내가 모르는 사이에 새로운 버전이 나왔고, 해당 버전이 깔린 이미지를 내려받은 것이었다. 이제 톰캣의 릴리즈 노트를 살펴본다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/todayError/Tomcat-FileCountLimitExceededException.assets/3.webp&quot; alt=&quot;3&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://tomcat.apache.org/tomcat-9.0-doc/changelog.html&quot;&gt;https://tomcat.apache.org/tomcat-9.0-doc/changelog.html&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;Coyote 쪽에 &lt;code&gt;maxPartCount&lt;/code&gt;라는 의심스러운 항목이 보인다. 이어 Tomcat 9.x 버전의 &lt;a href=&quot;https://tomcat.apache.org/security-9.html#Fixed_in_Apache_Tomcat_9.0.106&quot;&gt;취약점 패치 내용&lt;/a&gt;을 확인 해보면 2025년 6월 10일에 적용된 &lt;code&gt;9.0.106&lt;/code&gt; 패치에서 &lt;a href=&quot;https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2025-48988&quot;&gt;CVE-2025-48988&lt;/a&gt; 취약점으로 인해 최대 parts 수를 제한하는 &lt;code&gt;maxPartCount&lt;/code&gt; 라는 설정값이 추가되었고 기본값이 10으로 적용되었다고 나와있다.&lt;/p&gt;
&lt;p&gt;방금 에러가 터진 상황에서는 파일은 하나도 포함하지 않았지만 요청에 총 12개의 Part가 포함되어 있고 그로 인해 처음 화면과 같이&lt;code&gt;FileCountLimitExceededException&lt;/code&gt;이 발생한 것이다.&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;maxPartCount 를 늘려줘서 해결해주면 된다. 아래의 예시에서는 이전과 같이 무제한으로 설정하기 위해 -1 로 해두었지만, 이럴경우 기존처럼 DDOS 공격에 취약하다고 하니 적절한 수를 정해줘야 하겠다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-xml&quot;&gt;&amp;lt;Connector port=&amp;quot;8080&amp;quot; protocol=&amp;quot;HTTP/1.1&amp;quot;
           connectionTimeout=&amp;quot;20000&amp;quot;
           maxPartCount=&amp;quot;-1&amp;quot; 
           redirectPort=&amp;quot;8443&amp;quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spring Boot에는 &lt;code&gt;v3.4.7&lt;/code&gt; 부터 적용되었으며 &lt;code&gt;server.tomcat.max-part-count&lt;/code&gt; 설정을 통해 변경할 수 있다.&lt;/p&gt;
&lt;p&gt;이제 새로운 설정으로 도커 이미지를 다시 빌드하고 테스트 해보니 문제가 없었다.&lt;/p&gt;
&lt;h2&gt;마무리&lt;/h2&gt;
&lt;p&gt;톰캣에서 갑작스럽게 Part 수를 제한하고, 그 제한 수도 10개로 빡빡하게 둔건 예상하지 못했던 상황이라 당황스러웠다.&lt;/p&gt;
&lt;p&gt;보안상 어쩔 수 없는 판단이었고 나름 fail-fast 전략을 취해서 많은 사람들에게 알리려고 시도했다고 이해하기로 한다.&lt;/p&gt;
&lt;p&gt;끝&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://tomcat.apache.org/security-9.html#Fixed_in_Apache_Tomcat_9.0.106&quot;&gt;https://tomcat.apache.org/security-9.html#Fixed_in_Apache_Tomcat_9.0.106&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2025-48988&quot;&gt;https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2025-48988&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://stackoverflow.com/questions/79670639/how-to-configure-tomcat-max-file-count-size&quot;&gt;https://stackoverflow.com/questions/79670639/how-to-configure-tomcat-max-file-count-size&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-boot/issues/45881&quot;&gt;https://github.com/spring-projects/spring-boot/issues/45881&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/spring-projects/spring-boot/releases/tag/v3.4.7&quot;&gt;https://github.com/spring-projects/spring-boot/releases/tag/v3.4.7&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://joon2974.tistory.com/entry/Tomcat-FileCountLimitExceededException-%EC%9D%B4%EC%8A%88-%EC%88%98%EC%A0%95&quot;&gt;https://joon2974.tistory.com/entry/Tomcat-FileCountLimitExceededException-%EC%9D%B4%EC%8A%88-%EC%88%98%EC%A0%95&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Daily Error</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/548</guid>
      <comments>https://shanepark.tistory.com/548#entry548comment</comments>
      <pubDate>Tue, 1 Jul 2025 16:54:23 +0900</pubDate>
    </item>
    <item>
      <title>Jekyll 이 파일 변경을 감지하지 못할 때 해결 방법</title>
      <link>https://shanepark.tistory.com/547</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;Jekyll 개발 환경을 세팅할 때 정말 편한 점은, 파일을 수정하면 자동으로 변경사항을 감지해서 즉시 빌드해준다는 점이다. 거기에 &lt;code&gt;--livereload&lt;/code&gt; 옵션까지 준다면 브라우저를 새로고침 할 필요도 없다. &lt;/p&gt;
&lt;p&gt;그런데, 동료의 PC에 jekyll 개발환경을 세팅해주던 중 문제가 발생했다. 아무리 파일을 수정해도 즉시 반영이 안된다.&lt;/p&gt;
&lt;p&gt;내가 가진 리눅스 환경 및 맥북에서 모두 잘 작동하던 게 다른 동료의 맥북에서는 안 되었던 이유가 뭘까? 결론적으로는 &lt;code&gt;--force_polling&lt;/code&gt; 옵션으로 해결했다.&lt;br&gt;본 글에서는 왜 이게 필요한지, 또 어떤 환경에서 이런 문제가 발생하는지 정리해본다.&lt;/p&gt;
&lt;h2&gt;현상&lt;/h2&gt;
&lt;p&gt;Jekyll 서버는 잘 뜬다. 로그도 잘 찍힌다. 하지만 파일을 수정하고 저장해도 아무런 재 빌드 반응이 없다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bundle exec jekyll serve --livereload&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 실행한 상태에서 &lt;code&gt;index.html&lt;/code&gt;을 수정해도 &amp;quot;Regenerating&amp;quot; 로그가 안 뜨고, 브라우저에서도 바뀐 내용이 반영되지 않는다.&lt;/p&gt;
&lt;p&gt;서버를 종료하고 서버를 다시 띄우면 그제서야 변경사항이 반영되었지만 이런 상태로는 개발을 못한다.&lt;/p&gt;
&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;여러가지 시행착오가 있었지만 결국 찾아낸 원인은 동료의 PC에서 프로젝트를 설치한 경로가 외장 드라이브였기 때문이었다.&lt;/p&gt;
&lt;p&gt;macOS에서는 기본적으로 파일 변경을 감지하기 위해 FSEvents라는 이벤트 시스템을 사용하는데, 이 FSEvents는 MacOS 의 표준 파일 시스템에서만 잘 작동한다.&lt;/p&gt;
&lt;p&gt;즉, 외장 하드, 네트워크 드라이브, 클라우드 드라이브 등에서 작업하고 있다면 &lt;code&gt;listen&lt;/code&gt; gem이 내부적으로 감시를 하지 못하고, 결과적으로 아무 반응이 없는 것처럼 보이게 된다.&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;h3&gt;&lt;code&gt;--force_polling&lt;/code&gt; 옵션 사용&lt;/h3&gt;
&lt;p&gt;이 옵션을 쓰면 이벤트 기반 감시가 아니라, &lt;strong&gt;일정 간격으로 파일 변경을 직접 확인&lt;/strong&gt;하는 폴링 방식으로 감시하게 된다.&lt;br&gt;외장하드처럼 FSEvents가 지원되지 않는 환경에서도 제대로 작동한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;bundle exec jekyll serve --livereload --force_polling&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;macOS의 권한 설정 확인&lt;/h3&gt;
&lt;p&gt;그 외에도 터미널에 전체 디스크 접근 권한이 설정되어 있는지 확인해보자. &lt;/p&gt;
&lt;p&gt;&lt;code&gt;시스템 설정 &amp;gt; 개인정보 보호 및 보안 &amp;gt; 전체 디스크 접근&lt;/code&gt;에서 터미널이나 iTerm이 등록되어 있어야 한다.&lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;jekyll serve&lt;/code&gt;가 변경을 감지하지 못했다면 &lt;code&gt;--force_polling&lt;/code&gt; 옵션 하나로 간단히 해결할 수 있다.&lt;/p&gt;
&lt;p&gt;그래도 가능하다면 Jekyll 프로젝트처럼 파일 변경 감지가 필요할땐 기본 내장 디스크에 저장하자. &lt;/p&gt;
&lt;p&gt;폴링 방식은 리소스 낭비가 많을 뿐더러 저장 후 polling 주기 (기본 설정 1초) 동안 기다려야하는 단점이 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/guard/listen&quot;&gt;https://github.com/guard/listen&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Daily Error</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/547</guid>
      <comments>https://shanepark.tistory.com/547#entry547comment</comments>
      <pubDate>Fri, 20 Jun 2025 17:45:18 +0900</pubDate>
    </item>
    <item>
      <title>Apple Silicon Mac 에서 Ruby 설치 문제 해결</title>
      <link>https://shanepark.tistory.com/546</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;MacOS 에는 기본적으로 Ruby 가 설치되어 있다. 그런데 그걸 이용해서 바로 &lt;code&gt;gem install&lt;/code&gt; 등의 명령어를 사용하려 하면&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;You don&amp;#39;t have write permissions for the /Library/Ruby/Gems/2.6.0 directory.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이라는 에러를 맞이하게 되는데, Ruby 환경을 별도로 분리해서 설치해줘야 한다.&lt;/p&gt;
&lt;p&gt;그런데 Ruby 설치가 간단하게 되지는 않는다.&lt;/p&gt;
&lt;h2&gt;설치&lt;/h2&gt;
&lt;p&gt;일단 제일 먼저 rbenv 를 설치해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# rbenv 설치
brew install rbenv ruby-build

# 셀 초기화 파일 수정
echo &amp;#39;eval &amp;quot;$(rbenv init - zsh)&amp;quot;&amp;#39; &amp;gt;&amp;gt; ~/.zshrc
source ~/.zshrc&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기까지는  보통 아무 문제 없다.&lt;/p&gt;
&lt;p&gt;그런데 여기에서 원하는 Ruby 버전을 설치 할 때 문제가 발생하는데&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;rbenv install 3.2.2
rbenv global 3.2.2&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;에러&lt;/h2&gt;
&lt;p&gt;에러 전문은 아래와 같다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;==&amp;gt; Installing ruby-3.2.2...
ruby-build: using readline from homebrew
ruby-build: using libyaml from homebrew
ruby-build: using gmp from homebrew
-&amp;gt; ./configure &amp;quot;--prefix=$HOME/.rbenv/versions/3.2.2&amp;quot; --with-openssl-dir=/opt/homebrew/opt/openssl@3 --enable-shared --with-readline-dir=/opt/homebrew/opt/readline --with-libyaml-dir=/opt/homebrew/opt/libyaml --with-gmp-dir=/opt/homebrew/opt/gmp --with-ext=openssl,psych,+

BUILD FAILED (macOS 15.5 on arm64 using ruby-build 20250610)

You can inspect the build directory at /var/folders/31/yp1smy8j3l3gqywfgs3pr1kr0000gn/T/ruby-build.20250614083423.69006.xwyxfr
See the full build log at /var/folders/31/yp1smy8j3l3gqywfgs3pr1kr0000gn/T/ruby-build.20250614083423.69006.log
rbenv: version 3.2.2&amp;#39; not installed&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;여기에서 보면 macOS 버전을 명시하기 때문에 OS 버전과 아키처의 문제로 보이며 OS 업데이트를 제때 안한 사용자에들이 찔려하며 OS 업데이트를 하는 일이 일어날 수 있는데. 일단 명시된 빌드 에러 로그를 확인해봐야 한다.&lt;/p&gt;
&lt;p&gt;위에서는 &lt;code&gt;/var/folders/31/yp1smy8j3l3gqywfgs3pr1kr0000gn/T/ruby-build.20250614083423.69006.log&lt;/code&gt; 로그를 확인하라고 써 있는데 사람마다 다를 수 있으니 각자 콘솔에 뜬 로그를 확인해본다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;view /var/folders/31/yp1smy8j3l3gqywfgs3pr1kr0000gn/T/ruby-build.20250614083423.69006.log&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;로그를 확인해보니 나의 경우는 아래와 같았다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;checking whether make sets $(MAKE)... yes
checking for a BSD-compatible install... /opt/homebrew/bin/ginstall -c
checking for a race-free mkdir -p... /opt/homebrew/bin/gmkdir -p
checking for dtrace... dtrace
checking for dot... no
checking for doxygen... no
checking for pkg-config... pkg-config
checking whether it is Android... no
checking for cd using physical directory... cd -P
checking whether CFLAGS is valid... yes
checking whether LDFLAGS is valid... no
configure: error: something wrong with LDFLAGS=&amp;quot;-L/opt/homebrew/opt/node@14/lib&amp;quot;
external command failed with status 1&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;LDFLAGS 라는 환경변수가 node14 경로로 오염된걸로 보인다.&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;문제가 된 환경변수들을 &lt;code&gt;unset LDFLAGS&lt;/code&gt; 로 제거해서 재 시도 할 수 있지만 몇개의 환경변수가 오염된지 알 수 없으니 깨끗한 셀에서 시도해본다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;env -i HOME=$HOME PATH=/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin rbenv install 3.2.2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;잘 설치가 되었다면 이어서 해당 버전을 기본(global)으로 사용하도록 설정한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;rbenv global 3.2.2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;터미널을 새로 켜서 버전을 확인해준다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ruby --version
#ruby 3.2.2 (2023-03-30 revision e51014f9c0) [arm64-darwin24]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;해결이 되는걸 볼 수 있다.&lt;/p&gt;
&lt;p&gt;요즘엔 이러한 대부분의 문제는 검색하거나 LLM에 물어보면 손쉽게 해결되는데, 이상하게도 이 문제는 그렇지가 않았다. &lt;/p&gt;
&lt;p&gt;로그까지 파고 들어가서 에러 원인에 대한 분기를 시키지 않으면 문제 원인이 너무 다양한 모양이다.&lt;/p&gt;</description>
      <category>Development/Daily Error</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/546</guid>
      <comments>https://shanepark.tistory.com/546#entry546comment</comments>
      <pubDate>Sat, 14 Jun 2025 09:22:53 +0900</pubDate>
    </item>
    <item>
      <title>점심 식단표 알림 봇 개발 분투기</title>
      <link>https://shanepark.tistory.com/545</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;6개월간의 육아휴직을 마치고 복직하니 회사에 많은 것이 달라져 있었다.&lt;/p&gt;
&lt;p&gt;더 넓고 좋은 사무실로 이사했으며, 새로운 동료들도 여럿 입사하였다.&lt;/p&gt;
&lt;p&gt;그중에 또 중요한 변화가 있으니, 점심 도시락 업체가 바뀌었다는 점이다. 이전 업체보다 더 맛있어졌고 반찬의 종류도 풍부해졌는데 특이한 점은 창의적인 메뉴가 많다는 것 이었다.&lt;/p&gt;
&lt;p&gt;직원들은 식사 중에도 ‘이건 무슨 요리일까?’를 자주 묻고는 했지만 누구도 명확한 답을 내리지 못했다.&lt;/p&gt;
&lt;p&gt;호기심에 도시락 업체명으로 검색해 보니 식단표가 업로드되는 웹페이지가 있었고, 매일 도시락 메뉴를 확인하는 건 번거로우니 자동으로 점심 메뉴를 출근 시간에 맞춰 알림으로 보내면 좋겠다고 생각했다.&lt;/p&gt;
&lt;p&gt;본 글은 해당 서비스를 제작하면서 부딪쳤던 문제들과 해결방법을 기록하기 위해 작성했다.&lt;/p&gt;
&lt;h2&gt;구현&lt;/h2&gt;
&lt;p&gt;단순하게 메뉴를 바로 볼 수 있는 링크를 올려둔다거나, 메뉴 사진을 통째로 보여주는 쉬운 구현도 생각해 보았지만, 정형데이터로 가공하는 쪽이 나중에 데이터를 활용해 여러 가지 기능을 추가하기에도 유리하다고 생각해서 &lt;code&gt;메뉴 이미지 크롤링 -&amp;gt; OCR -&amp;gt; 데이터 저장 -&amp;gt; 슬랙 발송&lt;/code&gt; 플로우로 진행하기로 정했다.&lt;/p&gt;
&lt;h3&gt;크롤링&lt;/h3&gt;
&lt;p&gt;다행히도 메뉴 이미지를 크롤링 해오는 건 어렵지 않았는데&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;메뉴가 올라오는 URL이 변하지 않음&lt;/li&gt;
&lt;li&gt;항상 같은 element의 두번째 이미지로 메뉴가 업로드 됨. (첫 번째 이미지는 매장 소개)&lt;/li&gt;
&lt;li&gt;크롤링을 금지하거나 방해하지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;위의 조건을 만족하기 때문에 필요할 때 및 일정 주기로 이미지를 요청하여 얻어올 수 있었다.&lt;/p&gt;
&lt;h3&gt;OCR&lt;/h3&gt;
&lt;p&gt;사실상 이 프로젝트의 성패를 정하게 될 핵심 포인트인데 단순하게 이미지에 있는 텍스트를 OCR 하는 것 뿐만 아니라 적절한 범위로 구역을 나누는 것도 만만치 않게 중요하다.&lt;/p&gt;
&lt;p&gt;대표적인 오픈소스 OCR 소프트웨어는 &lt;a href=&quot;https://github.com/tesseract-ocr/tesseract&quot;&gt;Tesseract&lt;/a&gt;, &lt;a href=&quot;https://github.com/JaidedAI/EasyOCR&quot;&gt;EascyOcr&lt;/a&gt;, &lt;a href=&quot;https://github.com/PaddlePaddle/PaddleOCR&quot;&gt;PaddleOCR&lt;/a&gt; 등이 있는데, &lt;/p&gt;
&lt;p&gt;오수은님의 &lt;a href=&quot;https://devocean.sk.com/blog/techBoardDetail.do?ID=165524&amp;amp;boardType=techBlog#none&quot;&gt;[OCR/AI] 2023년 최신판 OCR 8가지 API 비교평가 테스트&lt;/a&gt; 글을 참고해서 Tesseract를 먼저 테스트 해 보기로 정했다.&lt;/p&gt;
&lt;p&gt;한글 모델을 다운받아야 하는데, 여러 가지 모델을 비교해 본 결과 적어도 도시락 메뉴 이미지에서는 &lt;a href=&quot;https://github.com/tesseract-ocr/tessdata_best&quot;&gt;https://github.com/tesseract-ocr/tessdata_best&lt;/a&gt; 에 있는 모델의 한국어 인식률이 가장 좋았다.&lt;/p&gt;
&lt;p&gt;다만 tesseract를 별도로 설치해야 하는 번거로움이 있었는데 Dockerfile 에 아래의 내용을 추가하고 서버를 도커 컨테이너에서 돌리는 방식으로 해결했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-dockerfile&quot;&gt;# Install tesseract-ocr and necessary packages
RUN apt-get update &amp;amp;&amp;amp; apt-get install -y \
    tesseract-ocr \
    wget \
    &amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*

# Download the Korean trained data for Tesseract
RUN wget -O /usr/share/tesseract-ocr/5/tessdata/kor.traineddata \
    https://github.com/tesseract-ocr/tessdata_best/raw/refs/heads/main/kor.traineddata&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Apple Silicon Mac에서 JNA가 Tesseract를 찾지 못하는 문제도 있었는데&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;pre&gt;&lt;code&gt;UnsatisfiedLinkError ... libtesseract.dylib ...&lt;/code&gt;&lt;/pre&gt;&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이건 &lt;code&gt;jna.library.path&lt;/code&gt;에 경로를 등록 해주는 걸로 해결이 가능했다. 아래의 두 가지 방법 중 하나를 사용하면 되는데&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;System.setProperty(&amp;quot;jna.library.path&amp;quot;, &amp;quot;/opt/homebrew/opt/tesseract/lib&amp;quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-groovy&quot;&gt;# build.gradle에 추가
test {
    systemProperty &amp;quot;jna.library.path&amp;quot;, &amp;quot;/opt/homebrew/opt/tesseract/lib&amp;quot;
}

bootRun {
    systemProperty &amp;quot;jna.library.path&amp;quot;, &amp;quot;/opt/homebrew/opt/tesseract/lib&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이건 런타임에서 OS를 및 아키텍처를 확인하고 필요할 때만 등록하게 하기 위해, 코드로 아래와 같이 처리했다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;String os = System.getProperty(&amp;quot;os.name&amp;quot;);
log.info(&amp;quot;OS: {}, arch: {}&amp;quot;, os, arch);

if (os.contains(&amp;quot;Mac&amp;quot;) &amp;amp;&amp;amp; arch.contains(&amp;quot;aarch64&amp;quot;)) {
  System.setProperty(&amp;quot;jna.library.path&amp;quot;, &amp;quot;/opt/homebrew/opt/tesseract/lib&amp;quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;특이하게도 Tesseract는 이미지의 전처리가 정확도에 있어 굉장히 중요했는데,  전체 이미지를 한번에 OCR하는 것 보다, 여러 개의 이미지로 쪼개어 별도로 요청하는 게 더 좋은 결과를 반환했고&lt;/p&gt;
&lt;p&gt;단순한 이미지 확대만으로도 더 나은 인식률을 얻어낼 수 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private BufferedImage preprocessImage(BufferedImage image) {
    try (OpenCVFrameConverter.ToMat converterToMat = new OpenCVFrameConverter.ToMat();
         Java2DFrameConverter converterToFrame = new Java2DFrameConverter()) {
        Mat mat = converterToMat.convert(converterToFrame.convert(image));
        Mat resizedMat = new Mat();
        opencv_imgproc.resize(mat, resizedMat, new Size(mat.cols() * 2, mat.rows() * 2));
        return converterToFrame.convert(converterToMat.convert(resizedMat));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;또한 블랙리스트, 화이트리스트를 추가하여 인식에 도움을 줄 수도 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private String readImagePartHeader(BufferedImage image, Tesseract tesseract, ParseRegion region) throws TesseractException {
    tesseract.setVariable(&amp;quot;tessedit_char_whitelist&amp;quot;, &amp;quot;0123456789년월화수목금/()&amp;quot;);
    tesseract.setVariable(&amp;quot;tessedit_char_blacklist&amp;quot;, &amp;quot;&amp;quot;);
    return readImagePart(image, tesseract, region);
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;날짜와 요일만 나오는 헤더를 인식할 때는 whitelist를 이용해 정확히 검출해낼 수 있다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;여러모로 커스터마이징의 요소가 많기 때문에 전체적으로 이미지를 흑백처리 해본다거나 컬러셋을 조정해본다거나 하는식으로 여러가지 테스트가 꼭 필요하다.&lt;/p&gt;
&lt;h3&gt;구역 나누기&lt;/h3&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/10.webp&quot; alt=&quot;10&quot;&gt;&lt;/p&gt;
&lt;p&gt; 정형데이터로 변환하기 위해 OCR에 앞서 구역을 나누어 내는게 필수인데, 이게 쉽지 않다.&lt;/p&gt;
&lt;p&gt;메뉴는 위의 그림과 같았는데, 매번 업로드 될 때 마다 형식은 비슷한데 왼편의 일반, 샐러드 글씨가 있을때도 없을때도 있었고 각 구역의 좌표 및 크기도 미묘하게 달라져서 일관된 방법을 쓸 수 없었다.&lt;/p&gt;
&lt;p&gt;그래서 좌상단부터 이미지의 픽셀을 잃으면서 백그라운드가 아닌 좌표를 찾아내고&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private Point findLeftTop(BufferedImage image, int width, int height) {
    for (int y = 0; y &amp;lt; height; y++) {
        for (int x = 0; x &amp;lt; width; x++) {
            if (isNotBackgroundColor(image.getRGB(x, y))) {
                return new Point(x, y);
            }
        }
    }
    throw new RuntimeException(&amp;quot;Cannot find left top point&amp;quot;);
}

private boolean isBackgroundColor(int rgb) {
    int red = (rgb &amp;gt;&amp;gt; 16) &amp;amp; 0xFF;
    int green = (rgb &amp;gt;&amp;gt; 8) &amp;amp; 0xFF;
    int blue = rgb &amp;amp; 0xFF;
    return red == 255 &amp;amp;&amp;amp; green == 255 &amp;amp;&amp;amp; blue == 255;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;그걸 토대로 각 표의 폭을 계산해낸 뒤&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;다행히도 항상 폭은 모든 10개의 일자가 동일했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private int calcSingleWidth(BufferedImage image, Point leftTop, int width) {
    for (int x = leftTop.x; x &amp;lt; width; x++) {
        if (isBackgroundColor(image.getRGB(x, leftTop.y))) {
            return x - leftTop.x;
        }
    }
    throw new RuntimeException(&amp;quot;Cannot find single width&amp;quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;비슷한 방법으로 header(날짜)의 높이, 메뉴칸의 높이 및 각 칸의 좌표들을 모두 계산해낸다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private ImageMarginData calcMargin(BufferedImage image) {
    int width = image.getWidth();
    int height = image.getHeight();
    Point leftTop = findLeftTop(image, width, height);
    int marginTop = leftTop.y;
    int marginLeft = leftTop.x;
    int singleWidth = calcSingleWidth(image, leftTop, width);
    int gapSmall = calcGapSmall(image, leftTop, singleWidth, width);
    int headerHeight = calcHeaderHeight(image, leftTop, height);
    int singleHeight = calcSingleHeight(image, leftTop, headerHeight, height);
    int gapBig = calcGapBig(image, height, leftTop);
    return new ImageMarginData(
            marginLeft,
            marginTop,
            singleWidth,
            singleHeight,
            headerHeight,
            gapSmall,
            gapBig
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 그걸 토대로 날짜 구역과 메뉴 구역을 포함한 총 DayRegion을 계산한다. 이렇게 계산한 구역대로 이미지를 잘라내고 전처리해서 Tesseract로 보내 문자열을 추출하면 끝이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;    final int DAY_PER_ROW = 5;
    public List&amp;lt;DayRegion&amp;gt; calcParseRegions(BufferedImage image) {
        ImageMarginData marginData = calcMargin(image);
        List&amp;lt;ParseRegion&amp;gt; dateRegions = new ArrayList&amp;lt;&amp;gt;();
        List&amp;lt;ParseRegion&amp;gt; menuRegions = new ArrayList&amp;lt;&amp;gt;();

        // first row
        int x = marginData.marginLeft();
        int y = marginData.marginTop();
        ParseRegion dateRegion = new ParseRegion(x, y, marginData.singleWidth(), marginData.headerHeight());

        for (int i = 0; i &amp;lt; DAY_PER_ROW; i++) {
            dateRegions.add(dateRegion);
            dateRegion = dateRegion.addX(marginData.singleWidth() + marginData.gapSmall());
        }

        ParseRegion menuRegion = new ParseRegion(x, y + marginData.headerHeight() + marginData.gapSmall(), marginData.singleWidth(), marginData.singleHeight());

        for (int i = 0; i &amp;lt; DAY_PER_ROW; i++) {
            menuRegions.add(menuRegion);
            menuRegion = menuRegion.addX(marginData.singleWidth() + marginData.gapSmall());
        }

        // Last row
        y += marginData.headerHeight() + marginData.gapSmall() + marginData.singleHeight() + marginData.gapBig();
        dateRegion = new ParseRegion(x, y, marginData.singleWidth(), marginData.headerHeight());

        for (int i = 0; i &amp;lt; DAY_PER_ROW; i++) {
            dateRegions.add(dateRegion);
            dateRegion = dateRegion.addX(marginData.singleWidth() + marginData.gapSmall());
        }

        y += marginData.headerHeight() + marginData.gapSmall();
        menuRegion = new ParseRegion(x, y, marginData.singleWidth(), marginData.singleHeight());

        for (int i = 0; i &amp;lt; DAY_PER_ROW; i++) {
            menuRegions.add(menuRegion);
            menuRegion = menuRegion.addX(marginData.singleWidth() + marginData.gapSmall());
        }

        List&amp;lt;DayRegion&amp;gt; list = new ArrayList&amp;lt;&amp;gt;();
        int totalDays = dateRegions.size();
        for (int i = 0; i &amp;lt; totalDays; i++) {
            list.add(new DayRegion(dateRegions.get(i), menuRegions.get(i)));
        }
        return list;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 만든 프로토타입으로 한명의 직원만 슬랙 채널에 초대해 몇주동안 상황을 지켜 봤고, 정상적으로 작동한다고 판단 될 때마다 한명씩 초대 인원을 늘렸다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/11.webp&quot; alt=&quot;11.webp&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;찹쌀밥 -&amp;gt; 참쌀밥&lt;/p&gt;
&lt;p&gt;양념 깻잎 무침 -&amp;gt; 양념 잎 무침&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;OCR에서 약간의 부족함이 있었지만 기대했던 것 보다는 괜찮은 정확도를 보였고, 꼼꼼히 읽는게 아니라면 오타가 있는걸 모르기도 하고 전체적인 메뉴 파악에는 아무런 문제가 없었다.&lt;/p&gt;
&lt;h2&gt;문제해결&lt;/h2&gt;
&lt;h3&gt;Naver OCR&lt;/h3&gt;
&lt;p&gt;몇주간 운영을 하며 지켜봤는데, 꾸준히 오타가 나는게 영 거슬렸다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;깍두기&lt;/code&gt;는 &lt;code&gt;짝두기&lt;/code&gt;로, &lt;code&gt;깻잎&lt;/code&gt;은 &lt;code&gt;쨋잎&lt;/code&gt;으로 해석하는데 항상 그런다. 어떤 메뉴인지 알아 볼 수는 있다지만..&lt;/p&gt;
&lt;p&gt; 직원들을 대상으로 도시락 메뉴를 보낼건데 명색에 프로그래머가 눈에 보이는 명백한 결함을 방치한다는게 영 자존심이 허락하지 않는다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;처음에는 tesstrain으로 훈련시켜서 여러가지 자주 오류가 나는 텍스트들을 해결해볼까 했는데 훈련데이터를 준비해서 훈련시키는게 만만치가 않은 작업이다. 이걸 이렇게까지 해야하나 싶어 탈락.&lt;/li&gt;
&lt;li&gt;PaddleOCR 등의 다른 오픈소스 OCR도 테스트 해 보았으나 Tesseract 보다 인식률이 떨어짐.&lt;/li&gt;
&lt;li&gt;LLM을 활용해서 오타로 판단되는 텍스트들은 알아서 수정하게끔 맡겨보았는데, &amp;quot;단짠 감자 조림&amp;quot; 같은 희한하지만 제대로 된 메뉴명들을 자기 멋대로 바꿔버려서 탈락.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;그러다 문득 &lt;a href=&quot;https://deview.kr/2023/&quot;&gt;Naver Deview 2023&lt;/a&gt; 에 참석했을 때 들었던 세션 중 하나였던 네이버 OCR이 떠올랐다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/3.webp&quot; alt=&quot;3&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://deview.kr/2023/sessions/560&quot;&gt;https://deview.kr/2023/sessions/560&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://tv.naver.com/v/33862691&quot;&gt;https://tv.naver.com/v/33862691&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;국제 대회에서 우승까지 했다고 들었는데 그 성능좀 보자~  반신반의 하며 이미지들을 넣어 보았는데 모든 텍스트를 완벽하게 추출해냈다. 외부 연동 없이 최대한 심플하게 만들어내고 싶었지만 이정도 정확도를 그 누가 외면할 수 있을까? 바로 금액부터 확인해본다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/4.webp&quot; alt=&quot;4&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://www.ncloud.com/product/aiService/ocr#pricing&quot;&gt;https://www.ncloud.com/product/aiService/ocr#pricing&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이 얼마나 아름다운 요금인가. 100회까지는 무료에, 100회를 넘긴다고 해도 건당 3원만 내면 된다.&lt;/p&gt;
&lt;p&gt;2주 단위로 메뉴가 나오기때문에 한달에 최소 2번만 OCR을 요청하면 된다. 구역별로 이미지를 나누어 하루씩 구역을 잘라서 보낸다고 해도 월 22건 정도면 충분하다.&lt;/p&gt;
&lt;p&gt;혹시 테스트 과정에서 100건을 넘길 수 있으니, 미리 응답결과를 파일로 저장해놓고 테스트 과정에선 실제 API 요청 대신 그 파일을 대신 읽도록 하면 된다.&lt;/p&gt;
&lt;p&gt;참고로 OCR 요청은 POST 요청을 하면 되는데 꽤 복잡하다. RestTemplate 기준으로 아래와 같이 요청하면 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Component
@RequiredArgsConstructor
public class NaverClovaApi {
    private final NaverClovaConfig naverClovaConfig;
    private final RestTemplate restTemplate = new RestTemplateBuilder()
            .setConnectTimeout(Duration.ofSeconds(5))
            .setReadTimeout(Duration.ofSeconds(10))
            .build();

    public String clovaRequest(String base64Image) {
        String requestBody = createRequestBody(base64Image);
        HttpHeaders headers = createHeaders();
        HttpEntity&amp;lt;String&amp;gt; request = new HttpEntity&amp;lt;&amp;gt;(requestBody, headers);
        ResponseEntity&amp;lt;String&amp;gt; response = restTemplate.exchange(naverClovaConfig.getUrl(), POST, request, String.class);
        return response.getBody();
    }

    private static String createRequestBody(String base64Image) {
        JsonObject bodyJson = new JsonObject();
        bodyJson.add(&amp;quot;images&amp;quot;, createImageArray(base64Image));
        bodyJson.addProperty(&amp;quot;lang&amp;quot;, &amp;quot;ko&amp;quot;);
        bodyJson.addProperty(&amp;quot;requestId&amp;quot;, &amp;quot;string&amp;quot;);
        bodyJson.addProperty(&amp;quot;resultType&amp;quot;, &amp;quot;string&amp;quot;);
        bodyJson.addProperty(&amp;quot;timestamp&amp;quot;, Instant.now().toEpochMilli());
        bodyJson.addProperty(&amp;quot;version&amp;quot;, &amp;quot;V1&amp;quot;);
        return bodyJson.toString();
    }

    private static JsonArray createImageArray(String base64Image) {
        JsonArray images = new JsonArray();
        JsonObject image = new JsonObject();
        image.addProperty(&amp;quot;format&amp;quot;, &amp;quot;png&amp;quot;);
        image.addProperty(&amp;quot;name&amp;quot;, &amp;quot;menu&amp;quot;);
        image.addProperty(&amp;quot;data&amp;quot;, base64Image);
        images.add(image);
        return images;
    }

    private HttpHeaders createHeaders() {
        HttpHeaders headers = new HttpHeaders();
        headers.set(&amp;quot;X-OCR-SECRET&amp;quot;, naverClovaConfig.getSecretKey());
        headers.set(&amp;quot;Content-Type&amp;quot;, &amp;quot;application/json&amp;quot;);
        return headers;
    }

}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Clova OCR의 API 응답은 아래와 같은 방식으로 왔는데&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
          &amp;quot;valueType&amp;quot;: &amp;quot;ALL&amp;quot;,
          &amp;quot;boundingPoly&amp;quot;: {
            &amp;quot;vertices&amp;quot;: [
              {
                &amp;quot;x&amp;quot;: 530.0,
                &amp;quot;y&amp;quot;: 1060.0
              },
              {
                &amp;quot;x&amp;quot;: 579.0,
                &amp;quot;y&amp;quot;: 1060.0
              },
              {
                &amp;quot;x&amp;quot;: 579.0,
                &amp;quot;y&amp;quot;: 1081.0
              },
              {
                &amp;quot;x&amp;quot;: 530.0,
                &amp;quot;y&amp;quot;: 1081.0
              }
            ]
          },
          &amp;quot;inferText&amp;quot;: &amp;quot;단호박&amp;quot;,
          &amp;quot;inferConfidence&amp;quot;: 1.0
        },
        {
          &amp;quot;valueType&amp;quot;: &amp;quot;ALL&amp;quot;,
          &amp;quot;boundingPoly&amp;quot;: {
            &amp;quot;vertices&amp;quot;: [
              {
                &amp;quot;x&amp;quot;: 575.0,
                &amp;quot;y&amp;quot;: 1060.0
              },
              {
                &amp;quot;x&amp;quot;: 665.0,
                &amp;quot;y&amp;quot;: 1060.0
              },
              {
                &amp;quot;x&amp;quot;: 665.0,
                &amp;quot;y&amp;quot;: 1080.0
              },
              {
                &amp;quot;x&amp;quot;: 575.0,
                &amp;quot;y&amp;quot;: 1080.0
              }
            ]
          },
          &amp;quot;inferText&amp;quot;: &amp;quot;오리훈제볶음&amp;quot;,
          &amp;quot;inferConfidence&amp;quot;: 0.9997
        },
        {
          &amp;quot;valueType&amp;quot;: &amp;quot;ALL&amp;quot;,
          &amp;quot;boundingPoly&amp;quot;: {
            &amp;quot;vertices&amp;quot;: [
              {
                &amp;quot;x&amp;quot;: 685.0,
                &amp;quot;y&amp;quot;: 1071.0
              },
              {
                &amp;quot;x&amp;quot;: 719.0,
                &amp;quot;y&amp;quot;: 1071.0
              },
              {
                &amp;quot;x&amp;quot;: 719.0,
                &amp;quot;y&amp;quot;: 1091.0
              },
              {
                &amp;quot;x&amp;quot;: 685.0,
                &amp;quot;y&amp;quot;: 1091.0
              }
            ]
          },
          &amp;quot;inferText&amp;quot;: &amp;quot;철판&amp;quot;,
          &amp;quot;inferConfidence&amp;quot;: 0.9998
        },
        {
          &amp;quot;valueType&amp;quot;: &amp;quot;ALL&amp;quot;,
          &amp;quot;boundingPoly&amp;quot;: {
            &amp;quot;vertices&amp;quot;: [
              {
                &amp;quot;x&amp;quot;: 718.0,
                &amp;quot;y&amp;quot;: 1071.0
              },
              {
                &amp;quot;x&amp;quot;: 753.0,
                &amp;quot;y&amp;quot;: 1071.0
              },
              {
                &amp;quot;x&amp;quot;: 753.0,
                &amp;quot;y&amp;quot;: 1091.0
              },
              {
                &amp;quot;x&amp;quot;: 718.0,
                &amp;quot;y&amp;quot;: 1091.0
              }
            ]
          },
          &amp;quot;inferText&amp;quot;: &amp;quot;사각&amp;quot;,
          &amp;quot;inferConfidence&amp;quot;: 1.0
        },
        {
          &amp;quot;valueType&amp;quot;: &amp;quot;ALL&amp;quot;,
          &amp;quot;boundingPoly&amp;quot;: {
            &amp;quot;vertices&amp;quot;: [
              {
                &amp;quot;x&amp;quot;: 750.0,
                &amp;quot;y&amp;quot;: 1071.0
              },
              {
                &amp;quot;x&amp;quot;: 799.0,
                &amp;quot;y&amp;quot;: 1071.0
              },
              {
                &amp;quot;x&amp;quot;: 799.0,
                &amp;quot;y&amp;quot;: 1091.0
              },
              {
                &amp;quot;x&amp;quot;: 750.0,
                &amp;quot;y&amp;quot;: 1091.0
              }
            ]
          },
          &amp;quot;inferText&amp;quot;: &amp;quot;군만두&amp;quot;,
          &amp;quot;inferConfidence&amp;quot;: 1.0
        },&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;inferConfidence가 아주 낮은 텍스트는 엉뚱한 이미지가 들어갔을 때가 많기 때문에 무시하도록 처리하였다.&lt;/p&gt;
&lt;p&gt;또한 텍스트별로 x, y 좌표가 함께 제공되는데 먼저 작성한 코드에서 구역 나누기로 저장해 둔 날짜 및 메뉴 구역들이 있기 때문에 &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;package shanepark.foodbox.image.domain;

public record ParseRegion(int x, int y, int width, int height) {

    public ParseRegion addX(int i) {
        return new ParseRegion(this.x + i, y, width, height);
    }

    public boolean contains(int x, int y) {
        return this.x &amp;lt;= x &amp;amp;&amp;amp; x &amp;lt;= this.x + width &amp;amp;&amp;amp; this.y &amp;lt;= y &amp;amp;&amp;amp; y &amp;lt;= this.y + height;
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;위와 같이 구역에 속하는지를 검사해가며 파싱한 텍스트를 필요한 부분별로 구분해낼 수 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;    private static String buildMenuString(List&amp;lt;InferTextField&amp;gt; inferTextFields) {
        int lastY = -1;
        StringBuilder menuBuilder = new StringBuilder();
        for (InferTextField inferTextField : inferTextFields) {
            if (Math.abs(inferTextField.y - lastY) &amp;gt; 10) {
                if (!menuBuilder.isEmpty()) {
                    menuBuilder.append(&amp;quot;\n&amp;quot;);
                }
            } else {
                menuBuilder.append(&amp;quot; &amp;quot;);
            }

            menuBuilder.append(inferTextField.inferText);
            lastY = inferTextField.y;
        }
        return menuBuilder.toString();
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;잘 나누어낸 텍스트들은 메뉴 스트링을 다시 구성해 낼 때에, y 좌표로 같은 줄의 텍스트인지를 확인해가며, 띄어쓰기와 함께 이전 단어에 붙이거나 다음줄에서 새로운 메뉴명을 구성해내기 시작한다.&lt;/p&gt;
&lt;p&gt;OCR 벤더가 바뀌고 OCR 결과의 응답 포맷이 크게 달라진다고 해도 적절히 기능별로 코드 설계를 해두면 얼마든지 전에 작성했던 코드도 변경 없이 활용할 수 있다.&lt;/p&gt;
&lt;p&gt;Tesseract 에서 CLOVA OCR로 전환한 이후에는 인식률과 인식 속도가 매우 좋아졌다. 무엇보다 좋은건 램1GB의 저사양 무료 클라우드 컴퓨팅 인스턴스를 사용하고 있었는데 Tesseract 와 javaCV 를 걷어내니 빌드도 빨라지고 war 파일의 크기도 700MB 에서 20MB 로 줄어들었다. &lt;/p&gt;
&lt;p&gt;성능 때문에 포기했던 CI/CD도 이제 가능해보인다.&lt;/p&gt;
&lt;h3&gt;구역나누기2&lt;/h3&gt;
&lt;p&gt;그렇게 한창을 잘 운영 하다가 몇 가지 문제가 발생했다. &lt;/p&gt;
&lt;p&gt;&lt;strong&gt;첫 번째 문제&lt;/strong&gt;: 네이버 modoo 중단 공지&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://www.modoo.at/home/notice/noticeDetail?boardId=169&amp;amp;pageNum=1&quot;&gt;https://www.modoo.at/home/notice/noticeDetail?boardId=169&amp;amp;pageNum=1&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;메뉴가 올라오던 웹사이트를 modoo에서 호스팅 하고 있었기 때문에 메뉴가 더이상 업로드 되지 않을 수 있다. 그럼에도 첫 번째 문제는 해결할 필요도 없어진건 더 심각한 두번째 문제 때문이었는데&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;두 번째 문제&lt;/strong&gt;: 회사에 배달오는 메뉴가 메뉴표에 올라온 메뉴와 다르게 제공되기 시작했다. &lt;/p&gt;
&lt;p&gt;반년 이상  메뉴에 적혀 있는 메뉴가 정확히 배달왔었는데 갑자기 일주일 이상 메뉴가 계속 다른거다.&lt;/p&gt;
&lt;p&gt;도시락 제공 업체에 문의한 결과 본사와 다르게 자체적으로 메뉴를 제공하기 시작했으며 메뉴는 카카오톡 프로필 사진으로 보여주니 카톡 친구추가를 해서 메뉴를 확인하라는거다. &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/5.webp&quot; alt=&quot;5&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://developers.kakao.com/docs/latest/ko/kakaotalk-social/rest-api&quot;&gt;https://developers.kakao.com/docs/latest/ko/kakaotalk-social/rest-api&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;카카오톡 소셜의 친구 목록 가져오기 REST API를 테스트해보니 프로필 사진은 원본으로 제공하지 않고 썸네일로만 제공한다. 카카오 친구의 프로필 사진은 원본으로 저장이 안되며 스크린샷을 찍는다면 OCR 인식률이 크게 떨어진다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/6.webp&quot; alt=&quot;6&quot;&gt;&lt;/p&gt;
&lt;p&gt;다행히도 프로필 썸네일 이미지 url에서 &lt;code&gt;_110x110_c&lt;/code&gt; 라는 suffix를 제거하고 요청을 보내보니 썸네일 원본이 받아지긴 했다.&lt;/p&gt;
&lt;p&gt;그럼에도 카카오톡 소셜 API에는 큰 함정이 있었으니, &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/7.webp&quot; alt=&quot;7&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://developers.kakao.com/docs/latest/ko/kakaotalk-social/rest-api#get-friends&quot;&gt;https://developers.kakao.com/docs/latest/ko/kakaotalk-social/rest-api#get-friends&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;그냥 친구추가만 되어있으면 끝나는게 아니고 나와 상대방이 모두 플랫폼(Kakao developers에 새로 등록한 어플리케이션)에 등록이 되어 있으며 &lt;code&gt;카카오 서비스 내 친구목록(프로필사진, 닉네임, 즐겨찾기 포함)&lt;/code&gt; 에도 동의가 필요하다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/8.webp&quot; alt=&quot;8&quot;&gt;&lt;/p&gt;
&lt;p&gt;심지어 해당 동의 항목은 API 검수를 받아야 사용가능한데 업체에서 뭔지도 모르는 서비스에 가입 및 동의해줄리가 만무하다.&lt;/p&gt;
&lt;p&gt;카카오톡은 포기하고 다른 방법을 찾아보려는데, 메뉴에 등록된 QR코드를 찍어보니 네이버 QR 코드를 통해 생성한 QR 코드 였고, 그래서 &lt;code&gt;m.site.naver&lt;/code&gt; 링크가 자동으로 생성되어있어 그곳에서 식단표를 확인할 수 있었다.&lt;/p&gt;
&lt;p&gt;이제 새로운 양식의 식단표를 대상으로 구역 나누기 및 OCR을 새로 해야한다. 자연스레 기존의 코드는 폐기하고 새로 작성하면 되는데, 여기에서 세번째 문제가 발생했다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;세번째 문제&lt;/strong&gt;: 픽셀 컬러값으로 구역 나누기 실패&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/9.webp&quot; alt=&quot;9&quot;&gt;&lt;/p&gt;
&lt;p&gt;기존의 png 파일과 다르게 이번에는 jpg로 제공되는데 픽셀별 컬러를 따내서 영역을 나누는 전략이 더이상 먹히지 않는다. 확대해서 확인해보니 픽셀별 컬러가 제멋대로다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devlife/foodbox.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;전체 이미지&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이제부터는 구역을 나눌 때 새로운 전략으로 접근해야 한다.&lt;/p&gt;
&lt;p&gt;기존에는 이미지의 픽셀 컬러만을 이용해서 구역을 나누어 냈다면, 이제는 OCR 결과를 포함해서 활용하기로 했다. &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private final Pattern DATE_PATTERN = Pattern.compile(&amp;quot;\\d{1,2}일&amp;quot;);
    private final Pattern DAY_PATTERN = Pattern.compile(&amp;quot;MON|TUE|WED|THU|FRI&amp;quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;요일 및 일자 패턴들을 지정해놓고 &lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;    public GridParser(JsonArray fields) {
        for (JsonElement e : fields) {
            JsonObject field = e.getAsJsonObject();
            String inferText = field.get(&amp;quot;inferText&amp;quot;).getAsString();
            if (DAY_PATTERN.matcher(inferText).find()) {
                processDayPattern(field);
                continue;
            }
            if (DATE_PATTERN.matcher(inferText).find()) {
                processDate(field);
            }
        }
        if (mondayVertices == null || tuesdayVertices == null) {
            throw new IllegalStateException(&amp;quot;월요일 또는 화요일 패턴이 발견되지 않았습니다.&amp;quot;);
        }
        int midOfMonday = getMidX(mondayVertices);
        int midOfTuesday = getMidX(tuesdayVertices);
        width = midOfTuesday - midOfMonday - (GAP / 2);
        startX = midOfMonday - (width / 2) - GAP;
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;각 패턴에 따라 필요한 process 를 해준다. &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;요일 아래 랜덤하게 적히는 &lt;code&gt;서비스&lt;/code&gt;로 인해 텍스트가 올라가는 경우가 있기 때문에 월~금 까지 모두 확인하여 가장 높은 y 값을 찾아낸다.&lt;/li&gt;
&lt;li&gt;월요일 및 화요일 패턴의 좌표로 저장해두는건 메뉴의 폭을 계산하기 위함이다. 각 요일의 텍스트는 가운데 정렬로 되어있기 때문에 월요일, 화요일의 각 중간 값 간의 거리가 거의 정확한 각 셀의 폭이 된다. &lt;code&gt;width = midOfTuesday - midOfMonday - (GAP / 2);&lt;/code&gt; 이렇게 가운데 테두리 값만 빼주면 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;    public void processDayPattern(JsonObject field) {
        JsonArray vertices = getVertices(field);
        JsonObject leftTop = vertices.get(0).getAsJsonObject();
        String inferText = field.get(&amp;quot;inferText&amp;quot;).getAsString();
        if (inferText.startsWith(&amp;quot;월&amp;quot;) &amp;amp;&amp;amp; mondayVertices == null) {
            mondayVertices = vertices;
        }
        if (inferText.startsWith(&amp;quot;화&amp;quot;) &amp;amp;&amp;amp; tuesdayVertices == null) {
            tuesdayVertices = vertices;
        }

        int y = leftTop.get(&amp;quot;y&amp;quot;).getAsInt();
        if (menu1DateStart == -1) {
            menu1DateStart = y;
            return;
        }
        if (Math.abs(y - menu1DateStart) &amp;lt; 100) {
            menu1DateStart = Math.min(menu1DateStart, y);
            return;
        }
        menu2DateStart = Math.min(menu2DateStart, y);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;y 값이 크게 변했을때(100 이상으로 조건을 걸었다) 는 그 다음 주로 넘어갔다는 뜻이다. 새로운 셀의 y값을 찾아준다. 자연스럽게 첫주의 메뉴가 끝나는 y값도 찾아진다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;        {
          &amp;quot;valueType&amp;quot;: &amp;quot;ALL&amp;quot;,
          &amp;quot;boundingPoly&amp;quot;: {
            &amp;quot;vertices&amp;quot;: [
              {
                &amp;quot;x&amp;quot;: 54.0,
                &amp;quot;y&amp;quot;: 1335.0
              },
              {
                &amp;quot;x&amp;quot;: 119.0,
                &amp;quot;y&amp;quot;: 1335.0
              },
              {
                &amp;quot;x&amp;quot;: 119.0,
                &amp;quot;y&amp;quot;: 1353.0
              },
              {
                &amp;quot;x&amp;quot;: 54.0,
                &amp;quot;y&amp;quot;: 1353.0
              }
            ]
          },
          &amp;quot;inferText&amp;quot;: &amp;quot;c 자자재&amp;quot;,
          &amp;quot;inferConfidence&amp;quot;: 0.8001
        },&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;마지막에 &amp;quot;식자재 공급에 따라 변경될 수 있습니다&amp;quot; 텍스트 부분이 아직 완전히 해결되지는 않았는데, 저기 들어간 텍스트만 이상하게 위에 보이는 것 처럼 inferConfidence 0.8에 &lt;code&gt;c 자자재&lt;/code&gt;로 읽어버려서 텍스트 기반으로는 제거해낼 수가 없었다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;샐러드&lt;/code&gt; 텍스트가 자주 나오는 y값들을 저장해두고 그 끝을 유추해내는 방법을 쓰거나 혹은 ocr 응답을 전처리 해서 마지막 줄에 나오는 내용을 제거해버리는 등의 방법을 고려하고 있다.&lt;/p&gt;
&lt;p&gt;앞으로  두어번 정도 새로 발행되는 메뉴 이미지들을 확인해보고 개선해보려 한다.&lt;/p&gt;
&lt;h2&gt;끝마침&lt;/h2&gt;
&lt;p&gt;다행히도 이렇게 만든 점심 봇은 반응이 좋은 편이다. 다들 개발자 답게 새로운 기능이나 개선에 대한 요청도 종종 해줘서 기쁜 마음으로 반영하곤 한다. 다만 코드 기여를 좀 해달라는 내 요청을 외면 하는 건 아쉽다.&lt;/p&gt;
&lt;p&gt;모든 코드는 아래의 Github 저장소에 공개되어 있다.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/ArgonetDevStudio/foodbox&quot;&gt;https://github.com/ArgonetDevStudio/foodbox&lt;/a&gt;&lt;/p&gt;</description>
      <category>Development/DevLife</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/545</guid>
      <comments>https://shanepark.tistory.com/545#entry545comment</comments>
      <pubDate>Thu, 12 Jun 2025 22:21:07 +0900</pubDate>
    </item>
    <item>
      <title>커밋시 GitHub Actions 스킵하는 방법</title>
      <link>https://shanepark.tistory.com/544</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;코드와 관계없는 파일을 살짝 고치거나 README.md 파일만 업데이트했을 뿐인데, 매번 CI가 돌아가면서 빌드에 3~5분, 배포까지 하면 더 길어지는 시간. 이런 불필요한 CI 실행 때문에 리소스 낭비도 되고, 워크플로우 실행 제한에 걸리는 경우도 있다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 GitHub 공식 문서에 나온 내용을 바탕으로, CI를 스킵하는 여러 방법과 주의사항까지 정리해본다.&lt;/p&gt;
&lt;h2&gt;방법1: 커밋 메시지&lt;/h2&gt;
&lt;p&gt;워크플로우가 &lt;code&gt;on: push&lt;/code&gt;나 &lt;code&gt;on: pull_request&lt;/code&gt;에 의해 실행되는 경우, 커밋 메시지에 아래 키워드 중 하나라도 포함시키면 해당 워크플로우는 &lt;strong&gt;자동으로 실행되지 않는다&lt;/strong&gt;.&lt;/p&gt;
&lt;h3&gt;사용할 수 있는 키워드&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;[skip ci]
[ci skip]
[no ci]
[skip actions]
[actions skip]&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;예시&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git commit -m &amp;quot;Update README.md [skip ci]&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;혹은,&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git commit -m &amp;quot;문서 오타 수정 [no ci]&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;CI와 관련된 단어들 중 하나만 있어도 동작하므로, 자신의 스타일에 맞게 골라서 쓰면 된다.&lt;/p&gt;
&lt;h2&gt;방법2: &lt;code&gt;skip-checks&lt;/code&gt; 트레일러&lt;/h2&gt;
&lt;p&gt;커밋 메시지에 트레일러(trailer)를 사용하는 것도 가능하다. 트레일러는 커밋 메시지 마지막에 빈 줄 두 개를 넣고 작성해야 한다.&lt;/p&gt;
&lt;p&gt;예시:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-text&quot;&gt;문서 수정

skip-checks: true&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;--cleanup=verbatim&lt;/code&gt; 옵션을 사용하지 않으면 Git이 줄바꿈을 자동 정리해서 깨질 수 있으니 주의.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;git commit --cleanup=verbatim&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;주의할 점&lt;/h2&gt;
&lt;h3&gt;1. 체크가 “Pending” 상태로 남는다&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;[skip ci]&lt;/code&gt; 방식으로 워크플로우가 아예 실행되지 않은 경우, GitHub에서 &lt;strong&gt;해당 체크가 성공 처리되지 않고 “Pending”으로 남는다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;따라서 PR 병합 조건에 “모든 체크가 성공해야 함”이 걸려 있는 경우엔 병합이 &lt;strong&gt;막힐 수 있다.&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이럴 땐, skip 없이 새로운 커밋을 하나 더 올려서 체크를 실행시키면 해결된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;2. &lt;code&gt;pull_request_target&lt;/code&gt; 에는 적용되지 않음&lt;/h3&gt;
&lt;p&gt;이 스킵 키워드는 &lt;code&gt;on: push&lt;/code&gt;와 &lt;code&gt;on: pull_request&lt;/code&gt;에만 동작한다. 만약 &lt;code&gt;on: pull_request_target&lt;/code&gt;이나 &lt;code&gt;workflow_dispatch&lt;/code&gt;, &lt;code&gt;schedule&lt;/code&gt; 같은 이벤트에서는 무시된다.&lt;/p&gt;
&lt;p&gt;따라서 모든 경우에 스킵되는 게 아니니, 이벤트 종류를 먼저 확인하자.&lt;/p&gt;
&lt;h3&gt;3. 커밋 메시지에만 적용됨&lt;/h3&gt;
&lt;p&gt;PR에서 여러 커밋이 있을 경우, 가장 마지막 HEAD 커밋의 메시지를 기준으로 워크플로우 실행 여부가 결정된다. PR의 타이틀이나 설명에는 키워드를 넣어도 효과 없다.&lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;간단한 문서 작업, 주석 수정, README 업데이트 같은 변경에 CI/CD를 굳이 돌릴 필요는 없다. 이럴 땐 커밋 메시지에 &lt;code&gt;[skip ci]&lt;/code&gt; 또는 &lt;code&gt;skip-checks: true&lt;/code&gt;를 붙여서 &lt;strong&gt;GitHub Actions를 간단하게 스킵&lt;/strong&gt;할 수 있다.&lt;/p&gt;
&lt;p&gt;단, PR 병합 조건이나 이벤트 종류에 따라 예상과 다르게 작동할 수도 있으니 사용 전에 해당 리포지토리의 설정을 꼭 확인하자.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/skipping-workflow-runs&quot;&gt;https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/skipping-workflow-runs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Git</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/544</guid>
      <comments>https://shanepark.tistory.com/544#entry544comment</comments>
      <pubDate>Tue, 13 May 2025 21:52:03 +0900</pubDate>
    </item>
    <item>
      <title>Ubuntu 22.04 Chrome 135 한글 입력 문제</title>
      <link>https://shanepark.tistory.com/543</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;UPDATE: 2025년 8월 5일 릴리즈 된 Chrome 139 버전으로 업데이트 후 문제 해결됨.&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://developer.chrome.com/release-notes/139&quot;&gt;https://developer.chrome.com/release-notes/139&lt;/a&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# APT 저장소 최신 Chrome 버전 및 Candidate 확인. 139버전이 릴리즈 되어있어야함.
apt policy google-chrome-stable

# 기존의 134버전에 홀드 mark 했던 것 풀기
sudo apt-mark unhold google-chrome-stable

sudo apt update
sudo apt install google-chrome-stable

# 새로 설치된 버전 확인 후 Chrome 브라우저 종료 후 재시작
google-chrome --version&lt;/code&gt;&lt;/pre&gt;&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;우분투에서 크롬을 135 버전으로 올린 이후 일주일째 한글 입력할 때 마다 스트레스를 받고있다.&lt;/p&gt;
&lt;p&gt;135버전 업데이트 직후부터 발생했기때문에 이유는 확실한데, 아무리 찾아봐도 Chrome 브라우저 다운그레이를 하는 방법이 공식적으로 제공되는게 없어서 그냥 이슈 리포트 후 파이어폭스를 사용하며 새로운 업데이트를 기다리기로 했다.&lt;/p&gt;
&lt;p&gt;새로운 업데이트를 두 번 해서 &lt;code&gt;Google Chrome 135.0.7049.95&lt;/code&gt; 까지 왔는데도 여전히 해결이 되지 않길래 이제 좀 더 적극적으로 해결책을 찾아보기로 했다.&lt;/p&gt;
&lt;p&gt;다행히도 잘 찾아보니 같은 문제가 있다고 한 사람이 30건을 넘긴 이슈였다. 같은 문제를 겪는 사람이 많을수록 문제가 해결될 확률은 높아진다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devops/browser/chrome-135-korean.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://support.google.com/chrome/thread/337302644/%EC%9A%B0%EB%B6%84%ED%88%AC-22-04-%ED%81%AC%EB%A1%AC%EC%97%90%EC%84%9C%EB%A7%8C-%ED%95%9C%EA%B8%80-%EB%AA%A8%EB%93%9C%EC%97%90%EC%84%9C-%EC%8A%A4%ED%8E%98%EC%9D%B4%EC%8A%A4-%EA%B0%99%EC%9D%80-%ED%82%A4-%EC%9E%85%EB%A0%A5%EC%8B%9C-%EB%AC%B8%EC%A0%9C-%EB%B0%9C%EC%83%9D?hl=ko&quot;&gt;https://support.google.com/chrome/thread/337302644/%EC%9A%B0%EB%B6%84%ED%88%AC-22-04-%ED%81%AC%EB%A1%AC%EC%97%90%EC%84%9C%EB%A7%8C-%ED%95%9C%EA%B8%80-%EB%AA%A8%EB%93%9C%EC%97%90%EC%84%9C-%EC%8A%A4%ED%8E%98%EC%9D%B4%EC%8A%A4-%EA%B0%99%EC%9D%80-%ED%82%A4-%EC%9E%85%EB%A0%A5%EC%8B%9C-%EB%AC%B8%EC%A0%9C-%EB%B0%9C%EC%83%9D?hl=ko&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;원인&lt;/h2&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devops/browser/chrome-135-korean.assets/3.webp&quot; alt=&quot;3&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://issues.chromium.org/issues/407930251&quot;&gt;https://issues.chromium.org/issues/407930251&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;Chrome 135 버전부터 GTK4가 default로 사용되며 GTK IME 가 활성화되었는데, CharacterComposer 기능이 빠져있다보니 문제가 발생했다고 한다. 137 버전에서야 수정된 패치가 적용되었다고 하는데 배포를 기다리기엔 너무 멀다.&lt;/p&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;chrome://flags&lt;/code&gt; 에 들어가서 &lt;code&gt;Wayland text-input-v3&lt;/code&gt;을 활성화하는 방법이 있다고하는데 나는 wayland가 아닌 x11 을 사용중이라그런지 효과가 없었다.&lt;/p&gt;
&lt;p&gt;이제 선택을 해야 하는데, 해결되었다고 하는 137버전의 canary 혹은 unstable 버전을 쓸 것인가, 아니면 134버전으로 다운그레이드 할 것인가.&lt;/p&gt;
&lt;p&gt;업무용 pc다 보니 안전한 선택으로 134버전을 택했다. 과거 버전은 아래의 링크에서 다운받을 수 있다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://mirror.cs.uchicago.edu/google-chrome/pool/main/g/&quot;&gt;https://mirror.cs.uchicago.edu/google-chrome/pool/main/g/&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt remove google-chrome-stable
sudo dpkg --install google-chrome-stable_134.0.6998.88-1_amd64.deb

chrome --version            
# Google Chrome 134.0.6998.88 &lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여기서 한가지 더 해야할게 있는데, 크롬의 자동 업데이트를 차단하는 것이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt-mark hold google-chrome-stable

apt-mark showhold
# google-chrome-stable&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이렇게 해놓고 몇달정도 잊고 살았다가 나중에 해결되고 나서 업데이트를 하면 되겠다.&lt;/p&gt;
&lt;p&gt;나중에 hold를 풀고 싶다면 아래의 명령어를 입력하면 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo apt-mark unhold google-chrome-stable&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;당연하게도 134버전의 크롬에서는 한글 입력에 아무런 문제가 없다.&lt;/p&gt;
&lt;p&gt;끝&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://support.google.com/chrome/thread/337302644/%EC%9A%B0%EB%B6%84%ED%88%AC-22-04-%ED%81%AC%EB%A1%AC%EC%97%90%EC%84%9C%EB%A7%8C-%ED%95%9C%EA%B8%80-%EB%AA%A8%EB%93%9C%EC%97%90%EC%84%9C-%EC%8A%A4%ED%8E%98%EC%9D%B4%EC%8A%A4-%EA%B0%99%EC%9D%80-%ED%82%A4-%EC%9E%85%EB%A0%A5%EC%8B%9C-%EB%AC%B8%EC%A0%9C-%EB%B0%9C%EC%83%9D?hl=ko&quot;&gt;https://support.google.com/chrome/thread/337302644/%EC%9A%B0%EB%B6%84%ED%88%AC-22-04-%ED%81%AC%EB%A1%AC%EC%97%90%EC%84%9C%EB%A7%8C-%ED%95%9C%EA%B8%80-%EB%AA%A8%EB%93%9C%EC%97%90%EC%84%9C-%EC%8A%A4%ED%8E%98%EC%9D%B4%EC%8A%A4-%EA%B0%99%EC%9D%80-%ED%82%A4-%EC%9E%85%EB%A0%A5%EC%8B%9C-%EB%AC%B8%EC%A0%9C-%EB%B0%9C%EC%83%9D?hl=ko&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://issues.chromium.org/issues/407930251&quot;&gt;https://issues.chromium.org/issues/407930251&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Develop Tools</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/543</guid>
      <comments>https://shanepark.tistory.com/543#entry543comment</comments>
      <pubDate>Wed, 16 Apr 2025 10:24:02 +0900</pubDate>
    </item>
    <item>
      <title>Docker 로그 파일 용량 제한</title>
      <link>https://shanepark.tistory.com/542</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;Docker 컨테이너를 오래 실행하다 보면 로그 파일이 계속 쌓이면서 디스크 공간을 차지하는 문제가 발생한다.&lt;/p&gt;
&lt;p&gt;Docker의 기본 로그 드라이버는 &lt;code&gt;json-file&lt;/code&gt;이며, 별도로 설정하지 않으면 로그 파일 크기 제한 없이 계속 증가한다. 결국 서버의 디스크가 꽉 차서 장애가 발생할 수도 있다. 이를 방지하려면 로그 파일의 크기를 제한하는 설정을 적용하는 것이 중요하다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/devops/docker/docker-logfile.assets/1.webp&quot; alt=&quot;image-20250312095617219&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;끝없이 커진 로그파일이 결국 장애를 일으켜버렸다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이번 글에서는 Docker 컨테이너의 로그 파일 크기를 제한하는 방법과 기존 로그 파일을 정리하는 방법, 그리고 Docker Compose에서 설정하는 방법까지 알아본다.&lt;/p&gt;
&lt;h2&gt;Docker 로그 크기 제한&lt;/h2&gt;
&lt;p&gt;Docker 로그 크기 제한 방법은 크게 3가지로 나뉜다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;개별 컨테이너 실행 시 로그 제한 설정&lt;/li&gt;
&lt;li&gt;Docker 데몬 전체 설정 (&lt;code&gt;daemon.json&lt;/code&gt; 수정)&lt;/li&gt;
&lt;li&gt;Docker Compose에서 설정 적용&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;1. 개별 컨테이너 실행 시 로그 제한&lt;/h3&gt;
&lt;p&gt;컨테이너를 실행할 때 &lt;code&gt;--log-opt&lt;/code&gt; 옵션을 추가하면 특정 컨테이너의 로그 크기를 제한할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker run -d \
  --log-driver=json-file \
  --log-opt max-size=100m \
  --log-opt max-file=3 \
  --name my_container \
  my_image&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;max-size=100m&lt;/code&gt; → 로그 파일 최대 크기를 100MB로 제한&lt;/li&gt;
&lt;li&gt;&lt;code&gt;max-file=3&lt;/code&gt; → 최대 3개의 로그 파일 유지&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;즉, 로그 파일 크기가 100MB를 넘으면 새 파일이 생성되며, 가장 오래된 파일은 삭제된다.&lt;/p&gt;
&lt;p&gt;하지만 이 방법은 특정 컨테이너에만 적용되므로, 전체 컨테이너에 적용하려면 Docker 데몬 설정을 수정해야 한다.&lt;/p&gt;
&lt;h3&gt;2. Docker 데몬 전체 설정 (&lt;code&gt;daemon.json&lt;/code&gt; 수정)&lt;/h3&gt;
&lt;p&gt;모든 컨테이너에 대해 기본적으로 로그 크기 제한을 적용하려면 &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; 파일을 수정해야 한다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;code&gt;daemon.json&lt;/code&gt; 파일이 없을 수도 있으니 먼저 확인하고, 없으면 새로 생성한다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo vi /etc/docker/daemon.json&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;아래 내용을 추가한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &amp;quot;log-driver&amp;quot;: &amp;quot;json-file&amp;quot;,
  &amp;quot;log-opts&amp;quot;: {
    &amp;quot;max-size&amp;quot;: &amp;quot;100m&amp;quot;,
    &amp;quot;max-file&amp;quot;: &amp;quot;3&amp;quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 Docker 데몬을 재시작해서 설정을 적용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo systemctl restart docker&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;설정이 적용되었는지 확인하려면 아래 명령어를 실행해 보자.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker run --name log-test hello-world
docker inspect --format=&amp;#39;{{.HostConfig.LogConfig}}&amp;#39; log-test

# 출력 결과 {json-file map[max-file:3 max-size:100m]}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;하지만 이 방법도 기존의 컨테이너에는 적용되지 않고 새로 생성하는 컨테이너에만 적용된다.&lt;/p&gt;
&lt;h3&gt;3. Docker Compose에서 로그 제한 설정&lt;/h3&gt;
&lt;p&gt;Docker Compose를 사용할 경우, &lt;code&gt;docker-compose.yml&lt;/code&gt; 파일에서 &lt;code&gt;logging&lt;/code&gt; 옵션을 추가하면 된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;services:
  my_service:
    image: my_image
    container_name: my_container
    logging:
      driver: &amp;quot;json-file&amp;quot;
      options:
        max-size: &amp;quot;100m&amp;quot;
        max-file: &amp;quot;3&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 설정을 적용한 뒤, 기존 컨테이너를 다시 실행해야 한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker-compose up -d&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;마찬가지로 적용이 잘 되었는지도 한번 확인해준다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;docker inspect {컨테이너명} | grep -A5 &amp;quot;LogConfig&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;기존 로그 파일 정리&lt;/h2&gt;
&lt;p&gt;이미 쌓여 있는 로그 파일을 정리하려면 &lt;code&gt;truncate&lt;/code&gt; 명령어를 사용하는 것이 가장 안전한 방법이다.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;truncate&lt;/code&gt; vs &lt;code&gt;rm&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;로그 파일을 정리할 때 &lt;code&gt;rm&lt;/code&gt;을 사용하면 곤란한 상황이 온다.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;rm&lt;/code&gt; 명령어로 로그 파일을 삭제하면 Docker가 여전히 삭제된 파일을 잡고 있어서 디스크 공간이 해제되지 않는 문제가 발생해 기껏 삭제해도 디스크에 빈 공간이 늘어나지 않는다. &lt;/p&gt;
&lt;p&gt;대신 &lt;code&gt;truncate&lt;/code&gt;를 사용하면 파일을 삭제하지 않고 크기만 0으로 만들어 안전하게 정리할 수 있다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo truncate -s 0 /var/lib/docker/containers/&amp;lt;컨테이너ID&amp;gt;/*-json.log&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이 방법을 사용하면 Docker가 기존 파일을 유지하면서도 새로운 로그를 정상적으로 기록할 수 있다.&lt;/p&gt;
&lt;h3&gt;현재 로그 파일 크기 확인&lt;/h3&gt;
&lt;p&gt;어떤 컨테이너의 로그가 많이 쌓였는지 확인하려면 다음 명령어를 사용한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo sh -c &amp;#39;du -sh /var/lib/docker/containers/*/*-json.log&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 용량이 큰 로그 파일을 &lt;code&gt;truncate&lt;/code&gt;를 사용해 정리하면 된다.&lt;/p&gt;
&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;Docker 컨테이너의 로그 파일이 무제한으로 증가하는 것을 방지하려면 다음과 같은 방법을 적용하면 된다.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;개별 컨테이너 실행 시 &lt;code&gt;--log-opt&lt;/code&gt; 옵션 사용&lt;/li&gt;
&lt;li&gt;모든 컨테이너에 적용하려면 &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; 수정&lt;/li&gt;
&lt;li&gt;Docker Compose를 사용할 경우 &lt;code&gt;logging&lt;/code&gt; 옵션 추가&lt;/li&gt;
&lt;li&gt;기존 로그 파일 정리는 &lt;code&gt;truncate -s 0&lt;/code&gt;을 사용하여 안전하게 정리&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;특히 운영 환경에서는 로그 파일이 디스크를 꽉 채우는 불상사를 방지하도록 &lt;code&gt;max-size&lt;/code&gt;와 &lt;code&gt;max-file&lt;/code&gt;을 설정하는 것이 필수다.&lt;/p&gt;</description>
      <category>Development/DevOps</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/542</guid>
      <comments>https://shanepark.tistory.com/542#entry542comment</comments>
      <pubDate>Wed, 12 Mar 2025 10:22:35 +0900</pubDate>
    </item>
    <item>
      <title>IntelliJ IDEA Copilot 한글 깨짐 문제 해결</title>
      <link>https://shanepark.tistory.com/541</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt; 본 증상은 IntelliJ IDEA 2024.3.5 로 업데이트 되며 해결되었으나 추후 재발에 대비하여 글을 남겨둠&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;인텔리제이에서 Copilot을 사용할 때, 버전 업그레이드 후 한글 자동완성이 깨지는 문제가 발생했다. 이는 fallback font가 올바르게 불러와지지 않아서 발생하는 것으로 보인다. 자동완성 미리보기는 한글이 다 깨져서 나오는데, 막상 &lt;code&gt;tab&lt;/code&gt;을 누르면 제대로 입력된다. &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/intellij/Intellij-copilot-font.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/intellij/Intellij-copilot-font.assets/3.webp&quot; alt=&quot;3&quot;&gt;&lt;/p&gt;
&lt;p&gt;현 개발 환경은 다음과 같다.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Ubuntu 22.04&lt;/li&gt;
&lt;li&gt;IntelliJ IDEA Ultimate 2024.3.4.1&lt;/li&gt;
&lt;li&gt;Github Copilot 1.5.37-242&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;해결&lt;/h2&gt;
&lt;p&gt;원래 JetBrains Mono는 한글을 지원 하지 않는다. &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/intellij/Intellij-copilot-font.assets/4.webp&quot; alt=&quot;4&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://www.jetbrains.com/ko-kr/lp/mono/&quot;&gt;https://www.jetbrains.com/ko-kr/lp/mono/&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;그래서 적당한 Fallback이 이루어져야 하는데 Copilot 과 IntelliJ IDEA 둘이서 잘해보려다가 충돌이 발생한 모양이다.&lt;/p&gt;
&lt;p&gt;Fallback font 도 설정 해보고 Color Scheme Font 에 fall back 설정도 해보았는데도 효과가 없었다.&lt;/p&gt;
&lt;p&gt;물론 해결방법은 간단한데, 한글을 지원하는 폰트를 사용하면 된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/intellij/Intellij-copilot-font.assets/5.webp&quot; alt=&quot;5&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;D2Coding font 로 변경&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이렇게 간단하게 해결하면 되지만 이미 &lt;code&gt;JetBrains Mono&lt;/code&gt; 폰트에 길들어져서 폰트를 변경하고 싶지는 않다.&lt;/p&gt;
&lt;p&gt;그렇다면 이제 JetBrains Mono 폰트에 한글을 입혀 사용하면 되겠는데 마침 찾아보니 친절하게도 누군가가 만들어두었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt; &lt;a href=&quot;https://github.com/Jhyub/JetBrainsMonoHangul&quot;&gt;https://github.com/Jhyub/JetBrainsMonoHangul&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;위의 페이지에 방문해서 Releases 페이지에서 폰트 파일을 다운 받고 설치하면 된다. &lt;/p&gt;
&lt;p&gt;폰트 설치후에는 IntelliJ IDEA 가 자동으로 인식을 하진 못해서 한 번 재시작해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Settings(환경설정) → Editor → Font&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/intellij/Intellij-copilot-font.assets/6.webp&quot; alt=&quot;6&quot;&gt;&lt;/p&gt;
&lt;p&gt;이렇게 설정하고 나면 JetBrains Mono 폰트를 사용하며 한글영역만 D2Coding Font를 사용할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/intellij/Intellij-copilot-font.assets/7.webp&quot; alt=&quot;7&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;코파일럿의 한글 자동완성이 정상적으로 표시된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;</description>
      <category>Development/Develop Tools</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/541</guid>
      <comments>https://shanepark.tistory.com/541#entry541comment</comments>
      <pubDate>Fri, 7 Mar 2025 21:32:40 +0900</pubDate>
    </item>
    <item>
      <title>Selenium과 2Captcha로 Cloudflare Turnstile 우회</title>
      <link>https://shanepark.tistory.com/540</link>
      <description>&lt;h2&gt;Intro&lt;/h2&gt;
&lt;p&gt;웹 스크래핑을 하다 보면 Cloudflare Turnstile 캡차가 가로막는 경우가 많다. 특히, 자동화된 요청을 차단하려는 사이트에서는 이걸 우회하지 않으면 데이터를 가져올 수 없다. API가 제공되지 않는 경우, 어쩔 수 없이 크롤링을 통해 데이터를 수집해야 하지만, Turnstile이 이를 방해할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/2captcha-turnstile.assets/2.webp&quot; alt=&quot;2&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Verify you are human by completing the action below.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;위와 같이 Turnstile이 앞을 가로막아 자동수집이 안되면 곤란하다.&lt;/p&gt;
&lt;p&gt;이번 글에서는 Selenium과 2Captcha API를 활용해 Cloudflare 캡차를 뚫고 웹페이지에 자동으로 접속하는 과정을 정리한다. 또한, 성공적으로 접속한 후 HTML을 저장하는 방법까지 다룬다. 이제부터 캡차를 풀고 페이지를 저장하는 코드를 단계별로 살펴보자.&lt;/p&gt;
&lt;p&gt;아무래도 Captcha 는 창과 방패의 대결이기 때문에 주기적으로 코드를 갱신야 한다. 그래도 참고한 예제 코드가 6개월 전에 업데이트된걸 봐서는 엄청 번거롭게 하지는 않는 모양이다.&lt;/p&gt;
&lt;h2&gt;API Key 준비&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://2captcha.com&quot;&gt;https://2captcha.com&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;위의 사이트에 가입해준다. &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/2captcha-turnstile.assets/7.webp&quot; alt=&quot;7&quot;&gt;&lt;/p&gt;
&lt;p&gt;사이트 대문에는 자랑스럽게 실시간으로 풀어지고 있는 captcha 들의 현황과 각 타입별 비용이 적혀있다. Cloudflare Turnstile은 1,000 건당 &lt;code&gt;$1.45&lt;/code&gt; 으로, 대략 한번 풀때마다 2원씩 든다고 보면 된다. 굉장히 저렴한 편이라 생각된다.&lt;/p&gt;
&lt;p&gt;회원 가입 후에는 Worker 또는 Developer로 가입하는지를 체크하라고 하는데, 둘 다 할 수 있기 때문에 크게 신경 쓰지 않아도 된다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/2captcha-turnstile.assets/3.webp&quot; alt=&quot;3&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Worker Dashboard&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/2captcha-turnstile.assets/4.webp&quot; alt=&quot;4&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Developer dashboard&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;이제 API 사용을 위해서는 약간의 충전을 해야하는데, 재밌는건 Worker로도 등록할 수 있기 때문에 내가 필요한 요금을 스스로 벌어서 사용할 수 있다는거다. 아래의 예시는 비슷한 기능을 제공하는 다른 사이트인데, 인도, 파키스탄, 필리핀, 베트남 등의 실제 사람들이 캡챠를 풀어준다고 홍보하고 있다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/2captcha-turnstile.assets/1.webp&quot; alt=&quot;1&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;&lt;a href=&quot;https://anti-captcha.com/&quot;&gt;https://anti-captcha.com/&lt;/a&gt;&lt;br&gt;이 도표를 처음 봤을때는 스스로가 저 도넛 안에 들어가게 될거란 생각을 못했다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;자랑스럽게 Worker로 활동하여 약간의 한국인 지분을 늘리고 직접 벌어 사용하거나, 그게 싫다면 약간의 결제를 해야한다.&lt;/p&gt;
&lt;p&gt;최소 결제금액을 확인하니 &lt;code&gt;$3&lt;/code&gt; ...  본인은 괴롭지만 스스로 벌어서 채워넣는걸 선택했다.&lt;/p&gt;
&lt;p&gt;Worker Dashboard 에서 &lt;code&gt;Start work&lt;/code&gt;를 누르면 아주 간단하게 일을 시작할 수 있다. &lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/2captcha-turnstile.assets/8.webp&quot; alt=&quot;8&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;이런 문제를 열심히 대신 풀어주면 된다.&lt;/p&gt;
&lt;p&gt;API가 저렴하다고 좋아했는데 문제풀이할 때 버는 돈도 저렴하다. 위의 rate 의 경우에는 비슷한 문제를 무려 15번 제출해야 Cloudfare Turnstile 을 1회 풀 수 있는 비용이 준비된다. 다행히 어려운 문제들은 Rate 가 비교적 높다. &lt;/p&gt;
&lt;p&gt;열심히 풀고 나왔는데 Finances가 여전히 $0.00 이라고 표기된다고 너무 슬퍼 말자. $0.01 아래로 모았기 때문인데 사라진게 아니고 너무 적어서 안보이는 것 뿐이다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;참고로 응답율과 정확도라는 두가지 지표를 측정하는데, 둘 다 95% 이상을 유지해야 한다. 문제가 알아보기 힘들다고 skip 해버리면 안되고 진짜로 절대 풀 수 없는 상황에서만 &lt;code&gt;Cannot solve&lt;/code&gt;를 선택해야 한다. 개인적으로 &lt;code&gt;CAPTCHA&lt;/code&gt; 상황에서 어려운건 다음걸로 넘기는 걸 선호하는 편이라서 평소대로 알아보기 힘든 문제를 그냥 넘겼다가 Solved captchas 가 뚝 떨어지는 문제가 있었다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/2captcha-turnstile.assets/5.webp&quot; alt=&quot;5&quot;&gt;&lt;/p&gt;
&lt;p&gt;열심히 문제를 풀었거나 돈을 지불 했다면 어느정도 Finances가 쌓였을 텐데 이제 Developer Dashboard 에서 하단의 API Key를 복사해두면 준비가 끝난다. 참고로 Worker 와 Developer 의 API Key가 별개로 존재하니 반드시 Developer의 key를 준비하자.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/2captcha-turnstile.assets/6.webp&quot; alt=&quot;6&quot;&gt;&lt;/p&gt;
&lt;h2&gt;개발&lt;/h2&gt;
&lt;h3&gt;필요 라이브러리 설치&lt;/h3&gt;
&lt;p&gt;먼저 Python 환경에서 필요한 패키지를 설치한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;pip install selenium 2captcha-python&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;selenium&lt;/code&gt; : 브라우저 자동화를 위한 라이브러리&lt;/li&gt;
&lt;li&gt;&lt;code&gt;2captcha-python&lt;/code&gt; : 2Captcha API를 통해 캡차를 풀기 위한 라이브러리. &lt;code&gt;twocaptcha&lt;/code&gt; 같은 비공식 말고 반드시 &lt;code&gt;2captcha-python&lt;/code&gt; 를 설치하자.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;Selenium을 사용하려면 Chrome WebDriver도 필요하다. Chrome 버전에 맞는 최신의 WebDriver를 설치하고, 환경 변수에 추가하자.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h3&gt;2Captcha API 키 설정&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;위에서 가입해 준비한 API Key 확인 후, &lt;code&gt;config.json&lt;/code&gt; 파일을 생성해 저장한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
    &amp;quot;2captcha_api_key&amp;quot;: &amp;quot;여기에_본인의_2captcha_API_키를_입력&amp;quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 Python 코드에서 API 키를 불러올 수 있다.&lt;/p&gt;
&lt;h3&gt;전체 코드&lt;/h3&gt;
&lt;p&gt;먼저 실행 결과 및 전체 예제 코드를 보여주고 나서 단계별로 설명하도록 하겠다.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://raw.githubusercontent.com/ShanePark/mdblog/main/development/2captcha-turnstile.assets/9.webp&quot; alt=&quot;9&quot;&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;예제 코드에서는 실행 한 폴더에 &lt;code&gt;output.html&lt;/code&gt; 로 결과를 저장하게 하였는데, captcha 풀이 이후에는 다른 링크를 방문하거나 파싱을 하거나 각자 필요한 일을 하면 된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os
import time
import json
import re
from selenium import webdriver
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from twocaptcha import TwoCaptcha

# CONFIGURATION
with open(&amp;quot;config.json&amp;quot;, &amp;quot;r&amp;quot;) as config_file:
    config = json.load(config_file)

apikey = config[&amp;quot;2captcha_api_key&amp;quot;]
url = &amp;quot;https://2captcha.com/demo/cloudflare-turnstile-challenge&amp;quot;

intercept_script = &amp;quot;&amp;quot;&amp;quot; 
    console.clear = () =&amp;gt; console.log(&amp;#39;Console was cleared&amp;#39;)
    const i = setInterval(()=&amp;gt;{
    if (window.turnstile)
     console.log(&amp;#39;success!!&amp;#39;)
     {clearInterval(i)
         window.turnstile.render = (a,b) =&amp;gt; {
          let params = {
                sitekey: b.sitekey,
                pageurl: window.location.href,
                data: b.cData,
                pagedata: b.chlPageData,
                action: b.action,
                userAgent: navigator.userAgent,
            }
            console.log(&amp;#39;intercepted-params:&amp;#39; + JSON.stringify(params))
            window.cfCallback = b.callback
            return        
         } 
    }
},50)    
&amp;quot;&amp;quot;&amp;quot;

# ACTIONS
def get_captcha_params(script):
    &amp;quot;&amp;quot;&amp;quot;
    Refreshes the page, injects a JavaScript script to intercept Turnstile parameters, and retrieves them.

    Returns:
        dict: The intercepted Turnstile parameters as a dictionary.
    &amp;quot;&amp;quot;&amp;quot;
    print(&amp;quot;[INFO] 페이지를 새로고침하여 캡차 인터셉트 시도 중...&amp;quot;)
    browser.refresh() 

    print(&amp;quot;[INFO] 캡차 인터셉트 스크립트 실행 중...&amp;quot;)
    browser.execute_script(script)

    time.sleep(5)

    logs = browser.get_log(&amp;quot;browser&amp;quot;)
    params = None
    for log in logs:
        if &amp;quot;intercepted-params:&amp;quot; in log[&amp;#39;message&amp;#39;]:
            log_entry = log[&amp;#39;message&amp;#39;].encode(&amp;#39;utf-8&amp;#39;).decode(&amp;#39;unicode_escape&amp;#39;)
            match = re.search(r&amp;#39;intercepted-params:({.*?})&amp;#39;, log_entry)
            if match:
                json_string = match.group(1)
                params = json.loads(json_string)
                break
    if params:
        print(&amp;quot;[SUCCESS] 캡차 파라미터 인터셉트 성공!&amp;quot;)
    else:
        print(&amp;quot;[ERROR] 캡차 파라미터를 찾을 수 없습니다.&amp;quot;)
    return params

def solver_captcha(apikey, params):
    &amp;quot;&amp;quot;&amp;quot;
    Solves the Turnstile captcha using the 2Captcha service.

    Returns:
        str: The solved captcha token.
    &amp;quot;&amp;quot;&amp;quot;
    print(&amp;quot;[INFO] 2Captcha를 이용하여 캡차 풀이 중...&amp;quot;)
    solver = TwoCaptcha(apikey)
    try:
        result = solver.turnstile(sitekey=params[&amp;quot;sitekey&amp;quot;],
                                  url=params[&amp;quot;pageurl&amp;quot;],
                                  action=params[&amp;quot;action&amp;quot;],
                                  data=params[&amp;quot;data&amp;quot;],
                                  pagedata=params[&amp;quot;pagedata&amp;quot;],
                                  useragent=params[&amp;quot;userAgent&amp;quot;])
        print(&amp;quot;[SUCCESS] 캡차 풀이 완료!&amp;quot;)
        return result[&amp;#39;code&amp;#39;]
    except Exception as e:
        print(f&amp;quot;[ERROR] 캡차 풀이 중 오류 발생: {e}&amp;quot;)
        return None

def send_token_callback(token):
    &amp;quot;&amp;quot;&amp;quot;
    Executes the callback function with the given token.
    &amp;quot;&amp;quot;&amp;quot;
    print(&amp;quot;[INFO] 캡차 토큰을 브라우저에 전달 중...&amp;quot;)
    script = f&amp;quot;cfCallback(&amp;#39;{token}&amp;#39;)&amp;quot;
    browser.execute_script(script)
    print(&amp;quot;[SUCCESS] 캡차 토큰이 성공적으로 전달됨.&amp;quot;)

def save_page_html():
    &amp;quot;&amp;quot;&amp;quot;
    Saves the current page&amp;#39;s HTML to a file and prints the absolute path.
    &amp;quot;&amp;quot;&amp;quot;
    file_path = os.path.abspath(&amp;quot;output.html&amp;quot;)  # 절대 경로 얻기
    print(f&amp;quot;[INFO] 페이지 HTML을 {file_path} 에 저장 중...&amp;quot;)

    html_content = browser.page_source
    with open(file_path, &amp;quot;w&amp;quot;, encoding=&amp;quot;utf-8&amp;quot;) as file:
        file.write(html_content)

    print(f&amp;quot;[SUCCESS] 페이지 HTML 저장 완료! 저장 위치: {file_path}&amp;quot;)

# LOCATORS
locator = &amp;quot;//p[contains(@class,&amp;#39;successMessage&amp;#39;)]&amp;quot;

def check_success():
    &amp;quot;&amp;quot;&amp;quot;
    Checks if the element exists on the page.
    If found, saves the page HTML.
    &amp;quot;&amp;quot;&amp;quot;
    try:
        print(&amp;quot;[INFO] 페이지 성공 여부 확인 중...&amp;quot;)
        WebDriverWait(browser, 10).until(EC.presence_of_element_located((By.XPATH, locator)))
        print(&amp;quot;[SUCCESS] 페이지가 정상적으로 로드됨! &amp;lt;div class=&amp;#39;successMessage&amp;#39;&amp;gt; 발견됨.&amp;quot;)
        save_page_html()
    except Exception:
        print(&amp;quot;[WARNING] &amp;lt;div class=&amp;#39;successMessage&amp;#39;&amp;gt; 요소를 찾을 수 없음.&amp;quot;)

# MAIN LOGIC
chrome_options = Options()
chrome_options.add_argument(&amp;quot;user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) &amp;quot;
                            &amp;quot;Chrome/126.0.0.0 Safari/537.36&amp;quot;)
chrome_options.set_capability(&amp;quot;goog:loggingPrefs&amp;quot;, {&amp;quot;browser&amp;quot;: &amp;quot;INFO&amp;quot;})

with webdriver.Chrome(service=Service(), options=chrome_options) as browser:
    print(&amp;quot;[INFO] 브라우저 실행 중...&amp;quot;)
    browser.get(url)
    print(&amp;quot;[INFO] 웹사이트 접속 완료.&amp;quot;)

    params = get_captcha_params(intercept_script)

    if params:
        token = solver_captcha(apikey, params)

        if token:
            send_token_callback(token)
            time.sleep(5)  # 페이지가 로드될 시간을 줌

            check_success()

            print(&amp;quot;[SUCCESS] 모든 과정 완료!&amp;quot;)
        else:
            print(&amp;quot;[ERROR] 캡차 풀이 실패.&amp;quot;)
    else:
        print(&amp;quot;[ERROR] 캡차 인터셉트 실패.&amp;quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;전체 코드를 확인했으니 단계별로 잘라서 알아보자.&lt;/p&gt;
&lt;h3&gt;Selenium으로 웹사이트 접속&lt;/h3&gt;
&lt;p&gt;Selenium을 이용해 웹사이트에 접속하는 코드이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options

# Chrome 옵션 설정
chrome_options = Options()
chrome_options.add_argument(&amp;quot;user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36&amp;quot;)

# Chrome WebDriver 실행
browser = webdriver.Chrome(service=Service(), options=chrome_options)

# 접속할 웹사이트
url = &amp;quot;Turnstile 이 적용된 웹사이트 주소&amp;quot;
browser.get(url)
print(&amp;quot;[INFO] 웹사이트 접속 완료.&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Cloudflare Turnstile 캡차 인터셉트&lt;/h3&gt;
&lt;p&gt;웹사이트에 접속한 후, 캡차를 우회하려면 Turnstile에서 사용하는 파라미터를 가로채야 한다.&lt;/p&gt;
&lt;p&gt;Selenium에서 JavaScript를 실행하여 &lt;code&gt;sitekey&lt;/code&gt;, &lt;code&gt;pageurl&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt; 등의 정보를 가져오도록 설정한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;intercept_script = &amp;quot;&amp;quot;&amp;quot; 
    console.clear = () =&amp;gt; console.log(&amp;#39;Console was cleared&amp;#39;)
    const i = setInterval(()=&amp;gt;{
    if (window.turnstile)
     console.log(&amp;#39;success!!&amp;#39;)
     {clearInterval(i)
         window.turnstile.render = (a,b) =&amp;gt; {
          let params = {
                sitekey: b.sitekey,
                pageurl: window.location.href,
                data: b.cData,
                pagedata: b.chlPageData,
                action: b.action,
                userAgent: navigator.userAgent,
            }
            console.log(&amp;#39;intercepted-params:&amp;#39; + JSON.stringify(params))
            window.cfCallback = b.callback
            return        
         } 
    }
},50)    
&amp;quot;&amp;quot;&amp;quot;

# 인터셉트 스크립트 실행
browser.execute_script(intercept_script)
print(&amp;quot;[INFO] 캡차 인터셉트 스크립트 실행 중...&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 Turnstile 캡차에서 사용하는 주요 파라미터를 가로챌 수 있다.&lt;/p&gt;
&lt;h3&gt;2Captcha API로 캡차 풀이&lt;/h3&gt;
&lt;p&gt;캡차를 풀기 위해 2Captcha API에 요청을 보내고, 해결된 토큰을 받아온다. 위에서 인터셉터한 파라미터들을 보낸다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;from twocaptcha import TwoCaptcha
import json

# API 키 로드
with open(&amp;quot;config.json&amp;quot;, &amp;quot;r&amp;quot;) as config_file:
    config = json.load(config_file)

apikey = config[&amp;quot;2captcha_api_key&amp;quot;]
solver = TwoCaptcha(apikey)

# 캡차 파라미터 받아오기
params = {
    &amp;quot;sitekey&amp;quot;: &amp;quot;가로챈_sitekey&amp;quot;,
    &amp;quot;pageurl&amp;quot;: &amp;quot;가로챈_pageurl&amp;quot;,
    &amp;quot;data&amp;quot;: &amp;quot;가로챈_data&amp;quot;,
    &amp;quot;pagedata&amp;quot;: &amp;quot;가로챈_pagedata&amp;quot;,
    &amp;quot;action&amp;quot;: &amp;quot;가로챈_action&amp;quot;,
    &amp;quot;userAgent&amp;quot;: &amp;quot;가로챈_userAgent&amp;quot;
}

# 2Captcha API 호출
print(&amp;quot;[INFO] 2Captcha를 이용하여 캡차 풀이 중...&amp;quot;)
result = solver.turnstile(params)
captcha_token = result[&amp;#39;code&amp;#39;]
print(f&amp;quot;[SUCCESS] 캡차 풀이 완료! 토큰: {captcha_token}&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 캡차 토큰을 얻었으므로, 웹사이트에 적용하면 된다.&lt;/p&gt;
&lt;h3&gt;해결된 캡차 토큰 적용&lt;/h3&gt;
&lt;p&gt;브라우저에서 해결된 캡차 토큰을 적용하여 인증을 완료한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;# 캡차 토큰을 사이트에 전달
script = f&amp;quot;cfCallback(&amp;#39;{captcha_token}&amp;#39;)&amp;quot;
browser.execute_script(script)
print(&amp;quot;[SUCCESS] 캡차 토큰이 성공적으로 전달됨.&amp;quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;이제 Cloudflare Turnstile이 해결되고, 정상적으로 웹사이트를 이용할 수 있다.&lt;/p&gt;
&lt;h3&gt;성공적인 접속 확인 및 HTML 저장&lt;/h3&gt;
&lt;p&gt;캡차를 푼 후, 정상적으로 페이지에 접속했는지 확인하고, HTML을 저장한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-python&quot;&gt;import os
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By

def save_page_html():
    &amp;quot;&amp;quot;&amp;quot;
    Saves the current page&amp;#39;s HTML to a file.
    &amp;quot;&amp;quot;&amp;quot;
    file_path = os.path.abspath(&amp;quot;output.html&amp;quot;)
    print(f&amp;quot;[INFO] 페이지 HTML을 {file_path} 에 저장 중...&amp;quot;)

    html_content = browser.page_source
    with open(file_path, &amp;quot;w&amp;quot;, encoding=&amp;quot;utf-8&amp;quot;) as file:
        file.write(html_content)

    print(f&amp;quot;[SUCCESS] 페이지 HTML 저장 완료! 저장 위치: {file_path}&amp;quot;)

# 페이지 성공 여부 확인
try:
    print(&amp;quot;[INFO] 페이지 성공 여부 확인 중... `.container` 엘리먼트가 있는지 찾아본다.&amp;quot;)
    WebDriverWait(browser, 10).until(EC.presence_of_element_located((By.XPATH, &amp;quot;//div[contains(@class,&amp;#39;container&amp;#39;)]&amp;quot;)))
    print(&amp;quot;[SUCCESS] 페이지가 정상적으로 로드됨! &amp;lt;div class=&amp;#39;container&amp;#39;&amp;gt; 발견됨.&amp;quot;)
except:
    print(&amp;quot;[WARNING] &amp;lt;div class=&amp;#39;container&amp;#39;&amp;gt; 요소를 찾을 수 없음. 페이지를 저장합니다.&amp;quot;)
    save_page_html()&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;바로 위의 코드에서는 &lt;code&gt;.container&lt;/code&gt;를 성공여부 파악에 사용했는데, 방문할 웹사이트마다 다르니 개인적으로 적당한 css selector를 정해서 입력해두면 된다.&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;h2&gt;결론&lt;/h2&gt;
&lt;p&gt;이번 글에서는 Selenium과 2Captcha를 이용해 Cloudflare Turnstile을 우회하는 방법을 알아보았다.&lt;/p&gt;
&lt;h3&gt;정리하면 다음과 같다&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;Selenium으로 웹사이트 접속&lt;/li&gt;
&lt;li&gt;JavaScript 실행으로 캡차 파라미터 인터셉트&lt;/li&gt;
&lt;li&gt;2Captcha API로 캡차 풀이&lt;/li&gt;
&lt;li&gt;해결된 캡차 토큰을 브라우저에 적용&lt;/li&gt;
&lt;li&gt;정상 접속 확인 및 HTML 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;이제 Cloudflare Turnstile 에 막혀도 자동으로 해결하고 원하는 데이터를 가져올 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;References&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/2captcha/captcha-solver-selenium-python-examples&quot;&gt;https://github.com/2captcha/captcha-solver-selenium-python-examples&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://2captcha.com/p/cloudflare-turnstile&quot;&gt;https://2captcha.com/p/cloudflare-turnstile&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Development/Develop Tools</category>
      <author>Shane Park</author>
      <guid isPermaLink="true">https://shanepark.tistory.com/540</guid>
      <comments>https://shanepark.tistory.com/540#entry540comment</comments>
      <pubDate>Wed, 5 Mar 2025 14:29:00 +0900</pubDate>
    </item>
  </channel>
</rss>