<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: 바람의평온</title>
    <description>The latest articles on DEV Community by 바람의평온 (@kys7442).</description>
    <link>https://dev.to/kys7442</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3926107%2F670389ee-4bfd-49d3-84bc-6c5a90bb64e6.jpg</url>
      <title>DEV Community: 바람의평온</title>
      <link>https://dev.to/kys7442</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kys7442"/>
    <language>en</language>
    <item>
      <title>Flutter iOS 출시 빌드: 프로비저닝 프로파일 업데이트 실패 해결기</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Sun, 09 Aug 2026 20:48:02 +0000</pubDate>
      <link>https://dev.to/kys7442/flutter-ios-culsi-bildeu-peurobijeoning-peuropail-eobdeiteu-silpae-haegyeolgi-3ofh</link>
      <guid>https://dev.to/kys7442/flutter-ios-culsi-bildeu-peurobijeoning-peuropail-eobdeiteu-silpae-haegyeolgi-3ofh</guid>
      <description>&lt;h2&gt;
  
  
  Flutter iOS 출시 빌드: 프로비저닝 프로파일 업데이트 실패 해결기
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qbzlgprjtmbntbjcsqv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7qbzlgprjtmbntbjcsqv.png" alt="Flutter iOS 출시 빌드: 프로비저닝 프로파일 업데이트 실패 해결기" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;안녕하세요, 코딩아빠입니다. 오랜만에 주말 짬을 내어 지난주 Flutter iOS 앱 출시 빌드 과정에서 겪었던 한 가지 경험을 메모처럼 정리해 봅니다. 이 글은 제가 실제로 부딪히고 해결한 기록으로, 혹시 비슷한 문제를 겪는 분들에게 작은 실마리가 되기를 바랍니다. Flutter로 iOS 앱을 배포하려던 스크립트가 매번 프로비저닝 프로파일 업데이트 문제로 실패하는 상황이었는데, 결론부터 말씀드리면 &lt;code&gt;xcodebuild&lt;/code&gt; 명령에 특정 옵션 하나를 추가해서 해결할 수 있었습니다.&lt;/p&gt;

&lt;p&gt;이런 분께 — Flutter를 이용해 iOS 앱을 개발하고 배포하는 과정에서 서명 및 프로비저닝 관련 빌드 문제로 어려움을 겪는 개발자 · 난이도는 중급 정도&lt;/p&gt;

&lt;h3&gt;
  
  
  출시 빌드 스크립트가 멈춰선 순간
&lt;/h3&gt;

&lt;p&gt;며칠 전, 힘들게 개발한 Flutter iOS 앱을 앱스토어에 배포하기 위해 출시 빌드 스크립트를 실행했습니다. 그런데 평소 잘 되던 빌드가 갑자기 멈춰 서는 겁니다. 터미널에는 프로비저닝 프로파일 업데이트 관련 오류 메시지가 계속해서 쏟아져 나왔습니다. 이전에 잘 되던 것이 갑자기 안 되니 당황스럽더군요. 처음에는 혹시 개발자 계정 문제인가, 아니면 Xcode 설정이 어딘가 꼬였나 하는 막연한 생각만 들었습니다. 급한 마음에 여러 설정을 뒤적여봐도 딱히 문제점을 찾을 수 없었습니다. 가족들 잠든 늦은 밤에 겨우 시간 내서 작업하는데, 이런 예상치 못한 문제에 부딪히면 정말 힘이 빠집니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  막연했던 첫 삽질과 로그 분석
&lt;/h3&gt;

&lt;p&gt;처음에는 Xcode GUI에서 수동으로 프로비저닝 프로파일을 업데이트해보거나, 프로젝트 설정을 다시 확인하는 등 여러 시도를 해봤습니다. 하지만 스크립트로 빌드를 돌릴 때마다 같은 오류가 반복되었죠. 문제가 발생하면 역시 로그를 꼼꼼히 봐야 한다는 걸 다시 한번 느꼈습니다. 수많은 빌드 로그 중에서 '프로비저닝 프로파일 업데이트 실패'와 관련된 구체적인 메시지들을 집중적으로 살펴보았습니다. 대부분 'Xcode가 프로비저닝 프로파일을 자동으로 업데이트할 권한이 없다'는 뉘앙스의 내용이더군요. 이 부분을 보면서 &lt;code&gt;xcodebuild&lt;/code&gt; 명령 자체에 어떤 옵션이 필요하지 않을까 하는 가설을 세워봤습니다.&lt;/p&gt;

&lt;p&gt;.ba-pc{display:none}.ba-mo{display:block}&lt;a class="mentioned-user" href="https://dev.to/media"&gt;@media&lt;/a&gt; (min-width:768px){.ba-pc{display:block}.ba-mo{display:none}}&lt;/p&gt;

&lt;h3&gt;
  
  
  원인은 Xcode 빌드 시스템의 '묵묵부답'
&lt;/h3&gt;

&lt;p&gt;로그를 자세히 들여다보니, Xcode 빌드 시스템이 자동으로 프로비저닝 프로파일을 업데이트하려고 시도하지만, 그 권한이 명시적으로 주어지지 않아 실패하고 있다는 것을 알게 되었습니다. 보통 Xcode GUI 환경에서는 개발자 계정이 로그인되어 있으면 이런 과정이 자동으로 처리되는 경우가 많습니다. 하지만 CI/CD 환경이나 스크립트를 통해 빌드를 진행할 때는 GUI의 '자동' 기능이 제대로 작동하지 않을 수 있습니다. 특히 빌드 시스템이 특정 프로파일을 업데이트하거나 새로 생성해야 할 때, 이러한 명시적인 허용 없이는 작업을 진행하지 못하도록 되어 있는 듯했습니다. 이 부분이 제가 놓치고 있던 핵심이었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  명시적 허용, -allowProvisioningUpdates
&lt;/h3&gt;

&lt;p&gt;해결책은 의외로 간단했습니다. &lt;code&gt;xcodebuild&lt;/code&gt; 명령에 &lt;code&gt;-allowProvisioningUpdates&lt;/code&gt; 옵션을 추가하는 것이었습니다. 이 옵션은 Xcode 빌드 시스템이 프로비저닝 프로파일을 자동으로 업데이트하거나 새로 생성하는 것을 명시적으로 허용해 줍니다. 특히 CI/CD 파이프라인이나 스크립트 기반 빌드 환경에서 이러한 자동화된 처리가 필요할 때 유용하게 사용될 수 있습니다. 이 옵션을 추가함으로써 Xcode가 필요한 프로파일을 개발자 계정 정보를 바탕으로 직접 업데이트할 수 있게 되는 것이죠. 아래는 제가 사용한 명령어입니다:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;xcodebuild -workspace Runner.xcworkspace -scheme Runner -configuration Release -destination 'generic/platform=iOS' archive -allowProvisioningUpdates -archivePath build/Runner.xcarchive
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 명령어는 Flutter 프로젝트의 &lt;code&gt;Runner.xcworkspace&lt;/code&gt;를 아카이브하면서, 프로비저닝 프로파일 업데이트를 허용하도록 지시하는 역할을 합니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  옵션 추가 후 성공적인 빌드와 배움
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;xcodebuild&lt;/code&gt; 명령에 &lt;code&gt;-allowProvisioningUpdates&lt;/code&gt; 옵션을 추가한 후, 다시 iOS 출시 빌드 스크립트를 실행했습니다. 예상대로 더 이상 프로비저닝 프로파일 업데이트 관련 오류 메시지는 나타나지 않았고, 빌드는 성공적으로 완료되었습니다. 아카이브 생성 및 앱스토어 배포까지 순조롭게 진행되는 것을 보고 안도했습니다. 이번 경험을 통해 iOS 앱 배포 과정에서 발생하는 빌드 문제는 종종 Xcode의 서명 및 프로비저닝 설정과 관련이 깊다는 것을 다시 한번 깨달았습니다. 문제 발생 시 빌드 로그를 면밀히 검토하고, &lt;code&gt;xcodebuild&lt;/code&gt; 명령의 다양한 옵션을 확인하여 해결책을 찾는 습관이 중요함을 느꼈네요.&lt;/p&gt;

&lt;h3&gt;
  
  
  이 글에서 다루는 것
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Flutter iOS 출시 빌드 시 발생할 수 있는 프로비저닝 프로파일 업데이트 오류의 원인&lt;/li&gt;
&lt;li&gt;xcodebuild 명령의 &lt;code&gt;-allowProvisioningUpdates&lt;/code&gt; 옵션의 역할과 사용 방법&lt;/li&gt;
&lt;li&gt;iOS 앱 배포 과정에서 빌드 로그를 분석하는 중요성&lt;/li&gt;
&lt;li&gt;자동 프로비저닝 업데이트가 필요한 상황에 대한 이해&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  끝으로
&lt;/h3&gt;

&lt;p&gt;결국 이번 문제는 &lt;code&gt;xcodebuild&lt;/code&gt; 명령에 작은 옵션 하나를 추가하는 것으로 해결되었습니다. iOS 앱 빌드와 배포 과정은 때때로 복잡하고 예측 불가능한 오류를 던져주지만, 로그를 꼼꼼히 살피고 관련 문서를 찾아보는 기본적인 원칙이 문제를 해결하는 가장 빠른 길임을 다시 한번 확인했습니다. 다음에 같은 문제가 발생하면 당황하지 않고 이 옵션을 먼저 확인하게 될 것 같습니다.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>ios</category>
      <category>xcodebuild</category>
      <category>allowprovisioningupdates</category>
    </item>
    <item>
      <title>Gemini와 Claude, 언제 누구를 써야 할까? LLM 동적 라우팅 전략 경험기</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Thu, 06 Aug 2026 20:36:43 +0000</pubDate>
      <link>https://dev.to/kys7442/geminiwa-claude-eonje-nugureul-sseoya-halgga-llm-dongjeog-rauting-jeonryag-gyeongheomgi-3fhn</link>
      <guid>https://dev.to/kys7442/geminiwa-claude-eonje-nugureul-sseoya-halgga-llm-dongjeog-rauting-jeonryag-gyeongheomgi-3fhn</guid>
      <description>&lt;h2&gt;
  
  
  Gemini와 Claude, 언제 누구를 써야 할까? LLM 동적 라우팅 전략 경험기
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0a1lw1zpunvdswwirlu7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0a1lw1zpunvdswwirlu7.png" alt="Gemini와 Claude, 언제 누구를 써야 할까? LLM 동적 라우팅 전략 경험기" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;안녕하세요, 코딩아빠입니다. 오늘도 아들 재워놓고 제가 실제로 부딪히고 해결했던 개발 경험을 정리한 노트를 공유해 드립니다. 여러 LLM 모델을 서비스에 연동해 사용해 보신 분들이라면 아마 저와 비슷한 고민을 해보셨을 것 같습니다. 처음에는 하나의 모델로 모든 작업을 처리하거나, 필요할 때마다 수동으로 모델을 변경하는 방식으로 운영했었죠. 하지만 Gemini나 Claude 같은 다양한 LLM 모델이 등장하면서, 각자의 강점과 약점, 그리고 무엇보다 비용 구조가 제각각이라는 점이 서비스 운영에 큰 변수로 작용했습니다. 특정 작업에 부적합한 모델을 사용하면 불필요한 비용이 발생하거나, 기대했던 응답 품질을 얻기 어려워지는 문제가 발생하더군요.&lt;/p&gt;

&lt;h3&gt;
  
  
  이 글에서 짚는 것
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;다수의 LLM 모델을 통합할 때 발생하는 문제점과 비효율성을 이해합니다.&lt;/li&gt;
&lt;li&gt;각 LLM 모델의 특성과 비용 구조를 파악하는 중요성을 깨닫습니다.&lt;/li&gt;
&lt;li&gt;작업 유형에 따라 최적의 LLM을 동적으로 선택하는 라우팅 전략을 수립하는 방법을 배웁니다.&lt;/li&gt;
&lt;li&gt;파이썬 예시 코드를 통해 LLM 동적 라우팅 로직 구현의 기본을 익힙니다.&lt;/li&gt;
&lt;li&gt;구현된 라우팅 전략의 효과를 검증하고 지속적으로 개선하는 접근 방식을 알아봅니다.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  여러 LLM 모델을 한 서비스에서 활용할 때의 딜레마
&lt;/h3&gt;

&lt;p&gt;초기에는 서비스에서 필요한 LLM 기능을 구현할 때, 하나의 모델을 주로 사용하거나 필요에 따라 수동으로 변경하는 방식을 택했습니다. 예를 들어, 처음에는 주로 Gemini Pro 모델을 사용하다가, 특정 작업에서 Claude가 더 좋은 성능을 보인다는 소식을 들으면 해당 기능만 Claude로 변경하는 식이었죠. 문제는 이런 방식이 확장성과 효율성 면에서 한계를 보인다는 점이었습니다. 모든 작업에 최적화된 '만능 LLM'은 사실상 존재하지 않기 때문에, 어떤 모델은 짧은 질문 응답에 빠르고 저렴하지만 긴 문서 요약에는 비효율적일 수 있고, 또 다른 모델은 창의적인 글쓰기에는 탁월하지만 코딩 보조에는 아쉬운 성능을 보일 수 있었습니다. 결국, 매번 수동으로 모델을 선택하는 것은 개발 및 운영 비용을 증가시키는 요인이 되었고, 잘못된 선택은 서비스의 응답 품질 저하로 이어지기도 했습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  각 LLM의 고유한 강점과 비용 구조 파악의 중요성
&lt;/h3&gt;

