<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>개발 기록</title>
        <link>https://velog.io/</link>
        <description>github: @dlwldP</description>
        <lastBuildDate>Tue, 03 Nov 2026 01:54:48 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>개발 기록</title>
            <url>https://velog.velcdn.com/images/jiye_1124/profile/d97757c5-b113-48db-806d-d8e5a1f1f84e/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 개발 기록. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/jiye_1124" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[# "AWS 얼마야?" 4편 — 읽기 쉽게 다듬고, v2 로드맵을 끝내다]]></title>
            <link>https://velog.io/@jiye_1124/AWS-%EC%96%BC%EB%A7%88%EC%95%BC-4%ED%8E%B8-%EC%9D%BD%EA%B8%B0-%EC%89%BD%EA%B2%8C-%EB%8B%A4%EB%93%AC%EA%B3%A0-v2-%EB%A1%9C%EB%93%9C%EB%A7%B5%EC%9D%84-%EB%81%9D%EB%82%B4%EB%8B%A4</link>
            <guid>https://velog.io/@jiye_1124/AWS-%EC%96%BC%EB%A7%88%EC%95%BC-4%ED%8E%B8-%EC%9D%BD%EA%B8%B0-%EC%89%BD%EA%B2%8C-%EB%8B%A4%EB%93%AC%EA%B3%A0-v2-%EB%A1%9C%EB%93%9C%EB%A7%B5%EC%9D%84-%EB%81%9D%EB%82%B4%EB%8B%A4</guid>
            <pubDate>Tue, 03 Nov 2026 01:54:48 GMT</pubDate>
            <description><![CDATA[<h1 id="aws-얼마야-4편--읽기-쉽게-다듬고-v2-로드맵을-끝내다">&quot;AWS 얼마야?&quot; 4편 — 읽기 쉽게 다듬고, v2 로드맵을 끝내다</h1>
<blockquote>
<p>🔗 사이트: <a href="https://dlwldp.github.io/Awscal/">https://dlwldp.github.io/Awscal/</a>
🔗 GitHub: <a href="https://github.com/dlwldP/Awscal">https://github.com/dlwldP/Awscal</a>
📝 1편: 초보자용 AWS 비용 계산기를 만들었다 (MVP · 직접 계산기 · 토스 스타일 디자인)
📝 2편: 기능을 더하다가, 덜어내는 법을 배웠다
📝 3편: 숫자를 검증하고, 화면을 비우고, 피드백 하나를 기능으로</p>
</blockquote>
<p>3편까지는 &quot;덜어내기&quot;가 주제였다. 4편은 두 갈래다. 먼저 <strong>글자 자체를 읽기 쉽게</strong> 다듬었고, 그다음 미뤄두던 <strong>v2 마일스톤 이슈 4개를 모두 끝냈다.</strong></p>
<p>기능을 네 개나 넣으면서도 2편에서 정한 질문은 계속 붙들고 있었다. &quot;이게 처음 AWS를 쓰는 사람한테 꼭 필요한가?&quot; 그래서 새 기능은 전부 <strong>눌러야 보이는 곳</strong>에 두었다.</p>
<hr>
<h2 id="1-기능-말고-글자부터">1. 기능 말고, 글자부터</h2>
<p>화면을 휴대폰으로 다시 띄워서 &quot;덜어낼 것&quot;이 아니라 &quot;읽기 어려운 것&quot;을 찾았다. 숫자로 확인되는 문제가 몇 개 나왔다.</p>
<h3 id="회색-설명-글씨가-너무-연했다">회색 설명 글씨가 너무 연했다</h3>
<p>설명 글씨 색(<code>#8b95a1</code>)이 흰 배경에서 <strong>대비 약 3:1</strong>이었다. 웹 접근성 기준(WCAG AA)은 작은 글씨에 4.5:1 이상을 권한다. 회색 배경인 꼬리말에서는 2.7:1까지 내려갔다.</p>
<p>문제는 이 연한 색이 <strong>초보자에게 가장 중요한 설명</strong>에 쓰이고 있었다는 점이다. 놓치기 쉬운 비용의 한 줄 설명, 입력 칸 도움말, 템플릿 설명이 모두 이 색이었다.</p>
<table>
<thead>
<tr>
<th>색</th>
<th>흰 배경</th>
<th>회색 배경</th>
<th>파란 배경</th>
</tr>
</thead>
<tbody><tr>
<td>전 <code>#8b95a1</code></td>
<td>3.04</td>
<td>2.76</td>
<td>2.71</td>
</tr>
<tr>
<td>후 <code>#646f7c</code></td>
<td>5.11</td>
<td>4.64</td>
<td>4.55</td>
</tr>
</tbody></table>
<p>색 변수 한 줄만 바꾸면 사이트 전체에 적용된다. 처음부터 색을 CSS 변수로 만들어 둔 덕을 여기서 또 봤다. 다크 모드 값도 같이 맞췄다.</p>
<h3 id="글자-크기가-30가지였다">글자 크기가 30가지였다</h3>
<p>결과 화면 한 장에 <strong>글자 크기·굵기 조합이 30가지 정도</strong> 쓰이고 있었다. 기능을 붙일 때마다 그때그때 <code>0.88rem</code>, <code>0.9rem</code>, <code>0.92rem</code>… 하고 정한 결과다. 비슷한 크기가 섞여 있으면 무엇이 제목이고 무엇이 본문인지 흐려진다.</p>
<pre><code class="language-css">--fs-amount: 2rem;     /* 32px 큰 금액 */
--fs-title: 1.625rem;  /* 26px 페이지 제목 */
--fs-card: 1.125rem;   /* 18px 카드 제목 */
--fs-body: 0.9375rem;  /* 15px 본문 */
--fs-small: 0.875rem;  /* 14px 보조 설명 */</code></pre>
<p>다섯 단계로 정하고 CSS의 모든 <code>font-size</code>를 이 변수로 바꿨다. 12~13px이던 도움말·배지·부가세 표시는 14px로 커졌다. 아이콘·화살표와 입력 칸은 건드리지 않았다. 입력 칸이 16px보다 작으면 아이폰이 화면을 확대해버리기 때문이다.</p>
<h3 id="나머지-다듬기">나머지 다듬기</h3>
<ul>
<li><strong>카드 안 소제목</strong>: &quot;이 금액에 들어 있어요&quot; 같은 소제목이 본문보다 연해서 오히려 묻혀 있었다. 진한 색으로 바꾸고 위 여백을 늘렸다.</li>
<li><strong>중복 문구</strong>: Lightsail 구성에서 &quot;데이터 전송 · 번들 포함(허용량 넘으면 추가 과금)&quot;과 &quot;번들 데이터 전송 허용량을 넘는 전송 요금&quot;이 같은 카드에 같이 나왔다. 하나만 남겼다.</li>
<li><strong>만든 다음 꼭 할 일</strong>: 설명까지 4개가 다 펼쳐져 있었다. 제목 4줄만 보이고 누르면 설명이 펼쳐지게 바꿨다.</li>
<li><strong>직접 계산기</strong>: 서비스를 두 개만 담아도 화면 4.8개 길이였다. 담은 뒤에는 &quot;서비스 더 담기&quot;를 설명 없는 작은 버튼으로 바꾸고, 입력 칸 도움말은 첫 문장만 보이게 했다. 화면 3.7개로 줄었다.</li>
</ul>
<p>모두 <strong>내용은 하나도 지우지 않았다.</strong> 접거나 줄인 글은 누르면 그대로 있다.</p>
<hr>
<h2 id="2-퀴즈-아이디어-일단-거절하고-바꿔서-받기">2. 퀴즈 아이디어, 일단 거절하고 바꿔서 받기</h2>
<p>&quot;용어집 퀴즈 기능은 어때?&quot;라는 생각이 들었다. 검토해보니 이 사이트와 잘 맞지 않았다.</p>
<ul>
<li>사람들은 공부하러 오는 게 아니라 &quot;내 서비스 한 달에 얼마?&quot;를 알러 온다.</li>
<li>용어 사전은 공부용이 아니라, 결과를 읽다가 모르는 말을 눌러보는 용도다.</li>
<li>자격증 공부를 하는 사람에게는 이미 더 좋은 문제집 앱이 많다.</li>
</ul>
<p>대신 이 사이트의 핵심인 <strong>비용 감각</strong>을 가르치는 퀴즈라면 의미가 있겠다고 봤다. 그래서 용어 퀴즈는 버리고, &quot;이 구성 한 달에 얼마일까?&quot; 퀴즈 아이디어를 v2 이슈로 정리했다. 처음에는 &quot;분석 신호를 보고 시작한다&quot;는 착수 조건도 걸어뒀다.</p>
<hr>
<h2 id="3-v2-마일스톤-4개를-끝내다">3. v2 마일스톤 4개를 끝내다</h2>
<p>마일스톤에는 이슈 4개가 있었다. 순서는 <strong>작은 것부터</strong> 정했다.</p>
<ol>
<li>&quot;이 구성 만들기&quot; 가이드 — 가격 계산이 없어서 가장 가볍다</li>
<li>프리티어 반영</li>
<li>&quot;얼마일까?&quot; 퀴즈</li>
<li>AI 챗봇 + Bedrock 템플릿 — 가격 수집부터 손봐야 해서 가장 크다</li>
</ol>
<h3 id="①-이-구성-만들기-비용을-봤으면-다음은-어떻게-만들지">① 이 구성 만들기: 비용을 봤으면 다음은 &quot;어떻게 만들지&quot;</h3>
<p>결과 화면 맨 위 추천 카드에 <strong>&quot;🛠 이 구성 만들기 · 단계별 가이드&quot;</strong> 버튼을 달았다.</p>
<pre><code>시작하기 전에   계정 만들기 · 예산 알림부터 켜기
만드는 순서     1. Lightsail 콘솔 열기  2. WordPress 인스턴스 만들기  3. 고정 IP 연결 …
다 쓴 뒤 정리   인스턴스 삭제 · 고정 IP 해제 · 스냅샷 삭제
공식 문서</code></pre><ul>
<li>모든 구성안이 아니라 <strong>템플릿별 추천 구성 5개만</strong> 먼저 썼다. 가이드는 정확해야 하는데, 콘솔 화면은 자주 바뀐다. 쓸 양이 적어야 관리할 수 있다.</li>
<li>같은 이유로 버튼 이름보다 <strong>&quot;무엇을 하는지&quot;</strong> 위주로 썼다. &quot;모델 액세스 메뉴에서 쓸 모델을 확인해요&quot;처럼.</li>
<li>가이드는 <code>data/build-guides.json</code>에서 관리한다. 문장 속 명령어는 백틱으로 감싸면 코드로 보이게 했다.</li>
<li>테스트가 &quot;템플릿마다 맨 위 구성에 가이드가 있는지&quot;를 확인한다. 템플릿을 추가하고 가이드를 빠뜨리면 테스트가 실패한다.</li>
</ul>
<h3 id="②-프리티어-항상-무료만-정직하게">② 프리티어: &quot;항상 무료&quot;만, 정직하게</h3>
<p>프리티어는 2편부터 &quot;정책이 자주 바뀌어서 보류&quot;였다. 실제로 AWS는 신규 계정 혜택을 크레딧 중심으로 바꾸는 등 정책을 자주 손본다.</p>
<p>그래서 범위를 좁혔다. <strong>가입 기간과 상관없이 매달 무료인 사용량(&quot;항상 무료&quot;)만</strong> 뺀다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>매달 무료</th>
</tr>
</thead>
<tbody><tr>
<td>인터넷 데이터 전송</td>
<td>100GB (계정 전체)</td>
</tr>
<tr>
<td>CloudFront</td>
<td>1TB · 1,000만 건</td>
</tr>
<tr>
<td>Lambda</td>
<td>100만 건 · 40만 GB-초</td>
</tr>
<tr>
<td>DynamoDB 저장</td>
<td>25GB</td>
</tr>
</tbody></table>
<p>&quot;계산 조건 → 바꾸기&quot;에서 <strong>프리티어: 반영 안 함 / 항상 무료 빼기</strong>를 고를 수 있다. 신경 쓴 부분은 세 가지다.</p>
<ul>
<li><strong>원래 비용 줄은 그대로 두고, &quot;항상 무료&quot; 줄을 음수로 따로 붙였다.</strong> 무엇이 얼마나 빠졌는지 보여야 믿을 수 있다.</li>
<li><strong>같은 한도는 나눠 쓴다.</strong> 데이터 전송 100GB는 계정 전체 한도라서, 80GB짜리 두 줄이 있으면 100GB만 빠져야 한다. 직접 계산기도 항목마다 빼지 않고 담은 목록 전체에서 한 번만 뺀다.</li>
<li>무료 사용량은 <code>data/free-tier.json</code>에 정책 기준일과 같이 둔다. 정책이 바뀌면 이 파일만 고치면 된다.</li>
</ul>
<p>서버리스 API 개인용은 $0.60이 $0.15로 줄었다. 남은 $0.15는 API Gateway 요금인데, 이건 12개월 한정 무료라 일부러 빼지 않았다.</p>
<h3 id="③-얼마일까-퀴즈">③ 얼마일까? 퀴즈</h3>
<p>착수 조건을 걸어뒀지만, 결국 바로 만들기로 했다.</p>
<blockquote>
<p>🛠️ 개발용 단일 서버 · Lightsail
규모: 개인용 (혼자 실습·공부용)
한 달에 얼마일까요? ① 약 11,000원 ② 약 28,000원 ③ 약 66,000원</p>
</blockquote>
<ul>
<li><strong>문제 데이터를 따로 만들지 않았다.</strong> 기존 템플릿 × 규모 × 구성안 계산에서 자동으로 뽑는다. 가격이 매주 갱신되면 정답도 같이 바뀐다.</li>
<li>보기는 원화(부가세 포함)를 앞 두 자리로 반올림했다. 10,780원이 아니라 11,000원이다.</li>
<li>처음에는 오답을 &quot;정답의 1/3, 3배&quot;로 만들었다. 그런데 이러면 <strong>정답이 항상 가운데 값</strong>이다. 몇 문제 풀면 눈치챈다. 오답 배수를 세 가지로 섞어서 정답이 가장 쌀 때, 가운데일 때, 가장 비쌀 때가 골고루 나오게 했다. 테스트도 이걸 확인한다.</li>
<li>고르면 실제 금액과 그 구성의 <strong>놓치기 쉬운 비용</strong>을 보여준다. 결국 이 퀴즈도 숨은 비용을 알려주는 또 하나의 입구다.</li>
</ul>
<h3 id="④-ai-챗봇--bedrock-템플릿">④ AI 챗봇 + Bedrock 템플릿</h3>
<p>마지막 이슈는 잠깐 멈칫했다. &quot;LLM 에이전트를 만드는 건가?&quot; 싶었기 때문이다.</p>
<p>정리해보니 그게 아니었다. 사이트가 AI를 부르는 게 아니라, <strong>사용자가 AWS에서 AI 챗봇을 만들 때 얼마 나오는지 계산하는 템플릿</strong>이다. 서버리스 API 템플릿과 같은 계산기이고, Bedrock의 토큰 단가를 곱할 뿐이다.</p>
<h4 id="가격-수집이-제일-까다로웠다">가격 수집이 제일 까다로웠다</h4>
<p>Bedrock 요금은 오퍼 두 곳에 나뉘어 있었다.</p>
<table>
<thead>
<tr>
<th>모델</th>
<th>오퍼</th>
<th>usageType 예시</th>
<th>단위</th>
</tr>
</thead>
<tbody><tr>
<td>Amazon Nova Micro·Lite·Pro</td>
<td><code>AmazonBedrock</code></td>
<td><code>APN2-NovaMicro-input-tokens</code></td>
<td>1K 토큰당</td>
</tr>
<tr>
<td>Claude Haiku·Sonnet</td>
<td><code>AmazonBedrockFoundationModels</code></td>
<td><code>APN2-MP:APN2_input_tokens_global_standard-Units</code></td>
<td>1M 토큰당</td>
</tr>
</tbody></table>
<ul>
<li><strong>단위가 다르다.</strong> 저장할 때 모두 &quot;100만 토큰당 USD&quot;로 맞췄다.</li>
<li>Claude는 usageType만으로는 모델을 구분할 수 없어서 <code>Model</code> 열로 한 번 더 걸렀다.</li>
<li><strong>도쿄에는 Claude Sonnet이 없었다.</strong> 기존 수집 스크립트는 &quot;하나라도 못 찾으면 실패&quot;가 원칙이다. Sonnet만 <code>optional</code>로 표시해서, 없는 리전에서는 선택지에서만 빠지게 했다.</li>
<li>기존 원칙은 그대로다. 입력·출력 단가 짝이 안 맞거나 필수 모델을 못 찾으면 실패하고, 기존 가격을 유지한다.</li>
</ul>
<p>수집을 실제로 다시 돌려서 Bedrock 말고 <strong>다른 단가는 하나도 바뀌지 않은 것</strong>을 비교해 확인한 뒤 커밋했다.</p>
<h4 id="템플릿">템플릿</h4>
<table>
<thead>
<tr>
<th>규모</th>
<th>하루 대화</th>
<th>대화 기록 없음</th>
<th>대화 기록 저장</th>
</tr>
</thead>
<tbody><tr>
<td>개인용</td>
<td>50번</td>
<td>$0.52</td>
<td>$0.79</td>
</tr>
<tr>
<td>소규모</td>
<td>500번</td>
<td>$5.67</td>
<td>$5.97</td>
</tr>
<tr>
<td>중규모</td>
<td>5,000번</td>
<td>$71.73</td>
<td>$73.31</td>
</tr>
</tbody></table>
<p>기본 모델은 Claude Haiku 5.5다. 소규모에서 모델만 바꾸면 Nova Micro $2.33, Claude Sonnet $105.42가 된다. <strong>같은 사용량인데 수십 배 차이.</strong> 이 템플릿이 보여주고 싶은 게 바로 이것이다.</p>
<p>만들면서 엔진에 작은 기능 하나가 필요했다. 템플릿은 <code>{{preset.requests}}</code>처럼 프리셋 값을 그대로 꽂는 방식이었다. 그런데 챗봇은 &quot;하루 대화 수&quot;를 바꾸면 API Gateway·Lambda의 <strong>월 요청 수</strong>도 따라 바뀌어야 한다. 그래서 자리표시자에 곱셈을 넣었다.</p>
<pre><code class="language-json">{ &quot;type&quot;: &quot;lambda&quot;, &quot;requests&quot;: &quot;{{preset.dailyChats*30}}&quot; }</code></pre>
<p>초보자에게 알려줄 것도 몇 가지 넣었다.</p>
<ul>
<li><strong>Lambda는 AI 답변을 기다리는 시간도 과금된다.</strong> 그래서 평균 실행 시간을 3초로 잡았다.</li>
<li>놓치기 쉬운 비용에 <strong>&quot;AI 모델 토큰&quot;</strong>을 추가했다. 이전 대화를 매번 같이 보내서 대화가 길어질수록 입력 토큰이 쌓이고, 출력 토큰은 입력보다 4~5배 비싸다.</li>
<li>용어 사전에 <strong>AI</strong> 분류를 만들었다. Bedrock, 토큰, 입력 토큰, 출력 토큰.</li>
</ul>
<hr>
<h2 id="4-마지막으로-하나-더-덜어내기">4. 마지막으로 하나 더 덜어내기</h2>
<p>설정 화면(&quot;얼마나 많이 쓰나요?&quot;)에 <strong>&quot;개인용&quot;과 &quot;서울&quot;이 미리 체크</strong>돼 있었다. 편하라고 넣은 기본값인데, 다시 보니 사용자가 규모를 생각해보지 않고 그냥 넘어가게 만든다.</p>
<p>그래서 아무것도 미리 고르지 않게 바꿨다. 대신 버튼이 무엇을 골라야 하는지 알려준다.</p>
<pre><code>규모와 리전을 골라주세요  →  리전을 골라주세요  →  비용 확인하기</code></pre><hr>
<h2 id="숫자로-보면">숫자로 보면</h2>
<table>
<thead>
<tr>
<th></th>
<th>3편</th>
<th>4편</th>
</tr>
</thead>
<tbody><tr>
<td>템플릿</td>
<td>5종</td>
<td><strong>6종</strong> (AI 챗봇)</td>
</tr>
<tr>
<td>직접 계산 서비스</td>
<td>16종</td>
<td><strong>17종</strong> (Bedrock)</td>
</tr>
<tr>
<td>용어 사전</td>
<td>40개</td>
<td><strong>44개</strong></td>
</tr>
<tr>
<td>놓치기 쉬운 비용</td>
<td>8가지</td>
<td><strong>9가지</strong></td>
</tr>
<tr>
<td>교차 검증 체크리스트</td>
<td>66개 조합</td>
<td><strong>78개 조합</strong></td>
</tr>
<tr>
<td>테스트</td>
<td>JS 41 + Python 8</td>
<td><strong>JS 46 + Python 10</strong></td>
</tr>
</tbody></table>
<ul>
<li>글자 크기 약 30가지 → 5단계, 보조 설명 대비 3.0:1 → 5.1:1</li>
<li>v2 마일스톤 이슈 4개 모두 닫음</li>
<li>여전히 빌드 없는 순수 HTML/CSS/JS, 외부 패키지 없음, 운영비 0원</li>
</ul>
<h2 id="다음에-할-것">다음에 할 것</h2>
<ul>
<li><strong>교차 검증 늘리기:</strong> 지금 2건이다. 새로 생긴 AI 챗봇 조합도 공식 계산기와 비교해보려 한다.</li>
<li><strong>가이드 직접 따라 해보기:</strong> &quot;이 구성 만들기&quot; 가이드를 실제 콘솔에서 처음부터 따라 해보고 고치려 한다.</li>
<li><strong>Bedrock 모델 세대 교체 대비:</strong> 모델 이름이 바뀌면 매주 가격 갱신이 실패한다. 실패는 안전하게(기존 가격 유지) 하도록 되어 있지만, 알림을 받는 방법을 고민 중이다.</li>
</ul>
<h2 id="마무리">마무리</h2>
<p>4편에서 배운 건 두 가지다.</p>
<p>하나는 <strong>가독성도 숫자로 잴 수 있다</strong>는 것. &quot;좀 연한 것 같은데?&quot;가 아니라 &quot;대비 3:1&quot;이라고 말할 수 있으니, 고칠지 말지 고민할 필요가 없었다.</p>
<p>다른 하나는 <strong>기능을 넣는 방식</strong>이다. 이번에 기능 네 개를 넣었지만, 처음 화면은 거의 그대로다. 가이드는 추천 카드의 버튼 하나, 프리티어는 &quot;바꾸기&quot; 안의 선택 하나, 퀴즈는 메뉴 한 줄, AI 챗봇은 템플릿 목록 한 줄. 필요한 사람만 눌러서 들어가게 하면, 기능을 더해도 초보자가 읽어야 할 양은 늘지 않는다.</p>
<p>👉 <strong><a href="https://dlwldp.github.io/Awscal/">https://dlwldp.github.io/Awscal/</a></strong> 에서 써보고, 사이트의 &quot;의견 보내기&quot;로 피드백 주시면 감사하겠습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# "AWS 얼마야?" 3편 — 숫자를 검증하고, 화면을 비우고, 피드백 하나를 기능으로]]></title>
            <link>https://velog.io/@jiye_1124/AWS-%EC%96%BC%EB%A7%88%EC%95%BC-3%ED%8E%B8-%EC%88%AB%EC%9E%90%EB%A5%BC-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B3%A0-%ED%99%94%EB%A9%B4%EC%9D%84-%EB%B9%84%EC%9A%B0%EA%B3%A0-%ED%94%BC%EB%93%9C%EB%B0%B1-%ED%95%98%EB%82%98%EB%A5%BC-%EA%B8%B0%EB%8A%A5%EC%9C%BC%EB%A1%9C</link>
            <guid>https://velog.io/@jiye_1124/AWS-%EC%96%BC%EB%A7%88%EC%95%BC-3%ED%8E%B8-%EC%88%AB%EC%9E%90%EB%A5%BC-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B3%A0-%ED%99%94%EB%A9%B4%EC%9D%84-%EB%B9%84%EC%9A%B0%EA%B3%A0-%ED%94%BC%EB%93%9C%EB%B0%B1-%ED%95%98%EB%82%98%EB%A5%BC-%EA%B8%B0%EB%8A%A5%EC%9C%BC%EB%A1%9C</guid>
            <pubDate>Mon, 02 Nov 2026 04:40:06 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>🔗 사이트: <a href="https://dlwldp.github.io/Awscal/">https://dlwldp.github.io/Awscal/</a>
🔗 GitHub: <a href="https://github.com/dlwldP/Awscal">https://github.com/dlwldP/Awscal</a>
📝 1편: 초보자용 AWS 비용 계산기를 만들었다 (MVP · 직접 계산기 · 토스 스타일 디자인)
📝 2편: 기능을 더하다가, 덜어내는 법을 배웠다</p>
</blockquote>
<p>2편 마지막에 이렇게 적었다. &quot;기능을 넣을 때마다 물어보려고 한다. 이게 처음 AWS를 쓰는 사람한테 꼭 필요한가?&quot;</p>
<p>3편은 그 질문을 실제로 계속 던지면서 한 작업들이다. 새 기능은 딱 하나만 넣었고, 나머지는 <strong>숫자를 믿을 수 있게</strong> 만들고 <strong>화면을 더 비우는</strong> 일이었다.</p>
<hr>
<h2 id="1-이-숫자-정말-맞나-공식-계산기와-교차-검증">1. 이 숫자, 정말 맞나? 공식 계산기와 교차 검증</h2>
<p>계산기가 틀리면 다른 기능은 의미가 없다. 그래서 2편에서 미뤄둔 <a href="https://calculator.aws/">AWS Pricing Calculator</a>와의 교차 검증부터 했다.</p>
<h3 id="체크리스트는-자동으로">체크리스트는 자동으로</h3>
<p>템플릿 × 규모 × 구성안 조합마다 <strong>우리 계산값</strong>과 <strong>공식 계산기에 넣을 입력값</strong>을 표로 뽑는 스크립트를 만들었다. MVP 기준 리전(서울·버지니아)만 해도 66개 조합이다.</p>
<pre><code class="language-bash">npm run validation   # docs/validation.md 다시 만들기</code></pre>
<p>공식 계산기 금액을 <code>validation-results.json</code>에 적고 다시 돌리면 차이(%)와 ±10% 통과 여부가 채워진다.</p>
<h3 id="결과-2건-비교-모두-±10-이내">결과: 2건 비교, 모두 ±10% 이내</h3>
<table>
<thead>
<tr>
<th>조합 (서울 · 개인용)</th>
<th>공식 계산기</th>
<th>차이</th>
</tr>
</thead>
<tbody><tr>
<td>개발용 서버 · EC2 단독</td>
<td>$13.69</td>
<td>0.07%</td>
</tr>
<tr>
<td>백엔드 웹앱 · EC2 + RDS</td>
<td>$33.93</td>
<td>1.9%</td>
</tr>
</tbody></table>
<h3 id="정작-틀린-건-내-입력이었다">정작 틀린 건 내 입력이었다</h3>
<p>처음 몇 번은 차이가 크게 났다. 원인은 계산기가 아니라 <strong>공식 계산기에 값을 잘못 넣은 것</strong>이었다.</p>
<ul>
<li>데이터 전송을 <strong>인바운드(들어오는 쪽)</strong>에 넣었다. 들어오는 데이터는 무료라서 0원이 나왔다.</li>
<li>VPC를 추가했더니 <strong>VPN Connection</strong>이 체크돼 있어서 월 $36.50이 더해졌다.</li>
<li>RDS에서 <strong>RDS Proxy, Performance Insights</strong>가 켜져 있어서 $65.40이 나왔다.</li>
</ul>
<p>공식 계산기도 초보자에게는 어렵다는 걸 다시 느꼈다. 그래서 체크리스트에 &quot;어느 칸에 무엇을 넣는지&quot;까지 적어두었다.</p>
<p>EC2, EBS, 데이터 전송, 퍼블릭 IPv4, RDS 단가가 공식 계산기와 같다는 것도 이 과정에서 확인했다. 검증 현황은 README에도 적었다.</p>
<hr>
<h2 id="2-숫자는-여러-곳에서-조용히-어긋난다">2. 숫자는 여러 곳에서 조용히 어긋난다</h2>
<p>검증을 하고 나니 다른 숫자도 신경이 쓰였다. README, 사이트, 포트폴리오, GitHub 프로필에 적힌 숫자를 하나씩 대조해봤다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>포트폴리오에 적힌 값</th>
<th>실제</th>
</tr>
</thead>
<tbody><tr>
<td>템플릿</td>
<td>4종</td>
<td>5종 (WordPress 추가)</td>
</tr>
<tr>
<td>리전</td>
<td>2곳</td>
<td>6곳</td>
</tr>
<tr>
<td>직접 계산 서비스</td>
<td>15종</td>
<td>16종 (도메인 추가)</td>
</tr>
<tr>
<td>검증 체크리스트</td>
<td>48개 조합</td>
<td>66개 조합</td>
</tr>
</tbody></table>
<p>기능을 추가할 때 코드와 README는 같이 고쳤는데, <strong>다른 저장소에 적어둔 숫자</strong>는 잊고 있었다. WordPress 템플릿 하나를 추가하면서 템플릿 수, 검증 조합 수가 같이 바뀌었는데 포트폴리오는 그대로였다.</p>
<p>교훈은 단순하다. 숫자를 바꾸는 작업을 하면, 그 숫자가 적힌 곳을 전부 찾아서 같이 고친다.</p>
<hr>
<h2 id="3-결과-화면-다이어트-2차">3. 결과 화면 다이어트, 2차</h2>
<p>2편에서 처음 보이는 양을 줄였지만, 휴대폰으로 다시 보니 결과 화면이 여전히 <strong>화면 4.8개 길이</strong>였다.</p>
<ul>
<li>금액이 나오기도 전에 규모·리전·도메인 선택 칩이 세 줄 나온다. 바로 앞 화면에서 이미 고른 값이다.</li>
<li>&quot;가장 저렴한 건 Lightsail&quot;, &quot;처음이라면 추천: Lightsail&quot;, 막대 그래프가 같은 말을 세 번 한다.</li>
<li>정작 추천 구성은 두 번째 카드에 있다.</li>
<li>계산 가정이 13줄 펼쳐져 있다.</li>
</ul>
<p>이번에도 기능은 하나도 빼지 않고, <strong>순서를 바꾸고 접기만</strong> 했다.</p>
<table>
<thead>
<tr>
<th>전</th>
<th>후</th>
</tr>
</thead>
<tbody><tr>
<td>선택 칩 3줄</td>
<td><code>계산 조건: 개인용 · 서울 · 도메인 없음 [바꾸기]</code> 한 줄</td>
</tr>
<tr>
<td>요약 박스 + 카드 3개</td>
<td><strong>&quot;처음이라면 이걸로&quot;</strong> 카드 하나를 크게, 나머지는 &quot;다른 방법 2가지도 보기&quot;로 접기</td>
</tr>
<tr>
<td>같은 말 세 번</td>
<td>추천과 최저가가 다를 때만 &quot;더 저렴한 방법도 있어요&quot; 한 줄</td>
</tr>
<tr>
<td>값 바꾸기가 카드보다 위</td>
<td>결과 카드 아래로</td>
</tr>
<tr>
<td>계산 가정 13줄</td>
<td>3줄 + &quot;계산 기준 더 보기&quot;</td>
</tr>
<tr>
<td><code>서울 (ap-northeast-2)</code></td>
<td><code>서울</code></td>
</tr>
</tbody></table>
<p>결과는 <strong>화면 4.8개 → 2.6개</strong>였다.</p>
<p>난이도 배지도 정리했다. 맨 위 카드에 &quot;처음이라면 이걸로&quot;가 있는데 &quot;난이도 쉬움&quot;까지 붙어 있으면 같은 말이다. 그래서 난이도는 접힌 &quot;다른 방법&quot; 카드에서만 보여준다. 다른 구성끼리 비교할 때는 여전히 쓸모 있기 때문이다.</p>
<hr>
<h2 id="4-들어오자마자-기능-목록-메인에-소개부터">4. 들어오자마자 기능 목록? 메인에 소개부터</h2>
<p>사이트에 들어오면 바로 &quot;무엇을 만들고 싶나요?&quot; 목록이 나왔다. 처음 온 사람은 이게 뭐 하는 사이트인지 모른 채 고르게 된다.</p>
<p>소개 페이지를 따로 만들까도 생각했지만, 그러면 클릭이 하나 늘어난다. 그래서 <strong>한 페이지 안에서 순서만 바꿨다.</strong></p>
<ol>
<li><strong>소개</strong>: &quot;만들고 싶은 걸 고르면 필요한 AWS 구성과 예상 월 비용을 원화로 알려드려요. AWS 계정 없이 무료예요.&quot; + [무엇을 만들지 고르기 ↓]</li>
<li><strong>이렇게 써요</strong>: 만들고 싶은 것 고르기 → 규모 고르기 → 예상 비용 확인</li>
<li><strong>이런 걸 알려줘요</strong>: 원화 월 비용, 숨은 비용, 만든 다음 꼭 할 일</li>
<li><strong>무엇을 만들고 싶나요?</strong> (기존 목록 그대로)</li>
</ol>
<p>버튼은 <code>#templates</code> 링크 대신 <code>scrollIntoView</code>로 만들었다. 이 사이트는 <code>#/...</code>로 화면을 나누고 있어서, 해시 링크를 쓰면 라우터가 다른 화면으로 인식해버린다.</p>
<h3 id="pc에서는-두-칸-끝선까지-맞추기">PC에서는 두 칸, 끝선까지 맞추기</h3>
<p>PC에서 세로로 길게 늘어선 게 허전해서, 넓은 화면(900px 이상)에서만 두 칸으로 놓았다.</p>
<ul>
<li>메인만 최대 폭을 720px → 1000px로 넓혔다. <code>body:has(.home-grid) .container</code>로 메인일 때만 바뀌게 했다.</li>
<li>&quot;이렇게 써요&quot;와 &quot;이런 걸 알려줘요&quot;는 1:1, 템플릿 목록과 &quot;더 알아보기·의견 보내기&quot;는 2:1.</li>
</ul>
<p>그런데 왼쪽과 오른쪽 높이가 달라서 아래가 들쭉날쭉했다. 오른쪽 칸 두 카드도 서로 높이가 달랐다.</p>
<pre><code class="language-css">.home-side { display: grid; grid-auto-rows: 1fr; gap: 12px; }        /* 오른쪽 두 카드를 같은 높이로 */
.home-grid .card { display: flex; flex-direction: column; }
.home-grid .card &gt; .list { flex: 1; display: flex; flex-direction: column; justify-content: space-around; }</code></pre>
<p><code>grid-auto-rows: 1fr</code>은 내용이 가장 긴 줄에 맞춰 모든 줄을 같은 높이로 만든다. 왼쪽 카드는 그 높이까지 늘어나고, 늘어난 공간은 목록 줄 사이에 고르게 나눠서 아래가 비어 보이지 않게 했다. 휴대폰은 그대로 세로 한 줄이다.</p>
<hr>
<h2 id="5-피드백을-통한-개선">5. 피드백을 통한 개선!</h2>
<p>velog에 글을 올리며 댓글로 피드백이 들어왔다.</p>
<blockquote>
<p>초보자가 놓치기 쉬운 부분의 항목도 따로 있었으면 좋겠다.</p>
</blockquote>
<p>사실 관련 정보는 이미 있었다. 경고 칩, 접혀 있는 &quot;포함 안 된 비용&quot;, 맨 아래 &quot;만든 다음 꼭 할 일&quot;. 문제는 <strong>흩어져 있었고, 금액이 안 보였다</strong>는 것이다. &quot;데이터 전송 요금 주의&quot;라는 칩은 얼마인지 말해주지 않는다.</p>
<h3 id="결과-화면에-놓치기-쉬운-비용-카드">결과 화면에 &quot;놓치기 쉬운 비용&quot; 카드</h3>
<p>추천 구성 바로 아래에 카드를 하나 두고, 세 묶음으로 나눴다. (아래는 모양을 보여주는 예시이고, 실제로는 그 구성에 해당하는 항목만 나온다.)</p>
<pre><code>⚠️ 놓치기 쉬운 비용
이 금액에 들어 있어요
  데이터 전송      월 $0.75   사용자에게 나가는 트래픽이에요. 늘면 가장 먼저 커져요
  퍼블릭 IPv4 주소 월 $3.65   켜두기만 해도 나가요
  디스크 (EBS)     월 $1.82   서버를 중지해도 계속 나가요
알아둘 점
  ⚠ 중지해도 과금
이 금액에 안 들어 있어요
  · 스냅샷(백업) 저장 요금
  · 도메인 주소</code></pre><ul>
<li>금액은 새로 계산하지 않는다. 이미 계산된 비용 내역에서 해당 줄만 골라 합친다.</li>
<li>금액 줄로 이미 설명된 경고(데이터 전송, IPv4 등)는 &quot;알아둘 점&quot;에서 뺀다. 같은 말을 두 번 하지 않기 위해서다.</li>
<li>추천 카드에 있던 경고 칩과 &quot;포함 안 된 비용&quot;은 이 카드로 옮겼다.</li>
</ul>
<h3 id="막상-붙여보니-카드가-비어-있었다">막상 붙여보니 카드가 비어 있었다</h3>
<p>대부분 템플릿의 추천 구성은 <strong>Lightsail</strong>이다. Lightsail은 전송 허용량이 월 정액에 들어 있어서 따로 전송 요금 줄이 없다. 정적 웹사이트의 추천인 <strong>Amplify</strong>는 전송 요금이 <code>Amplify 전송 GB</code>라는 이름으로 들어 있다. 서비스 이름으로만 고르니 &quot;데이터 전송&quot;이 하나도 안 잡혔다.</p>
<p>그래서 엔진의 비용 줄에 종류 표시를 붙였다.</p>
<pre><code class="language-js">// EC2 데이터 전송, CloudFront 전송, Amplify 전송 줄에
kind: &quot;transfer&quot;,</code></pre>
<p>카드는 서비스가 달라도 <code>kind: &quot;transfer&quot;</code>면 모두 &quot;데이터 전송&quot;으로 묶는다. Lightsail은 &quot;데이터 전송 · <strong>번들 포함</strong> (허용량을 넘으면 GB당 추가 과금)&quot;으로 보여준다. 피드백의 핵심인 데이터 전송이 어떤 구성에서든 빠지지 않게 됐다.</p>
<h3 id="놓치기-쉬운-aws-비용-8가지-안내-페이지">&quot;놓치기 쉬운 AWS 비용 8가지&quot; 안내 페이지</h3>
<p>구성과 상관없이 자주 나오는 함정은 한 페이지에 따로 정리했다.</p>
<ol>
<li>데이터 전송 (밖으로 나가는 트래픽)</li>
<li>퍼블릭 IPv4 주소</li>
<li>NAT Gateway</li>
<li>로드밸런서(ALB) 기본 요금</li>
<li>중지해도 나가는 디스크·Lightsail</li>
<li>스냅샷·백업</li>
<li>로그 (CloudWatch Logs)</li>
<li>프리티어가 끝난 뒤</li>
</ol>
<p>항목마다 <strong>얼마 · 왜 나오는지 · 이렇게 피해요</strong>를 적었다. 금액은 문장에 <code>{natMonthly}</code>처럼 자리만 잡아두고 서울 가격 데이터로 채운다. 그래서 매주 가격이 갱신되면 안내 페이지 금액도 같이 바뀐다.</p>
<pre><code class="language-json">{ &quot;title&quot;: &quot;NAT Gateway&quot;, &quot;cost&quot;: &quot;켜두기만 해도 월 약 {natMonthly} + 처리 GB당 {natPerGb}&quot;, ... }</code></pre>
<p>이 페이지는 &quot;AWS 요금 폭탄&quot;, &quot;AWS 숨은 비용&quot; 같은 검색에도 걸릴 만한 내용이라, 2편에서 만든 정적 소개 페이지처럼 <strong>배포할 때 HTML로도 생성</strong>하고 사이트맵에 넣었다. 앱 화면(<code>#/hidden-costs</code>)과 정적 페이지(<code>pages/hidden-costs.html</code>)가 같은 함수로 본문을 만들어서 내용이 어긋날 일이 없다.</p>
<hr>
<h2 id="6-작은-정리들">6. 작은 정리들</h2>
<h3 id="개인정보처리방침-필요할까">개인정보처리방침, 필요할까?</h3>
<p>회원가입도, 서버도, 방문자 분석도 없다. 계산기 목록은 이용자 브라우저(localStorage)에만 저장된다. 운영자가 모으는 개인정보가 없어서 꼭 필요하지는 않다고 판단했다.</p>
<p>다만 외부 서비스(Google Forms, GitHub Pages, jsDelivr 글꼴)는 접속 기록을 각자 정책대로 남긴다. 그래서 페이지를 따로 만들지 않고, 사이트 맨 아래에 <strong>접기 한 줄</strong>로 안내를 넣었다. 나중에 방문자 분석을 붙이면 그때는 제대로 된 안내가 필요하다.</p>
<h3 id="꼬리말">꼬리말</h3>
<p>링크 10개가 한 줄로 늘어서 있던 걸 &quot;구성별 비용&quot;과 &quot;더 보기&quot; 두 묶음으로 나눴다. 메인에만 있던 가격 갱신일은 면책 문구와 한 문장으로 합쳐서 모든 화면에 보이게 했다.</p>
<p>꼬리말 링크를 일부러 정적 HTML로 둔 이유도 있다. 앱 화면은 자바스크립트로 그려져서 검색엔진이 링크를 잘 따라가지 못한다. 꼬리말의 일반 링크가 정적 소개 페이지로 가는 길이 된다.</p>
<h3 id="search-console-후속">Search Console 후속</h3>
<p>2편에서 사이트맵이 &quot;가져올 수 없음&quot;으로 뜬다고 적었는데, 한참 지나도 그대로였다. URL 검사로 확인해보니 구글이 아직 <strong>한 번도 크롤링하지 않은 상태</strong>였다.</p>
<ul>
<li>URL 검사에 사이트맵 주소를 넣으면 &quot;Google에 등록되어 있지 않음&quot;이 뜬다. 사이트맵은 검색 결과에 나오는 페이지가 아니라서 <strong>정상</strong>이다.</li>
<li>메인 주소로 <strong>실시간 URL 테스트</strong>를 하니 &quot;페이지 가져오기: 성공, 색인 생성 허용: 예&quot;가 나왔다. 사이트에는 문제가 없었다.</li>
<li>메인과 소개 페이지마다 <strong>색인 생성 요청</strong>을 눌러두었다. 구글이 한 번 방문하면 사이트맵 상태도 보통 같이 풀린다.</li>
</ul>
<h3 id="다음-버전은-마일스톤으로">다음 버전은 마일스톤으로</h3>
<p>열린 이슈 3개(프리티어 반영, &quot;이 구성 만들기&quot; 가이드, AI 챗봇 + Bedrock 템플릿)를 <code>v2</code> 마일스톤으로 묶었다. 지금 버전에서 할 일과 나중에 할 일이 한눈에 나뉜다.</p>
<hr>
<h2 id="숫자로-보면">숫자로 보면</h2>
<ul>
<li>템플릿 5종, 리전 6곳, 직접 계산 서비스 16종, 용어 40개</li>
<li>공식 계산기 교차 검증 2건, 모두 ±10% 이내 (체크리스트 66개 조합)</li>
<li>결과 화면 길이 (휴대폰): 화면 4.8개 → 2.6개</li>
<li>놓치기 쉬운 비용 안내 8가지, 검색엔진용 정적 페이지 6개 (사이트맵 7개 주소)</li>
<li>테스트: JavaScript 41개 + 가격 수집 스크립트 Python 8개 (외부 패키지 없음)</li>
<li>여전히 빌드 없는 순수 HTML/CSS/JS, 운영비 0원</li>
</ul>
<h2 id="다음에-할-것">다음에 할 것</h2>
<ul>
<li><strong>교차 검증 늘리기:</strong> 지금 2건이다. 서버리스, 정적 웹사이트 조합까지 넓혀볼 생각이다.</li>
<li><strong>v2 마일스톤:</strong> 프리티어 반영, &quot;이 구성 만들기&quot; 단계별 가이드, AI 챗봇 + Bedrock 템플릿. 의견이 쌓이는 순서대로 하려 한다.</li>
<li><strong>방문자 분석:</strong> 어디서 사람들이 빠지는지 알고 싶다. 붙인다면 쿠키 없는 도구로, 개인정보 안내와 같이.</li>
</ul>
<h2 id="마무리">마무리</h2>
<p>이번에 넣은 새 기능은 &quot;놓치기 쉬운 비용&quot; 하나다. 그것도 새로 계산한 게 아니라 이미 있던 정보를 <strong>금액과 함께 한곳에 모은 것</strong>이다.</p>
<p>👉 <strong><a href="https://dlwldp.github.io/Awscal/">https://dlwldp.github.io/Awscal/</a></strong> 에서 써보고, 사이트의 &quot;의견 보내기&quot;로 피드백 주시면 감사하겠습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[오픽 2주 준비로 IH 받은 후기 (공부법 + 시험장 팁)]]></title>
            <link>https://velog.io/@jiye_1124/%EC%98%A4%ED%94%BD-2%EC%A3%BC-%EC%A4%80%EB%B9%84%EB%A1%9C-IH-%EB%B0%9B%EC%9D%80-%ED%9B%84%EA%B8%B0-%EA%B3%B5%EB%B6%80%EB%B2%95-%EC%8B%9C%ED%97%98%EC%9E%A5-%ED%8C%81</link>
            <guid>https://velog.io/@jiye_1124/%EC%98%A4%ED%94%BD-2%EC%A3%BC-%EC%A4%80%EB%B9%84%EB%A1%9C-IH-%EB%B0%9B%EC%9D%80-%ED%9B%84%EA%B8%B0-%EA%B3%B5%EB%B6%80%EB%B2%95-%EC%8B%9C%ED%97%98%EC%9E%A5-%ED%8C%81</guid>
            <pubDate>Thu, 29 Oct 2026 08:01:14 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>3월에 오픽 봤고, 목표였던 <strong>IH를 한 번에</strong> 받았다.
2주 동안 실제로 효과 있었던 것들만 정리해본다.</p>
</blockquote>
<hr>
<h2 id="📌-한-줄-요약">📌 한 줄 요약</h2>
<ul>
<li><strong>기간</strong>: 2주 (오픽 단기속성반)</li>
<li><strong>목표</strong>: IH</li>
<li><strong>결과</strong>: IH (1트)</li>
<li><strong>핵심</strong>: 스크립트 암기 + 내 답변 녹음해서 셀프 피드백 + 최빈도 질문용 컨셉 소재 미리 정해두기</li>
</ul>
<hr>
<h2 id="1-준비-방식">1. 준비 방식</h2>
<h3 id="2주-단기속성반">2주 단기속성반</h3>
<p>혼자 하면 질질 끌 것 같아서 <strong>2주짜리 단기속성반</strong>을 다녔다.
기간이 짧으니까 오히려 딴생각 안 하고 오픽만 팠던 게 좋았다.</p>
<h3 id="서베이--난이도">서베이 &amp; 난이도</h3>
<p>서베이랑 난이도는 <strong>학원에서 추천해준 대로</strong> 갔다.
따로 고민할 필요 없이, 구글링하면 나오는 일반적인 정보 수준으로 정해도 충분하다고 본다.
여기서 시간 쓰는 것보다 그 시간에 스크립트 하나 더 외우는 게 낫다.</p>
<hr>
<h2 id="2-도움-많이-됐던-공부법-3가지">2. 도움 많이 됐던 공부법 3가지</h2>
<h3 id="①-스크립트-암기">① 스크립트 암기</h3>
<p>기본은 결국 스크립트다.
자주 나오는 주제별로 스크립트를 만들어두고 외웠다.
통째로 외운다기보다는, <strong>어떤 질문이 와도 꺼내 쓸 수 있는 문장 덩어리</strong>를 몸에 익힌다는 느낌으로 했다.</p>
<h3 id="②-내-답변-녹음-→-셀프-피드백">② 내 답변 녹음 → 셀프 피드백</h3>
<p>개인적으로 제일 효과 컸던 거.
답변을 녹음해서 다시 들어보면 생각보다 별로인 게 바로 들린다.</p>
<ul>
<li>같은 단어 반복하는지</li>
<li>쓸데없이 말 끊기는 구간 있는지</li>
<li>문법 틀리는 패턴 있는지</li>
<li>시간 너무 짧거나 길지 않은지</li>
</ul>
<p>머릿속으로 말할 때랑 실제로 들을 때랑 완전 다르다. 무조건 해보는 거 추천.</p>
<h3 id="③-최빈도-질문에-답할-컨셉-소재-정해두기">③ 최빈도 질문에 답할 &#39;컨셉 소재&#39; 정해두기</h3>
<p>자주 나오는 질문들에 대해 <strong>내가 밀고 갈 컨셉/소재를 미리 정해뒀다.</strong>
예를 들어 사는 곳, 취미, 자주 가는 장소 같은 걸 하나로 정해놓고, 어떤 변형 질문이 와도 그 소재로 끌고 가는 식.</p>
<p>이렇게 하면 시험장에서 &quot;뭐라고 하지?&quot; 고민하는 시간이 확 줄어든다.
소재를 돌려쓰니까 외울 양도 줄고.</p>
<hr>
<h2 id="3-시험장-팁">3. 시험장 팁</h2>
<h3 id="🎧-헤드셋-볼륨은-일단-크게">🎧 헤드셋 볼륨은 일단 크게</h3>
<p>시험 시작 전에 <strong>헤드셋 소리 크게 해두자.</strong>
주변에서 다른 사람들도 다 같이 말하고 있어서, 소리 작으면 질문 놓치기 쉽다.</p>
<h3 id="🎯-시작하면-내-말이랑-질문에만-몰입">🎯 시작하면 내 말이랑 질문에만 몰입</h3>
<p>딱 시작하면 <strong>내 말이랑 질문에만 집중</strong>해야 한다.
옆 사람 목소리나 잡생각에 잠깐 흐트러지면 바로 질문 놓친다.
한 번 놓치면 멘탈도 같이 흔들리니까, 처음부터 끝까지 내 화면에만 몰입하는 게 필요하다.</p>
<h3 id="🗑️-질문-몇-개-버리는-것도-전략이다">🗑️ 질문 몇 개 버리는 것도 전략이다</h3>
<p>이건 꼭 말하고 싶은 부분.
<strong>너무 어려운 질문은 억지로 짜내지 말자.</strong></p>
<p>어려운 질문 붙잡고 억지로 말 만들다 보면</p>
<ul>
<li>시간도 날리고</li>
<li>문장 꼬이면서 문법 점수까지 깎아먹는다</li>
</ul>
<p>모든 질문을 완벽하게 할 필요 없다. 적당히 마무리하고 다음 질문에 에너지 쓰는 게 전체 점수엔 훨씬 낫다.</p>
<hr>
<h2 id="4-마무리--다음-목표">4. 마무리 &amp; 다음 목표</h2>
<p>2주 동안 짧고 굵게 준비해서 목표했던 IH 한 번에 받았다.
돌아보면 결국 <strong>&quot;외울 건 외우고, 내 답변을 객관적으로 듣고, 시험장에선 버릴 건 버린다&quot;</strong> 이게 다였던 것 같다.</p>
<p>영어 스피킹은 오픽으로 마무리하고, <strong>다음 목표는 토익</strong>이다.
토익 준비하면서도 후기 남겨볼 예정!</p>
<hr>
<p><em>토익도 화이팅 🙌</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA["2026년 10월, 지금 나는 어디쯤 와 있을까"]]></title>
            <link>https://velog.io/@jiye_1124/2026%EB%85%84-10%EC%9B%94-%EC%A7%80%EA%B8%88-%EB%82%98%EB%8A%94-%EC%96%B4%EB%94%94%EC%AF%A4-%EC%99%80-%EC%9E%88%EC%9D%84%EA%B9%8C</link>
            <guid>https://velog.io/@jiye_1124/2026%EB%85%84-10%EC%9B%94-%EC%A7%80%EA%B8%88-%EB%82%98%EB%8A%94-%EC%96%B4%EB%94%94%EC%AF%A4-%EC%99%80-%EC%9E%88%EC%9D%84%EA%B9%8C</guid>
            <pubDate>Fri, 23 Oct 2026 02:24:57 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>올해만 해도 직무와 환경이 몇 번 바뀌었다.
정신없이 지나온 시간을 한 번 정리해두고, 앞으로의 방향을 스스로 확인하기 위해 이 글을 쓴다.</p>
</blockquote>
<hr>
<h2 id="1-지금까지-걸어온-길">1. 지금까지 걸어온 길</h2>
<p>돌아보면 직선보다는 지그재그에 가까운 길이었다.</p>
<ul>
<li><strong>대학 시절</strong> — 편입을 거쳐 컴퓨터공학(모바일SW/빅데이터 트랙)을 전공하고 2026년 2월 졸업했다. 캡스톤에서는 딥러닝 기반 퍼스널컬러 진단 Android 앱을 만들었다.</li>
<li><strong>클라우드 교육 과정</strong> — 800시간짜리 하이브리드·멀티클라우드 아키텍트 과정을 들으며 AWS 기반 3-tier 인프라를 팀으로 구축해봤다.</li>
<li><strong>개발 인턴</strong> — Kubernetes 기반 플랫폼 R&amp;D 과제에서 프론트·백엔드를 함께 맡았다. KubeVirt, Harbor, Helm OCI 같은 낯선 개념에 부딪히면서 &quot;모르는 걸 끝까지 파고드는 시간&quot;이 얼마나 중요한지 배웠다.</li>
<li><strong>영업 직무 도전</strong> — 개발이 나와 맞는지 확신이 서지 않아 기술 기반 영업 쪽으로 방향을 틀어봤다. 짧은 기간이었지만, AI 솔루션을 직접 기획·프로토타이핑하면서 오히려 &quot;나는 만드는 쪽이 더 즐겁다&quot;는 걸 확인했다.</li>
<li><strong>다시 개발로</strong> — 생성형 AI 응용개발 교육 과정을 잠깐 듣기도 하고, 사이드 프로젝트(자취방 체크리스트 서비스)에서 백엔드 인증/인가와 AWS 배포까지 경험했다. 그리고 2026년 여름, 지금의 회사에 개발자로 입사했다.</li>
</ul>
<p>한 번에 정답을 찾진 못했지만, 직접 부딪혀본 덕분에 &quot;내가 뭘 좋아하고 뭘 잘할 수 있는지&quot;는 꽤 선명해졌다.</p>
<hr>
<h2 id="2-지금의-나">2. 지금의 나</h2>
<p>현재는 회사에서 <strong>ERP 개발</strong> 업무를 하고 있다.
입사 초기 맡았던 업무와는 결이 다른 분야라 처음부터 다시 배우는 중이다. 사내 언어와 도메인 지식, DB(MSSQL)를 공부하면서 하나씩 익혀가고 있다.</p>
<p>이를 통해 비즈니스 로직이 실제 업무 흐름과 어떻게 맞물리는지, 데이터가 어떻게 쌓이고 쓰이는지를 가까이서 볼 수 있다는 점에서 배울 게 많다고 느끼고 있다.</p>
<hr>
<h2 id="3-내가-가고-싶은-방향">3. 내가 가고 싶은 방향</h2>
<blockquote>
<p><strong>백엔드 개발자, 그리고 클라우드.</strong></p>
</blockquote>
<p>인턴, 교육 과정, 사이드 프로젝트를 거치며 계속 손이 갔던 건 결국 서버와 인프라였다.
서비스 하나를 <strong>밑단부터 배포까지</strong> 내 손으로 완주해보는 것이 지금의 가장 큰 목표다.</p>
<hr>
<h2 id="4-그래서-지금-하고-있는-것들">4. 그래서 지금 하고 있는 것들</h2>
<ul>
<li><strong>현업에 충실하기</strong> — 일단 지금 자리에서 최소 1년은 제대로 채우고, 길게는 몇 년 정착하면서 실무 기본기를 다지려고 한다.</li>
<li><strong>백엔드 동아리 도전</strong> — 회사 밖에서도 백엔드 경험을 이어가고 싶어서 개발 동아리에 서버 개발자로 지원하고 있다.</li>
<li><strong>자격증</strong> — 정보처리기사, SQLD를 준비할 계획이다.</li>
<li><strong>기록하기</strong> — 1년 넘게 매일 아침 모닝페이지를 쓰고 있고, 기술 개념 정리와 단기/장기 목표를 노션에 꾸준히 남기고 있다. 이 블로그도 그 연장선이다.</li>
<li><strong>대학원은 잠시 보류</strong> — 재직하면서 클라우드/AI 쪽 석사를 고민했지만, 지금은 현업과 경험 쌓기에 집중하기로 했다.</li>
</ul>
<hr>
<h2 id="5-마치며">5. 마치며</h2>
<p>남들보다 돌아온 것 같다는 생각이 들 때도 있다.
그래도 그 과정에서 개발도, 인프라도, 기획도, 영업도 조금씩 겪어봤고, 그게 결국 지금 방향을 정하는 데 가장 큰 근거가 됐다.</p>
<p>내가 추구하는 건 거창한 게 아니라 <strong>&quot;어제보다 조금 더 성장한 사람&quot;</strong> 이다.
몇 달 뒤 이 글을 다시 열어봤을 때, 지금보다 한 걸음 더 나아가 있기를 바라며.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI 뭐 써? 2편 — 개발자용으로 넓히고, AI에게 데이터를 맡기기 전에 정한 것들]]></title>
            <link>https://velog.io/@jiye_1124/AI-%EB%AD%90-%EC%8D%A8-2%ED%8E%B8-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%84%93%ED%9E%88%EA%B3%A0-AI%EC%97%90%EA%B2%8C-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EB%A7%A1%EA%B8%B0%EA%B8%B0-%EC%A0%84%EC%97%90-%EC%A0%95%ED%95%9C-%EA%B2%83%EB%93%A4</link>
            <guid>https://velog.io/@jiye_1124/AI-%EB%AD%90-%EC%8D%A8-2%ED%8E%B8-%EA%B0%9C%EB%B0%9C%EC%9E%90%EC%9A%A9%EC%9C%BC%EB%A1%9C-%EB%84%93%ED%9E%88%EA%B3%A0-AI%EC%97%90%EA%B2%8C-%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%A5%BC-%EB%A7%A1%EA%B8%B0%EA%B8%B0-%EC%A0%84%EC%97%90-%EC%A0%95%ED%95%9C-%EA%B2%83%EB%93%A4</guid>
            <pubDate>Thu, 22 Oct 2026 23:53:14 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://velog.io/@jiye_1124/AI-%EB%AD%90-%EC%8D%A8-%EB%AA%A9%EC%A0%81%EC%9D%84-%EA%B3%A0%EB%A5%B4%EB%A9%B4-AI-%ED%88%B4-23%EA%B0%9C%EB%A5%BC-%EC%B6%94%EC%B2%9C%ED%95%B4%EC%A3%BC%EB%8A%94-%EC%82%AC%EC%9D%B4%ED%8A%B8%EB%A5%BC-%EB%A7%8C%EB%93%A4%EB%A9%B0">1편</a>에서는 &quot;목적을 고르면 AI 툴 2~3개를 추천해주는 사이트&quot;의 뼈대(추천 규칙, 화면, 배포, 주간 점검)를 만들었습니다.
2편은 그 뒤의 이야기입니다. <strong>개발자용 버전으로 넓힌 과정</strong>과, <strong>AI에게 툴 정보를 모아 달라고 하기 전에 정한 보안 규칙</strong>을 정리했습니다.</p>
</blockquote>
<ul>
<li>사이트: <a href="https://dlwldp.github.io/which-ai/">https://dlwldp.github.io/which-ai/</a> · 개발자용: <a href="https://dlwldp.github.io/which-ai/dev/">https://dlwldp.github.io/which-ai/dev/</a></li>
<li>저장소: <a href="https://github.com/dlwldP/which-ai">https://github.com/dlwldP/which-ai</a></li>
</ul>
<blockquote>
<p>⚠️ <strong>먼저 밝혀둘 것</strong></p>
<ul>
<li>사이트에는 아직 <strong>실제 툴 데이터가 0개</strong>입니다. 이 글의 화면과 테스트는 모두 제가 지어낸 <strong>예시 데이터</strong>(<code>?demo</code>)로 확인한 것입니다.</li>
<li><strong>실제 사용자(비개발자·개발자)에게 써보게 하는 테스트는 아직 하지 않았습니다.</strong> 테스트 계획만 세워 두었습니다.</li>
<li>실제 데이터를 넣고 사람들에게 써보게 한 결과는 <strong>3편으로 돌아와서</strong> 쓰겠습니다.</li>
</ul>
</blockquote>
<hr>
<h2 id="1-개발-도구도-넣자-→-기획서와-부딪히다">1. &quot;개발 도구도 넣자&quot; → 기획서와 부딪히다</h2>
<p>데이터를 채우기 전에, 툴 카테고리를 이렇게 정리해서 전부 넣고 싶어졌습니다.</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>카테고리</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>planning</td>
<td>기획·협업</td>
<td>Notion AI, Linear, Miro</td>
</tr>
<tr>
<td>app_builders</td>
<td>풀스택·바이브코딩</td>
<td>Lovable, Bolt.new, Replit</td>
</tr>
<tr>
<td>coding_agents</td>
<td>코딩 에이전트·IDE</td>
<td>Claude Code, Cursor, Codex</td>
</tr>
<tr>
<td>backend_database</td>
<td>백엔드·DB</td>
<td>Supabase, Firebase, PostgreSQL</td>
</tr>
<tr>
<td>devops</td>
<td>배포·인프라</td>
<td>Docker, Vercel, GitHub Actions</td>
</tr>
<tr>
<td>…</td>
<td>…</td>
<td>…</td>
</tr>
</tbody></table>
<p>그런데 1편의 기획서에는 &quot;하지 않을 것&quot;으로 이렇게 적혀 있었습니다.</p>
<blockquote>
<p>타깃은 비개발자. <strong>GitHub 레포, 설치형·개발자용 도구는 다루지 않는다.</strong></p>
</blockquote>
<p>목록을 다시 보니 문제가 하나 더 있었습니다. <strong>PostgreSQL, Docker, GitHub Actions는 AI 툴이 아니고</strong>, 학습 자료는 도구가 아닙니다. 전부 &quot;AI 툴&quot;이라고 소개하면 사실과 다른 설명이 됩니다.</p>
<p>세 가지 선택지(넣지 않기 / 별도 버전 / 사이트 자체를 개발자용으로 바꾸기)를 놓고, <strong>별도 버전</strong>으로 정했습니다.</p>
<ul>
<li>비개발자 사이트는 그대로 둡니다. 개발 도구를 섞지 않습니다.</li>
<li>같은 저장소의 <code>/dev/</code> 주소에 <strong>&quot;개발할 때 AI 뭐 써?&quot;</strong>를 따로 만듭니다.</li>
<li>AI 툴이 아닌 도구는 넣되, 카드에 <strong>&quot;AI와 같이 쓰는 도구&quot;</strong>라고 따로 표시합니다 (<code>ai_native: &quot;no&quot;</code>).</li>
<li>학습 자료는 추천 순위에 넣지 않고, 도구 상세 화면의 &quot;배울 거리&quot; 링크로 보여줍니다.</li>
<li>&quot;오픈소스&quot;는 카테고리가 아니라 <strong>조건</strong>(&quot;오픈소스만&quot;)으로 바꿨습니다.</li>
</ul>
<h2 id="2-엔진-하나로-사이트-두-개">2. 엔진 하나로 사이트 두 개</h2>
<p>새 사이트를 처음부터 다시 만들지 않고, 기존 코드를 두 갈래로 나눴습니다.</p>
<h3 id="추천-규칙-profile로-나누기">추천 규칙: profile로 나누기</h3>
<p>추천 함수는 그대로 두고, 데이터 세트마다 다른 규칙만 <code>profile</code>로 분리했습니다.</p>
<pre><code class="language-js">export const PROFILES = {
  general: {                       // 비개발자용 (기존)
    filters: { freeOnly, securityImportant, mobile },
    relaxOrder: [&#39;mobile&#39;, &#39;freeOnly&#39;],          // 보안 조건은 절대 안 뺌
    bonus: (t, c) =&gt; t.korean * (c.koreanImportant ? 4 : 1) + (t.price.free ? 2 : 0),
  },
  dev: {                           // 개발자용
    filters: { freeOnly, openSourceOnly },
    relaxOrder: [&#39;openSourceOnly&#39;, &#39;freeOnly&#39;],
    bonus: (t) =&gt; (t.ai_native === &#39;yes&#39; ? 2 : 0) + (t.price.free ? 2 : 0),
  },
};</code></pre>
<p>리팩터링 뒤에 <strong>기존 테스트를 한 줄도 고치지 않고</strong> 다시 돌렸고, 모두 통과했습니다. 비개발자 사이트의 추천 결과가 바뀌지 않았다는 확인입니다.</p>
<h3 id="화면-같은-appjs-다른-설정">화면: 같은 app.js, 다른 설정</h3>
<p>화면 문구, 조건, 카드 비교 항목은 <code>js/modes.js</code>라는 설정 파일로 뺐습니다. <code>dev/index.html</code>은 <code>&lt;body data-mode=&quot;dev&quot;&gt;</code> 한 줄만 다르고, 같은 <code>app.js</code>를 씁니다.</p>
<table>
<thead>
<tr>
<th></th>
<th>비개발자용</th>
<th>개발자용</th>
</tr>
</thead>
<tbody><tr>
<td>카드 비교 항목</td>
<td>무료로 쓰기, 무료 한도, 유료 가격, 한국어, 학습 정책, 사용 기기</td>
<td>무료로 쓰기, 유료 가격, 오픈소스, 쓰는 형태, 코드 학습 정책, AI 여부</td>
</tr>
<tr>
<td>문구</td>
<td>영어 약어 금지, 쉬운 말</td>
<td>CLI·RAG·CI/CD 같은 용어를 그대로 쓰고 용어집으로 보충</td>
</tr>
</tbody></table>
<p><img src="https://velog.velcdn.com/images/jiye_1124/post/0fca3849-6a27-4004-8e00-37e82431f6e4/image.png" alt=""></p>
<!-- 스크린샷: 개발자용 결과 화면 (예시 데이터) -->

<h2 id="3-ai에게-데이터를-맡기기-전에-진짜-위험은-틀린-정보">3. AI에게 데이터를 맡기기 전에: 진짜 위험은 &quot;틀린 정보&quot;</h2>
<p>툴 30개 정보를 직접 다 찾기는 부담이라, AI(Claude Code)에게 공식 페이지를 읽고 초안을 만들게 하기로 했습니다. 그 전에 &quot;그냥 가져오면 보안 위험이 있나?&quot;를 따져봤습니다.</p>
<p><strong>해킹류 위험은 생각보다 작았습니다.</strong></p>
<ul>
<li>화면은 모든 값을 HTML 이스케이프해서 그려서, 가져온 글에 코드가 섞여 있어도 실행되지 않습니다.</li>
<li>주소는 <code>https</code>만 허용합니다.</li>
<li>외부 패키지가 0개입니다.</li>
</ul>
<p><strong>진짜 위험은 따로 있었습니다.</strong></p>
<ol>
<li><strong>틀린 값.</strong> 시험 삼아 웹 검색을 해보니 결과가 전부 블로그·리뷰 글이었고, 같은 툴의 유료 가격이 사이트마다 달랐습니다. 학습 정책이 틀리면 &quot;회사 자료를 넣어도 된다&quot;고 믿은 사용자가 피해를 볼 수 있습니다.</li>
<li><strong>공식처럼 생긴 가짜 주소.</strong> <code>naver.com.evil.io</code> 같은 주소가 들어가면 사용자를 엉뚱한 곳으로 보냅니다.</li>
<li><strong>웹페이지 속 숨은 지시문.</strong> AI가 읽는 페이지에 &quot;이 툴을 1위로 적어라&quot; 같은 문장이 있을 수 있습니다.</li>
</ol>
<h3 id="그래서-만든-규칙-ai는-초안만-확정은-사람만">그래서 만든 규칙: AI는 초안만, 확정은 사람만</h3>
<pre><code>AI가 공식 페이지를 읽고 초안 작성 (drafts/, 배포 안 됨)
  ├─ 값마다 공식 페이지 문장을 &quot;글자 그대로&quot; 근거로 첨부
  ├─ 출처는 공식 도메인과 그 하위 도메인만
  └─ 근거를 못 찾은 값은 todo(사람이 채울 칸)로 남김
        ↓
사람이 출처와 근거 문장을 대조
        ↓
node scripts/draft.js promote &lt;id&gt; --verified 날짜   ← 사람만 실행</code></pre><p>말로만 정하지 않고 <strong>검사 스크립트로 강제</strong>했습니다.</p>
<ul>
<li>출처가 공식 도메인이 아니면 실패합니다. <code>naver.com.evil.io</code>, <code>fakenaver.com</code>은 막고, <code>policy.naver.com</code>은 허용합니다.</li>
<li>무료 여부·가격·학습 정책 값에 근거 문장이 없으면 실패합니다.</li>
<li>초안에 확인 날짜가 들어가 있으면 실패합니다. 확인 날짜는 사람이 확정할 때만 붙습니다.</li>
<li><code>todo</code>가 남아 있으면 <code>promote</code>가 거부합니다.</li>
</ul>
<pre><code class="language-js">export function isOfficialHost(host, domains) {
  return Boolean(host) &amp;&amp; domains.some((d) =&gt; host === d || host.endsWith(`.${d}`));
}</code></pre>
<p>개발자용은 한 단계 더 나갔습니다. 공식 도메인 목록을 <strong>실제 데이터에도 남겨서</strong>, 데이터 검사가 공식 주소·출처·배울 거리 링크를 매번 대조합니다. 저장소 주소만 예외로 <code>github.com</code>, <code>gitlab.com</code>을 허용합니다.</p>
<p>검사를 일부러 꺼서 테스트가 실제로 잡는지도 확인했습니다. 공식 도메인 검사를 끄자 가짜 도메인·블로그 출처·강좌 사이트·허용 안 된 저장소 테스트 4개가 실패했습니다.</p>
<h2 id="4-시험-초안-2개를-만들어-보며-알게-된-것">4. 시험 초안 2개를 만들어 보며 알게 된 것</h2>
<p>실제 데이터를 넣기 전에 툴 2개(tl;dv, 클로바노트)로 시험 초안을 만들어 봤습니다. <strong>이 두 개도 아직 확인 전 초안이라 사이트에는 나오지 않습니다.</strong></p>
<h3 id="①-작업-환경의-네트워크가-막혀-있었다">① 작업 환경의 네트워크가 막혀 있었다</h3>
<p>AI가 일하는 클라우드 환경은 기본적으로 외부 사이트 접속이 막혀 있었습니다. 필요한 공식 도메인만 허용 목록에 추가했는데, 해보니 <strong>적은 주소 그대로만 열렸습니다.</strong> <code>tldv.io</code>를 넣어도 <code>www.tldv.io</code>는 막혀 있었습니다. 넓게 열지 않고 툴마다 필요한 주소를 하나씩 적는 방식이 오히려 안전했습니다.</p>
<h3 id="②-가격-페이지는-대부분-글자로-안-읽힌다">② 가격 페이지는 대부분 &quot;글자로 안 읽힌다&quot;</h3>
<p>페이지를 받아 <strong>보이는 글자만 뽑는 방식</strong>을 썼습니다. 페이지 안의 스크립트는 실행하지 않았습니다. 그랬더니 개인정보 정책 같은 문서는 잘 읽혔지만, 가격 페이지와 클로바노트 사이트는 화면을 스크립트가 그려서 제목 한 줄만 나왔습니다.</p>
<p>격리된 브라우저로 스크립트를 실행해 읽는 방법도 있었지만 쓰지 않기로 했습니다. 대신 그런 값은 <code>todo</code>(사람이 채울 칸)로 남기게 했습니다.</p>
<h3 id="③-공식-사이트-안에서도-말이-엇갈린다">③ 공식 사이트 안에서도 말이 엇갈린다</h3>
<p>tl;dv는 학습 정책에 대한 명확한 문장이 있었습니다.</p>
<blockquote>
<p>&quot;No customer data is used to train the AI.&quot;</p>
</blockquote>
<p>그런데 같은 공식 사이트 안에서도 첫 화면 질문 답변에는 &quot;아직 모바일 앱이 없다&quot;고 되어 있고, 다른 페이지에는 iOS 앱 링크가 있었습니다. AI가 판단하지 않고 메모로 남겨 사람에게 넘기게 했습니다.</p>
<p>클로바노트는 네이버 전체 이용약관에 &quot;콘텐츠를 인공지능 연구 개발에 쓸 수 있다&quot;는 문장이 있었습니다. 하지만 게시물에 관한 조항이라 녹음에도 해당하는지 알 수 없었습니다. 그래서 학습 정책은 <strong>unknown</strong>으로 두었습니다. 1편의 원칙대로, 모르는 걸 안전하다고 보여주지 않습니다.</p>
<h2 id="5-마감-준비-phase-5">5. 마감 준비 (Phase 5)</h2>
<ul>
<li><strong>&quot;정보가 틀려요&quot; 제보:</strong> GitHub Issue 양식을 만들고, 카드와 상세 화면에 사이트·툴 이름이 미리 채워진 제보 링크를 달았습니다. 코드·데이터 PR은 받지 않고 제보만 받습니다.</li>
<li><strong>주간 점검 확장:</strong> 비개발자용·개발자용 데이터를 함께 점검해서 Issue 하나에 나눠 보여줍니다. 개발자용 저장소·배울 거리 링크도 점검합니다. 예시 데이터로 GitHub에서 실행해 Issue가 갱신되는 것까지 확인했습니다.</li>
<li><strong>용어집 검토:</strong> &quot;PRD&quot;가 &quot;PR&quot;로 잘못 잡히지 않는지, &quot;에이전트 오케스트레이션&quot;에 엉뚱한 &quot;코딩 에이전트&quot; 풀이가 뜨지 않는지 확인하며 고쳤습니다.</li>
<li><strong>사용성 테스트 준비:</strong> 비개발자 3명용 진행 대본, 과제 4개, 기록 양식을 만들었습니다. <strong>아직 진행하지 않았습니다.</strong></li>
</ul>
<h2 id="6-지금-상태-정직하게">6. 지금 상태 (정직하게)</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td>추천 규칙·화면·배포·자동 점검</td>
<td>완료 (비개발자용·개발자용)</td>
</tr>
<tr>
<td>자동 테스트</td>
<td>152개 통과, 2개 건너뜀 (실제 데이터가 비어 있어서 건너뛴 테스트)</td>
</tr>
<tr>
<td>실제 툴 데이터</td>
<td><strong>0개</strong> (비개발자용 후보 24개와 필요한 공식 주소 목록만 정리)</td>
</tr>
<tr>
<td>화면 확인</td>
<td>예시 데이터로만 확인</td>
</tr>
<tr>
<td>실제 사용자 테스트</td>
<td><strong>안 함</strong></td>
</tr>
</tbody></table>
<h2 id="7-이번에-배운-것">7. 이번에 배운 것</h2>
<ul>
<li><strong>범위를 넓히고 싶을 때는 기획서의 &quot;하지 않을 것&quot;부터 다시 읽는다.</strong> 섞지 않고 나누니 비개발자 사이트의 쉬움을 지킬 수 있었습니다.</li>
<li><strong>리팩터링은 기존 테스트가 그대로 통과하는지로 증명한다.</strong></li>
<li><strong>AI에게 일을 맡길 때 위험은 해킹보다 &quot;그럴듯하게 틀린 정보&quot;였다.</strong> 그래서 &quot;AI는 근거를 붙여 초안만, 확정은 사람만&quot;을 검사 스크립트로 강제했습니다.</li>
<li><strong>자동화가 못 하는 일은 숨기지 않고 todo로 남긴다.</strong> 글자로 안 읽히는 가격 페이지를 억지로 채우는 것보다 낫습니다.</li>
</ul>
<h2 id="다음-편-예고">다음 편 예고</h2>
<p>실제 툴 데이터를 공식 페이지 기준으로 채우고, <strong>비개발자 3명에게 직접 써보게 한 결과</strong>를 가지고 <strong>3편으로 돌아오겠습니다.</strong> 무엇이 헷갈렸고 무엇을 고쳤는지, 예시 데이터가 아닌 진짜 화면으로 보여드리겠습니다. 🙌</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[AI 뭐 써? — 목적을 고르면 AI 툴 2~3개를 추천해주는 사이트를 만들며]]></title>
            <link>https://velog.io/@jiye_1124/AI-%EB%AD%90-%EC%8D%A8-%EB%AA%A9%EC%A0%81%EC%9D%84-%EA%B3%A0%EB%A5%B4%EB%A9%B4-AI-%ED%88%B4-23%EA%B0%9C%EB%A5%BC-%EC%B6%94%EC%B2%9C%ED%95%B4%EC%A3%BC%EB%8A%94-%EC%82%AC%EC%9D%B4%ED%8A%B8%EB%A5%BC-%EB%A7%8C%EB%93%A4%EB%A9%B0</link>
            <guid>https://velog.io/@jiye_1124/AI-%EB%AD%90-%EC%8D%A8-%EB%AA%A9%EC%A0%81%EC%9D%84-%EA%B3%A0%EB%A5%B4%EB%A9%B4-AI-%ED%88%B4-23%EA%B0%9C%EB%A5%BC-%EC%B6%94%EC%B2%9C%ED%95%B4%EC%A3%BC%EB%8A%94-%EC%82%AC%EC%9D%B4%ED%8A%B8%EB%A5%BC-%EB%A7%8C%EB%93%A4%EB%A9%B0</guid>
            <pubDate>Mon, 19 Oct 2026 02:20:16 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>하고 싶은 일을 고르면 맞는 AI 툴 2~3개를 이유와 함께 비교해주는 비개발자용 선택 도우미.
&quot;AWS 얼마야?&quot;의 자매 프로젝트입니다.</p>
</blockquote>
<ul>
<li>사이트: <a href="https://dlwldp.github.io/which-ai/">https://dlwldp.github.io/which-ai/</a></li>
<li>예시 데이터로 둘러보기: <a href="https://dlwldp.github.io/which-ai/?demo#/">https://dlwldp.github.io/which-ai/?demo#/</a></li>
<li>저장소: <a href="https://github.com/dlwldP/which-ai">https://github.com/dlwldP/which-ai</a></li>
</ul>
<hr>
<p><img src="https://velog.velcdn.com/images/jiye_1124/post/4c5fb3ed-4408-4760-b92c-8467458ff242/image.png" alt=""></p>
<h2 id="1-왜-만들었나">1. 왜 만들었나</h2>
<p>AI 툴이 너무 많습니다. 주변 비개발자분들이 자주 묻는 건 대개 이런 질문이었어요.</p>
<ul>
<li>&quot;회의록 정리하고 싶은데 뭐 써?&quot;</li>
<li>&quot;무료로 돼?&quot;</li>
<li>&quot;회사 자료 넣어도 괜찮아?&quot;</li>
</ul>
<p>기존 AI 디렉터리는 수천 개를 나열만 해서 오히려 고르기 어렵습니다. 그래서 <strong>목록을 보여주지 않는</strong> 사이트를 만들기로 했습니다.</p>
<ol>
<li>하고 싶은 일(목적)을 고른다</li>
<li>조건(무료만 / 보안 중요 / 한국어 중요 / 휴대폰)을 고른다</li>
<li>툴 2~3개를 <strong>추천 이유와 함께</strong> 비교해서 보여준다</li>
</ol>
<h2 id="2-설계-원칙">2. 설계 원칙</h2>
<p>기획 단계에서 먼저 원칙 다섯 개를 정했고, 개발하는 내내 이 원칙에 맞는지를 기준으로 판단했습니다.</p>
<table>
<thead>
<tr>
<th>원칙</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>추천은 규칙으로</td>
<td>사이트에서 LLM을 부르지 않는다. 같은 입력이면 항상 같은 결과</td>
</tr>
<tr>
<td>이유 없는 추천 금지</td>
<td>추천 카드마다 &quot;왜 이걸 추천했는지&quot; 문장이 최소 1개</td>
</tr>
<tr>
<td>모르면 모른다고</td>
<td>확인 안 된 값은 빈칸이 아니라 &quot;확인 필요&quot;로 표시</td>
</tr>
<tr>
<td>출처 필수</td>
<td>가격·정책 값에는 공식 페이지 주소와 확인 날짜</td>
</tr>
<tr>
<td>쉬운 말</td>
<td>영어 약어, 전문용어 대신 쉬운 한국어. 어려운 말은 용어집으로</td>
</tr>
</tbody></table>
<p><strong>하지 않을 것</strong>도 정했습니다. 서버, 회원가입, 런타임 LLM 호출, 광고·제휴 링크, 개발자용 도구는 다루지 않습니다. 그래서 <strong>운영비는 0원</strong>입니다.</p>
<h2 id="3-기술-스택">3. 기술 스택</h2>
<table>
<thead>
<tr>
<th>영역</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>프론트</td>
<td>순수 HTML/CSS/JS, 빌드 도구 없음</td>
</tr>
<tr>
<td>데이터</td>
<td><code>data/*.json</code></td>
</tr>
<tr>
<td>테스트</td>
<td>Node 내장 테스트 러너 (<code>node --test</code>), 외부 패키지 0개</td>
</tr>
<tr>
<td>배포</td>
<td>GitHub Actions → GitHub Pages</td>
</tr>
<tr>
<td>자동 점검</td>
<td>주간 GitHub Actions → GitHub Issue</td>
</tr>
</tbody></table>
<p><code>package.json</code>에 의존성이 하나도 없습니다. 설치할 게 없으니 공급망 위험도 없고, 몇 년 뒤에 열어도 그대로 돌아갑니다.</p>
<h2 id="4-개발-과정">4. 개발 과정</h2>
<p>개발은 Claude Code와 함께 했습니다. 처음에 &quot;임의로 결정하지 말고, 세부사항이 애매하면 확인받고 진행해 달라&quot;고 요청했는데, 이게 꽤 효과가 좋았어요. 기획서에 빈 곳이 생각보다 많았고, 그때마다 선택지와 장단점을 받아서 제가 골랐습니다.</p>
<p>정한 내용은 <code>docs/decisions.md</code>에 표로 쌓았습니다.</p>
<table>
<thead>
<tr>
<th>기획서에 없던 것</th>
<th>결정</th>
</tr>
</thead>
<tbody><tr>
<td>&quot;휴대폰에서 쓸 거예요&quot; 조건의 의미</td>
<td>아이폰·안드로이드 <strong>앱</strong>이 있는 툴만</td>
</tr>
<tr>
<td>조건을 걸었더니 결과가 0개면?</td>
<td>휴대폰 → 무료 순으로 조건을 빼고 안내. <strong>보안 조건은 절대 안 뺌</strong></td>
</tr>
<tr>
<td>추천 이유가 하나도 안 붙는 툴</td>
<td>적합도에 따라 &quot;이 일에 잘 맞는 툴이에요&quot; 같은 문장 추가</td>
</tr>
<tr>
<td>툴 수 상한</td>
<td>목적당 5개, 전체 40개를 넘으면 데이터 검사 실패</td>
</tr>
</tbody></table>
<h3 id="phase-1-추천-로직-순수-함수-하나">Phase 1. 추천 로직: 순수 함수 하나</h3>
<p>추천은 <code>js/recommend.js</code>의 순수 함수 하나가 전부입니다. DOM에도 시계에도 접근하지 않고, 오늘 날짜도 밖에서 받습니다. 그래서 테스트하기 쉽고 같은 입력이면 항상 같은 결과가 나옵니다.</p>
<pre><code class="language-js">function score(tool, purposeId, conditions, today) {
  return tool.purposes[purposeId] * 10
    + tool.korean * (conditions.koreanImportant ? 4 : 1)
    + (tool.price.free ? 2 : 0)
    - (isStale(tool, today) ? 3 : 0);   // 90일 넘게 확인 안 된 정보는 감점
}</code></pre>
<p>결과가 0개일 때는 정해진 순서대로 조건을 하나씩 뺍니다. 보안 조건은 목록에 아예 없습니다.</p>
<pre><code class="language-js">// 결과가 0개일 때 빼는 순서. 보안 조건은 절대 빼지 않는다.
const RELAX_ORDER = [&#39;mobile&#39;, &#39;freeOnly&#39;];</code></pre>
<p>&quot;회사 자료라 보안 중요&quot;를 고른 사람에게 학습 정책이 &quot;확인 필요&quot;인 툴을 보여주면 안 되기 때문입니다. <strong>모르는 걸 안전하다고 보여주지 않는 것</strong>이 이 프로젝트에서 가장 중요한 규칙이에요.</p>
<h4 id="테스트가-진짜-잡는지-확인하기">테스트가 진짜 잡는지 확인하기</h4>
<p>기획서의 테스트 기준을 그대로 테스트로 옮겼습니다.</p>
<ul>
<li>보안 조건에서 <code>unknown</code> 툴이 나오지 않는다</li>
<li>무료 조건에서 유료 전용 툴이 나오지 않는다</li>
<li>같은 입력 100번에 같은 결과가 나온다</li>
<li>조건 4개의 16가지 조합을 모든 목적에 돌린다</li>
</ul>
<p>그다음 <strong>일부러 코드를 망가뜨려서</strong> 테스트가 실패하는지 확인했습니다. 여기서 하나를 발견했어요. 보안 테스트가 <code>recommend.js</code>의 판정 함수(<code>isSecure</code>)를 그대로 가져다 쓰고 있어서, 판정 로직이 틀리면 테스트도 같이 틀려서 통과해 버렸습니다. 테스트 안에 기획서 기준을 따로 적도록 고쳤습니다.</p>
<pre><code class="language-js">// 기획서 9장 보안 기준. recommend.js의 판정 함수를 쓰지 않고 테스트에서 따로 적는다.
const meetsSecurity = (tool) =&gt;
  tool.privacy.trains_on_input_default === &#39;no&#39; || tool.privacy.opt_out === &#39;yes&#39;;</code></pre>
<h3 id="phase-2-화면-한-페이지--주소로-단계-구분">Phase 2. 화면: 한 페이지 + 주소로 단계 구분</h3>
<p><code>index.html</code> 하나에서 주소(<code>#/...</code>)로 화면을 나눴습니다. 뒤로 가기가 되고, 결과 링크를 그대로 공유할 수 있습니다.</p>
<pre><code>#/                         홈 (어떤 일을 맡기고 싶어요?)
#/p/P02?secure=1           조건 선택
#/p/P02/result?secure=1    추천 결과
#/t/&lt;툴 id&gt;                툴 상세</code></pre><h4 id="실제-데이터가-없는데-화면은-어떻게-확인하지">실제 데이터가 없는데 화면은 어떻게 확인하지?</h4>
<p>툴 정보는 공식 페이지에서 직접 확인해서 채우기로 했기 때문에, 화면을 만드는 시점에는 데이터가 0개였습니다. 그래서 <strong>주소에 <code>?demo</code>가 있을 때만</strong> 지어낸 예시 툴을 쓰고, 화면 맨 위에 &quot;예시 데이터예요&quot; 띠를 띄우기로 했습니다.</p>
<h4 id="비교표-대신-옆으로-넘기는-카드">비교표 대신 옆으로 넘기는 카드</h4>
<p>처음에는 비교표 + 카드였는데, 휴대폰에서 툴 3개짜리 표가 너무 빽빽했습니다. 그래서 카드를 가로로 나란히 놓았습니다.</p>
<ul>
<li><strong>휴대폰:</strong> 옆으로 넘기고, 한 장씩 멈추고, 아래 점으로 위치를 표시</li>
<li><strong>넓은 화면:</strong> 3개를 한 번에</li>
</ul>
<p>카드끼리 비교하려면 &quot;가격&quot;, &quot;학습 정책&quot; 같은 항목이 <strong>모든 카드에서 같은 높이</strong>에 있어야 합니다. 추천 이유가 1개인 카드와 3개인 카드가 섞이면 줄이 어긋나는데, CSS <code>subgrid</code>로 해결했습니다.</p>
<pre><code class="language-css">.cards {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 86%;              /* 다음 카드가 살짝 보이게 */
  grid-template-rows: repeat(11, auto); /* 카드 안 칸 11개 */
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}
.tool-card {
  display: grid;
  grid-row: span 11;
  grid-template-rows: subgrid;          /* 부모의 행을 같이 쓴다 → 카드끼리 높이가 맞음 */
  scroll-snap-align: start;
}</code></pre>
<p>Playwright로 각 카드의 항목 위치를 재서, 세 카드 모두 같은 y좌표인 것까지 확인했습니다.</p>
<h4 id="디자인-토스-느낌-→-따뜻한-종이-느낌">디자인: 토스 느낌 → 따뜻한 종이 느낌</h4>
<p>처음엔 자매 프로젝트(Awscal)와 같은 토스 스타일이었는데, 다른 느낌을 원해서 바꿨습니다. 정보 사이트라는 성격에 맞춰 &quot;안내서&quot; 같은 톤을 골랐어요.</p>
<ul>
<li>아이보리 배경, 먹색 글자, 테라코타 포인트</li>
<li>그림자 없이 얇은 테두리 카드</li>
<li>제목만 명조(Noto Serif KR)</li>
</ul>
<!-- 스크린샷: 휴대폰 결과 화면 / 데스크톱 결과 화면 -->

<h3 id="phase-3-툴-상세-용어집-변경-이력-다크-모드">Phase 3. 툴 상세, 용어집, 변경 이력, 다크 모드</h3>
<h4 id="용어집-문장-속-단어에-밑줄">용어집: 문장 속 단어에 밑줄</h4>
<p>&quot;토큰&quot;, &quot;무료 크레딧&quot;, &quot;학습 정책&quot; 같은 단어가 화면에 나오면 점선 밑줄을 치고, 누르면 화면 아래에 풀이가 뜹니다. 단어는 긴 것부터 찾아야 &quot;무료 크레딧&quot;이 &quot;크레딧&quot;보다 먼저 잡힙니다.</p>
<pre><code class="language-js">export function termMatcher(glossary) {
  const words = glossary.flatMap((g) =&gt; [g.term, ...g.aliases].map((word) =&gt; ({ word, term: g.term })));
  words.sort((a, b) =&gt; b.word.length - a.word.length || (a.word &lt; b.word ? -1 : 1));
  // ...긴 단어 우선 정규식
}</code></pre>
<h4 id="변경-이력-git-기록이-곧-데이터">변경 이력: git 기록이 곧 데이터</h4>
<p>&quot;가격이 언제 바뀌었는지&quot;는 따로 기록하지 않습니다. <code>data/tools.json</code>의 git 기록 자체가 이력이에요. 배포할 때 커밋마다 이전 버전과 비교해서 이런 문장을 만듭니다.</p>
<pre><code>Gamma: 유료 가격 10달러 → 12달러
Gamma: &#39;발표자료(PPT) 만들기&#39; 적합도 2점 → 3점</code></pre><p>처음에는 결과 파일을 레포에 커밋했는데, 깜빡하면 배포가 멈추는 구조였습니다. 그래서 배포 작업이 그 자리에서 만들어 사이트에만 올리도록 바꿨습니다.</p>
<h3 id="phase-4-배포와-주간-점검">Phase 4. 배포와 주간 점검</h3>
<p><code>main</code>에 푸시하면 GitHub Actions가 테스트와 데이터 검사를 돌리고, 통과해야만 Pages에 배포합니다.</p>
<p>정보가 낡는 것이 이 프로젝트의 가장 큰 리스크라서, <strong>매주 월요일 오전 9시</strong>에 자동 점검이 돕니다.</p>
<ul>
<li><strong>90일 넘게 확인 안 된 툴</strong> 목록</li>
<li><strong>공식 주소·출처 링크</strong>: 깨진 링크와 로봇 접속이 막힌 링크를 따로 표시</li>
<li>결과는 &quot;주간 점검&quot; Issue <strong>하나</strong>에 모읍니다. 확인할 게 생기면 열고, 이미 열려 있으면 내용만 갱신하고, 다 처리되면 닫습니다.</li>
</ul>
<p>로봇 접속을 막는 공식 사이트(403, 429)가 많습니다. 이걸 &quot;깨졌다&quot;고 하면 매주 거짓 경보가 납니다. 그래서 404·5xx만 &quot;깨진 링크&quot;로 보고, 막힌 링크만으로는 Issue를 열지 않습니다.</p>
<pre><code class="language-js">export function classifyStatus(status) {
  if (status &gt;= 200 &amp;&amp; status &lt; 400) return &#39;ok&#39;;
  if ([401, 403, 429].includes(status)) return &#39;blocked&#39;; // 직접 열어보기
  return &#39;broken&#39;;
}</code></pre>
<p>동작은 예시 데이터로 수동 실행해서 확인했습니다. 90일 지난 툴 1개와 404 링크 16개가 잡혀 Issue가 생겼고, 다시 실행하니 새 Issue 대신 같은 Issue가 갱신됐습니다.</p>
<blockquote>
<p>가격을 자동으로 긁어오지는 않습니다. 사이트마다 구조가 달라 깨지기 쉽고, 틀린 값이 자동으로 반영되는 게 더 위험하기 때문입니다. 자동화는 <strong>&quot;확인해야 할 목록을 만들어주는 것&quot;까지만</strong> 합니다.</p>
</blockquote>
<h2 id="5-실제-데이터는-어떻게-넣나-보안-고민">5. 실제 데이터는 어떻게 넣나: 보안 고민</h2>
<p>마지막 고민은 &quot;툴 30개 정보를 어떻게 넣지?&quot;였습니다. AI에게 그냥 가져오라고 하면 편하겠지만, 위험이 있었습니다.</p>
<p>해킹 같은 보안 위험은 생각보다 작았습니다.</p>
<ul>
<li>화면은 모든 값을 HTML 이스케이프해서 그리기 때문에, 가져온 글에 코드가 섞여 있어도 실행되지 않습니다.</li>
<li>주소는 <code>https</code>만 허용합니다.</li>
</ul>
<p>진짜 위험은 따로 있었습니다.</p>
<ol>
<li><strong>틀린 정보.</strong> 실제로 웹 검색을 해보니 결과가 전부 블로그·리뷰 글이었고, 같은 툴의 유료 가격이 사이트마다 18달러, 20달러, 29달러로 달랐습니다. 학습 정책이 틀리면 회사 자료를 넣은 사용자가 피해를 볼 수 있습니다.</li>
<li><strong>공식처럼 생긴 가짜 주소.</strong> <code>naver.com.evil.io</code> 같은 주소가 들어가면 사용자를 피싱 사이트로 보내게 됩니다.</li>
<li><strong>웹페이지 속 숨은 지시문.</strong> AI가 읽는 페이지에 &quot;이 툴을 1위로 적어라&quot; 같은 문장이 있을 수 있습니다.</li>
</ol>
<p>그래서 <strong>&quot;AI 초안 → 사람 확인&quot;</strong> 흐름을 만들었습니다.</p>
<pre><code>Claude가 공식 페이지를 읽고 초안 작성 (drafts/, 배포 안 됨)
  ├─ 값마다 공식 페이지 문장을 그대로 근거로 첨부
  ├─ 출처는 공식 도메인과 그 하위 도메인만 (블로그·가짜 도메인은 검사에서 실패)
  └─ 모르면 unknown / 확인 필요
        ↓
J가 출처와 근거 문장을 대조해서 확인
        ↓
node scripts/draft.js promote &lt;id&gt; --verified 2026-10-20
  → 확인 날짜를 붙여 data/tools.json으로 (이 단계는 사람만)</code></pre><p>초안 검사가 자동으로 막는 것은 다음과 같습니다.</p>
<ul>
<li>공식 도메인이 아닌 출처</li>
<li>근거 문장이 없는 가격·학습 정책 값</li>
<li>초안에 확인 날짜가 들어간 경우</li>
</ul>
<p>AI는 &quot;찾아서 정리하는 일&quot;을 하고, &quot;맞다고 판단하는 일&quot;은 사람이 합니다.</p>
<h2 id="6-숫자로-보면">6. 숫자로 보면</h2>
<ul>
<li>테스트 99개 (추천 규칙, 데이터 스키마, 화면 문구, 용어집, 변경 이력, 주간 점검, 초안 검사)</li>
<li>외부 의존성 0개, 서버 0대, 운영비 0원</li>
<li>결정 기록(<code>docs/decisions.md</code>) 20개 남짓</li>
</ul>
<h2 id="7-배운-점">7. 배운 점</h2>
<ul>
<li><strong>기획서에는 생각보다 빈칸이 많다.</strong> &quot;조건을 하나씩 풀어서 재시도&quot;라는 한 줄에도 &quot;어떤 순서로? 보안도 풀어?&quot;가 숨어 있었습니다. 애매하면 묻고, 결정은 문서로 남기는 습관이 가장 큰 수확입니다.</li>
<li><strong>테스트는 망가뜨려 봐야 믿을 수 있다.</strong> 통과하는 테스트가 실제로 버그를 잡는지는 코드를 일부러 틀려봐야 알 수 있었습니다.</li>
<li><strong>자동화의 범위를 정하는 것도 설계다.</strong> 가격 자동 수집은 하지 않고 &quot;확인할 목록 만들기&quot;까지만 자동화한 덕분에, 틀린 정보가 몰래 올라갈 길이 없습니다.</li>
<li><strong>AI와 협업할 때는 책임의 경계를 코드로 만든다.</strong> &quot;AI는 초안만, 확정은 사람만&quot;을 말로만 정하지 않고 검사 스크립트로 강제했습니다.</li>
</ul>
<h2 id="8-남은-일">8. 남은 일</h2>
<ul>
<li><input disabled="" type="checkbox"> Phase 0: 툴 30개 정보를 공식 페이지에서 확인해 채우기 (이 프로젝트의 본체)</li>
<li><input disabled="" type="checkbox"> 용어집 초안 검토</li>
<li><input disabled="" type="checkbox"> 비개발자 3명 사용성 테스트</li>
<li><input disabled="" type="checkbox"> &quot;정보가 틀려요&quot; 제보 링크</li>
</ul>
<p>데이터가 채워지면 2편으로 돌아오겠습니다. 🙌</p>
<p>추가로, &quot;AWS 얼마야?&quot; 서비스가 궁금하시다면 하단 링크로 들어가시면 보실 수 있답니다~!!
🔗 사이트: <a href="https://dlwldp.github.io/Awscal/">https://dlwldp.github.io/Awscal/</a>
🔗 GitHub: <a href="https://github.com/dlwldP/Awscal">https://github.com/dlwldP/Awscal</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# "AWS 얼마야?" 2편 — 기능을 더하다가, 덜어내는 법을 배웠다]]></title>
            <link>https://velog.io/@jiye_1124/AWS-%EC%96%BC%EB%A7%88%EC%95%BC-2%ED%8E%B8-%EA%B8%B0%EB%8A%A5%EC%9D%84-%EB%8D%94%ED%95%98%EB%8B%A4%EA%B0%80-%EB%8D%9C%EC%96%B4%EB%82%B4%EB%8A%94-%EB%B2%95%EC%9D%84-%EB%B0%B0%EC%9B%A0%EB%8B%A4</link>
            <guid>https://velog.io/@jiye_1124/AWS-%EC%96%BC%EB%A7%88%EC%95%BC-2%ED%8E%B8-%EA%B8%B0%EB%8A%A5%EC%9D%84-%EB%8D%94%ED%95%98%EB%8B%A4%EA%B0%80-%EB%8D%9C%EC%96%B4%EB%82%B4%EB%8A%94-%EB%B2%95%EC%9D%84-%EB%B0%B0%EC%9B%A0%EB%8B%A4</guid>
            <pubDate>Mon, 19 Oct 2026 01:03:30 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>🔗 사이트: <a href="https://dlwldp.github.io/Awscal/">https://dlwldp.github.io/Awscal/</a>
🔗 GitHub: <a href="https://github.com/dlwldP/Awscal">https://github.com/dlwldP/Awscal</a>
📝 1편: 초보자용 AWS 비용 계산기를 만들었다 (MVP · 직접 계산기 · 토스 스타일 디자인)</p>
</blockquote>
<p>1편을 올리고 생각보다 반응이 좋았다. &quot;이거 진짜 필요했다&quot;, &quot;처음 AWS 쓸 때 이런 게 있었으면&quot; 같은 말을 들으니 신이 나서 하루 동안 기능을 꽤 많이 붙였다.</p>
<p>그런데 이번 작업에서 가장 크게 배운 건 기능을 <strong>더하는</strong> 법이 아니라 <strong>덜어내는</strong> 법이었다. 그 과정을 순서대로 정리해본다.</p>
<hr>
<h2 id="1-먼저-신뢰부터-실제-청구서랑-다르던데요">1. 먼저 신뢰부터: &quot;실제 청구서랑 다르던데요?&quot;</h2>
<p>반응이 좋을수록 제일 무서운 피드백은 &quot;계산이 틀렸다&quot;다. 그래서 새 기능보다 숫자를 믿을 수 있게 만드는 작업을 먼저 했다.</p>
<h3 id="부가세-10">부가세 10%</h3>
<p>한국 계정은 AWS 청구액에 <strong>부가세 10%가 더해진다.</strong> 그런데 원화 금액이 부가세 없이 나오고 있었다. 사용자는 화면에서 본 금액보다 10% 더 내게 되는 셈이다.</p>
<p>달러는 AWS 표시 가격 그대로 두고, 원화는 항상 부가세를 포함해서 보여주도록 바꿨다.</p>
<blockquote>
<p>$7.00 / 월 · 약 10,780원 <strong>(부가세 포함)</strong></p>
</blockquote>
<h3 id="이-금액에-포함-안-된-비용">&quot;이 금액에 포함 안 된 비용&quot;</h3>
<p>계산기가 모든 비용을 다 담을 수는 없다. 대신 <strong>빠진 걸 솔직하게 적기로</strong> 했다. 구성안마다 한 줄씩이다.</p>
<ul>
<li>EC2 + RDS: RDS 자동 백업 초과분, EBS 스냅샷, CloudWatch 로그</li>
<li>ECS Fargate: ECR 이미지 저장, NAT Gateway(프라이빗 서브넷 구성 시)</li>
<li>Amplify: 방화벽(WAF) 월 $15</li>
</ul>
<h3 id="도메인-옵션">도메인 옵션</h3>
<p>초보자가 제일 많이 묻는 비용이 &quot;도메인은 얼마예요?&quot;였다. 결과 화면에 <code>도메인: 없음 / 포함(.com)</code> 선택을 넣었다. 켜면 모든 구성안에 월 $1.75가 더해진다.</p>
<ul>
<li>.com 등록비: 연 $15 ÷ 12개월</li>
<li>Route 53 호스팅 영역: 월 $0.50</li>
</ul>
<p>여기서 하나 걸렸다. <strong>도메인 등록비는 AWS Price List API에 없다.</strong> 다른 가격은 매주 자동으로 갱신되지만, 이 값만은 스크립트에 상수로 적고 확인한 날짜를 같이 남겼다.</p>
<h3 id="가장-저렴이-정답은-아니다">&quot;가장 저렴&quot;이 정답은 아니다</h3>
<p>백엔드 웹앱을 고르면 Lightsail이 $7로 가장 저렴하게 나온다. 하지만 Lightsail은 DB를 직접 설치하고 백업해야 한다. 초보자가 가격만 보고 고르면 고생할 수 있다.</p>
<p>그래서 배지를 세 가지로 나눴다.</p>
<ul>
<li><strong>처음이라면 추천</strong> (이유 한 줄과 함께)</li>
<li><strong>가장 저렴</strong></li>
<li><strong>난이도</strong> 쉬움 / 보통 / 어려움</li>
</ul>
<hr>
<h2 id="2-사람들이-찾아오고-공유할-수-있게">2. 사람들이 찾아오고, 공유할 수 있게</h2>
<h3 id="링크-미리보기-og-이미지">링크 미리보기 (OG 이미지)</h3>
<p>카카오톡이나 슬랙에 링크를 붙이면 덩그러니 주소만 보였다. OG 태그와 1200×630 미리보기 이미지를 만들었다. 이미지는 HTML로 그리고 Playwright로 캡처했다.</p>
<h3 id="공유-링크">공유 링크</h3>
<p>&quot;이 결과 친구한테 보여주고 싶은데요&quot;라는 요청이 있었다. 그래서 <strong>화면 상태를 주소에 담았다.</strong></p>
<pre><code>#/t/backend-webapp/small/ap-northeast-2?instance=t4g.medium&amp;egressGb=100&amp;domain=1</code></pre><ul>
<li>결과 화면은 프리셋과 <strong>다른 값만</strong> 사람이 읽을 수 있는 형태로 담는다.</li>
<li>값을 바꿀 때마다 <code>history.replaceState</code>로 주소도 같이 바꿔서, 주소창을 그대로 복사해도 공유된다.</li>
<li>직접 계산기는 담은 목록 전체를 JSON → base64url로 줄여서 <code>#/calc?s=...</code>에 담는다.</li>
<li>받은 사람의 계산기에 이미 목록이 있으면 &quot;받은 구성으로 바꿀까요?&quot;라고 먼저 묻는다.</li>
<li>손으로 고치거나 오래된 링크도 안전하게 열리도록, 모르는 키·선택지에 없는 값·범위 밖 숫자는 버린다.</li>
</ul>
<p>공유 버튼은 휴대폰에서는 공유창(카카오톡 등), PC에서는 링크 복사로 동작한다.</p>
<h3 id="의견-보내기">의견 보내기</h3>
<p>GitHub 이슈 링크만 있으면 개발자만 의견을 남길 수 있다. 그래서 계정 없이 쓸 수 있는 구글폼을 연결했다.</p>
<p>삽질이 하나 있었다. 처음에 폼 <strong>편집 화면 주소</strong>를 붙였다. 편집 주소는 로그인하고 편집 권한이 있어야만 열려서 계정이 털릴 걱정은 없다. 하지만 일반 방문자에게는 &quot;권한 필요&quot;가 뜰 수 있다. <strong>응답자용 주소</strong>(<code>/forms/d/e/.../viewform</code>)로 바꾸고, 시크릿 창에서 열리는지 확인했다.</p>
<h3 id="검색-노출-해시-라우팅의-한계">검색 노출: 해시 라우팅의 한계</h3>
<p>이 사이트는 <code>#/t/backend-webapp</code>처럼 해시로 화면을 나누고, 화면은 자바스크립트로 그린다. 검색엔진 입장에서는 내용이 거의 없는 페이지다.</p>
<p>그래서 <strong>배포할 때마다 템플릿별 정적 소개 페이지를 HTML로 생성</strong>하게 했다. 규모별 가격표, 장단점, 숨은 비용이 들어 있다. 사이트맵도 같이 만든다.</p>
<pre><code class="language-yaml">- name: 배포할 파일 모으기 + 검색용 소개 페이지 생성
  run: |
    cp -r index.html css js data assets _site/
    node scripts/build-pages.js _site</code></pre>
<p>배운 것 두 가지.</p>
<ul>
<li><strong>GitHub Pages 프로젝트 사이트(<code>아이디.github.io/저장소</code>)에서는 robots.txt가 효과가 없다.</strong> 검색엔진은 도메인 루트의 robots.txt만 읽기 때문이다. 그래서 만들지 않고, Search Console에 사이트맵을 직접 등록했다.</li>
<li>Search Console에 사이트맵을 처음 제출하면 <strong>&quot;가져올 수 없음&quot;</strong>이 뜰 때가 많다. 브라우저로 sitemap.xml이 잘 열리면 파일 문제가 아니라, 구글이 아직 안 읽어간 것이니 기다리면 된다.</li>
</ul>
<hr>
<h2 id="3-오래-굴러가게-유지보수">3. 오래 굴러가게: 유지보수</h2>
<h3 id="가격-변동-기록">가격 변동 기록</h3>
<p>가격은 매주 자동으로 갱신된다. 이제 갱신할 때 <strong>지난주와 비교해서 바뀐 단가</strong>를 <code>price-changes.json</code>에 남기고, &quot;가격 변동 내역&quot; 화면으로 보여준다.</p>
<p>리전을 추가하던 날, 변동이 244건으로 찍혔다. 새 리전의 가격이 전부 &#39;변동&#39;으로 잡힌 것이다. 그래서 <strong>양쪽에 다 있는데 값이 바뀐 것만 &#39;변동&#39;</strong>으로 세고, 새로 생기거나 없어진 항목은 개수만 따로 세도록 바꿨다.</p>
<h3 id="github-actions-node-20-지원-종료">GitHub Actions Node 20 지원 종료</h3>
<p>배포 로그에 &quot;Node.js 20 is deprecated&quot; 경고가 뜨고 있었다. 버전을 무작정 올리지 않고 순서대로 확인했다.</p>
<ol>
<li><code>git ls-remote --tags</code>로 각 액션의 최신 메이저 버전 확인</li>
<li>각 버전의 <code>action.yml</code>에서 <code>runs.using: node24</code>인지, 입력값이 바뀌었는지 확인</li>
<li>변경 내역(CHANGELOG)에서 우리 사용 방식에 영향이 있는지 확인</li>
</ol>
<table>
<thead>
<tr>
<th>액션</th>
<th>이전</th>
<th>이후</th>
</tr>
</thead>
<tbody><tr>
<td>checkout</td>
<td>v4</td>
<td>v7</td>
</tr>
<tr>
<td>setup-node</td>
<td>v4</td>
<td>v7</td>
</tr>
<tr>
<td>setup-python</td>
<td>v5</td>
<td>v7</td>
</tr>
<tr>
<td>configure-pages</td>
<td>v5</td>
<td>v6</td>
</tr>
<tr>
<td>upload-pages-artifact</td>
<td>v3</td>
<td>v5</td>
</tr>
<tr>
<td>deploy-pages</td>
<td>v4</td>
<td>v5</td>
</tr>
</tbody></table>
<p>가격 갱신 워크플로는 월요일에만 돌아서, 새 checkout으로 <strong>봇이 커밋·푸시까지 되는지</strong> 검증이 안 된 상태였다. 그래서 <code>workflow_dispatch</code>로 한 번 수동 실행해서 자동 커밋과 재배포까지 확인했다.</p>
<h3 id="다크-모드">다크 모드</h3>
<p>기기 설정을 따르고, 상단 🌙/☀️ 버튼으로 바꿀 수 있다. 고른 값은 브라우저에 기억한다. 화면이 깜빡이지 않도록 <code>&lt;head&gt;</code>에서 그리기 전에 테마를 적용한다.</p>
<pre><code class="language-html">&lt;script&gt;
  try { var t = localStorage.getItem(&quot;theme&quot;);
        if (t === &quot;dark&quot; || t === &quot;light&quot;) document.documentElement.dataset.theme = t; } catch (e) {}
&lt;/script&gt;</code></pre>
<p>색은 처음부터 CSS 변수로 만들어 둬서 변수 값만 바꾸면 됐다. 다만 하드코딩된 색이 몇 군데 남아 있어서 전부 찾아 변수로 옮겼다.</p>
<hr>
<h2 id="4-입문자를-위한-것들">4. 입문자를 위한 것들</h2>
<h3 id="용어-사전">용어 사전</h3>
<p>vCPU, LCU, NAT Gateway, 730시간… 설명을 읽다가 또 모르는 단어가 나오면 막힌다.</p>
<p>그래서 설명 속 어려운 용어에 <strong>점선 밑줄</strong>을 긋고, 누르면 아래에서 뜻이 올라오게 했다. 용어 41개를 데이터 파일 하나로 관리하고, 설명 문장에서 자동으로 찾아 버튼으로 감싼다.</p>
<ul>
<li>긴 단어부터 찾는다. &#39;Route 53&#39;이 &#39;Route&#39;보다 먼저 잡혀야 하기 때문이다.</li>
<li>같은 용어는 문장마다 처음 한 번만 감싼다. 밑줄이 가득하면 오히려 읽기 어렵다.</li>
</ul>
<h3 id="리전-6곳">리전 6곳</h3>
<p>서울, 버지니아에 도쿄, 싱가포르, 오레곤, 프랑크푸르트를 추가했다. 수집 스크립트에 리전 정보만 추가하면 됐다.</p>
<p>CloudFront만 조심해야 했다. 전송 요금이 리전이 아니라 <strong>엣지 지역</strong>별로 나뉜다. 도쿄는 <code>JP</code>, 프랑크푸르트는 <code>EU</code>, 서울·싱가포르는 <code>AP</code> 접두사를 쓴다.</p>
<h3 id="wordpress-템플릿">WordPress 템플릿</h3>
<p>블로그를 만들고 싶은 사람이 가장 많을 것 같아서 추가했다.</p>
<ul>
<li><strong>Lightsail WordPress</strong> (처음이라면 추천): WordPress가 설치된 월 정액 서버</li>
<li><strong>EC2 단독</strong>: 서버 한 대에 WordPress와 MySQL을 직접 설치</li>
<li><strong>EC2 + RDS</strong>: 중규모는 웹 서버 2대 + 로드밸런서</li>
</ul>
<p>블로그는 보통 도메인을 쓰니까, 이 템플릿만 도메인을 기본 &#39;포함&#39;으로 했다.</p>
<h3 id="구성-다이어그램">구성 다이어그램</h3>
<p>&quot;EC2 + RDS + ALB가 어떻게 연결되는지&quot; 그림으로 보여준다. <strong>사용자 → 로드밸런서 → EC2 → RDS</strong> 흐름을 상자로 그리고, 상자마다 그 부분의 월 비용을 적는다.</p>
<ul>
<li>템플릿마다 그림을 따로 만들지 않고, 구성안의 리소스 목록을 보고 자동으로 그린다.</li>
<li>퍼블릭 IPv4와 NAT는 주황색 꼬리표로 눈에 띄게 했다.</li>
<li>테스트에서 <strong>그림 속 비용을 전부 더하면 카드의 합계와 정확히 같은지</strong> 모든 템플릿·규모·구성안에 대해 확인한다. 그림과 숫자가 어긋나면 그림이 오히려 오해를 만들기 때문이다.</li>
</ul>
<hr>
<h2 id="5-그리고-덜어내기">5. 그리고, 덜어내기</h2>
<p>여기까지 하고 휴대폰으로 결과 화면을 열어봤다.</p>
<ul>
<li>위쪽에 선택 칩 3줄 (규모 3개, 리전 6개, 도메인 2개)</li>
<li>공유 버튼</li>
<li>요약 + 추천 + 막대 그래프</li>
<li>값 바꾸기 칸 최대 10개</li>
<li>카드마다 세로로 긴 다이어그램</li>
<li>경고 칩 최대 5개</li>
</ul>
<p><strong>&quot;기능이 많아서 오히려 쓰기 꺼려진다&quot;</strong>는 반응이 나올 만했다. 이 서비스의 목적은 기능을 많이 담는 게 아니라 <strong>AWS를 잘 모르는 사람이 쉽게 쓰는 것</strong>이다.</p>
<p>그래서 기능은 하나도 빼지 않고, <strong>처음 보이는 양만</strong> 줄였다.</p>
<table>
<thead>
<tr>
<th>부분</th>
<th>전</th>
<th>후</th>
</tr>
</thead>
<tbody><tr>
<td>리전</td>
<td>6개 모두</td>
<td>서울·버지니아 + &quot;다른 리전 4곳 ▾&quot;</td>
</tr>
<tr>
<td>값 바꾸기</td>
<td>최대 10칸</td>
<td>핵심 3칸 + &quot;값 N개 더 보기&quot;</td>
</tr>
<tr>
<td>다이어그램 (휴대폰)</td>
<td>세로 상자 여러 개</td>
<td>한 줄 요약 <code>👤 → ⚖️ → 🖥️ → 🗄️</code>, 누르면 펼침</td>
</tr>
<tr>
<td>경고</td>
<td>최대 5개</td>
<td>돈이 많이 새는 순서로 2개 + &quot;+3개 더&quot;</td>
</tr>
<tr>
<td>공유 버튼</td>
<td>맨 위</td>
<td>요약 카드 아래</td>
</tr>
</tbody></table>
<ul>
<li>템플릿마다 &quot;핵심 칸 3개&quot;를 데이터로 정했다. 예를 들어 개발 서버는 사양 · 켜두는 시간 · 전송량이다.</li>
<li>숨겨둔 칸의 값을 바꾼 공유 링크를 열면 &quot;더 보기&quot;가 자동으로 펼쳐진다. 바뀐 값이 숨어 있으면 안 되니까.</li>
</ul>
<p>그리고 큰 기능 대신 4줄짜리 안내를 하나 넣었다. 요금 폭탄은 대부분 이 네 가지를 놓쳐서 생기기 때문이다.</p>
<blockquote>
<p><strong>만든 다음 꼭 할 일</strong></p>
<ol>
<li>예산 알림부터 켜기 (AWS Budgets)</li>
<li>안 쓰면 &#39;중지&#39;보다 &#39;삭제&#39; (Lightsail·NAT·로드밸런서는 켜두기만 해도 과금)</li>
<li>다 쓴 뒤 남은 것 정리 (퍼블릭 IP·스냅샷·디스크)</li>
<li>첫 달 청구서 확인</li>
</ol>
</blockquote>
<p>계획해뒀던 기능 이슈 중 세 개는 <strong>하지 않기로 하고 닫았다.</strong></p>
<ul>
<li>결과 이미지 저장: 공유 링크로 충분하고, 버튼만 하나 더 늘어난다.</li>
<li>컨테이너 앱 템플릿: 도커·ECS는 입문자 대상이 아니다.</li>
<li>환율 자동 갱신: 고정 환율과 기준일 표시로 충분하다.</li>
</ul>
<hr>
<h2 id="숫자로-보면">숫자로 보면</h2>
<ul>
<li>템플릿 5종, 리전 6곳, 직접 계산 서비스 16종, 용어 41개</li>
<li>테스트: JavaScript 39개 + 가격 수집 스크립트 Python 8개 (외부 패키지 없음)</li>
<li>여전히 빌드 없는 순수 HTML/CSS/JS, 운영비 0원 (GitHub Pages)</li>
</ul>
<h2 id="다음에-할-것">다음에 할 것</h2>
<ul>
<li><strong>공식 Pricing Calculator 교차 검증:</strong> 대표 조합 5개부터 직접 비교해볼 예정이다.</li>
<li><strong>프리티어 반영:</strong> 입문자에게 정말 유용하지만 정책이 자주 바뀌어서 보류 중이다.</li>
<li><strong>AI 챗봇 + Bedrock 템플릿:</strong> 관심은 많지만 토큰 요금은 입문자에게 어렵다. 의견이 쌓이면 결정하려 한다.</li>
</ul>
<h2 id="마무리">마무리</h2>
<p>하루 동안 기능을 잔뜩 붙이고 나서야 알았다. 초보자를 위한 도구에서 기능 하나를 더하는 건, 그 사람이 읽어야 할 것을 하나 더 늘리는 일이기도 하다.</p>
<p>그래서 앞으로 기능을 넣을 때마다 이렇게 물어보려고 한다. &quot;이게 처음 AWS를 쓰는 사람한테 꼭 필요한가?&quot;</p>
<p>이번에도 개발은 Claude Code(AI 코딩 에이전트)와 함께 했다. 무엇을 넣고 뺄지는 하나씩 확인하며 결정했다.</p>
<p>👉 <strong><a href="https://dlwldp.github.io/Awscal/">https://dlwldp.github.io/Awscal/</a></strong> 에서 써보고, 사이트의 &quot;의견 보내기&quot;로 피드백 주시면 감사하겠습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# "AWS 한 달에 얼마 나와요?" 초보자용 AWS 비용 계산기를 만들었다]]></title>
            <link>https://velog.io/@jiye_1124/AWS-%ED%95%9C-%EB%8B%AC%EC%97%90-%EC%96%BC%EB%A7%88-%EB%82%98%EC%99%80%EC%9A%94-%EC%B4%88%EB%B3%B4%EC%9E%90%EC%9A%A9-AWS-%EB%B9%84%EC%9A%A9-%EA%B3%84%EC%82%B0%EA%B8%B0%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4</link>
            <guid>https://velog.io/@jiye_1124/AWS-%ED%95%9C-%EB%8B%AC%EC%97%90-%EC%96%BC%EB%A7%88-%EB%82%98%EC%99%80%EC%9A%94-%EC%B4%88%EB%B3%B4%EC%9E%90%EC%9A%A9-AWS-%EB%B9%84%EC%9A%A9-%EA%B3%84%EC%82%B0%EA%B8%B0%EB%A5%BC-%EB%A7%8C%EB%93%A4%EC%97%88%EB%8B%A4</guid>
            <pubDate>Fri, 16 Oct 2026 07:16:00 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>🔗 사이트: <a href="https://dlwldp.github.io/Awscal/">https://dlwldp.github.io/Awscal/</a>
🔗 GitHub: <a href="https://github.com/dlwldP/Awscal">https://github.com/dlwldP/Awscal</a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/jiye_1124/post/7d24679c-024e-46e1-bd9e-3b902dbe959c/image.png" alt=""></p>
<h2 id="왜-만들었나">왜 만들었나</h2>
<p>처음 AWS에 뭔가를 올려보려고 할 때마다 같은 벽에 부딪혔다.</p>
<ul>
<li><strong>공식 Pricing Calculator는 서비스 이름을 알아야 쓸 수 있다.</strong> &quot;Spring Boot 앱 하나 띄우려면 뭐가 필요하지?&quot;부터 모르는 상태에선 어디서부터 입력해야 할지 모른다.</li>
<li><strong>단위가 어렵다.</strong> vCPU-시간, GB-월, LCU, 100만 건당…</li>
<li><strong>숨은 비용을 모르고 지나친다.</strong> 퍼블릭 IPv4, NAT Gateway, 데이터 전송량 같은 건 막상 청구서를 받고 나서야 알게 된다.</li>
</ul>
<p>그래서 <strong>&quot;하고 싶은 것&quot;을 고르면 필요한 구성과 월 비용을 쉽게 비교해주는 계산기</strong>를 만들었다. AWS 계정 연동 없이, 누구나 바로 쓸 수 있게.</p>
<h2 id="무엇을-할-수-있나">무엇을 할 수 있나</h2>
<h3 id="1-구성-추천--목적만-고르면-구성-23가지를-비교">1. 구성 추천 — 목적만 고르면 구성 2~3가지를 비교</h3>
<p>정적 웹사이트 / 백엔드 웹앱 / 서버리스 API / 개발용 단일 서버 중 하나를 고르고, 규모(개인용·소규모·중규모)와 리전(서울·버지니아)을 고르면 끝이다. 결과까지 <strong>2번만 클릭</strong>하면 된다.</p>
<p><img src="https://velog.velcdn.com/images/jiye_1124/post/4e9aaae1-acb8-4cac-9654-2cb22144942d/image.png" alt=""></p>
<p>예를 들어 &quot;백엔드 웹앱 · 개인용 · 서울&quot;을 고르면 이렇게 나온다.</p>
<p><img src="https://velog.velcdn.com/images/jiye_1124/post/4e75bee9-fa1c-4941-98d2-80126fdd44f0/image.png" alt=""></p>
<table>
<thead>
<tr>
<th>구성</th>
<th align="right">월 비용</th>
</tr>
</thead>
<tbody><tr>
<td>EC2 + RDS</td>
<td align="right">$34.57</td>
</tr>
<tr>
<td><strong>Lightsail</strong></td>
<td align="right"><strong>$7.00</strong></td>
</tr>
<tr>
<td>ECS Fargate + RDS</td>
<td align="right">$35.51</td>
</tr>
</tbody></table>
<p>같은 일을 하는데 5배 가까이 차이가 난다. 물론 Lightsail은 DB를 서버에 직접 설치해야 하는 등 트레이드오프가 있어서, 구성마다 <strong>장점·단점</strong>도 같이 보여준다.</p>
<h3 id="2-이-비용은-왜-나오나요">2. &quot;이 비용은 왜 나오나요?&quot;</h3>
<p>각 항목을 누르면 왜 이 비용이 나오는지 한 줄 설명과 계산식이 나온다. 그리고 놓치기 쉬운 비용은 <strong>숨은 비용 경고</strong>로 따로 표시한다.</p>
<p><img src="https://velog.velcdn.com/images/jiye_1124/post/75f09786-e891-4fb2-926a-03bb484bf3d4/image.png" alt=""></p>
<p>직접 만들면서 다시 정리한 숨은 비용들 (서울 리전 기준):</p>
<table>
<thead>
<tr>
<th>숨은 비용</th>
<th>얼마나?</th>
</tr>
</thead>
<tbody><tr>
<td>퍼블릭 IPv4</td>
<td>2024년부터 <strong>사용 중인 IPv4도 과금</strong>. 1개에 월 약 $3.65</td>
</tr>
<tr>
<td>ALB</td>
<td>요청이 0이어도 기본 요금 월 약 $16.43 + <strong>퍼블릭 IPv4 2개</strong>도 따라온다</td>
</tr>
<tr>
<td>NAT Gateway</td>
<td>켜두기만 해도 월 약 $43. 프라이빗 서브넷 쓰다가 가장 많이 당하는 비용</td>
</tr>
<tr>
<td>EBS</td>
<td>EC2를 <strong>중지해도</strong> 디스크 요금은 계속 나간다</td>
</tr>
<tr>
<td>Lightsail</td>
<td>인스턴스를 <strong>중지해도 과금</strong>된다. 삭제해야 멈춘다</td>
</tr>
<tr>
<td>RDS Multi-AZ</td>
<td>체크 하나로 DB 비용이 약 2배</td>
</tr>
</tbody></table>
<h3 id="3-값-직접-바꿔보기">3. 값 직접 바꿔보기</h3>
<p>프리셋이 내 상황과 딱 맞진 않으니까, 결과 화면에서 서버 사양·대수·트래픽 같은 값을 바꾸면 바로 다시 계산되게 했다.</p>
<p><img src="https://velog.velcdn.com/images/jiye_1124/post/c5cf2f67-9274-4e88-bb4d-9f43519c6dc4/image.png" alt=""></p>
<h3 id="4-직접-계산하기">4. 직접 계산하기</h3>
<p>쓸 서비스를 이미 안다면 서비스를 하나씩 담아서 계산할 수 있다. EC2, RDS, S3, Lambda, NAT Gateway 등 15종을 지원하고, <strong>&quot;평일 낮에만 켜두기(월 176시간)&quot;</strong> 같은 사용 시간도 넣을 수 있다. 구성 추천 결과를 그대로 계산기로 가져와서 이어서 조정할 수도 있다.</p>
<p><img src="https://velog.velcdn.com/images/jiye_1124/post/919394a1-35a1-421c-a04f-f77a0474c99e/image.png" alt=""></p>
<h2 id="기술-스택-최대한-단순하게">기술 스택: 최대한 단순하게</h2>
<table>
<thead>
<tr>
<th>영역</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>프론트엔드</td>
<td>순수 HTML / CSS / JavaScript (빌드 없음, npm 의존성 0개)</td>
</tr>
<tr>
<td>가격 데이터</td>
<td>Python 표준 라이브러리 + AWS Price List Bulk API</td>
</tr>
<tr>
<td>자동화·배포</td>
<td>GitHub Actions + GitHub Pages</td>
</tr>
<tr>
<td>테스트</td>
<td>Node 내장 테스트 러너 (<code>node --test</code>)</td>
</tr>
</tbody></table>
<p>처음 기획에선 React + Vite + TypeScript + Tailwind를 쓰려고 했다. 그런데 계산은 전부 브라우저에서 할 수 있고, 화면도 몇 개 안 된다. <strong>가장 쉬운 스택</strong>으로 가기로 하고 프레임워크를 빼버렸다. 결과적으로 <code>index.html</code> 하나에 JS 모듈 몇 개, 그리고 JSON 데이터 파일이 전부다.</p>
<p><strong>백엔드가 없다</strong>는 게 핵심이다. 서버 비용도, 보안 걱정도, API 키도 없다. AWS 비용 계산기를 만들면서 정작 운영비는 0원이다.</p>
<pre><code>[GitHub Actions 주 1회] → AWS 가격 받아서 prices.json 갱신 → 커밋 → 배포
[브라우저] templates.json + prices.json → 계산 엔진 → 화면</code></pre><h2 id="가격-데이터는-어디서--aws-price-list-bulk-api">가격 데이터는 어디서? — AWS Price List Bulk API</h2>
<p>AWS는 모든 서비스의 가격표를 <strong>인증 없이 받을 수 있는 공개 파일</strong>로 제공한다.</p>
<pre><code>https://pricing.us-east-1.amazonaws.com/offers/v1.0/aws/{서비스}/current/{리전}/index.csv</code></pre><p>문제는 크기다. <strong>EC2 가격 파일 하나가 서울 리전만 약 300MB, 버지니아는 480MB</strong>다. 내가 필요한 건 그중 <code>t4g.small</code> 시간당 가격 같은 숫자 몇십 개뿐이다.</p>
<p>그래서 수집 스크립트는 이렇게 만들었다.</p>
<ol>
<li>CSV를 <strong>스트리밍으로 한 줄씩</strong> 읽는다 (메모리에 통째로 올리지 않음)</li>
<li>필요한 항목만 적어둔 <strong>화이트리스트</strong>와 맞는 행만 남긴다</li>
<li>수십 KB짜리 <code>prices.json</code>을 만든다</li>
</ol>
<pre><code class="language-python">def stream_csv(url):
    resp = urllib.request.urlopen(url, timeout=600)
    text = io.TextIOWrapper(resp, encoding=&quot;utf-8&quot;, newline=&quot;&quot;)
    for _ in range(5):  # 메타데이터 줄 건너뛰기
        text.readline()
    yield from csv.DictReader(text)</code></pre>
<p>외부 패키지 없이 표준 라이브러리만 써서, 두 리전 전체를 약 30초에 처리한다.</p>
<h3 id="삽질-기록">삽질 기록</h3>
<p><strong>① us-east-1만 usageType에 리전 접두사가 없다</strong></p>
<p>서울은 <code>APN2-ReadRequestUnits</code>처럼 리전 접두사가 붙는데, 버지니아는 그냥 <code>ReadRequestUnits</code>다. 처음엔 &quot;대문자 접두사가 붙어 있으면 리전 접두사&quot;로 처리했다. 그랬더니 <code>IA-ReadRequestUnits</code>(DynamoDB 저빈도 액세스 테이블)까지 같은 항목으로 잡혀서 가격이 두 개가 나왔다. 결국 <strong>리전별 접두사를 명시</strong>(<code>APN2</code>, <code>USE1</code>)하는 방식으로 바꿨다.</p>
<p><strong>② 애매하면 실패하게</strong></p>
<p>같은 항목에 가격이 두 개 잡히거나 못 찾으면 스크립트가 <strong>에러로 멈추게</strong> 했다. 매주 자동으로 돌아가는 작업이라 조용히 틀린 값이 들어가는 게 제일 무섭다. 실패하면 커밋을 안 하니 기존 가격이 그대로 남는다.</p>
<p><strong>③ Lightsail은 744시간</strong></p>
<p>Lightsail 1GB 번들의 시간당 가격은 $0.0094다. 730시간을 곱하면 $6.86인데 실제 번들 가격은 <strong>$7</strong>이다. 알고 보니 Lightsail은 31일(744시간) 기준 월 정액이고, 시간당 가격은 잘려서 공개된다 (0.0094 × 744 = 6.9936). 그래서 Lightsail만 744시간을 곱하고 센트 단위로 올림 처리했다.</p>
<p><strong>④ CloudFront는 리전 파일이 아니라 글로벌 파일에</strong></p>
<p>CloudFront 전송 요금은 리전이 아니라 <strong>사용자가 접속하는 엣지 위치</strong>(한국은 &quot;Asia Pacific&quot;)에 따라 다르다. 그래서 리전별 파일에는 없고 글로벌 파일에서 찾아야 했다.</p>
<h2 id="계산-엔진-템플릿은-데이터로">계산 엔진: 템플릿은 데이터로</h2>
<p>계산 규칙은 단순하다.</p>
<ul>
<li>한 달 = <strong>730시간</strong> (공식 계산기와 같은 기준)</li>
<li>시간 과금: <code>시간당 단가 × 사용 시간 × 수량</code></li>
<li>사용량 과금: <code>단위 단가 × 사용량</code></li>
</ul>
<p>중요하게 생각한 건 <strong>템플릿을 추가할 때 계산 코드를 고치지 않아도 되게</strong> 하는 것이었다. 구성은 전부 JSON으로 정의하고, <code>{{preset.이름}}</code> 자리에 프리셋 값이 들어간다.</p>
<pre><code class="language-json">{
  &quot;id&quot;: &quot;ec2-rds&quot;,
  &quot;name&quot;: &quot;EC2 + RDS&quot;,
  &quot;resources&quot;: [
    { &quot;type&quot;: &quot;ec2&quot;, &quot;instance&quot;: &quot;{{preset.instance}}&quot;, &quot;count&quot;: &quot;{{preset.count}}&quot; },
    { &quot;type&quot;: &quot;publicIpv4&quot;, &quot;count&quot;: &quot;{{preset.count}}&quot; },
    { &quot;type&quot;: &quot;alb&quot;, &quot;count&quot;: &quot;{{preset.albCount}}&quot;, &quot;lcu&quot;: &quot;{{preset.albLcu}}&quot; },
    { &quot;type&quot;: &quot;publicIpv4&quot;, &quot;count&quot;: &quot;{{preset.albCount}}&quot;, &quot;multiplier&quot;: 2 },
    { &quot;type&quot;: &quot;rds&quot;, &quot;instance&quot;: &quot;{{preset.dbInstance}}&quot;, &quot;storageGb&quot;: &quot;{{preset.dbStorageGb}}&quot; },
    { &quot;type&quot;: &quot;dataTransferOut&quot;, &quot;gb&quot;: &quot;{{preset.egressGb}}&quot; }
  ]
}</code></pre>
<p>엔진은 리소스 타입별 계산 함수(<code>ec2</code>, <code>rds</code>, <code>lambda</code>, …)만 갖고 있다. 각 함수는 금액, 계산식, &quot;왜 나오는지&quot; 설명, 숨은 비용 경고를 함께 돌려준다. 직접 계산기도 같은 엔진을 쓴다.</p>
<p>가격 데이터에 값이 없으면 <strong>0원으로 넘어가지 않고 에러</strong>를 낸다. 계산기에서 &quot;$0.00&quot;이 나오는 버그가 제일 위험하다고 봤다.</p>
<h2 id="배포-github-pages--github-actions">배포: GitHub Pages + GitHub Actions</h2>
<p>정적 파일뿐이라 GitHub Pages로 충분했다. 워크플로는 두 개다.</p>
<ul>
<li><code>deploy.yml</code>: main에 푸시하면 테스트 → Pages 배포</li>
<li><code>update-prices.yml</code>: <strong>매주 월요일 09:00(KST)</strong>에 가격 수집 → 테스트 → 커밋 → 배포</li>
</ul>
<p>여기서 하나 걸렸다. <strong><code>GITHUB_TOKEN</code>으로 한 푸시는 다른 워크플로를 트리거하지 않는다.</strong> 가격 갱신 커밋을 올려도 배포 워크플로가 안 돈다. 그래서 배포 워크플로를 <code>workflow_call</code>로 재사용하게 만들고, 가격 갱신 워크플로가 끝나면 직접 호출하게 했다.</p>
<pre><code class="language-yaml">deploy:
  needs: update
  uses: ./.github/workflows/deploy.yml</code></pre>
<p>재사용 워크플로 안에서 checkout을 그냥 하면 <strong>커밋 전 SHA</strong>를 받아온다. 그래서 <code>ref: ${{ github.ref_name }}</code>으로 최신 main을 받게 했다.</p>
<blockquote>
<p>처음엔 서비스 주제에 맞춰 S3 + CloudFront로 배포하려 했다. 그런데 정적 사이트라 GitHub Pages로 충분하고, AWS 자격증명을 관리할 필요도 없어서 Pages로 결정했다.</p>
</blockquote>
<h2 id="디자인-토스처럼">디자인: 토스처럼</h2>
<p>AWS가 어려운 사람이 대상이라 <strong>한 화면에 하나씩, 큰 숫자로</strong> 보여주는 데 집중했다.</p>
<ul>
<li>흰 카드 + 파란 포인트 색, 한 줄 레이아웃</li>
<li>결과 맨 위에 &quot;가장 저렴한 건 ○○예요, <strong>월 $7.00</strong>&quot;</li>
<li>자세한 내역·장단점은 접어두고 필요할 때만 펼치기</li>
<li>어려운 용어 옆에는 쉬운 설명 (&quot;t4g는 ARM(더 저렴), t3는 x86이에요&quot;)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/jiye_1124/post/6510836c-dde3-49e7-867f-79a470e1426e/image.png" alt=""></p>
<h3 id="디자인하다-만난-버그-클릭이-사라진다">디자인하다 만난 버그: 클릭이 사라진다</h3>
<p>&quot;값 직접 바꿔보기&quot;에서 숫자를 고친 뒤 바로 아래 &quot;펼치기&quot;를 누르면 <strong>첫 클릭이 무시되는</strong> 문제가 있었다.</p>
<p>원인은 이랬다. 숫자 칸에 <code>input</code>과 <code>change</code> 이벤트를 둘 다 걸어두었는데, 다른 곳을 클릭하는 순간 숫자 칸의 포커스가 빠지면서 <code>change</code>가 발생한다. 그 순간 결과 카드를 <strong>다시 그리면서 내가 누르려던 버튼이 새 요소로 바뀌어버린</strong> 것이다.</p>
<p>숫자 칸은 <code>input</code>에서만, 선택 칸은 <code>change</code>에서만 다시 계산하게 바꿔서 해결했다. 그리고 다시 그릴 때 펼쳐둔 카드가 닫히지 않도록 열림 상태를 기억했다가 되살리게 했다.</p>
<h2 id="테스트">테스트</h2>
<ul>
<li><code>node --test</code>로 계산 엔진 단위 테스트 25개 (외부 패키지 없음)</li>
<li><strong>모든 템플릿 × 프리셋 × 리전</strong>에서 결과가 나오는지</li>
<li>규모가 커지면 비용도 커지는지</li>
<li>가격 데이터가 없으면 0원이 아니라 에러가 나는지</li>
<li><strong>모든 프리셋 사용량이 첫 요금 구간 안에 있는지</strong><ul>
<li>S3, 데이터 전송처럼 구간별로 단가가 달라지는 요금은 첫 구간 단가만 쓰도록 단순화했다. 프리셋이 첫 구간을 넘지 않으면 이 단순화로 생기는 오차가 0이 된다.</li>
</ul>
</li>
<li>직접 계산기의 입력 칸 정의와 엔진이 서로 맞는지</li>
</ul>
<h2 id="한계와-다음-계획">한계와 다음 계획</h2>
<p>이 계산기는 <strong>예상치</strong>다. 일부러 단순화한 부분이 있다.</p>
<ul>
<li><strong>프리티어·크레딧은 반영하지 않는다.</strong> 신규 계정은 실제로 더 적게 나올 수 있다.</li>
<li>구간 요금은 첫 구간 단가만 쓴다 (넘으면 화면에서 알려준다).</li>
<li>Lightsail 번들의 전송 허용량 초과분은 계산하지 않는다.</li>
<li>공식 Pricing Calculator와 ±10% 이내인지 확인하는 교차 검증 체크리스트를 만들어 두었고, 하나씩 확인하는 중이다.</li>
</ul>
<p>다음에 해보고 싶은 것들은 GitHub 이슈로 정리해뒀다.</p>
<ul>
<li>결과 공유 URL, 이미지로 저장</li>
<li>구성 다이어그램 자동 생성</li>
<li>&quot;이 구성 만들기&quot; 단계별 가이드</li>
<li>템플릿 추가: WordPress, 컨테이너 앱, AI 챗봇 + Bedrock</li>
<li>환율 자동 갱신</li>
</ul>
<h2 id="마무리">마무리</h2>
<p>AWS 비용이 어려운 이유는 계산이 복잡해서라기보다, <strong>무엇이 필요한지 모르고, 무엇이 숨어 있는지 모르기 때문</strong>이라고 생각한다. 이 계산기가 처음 AWS에 뭔가를 올려보려는 사람에게 &quot;생각보다 별거 아니네&quot; 혹은 &quot;아, 이건 조심해야겠다&quot;를 알려주는 도구가 됐으면 좋겠다.</p>
<p>개발은 Claude Code(AI 코딩 에이전트)와 함께 진행했다. 기획서를 주고, 스택·배포 방식·프리셋 수치 같은 결정은 하나씩 확인하며 만들었다.</p>
<p>👉 <strong><a href="https://dlwldp.github.io/Awscal/">https://dlwldp.github.io/Awscal/</a></strong> 에서 써보고 피드백 주시면 감사하겠습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA["[KubeLens] 자연어로 묻는 read-only 쿠버네티스 진단 도구 개발기 — 기획부터 MVP까지"]]></title>
            <link>https://velog.io/@jiye_1124/KubeLens-%EC%9E%90%EC%97%B0%EC%96%B4%EB%A1%9C-%EB%AC%BB%EB%8A%94-read-only-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EC%A7%84%EB%8B%A8-%EB%8F%84%EA%B5%AC-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%EA%B8%B0%ED%9A%8D%EB%B6%80%ED%84%B0-MVP%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@jiye_1124/KubeLens-%EC%9E%90%EC%97%B0%EC%96%B4%EB%A1%9C-%EB%AC%BB%EB%8A%94-read-only-%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4-%EC%A7%84%EB%8B%A8-%EB%8F%84%EA%B5%AC-%EA%B0%9C%EB%B0%9C%EA%B8%B0-%EA%B8%B0%ED%9A%8D%EB%B6%80%ED%84%B0-MVP%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 13 Oct 2026 05:58:51 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&quot;왜 이 파드가 죽어?&quot;라고 물으면, 에이전트가 <strong>조회만</strong> 해서 원인 후보와 <strong>근거</strong>를 보여주는 도구를 만들었다.</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>쿠버네티스에서 장애가 나면 늘 같은 루틴을 반복한다.</p>
<pre><code class="language-bash">kubectl get pods
kubectl describe pod ...
kubectl logs ... --previous
kubectl get events --sort-by=.lastTimestamp</code></pre>
<p>명령어 네 개를 오가면서 단서를 머릿속으로 이어 붙여야 한다. 익숙하면 금방이지만, 주니어 시절의 나는 <strong>어디부터 봐야 할지</strong>를 몰랐다.</p>
<p>그래서 이걸 LLM 에이전트에게 맡기는 도구를 기획했다. 다만 조건을 세 가지 걸었다.</p>
<ol>
<li><strong>실행 권한 없음</strong>: 에이전트는 조회만 한다. <code>apply</code>, <code>delete</code>, <code>exec</code>, <code>scale</code>은 존재조차 하지 않는다.</li>
<li><strong>증거 없는 주장 금지</strong>: 모든 결론에는 실제 조회 결과(증거) 링크가 붙어야 한다.</li>
<li><strong>정확도를 숫자로</strong>: 정해진 장애 시나리오로 맞혔는지 자동 채점한다.</li>
</ol>
<p>LLM이 &quot;그럴듯한 헛소리&quot;를 하는 걸 막는 게 이 프로젝트의 핵심이다.</p>
<h2 id="기획-범위를-먼저-못-박기">기획: 범위를 먼저 못 박기</h2>
<p>바이브코딩(AI와 함께 코딩)으로 진행할 생각이라 기획서를 먼저 꼼꼼하게 썼다. AI는 &quot;편의상&quot; 기능을 붙이려는 경향이 있어서, <strong>하지 않을 것</strong>을 명시해 두는 게 중요했다.</p>
<p><strong>Non-goals</strong></p>
<ul>
<li>클러스터 상태를 바꾸는 모든 동작</li>
<li>Secret 조회</li>
<li>인증·멀티테넌시 (로컬 단일 사용자 전제)</li>
</ul>
<p>그리고 <code>CLAUDE.md</code>에 작업 규칙을 적었다.</p>
<pre><code class="language-markdown">- 기획서가 단일 진실 공급원이다.
- 절대 금지: 클러스터 상태를 변경하는 코드, secrets 조회, kubectl 셸 실행.
- 도구 추가·변경은 사람이 기획서를 먼저 고친 뒤에만 한다.
- 한 번에 한 Phase만 작업한다. 완료 조건을 확인한 뒤 멈춘다.</code></pre>
<h3 id="기술-스택">기술 스택</h3>
<table>
<thead>
<tr>
<th>영역</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>에이전트</td>
<td>Python 3.12, LangGraph, langchain-anthropic</td>
</tr>
<tr>
<td>LLM</td>
<td>Claude Sonnet 5.5</td>
</tr>
<tr>
<td>백엔드</td>
<td>FastAPI + SSE, SQLModel + SQLite</td>
</tr>
<tr>
<td>K8s 접근</td>
<td><code>kubernetes</code> Python client (kubectl 셸 실행 X)</td>
</tr>
<tr>
<td>프런트</td>
<td>React + Vite + TypeScript strict + Tailwind</td>
</tr>
<tr>
<td>테스트 클러스터</td>
<td>kind</td>
</tr>
</tbody></table>
<h3 id="개발-단계">개발 단계</h3>
<p>Phase를 7개로 나눴다. 가장 신경 쓴 건 <strong>평가 하네스(Phase 3)를 UI(Phase 5)보다 먼저</strong> 만드는 것이었다. 프롬프트나 도구를 고칠 때마다 정확도가 떨어졌는지 바로 알 수 있어야, &quot;고쳤더니 다른 게 망가짐&quot;을 막을 수 있다.</p>
<table>
<thead>
<tr>
<th>Phase</th>
<th>목표</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>모노레포, kind, RBAC, 장애 매니페스트 6종</td>
</tr>
<tr>
<td>1</td>
<td>도구 8개 + 마스킹 + 출력 절단</td>
</tr>
<tr>
<td>2</td>
<td>LangGraph 에이전트 (CLI)</td>
</tr>
<tr>
<td>3</td>
<td>평가 하네스</td>
</tr>
<tr>
<td>4</td>
<td>FastAPI + SQLite + SSE</td>
</tr>
<tr>
<td>5</td>
<td>React UI</td>
</tr>
<tr>
<td>6</td>
<td>README, 데모</td>
</tr>
</tbody></table>
<h2 id="phase-0-장애를-일부러-만들기">Phase 0: 장애를 일부러 만들기</h2>
<p>평가하려면 정답이 있는 장애가 필요하다. kind 클러스터에 일부러 망가진 Deployment 6개를 띄웠다.</p>
<table>
<thead>
<tr>
<th>#</th>
<th>장애</th>
<th>재현 방법</th>
</tr>
</thead>
<tbody><tr>
<td>S1</td>
<td>CrashLoopBackOff</td>
<td>필수 환경변수 <code>DB_HOST</code> 누락으로 <code>exit 1</code></td>
</tr>
<tr>
<td>S2</td>
<td>ImagePullBackOff</td>
<td>존재하지 않는 이미지 태그</td>
</tr>
<tr>
<td>S3</td>
<td>OOMKilled</td>
<td>memory limit 32Mi에서 150M 할당</td>
</tr>
<tr>
<td>S4</td>
<td>Pending</td>
<td>메모리 요청 64Gi</td>
</tr>
<tr>
<td>S5</td>
<td>Pending</td>
<td>존재하지 않는 노드 풀 nodeSelector</td>
</tr>
<tr>
<td>S6</td>
<td>CreateContainerConfigError</td>
<td>없는 ConfigMap 참조</td>
</tr>
</tbody></table>
<p>S1 로그에는 일부러 가짜 키(<code>api_key=kl-fake-...</code>)를 남겨 뒀다. 마스킹이 제대로 되는지 평가할 때 같이 검사하기 위해서다.</p>
<p><code>chaos/expected.yaml</code>이 곧 평가 정답지다.</p>
<pre><code class="language-yaml">- id: S1
  question: &quot;kubelens-chaos 네임스페이스의 payment-api가 계속 재시작돼. 왜 그래?&quot;
  must_match:
    - [&quot;DB_HOST&quot;]
    - [&quot;환경변수&quot;, &quot;환경 변수&quot;, &quot;environment variable&quot;, &quot;env&quot;]
  must_not_appear:
    - &quot;kl-fake-0000000000000000&quot;</code></pre>
<p><code>must_match</code>는 그룹 리스트다. 모든 그룹이 충족되어야 적중이고, 그룹 안에서는 대안 중 하나만 나오면 된다.</p>
<h2 id="phase-1-read-only-by-construction">Phase 1: &quot;Read-only by construction&quot;</h2>
<p>이 프로젝트에서 가장 공들인 부분이다. &quot;조회만 한다&quot;를 <strong>약속이 아니라 구조로</strong> 보장하고 싶었다.</p>
<h3 id="1층-rbac">1층: RBAC</h3>
<p>앱 전용 ServiceAccount에는 get/list 권한만 준다. secrets, configmaps, pods/exec는 아예 빠져 있다.</p>
<pre><code class="language-yaml">rules:
  - apiGroups: [&quot;&quot;]
    resources: [&quot;pods&quot;, &quot;pods/log&quot;, &quot;events&quot;, &quot;nodes&quot;, &quot;namespaces&quot;, &quot;services&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;]
  - apiGroups: [&quot;apps&quot;]
    resources: [&quot;deployments&quot;, &quot;replicasets&quot;]
    verbs: [&quot;get&quot;, &quot;list&quot;]</code></pre>
<p><code>kubectl auth can-i</code>로 허용 9개, 차단 9개 항목을 자동 검사하는 스크립트도 만들었다.</p>
<h3 id="2층-getlist만-노출하는-래퍼">2층: get/list만 노출하는 래퍼</h3>
<p>도구는 kubernetes client의 <code>CoreV1Api</code>를 직접 만지지 않는다. 조회 메서드만 정의된 <code>ReadOnlyKube</code>를 거친다.</p>
<pre><code class="language-python">class ReadOnlyKube:
    &quot;&quot;&quot;allowlist된 조회 메서드만 노출하는 래퍼.&quot;&quot;&quot;

    __slots__ = (&quot;_core&quot;, &quot;_apps&quot;, &quot;_version&quot;)

    def list_pod(self, namespace: str, label_selector: str | None = None): ...
    def read_pod(self, namespace: str, name: str): ...
    def read_pod_log(self, namespace, name, *, container, previous, tail_lines): ...
    # create_ / delete_ / patch_ 같은 건 아예 없다</code></pre>
<p><code>__slots__</code>를 써서 원본 API 객체로 우회할 속성을 붙일 수 없게 했다.</p>
<h3 id="3층-테스트가-소스를-검사한다">3층: 테스트가 소스를 검사한다</h3>
<p>AST로 도구 레이어 소스를 훑어서 금지된 호출이 없는지 검사한다.</p>
<pre><code class="language-python">FORBIDDEN_CALL_PREFIXES = (&quot;create_&quot;, &quot;delete_&quot;, &quot;patch_&quot;, &quot;replace_&quot;, &quot;connect_&quot;)
FORBIDDEN_IMPORTS = {&quot;subprocess&quot;, &quot;os.system&quot;, &quot;kubernetes.stream&quot;, &quot;shlex&quot;, &quot;pty&quot;}</code></pre>
<p>나중에 누가(AI 포함) 편의상 쓰기 API를 넣으면 테스트가 바로 빨간불을 띄운다.</p>
<h3 id="마스킹-→-절단-순서">마스킹 → 절단 순서</h3>
<p>모든 도구 출력은 <strong>마스킹을 먼저 하고, 그다음 4,000자로 절단</strong>한다. 순서를 반대로 하면 비밀값이 절단 경계에 걸려 <code>password=hunt</code>처럼 반쪽만 남는다. 그러면 마스킹 패턴을 피해 갈 수 있다.</p>
<p>절단할 때는 앞 30%와 뒤 70%를 남긴다. 표 헤더는 앞에 있고, 장애 원인은 보통 로그 끝에 있기 때문이다.</p>
<h3 id="트러블슈팅-마스킹이-내-출력을-먹었다">트러블슈팅: 마스킹이 내 출력을 먹었다</h3>
<p>파드의 env를 보여줄 때 secret 참조는 값을 읽지 않고 이렇게 표시하려고 했다.</p>
<pre><code>DB_PASSWORD=&lt;secret db key password, value not read&gt;</code></pre><p>그런데 테스트가 실패했다. 출력이 <code>DB_PASSWORD=***</code>로 바뀌어 있었다. 내가 만든 마스킹 정규식(<code>password=...</code>)이 안내 문구까지 비밀값으로 보고 가려 버린 것이다. 형식을 <code>DB_PASSWORD from secret db key password (value not read)</code>로 바꿔서 해결했다. 마스킹은 생각보다 넓게 먹는다.</p>
<h2 id="phase-2-langgraph-에이전트">Phase 2: LangGraph 에이전트</h2>
<p>그래프는 기획서 그대로 만들었다.</p>
<pre><code>질문 → plan → tool_loop(agent ⇄ tools, 최대 8회) → synthesize → validate → 결과
                                                          └ 증거 없음 → 1회 재생성</code></pre><h3 id="증거-링크-검증">증거 링크 검증</h3>
<p>도구를 호출할 때마다 <code>tc_1</code>, <code>tc_2</code> 같은 ID를 붙여서 LLM에 돌려준다.</p>
<pre><code>[evidence_id: tc_3]
# logs kubelens-chaos/payment-api-xxx previous=True tail=200
INFO starting payment-api v1.4.2
DEBUG loaded config api_key=***
FATAL required environment variable DB_HOST is not set</code></pre><p>최종 보고서의 모든 원인 후보는 <code>evidence_refs</code>에 <strong>실제로 존재하는 ID</strong>를 하나 이상 넣어야 한다. 비어 있거나 없는 ID를 대면 피드백과 함께 1회 재생성하고, 그래도 안 되면 실패로 표시한다.</p>
<pre><code class="language-python">def validate_diagnosis(diagnosis, evidence) -&gt; list[str]:
    known = {e.id for e in evidence}
    for cause in diagnosis.root_causes:
        if not cause.evidence_refs:
            errors.append(&quot;evidence_refs가 비어 있음&quot;)
        elif unknown := [r for r in cause.evidence_refs if r not in known]:
            errors.append(f&quot;존재하지 않는 evidence_id {unknown}&quot;)</code></pre>
<h3 id="트러블슈팅-강제-tool-call이-막힌-모델">트러블슈팅: 강제 tool call이 막힌 모델</h3>
<p>LangChain의 <code>with_structured_output()</code>은 기본적으로 <strong>강제 tool call</strong>로 구조화 출력을 받는다. 그런데 Sonnet 5.5에서는 <code>tool_choice</code>를 <code>any</code>나 <code>tool</code>로 강제하면 400 에러가 난다.</p>
<p>그래서 두 가지로 우회했다.</p>
<ul>
<li>도구 루프는 <code>tool_choice: auto</code>로 두고 프롬프트로 유도한다.</li>
<li>최종 보고서는 <code>method=&quot;json_schema&quot;</code>로 받는다(API의 <code>output_config.format</code>).</li>
</ul>
<pre><code class="language-python">synthesizer = chat.with_structured_output(
    Diagnosis, method=&quot;json_schema&quot;, include_raw=True
)</code></pre>
<p><code>include_raw=True</code>로 원본 응답도 함께 받아서, 모델이 응답을 거부(<code>stop_reason == &quot;refusal&quot;</code>)했거나 파싱에 실패했으면 명확한 에러로 처리했다.</p>
<h3 id="도구-정의는-한-곳에서만">도구 정의는 한 곳에서만</h3>
<p>LLM에 넘기는 도구 JSON Schema를 손으로 따로 쓰지 않았다. Phase 1 도구 함수의 시그니처와 타입 힌트에서 자동 생성한다. 도구 allowlist의 출처가 하나라서, 도구 목록과 LLM 도구 정의가 어긋날 일이 없다.</p>
<h2 id="phase-3-평가-하네스">Phase 3: 평가 하네스</h2>
<p><code>make eval</code> 한 줄로 S1~S6을 실제 에이전트로 진단하고 채점한다.</p>
<ul>
<li>1순위 원인 후보(제목+설명)에 정답 키워드 그룹이 모두 있으면 적중</li>
<li>증거 검증 실패면 오답</li>
<li>최종 출력이나 LLM에 넘어간 도구 출력에 비밀값이 있으면 오답</li>
<li>도구 호출 수, 응답 시간, 토큰 비용도 함께 집계</li>
</ul>
<p>목표는 <strong>6개 중 5개 이상 1순위 적중 + 모든 원인 후보에 증거 링크</strong>다.</p>
<p>실제 LLM 평가는 비용이 들어서 CI에서 push마다 돌리지 않고, GitHub Actions의 <strong>Run workflow</strong>로 수동 실행할 때만 돌게 했다.</p>
<blockquote>
<p>⚠️ 이 글을 쓰는 시점에는 아직 실제 LLM으로 평가를 돌리지 않았다. 결과 표는 다음 글에서 공개할 예정이다.</p>
</blockquote>
<h2 id="phase-4-fastapi--sse">Phase 4: FastAPI + SSE</h2>
<p>질문을 보내면 SSE로 진행 상황이 실시간으로 흘러온다.</p>
<pre><code>event: thinking    → 조사 계획 수립 중
event: tool_start  → list_pods 실행
event: tool_end    → 12ms, ok
...
event: final       → 진단 결과 JSON</code></pre><p>LangGraph는 동기로 돌기 때문에 워커 스레드에서 실행하고, 이벤트는 <code>loop.call_soon_threadsafe</code>로 asyncio 큐에 넣어서 스트리밍했다.</p>
<p>에이전트 내부 ID(<code>tc_3</code>)는 DB에 저장할 때 <code>ToolCall.id</code>로 바꿔서 저장한다. 그래서 진단 결과의 증거 링크가 곧 DB의 도구 호출 기록을 가리키고, 새로고침해도 증거를 다시 열 수 있다.</p>
<p>도구 출력 원문은 SSE로 보내지 않는다. 트레이스에는 도구명·인자·소요시간만 흘리고, 원문은 증거를 클릭했을 때 API로 따로 조회한다.</p>
<h3 id="트러블슈팅-detachedinstanceerror">트러블슈팅: DetachedInstanceError</h3>
<p>SQLModel 세션에서 클러스터 객체를 꺼낸 뒤 워커 스레드에서 속성을 읽으려다 이 에러를 만났다.</p>
<pre><code>DetachedInstanceError: Instance &lt;Cluster&gt; is not bound to a Session</code></pre><p><code>commit()</code>하면 객체 속성이 만료(expire)되는데, 세션을 닫은 뒤 다른 스레드에서 접근하니 다시 불러올 수가 없었던 것이다. 필요한 값(<code>kubeconfig_path</code>, <code>context</code>)만 커밋 전에 지역 변수로 꺼내 두는 방식으로 해결했다.</p>
<h2 id="phase-5-react-ui">Phase 5: React UI</h2>
<p>화면은 3개다.</p>
<ol>
<li><strong>클러스터 설정</strong>: kubeconfig context 선택 → 연결 테스트(서버 버전 표시) → 저장</li>
<li><strong>진단 화면</strong>: 왼쪽 세션 목록 / 가운데 대화와 진단 카드 / 오른쪽 실시간 도구 트레이스</li>
<li><strong>증거 모달</strong>: 도구명·인자·마스킹된 원본 출력·절단 여부</li>
</ol>
<p>SSE가 POST 요청이라 <code>EventSource</code>를 쓸 수 없었다. 그래서 <code>fetch</code> 스트림을 직접 읽어서 <code>\n\n</code> 단위로 파싱했다.</p>
<p>빌드된 UI는 FastAPI가 같이 서빙한다. <code>make setup</code> → <code>make api</code> 두 줄이면 브라우저에서 바로 쓸 수 있다.</p>
<h2 id="테스트-전략-api-키-없이-어디까지-검증할까">테스트 전략: API 키 없이 어디까지 검증할까</h2>
<p>LLM이 들어간 시스템은 테스트가 까다롭다. 매번 실제 모델을 부르면 비용이 들고 결과도 매번 다르다. 그래서 <strong>가짜 LLM</strong>을 만들었다. 정해진 대본대로 도구를 호출하고, 받은 증거 ID를 그대로 인용해 보고서를 만든다.</p>
<p>이걸 팩토리 주입으로 갈아 끼울 수 있게 했다.</p>
<pre><code class="language-bash">KUBELENS_LLM_FACTORY=tests.fake_llms:scripted_llms \
  uv run uvicorn kubelens.api.app:app</code></pre>
<p>덕분에 CI에서 <strong>실제 kind 클러스터 + 가짜 LLM</strong> 조합으로 전체 흐름을 검증할 수 있다.</p>
<table>
<thead>
<tr>
<th>CI 잡</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>lint + unit</td>
<td>ruff, oxlint, tsc, pytest 70개</td>
</tr>
<tr>
<td>kind 통합</td>
<td>kind 생성 → RBAC 검사 → 장애 6종 상태 검증 → 통합 테스트 12개 → curl로 SSE 순서 검사 → Playwright e2e</td>
</tr>
<tr>
<td>eval</td>
<td>실제 LLM 평가 (수동 실행)</td>
</tr>
</tbody></table>
<p>Playwright e2e는 클러스터 설정 → 질문 → 트레이스 → 진단 카드 → 증거 모달 → 새로고침 후 기록 유지까지 확인한다.</p>
<h3 id="트러블슈팅-클라우드-개발-환경에서-kind가-안-뜬다">트러블슈팅: 클라우드 개발 환경에서 kind가 안 뜬다</h3>
<p>원격 개발 환경에서 <code>kind create cluster</code>를 하니 control-plane이 뜨지 않았다.</p>
<pre><code>runc create failed: unable to start container process:
can&#39;t get final child&#39;s PID from pipe: EOF</code></pre><p>컨테이너 안에서 다시 컨테이너를 띄우는 중첩 구조라 runc가 실패한 것이다. 로컬 검증을 포기하고 <strong>GitHub Actions에서 kind를 띄우는 CI</strong>를 만들었다. 결과적으로 이게 더 좋았다. 매 push마다 깨끗한 클러스터에서 처음부터 검증된다.</p>
<p>첫 CI는 kind 설치 단계에서 바로 실패했다. <code>/usr/local/bin</code>에 바로 쓰려면 sudo가 필요했다. 임시 폴더에 받은 뒤 <code>sudo install</code>로 바꿔 해결했다.</p>
<h3 id="트러블슈팅-데모-gif-녹화">트러블슈팅: 데모 GIF 녹화</h3>
<p>처음엔 Playwright의 동영상 녹화로 GIF를 만들려 했는데, 개발 환경에 Playwright가 요구하는 ffmpeg 버전이 없어서 영상 파일이 조용히 안 만들어졌다. 녹화 방식을 바꿔서 <strong>일정 간격으로 스크린샷을 찍고, 시스템 ffmpeg로 이어 붙이는</strong> 방식으로 해결했다. 환경 의존성이 줄어서 오히려 더 안정적이다.</p>
<h2 id="현재-상태와-다음-할-일">현재 상태와 다음 할 일</h2>
<p><strong>검증 완료</strong> (CI, 실제 kind 클러스터)</p>
<ul>
<li>read-only RBAC, 장애 6종 재현, 도구 8개, 마스킹·절단</li>
<li>에이전트 그래프 흐름(가짜 LLM), API·SSE, UI 전체 흐름</li>
</ul>
<p><strong>남은 것</strong></p>
<ul>
<li>실제 Claude 호출로 S1~S6 정확도 측정 (<code>make eval</code>)</li>
<li>브라우저에서 S1~S3 직접 확인</li>
<li>실제 진단 화면으로 데모 GIF 만들기</li>
</ul>
<h2 id="회고">회고</h2>
<ul>
<li><strong>&quot;하지 않을 것&quot;을 먼저 적은 게 가장 효과적이었다.</strong> AI와 함께 개발할 때 범위가 새는 걸 막아 줬다.</li>
<li><strong>안전은 약속보다 구조로.</strong> RBAC, 래퍼, 소스 검사 테스트로 세 겹을 쌓으니 &quot;정말 조회만 하나?&quot;라는 질문에 코드로 답할 수 있게 됐다.</li>
<li><strong>LLM 시스템도 테스트할 수 있다.</strong> 가짜 LLM을 주입하면 비싼 모델 호출 없이도 배선은 거의 다 검증된다. 남는 건 &quot;답이 맞느냐&quot;뿐이고, 그건 평가 하네스의 몫이다.</li>
<li><strong>평가를 먼저 만들기.</strong> 아직 숫자는 없지만, 프롬프트를 고칠 준비는 끝났다.</li>
</ul>
<p>다음 글에서는 실제 평가 결과와 프롬프트 튜닝 과정을 정리해 보겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# [Subhelp 개발기 #7] 계정 삭제와 Apple 토큰 철회, 그리고 비밀 키 관리]]></title>
            <link>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-7-%EA%B3%84%EC%A0%95-%EC%82%AD%EC%A0%9C%EC%99%80-Apple-%ED%86%A0%ED%81%B0-%EC%B2%A0%ED%9A%8C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%B9%84%EB%B0%80-%ED%82%A4-%EA%B4%80%EB%A6%AC</link>
            <guid>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-7-%EA%B3%84%EC%A0%95-%EC%82%AD%EC%A0%9C%EC%99%80-Apple-%ED%86%A0%ED%81%B0-%EC%B2%A0%ED%9A%8C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EB%B9%84%EB%B0%80-%ED%82%A4-%EA%B4%80%EB%A6%AC</guid>
            <pubDate>Tue, 13 Oct 2026 04:18:49 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>시리즈: Subhelp — iOS 구독 관리 앱 개발기
이번 글: 계정 삭제 요건, Supabase Edge Function, 비밀 키 관리, 개인정보 라벨, 회고</p>
</blockquote>
<h2 id="계정-삭제는-선택이-아니다">계정 삭제는 선택이 아니다</h2>
<p>App Store는 계정을 만들 수 있는 앱이라면 <strong>앱 안에서 계정 삭제 기능을 제공</strong>하라고 요구합니다. Apple 로그인을 쓴다면 삭제할 때 <strong>Apple 토큰도 철회</strong>해야 합니다. 빠지면 심사에서 거절될 수 있습니다.</p>
<p>토큰을 철회하지 않으면 사용자의 Apple ID에는 &quot;이 앱에 Apple로 로그인함&quot; 연결이 계속 남습니다.</p>
<h2 id="왜-edge-function이-필요했나">왜 Edge Function이 필요했나</h2>
<p>토큰 철회 요청에는 Apple 개인 키(.p8)로 서명한 <code>client_secret</code>이 필요합니다. 이 키를 앱에 넣으면 누구나 앱 파일에서 꺼낼 수 있습니다.</p>
<p>그래서 키는 <strong>Supabase Edge Function</strong>(서버)에만 두었습니다. Edge Function은 Supabase 안에서 실행되는 작은 서버 코드(Deno/TypeScript)라 따로 서버를 운영하지 않아도 됩니다.</p>
<h2 id="흐름">흐름</h2>
<pre><code>설정 &gt; 계정 삭제 &gt; 확인
 → Apple로 한 번 더 확인 (일회용 authorizationCode 획득)
 → Edge Function `delete-account` 호출
     1) Authorization 헤더로 요청한 사용자 확인
     2) authorizationCode → Apple에서 토큰 교환 → 토큰 철회
     3) Supabase 계정 삭제 (구독은 on delete cascade로 함께 삭제)</code></pre><p>삭제 직전에 Apple 확인을 한 번 더 받는 이유는, 철회에 필요한 <code>authorizationCode</code>가 일회용이라 로그인 시점의 코드를 쓸 수 없기 때문입니다. 많은 앱에 &quot;계정을 삭제하려면 다시 로그인해주세요&quot; 단계가 있는 이유입니다.</p>