&lt;p&gt;LLM 동적 라우팅 전략의 핵심은 각 모델의 특성과 비용 구조를 정확히 이해하는 데 있습니다. 단순히 '더 좋은' 모델이나 '더 싼' 모델을 찾는 것이 아니라, 특정 작업의 요구사항과 모델의 강점을 일치시키는 것이 중요하다고 판단했습니다. 예를 들어, Gemini는 복잡한 추론이나 코딩 관련 작업에 강점을 보이며 빠른 응답 속도를 자랑합니다. 반면 Claude는 긴 컨텍스트 처리 능력과 섬세한 대화에 유리한 특성을 가지고 있죠. 이처럼 모델별 강점 외에도, 각 모델의 토큰당 비용도 천차만별입니다. 저렴한 모델은 대량의 단순 작업에 적합하고, 고성능 모델은 비용에 민감하지 않은 핵심 기능에 활용하는 것이 바람직합니다. 저희 팀은 아래와 같이 각 모델의 대략적인 비용과 품질 점수를 내부적으로 정의하여, 어떤 모델이 어떤 작업에 적합할지 판단하는 기준으로 삼았습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MODEL_CONFIG = {
    'claude-3-opus': {'cost_per_token': 0.000015, 'quality_score': 9.5},
    'claude-3-sonnet': {'cost_per_token': 0.000003, 'quality_score': 8.0},
    'gemini-1.5-pro': {'cost_per_token': 0.0000035, 'quality_score': 9.0},
    'gemini-1.5-flash': {'cost_per_token': 0.00000035, 'quality_score': 7.5}
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 설정은 모델 선택의 근거가 되었고, 나중에 라우팅 로직을 만들 때 중요한 참고 자료가 되었습니다.&lt;/p&gt;

&lt;p&gt;.ba-pc{display:none}.ba-mo{display:block}&lt;a class="mentioned-user" href="https://dev.to/media"&gt;@media&lt;/a&gt; (min-width:768px){.ba-pc{display:block}.ba-mo{display:none}}&lt;/p&gt;

&lt;h3&gt;
  
  
  작업 특성에 따른 LLM 동적 라우팅 전략 수립
&lt;/h3&gt;

&lt;p&gt;각 LLM 모델의 특성을 파악한 후, 저희는 작업의 특성이나 서비스의 메뉴 유형에 따라 사용할 LLM 모델을 동적으로 선택하는 라우팅 전략을 수립하기로 결정했습니다. 이는 마치 택배 회사가 물건의 크기나 배송 거리에 따라 적합한 차량을 배정하는 것과 유사합니다. 비용에 민감하지 않고 고품질의 창의적인 응답이 필요한 작업에는 최상위 모델을, 대량 처리와 비용 절감이 중요한 작업에는 가성비 모델을 사용하도록 규칙을 정의한 것이죠. 이 전략의 핵심 목표는 두 가지였습니다. 첫째, 각 LLM의 강점을 최대한 활용하여 서비스의 전반적인 품질을 높이는 것. 둘째, 불필요한 비용 지출을 최소화하여 운영 효율성을 극대화하는 것이었습니다. 우리는 단순히 고정된 모델을 사용하는 것보다, 이런 동적인 접근 방식이 장기적으로 훨씬 유리할 것이라고 판단했네요.&lt;/p&gt;

&lt;h3&gt;
  
  
  파이썬으로 구현한 LLM 라우팅 로직
&lt;/h3&gt;

&lt;p&gt;실제로 이 라우팅 전략을 백엔드 애플리케이션에 적용하기 위해 간단한 파이썬 함수를 구현했습니다. 이 함수는 들어오는 요청의 &lt;code&gt;task_type&lt;/code&gt;과 &lt;code&gt;user_query&lt;/code&gt; 길이를 기반으로 가장 적합한 LLM 모델을 선택하도록 설계되었습니다. 예를 들어, '창의적인 글쓰기'와 같은 고품질이 요구되는 작업에는 Claude-3 Opus를, 길이가 긴 '요약' 작업에는 긴 컨텍스트 처리에 유리한 Claude-3 Sonnet을, 그리고 '코딩 도움'과 같이 특정 도메인 지식이 요구되는 작업에는 Gemini-1.5 Flash를 할당하는 식입니다. 나머지 일반적인 프롬프트는 Gemini-1.5 Pro를 기본으로 사용하도록 했습니다. 아래는 그 구현 예시입니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def route_llm(task_type: str, user_query: str):
    if task_type == 'creative_writing':
        return 'claude-3-opus'
    elif task_type == 'summarization' and len(user_query) &amp;gt; 5000:
        return 'claude-3-sonnet'
    elif task_type == 'coding_help':
        return 'gemini-1.5-flash'
    else:
        return 'gemini-1.5-pro'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 코드는 실제 서비스 환경에서 사용될 때는 훨씬 더 복잡한 조건과 예외 처리가 추가되겠지만, 핵심 로직은 이와 크게 다르지 않습니다. 작업 유형과 쿼리 특성에 따라 모델을 분기하는 것이 핵심이죠.&lt;/p&gt;

&lt;h3&gt;
  
  
  동적 라우팅 전략의 효과 검증 및 지속적인 개선
&lt;/h3&gt;

&lt;p&gt;라우팅 로직을 구현한 후에는 이 전략이 의도대로 작동하는지 검증하는 과정이 필수적이었습니다. 저희는 LLM API 호출 로그를 면밀히 모니터링하며, 특정 작업 유형에 대해 의도된 모델이 정확히 호출되는지 확인했습니다. 예를 들어, 'creative_writing' 요청이 들어왔을 때 실제로 'claude-3-opus'가 호출되는지, 5000자 이상의 긴 요약 요청에 'claude-3-sonnet'이 사용되는지 등을 지속적으로 확인한 것이죠. 단순히 호출 모델만 확인하는 것을 넘어, 각 모델의 응답 품질과 실제 처리 비용을 측정하여 라우팅 전략이 목표한 비용 효율성 및 품질 기준을 충족하는지 주기적으로 평가했습니다. 이 과정에서 예상치 못한 문제가 발견되면 라우팅 규칙을 수정하거나, 때로는 새로운 LLM 모델을 추가하는 등 전략을 유연하게 개선해 나갔습니다. 이처럼 동적 라우팅은 한 번 구현으로 끝나는 것이 아니라, LLM 시장의 변화와 서비스 요구사항에 맞춰 계속해서 진화해야 하는 부분이더군요.&lt;/p&gt;

&lt;h3&gt;
  
  
  더 나은 LLM 라우팅을 위한 고민들
&lt;/h3&gt;

&lt;p&gt;저희가 현재 구현한 동적 라우팅 전략은 비교적 단순한 규칙 기반입니다. 하지만 실제 운영 환경에서는 더 복잡하고 정교한 전략이 필요할 수 있다는 것을 깨달았습니다. 예를 들어, A/B 테스트를 통해 여러 라우팅 규칙의 성능을 비교하거나, 실시간으로 각 LLM의 응답 시간이나 성공률을 모니터링하여 문제가 발생한 모델은 자동으로 라우팅 대상에서 제외하는 기능도 고려해 볼 수 있습니다. 또한, 사용자 피드백을 수집하여 특정 작업에서 어떤 모델이 더 만족스러운 응답을 주는지 데이터를 기반으로 라우팅 가중치를 조절하는 방법도 있겠네요. 단순히 비용 효율성이나 품질 향상을 넘어, 서비스의 안정성과 사용자 경험 전반을 고려하는 방향으로 라우팅 전략을 고도화하는 것이 앞으로의 과제라고 생각합니다. 이처럼 LLM 동적 라우팅은 단순히 코드를 구현하는 것을 넘어, 서비스의 성장과 함께 끊임없이 고민하고 발전시켜야 할 중요한 영역인 것 같습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  남는 이야기
&lt;/h3&gt;

&lt;p&gt;다양한 LLM 모델을 활용하는 서비스에서 동적 라우팅 전략은 비용 효율성과 응답 품질을 동시에 잡을 수 있는 효과적인 방법이라는 것을 직접 경험했습니다. 각 모델의 특성과 비용을 면밀히 분석하고, 작업의 중요도에 맞춰 최적의 모델을 배정하는 지혜가 필요하다는 것을 다시 한번 깨달았네요. 이 글이 LLM 기반 서비스를 개발하고 운영하시는 다른 분들께 작은 도움이 되었으면 합니다. 다음에 또 다른 경험으로 찾아뵙겠습니다.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>gemini</category>
      <category>claude</category>
      <category>llmapi</category>
    </item>
    <item>
      <title>Petit Nube Silicone Teether: Bear &amp; Crab Wrist Teether Review</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Mon, 03 Aug 2026 20:49:35 +0000</pubDate>
      <link>https://dev.to/kys7442/petit-nube-silicone-teether-bear-crab-wrist-teether-review-2037</link>
      <guid>https://dev.to/kys7442/petit-nube-silicone-teether-bear-crab-wrist-teether-review-2037</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzsejgvcslqpxri43gjnl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzsejgvcslqpxri43gjnl.png" alt="1+1 (한정수량 300개) 국내생산 쁘띠누베 실리콘 곰 꽃게 손목 치발기 아기치발기 동물치발기, 단품, 베이비코랄, 2개" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These days, when I look at young parents' social media, there are so many&lt;/p&gt;

&lt;p&gt;baby products that look great, haha.&lt;/p&gt;

&lt;p&gt;But when I looked into them a bit later, some things appeared different based on my specific needs. Especially as my baby started sucking their hands frequently, I began looking for teethers and came across the 'Petit Nube Silicone Bear Crab Wrist Teether'. I've organized information around a few criteria to determine if it's the right teether for my baby.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrist-type design: When and for which babies is it good?
&lt;/h3&gt;

&lt;p&gt;The Petit Nube silicone teether has a wrist-worn design... If your baby's grip is still weak or they frequently drop objects, a wrist-type teether can be a good choice. The advantage is that the baby can wear it on their wrist and play freely, so parents don't have to constantly hold it for them. If your baby is very active or curious, they might enjoy playing with it on their own. However, if your baby is already accustomed to grasping objects or prefers various types of stimulation, it's good to consider other teether shapes as well. Wrist-type teethers can only stimulate specific areas, so combining them with other teethers for overall oral development is also an option.&lt;/p&gt;

&lt;h3&gt;
  
  
  Domestically produced food-grade silicone: Can we use it with peace of mind?
&lt;/h3&gt;

&lt;p&gt;Since this product goes directly into the baby's mouth, material safety is one of the most important criteria when choosing a teether. The Petit Nube teether is said to use domestically produced food-grade silicone.&lt;/p&gt;

&lt;p&gt;This seems to be a crucial factor that allows parents to give it to their baby with confidence, as food-grade silicone is made to be free from harmful substances. Another big advantage is that it can be sterilized with boiling water or steam, making hygiene management convenient. Since it's a product that babies chew and suck on daily, the ease of sterilization can be a great help for busy parents. If you are particularly concerned about environmental hormones or other harmful substances, it's advisable to carefully check these safety specifications.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzsejgvcslqpxri43gjnl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzsejgvcslqpxri43gjnl.png" alt="1+1 (한정수량 300개) 국내생산 쁘띠누베 실리콘 곰 꽃게 손목 치발기 아기치발기 동물치발기, 단품, 베이비코랄, 2개" width="800" height="800"&gt;&lt;/a&gt;Actual product image&lt;/p&gt;

&lt;h3&gt;
  
  
  Bear and crab shapes with various textures: Are they effective in engaging babies?
&lt;/h3&gt;

&lt;p&gt;This teether is designed in bear and crab shapes, which can visually engage babies. Cute animal shapes stimulate a baby's curiosity and can make playtime with the teether more enjoyable. The soft material and various sized nubs provide gentle stimulation to the baby's gums, helping to relieve gum itchiness and aid oral development. This can help alleviate the discomfort babies feel when their teeth start to emerge. If your baby is sensitive to certain textures or shapes, this product with its soft silicone material and multiple nubs might be a good fit.&lt;/p&gt;

&lt;p&gt;Conversely, if your baby prefers simpler shapes or firmer materials, it's a good idea to look at other types of teethers as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  1+1 configuration and price: Is it a reasonable choice?
&lt;/h3&gt;

&lt;p&gt;The Petit Nube Silicone Bear Crab Wrist Teether is sold in a 1+1 limited quantity configuration for 12,500 won. Teethers often need to be replaced periodically for hygiene reasons, or babies may use several interchangeably.&lt;/p&gt;

&lt;p&gt;Therefore, the 1+1 configuration can be an attractive choice in terms of cost-effectiveness. You get two for the price of one, so you can use one at home and take the other for outings, or have a spare in case one gets lost haha. Parents with a set budget for teethers or those looking to buy multiple at once should consider this price point and configuration. However, it is not a Rocket Delivery product, so it's necessary to order in advance, considering the delivery time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/shorts/5xHigDj86BI" rel="noopener noreferrer"&gt;▶ Watch on Shorts&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;About the Author&lt;/strong&gt;&lt;br&gt;
I'm a dad raising two kids, and I personally test and choose the products we need.&lt;br&gt;
My reviews are honest, based on my actual experience, and prioritize 'would I buy this again?' over advertising fees.&lt;/p&gt;

</description>
      <category>babyproducts</category>
      <category>teetherreview</category>
      <category>parentingtips</category>
      <category>productreview</category>
    </item>
    <item>
      <title>LLM API 무료 티어 한도 초과? 자동 폴백 전략으로 서비스 중단 막기</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Mon, 03 Aug 2026 01:31:51 +0000</pubDate>
      <link>https://dev.to/kys7442/llm-api-muryo-tieo-hando-cogwa-jadong-polbaeg-jeonryageuro-seobiseu-jungdan-maggi-4341</link>
      <guid>https://dev.to/kys7442/llm-api-muryo-tieo-hando-cogwa-jadong-polbaeg-jeonryageuro-seobiseu-jungdan-maggi-4341</guid>
      <description>&lt;h2&gt;
  
  
  LLM API 무료 티어 한도 초과? 자동 폴백 전략으로 서비스 중단 막기
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2860rljhfcyza8qnv7fg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2860rljhfcyza8qnv7fg.png" alt="LLM API 무료 티어 한도 초과? 자동 폴백 전략으로 서비스 중단 막기" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;코딩아빠 개발 노트에 오신 걸 환영합니다. 주말에 짬 내어 제가 실제로 부딪히고 해결한 경험을 정리해 봅니다. 얼마 전 저희 LLM 기반 서비스에서 예상치 못한 중단 사태가 발생했습니다. 사용자들은 갑자기 응답이 오지 않는다고 불평했고, 운영팀은 문의 폭탄에 시달렸습니다. 급하게 로그를 살펴보니, 특정 LLM API에서 429 에러가 계속해서 터져 나오고 있더군요. 무료 티어를 사용하고 있었는데, 일일 호출 한도에 걸린 것이었습니다. 처음에는 단순한 API 장애인 줄 알았지만, 문제를 깊게 파고들면서 무료 티어의 편리함 뒤에 숨겨진 함정을 깨달았고, 이에 대한 견고한 방어 전략이 필요하다는 결론에 도달했습니다. 이번 글은 그 과정을 담은 기록입니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  이번에 정리한 내용
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;LLM API 무료 티어 한도 초과 시 발생하는 문제점&lt;/li&gt;
&lt;li&gt;API Rate Limit (429 에러) 및 기타 API 오류의 원인&lt;/li&gt;
&lt;li&gt;여러 LLM API 키와 모델을 활용한 폴백(Fallback) 전략 구현 방법&lt;/li&gt;
&lt;li&gt;Python 예시 코드를 통해 자동 폴백 로직 적용하기&lt;/li&gt;
&lt;li&gt;서비스 안정성 및 비용 효율성을 동시에 확보하는 운영 노하우&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  예상치 못한 서비스 중단, 그 시작
&lt;/h3&gt;

&lt;p&gt;어느 날 아침, 알림이 폭주하기 시작했습니다. 사용자들은 '응답이 안 와요', '서비스가 멈췄어요' 같은 메시지를 쏟아냈습니다. 급하게 상황판을 확인해보니, LLM API 호출 관련 지표가 곤두박질치고 있었더군요. 백엔드 애플리케이션 로그를 열어보니, 수많은 HTTP 429 에러가 연달아 찍히고 있었습니다. 처음에는 LLM 제공자 측의 일시적인 문제인 줄 알았습니다. 재배포나 서버 재시작으로 해결될까 싶어 시도했지만, 문제는 지속되었습니다. 서비스가 멈추자마자 CS팀은 아수라장이 되었고, 저도 마음이 급해지기 시작했습니다. 단순한 버그가 아니라, 서비스의 근간을 흔드는 문제라는 직감이 들었습니다.&lt;/p&gt;

&lt;p&gt;이런 상황은 개발자로서 가장 당황스러운 순간 중 하나입니다. 코드를 배포하고 나서 발생하는 예상치 못한 장애는 늘 긴장감을 주지만, 외부 API 의존성에서 오는 문제는 통제하기 어렵다는 점에서 더욱 까다롭습니다. 특히 LLM API는 단순한 데이터 요청을 넘어 서비스의 핵심 로직에 깊이 연결되어 있었기에, 그 여파는 더욱 컸습니다. 결국 급하게 임시방편으로 호출량을 줄이는 작업을 진행하며 원인 파악에 집중했습니다. 고객 경험이 저하되는 것을 보면서, 이런 상황을 미리 대비하지 못한 것에 대한 아쉬움이 컸습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  무료 티어의 양날의 검, 원인 분석
&lt;/h3&gt;

&lt;p&gt;로그에 찍힌 429 에러 메시지를 자세히 보니, 'Quota exceeded for quota metric 'Queries' and limit 'Queries per minute' of service...'라는 내용이 명확하게 보였습니다. 저희가 사용하던 LLM 서비스의 무료 티어 한도에 도달했던 것입니다. 평소에는 트래픽이 많지 않아 문제가 없었는데, 특정 이벤트로 인해 일시적으로 사용자가 몰리면서 일일 또는 시간당 호출 한도를 초과해 버린 것이죠. 무료 티어는 개발 초기나 소규모 서비스에는 분명 큰 도움이 됩니다. 하지만 이렇게 갑작스러운 트래픽 증가나 예상치 못한 사용 패턴 변화에는 취약하다는 것을 뼈저리게 느꼈습니다.&lt;/p&gt;

&lt;p&gt;또한, 특정 모델의 API가 불안정하거나 간헐적으로 오류를 반환하는 경우도 있었습니다. 이는 Rate Limit과는 별개의 문제로, 네트워크 지연이나 LLM 서버 내부 문제로 추정되었습니다. 이런 상황에서 단순한 재시도 로직만으로는 충분하지 않다는 것을 깨달았죠. 하나의 키나 하나의 모델에만 의존하는 것은 결국 서비스 안정성을 담보할 수 없다는 의미였습니다. 아래는 당시 Gemini API에서 확인했던 Rate Limit 응답의 예시입니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "error": {
    "code": 429,
    "message": "Quota exceeded for quota metric 'Queries' and limit 'Queries per minute' of service 'generativelanguage.googleapis.com' for consumer 'projects/PROJECT_NUMBER'.",
    "status": "RESOURCE_EXHAUSTED"
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이런 명확한 메시지는 문제 해결의 실마리가 되었지만, 동시에 저희 서비스가 얼마나 취약했는지 보여주는 증거이기도 했습니다. 원인 파악 후에는 근본적인 해결책을 마련해야 한다는 압박감이 들었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  급한 불 끄기: 다중 키 전략 구상
&lt;/h3&gt;

&lt;p&gt;원인 분석 후, 가장 먼저 떠올린 해결책은 '무료 키가 막히면 유료 키를 쓰자'는 단순한 아이디어였습니다. 하지만 단순히 유료 키를 등록하는 것을 넘어, 어떤 상황에서 어떻게 전환할지 구체적인 전략이 필요했습니다. 첫째, 비용 효율성을 고려해 무료 키를 최우선으로 사용해야 했습니다. 둘째, 무료 키가 한도에 도달하거나 API 오류가 발생하면 자동으로 유료 키로 전환되어야 했습니다. 셋째, 만약 특정 LLM 제공자의 API 자체가 불안정하다면, 다른 LLM 제공자의 모델로도 전환할 수 있는 유연성을 확보하는 것이 좋겠다고 생각했습니다. 이를 위해 여러 API 키를 애플리케이션 레벨에서 관리하는 로직을 구축하기로 했습니다.&lt;/p&gt;

&lt;p&gt;이 전략의 핵심은 '우선순위'와 '상태 관리'입니다. 각 키에 대한 사용 우선순위를 정하고, 현재 키의 상태(예: 한도 초과, 오류 발생)를 추적해야 했습니다. 예를 들어, 무료 키가 429 에러를 반환하면 해당 키는 당분간 사용 불가능한 상태로 표시하고, 다음 우선순위인 유료 키를 사용하도록 하는 방식입니다. 이 과정에서 재시도(Retry) 로직도 함께 고려해야 했습니다. 단순히 한 번 실패했다고 바로 다른 키로 넘어가는 것이 아니라, 짧은 백오프(Backoff) 후 몇 차례 재시도해본 뒤에도 실패하면 다음 키로 전환하는 것이 더 안정적이라고 판단했습니다. 이렇게 하면 일시적인 네트워크 문제나 API 지연에도 더 잘 대응할 수 있습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  견고한 폴백 로직 구현하기
&lt;/h3&gt;

&lt;p&gt;본격적인 폴백 로직 구현에 들어갔습니다. Python 환경에서 OpenAI API를 예시로 들자면, 여러 API 키를 리스트 형태로 관리하고, 순회하면서 API 호출을 시도하는 방식으로 구현할 수 있습니다. 각 호출에서 &lt;code&gt;RateLimitError&lt;/code&gt;나 기타 &lt;code&gt;OpenAIError&lt;/code&gt;가 발생하면, 현재 키를 건너뛰고 다음 키로 넘어가는 구조입니다. 이 과정에서 환경 변수를 활용하여 실제 키를 관리하는 것이 보안상 좋겠다고 생각했습니다. 아래는 제가 구현했던 로직의 핵심 아이디어를 담은 Python 코드입니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import os
import openai

def call_llm_with_fallback(prompt, free_key=os.getenv('FREE_LLM_KEY'), paid_key=os.getenv('PAID_LLM_KEY')):
    keys = [free_key, paid_key]
    for key in keys:
        if not key: continue
        try:
            openai.api_key = key
            response = openai.Completion.create(model="gpt-3.5-turbo-instruct", prompt=prompt)
            return response.choices[0].text.strip()
        except openai.error.RateLimitError:
            print(f"Rate limit hit with key: {key}. Trying next key...")
            continue
        except openai.error.OpenAIError as e:
            print(f"API error with key {key}: {e}. Trying next key...")
            continue
    raise Exception("All LLM keys failed.")
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 코드에서는 &lt;code&gt;free_key&lt;/code&gt;와 &lt;code&gt;paid_key&lt;/code&gt;를 순차적으로 시도합니다. &lt;code&gt;RateLimitError&lt;/code&gt;가 발생하면 다음 키로 넘어가는 것이 핵심이죠. 실제 운영 환경에서는 단순히 다음 키로 넘어가는 것을 넘어, 실패한 키의 상태를 일정 시간 동안 '사용 불가'로 표시하고, 재시도 간격에 지수 백오프(Exponential Backoff)를 적용하는 등의 정교한 로직이 필요합니다. 예를 들어, 특정 키가 429 에러를 반환하면 5분간 해당 키를 사용하지 않도록 캐시하거나, 실패 횟수에 따라 재시도 간격을 점진적으로 늘리는 방식입니다. 다음은 키 관리의 개념적인 의사 코드입니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Conceptual pseudo-code for key management
function get_next_available_llm_key(current_key_status):
    if current_key_status.free_key_limit_exceeded:
        return 'PAID_LLM_KEY'
    if current_key_status.model_a_error_rate &amp;gt; threshold:
        return 'MODEL_B_KEY'
    return 'FREE_LLM_KEY'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이처럼 &lt;code&gt;get_next_available_llm_key&lt;/code&gt; 함수는 현재 키들의 상태를 기반으로 가장 적합한 다음 키를 반환하도록 설계합니다. 이는 단순한 순환을 넘어, 각 키의 건강 상태를 모니터링하고 동적으로 우선순위를 조정하는 복잡한 로직을 포함할 수 있습니다. 이렇게 하면 서비스 중단 시간을 최소화하고, 안정적인 운영을 지속할 수 있습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  실제 환경에서의 검증 과정
&lt;/h3&gt;

&lt;p&gt;폴백 로직을 구현한 후, 실제 상황에서 제대로 동작하는지 검증하는 것이 중요했습니다. 개발 환경에서 테스트하는 것만으로는 부족하다고 생각했죠. 가장 확실한 방법은 의도적으로 문제를 발생시켜 폴백이 트리거되는지 확인하는 것이었습니다. 우선, 무료 티어의 API 키를 일시적으로 무효화해 보았습니다. 예상대로 API 호출이 실패했고, 로그에는 'API error with key [무료 키]: Invalid API key' 같은 메시지와 함께 다음 유료 키로 전환을 시도하는 로그가 찍혔습니다. 유료 키를 사용한 호출은 성공적으로 완료되었고, 서비스는 정상적으로 응답했습니다.&lt;/p&gt;

&lt;p&gt;다음으로는 Rate Limit을 재현하는 테스트를 진행했습니다. 짧은 시간 내에 무료 티어의 한도를 초과하도록 대량의 LLM 호출을 발생시켰습니다. 처음에는 무료 키로 호출이 잘 되다가, 곧이어 429 에러가 발생하기 시작했습니다. 그리고 로그에는 'Rate limit hit with key [무료 키]. Trying next key...'라는 메시지가 명확히 보이며, 유료 키로 전환되어 정상적으로 응답을 받아오는 것을 확인할 수 있었습니다. 이 과정을 통해 저희가 구현한 폴백 전략이 예상대로 동작하며, 서비스 중단 없이 안정적으로 API를 활용할 수 있음을 검증했습니다. 실제 운영 환경과 유사한 조건에서 테스트함으로써, 잠재적인 문제점들을 미리 발견하고 보완할 수 있었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  안정성과 비용 효율, 두 마리 토끼 잡기
&lt;/h3&gt;

&lt;p&gt;이번 경험을 통해 LLM API 폴백 전략이 단순히 장애 대응을 넘어, 서비스 운영의 핵심적인 설계 원칙임을 깨달았습니다. 첫째, 서비스 안정성 측면에서 LLM 제공자의 일시적인 장애나 Rate Limit에 상관없이 사용자에게 지속적인 서비스를 제공할 수 있게 되었습니다. 이는 사용자 경험을 크게 개선하고, 운영팀의 부담을 줄이는 데 결정적인 역할을 했습니다. 둘째, 비용 효율성 측면에서도 큰 이점이 있습니다. 평소에는 무료 티어를 최대한 활용하고, 오직 무료 티어 한도 초과나 장애 발생 시에만 유료 키나 고비용 모델로 전환함으로써 전체 API 호출 비용을 절감할 수 있었습니다.&lt;/p&gt;

&lt;p&gt;물론 이 전략이 만능은 아닙니다. 여러 LLM 제공자를 사용하는 경우, 각 모델의 응답 품질이나 지연 시간, 비용 등을 지속적으로 모니터링하고 최적화하는 노력이 필요합니다. 또한, 키 관리 시스템 자체의 안정성과 보안에도 신경 써야 합니다. 하지만 이 정도의 노력으로 얻을 수 있는 서비스 안정성과 비용 절감 효과는 충분히 가치 있다고 생각합니다. 앞으로 LLM 기반 서비스를 개발할 때는 이 폴백 전략을 기본 설계에 포함하여, 더욱 견고하고 효율적인 서비스를 만들어나갈 계획입니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  마치며
&lt;/h3&gt;

&lt;p&gt;LLM API 호출에서 발생할 수 있는 무료 티어 한도 초과나 일시적 오류는 서비스 안정성을 위협하는 큰 요인이었습니다. 하지만 이번에 구축한 자동 폴백 전략 덕분에 이런 위험을 효과적으로 관리할 수 있게 되었습니다. 여러 API 키를 우선순위에 따라 사용하고, 에러 발생 시 자동으로 전환하는 로직은 서비스 중단을 막는 든든한 방패가 되어주었습니다. 앞으로도 LLM 기반 서비스를 안정적으로 운영하기 위한 다양한 전략들을 지속적으로 고민해볼 생각입니다.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>api</category>
      <category>fallback</category>
      <category>ratelimit</category>
    </item>
    <item>
      <title>FCM HTTP v1 푸시 알림, 서버와 Flutter 앱에서 처음부터 구현하기</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Tue, 28 Jul 2026 08:52:52 +0000</pubDate>
      <link>https://dev.to/kys7442/fcm-http-v1-pusi-alrim-seobeowa-flutter-aebeseo-ceoeumbuteo-guhyeonhagi-32ff</link>
      <guid>https://dev.to/kys7442/fcm-http-v1-pusi-alrim-seobeowa-flutter-aebeseo-ceoeumbuteo-guhyeonhagi-32ff</guid>
      <description>&lt;h2&gt;
  
  
  FCM HTTP v1 푸시 알림, 서버와 Flutter 앱에서 처음부터 구현하기
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9vrop4v4y2njj2rb13k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ft9vrop4v4y2njj2rb13k.png" alt="FCM HTTP v1 푸시 알림, 서버와 Flutter 앱에서 처음부터 구현하기" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;안녕하세요, 코딩아빠입니다. 오늘 제가 들려드릴 이야기는, 최근에 직접 부딪히고 해결했던 경험을 고스란히 담아낸 기술 노트입니다. 기존 푸시 알림 시스템이 오래된 FCM Legacy API를 사용하고 있었는데, 이 방식이 앞으로는 권장되지 않거나 언제든 지원이 중단될 수 있다는 소식에 마음이 편치 않았습니다. 그래서 고심 끝에, 최신 FCM HTTP v1 API를 이용해 서버부터 Flutter 앱까지 푸시 알림 시스템 전체를 새롭게 구축하기로 결정했습니다. 아무것도 없는 백지상태에서 시작해야 했기에, 그 과정에서 꽤 많은 시행착오를 겪었지만, 덕분에 많은 것을 배울 수 있었네요. 이 글이 저와 비슷한 고민을 하고 계신 분들께 작은 길잡이가 되었으면 합니다.&lt;/p&gt;

&lt;p&gt;이런 분께 — PHP 서버와 Flutter 앱으로 FCM HTTP v1 푸시 알림 시스템을 처음부터 구축하려는 개발자. · 난이도는 중급 정도&lt;/p&gt;

&lt;h3&gt;
  
  
  글의 요점
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;FCM HTTP v1 API를 사용하는 이유와 이점&lt;/li&gt;
&lt;li&gt;PHP 서버에서 Firebase 서비스 계정을 이용한 인증 및 메시지 발송 방법&lt;/li&gt;
&lt;li&gt;Flutter 앱에서 FCM 토큰 관리 및 포그라운드/백그라운드 메시지 수신 처리&lt;/li&gt;
&lt;li&gt;푸시 알림 시스템 구축 시 필요한 서버, 앱, DB, 관리자 UI 연동 과정&lt;/li&gt;
&lt;li&gt;실제 시스템 구축 중 발생할 수 있는 주요 문제점과 해결 과정&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  오래된 방식 대신 새로운 길을 택하며 겪은 고민
&lt;/h3&gt;

&lt;p&gt;새로운 푸시 알림 시스템을 구축해야 한다는 과제를 받았을 때, 가장 먼저 마주한 것은 어떤 API를 사용할 것인가 하는 문제였습니다. 기존에 사용하던 FCM Legacy API는 분명 익숙했지만, Firebase 공식 문서에서는 이미 HTTP v1 API로의 전환을 강력히 권장하고 있었죠. Legacy API가 언제든 지원이 중단될 수 있다는 경고 문구가 계속 마음에 걸렸습니다. 당장은 큰 문제가 없어도, 몇 년 후에는 시스템을 다시 뜯어고쳐야 할 수도 있다는 생각이 들더군요. 그래서 눈앞의 편의보다는 장기적인 안정성과 확장성을 택해, 다소 생소했지만 최신 HTTP v1 API를 처음부터 적용하기로 마음먹었습니다.&lt;/p&gt;

&lt;p&gt;이 결정은 새로운 학습 곡선을 의미했습니다. Legacy API는 간단한 키 기반 인증으로 메시지를 보낼 수 있었지만, v1 API는 OAuth 2.0 기반의 인증 절차를 요구했으니까요. 단순히 메시지 페이로드만 바꾸는 문제가 아니라, 서버 측에서 인증 토큰을 관리하고 갱신하는 로직까지 새로 만들어야 한다는 부담이 있었습니다. 또한, Flutter 앱에서도 기존 FCM 연동 방식을 최신 &lt;code&gt;firebase_messaging&lt;/code&gt; 패키지에 맞춰 재정비해야 했습니다. 말 그대로 바닥부터 시작하는 셈이었지만, 한 번 제대로 구축해두면 오랫동안 안정적으로 사용할 수 있을 것이라는 기대로 차근차근 준비를 시작했습니다.&lt;/p&gt;

&lt;p&gt;가장 먼저 고려한 것은 서버 측에서 Firebase 서비스 계정을 어떻게 활용할 것인가였습니다. 서비스 계정 JSON 파일을 PHP 서버에서 안전하게 관리하고, 이를 이용해 Google OAuth 2.0 액세스 토큰을 발급받는 과정이 핵심이었습니다. 처음에는 PHP에서 직접 JWT를 구성하여 액세스 토큰을 요청하는 방법을 생각했는데, 이는 생각보다 복잡하고 오류 가능성이 높아 보였습니다. 다행히 Google API 클라이언트 라이브러리가 PHP용으로 잘 준비되어 있어, 이를 활용하기로 방향을 잡았습니다. 이 라이브러리를 사용하면 복잡한 인증 절차를 비교적 쉽게 처리할 수 있겠더군요.&lt;/p&gt;

&lt;h3&gt;
  
  
  서버에서 FCM HTTP v1 API와 첫 대면하기: 인증의 벽
&lt;/h3&gt;

&lt;p&gt;PHP 서버에서 FCM HTTP v1 API를 사용하기 위한 첫 관문은 바로 인증이었습니다. FCM 메시지 발송은 Firebase 프로젝트에 대한 권한이 필요하며, 이를 위해 OAuth 2.0 액세스 토큰을 받아야 했습니다. Firebase 서비스 계정 JSON 파일을 서버에 안전하게 업로드하고, 이 파일을 통해 토큰을 발급받는 것이 핵심 과정이었죠. 처음에는 &lt;code&gt;Google_Client&lt;/code&gt; 클래스를 어떻게 초기화하고 사용할지 감이 잘 오지 않았습니다. 문서들을 찾아보면서 &lt;code&gt;setAuthConfig&lt;/code&gt; 메소드를 통해 서비스 계정 파일을 지정하고, &lt;code&gt;setScopes&lt;/code&gt;로 필요한 권한 범위를 설정해야 한다는 것을 알게 되었습니다.&lt;/p&gt;

&lt;p&gt;특히 &lt;code&gt;scopes&lt;/code&gt; 설정이 중요했는데, FCM 메시징을 위한 정확한 스코프인 &lt;code&gt;'https://www.googleapis.com/auth/firebase.messaging'&lt;/code&gt;를 지정해야 했습니다. 만약 이 스코프를 잘못 지정하거나 누락하면, 토큰 발급은 성공하더라도 메시지 발송 API 호출 시 권한 오류가 발생하더군요. 몇 번의 시도 끝에 올바른 스코프를 찾아 적용하니, 비로소 &lt;code&gt;fetchAccessTokenWithAssertion()&lt;/code&gt; 메소드를 통해 유효한 액세스 토큰을 받아낼 수 있었습니다. 이 토큰은 유효 기간이 정해져 있으므로, 실제 시스템에서는 토큰 만료 시 자동으로 갱신하는 로직을 추가해야 했습니다. 저는 토큰을 캐싱해두고 만료 직전에 갱신하는 방식을 채택했습니다.&lt;/p&gt;

&lt;p&gt;아래는 PHP에서 OAuth 토큰을 발급받는 기본적인 예시입니다. 서비스 계정 JSON 파일 경로를 정확히 지정하는 것이 중요합니다. 이 과정이 성공적으로 이루어져야만 FCM HTTP v1 API를 호출할 수 있는 자격을 얻게 됩니다. 처음에는 이 인증 과정에서 시간을 많이 할애했는데, 결국은 라이브러리의 도움을 받아 해결할 수 있었습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$client = new Google_Client();
$client-&amp;gt;setAuthConfig('path/to/your-service-account.json');
$client-&amp;gt;setScopes(['https://www.googleapis.com/auth/firebase.messaging']);
$accessToken = $client-&amp;gt;fetchAccessTokenWithAssertion()['access_token'];
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이렇게 발급받은 액세스 토큰은 FCM 발송 요청의 &lt;code&gt;Authorization&lt;/code&gt; 헤더에 &lt;code&gt;Bearer&lt;/code&gt; 토큰으로 포함되어야 합니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  메시지 발송 로직 구현: 헛다리와 실제 동작
&lt;/h3&gt;

&lt;p&gt;액세스 토큰을 발급받는 데 성공했으니, 이제 실제 FCM 메시지를 발송할 차례였습니다. FCM HTTP v1 API는 &lt;code&gt;https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send&lt;/code&gt; 엔드포인트를 사용하며, &lt;code&gt;POST&lt;/code&gt; 방식으로 JSON 형태의 메시지 페이로드를 전송해야 합니다. 처음에는 Legacy API와 유사하게 단순한 &lt;code&gt;notification&lt;/code&gt; 필드만으로 메시지를 구성했는데, 생각보다 복잡한 구조를 요구하더군요. &lt;code&gt;message&lt;/code&gt; 객체 안에 &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;notification&lt;/code&gt;, &lt;code&gt;data&lt;/code&gt; 등의 필드를 계층적으로 구성해야 했습니다. 공식 문서를 꼼꼼히 살펴보며 JSON 구조를 맞춰나가는 데 시간이 좀 걸렸습니다.&lt;/p&gt;

&lt;p&gt;특히, &lt;code&gt;token&lt;/code&gt; 필드에 기기별 FCM 토큰을 정확히 넣어주는 것이 중요했습니다. 이 토큰이 없으면 어떤 기기로도 메시지가 전달되지 않으니까요. &lt;code&gt;notification&lt;/code&gt; 필드는 알림창에 표시될 제목과 본문을 정의하고, &lt;code&gt;data&lt;/code&gt; 필드는 앱에서 처리할 추가 데이터를 key-value 형태로 담는 데 사용됩니다. &lt;code&gt;data&lt;/code&gt; 메시지는 앱이 백그라운드나 종료 상태일 때 &lt;code&gt;notification&lt;/code&gt; 메시지와 함께 전달되거나, 포그라운드 상태일 때 앱 내에서만 처리되는 용도로 유용하게 쓰일 수 있습니다.&lt;/p&gt;

&lt;p&gt;아래는 PHP에서 &lt;code&gt;cURL&lt;/code&gt;을 이용해 FCM HTTP v1 메시지를 발송하는 예시입니다. &lt;code&gt;Authorization&lt;/code&gt; 헤더에 앞서 발급받은 액세스 토큰을 포함하고, &lt;code&gt;Content-Type&lt;/code&gt;을 &lt;code&gt;application/json&lt;/code&gt;으로 설정해야 합니다. 또한, &lt;code&gt;YOUR_PROJECT_ID&lt;/code&gt; 부분은 실제 Firebase 프로젝트 ID로 교체해야 한다는 점을 잊지 말아야 합니다. 이 부분을 처음에는 프로젝트 이름으로 잘못 넣었다가 오류를 겪기도 했습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$headers = ['Authorization: Bearer ' . $accessToken, 'Content-Type: application/json'];
$data = ['message' =&amp;gt; ['token' =&amp;gt; 'FCM_DEVICE_TOKEN', 'notification' =&amp;gt; ['title' =&amp;gt; '제목', 'body' =&amp;gt; '내용']]];
$ch = curl_init('https://fcm.googleapis.com/v1/projects/YOUR_PROJECT_ID/messages:send');
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_exec($ch);
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이렇게 코드를 작성하고 테스트 발송을 해보니, 드디어 첫 FCM 알림이 기기에 도착했습니다. 성공적인 발송을 확인한 후에는, 발송 결과를 데이터베이스에 로그로 남기는 로직을 추가하여 추후 문제 발생 시 추적할 수 있도록 대비했습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  Flutter 앱 연동 과정: 토큰 등록과 메시지 수신 확인
&lt;/h3&gt;

&lt;p&gt;서버 측에서 메시지를 발송할 준비가 되었으니, 이제 Flutter 앱에서 이 메시지를 수신하고 처리할 차례였습니다. Flutter 앱에 FCM을 연동하기 위해서는 &lt;code&gt;firebase_messaging&lt;/code&gt; 패키지를 사용해야 합니다. 먼저 &lt;code&gt;main&lt;/code&gt; 함수에서 &lt;code&gt;Firebase.initializeApp()&lt;/code&gt;를 호출하여 Firebase를 초기화하는 것이 필수적입니다. 이 과정이 누락되면 FCM 기능이 제대로 동작하지 않더군요. 또한, 앱이 실행될 때 기기의 고유한 FCM 토큰을 받아 서버에 등록하는 로직을 구현해야 했습니다.&lt;/p&gt;

&lt;p&gt;FCM 토큰은 기기가 변경되거나 앱이 재설치될 때 등 여러 상황에서 갱신될 수 있기 때문에, &lt;code&gt;FirebaseMessaging.instance.onTokenRefresh.listen()&lt;/code&gt; 메소드를 사용하여 토큰 갱신 이벤트를 감지하고, 갱신된 토큰을 즉시 서버에 업데이트하는 것이 중요했습니다. 만약 이 과정을 놓치면, 서버가 구형 토큰으로 메시지를 보내게 되어 알림이 도달하지 않는 문제가 발생할 수 있습니다. 저는 앱 실행 시 현재 토큰을 한 번 서버에 전송하고, 토큰 갱신 이벤트가 발생할 때마다 다시 전송하도록 구현했습니다.&lt;/p&gt;

&lt;p&gt;메시지 수신 처리는 앱의 상태에 따라 다르게 접근해야 합니다. 앱이 포그라운드에 있을 때는 &lt;code&gt;FirebaseMessaging.onMessage.listen()&lt;/code&gt;을 통해 메시지를 실시간으로 수신하고, 사용자에게 직접 알림을 보여주거나 앱 내에서 특정 동작을 수행할 수 있습니다. 앱이 백그라운드나 종료 상태일 때는 &lt;code&gt;FirebaseMessaging.onBackgroundMessage()&lt;/code&gt; 핸들러를 등록하여 메시지를 처리하도록 했습니다. 이 백그라운드 핸들러는 앱이 완전히 종료된 상태에서도 메시지를 받아 처리할 수 있게 해주므로, 매우 중요한 부분입니다. 이 핸들러는 반드시 최상위 함수로 선언되어야 한다는 제약도 있었습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Future&amp;lt;void&amp;gt; _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
  // 백그라운드 메시지 처리: 예를 들어, 로컬 알림을 띄우거나 데이터 업데이트 등
  await Firebase.initializeApp(); // 백그라운드에서도 Firebase 초기화 필요
  print('Handling a background message: ${message.messageId}');
}

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);

  // FCM 토큰 갱신 리스너
  FirebaseMessaging.instance.onTokenRefresh.listen((fcmToken) {
    print('FCM Token refreshed: $fcmToken');
    // 서버에 토큰 등록 API 호출: 이 부분을 구현해야 합니다.
    // 예를 들어, ApiService.registerFcmToken(fcmToken); 와 같이 호출합니다.
  });

  // 앱 시작 시 현재 FCM 토큰 가져와서 서버에 등록 (선택 사항이지만 권장)
  String? currentToken = await FirebaseMessaging.instance.getToken();
  if (currentToken != null) {
    print('Current FCM Token: $currentToken');
    // 서버에 토큰 등록 API 호출
  }

  // 포그라운드 메시지 처리
  FirebaseMessaging.onMessage.listen((RemoteMessage message) {
    print('Got a message whilst in the foreground!');
    print('Message data: ${message.data}');
    if (message.notification != null) {
      print('Message also contained a notification: ${message.notification!.title} / ${message.notification!.body}');
      // 로컬 알림을 띄우는 등의 처리
    }
  });

  runApp(const MyApp());
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이렇게 앱 코드까지 완성하고 나니, 이제 서버와 앱 간의 연동 준비가 거의 끝난 것 같았습니다. 마지막으로 Firebase 콘솔에서 APNs 인증 키를 등록하는 등, iOS 환경에서 푸시 알림이 제대로 동작하도록 몇 가지 수동 설정을 해주는 것도 잊지 않았습니다. 이 설정이 없으면 iOS 기기에서는 알림이 오지 않더군요.&lt;/p&gt;

&lt;h3&gt;
  
  
  견고한 시스템을 위한 뒷단 작업: DB와 관리 도구
&lt;/h3&gt;

&lt;p&gt;서버와 앱 간의 기본적인 푸시 알림 연동은 마쳤지만, 실질적인 시스템으로 운영하기 위해서는 몇 가지 뒷단 작업이 더 필요했습니다. 가장 중요한 것은 바로 사용자 기기별 FCM 토큰을 저장하고 관리하는 데이터베이스 스키마와 API를 구축하는 것이었습니다. 토큰은 사용자별로 고유하며, 앱 설치나 재설치, 기기 변경 등 다양한 상황에서 갱신될 수 있으므로, 항상 최신 상태를 유지해야 했습니다. 저는 MySQL 데이터베이스에 사용자 ID와 FCM 토큰을 매핑하는 테이블을 만들고, 토큰이 갱신될 때마다 &lt;code&gt;UPDATE&lt;/code&gt; 하거나, 새로운 기기에서 로그인 시 &lt;code&gt;INSERT&lt;/code&gt; 하는 로직을 구현했습니다.&lt;/p&gt;

&lt;p&gt;또한, 푸시 알림 발송 기록을 남기는 것도 중요했습니다. 어떤 알림을 언제, 누구에게 보냈는지, 그리고 그 결과는 어떠했는지 추적할 수 있어야 했기 때문입니다. 이를 위해 발송 로그 테이블을 별도로 구축하고, 메시지 제목, 내용, 대상 사용자, 발송 시간, 그리고 Firebase로부터 받은 발송 응답까지 기록하도록 했습니다. 이 로그는 나중에 알림 전달 문제를 디버깅하거나, 알림 효과를 분석하는 데 귀중한 자료가 됩니다.&lt;/p&gt;