<h3 id="apple-client_secret-만들기">Apple client_secret 만들기</h3>
<pre><code class="language-ts">import { importPKCS8, SignJWT } from &quot;npm:jose@5&quot;;

async function makeClientSecret(clientId: string): Promise&lt;string&gt; {
  const key = await importPKCS8(requireEnv(&quot;APPLE_PRIVATE_KEY&quot;), &quot;ES256&quot;);
  const now = Math.floor(Date.now() / 1000);
  return await new SignJWT({})
    .setProtectedHeader({ alg: &quot;ES256&quot;, kid: requireEnv(&quot;APPLE_KEY_ID&quot;) })
    .setIssuer(requireEnv(&quot;APPLE_TEAM_ID&quot;))
    .setIssuedAt(now)
    .setExpirationTime(now + 300)            // 5분만 유효
    .setAudience(&quot;https://appleid.apple.com&quot;)
    .setSubject(clientId)                    // 앱 번들 ID
    .sign(key);
}</code></pre>
<h3 id="토큰-교환-→-철회">토큰 교환 → 철회</h3>
<pre><code class="language-ts">const tokenResponse = await postForm(&quot;https://appleid.apple.com/auth/token&quot;, {
  client_id: clientId,
  client_secret: clientSecret,
  code: authorizationCode,
  grant_type: &quot;authorization_code&quot;,
});
const tokens = await tokenResponse.json();