&lt;p&gt;마지막으로, 관리자가 직접 푸시 알림을 작성하고 발송할 수 있는 UI를 구축했습니다. 이 UI는 알림 제목과 내용을 입력하고, 특정 사용자 그룹이나 전체 사용자에게 알림을 보낼 수 있는 기능을 포함했습니다. 이 관리자 UI를 통해 테스트 알림을 쉽게 발송하고, 실제 운영 환경에서도 효율적으로 알림을 관리할 수 있도록 했습니다. UI에서 입력된 데이터는 앞서 만든 서버 API를 통해 FCM 발송 로직으로 전달되고, 발송 후에는 DB에 로그가 기록되는 전체 워크플로우를 완성한 셈입니다. 이처럼 서버, 앱, DB, 관리자 UI까지 전체적인 흐름을 한 번에 고려해야만 견고하고 사용하기 편리한 푸시 알림 시스템을 만들 수 있다는 것을 다시 한번 깨달았네요.&lt;/p&gt;

&lt;h3&gt;
  
  
  마무리 검증: 예상치 못한 상황과 최종 확인
&lt;/h3&gt;

&lt;p&gt;모든 시스템 구축이 끝나고 나면, 이제 철저한 검증 단계가 남아 있었습니다. 단순히 알림이 한두 번 오는 것을 확인하는 것을 넘어, 다양한 시나리오에서 시스템이 안정적으로 동작하는지 확인해야 했죠. 저는 관리자 UI를 통해 여러 종류의 테스트 푸시 알림을 발송하며 개발 및 실제 디바이스에서 수신 여부를 확인했습니다. 특히 중요한 것은 앱의 상태에 따른 알림 수신이었습니다. 포그라운드, 백그라운드, 그리고 앱이 완전히 종료된 상태에서도 알림이 정상적으로 도착하는지 여러 번 확인했습니다.&lt;/p&gt;

&lt;p&gt;초기에는 백그라운드 메시지 처리가 제대로 되지 않는 문제가 있었습니다. Flutter의 &lt;code&gt;onBackgroundMessage&lt;/code&gt; 핸들러가 최상위 함수로 선언되지 않아 발생한 문제였는데, 이 부분을 수정하니 잘 동작했습니다. 또한, iOS 환경에서는 APNs 인증 키 설정이 제대로 되지 않아 알림이 오지 않는 경우가 있었는데, Firebase 콘솔에서 &lt;code&gt;Apple 개발자 계정&lt;/code&gt;에서 발급받은 &lt;code&gt;APNs 인증 키&lt;/code&gt;를 올바르게 등록하고, Firebase 프로젝트 설정에서 Bundle ID와 팀 ID를 정확히 입력하니 해결되었습니다. 이처럼 플랫폼별 특성을 고려한 설정이 중요하더군요.&lt;/p&gt;

&lt;p&gt;데이터베이스에 발송 로그가 올바르게 기록되는지도 꼼꼼히 검증했습니다. 메시지 발송 후 DB를 확인하여, 발송 시간, 내용, 대상 등 모든 정보가 정확히 저장되어 있는지 확인했죠. 만약 발송은 되었는데 DB에 기록이 없다면 나중에 문제 추적이 어려워질 수 있습니다. 마지막으로, FCM 토큰 갱신 시 서버에 제대로 반영되는지도 확인했습니다. 앱을 삭제 후 재설치하거나, 다른 기기에서 로그인하는 등의 상황을 시뮬레이션하여 토큰이 변경되었을 때 서버의 DB에 최신 토큰이 정확히 업데이트되는지 확인하는 것이 중요했습니다. 이 모든 검증 과정을 거치고 나서야 비로소 FCM HTTP v1 푸시 알림 시스템이 안정적으로 작동할 준비가 되었다고 판단했습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  남는 이야기
&lt;/h3&gt;

&lt;p&gt;FCM HTTP v1 API를 이용한 푸시 알림 시스템을 처음부터 구축하는 과정은 결코 쉽지 않았습니다. 특히 구형 API와는 다른 인증 방식과 메시지 페이로드 구조 때문에 초반에 헤매기도 했습니다. 하지만 서버의 인증 및 발송 로직, Flutter 앱에서의 토큰 관리와 메시지 수신 처리, 그리고 이를 뒷받침하는 데이터베이스 스키마와 관리자 UI까지 전체적인 그림을 완성하면서 많은 것을 배울 수 있었습니다. 이 과정에서 겪었던 시행착오와 해결 과정이, 혹시라도 저와 같은 길을 걷고 계실 다른 개발자분들에게 조금이나마 도움이 되었으면 하는 바람입니다. 최신 기술 스택을 적용하는 것은 때론 번거롭지만, 그만큼 더 안정적이고 확장성 있는 시스템을 만들 수 있다는 보람이 큰 작업이었습니다.&lt;/p&gt;

</description>
      <category>fcmhttpv1</category>
      <category>firebasecloudmessaging</category>
      <category>php</category>
      <category>flutter</category>
    </item>
    <item>
      <title>그누보드와 Flutter 앱, JWT로 회원 연동하기: 레거시 시스템 통합 경험기</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Mon, 27 Jul 2026 08:29:37 +0000</pubDate>
      <link>https://dev.to/kys7442/geunubodeuwa-flutter-aeb-jwtro-hoeweon-yeondonghagi-regeosi-siseutem-tonghab-gyeongheomgi-4d16</link>
      <guid>https://dev.to/kys7442/geunubodeuwa-flutter-aeb-jwtro-hoeweon-yeondonghagi-regeosi-siseutem-tonghab-gyeongheomgi-4d16</guid>
      <description>&lt;h2&gt;
  
  
  그누보드와 Flutter 앱, JWT로 회원 연동하기: 레거시 시스템 통합 경험기
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd083prk0k42o27amhsr8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd083prk0k42o27amhsr8.png" alt="그누보드와 Flutter 앱, JWT로 회원 연동하기: 레거시 시스템 통합 경험기" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;안녕하세요, 코딩아빠입니다. 이번 글은 제가 최근 직접 부딪히고 해결했던 개발 경험을 정리한 기록입니다. 레거시 PHP CMS인 그누보드 웹사이트의 회원 체계를 그대로 활용하면서 Flutter 앱에서 자체 로그인, 회원가입, 토큰 갱신 기능을 구현해야 했던 상황이었죠. 기존 시스템의 작동 방식을 깊이 이해하는 것이 얼마나 중요한지 다시 한번 깨달았고, 특히 include 순서나 전역 변수 사용 방식 같은 사소한 부분이 예상치 못한 큰 오류를 발생시킬 수 있다는 교훈을 얻었습니다. 이 경험을 통해 얻은 노하우를 공유해 드립니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  이 글에서 짚는 것
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;레거시 그누보드 환경에서 PHP 기반 API를 구축하는 방법&lt;/li&gt;
&lt;li&gt;JWT(JSON Web Token)를 활용한 앱 인증 시스템 구현 원리&lt;/li&gt;
&lt;li&gt;그누보드 &lt;code&gt;common.php&lt;/code&gt; 및 &lt;code&gt;lib.php&lt;/code&gt; 로딩의 중요성과 올바른 적용법&lt;/li&gt;
&lt;li&gt;기존 웹사이트 회원 데이터베이스를 앱과 연동하는 실제 과정&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  오래된 그누보드, 새로운 Flutter 앱과의 만남 준비
&lt;/h3&gt;

&lt;p&gt;새로운 Flutter 앱을 개발하면서 기존에 운영하던 그누보드 웹사이트의 회원 체계를 그대로 사용해야 하는 상황에 놓였습니다. 별도의 소셜 로그인 연동 없이, 웹과 앱 사용자가 동일한 아이디와 비밀번호로 로그인하고 회원 정보를 공유하는 통합 시스템이 필요했죠. 이는 기존 회원의 불편을 최소화하고 데이터 일관성을 유지하기 위한 중요한 결정이었습니다. 가장 먼저 고민했던 부분은 바로 어떻게 기존 그누보드 회원 데이터베이스를 안전하고 효율적으로 앱과 연동할 것인가였습니다.&lt;/p&gt;

&lt;p&gt;그누보드가 PHP 기반의 레거시 CMS라는 점은 분명 도전 과제였습니다. 자체 라이브러리 로딩 방식과 전역 변수 의존성이 높다는 특성 때문에, 신규 API 개발 시에는 기존 그누보드 환경을 해치지 않으면서도 필요한 기능을 추가해야 했으니까요. 특히 기존 웹사이트의 로그인 로직을 직접 복제하거나 수정하는 대신, 앱만을 위한 별도의 인증 시스템을 구축하여 보안성과 확장성을 확보하는 방향으로 가닥을 잡았습니다. 여기에는 JWT(JSON Web Token)를 활용하는 것이 가장 적합하다고 판단했지요.&lt;/p&gt;

&lt;p&gt;JWT는 클라이언트와 서버 간의 정보 교환 시 사용되는 안전한 방법으로, 서버가 상태를 유지할 필요가 없어 확장성이 좋다는 장점이 있습니다. 앱에서 로그인 요청을 보내면, 서버는 회원 정보를 확인하고 유효한 경우 JWT를 발급하여 앱에 넘겨주는 방식입니다. 이후 앱은 이 토큰을 매 요청마다 헤더에 포함하여 보내고, 서버는 토큰의 유효성을 검증하여 인증된 사용자임을 확인하는 것이죠. 이러한 접근 방식은 레거시 시스템의 복잡성을 최소화하면서도 현대적인 앱 인증 시스템을 구축할 수 있는 좋은 대안이었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  그누보드 환경 위에서 API 엔드포인트 설계하기
&lt;/h3&gt;

&lt;p&gt;JWT 기반 인증 시스템을 구현하기 위해 서버에 몇 가지 새로운 API 엔드포인트를 구축해야 했습니다. 구체적으로는 로그인(&lt;code&gt;api/app/login.php&lt;/code&gt;), 회원가입(&lt;code&gt;api/app/register.php&lt;/code&gt;), 그리고 토큰 갱신(&lt;code&gt;api/app/refresh.php&lt;/code&gt;) 기능을 담당하는 PHP 파일을 새로 만들었죠. 이 파일들은 그누보드 설치 경로 내에 별도의 디렉터리를 만들어 관리함으로써 기존 그누보드 코드와의 충돌을 피하려고 했습니다.&lt;/p&gt;

&lt;p&gt;각 엔드포인트는 그누보드의 &lt;code&gt;g5_member&lt;/code&gt; 테이블을 직접 조회하거나 업데이트하는 방식으로 작동합니다. 예를 들어, 로그인 요청이 오면 전달받은 아이디와 비밀번호를 &lt;code&gt;g5_member&lt;/code&gt; 테이블의 데이터와 비교하여 일치 여부를 확인하는 식입니다. 여기서 가장 중요했던 부분은 바로 '그누보드 환경을 올바르게 초기화하는 것'이었습니다. 그누보드 핵심 라이브러리들을 제대로 로드하지 않으면 데이터베이스 연결이나 기타 전역 변수들이 초기화되지 않아 예상치 못한 오류를 뱉어내더군요.&lt;/p&gt;

&lt;p&gt;이를 해결하기 위해 각 API 파일의 최상단에 &lt;code&gt;define('_GNUBOARD_', true);&lt;/code&gt;를 선언하고, 이어서 &lt;code&gt;include_once('../../common.php');&lt;/code&gt;를 호출하는 순서를 엄격하게 지켰습니다. 이 &lt;code&gt;common.php&lt;/code&gt; 파일은 그누보드의 거의 모든 환경 설정과 핵심 함수들을 로드하는 역할을 하죠. 이 한 줄이 빠지거나 순서가 틀리면, 그누보드 환경이 제대로 잡히지 않아 데이터베이스 연결조차 되지 않는 문제가 발생했습니다. 제가 처음 겪었던 시행착오 중 하나였습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;?php
define('_GNUBOARD_', true);
include_once('../../common.php'); // 그누보드 환경 초기화

// JWT 라이브러리 로드 및 API 로직 구현
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이렇게 &lt;code&gt;common.php&lt;/code&gt;를 로드한 후에는 비로소 그누보드의 데이터베이스 연결 객체(&lt;code&gt;$g5['db']&lt;/code&gt;)나 다른 유틸리티 함수들을 사용할 수 있게 됩니다. 이 부분이 레거시 시스템 위에서 새로운 기능을 개발할 때 가장 먼저 해결해야 할 퍼즐 조각이었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  JWT 발급과 갱신, 그리고 그누보드 회원 연동의 핵심
&lt;/h3&gt;

&lt;p&gt;그누보드 환경이 제대로 초기화되었다면, 이제 실제 로그인 로직을 구현하고 JWT를 발급하는 단계로 넘어갑니다. 앱에서 아이디와 비밀번호를 POST 요청으로 보내면, 서버에서는 이를 받아 &lt;code&gt;g5_member&lt;/code&gt; 테이블에서 해당 회원 정보를 조회합니다. 비밀번호는 그누보드의 해싱 방식에 맞춰 검증해야 하므로, 기존 그누보드 로그인 로직에서 사용되던 함수를 활용하는 것이 안전하고 정확합니다.&lt;/p&gt;

&lt;p&gt;인증이 성공하면, 해당 회원의 고유 식별자(예: &lt;code&gt;mb_id&lt;/code&gt;)를 포함하는 JWT 페이로드를 생성합니다. 이 페이로드에는 토큰의 만료 시간(&lt;code&gt;exp&lt;/code&gt;)과 같은 정보도 함께 담습니다. 만료 시간은 앱의 보안 정책과 사용자 편의성을 고려하여 적절히 설정하는 것이 중요합니다. 너무 짧으면 사용자가 자주 로그인해야 해서 불편하고, 너무 길면 보안에 취약해질 수 있으니까요. 발급된 JWT는 암호화되어 서명되며, 이 토큰을 HTTP 응답으로 앱에 전달합니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// JWT 발급 예시 (실제 라이브러리 코드는 복잡함)
$payload = ['user_id' =&amp;gt; $member['mb_id'], 'exp' =&amp;gt; time() + 3600];
$jwt = 'YOUR_GENERATED_JWT_TOKEN';
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;앱은 이 JWT를 안전하게 저장하고, 이후 보호된 API를 호출할 때마다 HTTP &lt;code&gt;Authorization&lt;/code&gt; 헤더에 &lt;code&gt;Bearer {JWT}&lt;/code&gt; 형태로 포함하여 보냅니다. 또한, 장기적인 사용자 경험을 위해 리프레시 토큰(Refresh Token) 메커니즘도 함께 구현했습니다. 액세스 토큰이 만료되면, 앱은 저장된 리프레시 토큰을 이용해 새로운 액세스 토큰을 요청하는 방식으로, 사용자가 다시 로그인할 필요 없이 세션을 연장할 수 있도록 했습니다. 이 과정 역시 &lt;code&gt;api/app/refresh.php&lt;/code&gt; 엔드포인트를 통해 처리됩니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  보호된 API 접근과 토큰 유효성 검증 과정
&lt;/h3&gt;

&lt;p&gt;JWT를 발급하고 나면, 앱은 이 토큰을 사용하여 회원 전용 기능과 같은 보호된 API에 접근하게 됩니다. 서버는 이러한 보호된 API 요청이 들어올 때마다 클라이언트로부터 전달받은 JWT의 유효성을 검증해야 합니다. 이 검증 과정은 보안에 있어 매우 중요한 단계이며, 토큰이 위조되지 않았는지, 만료되지는 않았는지 등을 확인합니다.&lt;/p&gt;

&lt;p&gt;일반적으로 JWT는 HTTP &lt;code&gt;Authorization&lt;/code&gt; 헤더에 &lt;code&gt;Bearer&lt;/code&gt; 스키마와 함께 전달됩니다. 서버 측 API에서는 먼저 이 헤더에서 토큰 문자열을 추출하는 작업이 필요합니다. &lt;code&gt;$_SERVER['HTTP_AUTHORIZATION']&lt;/code&gt; 변수에서 값을 읽어와 &lt;code&gt;Bearer&lt;/code&gt; 접두사를 제거하면 순수한 JWT를 얻을 수 있습니다. 만약 토큰이 없거나 형식이 올바르지 않으면 즉시 인증 실패를 처리해야 합니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// JWT 검증 예시 (실제 라이브러리 코드는 복잡함)
$token = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (strpos($token, 'Bearer ') === 0) {
    $token = substr($token, 7);
}
// if (isValidJwt($token)) { ... } else { unauthorized }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;추출된 토큰은 JWT 라이브러리를 사용하여 서명 검증, 만료 시간 확인, 페이로드 유효성 검사 등의 과정을 거칩니다. 토큰의 서명이 유효하지 않거나 이미 만료된 토큰이라면, 해당 요청은 인증되지 않은 것으로 간주하여 401 Unauthorized 응답을 반환합니다. 이 과정을 통해 오직 유효한 토큰을 가진 사용자만이 보호된 리소스에 접근할 수 있도록 보장할 수 있었지요. 이처럼 서버에서 철저하게 토큰을 검증하는 것이 시스템 전체의 보안을 유지하는 핵심이었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  실제로 작동하는 시스템을 위한 꼼꼼한 확인
&lt;/h3&gt;

&lt;p&gt;모든 API 엔드포인트 개발과 JWT 로직 구현을 마친 후, 실제로 시스템이 제대로 작동하는지 검증하는 과정이 필요했습니다. 단순히 코드가 에러 없이 실행되는 것을 넘어, Flutter 앱에서 실제 사용자 흐름에 맞춰 로그인, 회원가입, 토큰 갱신 기능을 사용했을 때 예상대로 동작하는지 확인하는 것이 중요했습니다. 저는 단계별로 꼼꼼하게 테스트 시나리오를 만들고 하나씩 점검해 나갔습니다.&lt;/p&gt;

&lt;p&gt;먼저, 새로운 계정으로 회원가입을 시도하여 그누보드 &lt;code&gt;g5_member&lt;/code&gt; 테이블에 새로운 레코드가 성공적으로 추가되는지 확인했습니다. 다음으로, 방금 가입한 계정으로 로그인 기능을 테스트하여 유효한 JWT가 발급되고 앱으로 전달되는지 응답 값을 면밀히 살펴보았죠. 발급된 JWT는 앱의 로컬 스토리지에 잘 저장되는지, 그리고 만료 시점에 맞춰 토큰 갱신 API가 호출되어 새로운 토큰을 받아오는지도 확인했습니다.&lt;/p&gt;

&lt;p&gt;가장 중요한 검증 단계는 바로 발급된 JWT를 사용하여 다른 보호된 API(예: 회원 정보 조회 API)에 접근했을 때였습니다. 앱이 토큰을 HTTP &lt;code&gt;Authorization&lt;/code&gt; 헤더에 포함하여 요청을 보냈을 때, 서버에서 이 토큰의 유효성을 성공적으로 검증하고 올바른 데이터를 반환하는지 확인했습니다. 만료된 토큰으로 접근을 시도했을 때는 401 Unauthorized 응답이 정확히 오는지도 확인하여, 인증 시스템이 설계대로 보안적으로도 작동하는지 검증할 수 있었습니다. 이 모든 과정을 거치면서 레거시 시스템과의 연동이 성공적으로 마무리되었음을 확인할 수 있었네요.&lt;/p&gt;

&lt;h3&gt;
  
  
  정리하며
&lt;/h3&gt;

&lt;p&gt;오래된 그누보드와 현대적인 Flutter 앱을 JWT 기반으로 연동하는 작업은 분명 쉽지 않은 과정이었습니다. 특히 레거시 시스템의 특성을 이해하고 그 위에서 새로운 기능을 안정적으로 구현하는 것이 관건이었죠. 이번 경험을 통해 저는 다시 한번 '기존 시스템의 작동 원리와 의존성을 깊이 파악하는 것이 중요하다'는 교훈을 얻었습니다. 모든 개발은 새로운 것을 만드는 즐거움도 있지만, 기존의 것을 존중하고 이해하는 데서 더 큰 가치를 찾을 때도 있다는 점을 잊지 말아야겠습니다. 같은 문제를 겪는 분들께 도움이 되었기를 바랍니다.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>jwt</category>
      <category>php</category>
      <category>api</category>
    </item>
    <item>
      <title>쿠팡 파트너스 API 연동 삽질기: HMAC 서명과 캐싱으로 검색 한도 극복하기</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Sun, 26 Jul 2026 13:06:52 +0000</pubDate>
      <link>https://dev.to/kys7442/kupang-pateuneoseu-api-yeondong-sabjilgi-hmac-seomyeonggwa-kaesingeuro-geomsaeg-hando-geugboghagi-1581</link>
      <guid>https://dev.to/kys7442/kupang-pateuneoseu-api-yeondong-sabjilgi-hmac-seomyeonggwa-kaesingeuro-geomsaeg-hando-geugboghagi-1581</guid>
      <description>&lt;h2&gt;
  
  
  쿠팡 파트너스 API 연동 삽질기: HMAC 서명과 캐싱으로 검색 한도 극복하기
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxn1mzfunohlz3q0nwkep.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxn1mzfunohlz3q0nwkep.png" alt="쿠팡 파트너스 API 연동 삽질기: HMAC 서명과 캐싱으로 검색 한도 극복하기" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;안녕하세요, 코딩아빠입니다. 오늘 제가 풀어낼 이야기는 외부 API를 연동하면서 겪었던 실제 경험을 정리한 노트입니다. 이번에는 쿠팡 파트너스 API를 사용하며 겪었던 HMAC 서명 구현의 난관과 엄격한 검색 한도에 대한 고민을 어떻게 해결했는지 담담히 기록해 보려고 합니다. 처음에는 상품 데이터를 안정적으로 수집하기 위해 API를 활용하려 했지만, 복잡한 인증 방식과 예상치 못한 호출 제약에 부딪혀 한동안 고생을 좀 했습니다. 젊은 친구들이야 이런 문제를 뚝딱 해결하겠지만, 저는 좀 더 시간을 들여 차근차근 접근했습니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;대상:&lt;/strong&gt; 쿠팡 파트너스 API 연동을 계획하는 개발자, 서명 기반 인증 API 구현에 어려움을 겪는 개발자, 외부 API 호출 시 캐싱 전략에 관심 있는 개발자&lt;br&gt;
&lt;strong&gt;난이도:&lt;/strong&gt; 중급&lt;/p&gt;
&lt;h3&gt;
  
  
  이번에 정리한 내용
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;쿠팡 파트너스 API의 HMAC-SHA256 서명 생성 과정에서 유의할 점&lt;/li&gt;
&lt;li&gt;API 호출 한도에 효과적으로 대응하는 캐싱 전략 구축 방법&lt;/li&gt;
&lt;li&gt;API 응답 코드 429(Too Many Requests)를 처리하는 견고한 로직 구현&lt;/li&gt;
&lt;li&gt;파이썬에서 &lt;code&gt;requests&lt;/code&gt;와 &lt;code&gt;Redis&lt;/code&gt;를 활용한 외부 API 연동 사례&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  예상치 못한 난관: HMAC 서명 생성의 까다로운 규칙들
&lt;/h3&gt;

&lt;p&gt;처음 쿠팡 파트너스 API 문서를 보면서 HMAC-SHA256 서명을 만들어야 한다는 것을 알았습니다. 단순히 키와 데이터를 조합하는 것이 아니라, 타임스탬프, HTTP 메서드, 요청 URI, 쿼리 파라미터, 심지어 바디 해시까지 포함하여 복잡한 문자열을 특정 규칙에 따라 정규화하고 인코딩해야 하더군요. 이 과정에서 가장 많이 시간을 허비했던 부분은 바로 요청 URI의 정규화와 쿼리 파라미터의 정렬 순서였습니다. 문서에 분명히 설명되어 있었음에도 불구하고, 제가 가진 일반적인 REST API 호출 지식만으로는 미처 파악하지 못했던 미묘한 차이들이 있었던 것이지요.&lt;/p&gt;

&lt;p&gt;특히 URI 정규화 부분에서 쿼리스트링을 포함한 전체 경로를 정확히 일치시키는 것이 중요했습니다. 예를 들어, &lt;code&gt;/?param=value&lt;/code&gt;와 &lt;code&gt;/path?param=value&lt;/code&gt;는 완전히 다른 URI로 취급된다는 점을 간과하면 서명이 계속 맞지 않는 오류를 겪게 됩니다. 쿼리 파라미터 역시 알파벳 순으로 정렬한 뒤 URL 인코딩해야 하는데, 이 순서를 지키지 않으면 서명 값 자체가 달라지니 API 서버에서는 '서명이 유효하지 않다'는 응답을 계속 보내왔습니다. 처음에는 제 API 키나 시크릿 키가 잘못된 줄 알고 몇 번이나 재발급받아 보기도 했지요.&lt;/p&gt;

&lt;p&gt;이런 시행착오 끝에 결국 문서에 명시된 모든 규칙을 빠짐없이 적용하여 서명 생성 로직을 완성할 수 있었습니다. 아래는 서명 생성 로직의 핵심 부분입니다. 메시지 문자열을 구성하는 방식이 중요합니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def generate_hmac_signature(method, uri, secret_key, access_key, params=None, body_hash=''):
    # ... (생략된 서명 생성 로직)
    msg = f'{method}\n{uri}\n{parsed_query}\n{body_hash}'
    h = hmac.new(secret_key.encode('utf-8'), msg.encode('utf-8'), hashlib.sha256)
    return base64.b64encode(h.digest()).decode('utf-8')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;위 코드에서 &lt;code&gt;msg&lt;/code&gt; 변수를 만드는 부분이 바로 핵심입니다. HTTP 메서드, 정규화된 URI, 정렬된 쿼리 파라미터, 그리고 바디 해시를 &lt;code&gt;\n&lt;/code&gt;으로 구분하여 하나의 문자열로 만드는 것이죠. 이 문자열을 가지고 HMAC-SHA256 해싱을 수행한 뒤 Base64 인코딩을 거치면 최종 서명 값이 나오게 됩니다. 이렇게 서명 생성 로직을 완벽하게 구현하고 나니 비로소 200 OK 응답을 받을 수 있었습니다. 테스트용 API 키로 반복해서 호출하며 서명이 제대로 되는지 꼼꼼히 확인했네요.&lt;/p&gt;

&lt;h3&gt;
  
  
  잦은 429 응답, 검색 한도 초과에 대한 해법으로 캐싱 도입
&lt;/h3&gt;

&lt;p&gt;HMAC 서명 문제를 해결하고 나니 또 다른 난관이 기다리고 있었습니다. 바로 쿠팡 파트너스 API의 엄격한 검색 엔드포인트 호출 한도였죠. 시간당 특정 횟수 이상 호출하면 429 Too Many Requests 응답을 받게 되었는데, 이는 서비스 안정성에 직접적인 위협이 될 수 있었습니다. 처음에는 단순히 재시도 로직을 넣을까도 생각했지만, 근본적인 해결책이 아니라는 판단이 들었습니다. 무턱대고 재시도하는 것은 API 서버에 더 많은 부하를 주는 셈이니까요.&lt;/p&gt;

&lt;p&gt;제가 선택한 방법은 24시간 유효한 캐시 시스템을 구축하는 것이었습니다. Redis를 활용하여 동일한 검색 요청에 대해서는 API를 다시 호출하는 대신 캐시된 데이터를 반환하도록 했습니다. 캐시 키는 API 엔드포인트와 요청 파라미터를 조합하여 고유하게 만들었지요. 이렇게 하면 자주 요청되는 검색 쿼리에 대해서는 한 번의 API 호출만으로도 여러 번 데이터를 제공할 수 있게 됩니다. 이는 호출 한도를 지키면서도 사용자에게 빠른 응답을 제공하는 효과적인 방법이라고 생각했습니다.&lt;/p&gt;

&lt;p&gt;아래는 제가 구현한 캐시 시스템의 핵심 로직입니다. &lt;code&gt;fetch_func&lt;/code&gt;는 실제 API 호출 로직을 담고 있습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;def get_or_set_cache(key, fetch_func, expiry_seconds=86400):
    cached_data = cache.get(key)
    if cached_data:
        return json.loads(cached_data)
    data = fetch_func()
    cache.set(key, json.dumps(data), ex=expiry_seconds)
    return data
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 &lt;code&gt;get_or_set_cache&lt;/code&gt; 함수는 먼저 &lt;code&gt;cache.get(key)&lt;/code&gt;로 캐시된 데이터가 있는지 확인합니다. 만약 데이터가 있다면 즉시 반환하고, 없다면 &lt;code&gt;fetch_func&lt;/code&gt;를 호출하여 새로운 데이터를 가져온 뒤 캐시에 저장하고 반환하는 방식입니다. &lt;code&gt;expiry_seconds&lt;/code&gt;를 86400초(24시간)로 설정하여 하루 동안은 같은 검색 결과를 재활용하도록 했습니다. 이렇게 캐시를 도입하고 나니, 고의로 호출 한도를 초과하는 상황을 재현했을 때도 캐시에서 데이터를 가져와 안정적으로 응답하는 것을 확인할 수 있었네요.&lt;/p&gt;

&lt;h3&gt;
  
  
  429 응답 감지 및 추가 호출 중단으로 서비스 보호하기
&lt;/h3&gt;

&lt;p&gt;캐싱 시스템으로 호출 한도 문제를 상당 부분 해결했지만, 만에 하나 캐시가 작동하지 않거나 새로운 검색 쿼리가 폭주하여 API 호출 한도를 초과하는 상황이 발생할 수도 있습니다. 이런 경우를 대비해 429 응답을 감지하면 즉시 추가 호출을 중단하고 다음 실행까지 대기하도록 하는 방어 로직을 마련했습니다. 이는 단순한 에러 처리를 넘어, API 서버에 대한 예의이자 저희 서비스의 안정성을 지키는 중요한 장치라고 생각합니다.&lt;/p&gt;

&lt;p&gt;API 응답을 받은 후 상태 코드를 확인하여 429라면 특정 예외를 발생시키고, 이 예외를 상위 로직에서 처리하도록 설계했습니다. 이렇게 함으로써 불필요한 API 호출을 즉시 막고, 일정 시간 동안 API 호출을 자제하게 되는 것이죠. 아래는 429 응답을 감지하는 핵심 코드입니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if response.status_code == 429:
    logger.warning('API 호출 한도 초과: 429 응답을 받았습니다. 다음 실행까지 대기합니다.')
    raise RateLimitExceededException('Rate limit exceeded')
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 로직을 통해 서비스는 429 응답을 받았을 때 즉시 호출을 멈추고 경고 로그를 남기게 됩니다. 이렇게 되면 개발팀에서는 문제가 발생했음을 빠르게 인지하고 대응할 수 있습니다. 고의로 호출 한도를 초과하는 상황을 재현하여 이 로직이 제대로 작동하는지 검증했습니다. 429 응답이 오자마자 즉시 호출이 중단되고 예외가 발생하는 것을 확인했습니다. 이런 자동화된 보호 장치 덕분에 마음 편히 서비스를 운영할 수 있게 되더군요.&lt;/p&gt;

&lt;h3&gt;
  
  
  마치며
&lt;/h3&gt;

&lt;p&gt;쿠팡 파트너스 API를 연동하면서 겪었던 HMAC 서명 구현과 호출 한도 극복 과정은 저에게 많은 것을 가르쳐 주었습니다. 특히 외부 API 문서를 꼼꼼히 읽고 그 규칙을 정확히 따르는 것이 얼마나 중요한지 다시 한번 깨닫게 되었습니다. 단순히 구현하는 것을 넘어, 발생할 수 있는 문제 상황까지 미리 예측하고 캐싱이나 호출 제어 로직으로 방어하는 것은 안정적인 서비스 운영에 필수적인 부분이라고 생각합니다. 이 글이 비슷한 문제를 겪고 계신 다른 개발자분들에게 작은 도움이 되기를 바랍니다.&lt;/p&gt;

</description>
      <category>api</category>
      <category>hmac</category>
    </item>
    <item>
      <title>제휴 링크와 유튜브, 무료 블로그에서 함께 쓰는 법: Blogger API 어댑터 개발기</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Thu, 23 Jul 2026 00:46:25 +0000</pubDate>
      <link>https://dev.to/kys7442/jehyu-ringkeuwa-yutyubeu-muryo-beulrogeueseo-hamgge-sseuneun-beob-blogger-api-eodaebteo-gaebalgi-58d4</link>
      <guid>https://dev.to/kys7442/jehyu-ringkeuwa-yutyubeu-muryo-beulrogeueseo-hamgge-sseuneun-beob-blogger-api-eodaebteo-gaebalgi-58d4</guid>
      <description>&lt;h2&gt;
  
  
  제휴 링크와 유튜브, 무료 블로그에서 함께 쓰는 법: Blogger API 어댑터 개발기
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbglanpam84lbwxrowgoq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbglanpam84lbwxrowgoq.png" alt="제휴 링크와 유튜브, 무료 블로그에서 함께 쓰는 법: Blogger API 어댑터 개발기" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;안녕하세요, 코딩아빠입니다. 오늘은 제가 직접 부딪히고 해결했던 흥미로운 경험 하나를 경험노트 형식으로 정리해 보았습니다. 블로그 콘텐츠를 효율적으로 확산하기 위해 무료 블로그 플랫폼을 알아보던 중, 제휴 링크와 유튜브 영상을 동시에 삽입할 수 있는 곳을 찾기가 여간 어려운 일이 아니더군요. 수많은 플랫폼의 약관을 샅샅이 뒤진 끝에, 결국 Blogger만이 유일한 해결책임을 발견했고, 이를 자동화하기 위한 API 어댑터를 직접 개발하게 되었습니다. 이 과정에서 기술 구현만큼이나 정책 조사가 중요하다는 것을 다시 한번 깨달았네요.&lt;/p&gt;

&lt;p&gt;이런 분께 — 무료 블로그 플랫폼에서 제휴 마케팅이나 유튜브 영상을 활용하려는 개발자 및 콘텐츠 크리에이터 · 난이도는 중급 정도&lt;/p&gt;

&lt;h3&gt;
  
  
  무료 블로그 플랫폼의 숨겨진 벽: 제휴 링크와 유튜브 영상
&lt;/h3&gt;

&lt;p&gt;새로운 콘텐츠를 만들면서, 이를 더 많은 사람에게 알리기 위해 무료 블로그 플랫폼을 활용하는 방안을 고민했습니다. 특히, 제가 만들 콘텐츠에는 특정 제품의 제휴 링크와 설명에 필요한 유튜브 영상이 함께 들어가야 하는 상황이었습니다. 무료 플랫폼은 접근성이 좋고 관리 부담이 적다는 장점이 있으니, 처음에는 당연히 쉽게 해결될 일이라고 생각했죠.&lt;/p&gt;

&lt;p&gt;하지만 현실은 달랐습니다. 국내외 유명 무료 블로그 플랫폼들을 하나씩 살펴보니, 대부분의 약관에서 제휴 링크 삽입을 제한하거나 아예 금지하고 있었습니다. 심지어 유튜브 영상 삽입조차 특정 방식이 아니면 허용하지 않는 곳도 더러 있더군요. 플랫폼 입장에서는 스팸성 콘텐츠를 방지하고 자체 광고 수익 모델을 보호하기 위한 조치겠지만, 저처럼 특정 목적을 가진 사용자에게는 큰 걸림돌이 아 되는 부분이었습니다.&lt;/p&gt;

&lt;p&gt;하나의 플랫폼에서 제휴 링크와 유튜브 영상 삽입을 모두 허용하는 경우는 정말 찾아보기 힘들었습니다. 어떤 곳은 제휴 링크는 허용하지만 영상 삽입이 까다롭고, 또 다른 곳은 영상은 자유롭지만 제휴 링크에 대한 명시적인 금지 조항이 있었습니다. 결국, 이 모든 조건을 동시에 만족하는 '무료 블로그'를 찾는 것 자체가 하나의 난관으로 다가오게 되었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  수많은 약관 속에서 찾은 유일한 대안, Blogger
&lt;/h3&gt;