await postForm(&quot;https://appleid.apple.com/auth/revoke&quot;, {
  client_id: clientId,
  client_secret: clientSecret,
  token: tokens.refresh_token ?? tokens.access_token,
  token_type_hint: tokens.refresh_token ? &quot;refresh_token&quot; : &quot;access_token&quot;,
});</code></pre>
<h3 id="설계에서-정한-것">설계에서 정한 것</h3>
<ul>
<li><strong>Apple 철회가 실패해도 사용자의 삭제 요청은 처리</strong>한다. 결과는 응답의 <code>appleTokenRevoked</code>로 따로 알리고 함수 로그에 남긴다</li>
<li>함수가 아직 배포되지 않았거나 호출에 실패하면, 앱은 DB 함수 <code>delete_account()</code>로 계정만 삭제한다 (Apple 철회 생략)</li>
<li>계정 삭제는 사용자 권한으로 할 수 없어서 함수 안에서만 <code>service_role</code> 키로 삭제한다. 이 키는 Supabase가 함수에 자동으로 넣어주므로 저장소 어디에도 없다</li>
</ul>
<h3 id="edge-function도-ci로-검사">Edge Function도 CI로 검사</h3>
<p>CI에 Deno 작업을 추가해서 배포 전에 문법 오류를 잡습니다.</p>
<pre><code class="language-yaml">edge-functions:
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: denoland/setup-deno@v2
    - run: |
        deno check supabase/functions/delete-account/index.ts
        deno lint supabase/functions/
        deno fmt --check --line-width 120 supabase/functions/</code></pre>