&lt;p&gt;이 문제를 해결하기 위해 제가 할 수 있는 유일한 방법은 각 플랫폼의 서비스 약관을 꼼꼼하게 전수 조사하는 것이었습니다. 시간과 노력이 많이 드는 작업이었지만, 이 단계 없이는 어떤 기술적인 해결책도 무용지물이 될 것이라고 판단했습니다. 네이버 블로그, 티스토리, 브런치, 미디엄 등 다양한 플랫폼의 약관을 한 줄 한 줄 읽어 내려갔습니다. 대부분의 플랫폼은 예상대로 제휴 마케팅에 대해 부정적인 입장을 취하거나, 명확하지 않은 조항으로 사용자에게 불확실성을 안겨주었지요.&lt;/p&gt;

&lt;p&gt;그렇게 여러 날을 약관과 씨름한 끝에, 마침내 한 줄기 빛을 발견했습니다. 바로 구글의 Blogger였습니다. Blogger는 다른 플랫폼에 비해 제휴 링크와 외부 콘텐츠(유튜브 영상 포함) 삽입에 대한 정책이 상대적으로 유연하다는 것을 확인할 수 있었습니다. 물론 무조건적인 허용은 아니지만, 명확한 고지와 함께 콘텐츠의 본질을 해치지 않는 선에서는 충분히 활용 가능성이 보였습니다. 이 발견은 이후 기술 구현 방향을 결정짓는 중요한 전환점이 되었지요.&lt;/p&gt;

&lt;p&gt;Blogger는 구글 계정만 있다면 누구나 쉽게 블로그를 만들 수 있고, API를 통해 게시물 발행을 자동화할 수 있다는 점도 매력적이었습니다. 단순히 정책 준수를 넘어, 제가 원하는 자동화된 발행 시스템을 구축하는 데 필요한 기술적 기반까지 갖추고 있었으니, 여러모로 최적의 선택지였습니다. 이제 남은 과제는 이 발견을 실제 시스템으로 구현하는 것이었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  Blogger API 어댑터 개발: 자동화를 위한 핵심 구현
&lt;/h3&gt;

&lt;p&gt;Blogger가 유일한 대안임을 확인한 후, 다음 단계는 이 플랫폼의 API를 활용하여 게시물 자동 발행 시스템을 구축하는 것이었습니다. 저는 Python을 사용하여 Blogger API 어댑터를 개발하기로 결정했습니다. Python은 강력한 HTTP 클라이언트 라이브러리와 JSON 처리 기능을 제공하여 API 연동에 매우 효율적이라고 생각했습니다.&lt;/p&gt;

&lt;p&gt;가장 먼저 해결해야 할 부분은 Blogger API에 대한 인증이었습니다. Google 서비스인 만큼, OAuth 2.0 프로토콜을 사용하여 안전하게 접근 권한을 획득해야 했습니다. 저희 어댑터는 서비스 계정 또는 사용자 계정의 OAuth 토큰을 발급받아 API 요청 시 사용하도록 설계했습니다. 이때 필요한 스코프는 블로그 게시물을 생성하고 수정할 수 있는 권한입니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.googleapis.com/auth/blogger
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;위 스코프를 통해 Blogger API에 접근할 수 있는 권한을 얻게 됩니다. 이렇게 획득한 토큰을 사용하여 게시물을 생성하는 API 요청은 다음과 같은 형태를 가집니다. 실제 구현에서는 Python의 &lt;code&gt;requests&lt;/code&gt; 라이브러리를 활용했습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl -X POST -H "Authorization: Bearer YOUR_ACCESS_TOKEN" -H "Content-Type: application/json" -d '{"kind": "blogger#post", "blog": {"id": "YOUR_BLOG_ID"}, "title": "게시물 제목", "content": "게시물 내용&amp;lt;a href=\"YOUR_AFFILIATE_LINK\"&amp;gt;링크&amp;lt;/a&amp;gt;&amp;lt;br&amp;gt;&amp;lt;iframe src=\"YOUR_YOUTUBE_EMBED_LINK\"&amp;gt;&amp;lt;/iframe&amp;gt;"}' "https://www.googleapis.com/blogger/v3/blogs/YOUR_BLOG_ID/posts"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 명령어는 지정된 블로그 ID에 제목과 내용을 포함한 새 게시물을 생성하는 API 호출 예시입니다. 여기서 &lt;code&gt;content&lt;/code&gt; 필드 내에 제휴 링크와 유튜브 임베드 링크를 HTML 형태로 삽입하는 방식으로 구현했습니다. 특히, 유튜브 링크 삽입 로직은 별도의 플래그로 분리하여, 필요에 따라 유튜브 영상을 포함하거나 제외할 수 있도록 유연성을 확보했습니다. 이로써 콘텐츠의 목적에 맞게 게시물 형식을 조절할 수 있게 되었지요.&lt;/p&gt;

&lt;h3&gt;
  
  
  정책 준수와 투명성 확보: 워드프레스 게이트 보강
&lt;/h3&gt;

&lt;p&gt;Blogger API 어댑터 개발과 함께, 저희가 운영하는 자사 WordPress 사이트에도 정책 준수를 위한 추가적인 조치를 취했습니다. 제휴 마케팅 활동을 함에 있어 가장 중요한 원칙 중 하나는 바로 '투명성'이라고 생각합니다. 사용자가 게시물 내 제휴 링크를 통해 수익이 발생할 수 있음을 명확히 인지하게 하는 것이 중요하기 때문입니다.&lt;/p&gt;

&lt;p&gt;이를 위해 기존 WordPress 경로에 '제휴 고지 게이트'를 보강했습니다. 이는 사용자가 제휴 링크가 포함된 페이지에 접근하기 전에, 해당 페이지가 제휴 마케팅 활동을 포함하고 있음을 명시적으로 알리고 동의를 구하는 팝업이나 배너를 의미합니다. 이렇게 함으로써 사용자는 콘텐츠를 소비하기 전에 중요한 정보를 인지할 수 있게 되고, 저희는 제휴 마케팅 정책을 성실히 준수하고 있음을 보여줄 수 있습니다.&lt;/p&gt;

&lt;p&gt;이 게이트는 사용자 경험을 해치지 않으면서도, 법적 요구사항과 플랫폼의 정책을 모두 만족시키는 방향으로 설계되었습니다. 단순히 기술적인 구현을 넘어, 서비스 운영 전반의 신뢰도를 높이는 중요한 장치라고 할 수 있겠네요. 이처럼 기술적인 해결책과 함께 정책적인 부분을 꼼꼼히 챙기는 것이 안정적인 서비스 운영에 필수적임을 다시 한번 느꼈습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  성공적인 발행과 꼼꼼한 최종 검증
&lt;/h3&gt;

&lt;p&gt;Blogger API 어댑터 개발과 WordPress 제휴 고지 게이트 보강이 완료된 후, 가장 중요한 단계는 바로 '검증'이었습니다. 개발된 어댑터를 통해 제휴 링크와 유튜브 영상이 모두 포함된 테스트 게시물을 Blogger에 발행했습니다. 발행된 게시물이 의도한 대로 정확하게 표시되는지, 링크와 영상이 정상적으로 작동하는지 육안으로 꼼꼼히 확인했습니다.&lt;/p&gt;

&lt;p&gt;또한, 발행된 게시물이 Blogger의 정책뿐만 아니라 Google의 전반적인 콘텐츠 가이드라인을 위반하지 않는지도 재차 확인했습니다. 정책 위반으로 인한 서비스 중단 같은 불상사를 막기 위해서는 이중, 삼중의 확인 과정이 필수적이라고 생각합니다. 자사 WordPress 사이트의 제휴 고지 게이트도 실제 환경에서 정상적으로 작동하는지, 사용자가 동의 과정을 거쳐야만 콘텐츠에 접근할 수 있는지 등을 면밀히 테스트했습니다.&lt;/p&gt;

&lt;p&gt;다행히 모든 테스트는 성공적이었습니다. 어댑터를 통해 발행된 게시물은 제휴 링크와 유튜브 영상이 완벽하게 삽입되어 있었고, 정책 위반 소지도 없었습니다. WordPress의 제휴 고지 게이트도 안정적으로 작동하여 사용자와의 투명성 약속을 지킬 수 있게 되었습니다. 이로써 무료 블로그 플랫폼의 제약 속에서도 저희의 콘텐츠 발행 목표를 달성할 수 있는 시스템을 성공적으로 구축하게 되었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  글의 요점
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;무료 블로그 플랫폼별 제휴 링크 및 유튜브 영상 삽입 정책 이해&lt;/li&gt;
&lt;li&gt;Blogger API를 활용한 게시물 자동 발행 방법&lt;/li&gt;
&lt;li&gt;Google OAuth 2.0을 이용한 안전한 API 접근 설정&lt;/li&gt;
&lt;li&gt;콘텐츠 정책 준수의 중요성과 기술 설계의 연관성&lt;/li&gt;
&lt;li&gt;자사 WordPress 사이트에서 제휴 고지 게이트를 보강하는 방법&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  끝으로
&lt;/h3&gt;

&lt;p&gt;무료 블로그 플랫폼에서 제휴 링크와 유튜브 영상을 함께 활용하려는 처음의 목표는 예상치 못한 정책적 제약에 부딪혔습니다. 하지만 포기하지 않고 각 플랫폼의 약관을 면밀히 조사한 끝에 Blogger라는 유일한 대안을 찾았고, 이를 자동화하기 위한 API 어댑터를 성공적으로 개발했습니다. 이 과정은 기술 구현에 앞서 정책과 규제를 깊이 이해하는 것이 얼마나 중요한지 다시 한번 일깨워주었습니다. 개발자로서 단순히 '무엇을 만들까'를 넘어 '어떻게 만들고, 그 환경의 규칙은 무엇인가'를 함께 고민해야 한다는 것을 느낀 의미 있는 경험이었습니다.&lt;/p&gt;

</description>
      <category>bloggerapi</category>
      <category>python</category>
      <category>googleoauth</category>
    </item>
    <item>
      <title>AI 코딩 에이전트의 시크릿 파일 접근, 훅으로 강제 차단한 경험</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Tue, 21 Jul 2026 00:14:40 +0000</pubDate>
      <link>https://dev.to/kys7442/ai-koding-eijeonteuyi-sikeuris-pail-jeobgeun-hugeuro-gangje-cadanhan-gyeongheom-1887</link>
      <guid>https://dev.to/kys7442/ai-koding-eijeonteuyi-sikeuris-pail-jeobgeun-hugeuro-gangje-cadanhan-gyeongheom-1887</guid>
      <description>&lt;h2&gt;
  
  
  AI 코딩 에이전트의 시크릿 파일 접근, 훅으로 강제 차단한 경험
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuyhamypuewkxxnocga0j.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuyhamypuewkxxnocga0j.png" alt="AI 코딩 에이전트의 시크릿 파일 접근, 훅으로 강제 차단한 경험" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AI 코딩 에이전트를 개발 작업에 활용하면서 생산성 향상을 체감했습니다. 하지만 이 편리함 뒤에는 간과하기 쉬운 보안 위험이 도사리고 있더군요. 개발 과정에서 AI가 실수로 .env, .p8, .pem 같은 민감한 시크릿 파일을 읽거나, 코드에 하드코딩하여 노출할 가능성이었습니다. 단순한 가이드라인만으로는 부족하다는 것을 깨달았고, 기계적인 강제 장치가 필요하다는 결론에 도달했습니다. 이번 글에서는 AI 에이전트의 시크릿 접근을 원천 차단하기 위해 구성했던 훅(hook)에 대한 경험을 공유합니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;대상:&lt;/strong&gt; AI 코딩 도구를 사용하며 보안 강화를 고민하는 개발자&lt;br&gt;
&lt;strong&gt;난이도:&lt;/strong&gt; 중급&lt;/p&gt;

&lt;h3&gt;
  
  
  글의 요점
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;AI 코딩 도구 사용 시 발생할 수 있는 시크릿 노출 위험&lt;/li&gt;
&lt;li&gt;AI 에이전트의 파일 시스템 접근 특성과 보안 위협&lt;/li&gt;
&lt;li&gt;파일 읽기 및 셸 호출을 가로채는 secrets-gate 훅 구성 방법&lt;/li&gt;
&lt;li&gt;코드 내 하드코딩된 시크릿 패턴을 검사하는 secrets-scan 훅 적용&lt;/li&gt;
&lt;li&gt;개발자의 실수를 줄이고 프로젝트 보안 수준을 높이는 자동화된 방법&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  AI 시대의 보안: 강제 장치의 필요성
&lt;/h3&gt;

&lt;p&gt;최근 개발 환경에서 AI 코딩 도구의 역할이 커지고 있습니다. 코드 생성, 디버깅, 문서 작성 등 다양한 영역에서 도움을 주고 있죠. 그러나 AI가 직접 프로젝트 파일 시스템에 접근하고 코드를 생성하는 과정에서 민감 정보 유출의 위험 또한 커졌다는 점을 간과해서는 안 됩니다. 단순히 '시크릿은 노출하지 말 것'과 같은 규칙이나 가이드라인만으로는 AI의 예측 불가능한 동작을 완전히 제어하기 어렵습니다. 개발자가 매번 생성된 코드를 면밀히 검토하는 것도 현실적으로 쉽지 않은 일입니다. 이런 환경에서는 인간의 실수를 넘어선, 기계적인 강제 장치를 마련하는 것이 필수적이라는 교훈을 얻었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  의도치 않은 노출, AI 에이전트의 시크릿 접근
&lt;/h3&gt;

&lt;p&gt;저의 경우, AI 코딩 에이전트가 개발 도중 .env, .p8, .pem과 같은 시크릿 파일을 실수로 읽거나, 생성된 코드에 API 키와 같은 민감 정보를 하드코딩할 위험이 있었습니다. 이러한 파일들은 데이터베이스 연결 정보, 서명 키, 인증서 등 프로젝트의 핵심 보안을 담당하는 요소들입니다. AI가 특정 로직을 구현하기 위해 참조할 파일을 찾다가 의도치 않게 시크릿 파일에 접근할 수 있고, 학습된 패턴에 따라 예시 코드를 생성하는 과정에서 실제 시크릿을 삽입할 수도 있다는 점이 문제였습니다. 이 문제는 개발 흐름에 스며들어 있기 때문에, 인지하지 못하면 그대로 배포될 수도 있는 위험이었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  넓은 접근 권한과 패턴 학습의 이면
&lt;/h3&gt;

&lt;p&gt;AI 코딩 에이전트의 동작 특성을 살펴보니, 문제의 원인이 명확해졌습니다. AI 에이전트는 효율적인 작업을 위해 프로젝트 파일 시스템에 대한 상당히 넓은 접근 권한을 가집니다. 이는 특정 파일을 읽거나, 셸 명령을 실행하는 등의 자유로운 동작을 가능하게 합니다. 또한, AI는 방대한 데이터를 학습하여 코드를 생성하므로, 특정 패턴이나 변수명에 반응하여 시크릿을 유추하거나, 이전에 학습했던 예시 코드를 재사용하는 과정에서 민감 정보가 포함될 수 있습니다. 이러한 특성은 개발의 편의성을 높이지만, 동시에 잠재적인 보안 취약점을 내포하고 있어 주의가 필요하다는 것을 깨달았습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  파일 접근을 막는 secrets-gate 훅 구현
&lt;/h3&gt;

&lt;p&gt;AI 에이전트가 시크릿 파일에 직접 접근하는 것을 막기 위해 'secrets-gate' 훅을 구성했습니다. 이 훅은 파일 읽기(read) 및 셸(bash) 호출 시 특정 시크릿 파일 경로를 탐지하고 접근을 즉시 차단하는 역할을 합니다. 예를 들어, AI가 .env 파일을 읽으려고 시도하면, 훅이 이를 감지하여 에러 메시지와 함께 실행을 중단시키는 방식입니다. 이 과정에서 사용한 스크립트의 핵심 로직은 다음과 같습니다. 특정 파일 확장자를 검사하여 접근을 차단하는 간단한 함수입니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  코드 내 시크릿 패턴 검사, secrets-scan 훅
&lt;/h3&gt;

&lt;p&gt;파일 접근 차단 외에, AI가 코드 자체에 시크릿을 하드코딩하는 것을 막기 위한 방안도 마련했습니다. 'secrets-scan' 훅은 코드를 저장하거나 커밋하기 전에 코드 내에 하드코딩된 API 키나 시크릿 패턴이 있는지 자동으로 검사합니다. 이는 Git의 pre-commit 훅과 같은 형태로 구성하여, 개발자가 의도치 않게 시크릿을 포함한 코드를 커밋하는 것을 방지할 수 있습니다. 예를 들어, &lt;code&gt;YOUR_API_KEY&lt;/code&gt;와 같은 명확한 시크릿 플레이스홀더나 특정 형식의 API 키 패턴을 찾아 경고를 보내거나 커밋을 거부하는 식으로 작동합니다. 실제 운영 환경에서는 더 정교한 정규 표현식을 사용하여 민감 정보를 탐지해야 합니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  설정한 보안 훅의 작동 확인
&lt;/h3&gt;

&lt;p&gt;구성한 두 가지 훅의 작동 여부를 검증했습니다. 먼저 secrets-gate 훅의 경우, AI 에이전트에게 '프로젝트 루트 디렉토리의 .env 파일을 읽어 내용을 요약해달라'고 의도적으로 지시했습니다. 예상대로 AI는 해당 파일에 접근하려 했고, secrets-gate 훅이 이를 감지하여 접근이 차단되었다는 에러 메시지를 반환했습니다. 다음으로 secrets-scan 훅은, 테스트용으로 &lt;code&gt;const API_KEY = 'YOUR_TEST_API_KEY';&lt;/code&gt;와 같이 시크릿 패턴이 포함된 코드를 작성한 후 저장 및 커밋을 시도했습니다. 이 경우 훅이 코드 내 패턴을 성공적으로 감지하고 경고를 표시하며 커밋을 차단했습니다. 이로써 AI 코딩 환경에서 시크릿 노출 위험을 기계적으로 방지할 수 있음을 확인했습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  마무리
&lt;/h3&gt;

&lt;p&gt;AI 코딩 도구는 분명 강력한 조력자이지만, 그 잠재적 위험을 인지하고 선제적으로 대응하는 것이 중요합니다. 특히 보안과 관련된 부분에서는 개발자의 주의를 넘어선 기계적인 강제 장치를 마련하는 것이 필수적입니다. 이번 경험을 통해 얻은 교훈은, 편리함 뒤에 숨은 위험을 항상 경계하고, 시스템적인 안전망을 구축해야 한다는 점입니다. 이는 같은 실수를 반복하지 않고 더 안전한 개발 환경을 만드는 데 도움이 될 것입니다.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>git</category>
      <category>secretsgate</category>
      <category>secretsscan</category>
    </item>
    <item>
      <title>더 좋다는 LLM 모델, 내 작업엔 어땠을까? 실측 비교와 유연한 라우팅 전략</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Sun, 19 Jul 2026 21:01:35 +0000</pubDate>
      <link>https://dev.to/kys7442/deo-johdaneun-llm-model-nae-jageoben-eoddaesseulgga-silceug-bigyowa-yuyeonhan-rauting-jeonryag-bef</link>
      <guid>https://dev.to/kys7442/deo-johdaneun-llm-model-nae-jageoben-eoddaesseulgga-silceug-bigyowa-yuyeonhan-rauting-jeonryag-bef</guid>
      <description>&lt;h2&gt;
  
  
  더 좋다는 LLM 모델, 내 작업엔 어땠을까? 실측 비교와 유연한 라우팅 전략
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fopj5c1i0071k9kayztmx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fopj5c1i0071k9kayztmx.png" alt="더 좋다는 LLM 모델, 내 작업엔 어땠을까? 실측 비교와 유연한 라우팅 전략" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;주말마다 아들과 공원 벤치에 앉아 블로그 초안을 검토하는 게 저의 작은 낙입니다. 최근 블로그 콘텐츠 자동 생성 스크립트의 핵심인 LLM 모델을 두고 고민이 많았습니다. '유료 상위 모델이 더 좋다'는 이야기는 귀에 못이 박히도록 들었지만, 과연 제 블로그 글쓰기 작업, 특히 제가 고심해서 만든 프롬프트에서는 어떤 결과물을 보여줄지 미지수였거든요. 막연한 기대감만으로 모델을 전환하기에는 왠지 찜찜한 기분이 들었습니다. 그래서 직접 두 모델의 실력을 겨뤄보기로 마음먹었습니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;대상:&lt;/strong&gt; LLM 모델 전환을 고려하거나 여러 LLM 모델의 성능을 비교하려는 개발자&lt;br&gt;
&lt;strong&gt;난이도:&lt;/strong&gt; 중급&lt;/p&gt;
&lt;h3&gt;
  
  
  여기서 확인할 것
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;LLM 모델 선택 시 일반 벤치마크의 한계와 실제 작업 환경 비교의 중요성&lt;/li&gt;
&lt;li&gt;Gemini와 Claude 모델의 실제 콘텐츠 생성 특성 비교&lt;/li&gt;
&lt;li&gt;콘텐츠 길이에 따른 LLM 모델별 출력 차이&lt;/li&gt;
&lt;li&gt;환경변수를 활용한 LLM 모델 라우팅 구현 방법&lt;/li&gt;
&lt;li&gt;비용 효율성을 고려한 LLM 모델 운영 전략&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  과연 상위 모델이 무조건 정답일까요?
&lt;/h3&gt;

&lt;p&gt;많은 분들이 저처럼 '더 좋은 모델'이라는 말에 혹할 때가 있을 겁니다. 저 역시 블로그 글 생성을 위해 사용하던 LLM 모델을 유료 상위 모델로 교체할지 진지하게 고민하기 시작했습니다. 일반적으로 새로 출시된 유료 모델은 기존 모델보다 성능이 우수하다는 인식이 지배적이고, 각종 벤치마크 결과도 이를 뒷받침하는 경우가 많았으니까요. 하지만 제 경험상, 이런 일반적인 평가는 실제 제 작업 환경에서 발목을 잡는 경우가 잦았습니다. 막상 적용해보면 기대했던 만큼의 효율이나 결과가 나오지 않아 실망하는 일도 있었고요.&lt;/p&gt;

&lt;p&gt;특히 블로그 콘텐츠 생성처럼 특정 목적과 스타일이 명확한 작업에서는 더욱 그렇습니다. 제가 사용하는 프롬프트는 오랜 시간 시행착오를 거쳐 최적화된 것이라, 단순히 '더 똑똑한' 모델이 같은 프롬프트에 더 좋은 결과를 내리라는 보장이 없다고 생각했습니다. 산책길에 아들이 주워온 나뭇가지도 특정 놀이에는 요긴하게 쓰이듯, 모델의 '강점'이 제 블로그 글쓰기 '용도'와 잘 맞을지가 중요했죠. 무작정 전환했다가 비용만 더 들고 결과물은 오히려 나빠질 수도 있다는 불안감이 저를 움직이게 했습니다.&lt;/p&gt;
&lt;h3&gt;
  
  
  내 프롬프트로는 어떤 결과가 나올까? 직접 비교하기
&lt;/h3&gt;

&lt;p&gt;그래서 저는 기존에 알려진 벤치마크나 다른 개발자들의 일반적인 평가에 의존하기보다는, 제가 실제로 사용하는 블로그 글쓰기 노트와 프롬프트를 가지고 두 모델의 실력을 직접 비교해보기로 했습니다. 비교 대상은 현재 사용 중인 무료 Gemini 모델과 유료 Claude 모델이었습니다. 실용적인 비교를 위해 블로그 글의 분량을 '짧음', '중간', '김' 세 가지 프로필로 나누어 각각 동일한 프롬프트로 결과물을 생성하고 그 차이를 면밀히 분석했습니다.&lt;/p&gt;

&lt;p&gt;단순히 글자 수만 비교하는 것이 아니라, 글의 논리적인 흐름, 정보의 정확성, 그리고 코드 블록이나 표와 같은 구조적인 요소들이 얼마나 잘 생성되는지를 중점적으로 살펴보았습니다. 결과는 예상보다 흥미로웠습니다. 전반적인 분량 면에서는 Gemini 모델이 평균 4,503자로 Claude 모델의 3,177자보다 훨씬 우세했습니다. 아이와 함께 즐겨 읽는 동화책처럼 술술 읽히는 길고 풍부한 설명을 만드는 데는 Gemini가 강점을 보인 것이죠. 하지만 블로그 글에 자주 들어가는 코드 블록이나 표 같은 구조적인 요소들의 정확성과 완성도는 Claude가 확연히 뛰어났습니다. 마치 정교한 레고 블록을 조립하듯, 필요한 구조를 깔끔하게 만들어내는 재주가 더 좋았달까요. 이처럼 각 모델이 가진 뚜렷한 장단점을 직접 확인하고 나니, 어떤 모델을 선택해야 할지 명확한 그림이 그려지기 시작했습니다.&lt;/p&gt;
&lt;h3&gt;
  
  
  모델의 강점을 살리는 유연한 라우팅 전략 구현
&lt;/h3&gt;

&lt;p&gt;실측 비교를 통해 얻은 인사이트는 '하나의 모델만 고집할 필요가 없다'는 것이었습니다. Gemini는 풍성한 분량과 자연스러운 문체에 강점이 있었고, Claude는 구조적인 정확성과 완성도에서 빛을 발했습니다. 이 두 모델의 장점을 모두 활용하면서도 비용 효율성을 놓치지 않으려면 어떻게 해야 할까 고민하다가, 모델 라우팅 로직을 구현하기로 마음먹었습니다.&lt;/p&gt;

&lt;p&gt;기본적으로는 무료인 Gemini 모델을 유지하되, 특정 구조(코드 블록, 표 등)가 필요한 글을 생성할 때는 환경변수를 통해 Claude 모델로 전환하는 방식입니다. 파이썬 스크립트에서 환경변수를 읽어 모델 인스턴스를 동적으로 결정하도록 구현했습니다. 아래는 그 예시 코드입니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import os

def get_llm_model():
    model_choice = os.getenv('BLOG_LLM_MODEL', 'gemini')
    if model_choice == 'claude':
        return "Claude Model Instance"
    else:
        return "Gemini Model Instance"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;위 코드는 &lt;code&gt;BLOG_LLM_MODEL&lt;/code&gt; 환경변수 값에 따라 적절한 LLM 모델 인스턴스를 반환하는 간단한 함수입니다. 이렇게 하면 스크립트 코드를 변경하지 않고도 외부에서 모델을 제어할 수 있게 됩니다. 실제 블로그 글 생성 스크립트에서는 이 함수를 호출하여 필요한 모델을 가져다 쓰면 됩니다.&lt;/p&gt;

&lt;p&gt;특정 모델을 사용하고 싶을 때는 다음과 같이 환경변수를 설정하면 됩니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;export BLOG_LLM_MODEL=claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;기본값인 Gemini 모델을 사용하려면, 다음과 같이 설정하거나 아예 환경변수를 설정하지 않아도 됩니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;export BLOG_LLM_MODEL=gemini
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이러한 라우팅 로직 덕분에 저희 아들과의 캠핑에서 꼭 필요한 장비만 챙겨가는 것처럼, 필요한 상황에 꼭 맞는 LLM 모델을 선택적으로 사용할 수 있게 되었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  마치며
&lt;/h3&gt;

&lt;p&gt;이번 LLM 모델 실측 비교와 라우팅 로직 구현 경험을 통해, '더 좋다'는 일반적인 평가가 반드시 내 작업 환경에 적용되는 것은 아니라는 사실을 다시금 깨달았습니다. 마치 내구성이 좋다는 등산화도 실제 내 발에는 안 맞을 수 있는 것처럼, LLM 모델도 직접 부딪혀보고 테스트하는 과정이 꼭 필요하더군요. 각 모델의 강점을 파악하고 용도에 따라 유연하게 활용하는 것이 비용 효율성과 결과물의 품질을 동시에 잡는 현명한 방법이라는 결론에 도달했습니다. 여러분도 LLM 모델 전환을 고려하고 있다면, 꼭 여러분의 실제 데이터와 프롬프트로 직접 테스트해보시길 강력히 권합니다.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>gemini</category>
      <category>claude</category>
    </item>
    <item>
      <title>로컬 FastAPI 관리 패널, Cloudflare Tunnel과 Access로 안전하게 외부 노출하고 보안 강화하기</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Sat, 18 Jul 2026 06:43:37 +0000</pubDate>
      <link>https://dev.to/kys7442/rokeol-fastapi-gwanri-paeneol-cloudflare-tunnelgwa-accessro-anjeonhage-oebu-noculhago-boan-ganghwahagi-4lj8</link>
      <guid>https://dev.to/kys7442/rokeol-fastapi-gwanri-paeneol-cloudflare-tunnelgwa-accessro-anjeonhage-oebu-noculhago-boan-ganghwahagi-4lj8</guid>
      <description>&lt;h2&gt;
  
  
  로컬 FastAPI 관리 패널, Cloudflare Tunnel과 Access로 안전하게 외부 노출하고 보안 강화하기
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhrdr48l8zxcj1obso2we.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhrdr48l8zxcj1obso2we.png" alt="로컬 FastAPI 관리 패널, Cloudflare Tunnel과 Access로 안전하게 외부 노출하고 보안 강화하기" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;아들과 주말마다 공원이며 캠핑을 다니는 즐거움은 말로 다 표현하기 어렵습니다. 그런데 가끔 집을 비울 때, 로컬에 띄워둔 FastAPI 기반의 관리 패널이 눈에 밟힐 때가 있더군요. 개발 중인 서비스의 상태를 확인하거나 간단한 설정을 변경할 일이 생기면, 꼭 집에 돌아와야만 한다는 점이 아쉽게 느껴졌습니다. 처음에는 간단히 포트 포워딩을 생각해보기도 했습니다. 공유기 설정을 만져 8770 포트를 외부로 열어두면 되겠지 싶었죠. 하지만 민감한 관리 패널을 단순히 포트 포워딩으로 외부에 노출하는 것은 보안상 너무나 위험한 일이었습니다. 고정 IP가 없는 유동 IP 환경에서 접속 주소가 계속 바뀌는 것도 번거로운 일이고요. 괜히 이런 허술한 방법으로 외부 공격에 노출될까 봐 불안한 마음이 들었습니다. 주말에 아들과 즐겁게 시간을 보내면서도, 한편으로는 안전하게 외부에서 로컬 서비스를 관리할 방법에 대한 고민이 머릿속을 떠나지 않았습니다.&lt;/p&gt;

&lt;p&gt;이런 분께 — 로컬에서 개발한 웹 서비스를 안전하게 외부로 노출하고 싶은 개발자, 특히 1인 개발자나 소규모 팀 개발자들에게 유용할 것입니다. · 난이도는 중급 정도&lt;/p&gt;

&lt;h3&gt;
  
  
  이번에 정리한 내용
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;로컬에서 실행되는 웹 서비스를 외부로 안전하게 노출하는 방법&lt;/li&gt;
&lt;li&gt;Cloudflare Tunnel을 활용한 포트 포워딩 없는 외부 접근 설정&lt;/li&gt;
&lt;li&gt;Cloudflare Access로 이메일 OTP 기반의 인증 게이트 구축&lt;/li&gt;
&lt;li&gt;FastAPI 백엔드에서 Cloudflare Access JWT를 검증하여 보안 강화&lt;/li&gt;
&lt;li&gt;민감한 터널 설정 파일을 안전하게 관리하는 팁&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  집 밖에서도 아들 재울 때 관리 패널을 열어볼까?
&lt;/h3&gt;

&lt;p&gt;아들과 주말마다 공원이며 캠핑을 다니는 즐거움은 말로 다 표현하기 어렵습니다. 그런데 가끔 집을 비울 때, 로컬에 띄워둔 FastAPI 기반의 관리 패널이 눈에 밟힐 때가 있더군요. 개발 중인 서비스의 상태를 확인하거나 간단한 설정을 변경할 일이 생기면, 꼭 집에 돌아와야만 한다는 점이 아쉽게 느껴졌습니다. 처음에는 간단히 포트 포워딩을 생각해보기도 했습니다. 공유기 설정을 만져 8770 포트를 외부로 열어두면 되겠지 싶었죠. 하지만 금세 머릿속에서 경고음이 울렸습니다. 민감한 관리 패널을 단순히 포트 포워딩으로 외부에 노출하는 것은 보안상 너무나 위험한 일이었습니다. 고정 IP가 없는 유동 IP 환경에서 접속 주소가 계속 바뀌는 것도 번거로운 일이고요. 괜히 이런 허술한 방법으로 외부 공격에 노출될까 봐 불안한 마음이 들었습니다. 주말에 아들과 즐겁게 시간을 보내면서도, 한편으로는 안전하게 외부에서 로컬 서비스를 관리할 방법에 대한 고민이 머릿속을 떠나지 않았습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  보안과 유연성을 고려하니 Cloudflare Tunnel이 답이더군요
&lt;/h3&gt;

&lt;p&gt;기본적인 포트 포워딩으로는 안 되겠다 싶어 다른 방법을 찾아보기 시작했습니다. VPN을 구축하는 것도 방법이겠지만, 간단한 관리 패널 접근을 위해 VPN 서버를 따로 운영하는 건 과하다고 생각했지요. 그러다 문득 Cloudflare Tunnel이 떠올랐습니다. 이전에 다른 프로젝트에서 잠깐 사용해본 경험이 있었는데, 외부에서 직접 서버 포트를 열 필요 없이 Cloudflare의 엣지 네트워크를 통해 안전하게 로컬 서비스를 노출할 수 있다는 점이 매력적이었습니다. Cloudflare Tunnel은 로컬에서 실행되는 &lt;code&gt;cloudflared&lt;/code&gt; 데몬이 Cloudflare 네트워크와 보안 터널을 생성하고, 이 터널을 통해 외부 요청이 로컬 서비스로 전달되는 방식입니다. 이렇게 하면 외부에서 직접 제 공유기나 서버의 IP 주소를 알 필요도 없고, 특정 포트를 열어둘 필요도 없으니 보안 관점에서 훨씬 유리합니다. 무엇보다 유동 IP 환경에서도 Cloudflare가 제공하는 도메인을 통해 안정적으로 접근할 수 있다는 점이 제 상황에 딱 맞았습니다. 관리의 용이성이나 내구성을 따져봐도 다른 대안들보다 훨씬 실용적이라고 판단했습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;cloudflared&lt;/code&gt;로 로컬 FastAPI 서비스 연결하기
&lt;/h3&gt;

&lt;p&gt;Cloudflare Tunnel을 사용하기로 결정한 후, 가장 먼저 할 일은 &lt;code&gt;cloudflared&lt;/code&gt; 클라이언트를 설치하고 터널을 생성하는 것이었습니다. &lt;code&gt;cloudflared&lt;/code&gt;는 macOS 환경에서 Homebrew로 쉽게 설치할 수 있었습니다. 터널을 생성한 다음, 로컬 FastAPI 서비스가 실행 중인 8770 포트를 Cloudflare 도메인과 연결하는 설정 파일을 만들어야 했습니다. 이 설정 파일은 YAML 형식으로 작성되며, 어떤 도메인으로 들어오는 요청을 로컬의 어떤 서비스로 연결할지 정의합니다. 예를 들어, &lt;code&gt;your-domain.com&lt;/code&gt;으로 들어오는 요청을 &lt;code&gt;http://localhost:8770&lt;/code&gt;으로 보내도록 설정하는 식이지요.&lt;/p&gt;

&lt;p&gt;이렇게 설정 파일을 작성하고 나면, 다음 명령어로 터널을 실행할 수 있습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cloudflared tunnel run YOUR_TUNNEL_NAME
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 명령을 실행하면 &lt;code&gt;cloudflared&lt;/code&gt; 데몬이 백그라운드에서 실행되며, Cloudflare 네트워크와 연결된 보안 터널을 유지합니다. 이때 중요한 점은 이 설정 파일에 터널 ID나 인증 관련 민감 정보가 포함될 수 있으므로, Git 저장소에 올릴 때는 반드시 &lt;code&gt;.gitignore&lt;/code&gt;에 추가하여 버전 관리 대상에서 제외해야 한다는 것입니다. 저는 이 부분을 초기 실수로 놓칠 뻔했다가 아차 싶어 바로 &lt;code&gt;.gitignore&lt;/code&gt;에 추가했습니다. 설정 파일의 내용은 다음과 같이 구성했습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;version: 0.0
hostname: your-domain.com
tunnel: YOUR_TUNNEL_UUID
metrics: 0.0.0.0:2006

ingress:
  - service: http://localhost:8770
  - service: http_status:404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;여기서 &lt;code&gt;your-domain.com&lt;/code&gt;은 제가 연결하고자 하는 도메인이고, &lt;code&gt;http://localhost:8770&lt;/code&gt;은 로컬 FastAPI 관리 패널이 실행되는 주소입니다. 마지막 &lt;code&gt;http_status:404&lt;/code&gt;는 위의 규칙에 해당하지 않는 모든 요청에 대해 404 에러를 반환하도록 하는 기본 규칙입니다. 이렇게 설정하니 제 도메인으로 접속했을 때 로컬 FastAPI 서비스가 외부에서 잘 보이는 것을 확인할 수 있었습니다. 물론 아직 보안 장치는 없는 상태였죠.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cloudflare Access로 보안의 문을 걸어 잠그다
&lt;/h3&gt;

&lt;p&gt;로컬 서비스가 외부로 노출되는 것을 확인했으니, 이제는 접근을 통제할 차례였습니다. 단순 노출만으로는 민감한 관리 패널을 안전하게 사용할 수 없기 때문입니다. Cloudflare Access는 이럴 때 아주 유용한 도구입니다. 특정 도메인에 대한 접근을 제한하고, 사용자 인증을 통과해야만 서비스에 접근할 수 있도록 해주는 보안 게이트웨이 역할을 합니다. 저는 이메일 OTP(One-Time Password) 방식을 사용하여 저만 접근할 수 있도록 설정했습니다.&lt;/p&gt;