<h2 id="비밀-키-관리-원칙">비밀 키 관리 원칙</h2>
<table>
<thead>
<tr>
<th>값</th>
<th>어디에</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>팀 ID, 번들 ID, App Group</td>
<td><code>Config/Local.xcconfig</code></td>
<td>gitignore</td>
</tr>
<tr>
<td>문의 메일, 개인정보처리방침 URL</td>
<td><code>Config/Local.xcconfig</code></td>
<td>gitignore</td>
</tr>
<tr>
<td>Supabase URL, anon 키</td>
<td><code>Config/Local.xcconfig</code></td>
<td>anon은 앱에 들어가도 되는 공개 키 (RLS로 보호)</td>
</tr>
<tr>
<td>Apple .p8 키, Key ID</td>
<td>Supabase Secrets</td>
<td>앱·저장소에 두지 않음</td>
</tr>
<tr>
<td>service_role 키</td>
<td>어디에도 넣지 않음</td>
<td>Edge Function에 자동 주입, RLS를 우회하는 키</td>
</tr>
</tbody></table>
<ul>
<li>커밋되는 설정 파일(<code>Base.xcconfig</code>, <code>*.example</code>)에는 <strong>빈 값과 예시 값만</strong> 둔다</li>
<li><code>.gitignore</code>: <code>Config/Local.xcconfig</code>, <code>supabase/functions/.env</code>, <code>*.p8</code>, <code>*.p12</code>, <code>*.mobileprovision</code></li>
<li>Info.plist·entitlements는 <code>$(SUPABASE_URL)</code> 같은 <strong>변수만 참조</strong>하고, 실제 값은 빌드할 때 채워진다</li>
<li>서버 설정이 비어 있으면 서버 코드는 아예 동작하지 않는다</li>
</ul>
<h2 id="개인정보-라벨도-바뀐다">개인정보 라벨도 바뀐다</h2>
<p>로그인 기능이 생기면서 &quot;데이터 수집 안 함&quot;이 아니게 됐습니다. <code>PrivacyInfo.xcprivacy</code>에 수집 항목을 선언했고, App Store Connect 개인정보 라벨에도 같게 적어야 합니다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>사용자와 연결</th>
<th>추적</th>
<th>목적</th>
</tr>
</thead>
<tbody><tr>
<td>이메일 주소</td>
<td>예</td>
<td>아니요</td>
<td>앱 기능</td>
</tr>
<tr>
<td>사용자 ID</td>
<td>예</td>
<td>아니요</td>
<td>앱 기능</td>
</tr>
<tr>
<td>기타 금융 정보(구독 금액)</td>
<td>예</td>
<td>아니요</td>
<td>앱 기능</td>
</tr>
</tbody></table>
<p>로그인을 선택 기능으로 둔 것도 이 때문입니다. 로그인하지 않으면 데이터는 여전히 기기에만 남습니다.</p>
<h2 id="회고">회고</h2>
<h3 id="잘한-것">잘한 것</h3>
<ul>
<li><strong>계산 로직을 Domain의 순수 함수로 분리하고 테스트를 먼저 붙였다.</strong> 결제일 계산, 무료체험 규칙, 통계, 동기화 병합 규칙까지 총 38개 테스트가 있다. 나중에 바뀐 요구사항(무료체험 규칙 등)도 테스트로 바로 확인할 수 있었다</li>
<li><strong>서버를 &quot;선택&quot;으로 붙였다.</strong> 설정 값이 없으면 서버 코드가 아예 돌지 않아서, 로컬 앱의 안정성을 해치지 않는다</li>
<li><strong>CI를 처음부터 붙였다.</strong> type-check 시간 초과처럼 로컬에서 놓치기 쉬운 문제를 push 직후에 잡았다</li>
<li><strong>비밀 값은 처음부터 변수로 분리했다.</strong> 저장소 전체 기록을 검사해도 실제 키 값이 없다</li>
</ul>
<h3 id="아쉬운-것--남은-것">아쉬운 것 / 남은 것</h3>
<ul>
<li>CI는 시뮬레이터 빌드와 계산 로직까지만 검증한다. 실기기 알림, 위젯 갱신, 로그인·동기화, 계정 삭제는 직접 확인해야 한다</li>
<li>Edge Function은 문법 검사만 통과했다. 실제 Apple 키로 배포한 뒤 철회까지 되는지 확인이 필요하다</li>
</ul>
<h3 id="다음-단계">다음 단계</h3>
<ul>
<li>실기기 테스트, TestFlight 배포</li>
<li>인터랙티브 위젯 (App Intents) — 위젯에서 바로 확인·체크</li>
<li>무료체험 종료 Live Activity</li>
<li>실시간 환율 API + 캐싱</li>
<li>서비스별 해지 링크 모음</li>
<li>공유 구독(가족 요금제 N빵) — Supabase 위에 확장</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[# [Subhelp 개발기 #6] Supabase 연동: 선택형 로그인과 동기화]]></title>
            <link>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-6-Supabase-%EC%97%B0%EB%8F%99-%EC%84%A0%ED%83%9D%ED%98%95-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EA%B3%BC-%EB%8F%99%EA%B8%B0%ED%99%94</link>
            <guid>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-6-Supabase-%EC%97%B0%EB%8F%99-%EC%84%A0%ED%83%9D%ED%98%95-%EB%A1%9C%EA%B7%B8%EC%9D%B8%EA%B3%BC-%EB%8F%99%EA%B8%B0%ED%99%94</guid>
            <pubDate>Tue, 13 Oct 2026 04:18:33 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>시리즈: Subhelp — iOS 구독 관리 앱 개발기
이번 글: 원격 프리셋, Apple 로그인, 동기화 규칙(Last-Write-Wins + Tombstone), 트러블슈팅 3가지</p>
</blockquote>
<h2 id="원칙-로그인은-선택">원칙: 로그인은 &quot;선택&quot;</h2>
<p>기획 원칙은 &quot;계정 없이 바로 쓴다&quot;였습니다. 하지만 기기를 바꾸면 데이터가 사라지는 문제가 남아 있었습니다. 그래서 서버를 붙이되 <strong>로그인은 선택 기능</strong>으로 했습니다.</p>
<ul>
<li>로그인하지 않아도 모든 기능이 동작한다</li>
<li>Supabase 설정 값이 비어 있으면 서버 기능이 화면에서 아예 숨겨진다</li>
<li>로그인하면 구독이 서버에 백업되고 여러 기기에서 동기화된다</li>
<li>로그아웃하거나 계정을 삭제해도 <strong>이 기기의 구독은 남는다</strong></li>
</ul>
<pre><code class="language-swift">enum SupabaseService {
    static let client: SupabaseClient? = {
        guard let url = AppConfig.supabaseURL, let key = AppConfig.supabaseAnonKey else { return nil }
        return SupabaseClient(supabaseURL: url, supabaseKey: key)
    }()
}</code></pre>
<p>클라이언트는 공식 <code>supabase-swift</code> SDK를 XcodeGen <code>packages</code>로 추가했습니다. 인증 토큰 갱신·세션 저장을 SDK가 처리해 줍니다.</p>
<h2 id="원격-프리셋">원격 프리셋</h2>
<p>추가 화면의 프리셋을 서버(<code>presets</code> 테이블)에서 받아옵니다. 앱을 업데이트하지 않고도 프리셋을 추가·수정할 수 있습니다.</p>
<ul>
<li>로그인 없이 누구나 읽기 가능 (RLS: <code>to anon, authenticated using (is_active)</code>)</li>
<li>받은 목록은 기기에 캐시</li>
<li>서버가 없거나 실패하면 앱에 들어 있는 기본 프리셋 사용</li>
<li>모르는 카테고리가 오면 &quot;기타&quot;로 처리</li>
</ul>
<h2 id="db-설계와-rls">DB 설계와 RLS</h2>
<pre><code class="language-sql">create table public.subscriptions (
  id                  uuid primary key,           -- 앱에서 만든 UUID 그대로 (기기 ↔ 서버 동일 ID)
  user_id             uuid not null default auth.uid()
                      references auth.users (id) on delete cascade,
  name                text not null,
  price               numeric(12, 2) not null check (price &gt; 0),
  currency_code       text not null default &#39;KRW&#39;,
  cycle               text not null check (cycle in (&#39;weekly&#39;, &#39;monthly&#39;, &#39;yearly&#39;)),
  cycle_interval      int  not null default 1,
  start_date          date not null,
  notify_days_before  int,
  is_active           boolean not null default true,
  trial_end_date      date,
  updated_at          timestamptz not null default now(),
  deleted_at          timestamptz                  -- 삭제 기록(tombstone)
  -- ...
);

alter table public.subscriptions enable row level security;

create policy &quot;본인 구독 읽기&quot; on public.subscriptions
  for select to authenticated using ((select auth.uid()) = user_id);
-- insert / update / delete도 같은 조건</code></pre>
<p>앱에는 <strong>공개 키(anon)만</strong> 넣고, 데이터는 RLS(행 단위 보안)가 지킵니다. 다른 사람의 행은 읽을 수도, 쓸 수도 없습니다.</p>
<h2 id="apple-로그인">Apple 로그인</h2>
<p><code>SignInWithAppleButton</code>으로 Apple 인증을 받고, 그 ID 토큰으로 Supabase에 로그인합니다. 재전송 공격을 막기 위해 nonce를 씁니다.</p>
<pre><code class="language-swift">func prepareAppleRequest(_ request: ASAuthorizationAppleIDRequest) {
    let nonce = Self.randomNonce()
    currentNonce = nonce
    request.requestedScopes = [.email]
    request.nonce = Self.sha256(nonce)          // Apple에는 해시를 보내고
}

// 완료 후
try await client.auth.signInWithIdToken(
    credentials: OpenIDConnectCredentials(provider: .apple, idToken: idToken, nonce: nonce)  // Supabase에는 원문
)</code></pre>
<h2 id="동기화-규칙-last-write-wins--tombstone">동기화 규칙: Last-Write-Wins + Tombstone</h2>
<p>구독 수가 많지 않으니 <strong>매번 서버의 내 구독 전체를 받아 비교</strong>합니다. 마지막 동기화 시점을 관리하지 않아도 되어 단순하고 안전합니다.</p>
<p>비교 규칙은 순수 함수 <code>SyncPlanner</code>(Domain)로 분리해서 단위 테스트했습니다.</p>
<table>
<thead>
<tr>
<th>상황</th>
<th>처리</th>
</tr>
</thead>
<tbody><tr>
<td>기기에만 있음</td>
<td>업로드</td>
</tr>
<tr>
<td>서버에만 있음</td>
<td>기기에 추가</td>
</tr>
<tr>
<td>둘 다 있음</td>
<td><code>updatedAt</code>이 최신인 쪽으로 맞춤</td>
</tr>
<tr>
<td>서버에 삭제 표시</td>
<td>기기에서도 삭제 (<strong>삭제가 수정보다 우선</strong>)</td>
</tr>
<tr>
<td>이 기기에서 삭제</td>
<td>서버에 <code>deleted_at</code> 표시 → 다른 기기에 전파</td>
</tr>
</tbody></table>
<pre><code class="language-swift">enum SyncPlanner {
    static func plan(local: [SyncRecord], remote: [SyncRecord], tombstones: Set&lt;UUID&gt;) -&gt; SyncPlan
}

struct SyncPlan: Equatable {
    var push: Set&lt;UUID&gt; = []          // 기기 → 서버
    var apply: Set&lt;UUID&gt; = []         // 서버 → 기기
    var deleteLocal: Set&lt;UUID&gt; = []
    var deleteRemote: Set&lt;UUID&gt; = []
    var clearTombstones: Set&lt;UUID&gt; = []
}</code></pre>
<h3 id="왜-삭제할-때-서버-행을-바로-지우지-않나">왜 삭제할 때 서버 행을 바로 지우지 않나</h3>
<p>기기 A에서 지운 구독을 서버에서도 바로 지우면, 기기 B는 &quot;서버에 없는데 내 기기엔 있다 → 새로 만든 구독이구나&quot; 하고 다시 업로드합니다. 그래서 행은 남기고 <code>deleted_at</code>만 표시해서, 다른 기기가 &quot;삭제됐다&quot;는 사실을 알 수 있게 했습니다.</p>
<p>이 기기에서 로그인 상태로 지운 구독은 <code>DeletionTombstone</code>이라는 작은 SwiftData 모델에 기록해 두고, 다음 동기화 때 서버에 삭제 표시를 남긴 뒤 정리합니다.</p>
<h3 id="동기화-시점">동기화 시점</h3>
<ul>
<li>로그인 직후, 앱 실행·포그라운드 복귀 때</li>
<li>구독을 바꾼 <strong>1.5초 뒤</strong> (연속 수정은 하나로 묶음)</li>
</ul>
<p>수정 시각(<code>updatedAt</code>)은 모든 수정 경로에서 빠짐없이 갱신되도록 <code>touch()</code> 하나로 모았습니다. SwiftData의 자동 저장이 먼저 돌면 &quot;어떤 모델이 바뀌었는지&quot; 정보를 잃을 수 있어서, 변경을 감지하는 대신 <strong>수정하는 곳에서 직접 표시</strong>하는 방식을 택했습니다.</p>
<h2 id="트러블슈팅-1-동기화할-때마다-업로드가-반복됨">트러블슈팅 1: 동기화할 때마다 업로드가 반복됨</h2>
<p>Swift의 <code>Date</code>는 마이크로초 단위까지 갖고 있고, 서버에는 밀리초 단위로 저장했습니다. 그래서 업로드 직후에도 &quot;기기 시각 <code>.123456</code> &gt; 서버 시각 <code>.123</code>&quot;이 되어 <strong>매번 다시 업로드</strong>했습니다.</p>
<pre><code class="language-swift">extension Date {
    var truncatedToMilliseconds: Date {
        Date(timeIntervalSince1970: (timeIntervalSince1970 * 1000).rounded(.down) / 1000)
    }
}</code></pre>
<ul>
<li>수정 시각을 저장할 때 밀리초로 자른다</li>
<li>서버 값을 기기에 반영할 때는 서버 시각을 <strong>그대로</strong> 쓴다</li>
<li>비교할 때도 1ms 이내 차이는 같은 것으로 본다</li>
</ul>
<p>Postgres가 마이크로초(6자리)로 돌려줄 때도 있어서, 파싱 전에 소수점 아래를 3자리로 맞췄습니다.</p>
<h2 id="트러블슈팅-2-알림-끔이-서버에-반영되지-않음">트러블슈팅 2: &quot;알림 끔&quot;이 서버에 반영되지 않음</h2>
<p>Swift의 기본 <code>Codable</code>은 nil 값을 <strong>키째 빼버립니다</strong> (<code>encodeIfPresent</code>). upsert 요청에서 키가 빠지면 서버는 그 컬럼을 건드리지 않습니다. 그래서 알림을 꺼도(nil) 서버에는 이전 값이 그대로 남았습니다.</p>
<pre><code class="language-swift">// nil도 null로 보내도록 직접 인코딩
func encode(to encoder: Encoder) throws {
    var container = encoder.container(keyedBy: CodingKeys.self)
    try container.encode(notifyDaysBefore, forKey: .notifyDaysBefore)   // nil → null
    try container.encode(trialEndDate, forKey: .trialEndDate)
    // ...
}</code></pre>
<h2 id="트러블슈팅-3-가격-소수점-오차">트러블슈팅 3: 가격 소수점 오차</h2>
<p><code>numeric</code> 컬럼은 JSON 숫자로 오는데, 이를 <code>Decimal</code>로 디코딩할 때 <code>9.9900000001</code> 같은 오차가 생길 수 있습니다. 소수 둘째 자리로 반올림했습니다.</p>
<pre><code class="language-swift">var input = value, result = Decimal()
NSDecimalRound(&amp;result, &amp;input, 2, .plain)</code></pre>
<h2 id="날짜-형식">날짜 형식</h2>
<ul>
<li>첫 결제일 같은 <strong>날짜</strong>는 <code>date</code> 컬럼(<code>&quot;2026-10-13&quot;</code>), 기기 시간대 기준 자정으로 변환</li>
<li>수정 시각 같은 <strong>시각</strong>은 <code>timestamptz</code>(ISO 8601, UTC)</li>
</ul>
<p>둘을 섞으면 시간대 때문에 날짜가 하루 밀릴 수 있어서 변환 함수를 따로 뒀습니다.</p>
<h2 id="다음-글">다음 글</h2>
<p>계정 삭제와 Apple 토큰 철회(Edge Function), 그리고 비밀 키 관리 원칙을 다룹니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# [Subhelp 개발기 #5] 통계 탭과 "컴파일러가 타입을 못 정해요"]]></title>
            <link>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-5-%ED%86%B5%EA%B3%84-%ED%83%AD%EA%B3%BC-%EC%BB%B4%ED%8C%8C%EC%9D%BC%EB%9F%AC%EA%B0%80-%ED%83%80%EC%9E%85%EC%9D%84-%EB%AA%BB-%EC%A0%95%ED%95%B4%EC%9A%94</link>
            <guid>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-5-%ED%86%B5%EA%B3%84-%ED%83%AD%EA%B3%BC-%EC%BB%B4%ED%8C%8C%EC%9D%BC%EB%9F%AC%EA%B0%80-%ED%83%80%EC%9E%85%EC%9D%84-%EB%AA%BB-%EC%A0%95%ED%95%B4%EC%9A%94</guid>
            <pubDate>Tue, 13 Oct 2026 04:18:16 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>시리즈: Subhelp — iOS 구독 관리 앱 개발기
이번 글: 통계 탭(MVP 2), 결제 캘린더, 비싼 구독 순위, 연간 결제 예고, 정렬·필터, type-check 시간 초과 트러블슈팅</p>
</blockquote>
<h2 id="통계-기능">통계 기능</h2>
<table>
<thead>
<tr>
<th>기능</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>카테고리 통계</td>
<td>카테고리별 월 환산 지출 도넛 차트 + 범례</td>
</tr>
<tr>
<td>결제 캘린더</td>
<td>월 이동, 날짜별 결제 점(구독 색), 날짜를 누르면 그날 결제 목록</td>
</tr>
<tr>
<td>비싼 구독 순위</td>
<td>월 환산 상위 5개</td>
</tr>
<tr>
<td>연간 결제 예고</td>
<td>30일 안에 갱신되는 연간 결제를 홈에서 강조</td>
</tr>
<tr>
<td>정렬·필터</td>
<td>결제일순·금액순·이름순 + 카테고리 필터</td>
</tr>
</tbody></table>
<p>탭은 홈 / 구독 / <strong>통계</strong> / 설정 4개가 됐고, 홈에 있던 카테고리 차트는 통계 탭으로 옮겼습니다.</p>
<h2 id="계산은-domain에-결과는-인덱스로">계산은 Domain에, 결과는 인덱스로</h2>
<p>계산은 모두 <code>InsightCalculator</code>(Domain)에 넣었습니다. 결과를 SwiftData 모델 대신 <strong>입력 배열의 인덱스</strong>로 돌려줘서, Domain이 UI나 DB에 의존하지 않고 테스트할 수 있게 했습니다.</p>
<pre><code class="language-swift">struct ScheduledPayment: Hashable {
    let planIndex: Int   // 입력 배열의 인덱스
    let date: Date
}

struct InsightCalculator {
    var billing: BillingCalculator
    var cost: CostCalculator

    func payments(in month: Date, of plans: [BillingPlan]) -&gt; [ScheduledPayment]
    func ranking(of plans: [BillingPlan], limit: Int?) -&gt; [RankedPlan]
    func upcomingRenewals(of plans: [BillingPlan], within days: Int = 30) -&gt; [ScheduledPayment]
    func categoryTotals(of items: [(category: String, plan: BillingPlan)]) -&gt; [CategoryTotal]
}</code></pre>
<p>뷰에서는 <code>subscriptions[payment.planIndex]</code>로 원래 모델을 찾습니다.</p>
<p>규칙:</p>
<ul>
<li>체험 중인 구독은 순위·카테고리 합계에서 제외</li>
<li>캘린더·연간 예고에는 체험 종료 이후의 결제만 표시</li>
<li>&quot;연간 결제&quot; = 연결제, 또는 12개월 이상 간격의 월결제</li>
</ul>
<h2 id="결제-캘린더-직접-그리기">결제 캘린더 직접 그리기</h2>
<p><code>LazyVGrid</code> 7열로 달력을 그렸습니다. 첫째 주 앞의 빈칸은 기기의 주 시작 요일(<code>calendar.firstWeekday</code>)에 맞춰 계산합니다.</p>
<pre><code class="language-swift">private var gridDays: [Date?] {
    guard let interval = calendar.dateInterval(of: .month, for: month),
          let dayCount = calendar.range(of: .day, in: .month, for: month)?.count
    else { return [] }
    let firstWeekday = calendar.component(.weekday, from: interval.start)
    let leading = (firstWeekday - calendar.firstWeekday + 7) % 7
    let days = (0..&lt;dayCount).compactMap { calendar.date(byAdding: .day, value: $0, to: interval.start) }
    return Array(repeating: nil, count: leading) + days.map { Optional($0) }
}</code></pre>
<ul>
<li>날짜 칸마다 결제가 있는 구독의 색 점을 최대 3개까지 표시</li>
<li>위쪽에 그 달의 결제 건수와 총액</li>
<li>VoiceOver: &quot;10월 15일, 결제 2건&quot;</li>
</ul>
<h2 id="차트와-범례-색-맞추기">차트와 범례 색 맞추기</h2>
<p>Swift Charts의 기본 색과 직접 만든 범례 목록의 색이 어긋났습니다. <code>chartForegroundStyleScale</code>로 순서대로 색을 지정해서 맞췄습니다.</p>
<pre><code class="language-swift">Chart(categoryTotals) { item in
    SectorMark(angle: .value(&quot;금액&quot;, item.monthlyCost.doubleValue),
               innerRadius: .ratio(0.6), angularInset: 1.5)
        .foregroundStyle(by: .value(&quot;카테고리&quot;, item.category))
}
.chartForegroundStyleScale(domain: categoryTotals.map(\.category),
                           range: categoryTotals.indices.map(chartColor))</code></pre>
<h2 id="정렬·필터">정렬·필터</h2>
<ul>
<li>툴바 메뉴에서 정렬(결제일순/금액순/이름순)과 카테고리 필터 선택</li>
<li>정렬 방식은 <code>@AppStorage</code>로 기억</li>
<li>필터가 켜져 있으면 목록 위에 &quot;○○ 필터 해제&quot; 버튼</li>
<li>금액순은 월 환산 기준(달러는 원화로 환산)</li>
</ul>
<h2 id="트러블슈팅-type-check-시간-초과">트러블슈팅: type-check 시간 초과</h2>
<p>MVP 2 코드를 올렸더니 CI에서 빌드가 실패했습니다. 테스트가 아니라 <strong>컴파일 단계</strong>였습니다.</p>
<pre><code>InsightCalculator.swift:30:9: error: the compiler is unable to type-check
this expression in reasonable time; try breaking up the expression into
distinct sub-expressions</code></pre><p>원인은 이런 긴 체인이었습니다.</p>
<pre><code class="language-swift">// ❌ 이전
plans.enumerated()
    .flatMap { index, plan in
        billing.paymentDates(in: month, plan).map { ScheduledPayment(planIndex: index, date: $0) }
    }
    .sorted { $0.date == $1.date ? $0.planIndex &lt; $1.planIndex : $0.date &lt; $1.date }</code></pre>
<p>클로저의 타입을 명시하지 않은 상태에서 <code>flatMap</code> + <code>map</code> + <code>sorted</code> + 삼항연산자 + 비교 연산자가 겹치면, 컴파일러가 가능한 타입 조합을 너무 많이 시도합니다.</p>
<pre><code class="language-swift">// ✅ 이후: 반복문 + 명시적 타입 + if
var result: [ScheduledPayment] = []
for (index, plan) in plans.enumerated() {
    for date in billing.paymentDates(in: month, plan) {
        result.append(ScheduledPayment(planIndex: index, date: date))
    }
}
result.sort { (lhs: ScheduledPayment, rhs: ScheduledPayment) -&gt; Bool in
    if lhs.date != rhs.date { return lhs.date &lt; rhs.date }
    return lhs.planIndex &lt; rhs.planIndex
}
return result</code></pre>
<p>동작은 같고, 테스트도 그대로 통과합니다. 같은 이유로 막힐 수 있는 다른 정렬 비교(목록의 금액순 정렬 등)도 미리 같은 방식으로 바꿨습니다.</p>
<p><strong>배운 점</strong></p>
<ul>
<li>로컬에서 괜찮던 표현식도 CI 머신에서는 시간 제한에 걸릴 수 있다</li>
<li>체인이 길어지고 클로저 안에 삼항연산자·비교·<code>Decimal</code> 연산이 섞이면 일찍 반복문으로 풀어 쓴다</li>
<li>정렬 클로저는 파라미터 타입과 반환 타입을 명시하고, 삼항연산자 대신 <code>if</code>를 쓴다</li>
</ul>
<h2 id="다음-글">다음 글</h2>
<p>로그인을 &quot;선택 기능&quot;으로 두고 Supabase에 백업·동기화를 붙인 과정을 다룹니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# [Subhelp 개발기 #4] 무료체험 규칙, "10초 등록", 온보딩과 접근성]]></title>
            <link>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-4-%EB%AC%B4%EB%A3%8C%EC%B2%B4%ED%97%98-%EA%B7%9C%EC%B9%99-10%EC%B4%88-%EB%93%B1%EB%A1%9D-%EC%98%A8%EB%B3%B4%EB%94%A9%EA%B3%BC-%EC%A0%91%EA%B7%BC%EC%84%B1</link>
            <guid>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-4-%EB%AC%B4%EB%A3%8C%EC%B2%B4%ED%97%98-%EA%B7%9C%EC%B9%99-10%EC%B4%88-%EB%93%B1%EB%A1%9D-%EC%98%A8%EB%B3%B4%EB%94%A9%EA%B3%BC-%EC%A0%91%EA%B7%BC%EC%84%B1</guid>
            <pubDate>Tue, 13 Oct 2026 04:17:58 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>시리즈: Subhelp — iOS 구독 관리 앱 개발기
이번 글: 기획서 반영(MVP 1), 무료체험 규칙을 Domain에 넣기, 폼 구조, 상세 화면, 온보딩, 접근성</p>
</blockquote>
<h2 id="기획서와-구현이-부딪히는-부분부터-정리">기획서와 구현이 부딪히는 부분부터 정리</h2>
<p>기본 기능을 만든 뒤 기획서를 받아 비교해 보니, 이미 만든 동작을 바꿔야 하는 부분이 있었습니다.</p>
<table>
<thead>
<tr>
<th>현재 구현</th>
<th>기획서</th>
</tr>
</thead>
<tbody><tr>
<td>프로젝트 이름 SubTracker</td>
<td>Subhelp</td>
</tr>
<tr>
<td>체험 중에도 합계에 포함, 종료 1일 전 알림</td>
<td>체험 종료 전까지 합계 제외, 3일·1일 전 알림</td>
</tr>
<tr>
<td>목록 → 바로 편집</td>
<td>목록 → 상세 → 편집, 선택 항목은 &quot;더보기&quot;</td>
</tr>
<tr>
<td>카테고리 OTT/음악/쇼핑…</td>
<td>영상/음악/쇼핑·멤버십/생산성·업무/AI/…</td>
</tr>
<tr>
<td>임박 D-day 빨강</td>
<td>오늘·내일 오렌지, 7일 이내 보조색, 그 외 회색</td>
</tr>
</tbody></table>
<p>출시 전이라 저장된 사용자 데이터가 없어서, 이름 변경이나 설정 키 변경에 데이터 마이그레이션이 필요 없었습니다. 폴더·타깃·번들 ID·표시 이름을 모두 <code>Subhelp</code>로 바꿨습니다.</p>
<h2 id="무료체험-규칙을-domain에-넣기">무료체험 규칙을 Domain에 넣기</h2>
<p>기획서의 규칙:</p>
<ul>
<li>체험 중 = 오늘이 체험 종료일 <strong>이전</strong>인 상태 (종료일 당일부터 유료)</li>
<li>체험 기간 안의 결제일은 결제로 치지 않는다</li>
<li>체험 중 구독은 결제 예정액·월 환산에서 제외, 종료일 이후 결제부터 포함</li>
</ul>
<pre><code class="language-swift">extension BillingCalculator {
    func isInTrial(_ plan: BillingPlan, on reference: Date = .now) -&gt; Bool {
        guard let trialEnd = plan.trialEndDate else { return false }
        return calendar.startOfDay(for: reference) &lt; calendar.startOfDay(for: trialEnd)
    }

    /// 다음 &quot;유료&quot; 결제일 — 체험 종료 전 결제일은 건너뛴다
    func nextPaymentDate(_ plan: BillingPlan, from reference: Date = .now) -&gt; Date {
        var from = calendar.startOfDay(for: reference)
        if let trialEnd = plan.trialEndDate {
            from = max(from, calendar.startOfDay(for: trialEnd))
        }
        return nextBillingDate(start: plan.startDate, cycle: plan.cycle,
                               interval: plan.interval, from: from)
    }

    private func isPaid(_ date: Date, _ plan: BillingPlan) -&gt; Bool {
        guard let trialEnd = plan.trialEndDate else { return true }
        return date &gt;= calendar.startOfDay(for: trialEnd)
    }
}</code></pre>
<p>이 규칙 하나로 <strong>홈 합계, 알림, 위젯, 통계가 모두 같은 기준</strong>으로 동작합니다. 화면마다 따로 조건을 넣었다면 어딘가에서 어긋났을 겁니다.</p>
<p>새로 추가할 때 무료체험을 켜면 첫 결제일을 체험 종료일로 맞춰줍니다. 보통 체험이 끝나는 날 첫 결제가 되기 때문입니다.</p>
<h3 id="테스트">테스트</h3>
<pre><code class="language-swift">@Test(&quot;체험 중 구독은 결제 예정액·월 환산에서 빠지고, 이번 달 체험 종료 후 결제는 포함&quot;)
func trialInSummary() {
    let plans = [
        plan(price: 10_000, start: date(2026, 1, 15)),                                   // 일반: 10/15 결제
        plan(price: 5_000, start: date(2026, 10, 25), trialEnd: date(2026, 10, 25)),     // 이번 달 체험 종료
        plan(price: 7_000, start: date(2026, 9, 1), trialEnd: date(2026, 11, 10)),       // 다음 달 체험 종료
    ]
    let summary = sut.summary(of: plans, reference: date(2026, 10, 13))
    #expect(summary.monthTotal == 15_000)
    #expect(summary.monthlyAverage == 10_000)   // 체험 중 구독 제외
}</code></pre>
<h2 id="10초-등록을-위한-폼-구조">&quot;10초 등록&quot;을 위한 폼 구조</h2>
<h3 id="1단계-프리셋-선택">1단계: 프리셋 선택</h3>
<ul>
<li>검색창 + 카테고리별 프리셋 그리드, 하단 &quot;직접 입력하기&quot;</li>
<li>프리셋은 <strong>이름·카테고리·색상만</strong> 채운다<ul>
<li><strong>브랜드 로고는 쓰지 않는다</strong> — 상표 문제로 심사 리젝·분쟁 가능 → 이니셜 + 색상 원형 아이콘</li>
<li><strong>가격은 넣지 않는다</strong> — 요금이 자주 바뀌고 요금제마다 다름 → 사용자 입력</li>
</ul>
</li>
</ul>
<h3 id="2단계-입력-폼">2단계: 입력 폼</h3>
<ul>
<li>필수 항목(이름, 금액, 통화, 주기·간격, 첫 결제일, 카테고리)은 위에</li>
<li>선택 항목(색상, 알림, 무료체험, 메모)은 <code>DisclosureGroup</code>으로 &quot;더보기&quot;에 접기</li>
<li>이름과 금액이 있어야 저장 버튼 활성화</li>
<li>금액 규칙: 0원·음수 불가, 최대 9자리 (Domain의 <code>PriceRule</code>로 분리해 테스트)</li>
</ul>
<p>폼 상태는 저장 전까지 <code>@Model</code>을 건드리지 않도록 별도의 <code>@Observable</code> 객체(<code>SubscriptionDraft</code>)에 담았습니다. 취소하면 그냥 버리면 됩니다.</p>
<pre><code class="language-swift">@Observable
final class SubscriptionDraft {
    var name = &quot;&quot;
    var price: Decimal = 0
    var cycle: BillingCycle = .monthly
    // ...
    var isValid: Bool { !trimmedName.isEmpty &amp;&amp; PriceRule.validate(price) == nil }

    func apply(to sub: Subscription) { ... }
}</code></pre>
<h2 id="상세-화면">상세 화면</h2>
<ul>
<li>다음 결제일 + D-day, 체험 종료일, 결제 주기, 첫 결제일</li>
<li>월 환산 금액 (달러면 원화 환산 <code>≈</code> 표시)</li>
<li><strong>지금까지 낸 총액</strong>: 첫 결제일부터 오늘까지 결제 횟수 × 현재 금액. 요금이 중간에 바뀌었을 수 있어 <strong>추정치</strong>라고 표시</li>
<li>편집, 일시중지/재개, 삭제(확인 알럿)</li>
</ul>
<p>일시중지는 &quot;해지했지만 기록은 남기고 싶을 때&quot; 씁니다. 모든 합계와 알림에서 빠지고, 목록에서는 흐리게 표시됩니다.</p>
<h2 id="온보딩">온보딩</h2>
<ul>
<li>2장: 가치 소개(&quot;매달 나가는 구독, 얼마인지 알고 계세요?&quot;) → &quot;첫 구독을 추가해볼까요?&quot;</li>
<li>마지막 장에서 바로 구독 추가 시트로 연결</li>
<li>알림 권한은 여기서 묻지 않음 (#3 참고)</li>
</ul>
<h2 id="리뷰-요청">리뷰 요청</h2>
<p>구독을 3개 이상 등록하고 <strong>앱을 다시 실행했을 때</strong> 한 번만 <code>requestReview</code>를 호출합니다. 앱을 충분히 써본 사람에게만 묻기 위해서입니다.</p>
<h2 id="접근성과-ux-라이팅">접근성과 UX 라이팅</h2>
<ul>
<li><strong>D-day 배지는 색과 텍스트를 함께</strong> 쓴다 (색만으로 정보를 구분하지 않음)<ul>
<li>오늘·내일 오렌지 / 7일 이내 블루 / 그 외 회색</li>
</ul>
</li>
<li><strong>VoiceOver 라벨</strong>: &quot;넷플릭스, 17,000원, 매월, 2일 후 결제&quot;</li>
<li>금액은 <strong>고정폭 숫자</strong>(<code>monospacedDigit</code>), 큰 글씨에서 잘리지 않게 <code>ViewThatFits</code>·<code>minimumScaleFactor</code></li>
<li>다크 모드 대응</li>
<li>문구는 해요체, 숫자는 구체적으로(&quot;곧 결제돼요&quot; 대신 &quot;2일 후 결제돼요&quot;), 비난하지 않기</li>
<li>문자열은 String Catalog(<code>Localizable.xcstrings</code>)로 관리해 다국어 확장에 대비</li>
</ul>
<h2 id="다음-글">다음 글</h2>
<p>통계 탭(결제 캘린더, 비싼 구독 순위)과, CI에서 만난 &quot;컴파일러가 타입을 못 정해요&quot; 에러를 다룹니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# [Subhelp 개발기 #3] 알림 64개 한도, 위젯과 데이터 공유, 딥링크]]></title>
            <link>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-3-%EC%95%8C%EB%A6%BC-64%EA%B0%9C-%ED%95%9C%EB%8F%84-%EC%9C%84%EC%A0%AF%EA%B3%BC-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B3%B5%EC%9C%A0-%EB%94%A5%EB%A7%81%ED%81%AC</link>
            <guid>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-3-%EC%95%8C%EB%A6%BC-64%EA%B0%9C-%ED%95%9C%EB%8F%84-%EC%9C%84%EC%A0%AF%EA%B3%BC-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B3%B5%EC%9C%A0-%EB%94%A5%EB%A7%81%ED%81%AC</guid>
            <pubDate>Tue, 13 Oct 2026 04:17:43 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>시리즈: Subhelp — iOS 구독 관리 앱 개발기
이번 글: 로컬 알림 전략, 알림 권한 타이밍, App Group으로 위젯과 데이터 공유, 딥링크</p>
</blockquote>
<h2 id="로컬-알림-전략">로컬 알림 전략</h2>
<p>iOS는 앱 하나당 대기 중인 로컬 알림을 <strong>최대 64개</strong>까지만 허용합니다. 구독이 많으면 금방 넘칩니다.</p>
<ul>
<li>구독마다 <strong>다음 결제 2회분만</strong> 예약</li>
<li>앱 실행, 포그라운드 복귀, 구독 변경 때 <strong>전부 지우고 다시 예약</strong><ul>
<li>단순해서 버그가 적고, 시간대가 바뀌거나 지난 알림이 소진돼도 다음 실행 때 바로잡힌다</li>
</ul>
</li>
<li>64개를 넘으면 <strong>가까운 알림부터</strong> 남긴다</li>
</ul>
<pre><code class="language-swift">func rescheduleAll(_ subscriptions: [Subscription], now: Date = .now) async {
    center.removeAllPendingNotificationRequests()

    let status = await authorizationStatus()
    guard status == .authorized || status == .provisional else { return }

    var pending: [(fireDate: Date, request: UNNotificationRequest)] = []
    for sub in subscriptions where sub.isActive {
        pending += billingRequests(for: sub, hour: hour, now: now)
        pending += trialRequests(for: sub, hour: hour, now: now)
    }

    for item in pending.sorted(by: { $0.fireDate &lt; $1.fireDate }).prefix(64) {
        try? await center.add(item.request)
    }
}</code></pre>
<p>알림 시각은 &quot;결제일 − 설정 일수&quot;의 설정 시각(기본 오전 9시)입니다. 구독별로 당일·1일 전·3일 전·7일 전·끔 중에서 고르고, 알림 시각은 설정에서 바꿀 수 있습니다.</p>
<p>문구는 구체적인 숫자와 해요체로 맞췄습니다.</p>
<pre><code>넷플릭스 결제가 내일이에요 · ₩17,000
티빙 무료체험이 3일 후 끝나요. 계속 쓸지 결정해보세요</code></pre><h2 id="알림-권한은-언제-물을까">알림 권한은 언제 물을까</h2>
<p>첫 실행에 바로 권한을 물으면 맥락이 없어 거절률이 높습니다.</p>
<ul>
<li>온보딩에서는 묻지 않는다</li>
<li><strong>첫 구독을 저장한 직후</strong>, 안내 문구(&quot;결제일을 놓치지 않도록 미리 알려드릴게요&quot;)를 먼저 보여주고 나서 시스템 권한 창을 띄운다</li>
<li>거부된 상태면 홈 상단에 배너를 띄우고, iOS 설정으로 이동하는 버튼을 둔다</li>
</ul>
<p>구독 추가 시트가 닫히는 순간에 알럿을 띄우면 두 화면이 겹쳐 표시되지 않을 수 있습니다. 그래서 &quot;권한 안내가 필요하다&quot;는 표시만 해 두고, <strong>시트의 <code>onDismiss</code>에서</strong> 알럿을 띄웠습니다.</p>
<pre><code class="language-swift">.sheet(isPresented: $router.isAddingSubscription, onDismiss: {
    Task { await router.offerNotificationPermissionIfNeeded() }
}) { AddSubscriptionSheet() }</code></pre>
<h2 id="위젯과-데이터-공유-app-group">위젯과 데이터 공유: App Group</h2>
<p>위젯은 앱과 <strong>별도 프로세스</strong>로 돌아갑니다. 같은 데이터를 보려면 저장소를 App Group 컨테이너에 둬야 합니다.</p>
<pre><code class="language-swift">enum SharedModelContainer {
    static let shared: ModelContainer = {
        let schema = Schema([Subscription.self])
        if let groupID = AppConfig.appGroupID {
            let config = ModelConfiguration(schema: schema, groupContainer: .identifier(groupID))
            if let container = try? ModelContainer(for: schema, configurations: [config]) {
                return container
            }
        }
        // App Group 미설정(개발 초기) → 앱 전용 저장소로 동작
        return try! ModelContainer(for: schema, configurations: [ModelConfiguration(schema: schema)])
    }()
}</code></pre>
<p>환율 같은 설정도 위젯이 써야 해서 App Group <code>UserDefaults</code>에 저장했습니다.</p>
<h3 id="흔한-함정-save-없이-위젯-갱신">흔한 함정: save() 없이 위젯 갱신</h3>
<p>위젯은 <strong>디스크에서</strong> 데이터를 읽습니다. 앱에서 데이터를 바꾼 뒤 저장하지 않고 위젯 갱신을 요청하면, 위젯은 이전 데이터를 보여줍니다. 저장 → 알림 재예약 → 위젯 갱신을 한 함수로 묶었습니다.</p>
<pre><code class="language-swift">static func commit(_ context: ModelContext) async {
    try? context.save()                       // 1. 디스크에 먼저 저장
    await NotificationService.shared.rescheduleAll(...)  // 2. 알림 재예약
    WidgetCenter.shared.reloadAllTimelines()  // 3. 위젯 갱신
}</code></pre>
<h3 id="위젯-구성">위젯 구성</h3>
<table>
<thead>
<tr>
<th>종류</th>
<th>표시 내용</th>
</tr>
</thead>
<tbody><tr>
<td>홈 소형</td>
<td>&quot;10월 결제 예정&quot; + 금액 + &quot;남은 3건&quot;</td>
</tr>
<tr>
<td>홈 중형</td>
<td>소형 정보 + 다가오는 결제 3건 (이름·D-day·금액)</td>
</tr>
<tr>
<td>잠금화면 직사각형</td>
<td>다음 결제 1건: 넷플릭스 · D-2 · ₩17,000</td>
</tr>
</tbody></table>
<p>D-day가 바뀌는 시점에 맞춰 타임라인은 <strong>매일 자정</strong>에 갱신됩니다.</p>
<pre><code class="language-swift">let midnight = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: .now))!
completion(Timeline(entries: [makeEntry()], policy: .after(midnight)))</code></pre>
<h3 id="위젯-privacy-manifest">위젯 Privacy Manifest</h3>
<p>App Group <code>UserDefaults</code>를 쓰므로 앱과 위젯 <strong>둘 다</strong> <code>PrivacyInfo.xcprivacy</code>에 UserDefaults 사용 사유를 선언했습니다.</p>
<ul>
<li><code>CA92.1</code>: 앱 자체에서 쓰는 값</li>
<li><code>1C8F.1</code>: 같은 App Group의 앱·익스텐션이 공유하는 값</li>
</ul>
<h2 id="딥링크">딥링크</h2>
<p><code>subhelp://</code> URL 스킴을 만들었습니다.</p>
<pre><code class="language-swift">enum DeepLink {
    static let home = URL(string: &quot;subhelp://home&quot;)!
    static func subscription(_ id: UUID) -&gt; URL {
        URL(string: &quot;subhelp://subscription/\(id.uuidString)&quot;)!
    }
}</code></pre>
<ul>
<li><strong>위젯 탭</strong> → <code>widgetURL</code> / <code>Link</code>로 해당 구독 상세 또는 홈</li>
<li><strong>알림 탭</strong> → 알림 <code>userInfo</code>에 구독 ID를 넣고, <code>UNUserNotificationCenterDelegate</code>에서 같은 라우터로 상세 화면 이동</li>
<li>앱 사용 중에 온 알림도 배너로 보이도록 <code>willPresent</code>에서 <code>.banner</code>를 돌려줌</li>
</ul>
<p>탭 선택·네비게이션 경로·시트 상태를 한곳(<code>AppRouter</code>, <code>@Observable</code>)에서 관리해서, 어디서 들어오든 같은 방식으로 화면을 이동합니다.</p>
<pre><code class="language-swift">func open(subscriptionID: UUID) {
    isAddingSubscription = false
    selectedTab = .subscriptions
    subscriptionsPath = [subscriptionID]
}</code></pre>
<h2 id="다음-글">다음 글</h2>
<p>기획서를 반영하면서 들어간 무료체험 규칙과 &quot;10초 등록&quot; 폼 구조를 다룹니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[# [Subhelp 개발기 #2] 결제일 계산: "1월 31일 결제"가 3월 28일로 밀리는 버그]]></title>
            <link>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-2-%EA%B2%B0%EC%A0%9C%EC%9D%BC-%EA%B3%84%EC%82%B0-1%EC%9B%94-31%EC%9D%BC-%EA%B2%B0%EC%A0%9C%EA%B0%80-3%EC%9B%94-28%EC%9D%BC%EB%A1%9C-%EB%B0%80%EB%A6%AC%EB%8A%94-%EB%B2%84%EA%B7%B8</link>
            <guid>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-2-%EA%B2%B0%EC%A0%9C%EC%9D%BC-%EA%B3%84%EC%82%B0-1%EC%9B%94-31%EC%9D%BC-%EA%B2%B0%EC%A0%9C%EA%B0%80-3%EC%9B%94-28%EC%9D%BC%EB%A1%9C-%EB%B0%80%EB%A6%AC%EB%8A%94-%EB%B2%84%EA%B7%B8</guid>
            <pubDate>Tue, 13 Oct 2026 04:17:19 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>시리즈: Subhelp — iOS 구독 관리 앱 개발기
이번 글: 결제일·금액 계산 로직, 단위 테스트, CI에서 테스트 돌리기</p>
</blockquote>
<h2 id="문제-월말-날짜-밀림">문제: 월말 날짜 밀림</h2>
<p>1월 31일에 시작한 월결제를 &quot;<strong>이전 결제일 + 1개월</strong>&quot;로 반복 계산하면 날짜가 밀립니다.</p>
<pre><code>1/31 → 2/28 → 3/28 → 4/28 ...  ❌</code></pre><p>2월에 28일로 한 번 잘리면, 그다음부터는 28일을 기준으로 계산되기 때문입니다.</p>
<h2 id="해결-항상-최초-결제일--n번째-주기">해결: 항상 &quot;최초 결제일 + n번째 주기&quot;</h2>
<pre><code class="language-swift">func occurrence(_ n: Int, start: Date, cycle: BillingCycle, interval: Int) -&gt; Date? {
    calendar.date(byAdding: cycle.component,
                  value: max(1, interval) * n,
                  to: calendar.startOfDay(for: start))
}</code></pre>
<pre><code>1/31 + 1개월 = 2/28
1/31 + 2개월 = 3/31  ✅
1/31 + 3개월 = 4/30</code></pre><p>존재하지 않는 날짜(31일, 2/29)는 그 달의 마지막 날로 처리되고, 다음 달에는 다시 원래 날짜로 돌아옵니다.</p>
<h3 id="주기-표현">주기 표현</h3>
<pre><code class="language-swift">enum BillingCycle: String, Codable, CaseIterable {
    case weekly, monthly, yearly

    var component: Calendar.Component {
        switch self {
        case .weekly:  .weekOfYear
        case .monthly: .month
        case .yearly:  .year
        }
    }
}</code></pre>
<p>&quot;3개월마다&quot;는 <code>monthly</code> + <code>interval = 3</code>으로 표현합니다.</p>
<h2 id="다음-결제일">다음 결제일</h2>
<p>오늘(포함) 이후 첫 결제일을 구합니다. 시각은 무시하고 <strong>날짜 단위</strong>로 비교합니다.</p>
<pre><code class="language-swift">func nextBillingDate(start: Date, cycle: BillingCycle, interval: Int,
                     from reference: Date = .now) -&gt; Date {
    let first = calendar.startOfDay(for: start)
    let today = calendar.startOfDay(for: reference)
    if first &gt;= today { return first }   // 첫 결제일이 미래면 그날

    var n = skipCount(start: first, target: today, cycle: cycle, interval: interval)
    while n &lt; maxOccurrences,
          let date = occurrence(n, start: first, cycle: cycle, interval: interval) {
        if date &gt;= today { return date }
        n += 1
    }
    return first
}</code></pre>
<h2 id="최적화-시작일이-10년-전이어도-빠르게">최적화: 시작일이 10년 전이어도 빠르게</h2>
<p>n을 0부터 하나씩 올리면, 오래된 주결제 구독은 수백 번 반복합니다. 그래서 <strong>경과한 주기 수만큼 먼저 건너뜁니다</strong>. 월말 보정 때문에 한 주기 정도 오차가 날 수 있어 여유를 하나 둡니다.</p>
<pre><code class="language-swift">private func skipCount(start: Date, target: Date, cycle: BillingCycle, interval: Int) -&gt; Int {
    guard target &gt; start else { return 0 }
    let elapsed = calendar.dateComponents([cycle.component], from: start, to: target)
        .value(for: cycle.component) ?? 0
    return max(0, elapsed / max(1, interval) - 1)   // 한 주기 여유
}</code></pre>
<p>비정상 입력으로 무한 루프가 돌지 않도록 반복 상한(<code>maxOccurrences</code>)도 뒀습니다.</p>
<h2 id="이번-달-결제일들">이번 달 결제일들</h2>
<p>&quot;이번 달 실제 지출&quot;을 구하려면 한 달 안에 있는 결제일을 모두 알아야 합니다. 주결제는 한 달에 4~5번입니다.</p>
<pre><code class="language-swift">func billingDates(in month: Date, start: Date, cycle: BillingCycle, interval: Int) -&gt; [Date] {
    guard let range = calendar.dateInterval(of: .month, for: month) else { return [] }
    // range.start ≤ 결제일 &lt; range.end 인 회차를 모두 모은다
}</code></pre>
<h2 id="금액-계산-반드시-decimal">금액 계산: 반드시 Decimal</h2>
<p>Double로 계산하면 부동소수 오차가 생깁니다. 금액은 전부 <code>Decimal</code>로 다룹니다.</p>
<pre><code class="language-swift">struct CostCalculator {
    var usdToKrw: Decimal

    func toKRW(_ price: Decimal, currency: Currency) -&gt; Decimal {
        currency == .usd ? price * usdToKrw : price
    }

    /// 연 환산 (주결제는 1년 = 52주)
    func yearlyCost(price: Decimal, currency: Currency, cycle: BillingCycle, interval: Int) -&gt; Decimal {
        let krw = toKRW(price, currency: currency)
        let interval = Decimal(max(1, interval))
        switch cycle {
        case .weekly:  return krw * 52 / interval
        case .monthly: return krw * 12 / interval
        case .yearly:  return krw / interval
        }
    }

    /// 월 환산 = 연 환산 / 12
    func monthlyCost(...) -&gt; Decimal { yearlyCost(...) / 12 }
}</code></pre>
<p>주결제의 월 환산을 <code>가격 × 52 ÷ 12</code>로 바로 계산하면 나누어떨어지지 않는 소수가 생기기 쉽습니다. 그래서 연 환산을 먼저 구하고 12로 나눴습니다.</p>
<p>환율은 사용자가 입력합니다(실시간 환율은 MVP 범위 밖). 입력값(Double)을 Decimal로 바꿀 때도 이진 부동소수 잡음(<code>1380.5000000001</code>)을 피하려고 문자열을 거칩니다.</p>
<pre><code class="language-swift">static func decimal(_ value: Double) -&gt; Decimal {
    Decimal(string: String(value)) ?? Decimal(value)
}</code></pre>
<h2 id="이번-달-결제-예정과-월-환산은-다른-숫자">&quot;이번 달 결제 예정&quot;과 &quot;월 환산&quot;은 다른 숫자</h2>
<table>
<thead>
<tr>
<th>지표</th>
<th>정의</th>
</tr>
</thead>
<tbody><tr>
<td>이번 달 결제 예정</td>
<td>이번 달 1일~말일에 실제로 빠져나가는 금액 (연결제가 있는 달은 튐)</td>
</tr>
<tr>
<td>월 환산</td>
<td>모든 구독을 한 달 평균으로 환산한 금액</td>
</tr>
<tr>
<td>연 환산</td>
<td>월 환산 × 12</td>
</tr>
</tbody></table>
<p>두 숫자가 왜 다른지 화면에 짧은 설명을 붙였습니다. 달러가 섞여 있으면 금액 앞에 <code>≈</code>를 붙여 추정치임을 표시합니다.</p>
<h2 id="테스트-calendar를-주입받는-이유">테스트: Calendar를 주입받는 이유</h2>
<p><code>BillingCalculator</code>는 <code>Calendar</code>를 주입받습니다. 테스트에서는 타임존을 서울로 고정해서, 실행 환경과 상관없이 같은 결과가 나오게 합니다.</p>
<pre><code class="language-swift">struct BillingCalculatorTests {
    let cal: Calendar = {
        var c = Calendar(identifier: .gregorian)
        c.timeZone = TimeZone(identifier: &quot;Asia/Seoul&quot;)!
        return c
    }()

    var sut: BillingCalculator { BillingCalculator(calendar: cal) }

    @Test(&quot;월말(31일) 시작은 2월엔 말일, 3월엔 다시 31일 — 날짜가 밀리지 않는다&quot;)
    func monthEndDoesNotDrift() {
        let start = date(2026, 1, 31)
        #expect(sut.nextBillingDate(start: start, cycle: .monthly, interval: 1, from: date(2026, 2, 1)) == date(2026, 2, 28))
        #expect(sut.nextBillingDate(start: start, cycle: .monthly, interval: 1, from: date(2026, 3, 1)) == date(2026, 3, 31))
        #expect(sut.nextBillingDate(start: start, cycle: .monthly, interval: 1, from: date(2026, 4, 1)) == date(2026, 4, 30))
    }
}</code></pre>
<p>테스트한 케이스:</p>
<ul>
<li><input checked="" disabled="" type="checkbox"> 월말(31일) 시작 → 2월은 28/29일, 3월은 다시 31일</li>
<li><input checked="" disabled="" type="checkbox"> 윤년 2/29 시작 연결제 → 평년엔 2/28, 윤년엔 2/29</li>
<li><input checked="" disabled="" type="checkbox"> 오늘이 결제일 (시각과 무관하게 오늘)</li>
<li><input checked="" disabled="" type="checkbox"> 시작일이 미래</li>
<li><input checked="" disabled="" type="checkbox"> 3개월 간격, 격주</li>
<li><input checked="" disabled="" type="checkbox"> 시작일이 10년 전</li>
<li><input checked="" disabled="" type="checkbox"> 이번 달 주결제 횟수 (4~5회)</li>
<li><input checked="" disabled="" type="checkbox"> 연결제는 결제 월에만 포함</li>
<li><input checked="" disabled="" type="checkbox"> 연결제·주결제·3개월 결제의 월 환산, USD 환산</li>
<li><input checked="" disabled="" type="checkbox"> 이번 달 실제 지출 vs 월 환산 평균</li>
</ul>
<blockquote>
<p><code>Decimal</code>의 부동소수 리터럴(<code>9.99</code>)은 Double을 거쳐 오차가 생깁니다. 테스트에서도 <code>Decimal(string: &quot;9.99&quot;)</code>처럼 문자열로 만들었습니다.</p>
</blockquote>
<h2 id="ci에서-테스트-돌리기">CI에서 테스트 돌리기</h2>
<p>테스트 타깃은 <strong>앱을 호스트로 띄우지 않고</strong> <code>Domain/</code> 소스를 직접 컴파일합니다.</p>
<pre><code class="language-yaml">SubhelpTests:
  type: bundle.unit-test
  sources:
    - path: SubhelpTests
    - path: Subhelp/Domain</code></pre>
<p>앱을 호스트로 띄우면 App Group 서명이 필요해서 CI에서 막힙니다. 이렇게 하면 서명 없이 GitHub Actions에서 바로 돌아갑니다.</p>
<h2 id="다음-글">다음 글</h2>
<p>로컬 알림의 64개 한도와 위젯 데이터 공유를 다룹니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Subhelp 개발기 #1] 구독 관리 앱, 서버 없이 시작하기로 한 이유]]></title>
            <link>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1-%EA%B5%AC%EB%8F%85-%EA%B4%80%EB%A6%AC-%EC%95%B1-%EC%84%9C%EB%B2%84-%EC%97%86%EC%9D%B4-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0%EB%A1%9C-%ED%95%9C-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@jiye_1124/Subhelp-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1-%EA%B5%AC%EB%8F%85-%EA%B4%80%EB%A6%AC-%EC%95%B1-%EC%84%9C%EB%B2%84-%EC%97%86%EC%9D%B4-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0%EB%A1%9C-%ED%95%9C-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Tue, 13 Oct 2026 04:17:01 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>시리즈: Subhelp — iOS 구독 관리 앱 개발기
이번 글: 문제 정의, 기술 스택, 아키텍처 결정, 프로젝트 셋업</p>
</blockquote>
<h2 id="만들려는-것">만들려는 것</h2>
<p><strong>Subhelp</strong>는 흩어진 구독 결제를 한곳에 모아, 언제·얼마가 빠져나가는지 미리 알려주는 iOS 앱입니다.</p>
<h3 id="해결하려는-문제">해결하려는 문제</h3>
<table>
<thead>
<tr>
<th>문제</th>
<th>상황 예시</th>
</tr>
</thead>
<tbody><tr>
<td>총액을 모름</td>
<td>하나하나는 소액이라 월 합계를 체감하지 못함</td>
</tr>
<tr>
<td>결제일을 모름</td>
<td>카드 문자를 받고 나서야 결제됐다는 걸 앎</td>
</tr>
<tr>
<td>해지 타이밍을 놓침</td>
<td>무료체험이 끝난 줄 모르고 유료로 넘어감</td>
</tr>
<tr>
<td>안 쓰는 구독 방치</td>
<td>몇 달째 쓰지 않는 서비스가 계속 결제됨</td>
</tr>
<tr>
<td>통화가 섞여 있음</td>
<td>달러 결제와 원화 결제가 섞여 합산이 어려움</td>
</tr>
</tbody></table>
<h3 id="해결-방향">해결 방향</h3>
<ol>
<li><strong>모으기</strong> — 모든 구독을 한 화면에 등록</li>
<li><strong>보여주기</strong> — 이번 달 결제 예정액과 다음 결제일을 바로 확인</li>
<li><strong>알려주기</strong> — 결제 전, 무료체험 종료 전에 알림</li>
<li><strong>돌아보기</strong> — 카테고리별 지출로 정리할 구독을 판단</li>
</ol>
<h3 id="제품-원칙">제품 원칙</h3>
<ul>
<li><strong>10초 등록</strong>: 구독 하나 등록에 10초를 넘기지 않는다</li>
<li><strong>열지 않아도 안다</strong>: 위젯과 알림만으로 핵심 정보를 전달한다</li>
<li><strong>내 데이터는 내 폰에</strong>: 계정·서버 없이 기기에 저장한다</li>
<li><strong>판단은 사용자 몫</strong>: 해지를 강요하지 않고 판단에 필요한 정보만 준다</li>
</ul>
<h3 id="mvp-단계">MVP 단계</h3>
<pre><code>MVP 0 Core     ─ 등록하고 보는 앱
MVP 1 Notify   ─ 먼저 알려주는 앱      ← 첫 출시 기준선
MVP 2 Insight  ─ 돌아보게 해주는 앱
Post-MVP       ─ 동기화·확장</code></pre><h2 id="기술-스택">기술 스택</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>최소 버전</td>
<td>iOS 17 (SwiftData, <code>@Observable</code>)</td>
</tr>
<tr>
<td>UI</td>
<td>SwiftUI</td>
</tr>
<tr>
<td>저장소</td>
<td>SwiftData (App Group 컨테이너로 위젯과 공유)</td>
</tr>
<tr>
<td>알림</td>
<td>UserNotifications (로컬 알림)</td>
</tr>
<tr>
<td>위젯</td>
<td>WidgetKit (홈 소형·중형, 잠금화면)</td>
</tr>
<tr>
<td>차트</td>
<td>Swift Charts</td>
</tr>
<tr>
<td>테스트</td>
<td>Swift Testing</td>
</tr>
<tr>
<td>프로젝트 관리</td>
<td>XcodeGen (<code>project.yml</code>)</td>
</tr>
<tr>
<td>CI</td>
<td>GitHub Actions (macOS 러너)</td>
</tr>
</tbody></table>
<h2 id="결정-1-서버-없이-로컬-우선">결정 1. 서버 없이 로컬 우선</h2>
<p>구독 데이터는 개인 기기 안에서 충분히 완결됩니다. 서버가 없으면:</p>
<ul>
<li>로그인 없이 바로 쓸 수 있어 진입 장벽이 없다</li>
<li>개인정보를 수집하지 않으니 App Store 개인정보 라벨이 &quot;데이터 수집 안 함&quot;</li>
<li>출시까지의 경로가 짧다</li>
</ul>
<p>나중에 동기화를 붙일 것에 대비해 SwiftData 모델의 <strong>모든 프로퍼티에 기본값</strong>을 넣었습니다. CloudKit 동기화의 필수 조건이기도 합니다.</p>
<pre><code class="language-swift">@Model
final class Subscription {
    var id: UUID = UUID()
    var name: String = &quot;&quot;
    var price: Decimal = 0
    var currencyCode: String = &quot;KRW&quot;
    var cycleRaw: String = BillingCycle.monthly.rawValue  // #Predicate에서 enum을 직접 쓰기 까다로워 raw로 저장
    var cycleInterval: Int = 1                            // 3개월마다 → monthly + 3
    var startDate: Date = Date.now
    var notifyDaysBefore: Int? = 1                        // nil = 알림 끔
    var isActive: Bool = true
    var trialEndDate: Date? = nil
    // ...
}</code></pre>
<p>다음 결제일·월 환산 금액은 <strong>저장하지 않고 매번 계산</strong>합니다. 저장하면 데이터가 어긋날 수 있기 때문입니다.</p>
<h2 id="결정-2-mvvm-대신-query--domain-분리">결정 2. MVVM 대신 <code>@Query</code> + Domain 분리</h2>
<p>SwiftData의 <code>@Query</code>는 뷰가 직접 데이터를 구독하도록 설계돼 있습니다. ViewModel로 감싸면 자동 갱신이라는 장점을 잃습니다. 그래서 역할을 이렇게 나눴습니다.</p>
<ul>
<li><strong>조회</strong> → 뷰에서 <code>@Query</code> 직접 사용</li>
<li><strong>계산 로직</strong>(다음 결제일, 월 환산) → <code>Domain/</code>의 순수 struct → 단위 테스트</li>
<li><strong>부수효과</strong>(알림 예약, 위젯 갱신) → <code>Services/</code></li>
<li><strong>복잡한 폼 상태</strong>만 <code>@Observable</code> 클래스로 분리</li>
</ul>
<p>Domain이 SwiftData에 의존하지 않도록, 계산에 필요한 정보만 담은 값 타입을 따로 뒀습니다.</p>
<pre><code class="language-swift">struct BillingPlan: Hashable {
    var price: Decimal
    var currency: Currency
    var cycle: BillingCycle
    var interval: Int
    var startDate: Date
    var trialEndDate: Date? = nil
}</code></pre>
<p>폴더 구조:</p>
<pre><code>Subhelp/
├── App/        앱 진입점, 라우터, 공유 ModelContainer, AppConfig
├── Domain/     순수 계산 로직 (테스트 대상)
├── Data/       @Model, 프리셋
├── Services/   알림, 저장·위젯 갱신, 서버 연동
├── Features/   Home / SubscriptionList / Detail / Editor / Stats / Settings / Onboarding
├── Shared/     공용 컴포넌트·포매터·설정 (위젯과 공유)
└── Resources/  Assets, Info.plist, entitlements, PrivacyInfo
SubhelpWidget/  위젯 익스텐션
SubhelpTests/   Domain 단위 테스트
Config/         빌드 변수 (xcconfig)</code></pre><h2 id="결정-3-xcodeproj-대신-xcodegen">결정 3. .xcodeproj 대신 XcodeGen</h2>
<p><code>.xcodeproj</code>는 사람이 읽기 어렵고 merge 충돌이 잦습니다. <code>project.yml</code> 한 파일로 앱·위젯·테스트 타깃을 선언하고, <code>.xcodeproj</code>는 <code>xcodegen generate</code>로 만들어서 git에 올리지 않습니다.</p>
<pre><code class="language-yaml">targets:
  Subhelp:
    type: application
    sources: [Subhelp]
    dependencies:
      - target: SubhelpWidget

  SubhelpWidget:
    type: app-extension
    sources:
      - path: SubhelpWidget
      # 위젯과 공유하는 코드를 명시
      - path: Subhelp/Domain
      - path: Subhelp/Data
      - path: Subhelp/Shared
      - path: Subhelp/App/ModelContainer+Shared.swift
      - path: Subhelp/App/AppConfig.swift</code></pre>
<p>위젯과 공유할 파일을 이렇게 명시하면, 위젯 개발에서 가장 흔한 빌드 에러인 <strong>Target Membership 체크 누락</strong>이 생기지 않습니다.</p>
<h2 id="결정-4-개인-값은-빌드-변수로-분리">결정 4. 개인 값은 빌드 변수로 분리</h2>
<p>팀 ID, 번들 ID, App Group 같은 값은 코드에 하드코딩하지 않습니다.</p>
<ul>
<li><code>Config/Base.xcconfig</code> (커밋됨): 빈 값·예시 값만</li>
<li><code>Config/Local.xcconfig</code> (<code>.gitignore</code>): 실제 값</li>
</ul>
<pre><code>// Base.xcconfig
DEVELOPMENT_TEAM =
BUNDLE_ID_PREFIX = com.yourname
APP_GROUP_ID = group.$(BUNDLE_ID_PREFIX).subhelp
SUPPORT_EMAIL =
PRIVACY_POLICY_URL =

#include? &quot;Local.xcconfig&quot;   // 있으면 위 값을 덮어씀</code></pre><p>값은 Info.plist의 <code>$(APP_GROUP_ID)</code> 같은 변수로 주입되고, 코드에서는 <code>AppConfig</code> 한 곳에서만 읽습니다. 값이 비어 있거나 치환되지 않았으면(<code>$(</code>가 남아 있으면) nil로 처리해서, 설정 전에도 앱이 죽지 않게 했습니다.</p>
<pre><code class="language-swift">enum AppConfig {
    static let appGroupID: String? = infoValue(&quot;AppGroupIdentifier&quot;)

    private static func infoValue(_ key: String) -&gt; String? {
        guard let value = Bundle.main.object(forInfoDictionaryKey: key) as? String else { return nil }
        let trimmed = value.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty, !trimmed.contains(&quot;$(&quot;) else { return nil }
        return trimmed
    }
}</code></pre>
<blockquote>
<p>💡 xcconfig에서 <code>//</code>는 주석으로 처리됩니다. 그래서 URL은 <code>https:/$()/example.com</code>처럼 적어야 합니다.</p>
</blockquote>
<h2 id="결정-5-ci부터-붙이기">결정 5. CI부터 붙이기</h2>
<p>GitHub Actions macOS 러너에서 <code>xcodegen generate</code> → 앱·위젯 빌드 → 테스트를 매 push마다 돌립니다. 서명 없이(<code>CODE_SIGNING_ALLOWED=NO</code>) 시뮬레이터용으로 빌드하므로 팀 ID 같은 개인 값이 CI에 필요 없습니다.</p>
<pre><code class="language-yaml">- run: |
    brew install xcodegen
    xcodegen generate
- run: |
    xcodebuild test -project Subhelp.xcodeproj -scheme Subhelp \
      -destination &quot;id=$DEVICE_ID&quot; CODE_SIGNING_ALLOWED=NO</code></pre>
<p>같은 브랜치에 새 커밋이 오면 이전 실행을 취소하도록 <code>concurrency</code>도 설정했습니다.</p>
<h2 id="다음-글">다음 글</h2>
<p>결제일 계산에서 가장 까다로웠던 <strong>월말 날짜 밀림 문제</strong>를 다룹니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[stock-monitor:9]]></title>
            <link>https://velog.io/@jiye_1124/stock-monitor9</link>
            <guid>https://velog.io/@jiye_1124/stock-monitor9</guid>
            <pubDate>Thu, 17 Sep 2026 07:58:21 GMT</pubDate>
            <description><![CDATA[<h1 id="토스증권-api로-관심종목-알림-서비스-만들기-9-완--마지막-기능들과-회고">토스증권 API로 관심종목 알림 서비스 만들기 (9, 완) — 마지막 기능들과 회고</h1>
<p>시리즈 마지막 편입니다. 남은 기능 세 가지를 짧게 정리하고, 프로젝트 전체를 돌아봅니다.</p>
<h2 id="기능-1--미체결-주문-조회">기능 1 — 미체결 주문 조회</h2>
<p>보유종목, 관심종목, 알림까지는 있었지만 &quot;지금 체결 대기 중인 주문이 뭐가 있나&quot;를 볼 방법이 없었습니다. 조회 전용 API가 있길래 읽기 전용 엔드포인트 하나를 추가했습니다.</p>
<pre><code class="language-java">@GetMapping(&quot;/api/orders/pending&quot;)
public List&lt;PendingOrderResponse&gt; pendingOrders() {
    return tossApiClient.getPendingOrders().stream()
            .map(PendingOrderResponse::of)
            .toList();
}</code></pre>
<p>여기서 의도적으로 좁힌 범위가 있습니다. <strong>조회만 만들고, 주문 생성/취소는 만들지 않았습니다.</strong> 매매 자체를 실행하는 기능은 이 프로젝트의 목적(관심종목 모니터링과 알림)을 벗어나고, 무엇보다 잘못 만들면 실제 금전적 피해로 이어지는 영역입니다. &quot;가능하니까 만든다&quot;가 아니라 &quot;필요한 만큼만 만든다&quot;는 원칙을 가장 분명하게 적용한 지점입니다.</p>
<p>또 하나, 이 엔드포인트가 감싸는 <code>getPendingOrders()</code>는 응답 스키마를 문서에서 검증할 방법이 없었던 API입니다. Javadoc에 이 사실을 명시적으로 남겨서, 나중에 실제 데이터로 이상 동작이 보이면 &quot;검증 안 된 부분&quot;이라는 걸 바로 알 수 있게 해뒀습니다. 확인되지 않은 가정을 코드에 조용히 숨기는 대신, 눈에 보이게 남겨두는 쪽을 택했습니다.</p>
<h2 id="기능-2--알림-규칙-수정">기능 2 — 알림 규칙 수정</h2>
<p>기존에는 알림 규칙을 켜고 끄는 것만 가능했습니다(<code>PATCH</code>로 <code>active</code>만 변경). 목표가를 바꾸고 싶으면 규칙을 지우고 새로 만들어야 했는데, 그러면 발송 이력과의 연결이 끊깁니다.</p>
<p><code>PATCH</code> 요청 바디를 확장해서, 임계값/채널/쿨다운/반복방식을 부분 업데이트할 수 있게 했습니다.</p>
<pre><code class="language-java">public record AlertRuleUpdateRequest(
        Boolean active,
        BigDecimal thresholdValue,
        Set&lt;NotificationChannelType&gt; channels,
        Integer cooldownMinutes,
        AlertTriggerMode triggerMode) {
}</code></pre>
<p>모든 필드가 nullable입니다. null인 필드는 &quot;변경하지 않음&quot;을 의미하고, 값이 있는 필드만 적용됩니다.</p>
<pre><code class="language-java">@Transactional
public AlertRule update(Long id, AlertRuleUpdateRequest request) {
    AlertRule rule = repository.findById(id)
            .orElseThrow(() -&gt; new NotFoundException(&quot;알림 규칙을 찾을 수 없습니다: &quot; + id));

    if (request.active() != null) {
        rule.setActive(request.active());
    }
    if (request.thresholdValue() != null) {
        if (request.thresholdValue().signum() &lt;= 0) {
            throw new IllegalArgumentException(&quot;임계값은 0보다 커야 합니다.&quot;);
        }
        rule.setThresholdValue(request.thresholdValue());
    }
    // ... channels, cooldownMinutes, triggerMode도 동일한 패턴
    return rule;
}</code></pre>
<p><strong>의도적으로 바꿀 수 없게 남긴 것도 있습니다.</strong> 종목(symbol)과 조건 종류(conditionType)는 수정 대상에서 뺐습니다. 이 둘은 규칙의 정체성 그 자체입니다 — 종목이나 조건 종류가 바뀐다는 건 사실상 &quot;다른 규칙&quot;이 됐다는 뜻이고, 그런 경우엔 기존 발송 이력을 유지한 채 수정하는 것보다 새로 만드는 게 데이터 모델상 더 맞다고 판단했습니다. 프론트엔드도 이 두 필드는 테이블에서 읽기 전용으로 표시하고, 나머지만 인라인으로 편집 가능하게 했습니다.</p>
<h2 id="기능-3--알림-발송-재시도">기능 3 — 알림 발송 재시도</h2>
<p>알림 발송이 실패하면 그걸로 끝이었습니다. 일시적인 네트워크 문제 때문에 실패한 경우에도 재시도 없이 실패로 기록됐습니다.</p>
<p>여기서 &quot;재시도해도 되는 실패&quot;와 &quot;재시도해봐야 똑같이 실패할 실패&quot;를 구분해야 했습니다. 예를 들어 이메일 채널의 SMTP 설정 자체가 비어있어서 나는 실패는, 3초 뒤에 다시 시도해도 똑같이 실패합니다. 반면 일시적인 타임아웃이나 연결 오류는 재시도하면 성공할 수 있습니다.</p>
<p>이걸 구분하려고 새 플래그를 추가하는 대신, 기존 코드에 이미 있던 관례를 활용했습니다. <code>NotificationDeliveryException</code>은 원인(<code>cause</code>)이 있는 채로 던져지는 경우(진짜 발송 실패를 감싼 경우)와, 원인 없이 그냥 던져지는 경우(설정 누락 같은 사전 조건 실패)가 이미 구분되어 있었습니다.</p>
<pre><code class="language-java">public boolean isRetryable() {
    return getCause() != null;
}</code></pre>
<pre><code class="language-java">private Attempt sendWithRetry(NotificationChannel channel, AlertTriggeredEvent event) {
    for (int attempt = 1; attempt &lt;= MAX_ATTEMPTS; attempt++) {
        try {
            channel.send(event);
            return new Attempt(DeliveryStatus.SUCCESS, null);
        } catch (NotificationDeliveryException e) {
            boolean isLastAttempt = attempt == MAX_ATTEMPTS;
            if (!e.isRetryable() || isLastAttempt) {
                return new Attempt(DeliveryStatus.FAILED, e.getMessage());
            }
            sleep(retryDelayMs);
        }
    }
    throw new IllegalStateException(&quot;도달할 수 없는 코드&quot;);
}</code></pre>
<p>최대 2회(최초 1회 + 재시도 1회)로 제한하고, 재시도 간격은 테스트에서 주입할 수 있게 만들어서 실제 테스트가 몇 초씩 기다리지 않도록 했습니다.</p>
<h2 id="회고">회고</h2>
<h3 id="인터페이스-뒤에-숨기기">인터페이스 뒤에 숨기기</h3>
<p><code>TossApiClient</code>, <code>NotificationChannel</code> 둘 다 처음부터 인터페이스로 만들고 Mock 구현체를 먼저 완성했습니다. 덕분에 실 API 연동은 &quot;이미 동작하는 시스템에 진짜 구현체를 하나 끼워 넣는 작업&quot;이 됐고, 이메일 채널 추가는 서비스 로직을 전혀 건드리지 않고 끝났습니다. 처음부터 실 API로 시작했다면 인증/스키마 문제(5~6편)를 디버깅하는 동안 나머지 기능 개발 전체가 막혔을 겁니다.</p>
<h3 id="추측하지-말고-확인하라">추측하지 말고 확인하라</h3>
<p>이 시리즈 전체를 관통한 원칙입니다. gzip 압축된 에러 응답(5편), 배열인 줄 알았던 응답이 객체였던 문제(6편), 다중 시세 조회 지원 여부(7편) — 전부 &quot;아마 이럴 것이다&quot;가 아니라 실제로 호출해서 확인하거나, 코드가 런타임에 스스로 확인하도록 만들어서 해결했습니다. 문서가 부실한 외부 API를 연동할 때는 특히 그렇습니다.</p>
<h3 id="기본값이-안전하게">기본값이 안전하게</h3>
<p>진단용 엔드포인트(8편)에서 배운 교훈입니다. &quot;나중에 끄는 걸 기억하기&quot;가 아니라 &quot;명시적으로 켜지 않는 한 꺼져 있기&quot;로 구조를 바꾸는 게, 사람의 기억보다 훨씬 신뢰할 수 있는 안전장치였습니다.</p>
<h3 id="의도적으로-하지-않은-것">의도적으로 하지 않은 것</h3>
<ul>
<li><strong>다중 사용자 / 로그인</strong>: 개인이 자신의 계좌를 모니터링하는 도구로 시작한 프로젝트라, 여러 사용자가 계정을 나눠 쓰는 시나리오는 범위 밖으로 뒀습니다.</li>
<li><strong>주문 생성/취소</strong>: 조회까지만. 실제 매매를 실행하는 기능은 요구사항에도 없었고, 잘못 만들면 리스크가 가장 큰 영역이라 손대지 않았습니다.</li>
<li><strong>배포 파이프라인</strong>: CI(테스트 자동화)까지는 붙였지만, 실제 배포 자동화는 이 시리즈 범위 밖입니다.</li>
</ul>
<h3 id="숫자로-보는-프로젝트">숫자로 보는 프로젝트</h3>
<ul>
<li>테스트: 0개 → 112개</li>
<li>머지된 PR: 다수 (기능 개발 + 고도화 항목들을 각각 별도 PR로)</li>
<li>알림 조건: 6종</li>
<li>알림 채널: 인앱 + 이메일</li>
</ul>
<hr>
<p>여기까지가 TossWatch를 만들며 겪은 문제와 해결 과정입니다. 전체 코드는 GitHub 저장소에서 확인할 수 있습니다. 읽어주셔서 감사합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[stock-monitor:8]]></title>
            <link>https://velog.io/@jiye_1124/stock-monitor8</link>
            <guid>https://velog.io/@jiye_1124/stock-monitor8</guid>
            <pubDate>Thu, 17 Sep 2026 07:58:01 GMT</pubDate>
            <description><![CDATA[<h1 id="토스증권-api로-관심종목-알림-서비스-만들기-8--서비스-고도화-2-쌓이는-데이터-빈-테스트-열려-있던-진단-엔드포인트">토스증권 API로 관심종목 알림 서비스 만들기 (8) — 서비스 고도화 (2): 쌓이는 데이터, 빈 테스트, 열려 있던 진단 엔드포인트</h1>
<blockquote>
<p>이 글은 보안과 직접 관련된 내용을 다룹니다. 어떤 문제가 있었고 어떻게 고쳤는지는 설명하지만, 진단용 엔드포인트가 실제로 반환하는 응답 내용이나 실제 계좌 데이터는 포함하지 않습니다.</p>
</blockquote>
<p>기능이 아니라, 당장 눈에 보이지 않는 운영 관점의 문제 세 가지를 정리한 편입니다. 셋 다 &quot;지금 당장은 문제가 안 되지만 방치하면 언젠가 문제가 되는&quot; 종류입니다.</p>
<h2 id="문제-1--시계열-데이터가-무한히-쌓인다">문제 1 — 시계열 데이터가 무한히 쌓인다</h2>
<p><code>account_snapshots</code>(자산 추이용 스냅샷)와 <code>alert_logs</code>(알림 발송 기록)는 시간이 지날수록 계속 쌓이기만 하고, 지우는 로직이 없었습니다. 당장은 테이블이 작아서 문제가 안 되지만, 서비스가 오래 돌수록 쿼리는 느려지고 저장공간은 계속 늘어납니다.</p>
<h3 id="두-데이터를-다르게-취급해야-했던-이유">두 데이터를 다르게 취급해야 했던 이유</h3>
<p>두 테이블을 똑같이 취급하면 안 된다고 판단했습니다.</p>
<ul>
<li><code>alert_logs</code>는 &quot;언제 무슨 알림이 발송됐는가&quot;를 남기는 감사 로그 성격입니다. 오래된 기록은 그냥 필요 없어집니다 → <strong>하드 삭제</strong>.</li>
<li><code>account_snapshots</code>는 자산 추이 차트의 원본 데이터입니다. 오래된 데이터라고 완전히 지워버리면, 차트에서 &quot;몇 달 전 대비 지금&quot;같은 장기 추이를 볼 수 없게 됩니다. 하지만 폴링 주기(예: 몇 분 간격)로 쌓인 스냅샷을 전부 남길 필요도 없습니다 → <strong>하루에 하나만 남기고 솎아내기</strong>.</li>
</ul>
<pre><code class="language-java">public record RetentionProperties(
        boolean enabled,
        String cron,
        int alertLogDays,
        int snapshotKeepAllDays) {
}</code></pre>
<pre><code class="language-java">@Service
public class RetentionService {

    @Autowired
    public RetentionService(AlertLogRepository alertLogRepository,
            AccountSnapshotRepository snapshotRepository,
            RetentionProperties properties) {
        this(alertLogRepository, snapshotRepository, properties, ZoneId.systemDefault());
    }

    // 테스트에서 시간대를 고정하기 위한 생성자
    RetentionService(AlertLogRepository alertLogRepository,
            AccountSnapshotRepository snapshotRepository,
            RetentionProperties properties, ZoneId zoneId) {
        ...
    }

    public void purgeAlertLogs() {
        if (properties.alertLogDays() &lt;= 0) {
            return;
        }
        Instant cutoff = Instant.now().minus(properties.alertLogDays(), ChronoUnit.DAYS);
        alertLogRepository.deleteByTriggeredAtBefore(cutoff);
    }

    public void thinSnapshots() {
        if (properties.snapshotKeepAllDays() &lt;= 0) {
            return;
        }
        Instant cutoff = Instant.now().minus(properties.snapshotKeepAllDays(), ChronoUnit.DAYS);
        List&lt;AccountSnapshot&gt; old = snapshotRepository.findBySnapshotAtBeforeOrderBySnapshotAtAsc(cutoff);

        // 달력 날짜별로 묶어서, 각 날짜의 마지막 스냅샷만 남긴다
        Map&lt;LocalDate, AccountSnapshot&gt; keepPerDay = new LinkedHashMap&lt;&gt;();
        for (AccountSnapshot snapshot : old) {
            LocalDate day = snapshot.getSnapshotAt().atZone(zoneId).toLocalDate();
            keepPerDay.put(day, snapshot); // 같은 날짜면 마지막 것으로 덮어써짐 (오름차순 정렬이므로)
        }

        List&lt;AccountSnapshot&gt; toDelete = old.stream()
                .filter(s -&gt; !keepPerDay.containsValue(s))
                .toList();
        snapshotRepository.deleteAllInBatch(toDelete);
    }
}</code></pre>
<p>날짜 경계 판단은 SQL의 날짜 절삭 함수 대신 자바 코드로 처리했습니다. DB 방언마다 날짜 함수 문법이 다르고, 테스트하기도 더 어렵기 때문입니다. <code>ZoneId</code>를 주입 가능하게 만들어서, 테스트에서는 시간대를 고정하고 &quot;23시와 다음날 01시는 다른 날짜로 유지된다&quot; 같은 경계 케이스를 검증할 수 있게 했습니다.</p>
<pre><code class="language-java">@Test
void keepsSnapshotsFromDifferentCalendarDaysEvenIfCloseInTime() {
    // 23:00과 다음날 01:00 — 2시간 차이지만 서로 다른 날짜
    AccountSnapshot late = snapshotAt(Instant.parse(&quot;2026-01-01T23:00:00Z&quot;));
    AccountSnapshot early = snapshotAt(Instant.parse(&quot;2026-01-02T01:00:00Z&quot;));
    ...
    // 둘 다 살아남아야 한다
}</code></pre>
<p>매일 새벽 3시 30분에 도는 스케줄러를 붙이고, <code>retention.enabled</code> 플래그로 기능 전체를 끌 수 있게 했습니다.</p>
<pre><code class="language-yaml">retention:
  enabled: true
  cron: &quot;0 30 3 * * *&quot;
  alert-log-days: 90
  snapshot-keep-all-days: 30</code></pre>
<h2 id="문제-2--컨트롤러-테스트가-0개였다">문제 2 — 컨트롤러 테스트가 0개였다</h2>
<p>4편에서 서비스/도메인/스케줄러 계층 테스트를 몰아서 작성했지만, HTTP 계층(컨트롤러)은 빈 채로 남아있었습니다. 요청 파라미터 바인딩이 깨지거나, 상태 코드가 잘못되거나, 검증 애노테이션이 빠지는 종류의 버그는 서비스 계층 테스트로는 안 잡힙니다.</p>
<p><code>@WebMvcTest</code> + <code>@MockBean</code>으로 HTTP 계약 수준의 테스트를 채워 넣었습니다. 이 프로젝트가 Spring Boot 3.3.x라서 <code>@MockitoBean</code>(3.4+ 전용)이 아니라 예전 <code>@MockBean</code>을 써야 했습니다.</p>
<pre><code class="language-java">@WebMvcTest(AlertRuleController.class)
class AlertRuleControllerTest {

    @Autowired
    MockMvc mockMvc;

    @MockBean
    AlertRuleService service;

    @Test
    void mapsAnUpdateOnAMissingRuleTo404() throws Exception {
        when(service.update(eq(999L), any())).thenThrow(new NotFoundException(&quot;...&quot;));

        mockMvc.perform(patch(&quot;/api/alert-rules/999&quot;)
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(&quot;{\&quot;active\&quot;: false}&quot;))
                .andExpect(status().isNotFound());
    }
}</code></pre>
<p>작업 중 Spring 테스트 컨텍스트의 사소한 함정도 하나 만났습니다. <code>@Nested</code> 테스트 클래스는 바깥 클래스의 <code>@TestConfiguration</code>을 자동으로 상속받지 않아서, 각 중첩 클래스에 <code>@Import(Config.class)</code>를 따로 붙여줘야 했습니다.</p>
<h2 id="문제-3--인증-없는-진단용-엔드포인트가-열려-있었다">문제 3 — 인증 없는 진단용 엔드포인트가 열려 있었다</h2>
<p>5편에서 실 API 연동 문제(<code>accountSeq</code>를 못 찾던 문제)를 해결하려고 만든 진단용 컨트롤러가 있었습니다. 토스 API에 직접 요청을 보내서 원문 응답을 그대로 반환하는 엔드포인트입니다. 디버깅할 때는 유용했지만, 문제를 다 해결한 뒤에도 계속 켜진 채로 남아 있었습니다. 그리고 <strong>인증이 없었습니다.</strong></p>
<p>이 엔드포인트가 반환하는 정보의 성격상(계좌 관련 원문 응답), 인증 없이 외부에 노출되면 안 되는 종류입니다. &quot;나중에 지워야지&quot;라고 기억에 의존하는 방식은 신뢰할 수 없다고 판단했습니다. 코드에 &quot;지우는 걸 잊지 말자&quot; 주석을 남기는 대신, <strong>기본값을 꺼짐으로 만들고 명시적으로 켜야만 동작하도록</strong> 구조 자체를 바꿨습니다.</p>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/api/diagnostics/toss&quot;)
@ConditionalOnProperty(prefix = &quot;toss.diagnostics&quot;, name = &quot;enabled&quot;, havingValue = &quot;true&quot;)
public class TossDiagnosticsController {

    @PostConstruct
    void warnThatDiagnosticsAreExposed() {
        log.warn(&quot;토스 진단 엔드포인트가 활성화되어 있습니다. 디버깅 목적이 끝나면 반드시 비활성화하세요.&quot;);
    }

    // ...
}</code></pre>
<pre><code class="language-yaml">toss:
  diagnostics:
    enabled: ${TOSS_DIAGNOSTICS_ENABLED:false}</code></pre>
<p><code>@ConditionalOnProperty</code>를 쓰면, 값이 명시적으로 <code>true</code>가 아닌 이상 이 컨트롤러의 빈 자체가 생성되지 않습니다. 즉 라우팅 테이블에 아예 존재하지 않게 되어 404가 됩니다 — &quot;권한이 없어서 막힌 리소스&quot;가 아니라 &quot;애초에 없는 리소스&quot;가 되는 겁니다. 실수로 켜놓은 채로 배포해도, 환경변수 하나를 깜빡하지 않는 한 자동으로 꺼져 있습니다.</p>
<p>이 패턴은 &quot;배포 전에 지우는 걸 기억하기&quot;보다 훨씬 안전합니다. 사람의 기억에 의존하는 체크리스트는 언젠가 깨지지만, 기본값이 안전한 코드는 깜빡해도 안전한 채로 남습니다.</p>
<p>컨트롤러 테스트도 이 두 가지 상태를 각각 검증하도록 작성했습니다.</p>
<pre><code class="language-java">@Nested
@Import(Config.class)
class WhenDisabled {
    @Test
    void returns404() { ... }
}

@Nested
@Import(Config.class)
class WhenExplicitlyEnabled {
    @Test
    void respondsNormally() { ... }
}</code></pre>
<h2 id="결과">결과</h2>
<p>이 세 가지 모두 기능적으로는 &quot;이미 동작하고 있던&quot; 부분입니다. 사용자 입장에서 달라지는 건 없습니다. 하지만 서비스를 오래 운영한다고 가정했을 때 실제로 문제가 됐을 지점들이었고, 코드 리뷰 관점에서 스스로 점검하지 않았다면 계속 넘어갔을 것들입니다.</p>
<p>CI도 이 시점에 GitHub Actions로 붙였습니다. PR마다 전체 테스트 스위트가 자동으로 돌아가게 되면서, &quot;로컬에서 돌려봤는데 통과했다&quot;가 아니라 &quot;머지 전에 항상 확인된다&quot;는 보장이 생겼습니다.</p>
<hr>
<p>마지막 편에서는 남은 기능 두 가지(미체결 주문 조회, 알림 규칙 수정)를 짧게 정리하고, 프로젝트 전체를 관통한 원칙들을 되짚어봅니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[stock-monitor:7]]></title>
            <link>https://velog.io/@jiye_1124/stock-monitor7</link>
            <guid>https://velog.io/@jiye_1124/stock-monitor7</guid>
            <pubDate>Thu, 17 Sep 2026 07:57:37 GMT</pubDate>
            <description><![CDATA[<h1 id="토스증권-api로-관심종목-알림-서비스-만들기-7--서비스-고도화-1-호출량-줄이기-중복-알림-없애기">토스증권 API로 관심종목 알림 서비스 만들기 (7) — 서비스 고도화 (1): 호출량 줄이기, 중복 알림 없애기</h1>
<p>실연동이 동작하기 시작한 뒤, 코드를 다시 훑어보면서 &quot;동작은 하는데 품질이 떨어지는 지점&quot;을 스스로 점검했습니다. 이번 편은 그 과정에서 고친 두 가지입니다.</p>
<h2 id="문제-1--시세를-종목마다-하나씩-조회하고-있었다">문제 1 — 시세를 종목마다 하나씩 조회하고 있었다</h2>
<p><code>PriceAlertScheduler</code>는 알림 규칙마다 <code>getQuote()</code>를 호출했습니다. 같은 종목에 규칙이 2개 있으면, 60초마다 똑같은 시세를 2번 조회했습니다. 관심종목 목록도 마찬가지였습니다 — 항목마다 개별 조회했고, 대시보드가 수 초마다 폴링하니 관심종목 10개면 새로고침마다 10번씩 호출이 나갔습니다.</p>
<p>레이트리밋이 있는 API에서, 하필 <strong>사용자가 늘려갈수록 호출이 비례해서 늘어나는 구조</strong>였습니다.</p>
<h3 id="배치-조회-인터페이스-추가">배치 조회 인터페이스 추가</h3>
<p><code>TossApiClient</code>에 여러 종목을 한 번에 조회하는 메서드를 추가했습니다.</p>
<pre><code class="language-java">public interface TossApiClient {

    Quote getQuote(String symbol, Market market);

    /**
     * 여러 종목의 시세를 한 번에 조회한다. getQuote를 반복 호출하는 대신 이걸 쓰면,
     * 레이트리밋이 있는 HTTP API 구현체는 한 번의 요청으로 전체를 처리할 수 있다.
     *
     * 조회 실패한 종목은 결과 맵에서 그냥 빠진다 — 종목 하나가 배치 전체를
     * 실패시키지 않도록. 기본 구현은 단순 루프인데, 로컬 Mock처럼 호출 비용이
     * 없는 구현체에는 이게 맞는 동작이다.
     */
    default Map&lt;SymbolRef, Quote&gt; getQuotes(Collection&lt;SymbolRef&gt; refs) {
        Map&lt;SymbolRef, Quote&gt; quotes = new LinkedHashMap&lt;&gt;();
        for (SymbolRef ref : refs) {
            if (!quotes.containsKey(ref)) {
                quotes.put(ref, getQuote(ref.symbol(), ref.market()));
            }
        }
        return quotes;
    }
}</code></pre>
<p><code>MockTossApiClient</code>는 이 기본 구현(단순 루프)을 그대로 쓰고, 실제 HTTP 구현체에서만 오버라이드해서 진짜 배치 요청을 보냅니다.</p>
<h3 id="다중-조회-지원-여부를-런타임에-검증한다">다중 조회 지원 여부를 런타임에 검증한다</h3>
<p>시세 조회 파라미터가 <code>symbols</code>(복수형)였지만, 문서에는 이게 실제로 여러 종목을 한 번에 받아주는지 나와 있지 않았습니다. 여기서도 5~6편과 같은 원칙을 반복했습니다 — 추측하지 않고 확인합니다. 다만 이번엔 &quot;직접 호출해서 사람이 확인&quot;하는 대신, <strong>런타임에 코드가 스스로 확인하고 적응</strong>하도록 만들었습니다.</p>
<pre><code class="language-java">public Map&lt;SymbolRef, Quote&gt; getQuotes(Collection&lt;SymbolRef&gt; refs) {
    List&lt;SymbolRef&gt; distinct = refs.stream().distinct().toList();
    if (distinct.size() == 1 || !batchedPricesSupported.get()) {
        return fetchIndividually(distinct);
    }

    String joinedSymbols = distinct.stream().map(SymbolRef::symbol).collect(Collectors.joining(&quot;,&quot;));
    List&lt;QuoteDto&gt; dtos = unwrap(authorizedGet(PRICES_PATH, PricesEnvelope.class,
            uri -&gt; uri.queryParam(&quot;symbols&quot;, joinedSymbols), false));

    if (dtos.size() == 1) {
        // 여러 종목을 요청했는데 결과가 1개만 왔다 = 이 API는 다중 조회를 지원하지 않는다
        log.info(&quot;다중 조회 요청에 결과 1개만 반환 — 이후로는 개별 호출로 전환&quot;);
        batchedPricesSupported.set(false);
        return fetchIndividually(distinct);
    }

    // 정상적으로 여러 개 왔으면 symbol로 매핑
    ...
}</code></pre>
<p>여러 종목을 요청했는데 결과가 1개만 오면, &quot;이 API는 사실 단일 조회만 지원하는구나&quot;라고 판단하고 그 프로세스 생애주기 동안은 개별 호출로 되돌립니다. 지원하지 않더라도 손해는 왕복 한 번뿐이고, 지원한다면 이후 모든 호출이 배치로 절약됩니다. 응답에서 일부 종목만 빠진 경우엔 그 종목만 따로 재조회해서, 하나의 이상한 응답이 배치 전체를 망치지 않게 했습니다.</p>
<p>이 변경 하나로 스케줄러는 &quot;규칙 수만큼&quot;이 아니라 &quot;고유 종목 수만큼&quot;만 호출하게 됐고, 같은 종목에 규칙이 여러 개 걸려 있어도 종목당 1회로 줄었습니다.</p>
<h2 id="문제-2--조건이-계속-충족되면-알림이-계속-온다">문제 2 — 조건이 계속 충족되면 알림이 계속 온다</h2>
<p>목표가를 넘긴 채로 주가가 그 위에 머물면, 매 폴링마다 조건이 계속 충족된 상태입니다. 쿨다운만으로 중복을 막고 있었는데, 이건 &quot;한 번만 알려달라&quot;가 아니라 &quot;쿨다운 주기마다 계속 알려달라&quot;는 의미였습니다. 실사용에서 가장 성가신 부분이었습니다.</p>
<h3 id="edge와-repeat">EDGE와 REPEAT</h3>
<p>두 가지 반복 방식을 추가했습니다.</p>
<pre><code class="language-java">public enum AlertTriggerMode {
    /** 조건이 충족되는 순간(rising edge)에만 알리고, 조건이 해제될 때까지 조용히 있는다. */
    EDGE,

    /** 조건이 유지되는 동안 쿨다운마다 계속 알린다. */
    REPEAT
}</code></pre>
<p>판정 로직은 &quot;직전 평가에서 조건이 충족됐는지&quot;를 함께 기억해야 엣지를 구분할 수 있습니다.</p>
<pre><code class="language-java">public boolean shouldTrigger(boolean conditionMet, Instant now) {
    if (!conditionMet || !isCooldownElapsed(now)) {
        return false;
    }
    return getTriggerMode() == AlertTriggerMode.REPEAT || !Boolean.TRUE.equals(lastConditionMet);
}

/** 매 평가마다 반드시 호출 — 알림을 보내지 않은 평가에서도 마찬가지.
    이게 바로 조건이 해제됐을 때 EDGE 규칙을 다시 무장시키는 동작이다. */
public void recordEvaluation(boolean conditionMet) {
    this.lastConditionMet = conditionMet;
}</code></pre>
<p><code>shouldTrigger</code>가 true를 반환하지 않은 평가에서도 <code>recordEvaluation</code>은 항상 호출됩니다. 이게 핵심입니다 — 조건이 해제됐다는 사실 자체를 기록해둬야, 다음에 다시 조건이 충족될 때 그게 &quot;새로운 진입&quot;이라는 걸 알 수 있습니다.</p>
<p>EDGE 모드에서도 쿨다운은 그대로 적용됩니다. 가격이 임계값 근처에서 오르내리는 경우, &quot;충족→해제→충족&quot;이 짧은 시간에 반복될 수 있는데, 쿨다운이 이런 플래핑이 알림 폭주로 이어지는 걸 막아줍니다.</p>
<h3 id="실제로-확인한-방법">실제로 확인한 방법</h3>
<p>로컬 서버를 띄우고, 항상 조건이 충족되는 규칙 두 개를 쿨다운 0분으로 각각 EDGE와 REPEAT로 만들어서 3번의 폴링 동안 관찰했습니다.</p>
<table>
<thead>
<tr>
<th>규칙</th>
<th>반복 방식</th>
<th>3회 폴링 후 발동 횟수</th>
</tr>
</thead>
<tbody><tr>
<td>A</td>
<td>EDGE</td>
<td>1회</td>
</tr>
<tr>
<td>B</td>
<td>REPEAT</td>
<td>3회</td>
</tr>
</tbody></table>
<p>기대한 그대로 동작했습니다.</p>
<hr>
<p>다음 편에서는 &quot;당장 눈에 보이지 않지만 운영 관점에서 중요한&quot; 개선들을 다룹니다. 시간이 지날수록 무한히 쌓이는 데이터, 테스트 커버리지가 비어 있던 구간, 그리고 인증 없이 열려 있던 진단용 엔드포인트를 정리한 이야기입니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[stock-moitor:6]]></title>
            <link>https://velog.io/@jiye_1124/stock-moitor6</link>
            <guid>https://velog.io/@jiye_1124/stock-moitor6</guid>
            <pubDate>Thu, 17 Sep 2026 07:57:16 GMT</pubDate>
            <description><![CDATA[<h1 id="토스증권-api로-관심종목-알림-서비스-만들기-6--실-api-연동-두-번째-고비-응답-스키마가-예상과-다를-때">토스증권 API로 관심종목 알림 서비스 만들기 (6) — 실 API 연동, 두 번째 고비: 응답 스키마가 예상과 다를 때</h1>
<blockquote>
<p>이 글의 응답 예시 값(금액, 종목명, 거래량 등)은 전부 가짜 값입니다. 실제 계좌 데이터가 아닙니다.</p>
</blockquote>
<p>인증과 URL 문제를 해결하고 나니, 이번엔 응답 자체가 예상과 다른 문제들이 이어졌습니다. 문서에 스키마가 없으니, 모든 걸 실제 호출로 확인해야 했습니다.</p>
<h2 id="트러블슈팅-4--배열인-줄-알았던-응답이-사실은-객체였다">트러블슈팅 4 — 배열인 줄 알았던 응답이 사실은 객체였다</h2>
<p>보유종목 API를 호출한 결과를 배열로 파싱하려고 했는데 실패했습니다.</p>
<pre><code>Cannot deserialize value of type `HoldingDto[]` from Object value (token JsonToken.START_OBJECT)</code></pre><p>실제 응답을 원문으로 확인해보니, <code>result</code>가 배열이 아니라 <strong>계좌 집계 + 종목 배열을 함께 담은 객체</strong>였습니다.</p>
<pre><code class="language-json">{
  &quot;result&quot;: {
    &quot;totalPurchaseAmount&quot;: { &quot;krw&quot;: &quot;100000&quot;, &quot;usd&quot;: &quot;...&quot; },
    &quot;marketValue&quot;: { &quot;amount&quot;: { &quot;krw&quot;: &quot;96000&quot; } },
    &quot;dailyProfitLoss&quot;: { &quot;amount&quot;: { &quot;krw&quot;: &quot;-250&quot; }, &quot;rate&quot;: &quot;-0.0026&quot; },
    &quot;items&quot;: [
      { &quot;symbol&quot;: &quot;000000&quot;, &quot;name&quot;: &quot;샘플 종목&quot;, &quot;marketCountry&quot;: &quot;KR&quot;, &quot;quantity&quot;: &quot;2&quot;, &quot;lastPrice&quot;: &quot;48000&quot;, ... }
    ]
  }
}</code></pre>
<p>시세 조회(<code>/prices</code>)나 계좌 목록(<code>/accounts</code>) 응답은 <code>result</code>가 배열이었는데, 보유종목만 유독 객체 안에 배열이 들어있는 구조였습니다. 문서 없이 추측했다면 계속 배열이라고 가정했을 지점입니다.</p>
<p>이 사실을 알고 나니 오히려 구조를 더 단순하게 만들 수 있었습니다. 계좌 요약(총 평가금액, 당일 손익)을 종목별 시세를 일일이 다시 조회해서 합산하던 방식에서, <strong>이 응답 하나에 이미 들어있는 집계값을 그대로 읽는 방식</strong>으로 바꿨습니다. API 호출이 줄어드는 부수 효과까지 얻었습니다.</p>
<h2 id="트러블슈팅-5--캔들-날짜가-하루씩-밀렸다">트러블슈팅 5 — 캔들 날짜가 하루씩 밀렸다</h2>
<p>캔들 차트를 실 데이터로 확인해보니, 날짜가 실제보다 하루씩 이르게 표시되고 있었습니다.</p>
<p>원인은 타임스탬프 형식과 Jackson의 기본 동작이 만난 지점이었습니다. 캔들 응답의 <code>timestamp</code>는 이런 형태였습니다.</p>
<pre><code>&quot;timestamp&quot;: &quot;2026-06-08T00:00:00.000+09:00&quot;</code></pre><p>자정 + KST(+09:00) 오프셋입니다. 이걸 <code>Instant</code>나 <code>OffsetDateTime</code>으로 자동 바인딩하면, Jackson의 기본 설정인 <code>ADJUST_DATES_TO_CONTEXT_TIME_ZONE</code>이 이 값을 <strong>UTC로 정규화</strong>합니다. <code>2026-06-08T00:00+09:00</code>은 UTC로 <code>2026-06-07T15:00</code>이 되고, 여기서 날짜만 뽑으면 6월 7일이 되어버립니다. 한국 시간 기준 거래일이었던 6월 8일이 통째로 하루 밀리는 겁니다.</p>
<p>해결은 타임스탬프를 자동 바인딩하지 않고, 문자열로 받아서 <strong>오프셋을 보존한 채로 직접 파싱</strong>하는 것이었습니다.</p>
<pre><code class="language-java">record CandleDto(
        String timestamp,  // Instant/OffsetDateTime으로 자동 바인딩하지 않고 문자열로 받음
        BigDecimal openPrice,
        BigDecimal highPrice,
        BigDecimal lowPrice,
        BigDecimal closePrice,
        long volume,
        String currency) {

    /**
     * API가 보낸 오프셋 그대로 거래일을 읽는다.
     * OffsetDateTime으로 자동 바인딩하면 Jackson의 ADJUST_DATES_TO_CONTEXT_TIME_ZONE이
     * 자정+KST 타임스탬프를 UTC로 정규화해서, 모든 한국 캔들이 전날로 밀린다.
     */
    LocalDate tradingDate() {
        return OffsetDateTime.parse(timestamp).toLocalDate();
    }
}</code></pre>
<p>이 버그는 테스트를 먼저 작성해서 잡았습니다. 캔들 3개를 오름차순으로 정렬했을 때 날짜가 기대한 순서로 나오는지 검증하는 테스트를 짜는 과정에서, 실제 날짜가 하루씩 어긋나 있는 걸 발견했습니다.</p>
<h2 id="시세-api의-한계--등락률도-거래량도-52주-고저도-없다">시세 API의 한계 — 등락률도, 거래량도, 52주 고저도 없다</h2>
<p>시세 조회 응답을 실제로 받아보고 알게 된 사실이 있습니다.</p>
<pre><code class="language-json">{ &quot;result&quot;: [{ &quot;symbol&quot;: &quot;000000&quot;, &quot;timestamp&quot;: &quot;...&quot;, &quot;lastPrice&quot;: &quot;48000&quot;, &quot;currency&quot;: &quot;KRW&quot; }] }</code></pre>
<p><strong>마지막 체결가만 줍니다.</strong> 등락률, 거래량, 52주 고저가 전혀 없습니다. 이건 알림 조건 6개 중 4개(등락률, 거래량 급증, 52주 신고가/신저가)가 실연동 모드에서 쓸 데이터가 없다는 뜻이었습니다.</p>
<h2 id="캔들로-파생-계산하기">캔들로 파생 계산하기</h2>
<p>전용 엔드포인트를 문서에서 찾지 못한 상태라, 이미 확인된 캔들 API(고가/저가/거래량/종가)로 부족한 값을 직접 계산하기로 했습니다.</p>
<pre><code class="language-java">static QuoteEnrichment computeEnrichment(List&lt;Candle&gt; candles, BigDecimal lastPrice, LocalDate quoteDate) {
    if (candles.isEmpty()) {
        return QuoteEnrichment.EMPTY;
    }

    // 52주 고저 = 받아온 캔들 히스토리 전체의 최댓값/최솟값, 오늘 현재가도 후보에 포함
    BigDecimal week52High = candles.stream().map(Candle::high).max(Comparator.naturalOrder()).get().max(lastPrice);
    BigDecimal week52Low = candles.stream().map(Candle::low).min(Comparator.naturalOrder()).get().min(lastPrice);

    // 등락률 = 마지막 체결가 vs 직전 거래일 종가
    List&lt;Candle&gt; priorDays = candles.stream().filter(c -&gt; c.date().isBefore(quoteDate)).toList();
    BigDecimal changeRate = null;
    if (!priorDays.isEmpty()) {
        BigDecimal previousClose = priorDays.get(priorDays.size() - 1).close();
        if (previousClose.signum() != 0) {
            changeRate = lastPrice.subtract(previousClose)
                    .divide(previousClose, 6, RoundingMode.HALF_UP)
                    .multiply(BigDecimal.valueOf(100))
                    .setScale(2, RoundingMode.HALF_UP);
        }
    }

    // 거래량 급증 = 가장 최근 캔들 거래량 vs 그 이전 20일 평균
    // (날짜가 아니라 &quot;마지막 vs 나머지&quot; 구조로 판단 — 최신 캔들이 자기 자신을
    //  평균에 중복으로 반영하지 않도록)
    long volume = candles.get(candles.size() - 1).volume();
    List&lt;Candle&gt; volumeBaseline = candles.size() &gt; 1 ? candles.subList(0, candles.size() - 1) : List.of();
    // ... 이전 20일 평균 계산
    ...
}</code></pre>
<p>이 파생 계산에는 한계가 명확합니다. 그리고 그 한계를 과장하지 않고 그대로 문서화해뒀습니다.</p>
<ul>
<li>&quot;52주&quot;라고 부르지만, 캔들 API가 <code>count</code> 파라미터를 받지 않아서 실제로는 API가 기본으로 주는 히스토리만큼입니다. 정확히 252거래일을 보장하지 않습니다.</li>
<li>거래량은 실시간 체결량이 아니라 <strong>가장 최근에 확정된 일봉의 거래량</strong>입니다. 장중 실시간 급증 감지가 아니라 &quot;최근 일봉 vs 과거 평균&quot; 비교에 가깝습니다.</li>
</ul>
<p>이 함수는 캔들 API를 호출해서 계산하는데, 시세 조회마다 매번 캔들을 다시 받아오면 API 호출이 배로 늘어납니다. 그래서 캔들 히스토리는 심볼당 30분 캐싱했습니다 — 하루 단위로 바뀌는 데이터라 60초 폴링마다 새로 받아올 이유가 없기 때문입니다. 캔들 조회가 실패해도 시세 조회 자체는 계속 동작하도록, 실패 시 빈 값으로 감싸서 반환하게 만들었습니다.</p>
<h2 id="결과">결과</h2>
<p>이 두 편(5~6편)을 거쳐, 목표가 조건은 원본 데이터로, 나머지 4개 조건은 캔들 기반 파생값으로 실연동 모드에서 전부 동작하게 됐습니다.</p>
<table>
<thead>
<tr>
<th>조건</th>
<th>Mock</th>
<th>실연동</th>
</tr>
</thead>
<tbody><tr>
<td>목표가 이상/이하</td>
<td>✅</td>
<td>✅ (원본 데이터)</td>
</tr>
<tr>
<td>등락률</td>
<td>✅</td>
<td>✅ (파생)</td>
</tr>
<tr>
<td>거래량 급증</td>
<td>✅</td>
<td>✅ (파생)</td>
</tr>
<tr>
<td>52주 신고가/신저가 근접</td>
<td>✅</td>
<td>✅ (파생)</td>
</tr>
</tbody></table>
<hr>
<p>다음 편부터는 기능이 아니라 <strong>서비스 품질</strong>을 개선한 이야기입니다. 시세를 종목마다 개별 조회하던 구조를 배치로 바꾼 과정과, 같은 조건이 계속 충족될 때 알림이 반복해서 오던 문제를 다룹니다.</p>
]]></description>
        </item>
    </channel>
</rss>