&lt;p&gt;Cloudflare 대시보드에서 Access 애플리케이션을 생성하고, 정책을 추가했습니다. 정책 설정은 간단합니다. 특정 도메인(&lt;code&gt;your-domain.com&lt;/code&gt;)에 접근할 때, 미리 지정한 이메일 주소(제 개인 이메일)로 OTP를 보내고, 그 OTP를 입력해야만 통과시키는 방식입니다. 이렇게 설정하고 나니, 이제 제 도메인으로 접속하면 Cloudflare Access의 로그인 페이지가 먼저 나타나더군요. 등록된 이메일 주소를 입력하면 해당 이메일로 OTP가 전송되고, 그 코드를 웹페이지에 입력해야만 비로소 FastAPI 관리 패널에 접근할 수 있게 되었습니다. 이 정도면 웬만한 무단 접근 시도는 막을 수 있겠다는 안도감이 들었습니다. 휴대폰으로 OTP를 확인하고 입력하는 과정이 아주 잠깐 번거롭긴 하지만, 그만큼 얻는 보안의 가치가 훨씬 컸습니다. 공원 벤치에 앉아있다가도, 아들이 잠든 캠핑장 텐트 안에서도 안심하고 관리 패널에 접속할 수 있게 된 것이죠. 혹시 모를 상황에 대비해 든든한 방어막을 친 기분입니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  백엔드에서 JWT를 다시 확인하는 이중 방어
&lt;/h3&gt;

&lt;p&gt;Cloudflare Access로 1차 보안을 구축했지만, 여기서 한 발 더 나아가기로 했습니다. 만약 어떤 이유로든 Cloudflare Tunnel을 우회하거나 Access 정책이 제대로 작동하지 않는 상황이 발생한다면 어떻게 될까요? 직접적인 IP 주소는 숨겨져 있지만, 만약의 사태에 대비한 이중 방어가 필요하다고 생각했습니다. Cloudflare Access는 인증에 성공하면 요청 헤더에 JWT(JSON Web Token)를 추가하여 서비스로 전달해줍니다. 저는 이 JWT를 FastAPI 백엔드에서 다시 한번 검증하는 미들웨어를 추가하여, 터널을 우회한 직접 접근을 원천적으로 차단하기로 했습니다.&lt;/p&gt;

&lt;p&gt;FastAPI 애플리케이션에 미들웨어를 추가하여 모든 요청이 들어올 때마다 Cloudflare Access에서 발급한 JWT 헤더가 유효한지 확인하도록 했습니다. 유효하지 않은 JWT가 발견되면, 즉시 401 Unauthorized 응답을 반환하여 접근을 거부하는 방식입니다. 이렇게 하면 Access를 통과하지 않은 요청은 결코 제 관리 패널에 도달할 수 없게 됩니다. 제가 작성한 미들웨어의 기본적인 구조는 다음과 같습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;from fastapi import FastAPI, Request, HTTPException
from fastapi.security import HTTPBearer

app = FastAPI()
security = HTTPBearer()

@app.middleware("http")
async def verify_cloudflare_access_jwt(request: Request, call_next):
    # JWT 검증 로직 구현 (예: JWT 헤더 유효성, 서명 등)
    # 유효하지 않으면 HTTPException(status_code=401, detail="Unauthorized")
    response = await call_next(request)
    return response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;물론 위 코드의 &lt;code&gt;# JWT 검증 로직 구현&lt;/code&gt; 부분에는 실제 JWT의 서명 검증, 만료 시간 확인, 발급자 확인 등 구체적인 로직이 들어가야 합니다. Cloudflare Access에서 제공하는 공개 키를 사용하여 JWT의 유효성을 검증하는 것이 일반적인 방법입니다. 이 과정을 추가함으로써, 만에 하나 Cloudflare Access 자체에 문제가 생기거나, 혹은 터널 설정에 오류가 발생하여 외부에서 직접 로컬 서비스로 요청이 들어오는 상황이 발생하더라도, 백엔드에서 마지막 방어선 역할을 해주게 됩니다. 이렇게 이중으로 보안 장치를 마련하니 훨씬 더 마음이 놓이더군요. 체감상 안정성과 보안성이 크게 향상되었다고 느꼈습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  마무리
&lt;/h3&gt;

&lt;p&gt;로컬에서만 사용하던 FastAPI 기반의 관리 패널을 Cloudflare Tunnel과 Access를 활용하여 안전하게 외부로 노출하고, 심지어 백엔드 단에서 JWT를 검증하는 이중 방어막까지 구축하는 과정을 상세히 기록해 보았습니다. 처음에는 단순히 포트 포워딩을 고민했던 것과 비교하면, 훨씬 견고하고 유연한 접근 제어 시스템을 갖추게 된 셈입니다. 이 덕분에 이제는 집 밖 어디에서든, 아들과 즐거운 시간을 보내는 중에도 안심하고 제 서비스의 상태를 확인하고 관리할 수 있게 되었습니다. 혹시 저처럼 로컬 서비스를 안전하게 외부에 노출해야 하는 분들이 계시다면, 이 방법이 좋은 대안이 될 것이라고 생각합니다. 저의 경험이 여러분의 개발 여정에 작은 도움이 되었기를 바랍니다.&lt;/p&gt;

</description>
      <category>fastapi</category>
      <category>cloudflaretunnel</category>
      <category>cloudflareaccess</category>
      <category>jwt</category>
    </item>
    <item>
      <title>첫 출시 앱 심사: 인앱 결제(IAP) 버튼 무반응 문제와 해결 전략</title>
      <dc:creator>바람의평온</dc:creator>
      <pubDate>Wed, 15 Jul 2026 12:14:00 +0000</pubDate>
      <link>https://dev.to/kys7442/ceos-culsi-aeb-simsa-inaeb-gyeoljeiap-beoteun-mubaneung-munjewa-haegyeol-jeonryag-18l7</link>
      <guid>https://dev.to/kys7442/ceos-culsi-aeb-simsa-inaeb-gyeoljeiap-beoteun-mubaneung-munjewa-haegyeol-jeonryag-18l7</guid>
      <description>&lt;h2&gt;
  
  
  첫 출시 앱 심사: 인앱 결제(IAP) 버튼 무반응 문제와 해결 전략
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frfxfo6jec3kk8mf4s414.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frfxfo6jec3kk8mf4s414.png" alt="첫 출시 앱 심사: 인앱 결제(IAP) 버튼 무반응 문제와 해결 전략" width="800" height="420"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;앱의 첫 출시 심사를 준비하면서 인앱 결제(IAP) 기능 때문에 예상치 못한 난관에 부딪혔습니다. 야심 차게 준비한 구매 버튼이 심사 빌드에서 아무런 반응 없이 먹통이 되는 문제를 겪었던 것이죠. 이는 앱 심사 리젝 사유가 될 수 있는 치명적인 상황이었습니다. 결론부터 말씀드리자면, 스토어 백엔드에서 인앱 결제 상품이 아직 활성화되지 않아 발생하는 문제였고, 첫 출시 심사용 빌드에서는 인앱 결제 기능을 의도적으로 비활성화하여 이 문제를 우회했습니다. 이후 앱이 성공적으로 출시된 뒤 별도 업데이트를 통해 IAP 기능을 다시 활성화하는 단계적 접근으로 해결했습니다. 이 경험은 첫 출시 심사 시 인앱 결제 기능 관리의 중요성을 깨닫게 해주었습니다.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;대상:&lt;/strong&gt; Flutter 앱 개발자, 앱 출시 심사 과정에서 인앱 결제 문제로 고민하는 개발자&lt;br&gt;
&lt;strong&gt;난이도:&lt;/strong&gt; 중급&lt;/p&gt;
&lt;h3&gt;
  
  
  이 글에서 다루는 것
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;앱 첫 출시 심사 시 인앱 결제(IAP) 상품 미로드 문제의 원인&lt;/li&gt;
&lt;li&gt;스토어 활성화 시점과 앱 심사 타이밍 불일치에 대한 이해&lt;/li&gt;
&lt;li&gt;첫 출시 빌드에서 IAP 기능을 안전하게 비활성화하는 방법&lt;/li&gt;
&lt;li&gt;인앱 결제 상품 로드 실패 시 UI/UX를 저해하지 않도록 버튼을 처리하는 로직&lt;/li&gt;
&lt;li&gt;단계적인 인앱 결제 기능 활성화 전략&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  앱 심사 중 만난 인앱 결제(IAP) 버튼 무반응 문제
&lt;/h3&gt;

&lt;p&gt;저희 팀이 야심 차게 준비한 Flutter 앱의 첫 출시 심사 과정에서 예상치 못한 문제가 발생했습니다. 인앱 결제(IAP) 기능을 테스트하는데, 구매 버튼이 아무런 반응도 하지 않는 것이었습니다. 개발 환경에서는 분명히 잘 동작하던 기능이었기에, 심사용 빌드에서만 발생하는 이 현상에 당황스러움을 감출 수 없었죠. 이런 무반응 버튼은 사용자 경험을 해칠 뿐만 아니라, 앱 심사팀으로부터 리젝 사유가 될 가능성이 매우 높았습니다.&lt;/p&gt;

&lt;p&gt;앱 심사팀은 사용자 관점에서 앱을 테스트하기 때문에, 인앱 결제 기능이 정상적으로 동작하지 않으면 앱의 기능적 결함으로 판단하기 십상입니다. 저희는 즉시 로그를 확인하며 문제의 원인을 파악하기 시작했습니다. 로컬 환경과 심사 빌드 환경의 차이를 좁히는 것이 급선무였죠. 이 과정에서 인앱 결제와 관련된 중요한 사실을 깨달았습니다.&lt;/p&gt;

&lt;p&gt;결론적으로, 첫 출시 심사를 위해 제출된 빌드에서는 IAP 기능을 아예 비활성화하는 방식으로 이 문제를 우회하기로 결정했습니다. 기능이 아예 없으면 심사팀에서 테스트할 여지도 없으니, 적어도 리젝 사유는 되지 않을 것이라는 판단이었죠. 이후 앱이 성공적으로 출시되면 별도의 업데이트를 통해 IAP 기능을 다시 활성화하기로 계획을 세웠습니다.&lt;/p&gt;
&lt;h3&gt;
  
  
  스토어 백엔드와의 타이밍 불일치: 상품 미로드의 원인
&lt;/h3&gt;

&lt;p&gt;구매 버튼이 무반응이었던 근본적인 원인은 앱이 스토어 백엔드로부터 인앱 결제 상품 정보를 제대로 로드하지 못했기 때문이었습니다. 첫 출시 심사 시점에는 아직 스토어 콘솔에 등록된 인앱 결제 상품들이 ‘활성화’되지 않은 상태였던 것이죠. 스토어 심사 프로세스와 인앱 상품의 활성화 타이밍이 동기화되지 않는다는 사실을 미처 간과하고 있었던 것입니다.&lt;/p&gt;

&lt;p&gt;저희 앱의 인앱 결제 로직은 상품 정보를 성공적으로 로드했을 때만 구매 버튼을 활성화하도록 구현되어 있었습니다. 만약 상품 목록이 비어 있으면, 즉 &lt;code&gt;_products&lt;/code&gt; 리스트가 비어 있으면 구매 버튼의 &lt;code&gt;onPressed&lt;/code&gt; 콜백이 &lt;code&gt;null&lt;/code&gt;로 설정되어 버튼이 비활성화되거나 아무런 동작을 하지 않도록 되어 있었죠. 이는 평소에는 안전한 구현 방식이지만, 상품 로드 자체가 실패하는 특수한 상황에서는 의도치 않은 문제를 발생시켰습니다.&lt;/p&gt;

&lt;p&gt;결국, 앱은 상품이 없다고 판단했고, 그에 따라 구매 버튼이 무반응 상태가 된 것이었습니다. 이 문제를 해결하기 위해서는 단순히 상품이 로드되지 않았을 때의 UI 처리뿐만 아니라, 첫 출시 심사라는 특수한 상황에서 인앱 결제 상품 활성화 여부를 전략적으로 다룰 필요가 있음을 깨닫게 되었습니다.&lt;/p&gt;
&lt;h3&gt;
  
  
  첫 출시 빌드를 위한 IAP 기능 일시 비활성화 전략
&lt;/h3&gt;

&lt;p&gt;저희는 첫 출시 심사용 빌드에 한해 인앱 결제 기능을 완전히 비활성화하는 전략을 택했습니다. 이를 위해 코드 내부에 간단한 상수를 정의하여 인앱 결제 기능의 활성화 여부를 제어하도록 만들었습니다. 다음과 같은 플래그를 추가했죠.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;const bool kEnableIapForFirstRelease = false; // 첫 출시 심사용 빌드에서 false로 설정
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;이 &lt;code&gt;kEnableIapForFirstRelease&lt;/code&gt; 상수는 첫 출시 심사를 위한 빌드를 만들 때만 &lt;code&gt;false&lt;/code&gt;로 설정하고, 앱이 스토어에 출시된 이후에는 &lt;code&gt;true&lt;/code&gt;로 변경하여 업데이트 빌드를 제출하는 방식으로 활용했습니다. 이 플래그를 통해 인앱 결제 초기화 로직을 조건부로 실행하도록 했습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if (kEnableIapForFirstRelease) { await InAppPurchase.instance.initialize(); }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;위 코드처럼 &lt;code&gt;initialize()&lt;/code&gt; 호출 자체를 조건부로 감싸면, &lt;code&gt;kEnableIapForFirstRelease&lt;/code&gt;가 &lt;code&gt;false&lt;/code&gt;일 때는 인앱 결제 모듈이 아예 초기화되지 않아 상품 로드를 시도조차 하지 않게 됩니다. 이는 불필요한 네트워크 요청과 에러 발생 가능성을 원천적으로 차단하는 효과를 가져다주었습니다. 덕분에 심사 빌드에서는 인앱 결제 관련 로직이 전혀 실행되지 않아 안정성을 확보할 수 있었지요.&lt;/p&gt;

&lt;h3&gt;
  
  
  사용자 경험을 해치지 않는 구매 버튼 UI 처리
&lt;/h3&gt;

&lt;p&gt;인앱 결제 기능을 일시적으로 비활성화하더라도, 사용자 인터페이스(UI)는 여전히 적절하게 처리되어야 합니다. 단순히 버튼이 사라지거나 오류 메시지만 표시되는 것보다는, 기능이 현재 사용 불가능하다는 것을 명확하게 인지시키는 것이 중요합니다. 저희는 &lt;code&gt;kEnableIapForFirstRelease&lt;/code&gt; 플래그와 함께 &lt;code&gt;_products&lt;/code&gt; 리스트의 상태를 이용하여 구매 버튼의 동작을 제어했습니다.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ElevatedButton(
  onPressed: _products.isNotEmpty &amp;amp;&amp;amp; kEnableIapForFirstRelease ? () =&amp;gt; _buyProduct() : null,
  child: Text('구매하기'),
)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;위 코드에서 보듯이, 구매 버튼의 &lt;code&gt;onPressed&lt;/code&gt; 콜백은 두 가지 조건이 모두 충족될 때만 활성화됩니다. 첫째, &lt;code&gt;_products.isNotEmpty&lt;/code&gt;는 인앱 결제 상품 목록이 비어 있지 않아야 한다는 것을 의미합니다. 상품이 로드되지 않았으면 구매 버튼이 활성화될 이유가 없으니까요. 둘째, &lt;code&gt;kEnableIapForFirstRelease&lt;/code&gt;는 인앱 결제 기능 자체가 활성화되어 있어야 함을 나타냅니다. 이 두 조건 중 하나라도 만족하지 않으면 &lt;code&gt;onPressed&lt;/code&gt;는 &lt;code&gt;null&lt;/code&gt;이 되어 버튼은 자동으로 비활성화됩니다.&lt;/p&gt;

&lt;p&gt;이러한 조건부 로직 덕분에 첫 출시 심사 빌드에서는 &lt;code&gt;kEnableIapForFirstRelease&lt;/code&gt;가 &lt;code&gt;false&lt;/code&gt;였으므로, 설령 &lt;code&gt;_products&lt;/code&gt;가 채워지더라도 버튼이 비활성화 상태를 유지했습니다. 이는 심사팀이 무반응 버튼을 발견하고 리젝하는 상황을 효과적으로 방지할 수 있었죠. 버튼이 비활성화되면 사용자에게 명확하게 '지금은 구매할 수 없다'는 메시지를 전달하는 효과도 있습니다. 앱의 기능은 명확하게 전달하되, 심사 과정에서는 불필요한 오해를 사지 않도록 설계한 셈입니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  안정적인 출시와 인앱 결제 기능의 단계적 활성화
&lt;/h3&gt;

&lt;p&gt;인앱 결제 기능이 비활성화된 첫 번째 빌드는 별다른 문제 없이 앱 심사를 통과했습니다. 이로써 우리는 첫 출시의 가장 큰 허들 중 하나를 안전하게 넘길 수 있었죠. 심사 통과 후 앱이 공식적으로 스토어에 출시되었을 때, 인앱 결제 상품들도 스토어 백엔드에서 정상적으로 활성화되는 것을 확인했습니다. 이제는 인앱 결제 기능을 다시 켜야 할 때가 된 것이었습니다.&lt;/p&gt;

&lt;p&gt;저희는 &lt;code&gt;kEnableIapForFirstRelease&lt;/code&gt; 상수를 &lt;code&gt;true&lt;/code&gt;로 변경하고, 인앱 결제 기능을 활성화한 새로운 업데이트 빌드를 제출했습니다. 이 빌드는 다시 한번 스토어 심사를 거쳐야 했지만, 이미 상품들이 활성화된 상태였기 때문에 별다른 이슈 없이 심사를 통과할 수 있었습니다. 이후 앱 내에서 모든 인앱 결제 상품이 정상적으로 로드되고, 구매 프로세스 또한 원활하게 작동하는 것을 최종적으로 확인했습니다.&lt;/p&gt;

&lt;p&gt;이러한 단계적 접근 방식은 첫 출시 심사 과정에서 발생할 수 있는 잠재적인 문제를 효과적으로 회피하며, 안정적인 앱 출시를 가능하게 했습니다. 만약 처음부터 인앱 결제 기능을 완전히 활성화한 채로 심사를 진행했다면, 상품 미로드로 인한 리젝과 그에 따른 출시 지연을 겪었을지도 모릅니다. 저희의 경험은 첫 출시 심사 시 인앱 결제 상품의 스토어 활성화 시점과 앱 심사 타이밍이 항상 일치하지 않을 수 있다는 점을 상기시켜 주었습니다.&lt;/p&gt;

&lt;h3&gt;
  
  
  마치며
&lt;/h3&gt;

&lt;p&gt;앱의 첫 출시 심사는 여러 변수가 많아 개발자에게 긴장감을 안겨주는 과정입니다. 특히 인앱 결제와 같은 외부 의존성이 큰 기능은 스토어 백엔드와의 동기화 문제로 예상치 못한 난관을 초래할 수 있습니다. 저희는 첫 출시 심사 시 인앱 결제 상품 미로드로 인한 버튼 무반응 문제를 겪었지만, IAP 기능을 일시적으로 비활성화하고 단계적으로 활성화하는 전략을 통해 이를 성공적으로 해결했습니다. 이 경험이 첫 출시를 준비하는 다른 Flutter 개발자분들께도 도움이 되기를 바랍니다.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>iap</category>
    </item>
  </channel>
</rss>
