<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>heo-hyuk.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Thu, 01 Oct 2026 23:52:04 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>heo-hyuk.log</title>
            <url>https://velog.velcdn.com/images/heo-hyuk/profile/28bdc61d-8e06-4822-a86d-cc752ad4d103/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. heo-hyuk.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/heo-hyuk" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Fullstack 116]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-116</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-116</guid>
            <pubDate>Thu, 01 Oct 2026 23:52:04 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="심심오락실-개발일지-초성-퀴즈-정식-승격부터-구슬-레이스-재설계까지--20261002">[심심오락실 개발일지] 초성 퀴즈 정식 승격부터 구슬 레이스 재설계까지 — 2026.10.02</h1>
<blockquote>
<p>예능식 퀴즈와 추억의 플래시 게임을 웹에서 즐기는 <strong>심심오락실(simsim-arcade)</strong> 개발기.
오늘은 PR 5개를 머지했다. 그중 하나는 &quot;저품질&quot;이라는 피드백을 받고 처음부터 다시 만든 PR이다.</p>
</blockquote>
<ul>
<li>스택: React + TypeScript + Vite (Cloudflare Pages) / Hono + D1 (Cloudflare Workers) / pnpm 모노레포</li>
<li>오늘 작업: Claude Code와 함께 작업</li>
</ul>
<hr>
<h2 id="오늘-한-일-한눈에-보기">오늘 한 일 한눈에 보기</h2>
<table>
<thead>
<tr>
<th>PR</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>#59</td>
<td>초성 퀴즈를 샘플에서 <strong>공통 정식 게임</strong>으로 업그레이드</td>
</tr>
<tr>
<td>#60</td>
<td>게임 카드 번호 <strong>자동 지정</strong> (<code>owner</code> 만 적으면 끝)</td>
</tr>
<tr>
<td>#62</td>
<td>게임과 분리된 <strong>📺 방송 도구</strong> 섹션 신설</td>
</tr>
<tr>
<td>#63</td>
<td>방송 도구 1호 「구슬 레이스」 v1 (…핀볼 짝퉁이라는 평가)</td>
</tr>
<tr>
<td>#66</td>
<td>구슬 레이스를 <strong>위에서 내려다본 서킷 레이스</strong>로 전면 재설계</td>
</tr>
</tbody></table>
<p>같은 날 팀원 신영 님도 「캣 블레이드」를 추가·개편해서(#61, #64, #65) main 이 꽤 바빴던 하루.</p>
<hr>
<h2 id="1-초성-퀴즈-샘플-→-정식-게임-59">1. 초성 퀴즈: 샘플 → 정식 게임 (#59)</h2>
<p>초성 퀴즈는 원래 &quot;새 게임은 이렇게 만들어요&quot;를 보여 주는 구조 참고용 샘플이었다. 문제 20개, 정답 1개당 10점이라 게임이라기보다 예제에 가까웠다. 그런데 아이템 자체가 좋아서 정식 게임으로 올리기로 했다. 담당자 카드 번호 없이 공통 게임 자리(메인 맨 뒤)는 그대로 둔다.</p>
<h3 id="문제-20개-→-257개-그리고-생성-스크립트">문제 20개 → 257개, 그리고 생성 스크립트</h3>
<p>10개 분야(음식·동물·과일·채소·물건·탈것·장소·직업·스포츠·사자성어·속담)로 늘렸다.
초성을 손으로 쓰면 반드시 오타가 나므로 <strong>정답에서 초성을 자동으로 뽑아 시드 SQL을 만드는 스크립트</strong>를 따로 만들었다.</p>
<pre><code class="language-js">const INITIALS = &#39;ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ&#39;;
function toChosung(text) {
  return [...text].map((ch) =&gt; {
    const code = ch.charCodeAt(0);
    if (code &lt; 0xac00 || code &gt; 0xd7a3) return ch; // 한글이 아니면(띄어쓰기 등) 그대로
    return INITIALS[Math.floor((code - 0xac00) / 588)];
  }).join(&#39;&#39;);
}
// &#39;등잔 밑이 어둡다&#39; → &#39;ㄷㅈ ㅁㅇ ㅇㄷㄷ&#39;</code></pre>
<p>스크립트는 <strong>힌트에 정답이 그대로 들어 있거나 정답이 중복되면 에러</strong>를 내도록 했다. 문제를 수백 개 쓰다 보면 실수가 생기니까.</p>
<h3 id="헬기는-정답으로-인정하면-안-된다">&quot;헬기&quot;는 정답으로 인정하면 안 된다</h3>
<p>처음엔 동의어를 정답으로 넉넉하게 넣었다(헬리콥터/헬기, 치킨/통닭…). 그런데 생각해 보니 <strong>초성 퀴즈에서 초성이 다른 동의어를 받아 주면 게임의 의미가 없어진다.</strong> 그래서 인정 답은 <em>표기만 다른 말</em>(짜장면/자장면, 리모컨/리모콘)로 제한했다.</p>
<h3 id="점수-체계">점수 체계</h3>
<ul>
<li>정답 100점, 힌트를 보고 맞히면 50점</li>
<li>연속 정답 보너스 +10씩 (최대 +50), 오답·패스하면 끊김</li>
<li>20문제를 시간 안에 전부 맞히면 남은 시간 1초당 +10점</li>
</ul>
<p>서버는 비정상 점수를 막기 위해 게임별 상한(<code>MAX_SCORE_BY_GAME</code>)을 둔다. 상한값을 감으로 정하지 않고 <code>maxScore()</code> 함수로 계산해서 <strong>3440점</strong>으로 맞췄다.</p>
<hr>
<h2 id="2-게임-카드-번호-자동-지정-60">2. 게임 카드 번호 자동 지정 (#60)</h2>
<p>우리 팀은 메인 화면 카드 번호를 <strong>4명이 순서대로 돌아가며</strong> 받는다. 규칙은 이렇다.</p>
<blockquote>
<p>내 n번째 게임의 카드 번호 = (n − 1) × 4 + 내 순번</p>
</blockquote>
<p>지금까지는 이 번호를 각자 계산해서 <code>card: 6</code> 처럼 적었다. 빼먹거나 계산을 틀려도 아무도 몰랐다는 게 문제였다. 팀에서도 이 얘기가 나와서 <strong>이름만 적으면 번호가 자동으로 붙게</strong> 바꿨다.</p>
<pre><code class="language-ts">export const CARD_OWNERS = [&#39;혁&#39;, &#39;경수&#39;, &#39;신영&#39;, &#39;동한&#39;] as const;

function assignCards(entries: readonly GameEntry[]): GameMeta[] {
  const countByOwner = new Map&lt;CardOwner, number&gt;();
  return entries.map((entry) =&gt; {
    if (!entry.owner) return entry; // owner 없으면 공통 게임 (맨 뒤)
    const nth = (countByOwner.get(entry.owner) ?? 0) + 1;
    countByOwner.set(entry.owner, nth);
    const ownerOrder = CARD_OWNERS.indexOf(entry.owner) + 1;
    return { ...entry, card: (nth - 1) * CARD_OWNERS.length + ownerOrder };
  });
}</code></pre>
<ul>
<li><code>owner</code> 가 <code>&#39;혁&#39; | &#39;경수&#39; | &#39;신영&#39; | &#39;동한&#39;</code> 타입이라 <strong>이름 오타는 typecheck 에서 걸린다</strong></li>
<li>사람마다 따로 세기 때문에 동시에 여러 명이 게임을 추가해도 번호가 겹치지 않는다</li>
<li>기존 게임에 적용해서 번호가 바뀌지 않는지 확인했다 (1·2·3·5·6·7·9 그대로)</li>
</ul>
<p>대신 규칙이 하나 생겼다. 등록 순서로 번호를 세기 때문에 <strong>새 게임은 항상 목록 맨 뒤에 추가</strong>해야 한다. 이 PR이 머지되자마자 신영 님의 캣 블레이드 PR(#61)이 새 방식(<code>owner: &#39;신영&#39;</code>, 맨 뒤 추가)으로 들어왔고, 자동으로 카드 11번이 붙었다. 바로 써먹은 셈.</p>
<hr>
<h2 id="3-방송-도구-섹션-신설-62">3. 방송 도구 섹션 신설 (#62)</h2>
<p>다음 아이템은 게임이 아니었다. <strong>인터넷 방송 스트리머들이 핀볼 룰렛이나 사다리타기로 시청자를 뽑는데, 이걸 대체할 추첨 도구</strong>를 만들고 싶었다.</p>
<p>문제는 사이트 구조였다. 지금은 모든 게임이 <code>onFinish(score)</code> → 점수 등록 → 랭킹 흐름을 따르는데, 추첨 도구에는 점수도 랭킹도 없다. 억지로 끼워 넣지 않고 <strong>게임과 나란히 서는 별도 구조</strong>를 만들었다.</p>
<ul>
<li><code>apps/web/src/tools/&lt;도구id&gt;/</code> + <code>tools/registry.ts</code> (props 없는 컴포넌트)</li>
<li><code>/tools/:toolId</code> 라우트 하나로 자동 생성 (도구별 라우트를 따로 만들지 않는 기존 원칙 유지)</li>
<li><code>ToolPage</code>: 게임 페이지와 같은 테마 틀에 랭킹 대신 <strong>전체 화면 버튼</strong> (OBS 창 캡처용)</li>
<li>메인 화면·시작 메뉴에 &quot;📺 방송 도구&quot; 섹션 (등록된 도구가 없으면 숨김)</li>
<li>DB·API·점수 상한은 필요 없음</li>
</ul>
<hr>
<h2 id="4-구슬-레이스-v1-그리고-핀볼-짝퉁-63">4. 구슬 레이스 v1… 그리고 &quot;핀볼 짝퉁&quot; (#63)</h2>
<p>방송 도구 1호로 <strong>구슬 레이스</strong>를 골랐다. 시청자 이름이 적힌 구슬 수십~수백 개가 코스를 굴러가고, 도착 순서로 당첨자를 뽑는다.</p>
<h3 id="신경-쓴-것-공정성">신경 쓴 것: 공정성</h3>
<p>추첨 도구라 <strong>결과를 조작할 수 없다는 게</strong> 가장 중요했다.</p>
<ul>
<li>매 판 <code>crypto.getRandomValues</code> 로 시드를 새로 뽑고, 코스와 출발 자리를 그 시드로 정한다</li>
<li>결과 화면에 &quot;추첨 번호 #시드&quot;를 보여 준다</li>
<li>물리 계산은 화면 프레임과 무관하게 <strong>1/60초 고정 스텝</strong>으로 돌린다. 그래서 배속을 바꾸거나 컴퓨터가 느려도 같은 시드면 같은 결과가 나온다</li>
</ul>
<h3 id="화면-없이-물리를-튜닝하는-법-헤드리스-시뮬레이션">화면 없이 물리를 튜닝하는 법: 헤드리스 시뮬레이션</h3>
<p>레이스 로직을 DOM 과 분리해 두니 <strong>Node 에서 레이스 수백 판을 그냥 돌려 볼 수 있었다</strong>. 구슬 2~300개로 레이스 시간, 시간 초과, 끼임 횟수를 쟀다. 이 과정에서 버그를 꽤 잡았다.</p>
<ul>
<li>지그재그 경사로 사이 틈(약 20)이 구슬 지름(22)보다 좁아서 끼던 문제</li>
<li>벽에 붙어 장애물 없이 내려가는 <strong>지름길</strong> 때문에 6초 만에 1등이 들어오던 문제 → 벽 방지턱 추가</li>
</ul>
<p>숫자상으로는 완벽했다. 그런데 배포 후 반응은…</p>
<blockquote>
<p>&quot;핀볼 짝퉁 같고 너무 저품질인데?&quot;</p>
</blockquote>
<p>맞는 말이었다. 원인은 두 가지였다.</p>
<ol>
<li><strong>구조가 핀볼 그대로</strong>: 위에서 떨어지고 핀에 튕기는 게 전부였다.</li>
<li><strong>화면을 한 번도 안 보고 만들었다</strong>: 작업 환경에서 브라우저가 안 떠서 숫자만 맞췄다. 나중에 찍어 보니 카메라가 선두만 쫓아가서 <strong>구슬 45개 중 1개만 빈 판을 혼자 떨어지는</strong> 화면이었다. 레이스가 아니라 공 하나 떨어지는 영상.</li>
</ol>
<hr>
<h2 id="5-구슬-레이스-v2-진짜-레이스로-66">5. 구슬 레이스 v2: 진짜 레이스로 (#66)</h2>
<h3 id="판을-바꿨다">판을 바꿨다</h3>
<p>세로 핀볼 판을 버리고 <strong>위에서 내려다본 서킷을 왼쪽 → 오른쪽으로 달리는</strong> 구조로 바꿨다.</p>
<ul>
<li>중력이 없으니 <strong>모든 구슬을 트랙 방향으로 똑같은 힘으로 민다</strong> (공정성 유지)</li>
<li>트랙은 사인파 몇 개를 겹쳐 매 판 무작위로 만든다. x 가 계속 오른쪽으로만 가게 해서 트랙이 자기 자신과 겹치지 않는다</li>
<li>커브가 너무 급하면(안쪽 벽이 꼬이면) 진폭을 줄여서 다시 만든다</li>
<li>구슬 위치는 &quot;트랙을 따라 간 거리(s) + 가운데선에서 벗어난 거리(lat)&quot;로 계산해서 순위를 매긴다</li>
</ul>
<h3 id="역전이-계속-나오게">역전이 계속 나오게</h3>
<ul>
<li><strong>가속 패드는 트랙 한쪽 절반에만</strong> 깐다 → 그쪽으로 간 구슬만 빨라져서 추월이 생긴다</li>
<li>진흙(감속), 구멍(빠지면 뒤로 되돌아감), 범퍼, 회전 바, 좁아지는 시케인 2곳</li>
</ul>
<h3 id="방송용-연출">방송용 연출</h3>
<ul>
<li>F1 식 출발 신호등 (빨간불 3개 → 출발)</li>
<li>&quot;🔥 ○○ 선두 탈환!&quot;, &quot;🕳️ ○○ 구멍에 빠졌다! (2위)&quot; 중계 자막 + 효과음</li>
<li>선두 무리 전체가 들어오게 확대·축소하며 따라가는 카메라, 미니맵, 남은 거리</li>
<li>결승 직전 1·2위가 붙어 있을 때만 <strong>슬로모션</strong> (위아래 검은 띠 + &quot;SLOW MOTION — 접전!&quot;)</li>
<li>시상대 🥇🥈🥉 + 종이가루</li>
<li>트랙 테마 4종(그린 서킷·사막 랠리·네온 나이트·스노우 컵)을 매 판 무작위로</li>
</ul>
<p>튜닝 결과 (Node 시뮬레이션, 구슬 2~300개)</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>첫 도착</td>
<td>40~51초</td>
</tr>
<tr>
<td>선두 교체</td>
<td>한 판에 3~11번</td>
</tr>
<tr>
<td>벽 이탈</td>
<td>0</td>
</tr>
<tr>
<td>슬로모션</td>
<td>대략 4판에 1번 (접전일 때만)</td>
</tr>
</tbody></table>
<h3 id="이번엔-화면을-보면서-만들었다">이번엔 화면을 보면서 만들었다</h3>
<p>v1 의 교훈대로 헤드리스 브라우저로 <strong>신호등 → 중반 → 후반 → 결과 → 휴대폰</strong>을 매번 찍어 보면서 고쳤다. 스크린샷에서 바로 보인 버그들은 다음과 같다.</p>
<p><strong>① 트랙 안이 잔디, 밖이 아스팔트로 칠해짐.</strong> 트랙 면을 &quot;왼쪽 가장자리 → 오른쪽 가장자리(역순)&quot;로 한 바퀴 그려야 하는데, 오른쪽을 그릴 때 <code>moveTo</code> 로 새로 시작해 버렸다. 그래서 다각형이 둘로 쪼개져 엉뚱한 곳이 칠해졌다.</p>
<pre><code class="language-ts">// 수정 후: 두 번째 가장자리는 앞 경로에 이어 그린다
private surface(points: TrackPoint[], extra: number): Path2D {
  const path = new Path2D();
  this.edge(points, (p) =&gt; p.w + extra, path);
  this.edge([...points].reverse(), (p) =&gt; -(p.w + extra), path, /* connect */ true);
  path.closePath();
  return path;
}</code></pre>
<p><strong>② 진흙이 커브에서 트랙 밖 잔디로 삐져나옴.</strong> 바닥 장치는 트랙 면(Path2D)으로 <code>clip</code> 해서 그리도록 했다.</p>
<p><strong>③ 무리 속 이름표가 겹쳐 안 읽힘.</strong> 앞선 구슬부터 자리를 잡고, 겹치면 위로 한두 칸 올린 뒤 구슬과 선으로 이어 줬다.</p>
<p><strong>④ 휴대폰에서 순위표가 한 줄로 찌그러짐.</strong> 고정 높이 그리드를 풀었다.</p>
<hr>
<h2 id="삽질-기록-다음의-나를-위해">삽질 기록 (다음의 나를 위해)</h2>
<p><strong>Squash merge + 이어진(stacked) PR = 충돌.</strong> #62 위에 #63 브랜치를 쌓아 올렸다. #62 가 squash 로 머지되면서 커밋이 새로 만들어졌고, #63 에는 원래 커밋이 남아 있어 GitHub 이 충돌로 표시했다. 실제 코드가 부딪힌 게 아니라서 아래 명령으로 정리했다.</p>
<pre><code class="language-bash">git rebase --onto main &lt;원래 #62 커밋&gt; feature/...
git push --force-with-lease</code></pre>
<p><strong>WSL 에서 <code>/mnt/c</code> 프로젝트는 vite 가 파일 변경을 못 본다.</strong> 고쳤는데 화면이 그대로라서 한참 헤맸다. dev 서버가 옛날 파일을 계속 주고 있었다. 수정 후엔 dev 서버를 재시작해야 한다. 게다가 <code>pkill -f &quot;vite --port&quot;</code> 로는 프로세스가 안 잡혔다(실제 이름이 <code>vite.js --port</code>). 결국 PID 로 직접 죽였다.</p>
<p><strong>sudo 없이 헤드리스 크롬 띄우기.</strong> 필요한 라이브러리가 없었는데 sudo 비밀번호를 넣을 수 없는 상황이었다. <code>apt-get download</code> 는 root 없이 되므로 패키지 파일만 받아 <code>dpkg -x</code> 로 풀고, <code>LD_LIBRARY_PATH</code> 로 경로를 지정해 해결했다. 이모지 폰트도 같은 방법으로 넣었다.</p>
<hr>
<h2 id="회고">회고</h2>
<ul>
<li><strong>숫자가 맞아도 화면이 구리면 끝이다.</strong> v1 은 시뮬레이션상 완벽했지만 &quot;핀볼 짝퉁&quot; 한 마디에 무너졌다. 그림이 중요한 기능은 직접 눈으로 보고 나서 완료라고 하기로.</li>
<li><strong>&quot;왜 굳이 이걸 쓰지?&quot;에 답이 있어야 한다.</strong> 핀볼 대체품이 핀볼처럼 생기면 의미가 없다. 구조부터 레이스로 바꾸니 그제야 다른 물건이 됐다.</li>
<li><strong>반복되는 규칙은 코드로.</strong> 카드 번호 계산, 초성 만들기처럼 사람이 매번 손으로 하던 일을 코드로 옮기니 실수할 여지가 사라졌다.</li>
</ul>
<h2 id="다음에-할-일">다음에 할 일</h2>
<ul>
<li>배포된 사이트에서 구슬 레이스 소리·전체 화면 버튼 확인</li>
<li>초성 퀴즈 점수 체계가 바뀌어서(최대 100 → 3440) 기존 랭킹 기록을 어떻게 할지 팀에서 결정</li>
<li>방송 도구 2호 아이디어 (이름 서바이벌? 폭탄 돌리기?)</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 115]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-115</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-115</guid>
            <pubDate>Wed, 30 Sep 2026 23:45:01 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="심심오락실-개발일지-윈도우-7·11-테마-물리-엔진으로-만든-의자-탑-쌓기-그리고-빈-카드-정리">[심심오락실 개발일지] 윈도우 7·11 테마, 물리 엔진으로 만든 의자 탑 쌓기, 그리고 빈 카드 정리</h1>
<blockquote>
<p>2026-10-01 · 심심오락실(simsim-arcade) 팀 프로젝트</p>
</blockquote>
<p>심심오락실은 예능식 퀴즈 게임과 추억의 플래시 스타일 게임을 웹에서 즐길 수 있게 만드는 팀 프로젝트입니다.
Cloudflare 하나로 웹(Pages), API(Workers), DB(D1)를 모두 운영하고, 팀원 4명이 웹 공통 작업과 &quot;1인 1게임&quot; 게임 카드를 나눠 맡고 있습니다.</p>
<p>오늘 제가 머지한 PR은 6개입니다.</p>
<table>
<thead>
<tr>
<th>PR</th>
<th>종류</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>#44</td>
<td>공통(프론트)</td>
<td>윈도우 7 · 11 테마 추가</td>
</tr>
<tr>
<td>#45</td>
<td>공통(프론트)</td>
<td>윈도우 11 테마 시작 버튼 · 글씨 대비 개선</td>
</tr>
<tr>
<td>#48</td>
<td>공통(프론트)</td>
<td>2D 물리 엔진 matter-js 추가</td>
</tr>
<tr>
<td>#49</td>
<td>게임 카드 9</td>
<td>의자 탑 쌓기</td>
</tr>
<tr>
<td>#51</td>
<td>게임 카드 9</td>
<td>의자 탑 쌓기 난이도 쉬움 · 어려움</td>
</tr>
<tr>
<td>#52</td>
<td>공통(프론트)</td>
<td>아직 등록하지 않은 카드 칸 숨기기</td>
</tr>
</tbody></table>
<hr>
<h2 id="1-윈도우-7-·-11-테마-추가-44">1. 윈도우 7 · 11 테마 추가 (#44)</h2>
<p>사이트에는 이미 XP · 98 · 클래식 세 가지 테마가 있었습니다.
XP와 98은 &quot;바탕화면 + 창 + 작업 표시줄 + 시작 메뉴&quot; 구조를 같이 쓰고, 스타일만 테마별 CSS 파일로 나눠 둔 구조입니다.</p>
<p>처음에는 10과 11 중 하나만 넣으려고 했습니다.</p>
<ul>
<li><strong>10</strong>은 네모난 평면 디자인이라 웹에 옮기면 평범한 웹사이트처럼 보이고, 기존 클래식 테마와 겹치는 느낌이었습니다.</li>
<li><strong>11</strong>은 둥근 모서리, 반투명 효과, 가운데 정렬 작업 표시줄이 있어서 XP·98 옆에 두어도 바로 구분됩니다.</li>
<li>사이트 콘셉트가 &quot;추억의 오락실&quot;이라 98 → XP → <strong>7</strong>(Aero 유리)로 이어지는 흐름도 잘 어울렸습니다.</li>
</ul>
<p>그래서 <strong>7과 11을 둘 다</strong> 넣었습니다.</p>
<p><strong>구현 방식</strong></p>
<ul>
<li><code>Theme</code> 타입에 <code>win7</code>, <code>win11</code>을 추가하면 타입 검사가 빠진 곳을 전부 알려 줍니다.</li>
<li><code>global.css</code>에 테마별 색 토큰(<code>--bg</code>, <code>--surface</code>, <code>--accent</code> 등)과 공통 버튼 스타일을 넣었습니다.</li>
<li>공통 컴포넌트 7개와 페이지 3개에 테마별 CSS 파일(<code>*.win7.module.css</code>, <code>*.win11.module.css</code>) 20개를 추가했습니다.</li>
<li>게임들은 색 토큰만 쓰도록 규칙을 정해 둔 덕분에 <strong>게임 폴더는 하나도 고치지 않고</strong> 새 테마를 자동으로 따라갔습니다.</li>
</ul>
<p><strong>7 테마</strong>는 짙은 파랑 위에 빛줄기가 퍼지는 바탕화면, 뒤가 흐리게 비치는 유리 창 테두리(<code>backdrop-filter: blur</code>), 둥근 시작 버튼(오브), 위아래 두 톤 광택 버튼으로 만들었습니다.
<strong>11 테마</strong>는 둥근 창, 가운데 정렬 작업 표시줄, 가운데에서 떠오르는 시작 메뉴, Fluent 스타일 버튼과 카드로 만들었습니다.</p>
<p>CSS 그라디언트만으로 바탕화면을 그려서 이미지 파일은 하나도 추가하지 않았습니다.</p>
<h2 id="2-11-테마-다듬기-45">2. 11 테마 다듬기 (#45)</h2>
<p>써 보니 11 테마에서 불편한 점이 두 가지 있었습니다.</p>
<ol>
<li><strong>시작 버튼인지 모르겠다</strong> — 실제 11처럼 아이콘(🕹️)만 두고 &quot;시작&quot; 글자는 화면 읽기 프로그램용으로 숨겼더니, 버튼처럼 보이지 않았습니다.
→ &quot;🕹️ 시작&quot; 글자가 보이는 파란 알약 모양 버튼으로 바꿨습니다.</li>
<li><strong>창 글씨가 잘 안 읽힌다</strong> — 확인해 보니 본문 배경은 원래 불투명했고, 실제 원인은 반투명 테두리와 흐린 회색 글씨의 낮은 대비였습니다.
→ 창의 흐림 효과를 빼서 불투명하게 하고, 흐린 글씨 색을 <code>#5f5f5f</code>에서 <code>#4a4a4a</code>로 진하게, 제목 글씨는 조금 키우고 굵게 했습니다.</li>
</ol>
<p>&quot;진짜 윈도우처럼&quot;보다 <strong>&quot;우리 사이트에서 쓰기 편하게&quot;</strong>가 먼저라는 걸 다시 느꼈습니다.</p>
<hr>
<h2 id="3-게임-카드-9--의자-탑-쌓기-48-49">3. 게임 카드 9 — 의자 탑 쌓기 (#48, #49)</h2>
<p>이번 제 세 번째 게임은 보드게임에서 의자를 쌓아 올리는 놀이를 <strong>옆에서 보는 2D 물리 게임</strong>으로 옮긴 것입니다.</p>
<h3 id="게임-규칙">게임 규칙</h3>
<ul>
<li>하늘에서 의자를 들고 좌우로 옮기고 돌린 다음 떨어뜨립니다.</li>
<li>의자는 실제 물리대로 기울고, 미끄러지고, 다리가 걸칩니다.</li>
<li>하나라도 받침대 아래로 떨어지면 끝. <strong>점수는 탑의 최고 높이(cm)</strong>입니다.</li>
<li>의자는 7종류(기본 · 스툴 · 바 의자 · 벤치 · 등받이 높은 의자 · 팔걸이 의자 · 흔들의자)입니다.</li>
<li>조작: 키보드(←/→ 이동, ↑·X/Z 회전, 스페이스 떨어뜨리기), 마우스(이동 · 휠 회전 · 클릭), 휴대폰(끌어서 이동 + 아래 버튼)</li>
</ul>
<h3 id="물리-엔진은-왜-matter-js인가">물리 엔진은 왜 matter-js인가</h3>
<p>다리가 달린 울퉁불퉁한 의자가 기울고 걸치는 걸 직접 계산하기는 어렵습니다. 탑이 덜덜 떨리거나 서로 뚫고 지나가는 버그가 생기기 쉽습니다.
그래서 2D 물리 엔진 중 가장 많이 쓰이고, 여러 도형을 합친 모양(복합 물체)을 지원하는 <strong>matter-js</strong>를 골랐습니다.</p>
<p>다만 우리 팀 규칙상 게임 PR에서는 <strong>내 게임 폴더와 등록 줄만</strong> 고칠 수 있고, <code>package.json</code>이나 lockfile 같은 공통 파일은 건드릴 수 없습니다.
그래서 순서를 나눴습니다.</p>
<ol>
<li><code>feature/fe-add-matter-js</code> — 라이브러리만 추가하는 공통 PR (#48)</li>
<li><code>feature/game-chair-stack</code> — 게임 PR (#49). 처음엔 #48 위에서 작업하다가 #48이 머지된 뒤 <code>git rebase --onto</code>로 최신 main 위로 옮겼습니다.</li>
</ol>
<p>게임은 <code>React.lazy</code>로 따로 불러오기 때문에 matter-js는 <strong>게임 코드에만 들어가고</strong>(gzip 약 32KB) 메인 화면 번들 크기에는 영향이 없습니다.</p>
<h3 id="의자를-부품으로-조립하기">의자를 부품으로 조립하기</h3>
<p>의자는 옆에서 본 모습을 직사각형 부품 여러 개로 정의하고, matter-js의 <code>Body.create({ parts })</code>로 하나의 물체로 묶었습니다.</p>
<pre><code class="language-ts">{
  id: &#39;basic&#39;,
  name: &#39;기본 의자&#39;,
  parts: [
    { x: 0, y: 0, w: 56, h: 7 },             // 앉는 판
    { x: -24.5, y: -28, w: 7, h: 49 },       // 등받이
    { x: -24, y: 22, w: 6, h: 37, dark: true }, // 다리
    { x: 24, y: 22, w: 6, h: 37, dark: true },
  ],
}</code></pre>
<p>월드 좌표 1 = 1cm로 잡아서, 점수(높이)를 그대로 cm로 보여 줄 수 있게 했습니다.
그리기는 matter-js 렌더러 대신 캔버스에 직접 부품 꼭짓점을 그려서, 의자 색과 높이 눈금, 최고 높이 점선, 다음 의자 미리보기를 자유롭게 넣었습니다.</p>
<h3 id="트러블슈팅-똑바로-떨어뜨렸는데-의자가-넘어진다">트러블슈팅: 똑바로 떨어뜨렸는데 의자가 넘어진다</h3>
<p>화면을 띄우기 전에 <strong>Node에서 물리만 따로 시뮬레이션</strong>해 보는 테스트를 만들었습니다.
의자를 받침대 가운데에 똑바로 떨어뜨리고, 멈출 때까지 돌린 뒤 기울기와 높이를 출력하는 방식입니다.</p>
<pre><code>single basic     settle=300f tilt=-160° height=-785cm FELL
single stool     settle= 98f tilt=   0° height=46cm
single highback  settle=300f tilt=-401° height=-2202cm FELL</code></pre><p>기본 의자와 등받이 높은 의자가 <strong>아무것도 안 했는데 넘어져서 떨어졌습니다.</strong>
프레임별로 기울기를 찍어 보니, 착지한 뒤 각도가 0.5° → 3° → 4.7° → -1.3° → … → -35°처럼 <strong>흔들림이 점점 커지다가</strong> 넘어지고 있었습니다.</p>
<p>원인은 부품 모서리를 둥글게 깎는 옵션(<code>chamfer</code>)이었습니다.
폭 6cm짜리 가는 다리 끝을 둥글게 깎으니 <strong>다리 끝이 흔들의자처럼 굴러다녔던</strong> 것입니다.</p>
<p>고친 내용은 세 가지입니다.</p>
<ul>
<li><code>chamfer</code> 제거</li>
<li>회전 관성 4배 (<code>Body.setInertia</code>) — 덜덜 떨림이 커지지 않게</li>
<li>약간의 공기 저항(<code>frictionAir: 0.02</code>)과 반발 계수 0</li>
</ul>
<pre><code>single basic     settle=103f tilt=   0° height=93cm
single highback  settle=102f tilt=   0° height=113cm</code></pre><p>이후 7종류 모두 혼자 똑바로 섰고, 조준 없이 가운데에만 계속 떨어뜨리면 3~6개쯤에서 무너지는 정도의 난이도가 나왔습니다.
캔버스·<code>requestAnimationFrame</code>·<code>ResizeObserver</code>를 가짜로 채워서 <strong>엔진을 한 판 끝까지(게임 오버 → 다시 시작) 돌리는 스모크 테스트</strong>도 같이 돌렸습니다.</p>
<h2 id="4-난이도-쉬움-·-어려움-51">4. 난이도 쉬움 · 어려움 (#51)</h2>
<p>의자 모양이 매번 바뀌니 처음 하는 사람에게는 꽤 어려웠습니다. 그래서 난이도를 나눴습니다.</p>
<table>
<thead>
<tr>
<th>난이도</th>
<th>의자</th>
<th>점수</th>
</tr>
</thead>
<tbody><tr>
<td>쉬움</td>
<td>모두 같은 기본 의자 (색만 무작위)</td>
<td>최고 높이(cm)</td>
</tr>
<tr>
<td>어려움</td>
<td>7가지 의자가 무작위</td>
<td>최고 높이 × 1.5</td>
</tr>
</tbody></table>
<p>랭킹은 게임마다 하나뿐이라, 배율이 없으면 쉬움이 같은 랭킹에서 유리해집니다.
그래서 국기 퀴즈의 난이도 배율처럼 <strong>어려움에 ×1.5</strong>를 줬고, 결과 화면에 &quot;어려움 · 최고 높이 109cm × 1.5&quot;처럼 점수 계산 과정을 같이 보여 주도록 했습니다.</p>
<hr>
<h2 id="5-아직-등록하지-않은-카드-칸-숨기기-52">5. 아직 등록하지 않은 카드 칸 숨기기 (#52)</h2>
<p>우리 팀은 게임 카드 번호를 순서대로 돌아가며 받습니다(혁 1·5·9, 경수 2·6·10 …).
지금까지 메인 화면은 번호 칸을 미리 만들어 두고, 게임이 없는 칸은 &quot;🚧 준비 중&quot;으로 보여 줬습니다.</p>
<p>하지만 번호는 이미 정해져 있으니 <strong>등록하면 그때 보여 주면 되는</strong> 일이었습니다.</p>
<ul>
<li>메인 화면과 시작 메뉴 모두 <strong>등록된 게임만</strong> 카드 번호 순서대로 보여 주도록 바꿨습니다.</li>
<li>두 곳이 같은 순서를 쓰도록 <code>registry.ts</code>에 <code>GAMES_BY_CARD</code>를 만들었습니다.</li>
<li>더 이상 쓰지 않는 &quot;준비 중&quot; 카드 컴포넌트, 5개 테마의 CSS, 빈 칸 계산 코드, Q&amp;A 항목을 지웠습니다.</li>
<li>팀 규칙 문서(CLAUDE.md)와 README 설명도 같이 고쳤습니다.</li>
</ul>
<p>결과적으로 <strong>추가 17줄, 삭제 224줄</strong>. 기능을 줄이면서 코드도 줄어드는 작업은 언제나 기분이 좋습니다.</p>
<hr>
<h2 id="6-덤-액션에서-멈췄어요-확인">6. 덤: &quot;액션에서 멈췄어요&quot; 확인</h2>
<p>팀원 PR(#50)이 머지된 뒤 GitHub Actions의 CI가 &quot;cancelled&quot;로 표시되어 확인해 봤습니다.</p>
<ul>
<li>#50이 머지되고 <strong>10초 뒤</strong> 제 #51이 머지됐습니다.</li>
<li><code>ci.yml</code>이 같은 브랜치의 이전 실행을 취소하도록(<code>cancel-in-progress: true</code>) 설정되어 있어서 #50의 CI가 취소된 것이었습니다.</li>
<li>#51 이후의 <code>main</code> CI는 #50의 코드까지 포함해서 <strong>성공</strong>했고, Cloudflare Pages 배포도 두 커밋 모두 <strong>성공</strong>했습니다.</li>
</ul>
<p>실패가 아니라 취소였고, 다시 돌릴 필요는 없었습니다.
다만 머지가 몰릴 때마다 헷갈릴 수 있어서, <code>main</code>에서는 끝까지 돌리고 PR에서만 취소하도록 바꾸는 것도 검토해 볼 만합니다.</p>
<hr>
<h2 id="오늘-배운-것">오늘 배운 것</h2>
<ul>
<li><strong>규칙이 확장을 쉽게 만든다.</strong> &quot;게임은 색 토큰만 쓴다&quot;는 규칙 덕분에 테마 두 개를 추가하면서 게임 코드는 한 줄도 안 고쳤습니다.</li>
<li><strong>물리는 화면보다 숫자로 먼저 본다.</strong> 의자가 넘어지는 문제는 화면으로 봤으면 &quot;원래 그런가?&quot; 하고 넘어갔을 수도 있는데, 시뮬레이션 숫자로 보니 원인(굴러가는 다리 끝)을 바로 좁힐 수 있었습니다.</li>
<li><strong>공통 파일과 게임 파일은 PR을 나눈다.</strong> 라이브러리 추가 PR을 먼저 분리하니 리뷰도 쉽고, 게임 PR은 &quot;게임 파일 + 등록 줄&quot;만 남아 깔끔했습니다.</li>
<li><strong>따라 하기보다 쓰기 편하게.</strong> 11 테마의 아이콘만 있는 시작 버튼은 원본에 충실했지만 사용자에게는 불편했습니다.</li>
</ul>
<h2 id="다음에-할-일">다음에 할 일</h2>
<ul>
<li>CLAUDE.md &quot;게임 카드 담당&quot; 표에 세 번째 게임 열(카드 9~12) 추가</li>
<li>CI의 <code>cancel-in-progress</code>를 PR에서만 동작하도록 조정 검토</li>
<li>의자 탑 쌓기 실제 플레이 피드백 받아 흔들림 · 난이도 조정</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 114]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-114</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-114</guid>
            <pubDate>Tue, 29 Sep 2026 23:50:48 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="심심오락실-개발-일지--2026-09-30">심심오락실 개발 일지 — 2026-09-30</h1>
<blockquote>
<p>예능식 퀴즈와 추억의 플래시 게임을 웹에서 즐기는 <strong>심심오락실</strong>(simsim-arcade) 팀 프로젝트.
Cloudflare Pages + Workers + D1, React + Hono, pnpm 모노레포로 만들고 있다.
오늘은 <strong>힌트 퀴즈</strong>를 크게 손보고, 두 번째 게임 <strong>국기 퀴즈</strong>를 새로 만들었다.</p>
</blockquote>
<h2 id="오늘-한-일-한눈에-보기">오늘 한 일 한눈에 보기</h2>
<table>
<thead>
<tr>
<th>PR</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>#31</td>
<td>힌트 퀴즈 — 외국 선수 이름을 국내 통용 표기로 보정, 발음 기호 입력 인정</td>
</tr>
<tr>
<td>#32</td>
<td>게임 카드 번호를 4명이 돌아가며 자동 배정</td>
</tr>
<tr>
<td>#33</td>
<td>축구선수 문제를 FC온라인 인기 선수 위주로 교체</td>
</tr>
<tr>
<td>#34</td>
<td>한 글자 성(요나탄 <strong>타</strong>, 루크 <strong>쇼</strong>)도 부분 정답 인정</td>
</tr>
<tr>
<td>#35</td>
<td>힌트 퀴즈에 ⚾ 야구선수(KBO) 장르 추가</td>
</tr>
<tr>
<td>#36</td>
<td>🏳️ 새 게임 <strong>국기 퀴즈</strong> (카드 5)</td>
</tr>
<tr>
<td>#38</td>
<td>힌트 퀴즈 동물·음식 50문제씩, 나라 195문제로 확대</td>
</tr>
</tbody></table>
<p>(#37 스카이 에이스는 팀원 신영의 새 게임 — 오늘 main 에 같이 들어왔다)</p>
<hr>
<h2 id="1-어제-남긴-이슈부터--외국-선수-이름-표기-31">1. 어제 남긴 이슈부터 — 외국 선수 이름 표기 (#31)</h2>
<p>힌트 퀴즈 축구선수 문제는 위키데이터에서 자동으로 만든다. 그런데 정답이 위키데이터 한국어 이름 그대로라
<strong>&quot;하칸 칼하노글루&quot;</strong> 처럼 국내에서 잘 안 쓰는 표기가 정답으로 나오는 문제가 있었다. (보통은 &quot;찰하놀루&quot;)</p>
<ul>
<li>수집 스크립트에 <strong>표기 보정 목록</strong>(위키데이터 ID → 통용 표기)을 만들어 78명을 고쳤다.
원래 이름은 별칭으로 남겨서 둘 다 정답으로 인정된다.</li>
<li>영문 입력도 문제였다. <code>Çalhanoğlu</code>, <code>Modrić</code>, <code>Özil</code> 처럼 발음 기호가 붙은 이름이 별칭 필터에 걸려 빠지고 있었다.
→ 발음 기호를 떼서 별칭에 넣고, 정답 비교 함수에도 같은 규칙을 넣었다.</li>
</ul>
<pre><code class="language-ts">// 라틴 문자만 바꾼다 — 문자열 전체를 NFD 로 바꾸면 한글이 자모로 풀려 버린다
text.replace(/[À-ɏḀ-ỿ]/g, (ch) =&gt;
  ch.normalize(&#39;NFD&#39;).replace(/[̀-ͯ]/g, &#39;&#39;));</code></pre>
<blockquote>
<p>배운 점: <code>normalize(&#39;NFD&#39;)</code> 를 한글이 섞인 문자열 전체에 걸면 &quot;가&quot; 가 &quot;ㄱ+ㅏ&quot; 로 쪼개진다. 범위를 좁혀서 써야 한다.</p>
</blockquote>
<h2 id="2-게임-카드-번호-규칙-32">2. 게임 카드 번호 규칙 (#32)</h2>
<p>팀원 4명이 1인 1게임으로 카드 1~4번을 맡고 있었는데, 이제 두 번째 게임을 만들 차례.
몇 개를 추가하든 자기 번호가 정해지도록 <strong>4명이 돌아가며 번호를 받는 규칙</strong>으로 바꿨다.</p>
<p><strong>내 n번째 게임의 카드 번호 = (n − 1) × 4 + 내 순번</strong></p>
<table>
<thead>
<tr>
<th>순번</th>
<th>담당</th>
<th>카드 번호</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>혁</td>
<td>1, 5, 9, …</td>
</tr>
<tr>
<td>2</td>
<td>경수</td>
<td>2, 6, 10, …</td>
</tr>
<tr>
<td>3</td>
<td>신영</td>
<td>3, 7, 11, …</td>
</tr>
<tr>
<td>4</td>
<td>동한</td>
<td>4, 8, 12, …</td>
</tr>
</tbody></table>
<p>메인 화면 카드 칸도 고정 4칸 대신, 등록된 가장 큰 번호가 속한 4장 묶음까지 자동으로 생기게 했다.
(5번이 등록되면 5~8 칸이 생기고 비어 있는 칸은 &quot;준비 중&quot;)</p>
<h2 id="3-축구선수-문제를-요즘-선수-위주로-33-34">3. 축구선수 문제를 &quot;요즘 선수&quot; 위주로 (#33, #34)</h2>
<p>문제를 풀어 보니 <strong>옛날 레전드 선수가 너무 많았다.</strong> 위키백과 문서 수로 유명도를 매기다 보니
1979년 이전 출생이 36%나 됐다.</p>
<p>그래서 <strong>FC온라인에서 인기 있는 선수</strong>를 기준으로 바꿨다.</p>
<ul>
<li>넥슨 Open API 가 공개하는 FC온라인 선수 목록(약 5만 명)을 쓰고,
<strong>특수 시즌 카드가 많을수록 인기 선수</strong>로 봤다. 25 LIVE 카드가 없으면 은퇴 선수로 보고 10%까지만.</li>
<li>정답도 <strong>FC온라인 표기</strong>로 바꿨다. (리로이 사네, 존 스톤스, 레반도프스키 …)</li>
<li>결과: 1979년 이전 출생 <strong>36% → 6%</strong>, 1995년 이후 출생 <strong>13% → 54%</strong></li>
</ul>
<p>제일 까다로웠던 건 <strong>두 데이터를 잇는 일</strong>이었다. 위키데이터에 EA 선수 ID 가 없어서 한국어 이름으로 맞춰야 했다.</p>
<ul>
<li>정확히 같은 이름 → 성만 같은 이름(레반도프스키 → 로베르트 레반도프스키) → 표기만 조금 다른 이름(사네 ↔ 자네) 순서로 연결</li>
<li>그래도 한 단어 이름은 엉뚱하게 이어졌다. &quot;사울&quot; → 로드리고 데 폴, &quot;마르키뉴스&quot; → 아스널 유스 선수, &quot;김도연&quot; → 여자 선수…
→ 연결 결과를 <strong>직접 검토</strong>해서 틀린 것들을 수동 연결 목록으로 바로잡고, 남자 선수로 한정했다.</li>
<li>FC온라인 이름이 &quot;T. 알렉산더-아놀드&quot; 처럼 이니셜이면 위키데이터 이름을 붙여 &quot;트렌트 알렉산더-아놀드&quot; 로 만들었다.</li>
<li>화면에는 넥슨 Open API 약관에 따라 <strong>&quot;Data based on NEXON Open API&quot;</strong> 출처를 표시했다.</li>
</ul>
<p>후속으로 &quot;요나탄 <strong>타</strong>&quot;, &quot;루크 <strong>쇼</strong>&quot; 처럼 성이 한 글자인 선수는 성만 입력하면 오답이던 것도 고쳤다 (#34).
두 글자 이상 성(&quot;포든&quot;)은 되는데 한 글자만 안 되는 게 어색했다. 앞쪽 한 글자 이름(&quot;존&quot;, &quot;벤&quot;)은 너무 흔해서 계속 오답.</p>
<blockquote>
<p>배운 점: 이름으로 두 데이터를 잇는 건 생각보다 틀리기 쉽다. <strong>자동 연결 결과를 사람이 검토하는 단계</strong>가 꼭 필요했다.</p>
</blockquote>
<h2 id="4-야구선수kbo-장르-추가-35">4. 야구선수(KBO) 장르 추가 (#35)</h2>
<p>축구와 같은 방식으로 위키데이터에서 KBO 선수 295명을 만들었다.</p>
<ul>
<li>이번엔 유명도 기준으로 <strong>한국어 위키백과 최근 1년 조회수</strong>를 썼다.
위키백과 문서 수는 최대 20개라 변별력이 없고, 오히려 외국인·은퇴 선수가 위로 올라왔다.
조회수로 바꾸니 류현진, 이정후, 안현민, 폰세처럼 <strong>요즘 화제인 선수</strong>가 위로 왔다.</li>
<li>힌트: 국적 → 출생 연도 → 포지션 → KBO 소속팀 → 출신 학교 → 다른 소속팀(MLB·NPB 포함)</li>
<li>신장은 2,400여 명 중 336명만 자료가 있어서 뺐다.</li>
</ul>
<p>작은 함정도 하나 있었다. 축구 시드가 &quot;위키데이터로 만든 문제&quot;를 <strong>전부</strong> 지우고 다시 넣고 있었는데,
시드는 파일 이름 순서로 적용되니 <code>hint-quiz-baseball.sql</code> 이 먼저 들어가고 <code>hint-quiz-football.sql</code> 이 <strong>야구 문제를 지워 버린다.</strong>
→ 각 시드가 <strong>자기 장르만</strong> 지우도록 삭제 조건에 장르를 추가했다.</p>
<h2 id="5-새-게임-국기-퀴즈-🏳️-36">5. 새 게임: 국기 퀴즈 🏳️ (#36)</h2>
<p>내 두 번째 게임 (카드 5).</p>
<ul>
<li>모드: <strong>국기 → 나라</strong>, <strong>나라 → 수도</strong></li>
<li>10문제 4지선다, 문제당 10초 (3초 안에 맞히면 100점, 늦을수록 줄어 최소 50점)</li>
<li>난이도: 쉬움(잘 알려진 나라 61개) / 전체(195개국, 점수 ×1.5)</li>
<li>오답 보기는 <strong>같은 대륙</strong>에서 골라서 헷갈리게</li>
</ul>
<p>신경 쓴 부분</p>
<ul>
<li><strong>국기 이모지는 윈도우에서 &quot;KR&quot; 같은 글자로 보인다.</strong> 그래서 국기 그림은 flag-icons(MIT) SVG 를 게임 폴더에 넣었다.
Vite 의 <code>import.meta.glob</code> 으로 불러서 화면에 나올 때만 내려받는다.</li>
<li>나라·수도 데이터는 위키데이터에서 한 번 뽑아 다듬었다.<ul>
<li>&quot;조선민주주의인민공화국 → 북한&quot;, &quot;서울특별시 → 서울&quot;</li>
<li>수도가 여럿인 나라는 대표 수도 하나 (인도네시아 자카르타, 볼리비아 수크레)</li>
<li>수도 모드에서 뺀 나라: 수도 논란(이스라엘·팔레스타인), 수도 이름에 나라 이름이 들어 있어 너무 쉬운 곳(싱가포르, 멕시코시티)</li>
</ul>
</li>
<li>헤드리스 Chromium 으로 한 판을 끝까지 돌려서 정답·오답·시간 초과·점수 등록·모바일 화면까지 확인했다.</li>
</ul>
<h2 id="6-힌트-퀴즈-나머지-장르-확대-38">6. 힌트 퀴즈 나머지 장르 확대 (#38)</h2>
<table>
<thead>
<tr>
<th>장르</th>
<th>전</th>
<th>후</th>
<th>방식</th>
</tr>
</thead>
<tbody><tr>
<td>🐾 동물</td>
<td>8</td>
<td>50</td>
<td>직접 작성</td>
</tr>
<tr>
<td>🍜 음식</td>
<td>6</td>
<td>50</td>
<td>직접 작성 (한식 22 · 세계 음식 22)</td>
</tr>
<tr>
<td>🌏 나라</td>
<td>8</td>
<td>195</td>
<td>위키데이터 자동 생성</td>
</tr>
</tbody></table>
<ul>
<li>동물·음식은 위키데이터로 만들면 &quot;포유류 / 식육목 / 학명&quot; 같은 딱딱한 힌트만 나와서 <strong>직접 썼다.</strong>
예) 하마 — &quot;이름의 뜻은 &#39;강의 말&#39;&quot;, 나무늘보 — &quot;일주일에 한 번쯤 땅에 내려와 똥을 눈다&quot;</li>
<li>나라 힌트: 대륙 → 인구 → 면적(순위) → 이웃 나라 → 공용어 → 수도.
힌트 속 나라 이름은 가렸다 (&quot;프랑스어&quot; → &quot;○○○어&quot;).</li>
</ul>
<p>그리고 테스트에서 재밌는(?) 버그를 찾았다. 외래어 표기 차이를 봐주려고 만든 <strong>&quot;비슷한 표기도 정답&quot;</strong> 규칙 때문에</p>
<ul>
<li>&quot;이란&quot; 을 쓰면 <strong>이라크</strong>가 정답</li>
<li>&quot;감비아&quot; 를 쓰면 <strong>잠비아</strong>가 정답</li>
<li>&quot;기니&quot; 를 쓰면 <strong>적도 기니</strong>가 정답</li>
</ul>
<p>…이런 게 14건이나 나왔다. 선수 이름에서는 유용한 규칙이 나라 이름에서는 독이 됐다.
→ <strong>나라 장르는 정확히 일치(별칭 포함)만 인정</strong>하고, 대신 &quot;한국·남한·남아공·usa·uk&quot; 같은 줄임말을 별칭으로 넣었다.</p>
<blockquote>
<p>배운 점: 같은 판정 규칙이라도 <strong>데이터 성격에 따라 맞고 틀리다.</strong> 장르를 추가할 때마다 &quot;다른 문제의 정답이 이 문제에서 정답 처리되는지&quot; 교차 검사를 돌린 게 큰 도움이 됐다.</p>
</blockquote>
<hr>
<h2 id="정리">정리</h2>
<ul>
<li>힌트 퀴즈: 축구 1,000 · 야구 295 · 나라 195 · 동물 50 · 음식 50 문제</li>
<li>새 게임: 국기 퀴즈 (카드 5)</li>
<li>팀 규칙: 게임 카드 번호 자동 배정</li>
</ul>
<p>오늘의 교훈 세 줄</p>
<ol>
<li>공식 자료 기준 ≠ 사람들이 흥미를 느끼는 기준. 인기 지표(게임 카드 수, 위키 조회수)를 따로 찾아야 했다.</li>
<li>이름으로 데이터를 잇는 건 자동화 + 사람 검토 조합이 답.</li>
<li>판정 규칙은 장르마다 따로 검증해야 한다.</li>
</ol>
<p>다음엔 팀원들의 두 번째 게임이 들어올 차례!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 113]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-113</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-113</guid>
            <pubDate>Mon, 28 Sep 2026 23:55:15 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="🕹️-심심오락실-개발기-2--윈도우-xp·98-테마-그리고-축구선수-1000명-힌트-퀴즈">🕹️ 심심오락실 개발기 #2 — 윈도우 XP·98 테마, 그리고 축구선수 1000명 힌트 퀴즈</h1>
<blockquote>
<p>지난 글에서 &quot;게임을 하나씩 쉽게 추가할 수 있는 뼈대&quot;를 만들었다면,
이번에는 그 뼈대 위에 <strong>사이트 분위기(테마)</strong> 를 입히고, 제 담당인 <strong>카드 1번 게임 — 힌트 퀴즈</strong> 를 만들었습니다.
하루 동안 PR 11개를 머지하면서 겪은 시행착오까지 정리해 봤습니다.</p>
</blockquote>
<p>🌐 <strong><a href="https://simsim-arcade.pages.dev">https://simsim-arcade.pages.dev</a></strong></p>
<hr>
<h2 id="1-오늘-한-일-한눈에-보기">1. 오늘 한 일 한눈에 보기</h2>
<table>
<thead>
<tr>
<th>PR</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>#10 · #13</td>
<td>README 정리 — 배포 배지·링크, 저장소 설정, <strong>프로젝트 목표</strong></td>
</tr>
<tr>
<td>#12</td>
<td>🪟 <strong>윈도우 XP 테마</strong> + 클래식 테마 전환</td>
</tr>
<tr>
<td>#15</td>
<td>메인 화면 <strong>카드 4칸</strong> + &quot;준비 중&quot; 카드</td>
</tr>
<tr>
<td>#16</td>
<td>XP <strong>시작 메뉴</strong> + <strong>Q&amp;A 페이지</strong></td>
</tr>
<tr>
<td>#19</td>
<td>🎯 <strong>힌트 퀴즈</strong> 게임 추가 (카드 1)</td>
</tr>
<tr>
<td>#20</td>
<td>문제 조회 API 에 <strong>장르(category) 필터</strong></td>
</tr>
<tr>
<td>#22</td>
<td>장르 선택 + <strong>축구선수 1000명</strong> (위키데이터)</td>
</tr>
<tr>
<td>#24</td>
<td>난이도 완화 + <strong>부분·유사 정답</strong> 인정</td>
</tr>
<tr>
<td>#25</td>
<td><strong>문항 수 선택</strong> + 문제당 제한시간 + 시간 보너스 점수</td>
</tr>
<tr>
<td>#26</td>
<td>🖥️ <strong>윈도우 98 테마</strong> + 테마 3종 선택</td>
</tr>
</tbody></table>
<p>같은 날 팀원(카드 3번)의 <strong>과일 슬라이서</strong>도 머지되면서, 메인 화면에 드디어 게임이 여러 개 생겼습니다. 🍉</p>
<hr>
<h2 id="2-이-프로젝트의-목표">2. 이 프로젝트의 목표</h2>
<p>README 에 프로젝트 목표를 한 줄로 못 박아 두었습니다.</p>
<blockquote>
<p><strong>팀원 각자가 게임 하나를 맡아 처음부터 끝까지 직접 만들면서, 풀스택 개발자로서의 역량을 확인하는 프로젝트</strong></p>
</blockquote>
<ul>
<li><strong>1인 1게임</strong> — 화면, 게임 로직, 점수 계산, 문제 데이터(DB 시드), 서버 점수 검증까지 혼자</li>
<li><strong>공통 기반은 역할대로 함께</strong> — 레이아웃·공통 컴포넌트·API·DB 스키마는 프론트/백엔드로 나눠서</li>
<li><strong>실제 서비스처럼 운영</strong> — PR 리뷰, CI, <code>main</code> 머지 = 자동 배포</li>
</ul>
<hr>
<h2 id="3-저장소-운영--팀원-권한과-main-보호">3. 저장소 운영 — 팀원 권한과 main 보호</h2>
<p>팀원 초대는 GitHub 저장소 <strong>Write 권한</strong>이면 충분했습니다.
브랜치 push, PR, 리뷰 승인, 머지, 태그까지 다 되고, 설정·Secrets 만 관리자 몫입니다.</p>
<p>대신 <strong>main 보호 규칙(Ruleset)</strong> 이 있어야 규칙이 &quot;말&quot;이 아니라 &quot;강제&quot;가 됩니다.</p>
<table>
<thead>
<tr>
<th>규칙</th>
<th>설정</th>
</tr>
</thead>
<tbody><tr>
<td>PR 필수</td>
<td>main 직접 push 불가</td>
</tr>
<tr>
<td>리뷰 승인</td>
<td>1명 이상</td>
</tr>
<tr>
<td>필수 체크</td>
<td>CI(typecheck · lint · format · build) 통과</td>
</tr>
<tr>
<td>머지 방식</td>
<td>Squash and merge 만</td>
</tr>
<tr>
<td>강제 push / 삭제</td>
<td>금지</td>
</tr>
</tbody></table>
<blockquote>
<p>💡 점검해 보니 관리자 계정이 Ruleset 우회 목록에 &quot;Always&quot;로 들어가 있었습니다.
main 에 올라가는 순간 운영 배포 + 운영 DB 시드가 도는 구조라, 우회 권한은 꼭 필요한 만큼만 두는 게 안전합니다.</p>
</blockquote>
<hr>
<h2 id="4-🪟-윈도우-xp-·-🖥️-98-테마--그리고-테마-전환">4. 🪟 윈도우 XP · 🖥️ 98 테마 — 그리고 테마 전환</h2>
<p>&quot;추억의 오락실&quot; 콘셉트에 맞춰 사이트 전체를 <strong>윈도우 XP 느낌</strong>으로 바꿔 봤습니다.</p>
<ul>
<li>푸른 하늘 + 초록 언덕 바탕화면 (이미지 없이 <strong>CSS 그라디언트로만</strong>)</li>
<li>파란 제목 표시줄과 최소화·최대화·닫기 버튼이 있는 <strong>창(Window) 공통 컴포넌트</strong></li>
<li>하단 <strong>작업 표시줄</strong> — 시작 버튼, 열린 창 버튼, 시계</li>
<li>XP 기본 버튼, 초록 블록 진행 막대, 파란 선택색 랭킹</li>
</ul>
<blockquote>
<p>로고나 공식 배경 사진 같은 저작권 있는 이미지는 쓰지 않고, 분위기만 CSS 로 재현했습니다.</p>
</blockquote>
<h3 id="이전-디자인도-살리고-싶어서-→-테마-전환">이전 디자인도 살리고 싶어서 → 테마 전환</h3>
<p>XP 가 마음에 들었지만 처음 디자인도 버리기 아까워서 <strong>두 테마를 버튼 하나로 전환</strong>하게 만들었습니다.</p>
<pre><code class="language-html">&lt;html data-theme=&quot;xp&quot;&gt;   &lt;!-- 또는 classic --&gt;</code></pre>
<ul>
<li>테마별 <strong>색·모서리 토큰</strong>은 <code>global.css</code> 에서 <code>:root</code> / <code>:root[data-theme=&#39;xp&#39;]</code> 로 나눔</li>
<li>공통 컴포넌트는 <code>Xxx.classic.module.css</code> / <code>Xxx.xp.module.css</code> 로 스타일 분리</li>
<li>선택한 테마는 <code>localStorage</code> 에 저장, <code>index.html</code> 에서 <strong>렌더링 전에 먼저 적용</strong>해 깜빡임 방지</li>
</ul>
<p>가장 좋았던 점은 <strong>게임 담당자가 따로 할 일이 없다</strong>는 것입니다.
게임 CSS 에서 <code>var(--accent)</code>, <code>var(--surface)</code> 같은 토큰만 쓰면 두 테마를 자동으로 따라갑니다.</p>
<h3 id="시작-메뉴와-qa">시작 메뉴와 Q&amp;A</h3>
<p>XP 모드의 [시작] 버튼을 누르면 진짜 시작 메뉴처럼 열립니다.</p>
<pre><code>┌──────────────────────────────┐
│ 🕹️ 심심오락실                  │
├───────────────┬──────────────┤
│ 게임 목록       │ 🏠 게임 목록   │
│ (registry 자동) │ ❓ Q&amp;A · 도움말 │
│               │ 📁 프로젝트 소개 │
├───────────────┴──────────────┤
│              🎨 클래식 테마로 바꾸기 │
└──────────────────────────────┘</code></pre><p>게임 목록은 <code>registry.ts</code> 에서 자동으로 만들어져서, 게임이 추가되면 메뉴에도 알아서 들어갑니다.</p>
<h3 id="🖥️-내친김에-윈도우-98-까지">🖥️ 내친김에 윈도우 98 까지</h3>
<p>XP 가 있으니 98 도 빠질 수 없죠. 하루의 마지막 작업으로 <strong>세 번째 테마</strong>를 추가했습니다.</p>
<ul>
<li>청록색(#008080) 단색 바탕화면</li>
<li>회색 <strong>입체 테두리</strong> 창 + 남색 → 파랑 그라디언트 제목 표시줄 + 작은 회색 □ 버튼</li>
<li>입체 [시작] 버튼, 눌린 모양 + 바둑판 무늬의 활성 창 버튼, 들어간 시계 칸</li>
<li>왼쪽에 세로 배너 <strong>&quot;심심오락실 98&quot;</strong> 이 있는 시작 메뉴</li>
<li>남색 블록 진행 막대, 굴림 글꼴</li>
</ul>
<p>98 특유의 입체감은 <code>box-shadow</code> 를 <strong>네 겹</strong> 겹쳐서 만들었습니다. 바깥은 흰색/검은색, 안쪽은 밝은 회색/어두운 회색.</p>
<pre><code class="language-css">:root[data-theme=&#39;win98&#39;] {
  /* 튀어나온 버튼 */
  --bevel-raised:
    inset -1px -1px #0a0a0a, inset 1px 1px #ffffff,
    inset -2px -2px #808080, inset 2px 2px #dfdfdf;
  /* 눌린 버튼 — 빛 방향만 뒤집으면 끝 */
  --bevel-pressed:
    inset -1px -1px #ffffff, inset 1px 1px #0a0a0a,
    inset -2px -2px #dfdfdf, inset 2px 2px #808080;
}

[data-theme=&#39;win98&#39;] .btn        { box-shadow: var(--bevel-raised); }
[data-theme=&#39;win98&#39;] .btn:active { box-shadow: var(--bevel-pressed); }</code></pre>
<h4 id="xp-와-구조는-공유-스타일만-분리">XP 와 구조는 공유, 스타일만 분리</h4>
<p>XP 와 98 은 <strong>바탕화면 · 창 · 작업 표시줄 · 시작 메뉴</strong> 라는 화면 구조가 똑같습니다.
그래서 컴포넌트는 그대로 두고 CSS 만 <code>*.win98.module.css</code> 로 추가했습니다.</p>
<pre><code class="language-ts">export type Theme = &#39;classic&#39; | &#39;xp&#39; | &#39;win98&#39;;

// 바탕화면 구조를 쓰는 테마인지 (XP·98) — 클래식만 헤더·푸터 구조
export const isDesktopTheme = (theme: Theme) =&gt; theme !== &#39;classic&#39;;

// Record&lt;Theme, T&gt; 라서 테마를 추가하면 스타일이 빠진 곳을 타입 검사가 알려 준다
const styles = useThemeStyles({ classic: classicStyles, xp: xpStyles, win98: win98Styles });</code></pre>
<p><code>Theme</code> 타입에 <code>&#39;win98&#39;</code> 한 단어를 추가하자마자 TypeScript 가 <strong>98 스타일이 빠진 컴포넌트를 전부 에러로</strong> 알려 줘서,
빠뜨린 곳 없이 채울 수 있었습니다.</p>
<p>테마가 셋이 되면서 &quot;다음 테마로 넘기는 토글 버튼&quot;은 불편해져서, 고르는 방식으로 바꿨습니다.</p>
<table>
<thead>
<tr>
<th>테마</th>
<th>바꾸는 곳</th>
</tr>
</thead>
<tbody><tr>
<td>XP · 98</td>
<td>[시작] 메뉴 맨 아래 — 🪟 XP / 🖥️ 98 / 📄 클래식 버튼 (지금 테마는 눌린 모양)</td>
</tr>
<tr>
<td>클래식</td>
<td>헤더 오른쪽 테마 선택 목록</td>
</tr>
</tbody></table>
<p>역시 게임 담당자는 할 일이 없습니다. 토큰만 쓴 게임 화면은 98 에서도 자동으로 회색·남색 톤이 됩니다.</p>
<hr>
<h2 id="5-메인-화면-카드-4칸--준비-중">5. 메인 화면 카드 4칸 — &quot;준비 중&quot;</h2>
<p>게임이 아직 다 없어도 <strong>카드 1~4번 자리를 미리</strong> 보여주고 싶었습니다.</p>
<pre><code class="language-ts">// registry.ts — 카드 칸은 공통 코드
export const GAME_CARD_SLOTS = [
  { card: 1, owner: &#39;혁&#39; },
  { card: 2, owner: &#39;경수&#39; },
  { card: 3, owner: &#39;신영&#39; },
  { card: 4, owner: &#39;동한&#39; },
];

// 게임 담당자는 자기 항목에 card 번호만 적으면 끝
{ id: &#39;hint-quiz&#39;, ..., card: 1 }</code></pre>
<p>게임 담당자는 여전히 <strong>&quot;항목 1개 추가&quot;</strong> 규칙만 지키면 되고, 비어 있는 칸은 🚧 &quot;준비 중&quot; 카드로 표시됩니다.</p>
<blockquote>
<p>🐛 과일 슬라이서는 이 기능보다 먼저 머지돼서 <code>card: 3</code> 이 빠져 있었고, 3번 칸이 &quot;준비 중&quot; + 게임은 맨 뒤에 따로 뜨는 문제가 있었습니다.
다음 업데이트 PR 에서 한 줄이 추가되며 해결 — 공통 규칙이 바뀌면 이미 머지된 코드도 같이 봐야 한다는 교훈.</p>
</blockquote>
<hr>
<h2 id="6-🎯-내-게임-힌트-퀴즈">6. 🎯 내 게임: 힌트 퀴즈</h2>
<h3 id="콘셉트">콘셉트</h3>
<p>제시어가 아니라 <strong>힌트가 하나씩 열리고, 적은 힌트로 먼저 맞힐수록 이기는</strong> 퀴즈입니다.</p>
<pre><code>[축구선수]                        지금 맞히면 +85점
          이 축구선수는 누구일까요?
 ① 국적        대한민국
 ② 출생 연도    1989년
 ③ 🔒 힌트 3
 ...
 ⑦ 🔒 힌트 7   (마지막은 이름 초성!)</code></pre><p>&quot;먼저 맞히는 사람이 이긴다&quot;를 실시간 대결로 만들려면 Durable Objects 가 필요한데, 팀 규칙상 <strong>파티 모드는 나중</strong>이라
지금은 <strong>1인용 + 랭킹 경쟁</strong>으로 만들었습니다.</p>
<h3 id="규칙이-바뀌어-온-과정">규칙이 바뀌어 온 과정</h3>
<p>직접 플레이해 보면서 규칙을 계속 고쳤습니다.</p>
<table>
<thead>
<tr>
<th>버전</th>
<th>규칙</th>
<th>문제점 → 개선</th>
</tr>
</thead>
<tbody><tr>
<td>v1</td>
<td>힌트 5개, 10초마다 자동 공개</td>
<td>시간이 너무 짧고 힌트가 어려움</td>
</tr>
<tr>
<td>v2</td>
<td>20초, 힌트 순서 변경, <strong>이름 초성</strong> 힌트 추가, 못 맞히면 정답 공개 후 클릭해서 다음</td>
<td>풀네임만 정답이라 억울함</td>
</tr>
<tr>
<td>v3</td>
<td><strong>부분·유사 정답</strong> 인정</td>
<td>문항 수가 고정</td>
</tr>
<tr>
<td>v4</td>
<td><strong>문항 수 선택(5·10·20)</strong>, 문제당 60초, 시간 초과 = 0점, 빨리 맞히면 보너스</td>
<td>👍</td>
</tr>
</tbody></table>
<h3 id="점수--문항-수가-달라도-공평하게">점수 — 문항 수가 달라도 공평하게</h3>
<p>5문제와 20문제 판이 <strong>하나의 랭킹</strong>을 쓰기 때문에, 점수를 그냥 더하면 20문제 판이 항상 이깁니다.
그래서 <strong>문제당 평균</strong>으로 환산했습니다.</p>
<pre><code>문제 점수 = 힌트 점수(100 → 85 → 70 → … → 10) × 시간 보너스(10초 이내 ×1.0 → 60초 ×0.5)
최종 점수 = 문제당 평균 × 10   (최고 1000점)</code></pre><table>
<thead>
<tr>
<th>판</th>
<th>결과</th>
<th>최종 점수</th>
</tr>
</thead>
<tbody><tr>
<td>5문제</td>
<td>3개 만점</td>
<td>600점</td>
</tr>
<tr>
<td>20문제</td>
<td>12개 만점</td>
<td>600점</td>
</tr>
</tbody></table>
<p>같은 실력이면 같은 점수가 나옵니다. 서버의 점수 상한(<code>MAX_SCORE_BY_GAME</code>)도 1000 으로 맞췄습니다.</p>
<hr>
<h2 id="7-장르-선택--공통-api-는-따로-pr-로">7. 장르 선택 — 공통 API 는 따로 PR 로</h2>
<p>장르(축구선수·동물·나라·음식)를 고르면 그 장르 문제만 나와야 하는데,
기존 문제 API 는 <strong>게임 전체에서 랜덤</strong>으로만 뽑았습니다.</p>
<pre><code>GET /api/games/hint-quiz/questions?limit=10&amp;category=동물</code></pre><p><code>quiz_item.meta.category</code> 로 거르는 <strong>범용 필터</strong>를 추가했습니다. (다른 게임도 사용 가능)</p>
<pre><code class="language-ts">.where(
  and(
    eq(quizItem.gameId, gameId),
    // 값은 바인딩 파라미터로 전달된다 (SQL 인젝션 안전)
    category ? sql`json_extract(${quizItem.meta}, &#39;$.category&#39;) = ${category}` : undefined,
  ),
)</code></pre>
<p>API 는 <strong>공통 코드(백엔드 담당 영역)</strong> 라 게임 PR 에 섞지 않고 <code>feature/be-*</code> PR 로 분리해서 백엔드 팀원 리뷰를 받았습니다.
빈 값, 너무 긴 값, <code>&#39; OR 1=1 --</code> 같은 인젝션 시도까지 로컬에서 확인했습니다.</p>
<hr>
<h2 id="8-⚽-축구선수-1000명--위키데이터로-수집하기">8. ⚽ 축구선수 1000명 — 위키데이터로 수집하기</h2>
<h3 id="왜-위키데이터">왜 위키데이터?</h3>
<p>처음엔 구글이나 구단 공식 사이트를 생각했지만, <strong>이용약관·저작권</strong> 문제가 있고 1000명을 하나씩 검증하기도 어렵습니다.
<strong>위키데이터(Wikidata)</strong> 는 위키백과의 구조화 데이터로, 라이선스가 <strong>CC0(자유 이용)</strong> 이고 SPARQL 로 조회할 수 있습니다.</p>
<pre><code class="language-sparql">SELECT ?p ?links WHERE {
  ?p wdt:P106 wd:Q937857;          # 직업: 축구선수
     wikibase:sitelinks ?links.     # 위키백과 문서 수 = 유명도
  ?p wdt:P413 []; wdt:P2048 []; wdt:P569 [].   # 포지션·신장·생년 있는 사람만
  FILTER EXISTS { ?p rdfs:label ?ko FILTER(LANG(?ko) = &quot;ko&quot;) }   # 한국어 이름
}
ORDER BY DESC(?links)</code></pre>
<p>수집 스크립트(<code>seeds/scripts/hint-quiz-football.mjs</code>)를 레포에 남겨 두어서, 다시 돌리면 최신 데이터로 갱신됩니다.
결과는 약 330KB 짜리 시드 SQL — D1 은 <strong>SQL 문 하나가 100KB</strong> 를 넘으면 안 돼서 100행씩 나눠 INSERT 합니다.</p>
<h3 id="수집하면서-만난-함정들">수집하면서 만난 함정들</h3>
<p><strong>① 알베르 카뮈가 축구선수?</strong>
작가 카뮈도 젊은 시절 골키퍼여서 &quot;축구선수&quot;로 잡혔습니다. → 포지션·신장·소속팀 이력이 모두 있는 사람만 남기도록 필터 강화.</p>
<p><strong>② 수아레스의 소속팀이 인터 마이애미 하나뿐?</strong>
위키데이터는 &quot;현재 소속팀&quot;에 <strong>우선 순위(preferred rank)</strong> 가 붙어 있으면, 간단한 조회(<code>wdt:</code>)로는 그 팀만 돌려줍니다.
소속팀 기록은 12개나 있었는데 말이죠. → 모든 기록을 읽는 <code>p:P54/ps:P54</code> 방식으로 변경.</p>
<pre><code class="language-sparql">?p p:P54 ?st. ?st ps:P54 ?team.
FILTER NOT EXISTS { ?st wikibase:rank wikibase:DeprecatedRank }</code></pre>
<p><strong>③ 한국 선수가 1명뿐</strong>
전 세계 &quot;유명한 순&quot;으로 뽑으니, 기성용·이강인·황희찬(문서 수 30~40개)은 밀려나고
한국어 문서가 많은 일본 J리그 선수가 180명이나 들어왔습니다.
→ <strong>한국 선수 몫 200명</strong> + 나머지 800명은 <strong>나라당 최대 70명</strong> 으로 선발 방식 변경.</p>
<p><strong>④ 그 외</strong></p>
<ul>
<li>&quot;네덜란드 왕국&quot;, &quot;유고슬라비아 사회주의 연방공화국&quot; → 흔히 쓰는 국가명으로 변환</li>
<li>동명이인 괄호 제거: <code>이명주 (축구 선수)</code> → <code>이명주</code></li>
<li>&quot;주로 쓰는 발&quot;도 힌트로 쓰고 싶었지만 유명 선수 454명 중 <strong>5명</strong>만 정보가 있어서 포기</li>
</ul>
<p>최종 힌트 순서는 <strong>국적 → 출생 연도 → 포지션 → 소속팀 → 신장 → 다른 소속팀 → 이름 초성</strong> 입니다.</p>
<hr>
<h2 id="9-정답-판정--메시도-음밥페도-정답">9. 정답 판정 — &quot;메시&quot;도, &quot;음밥페&quot;도 정답</h2>
<p>외래어 표기는 사람마다 다르게 씁니다. 풀네임만 정답이면 억울한 경우가 너무 많았습니다.</p>
<table>
<thead>
<tr>
<th>종류</th>
<th>예</th>
</tr>
</thead>
<tbody><tr>
<td>부분 정답</td>
<td><code>메시</code>, <code>리오넬</code>, <code>수아레스</code> / 한국 선수는 성 뺀 이름 <code>흥민</code></td>
</tr>
<tr>
<td>유사 정답</td>
<td><code>음밥페</code>, <code>킬리앙 음바페</code>, <code>데이빗 베컴</code>, <code>엘링 홀란드</code>, <code>주제 무리뉴</code></td>
</tr>
</tbody></table>
<h3 id="방법-자모-단위-편집-거리">방법: 자모 단위 편집 거리</h3>
<ol>
<li>한글을 <strong>자모로 분해</strong> (<code>베컴</code> → ㅂ ㅔ ㅋ ㅓ ㅁ)</li>
<li>외래어에서 흔히 섞이는 자모를 합침 — ㅐ/ㅔ, 쌍자음/예사소리, 끼어드는 &#39;ㅡ&#39;(홀란/홀란<strong>드</strong>)</li>
<li><strong>편집 거리</strong>가 이름 길이에 비례한 허용치 이하면 정답</li>
</ol>
<pre><code class="language-ts">function allowedDistance(length: number): number {
  if (length &lt; 5) return 0;   // 짧은 이름은 정확히
  if (length &lt; 9) return 1;
  if (length &lt; 14) return 2;
  return 3;
}</code></pre>
<h3 id="너그러우면서도-틀릴-건-틀려야-한다">너그러우면서도 틀릴 건 틀려야 한다</h3>
<ul>
<li><strong>한국 사람 이름은 유사 정답 제외</strong> — <code>김민재</code> 와 <code>김민수</code> 는 한 글자 차이지만 다른 사람</li>
<li>부분 정답은 <strong>본 이름에서만</strong> — 호나우지뉴의 별칭 &quot;작은 호나우두&quot; 때문에 <code>호나우두</code> 가 정답 처리되던 문제 수정</li>
<li><code>호날두</code> ≠ <code>호나우두</code>, <code>해리 케인</code> ≠ <code>해리 매과이어</code> 는 오답</li>
</ul>
<p>테스트로 검증했습니다.</p>
<ul>
<li>맞아야 할/틀려야 할 <strong>30개 케이스 전부 통과</strong></li>
<li>실제 문제 1030개 전체: 정답·별칭 자기 인정 실패 <strong>0건</strong></li>
<li>다른 선수 이름을 넣었을 때 오인정: <strong>약 100만 조합 중 8건</strong> (로이 킨 ↔ 로비 킨 처럼 실제로 한 글자 차이)</li>
</ul>
<hr>
<h2 id="10-트러블슈팅-메모">10. 트러블슈팅 메모</h2>
<table>
<thead>
<tr>
<th>증상</th>
<th>원인</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>코드를 고쳐도 화면이 안 바뀜</td>
<td>저장소가 WSL 의 <code>/mnt/c</code>(Windows 디스크)에 있어 <strong>파일 변경 감지 불가</strong> → Vite 가 예전 코드를 캐시</td>
<td>개발 서버 재시작 (근본 해결: 저장소를 WSL 홈으로 이동 or <code>usePolling</code>)</td>
</tr>
<tr>
<td>이어서 올린 PR 을 만들 수 없음</td>
<td>기반 PR 이 먼저 머지되며 브랜치가 삭제됨</td>
<td><code>git rebase --onto origin/main &lt;기반 커밋&gt;</code> 으로 옮긴 뒤 main 대상 PR</td>
</tr>
<tr>
<td>PR 에 두 번째 커밋이 빠짐</td>
<td>push 하기 전에 PR 이 이미 머지됨</td>
<td>빠진 커밋만 cherry-pick 해서 새 PR</td>
</tr>
<tr>
<td>Squash 머지 후 로컬 브랜치가 안 지워짐</td>
<td><code>git branch --merged</code> 는 Squash 머지를 인식 못함</td>
<td>PR 머지 여부를 확인하고 <code>git branch -D</code></td>
</tr>
<tr>
<td>시드 두 개가 서로 문제를 지움</td>
<td>둘 다 <code>DELETE WHERE game_id = ...</code></td>
<td>파일마다 <strong>자기 문제만</strong> 지우도록 <code>meta.source</code> 로 구분</td>
</tr>
</tbody></table>
<hr>
<h2 id="11-마치며--다음-계획">11. 마치며 &amp; 다음 계획</h2>
<p>하루 동안 <strong>공통 기반(XP·98 테마·시작 메뉴·카드 칸·API)</strong> 과 <strong>내 게임(힌트 퀴즈)</strong> 을 오가며 작업했습니다.
&quot;게임 PR 엔 내 게임만, 공통 변경은 따로&quot; 규칙 덕분에 PR 하나하나가 작고 리뷰하기 쉬웠고,
머지 = 자동 배포라서 PR 이 머지될 때마다 바로 운영 사이트에서 확인할 수 있었습니다.</p>
<p>다음 단계는</p>
<ul>
<li><input disabled="" type="checkbox"> 힌트 퀴즈 장르 추가 — 국기 맞히기, 수도 맞히기, 야구선수</li>
<li><input disabled="" type="checkbox"> 동물·나라·음식 장르 문제 20개 이상으로 늘리기</li>
<li><input disabled="" type="checkbox"> 랭킹 기준이 바뀌었으니(500 → 1000점) 기존 기록 정리</li>
<li><input disabled="" type="checkbox"> 축구선수 난이도(유명도) 구분 검토</li>
<li><input disabled="" type="checkbox"> 나머지 카드(2번 임진 50, 4번) 게임 머지</li>
<li><input disabled="" type="checkbox"> 언젠가 실시간 대결 모드 (Durable Objects)</li>
</ul>
<p>다음 글에서 또 만나요. 🎮</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 112]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-112</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-112</guid>
            <pubDate>Sun, 27 Sep 2026 23:47:43 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="🕹️-심심오락실-개발기-1--게임을-하나씩-쉽게-추가할-수-있는-구조-만들기">🕹️ 심심오락실 개발기 #1 — 게임을 &quot;하나씩 쉽게&quot; 추가할 수 있는 구조 만들기</h1>
<blockquote>
<p>예능에서 보던 초성 퀴즈, 어릴 적 하던 플래시 게임 같은 걸 웹에서 한곳에 모아 즐기는 사이트, <strong>심심오락실(simsim-arcade)</strong>
첫 글에서는 게임을 붙이기 전에 <strong>뼈대를 어떻게 설계했는지</strong> 정리해 봤습니다.</p>
</blockquote>
<hr>
<h2 id="1-프로젝트-소개">1. 프로젝트 소개</h2>
<p>심심오락실은 4명이 함께 만드는 팀 프로젝트입니다.</p>
<ul>
<li>예능식 퀴즈 게임 (초성 퀴즈 등)</li>
<li>추억의 플래시 스타일 미니게임</li>
<li>게임마다 <strong>닉네임 + 점수 랭킹</strong></li>
</ul>
<p>게임은 앞으로 <strong>하나씩 계속 추가</strong>해 나갈 예정입니다.
그래서 처음부터 가장 신경 쓴 목표는 이것이었습니다.</p>
<blockquote>
<p><strong>&quot;새 게임을 추가할 때 공통 코드는 건드리지 않고, 파일 몇 개만 추가하면 끝나야 한다.&quot;</strong></p>
</blockquote>
<hr>
<h2 id="2-기술-스택--cloudflare-하나로-전부">2. 기술 스택 — Cloudflare 하나로 전부</h2>
<p>서버 관리 부담을 줄이려고 배포 인프라를 <strong>Cloudflare 하나로 통일</strong>했습니다.</p>
<table>
<thead>
<tr>
<th>영역</th>
<th>기술</th>
<th>배포</th>
</tr>
</thead>
<tbody><tr>
<td>모노레포</td>
<td>pnpm workspace</td>
<td>—</td>
</tr>
<tr>
<td>프론트 (<code>apps/web</code>)</td>
<td>React + TypeScript + Vite + React Router</td>
<td>Cloudflare Pages</td>
</tr>
<tr>
<td>백엔드 (<code>apps/api</code>)</td>
<td>Cloudflare Workers + Hono + TypeScript</td>
<td>Cloudflare Workers</td>
</tr>
<tr>
<td>DB</td>
<td>Cloudflare D1 (SQLite) + Drizzle ORM</td>
<td>D1</td>
</tr>
<tr>
<td>공용 타입 (<code>packages/shared</code>)</td>
<td>TypeScript</td>
<td>web·api 가 함께 import</td>
</tr>
</tbody></table>
<p><strong>Hono</strong>는 Workers 환경에서 가볍게 돌아가는 웹 프레임워크라 골랐고, <strong>Drizzle ORM</strong>은 D1(SQLite)과 궁합이 좋고 스키마를 TypeScript로 관리할 수 있다는 점이 좋았습니다.</p>
<p>파티 모드(Durable Objects), 파일 저장(R2) 같은 기능은 욕심나지만 <strong>지금은 만들지 않기로</strong> 했습니다. 필요해질 때 붙여도 늦지 않으니까요.</p>
<hr>
<h2 id="3-폴더-구조">3. 폴더 구조</h2>
<pre><code>simsim-arcade/
├─ apps/
│  ├─ web/                  # 프론트엔드
│  │  └─ src/
│  │     ├─ games/          # ⭐ 게임별 독립 폴더 + registry.ts
│  │     │  ├─ chosung-quiz/
│  │     │  └─ registry.ts
│  │     ├─ components/     # 공통 UI: Timer, ResultModal, RankingList, GameCard ...
│  │     ├─ pages/          # HomePage, GamePage, NotFoundPage
│  │     └─ lib/            # api 클라이언트, useFetch
│  └─ api/                  # 백엔드
│     ├─ src/routes/games.ts   # gameId 기반 범용 API
│     ├─ src/db/schema.ts      # Drizzle 스키마
│     ├─ migrations/           # 생성된 마이그레이션 SQL
│     └─ seeds/&lt;게임id&gt;.sql    # 게임별 시드 데이터
└─ packages/
   └─ shared/               # 프론트·백엔드 공용 타입</code></pre><p>모노레포로 묶은 가장 큰 이유는 <strong><code>packages/shared</code></strong> 입니다.
API 요청/응답 타입을 한곳에만 정의하고 프론트와 백엔드가 같이 가져다 쓰니까, 한쪽만 바뀌어서 어긋나는 일이 타입 체크 단계에서 바로 잡힙니다.</p>
<pre><code class="language-ts">// packages/shared/src/api.ts
export interface SubmitScoreRequest {
  nickname: string;
  score: number;
}

export interface SubmitScoreResponse {
  id: number;
  /** 등록 직후 이 점수의 순위 (1부터) */
  rank: number;
}</code></pre>
<p>닉네임 길이 제한(1~12자) 같은 상수도 여기에 두고, 서버 검증과 프론트 입력 제한이 같은 값을 씁니다.</p>
<hr>
<h2 id="4-프론트엔드-설계--게임은-폴더-하나">4. 프론트엔드 설계 — 게임은 &quot;폴더 하나&quot;</h2>
<h3 id="4-1-모든-게임은-같은-약속을-따른다">4-1. 모든 게임은 같은 약속을 따른다</h3>
<p>모든 게임 컴포넌트는 딱 하나의 Props만 받습니다.</p>
<pre><code class="language-ts">export interface GameProps {
  /** 게임이 끝났을 때 최종 점수를 전달한다. 한 판에 한 번만 호출해야 한다. */
  onFinish: (score: number) =&gt; void;
}</code></pre>
<p>게임은 <strong>점수 계산까지만</strong> 책임집니다.
점수 등록, 결과 모달, 랭킹 표시는 전부 공통 페이지(<code>GamePage</code>)가 처리합니다.
덕분에 게임 개발자는 &quot;게임 자체&quot;에만 집중하면 됩니다.</p>
<h3 id="4-2-registry-한-줄--메인-카드--라우트">4-2. registry 한 줄 = 메인 카드 + 라우트</h3>
<p>게임은 <code>registry.ts</code>에 등록만 하면 됩니다.</p>
<pre><code class="language-ts">export const GAMES: readonly GameMeta[] = [
  {
    id: &#39;chosung-quiz&#39;,
    name: &#39;초성 퀴즈&#39;,
    description: &#39;초성만 보고 단어를 맞혀라! 60초 동안 10문제&#39;,
    thumbnail: chosungQuizThumbnail,
    category: &#39;quiz&#39;,
    component: lazy(() =&gt; import(&#39;./chosung-quiz&#39;)),
  },
];</code></pre>
<ul>
<li>메인 화면의 게임 카드 목록 → 이 배열로 자동 생성</li>
<li><code>/games/:gameId</code> 라우트 → 이 배열에서 찾아서 실행</li>
<li><code>React.lazy</code>로 등록해서 <strong>게임별로 코드가 분리 로딩</strong>됩니다. 게임이 늘어나도 첫 화면이 무거워지지 않아요.</li>
</ul>
<p>게임별 라우트를 따로 만들 필요가 전혀 없습니다.</p>
<h3 id="4-3-다시-하기는-새로-마운트해서">4-3. &quot;다시 하기&quot;는 새로 마운트해서</h3>
<p>다시 하기를 구현할 때 게임마다 상태 초기화 로직을 짜게 하면 버그가 생기기 쉽습니다.
그래서 <code>GamePage</code>가 <code>key</code>를 바꿔 <strong>게임 컴포넌트를 통째로 새로 마운트</strong>하는 방식으로 처리했습니다.</p>
<pre><code class="language-tsx">const [round, setRound] = useState(0);

function handleRetry() {
  setFinalScore(null);
  setRound((r) =&gt; r + 1);   // key 가 바뀌면 게임이 처음부터 다시 시작
}

&lt;Game key={round} onFinish={handleFinish} /&gt;</code></pre>
<p>게임 쪽에서는 &quot;리셋&quot;을 신경 쓸 필요가 없어졌습니다.</p>
<hr>
<h2 id="5-백엔드-설계--게임별-api는-만들지-않는다">5. 백엔드 설계 — 게임별 API는 만들지 않는다</h2>
<p>API는 게임마다 만들지 않고 <strong><code>gameId</code> 하나로 구분하는 범용 API</strong> 3개로 끝냈습니다.</p>
<table>
<thead>
<tr>
<th>메서드</th>
<th>경로</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>POST</code></td>
<td><code>/api/games/:gameId/scores</code></td>
<td>닉네임 + 점수 등록 (등록 직후 순위 반환)</td>
</tr>
<tr>
<td><code>GET</code></td>
<td><code>/api/games/:gameId/ranking?limit=N</code></td>
<td>상위 N개 랭킹</td>
</tr>
<tr>
<td><code>GET</code></td>
<td><code>/api/games/:gameId/questions?limit=N</code></td>
<td>퀴즈 문제 랜덤 N개</td>
</tr>
</tbody></table>
<p>에러는 모두 같은 형식으로 응답합니다.</p>
<pre><code class="language-json">{ &quot;error&quot;: { &quot;code&quot;: &quot;GAME_NOT_FOUND&quot;, &quot;message&quot;: &quot;존재하지 않는 게임입니다.&quot; } }</code></pre>
<h3 id="점수-조작-방지-최소한의-방어">점수 조작 방지 (최소한의 방어)</h3>
<p>프론트에서 점수를 보내는 구조라 누군가 999999점을 보낼 수도 있습니다.
그래서 <code>packages/shared</code>에 <strong>게임별 점수 상한</strong>을 두고 서버가 검증합니다.</p>
<pre><code class="language-ts">export const MAX_SCORE_BY_GAME: Record&lt;string, number&gt; = {
  &#39;chosung-quiz&#39;: 100,
};</code></pre>
<p>완벽한 방어는 아니지만, 말도 안 되는 점수가 랭킹 1위에 올라가는 건 막을 수 있습니다.</p>
<hr>
<h2 id="6-db-설계--테이블-3개로-모든-게임을-커버">6. DB 설계 — 테이블 3개로 모든 게임을 커버</h2>
<pre><code>game      (id, name, category, created_at)
score     (id, game_id, nickname, score, created_at)
quiz_item (id, game_id, question, answer, meta JSON, created_at)</code></pre><p>핵심은 <strong><code>quiz_item.meta</code>(JSON)</strong> 입니다.
게임마다 필요한 데이터가 다른데, 그때마다 테이블이나 컬럼을 추가하면 스키마가 금방 지저분해집니다.
그래서 게임별 추가 데이터는 전부 <code>meta</code>에 담고, <strong>그 모양은 TypeScript 타입으로 정의</strong>합니다.</p>
<pre><code class="language-ts">/** 초성 퀴즈 문제의 meta */
export interface ChosungQuizMeta {
  category: string;     // 정답 분류 (예: 과일, 동물)
  hint?: string;        // 추가 힌트
  aliases?: string[];   // 정답으로 함께 인정할 단어
}</code></pre>
<p>문제 데이터는 게임별 시드 SQL 파일로 관리하고, <strong>여러 번 실행해도 결과가 같도록</strong> 작성했습니다.</p>
<pre><code class="language-sql">INSERT OR IGNORE INTO game (id, name, category) VALUES (&#39;chosung-quiz&#39;, &#39;초성 퀴즈&#39;, &#39;quiz&#39;);

DELETE FROM quiz_item WHERE game_id = &#39;chosung-quiz&#39;;   -- 내 게임 문제만 지우고

INSERT INTO quiz_item (game_id, question, answer, meta) VALUES
  (&#39;chosung-quiz&#39;, &#39;ㅅㄱ&#39;, &#39;사과&#39;, &#39;{&quot;category&quot;:&quot;과일&quot;,&quot;hint&quot;:&quot;빨갛고 아삭한 과일&quot;}&#39;),
  ...</code></pre>
<p>점수 기록은 그대로 두고 문제만 갈아끼울 수 있습니다.
(참고로 문제는 전부 직접 만든 것만 씁니다. 실제 방송 문제·로고는 사용하지 않아요.)</p>
<hr>
<h2 id="7-새-게임-추가는-이렇게">7. 새 게임 추가는 이렇게</h2>
<p>이 구조 덕분에 새 게임 추가는 아래 4가지면 끝납니다.</p>
<ol>
<li><code>apps/web/src/games/&lt;게임id&gt;/</code> 폴더 만들기 — <code>index.tsx</code>, <code>config.ts</code>, 스타일, 썸네일</li>
<li><code>registry.ts</code>에 항목 1개 추가</li>
<li><code>packages/shared</code>의 <code>MAX_SCORE_BY_GAME</code>에 1줄 추가 (퀴즈류면 meta 타입도 추가)</li>
<li><code>apps/api/seeds/&lt;게임id&gt;.sql</code> 새 파일 작성</li>
</ol>
<p>그리고 <strong>게임 ID는 4곳에서 반드시 같아야</strong> 합니다.
폴더명 = registry의 <code>id</code> = DB <code>game.id</code> = <code>MAX_SCORE_BY_GAME</code> 키</p>
<p>구조 검증용으로 <strong>초성 퀴즈(<code>chosung-quiz</code>)</strong> 를 샘플 게임으로 먼저 만들어서, 한 판 → 점수 등록 → 랭킹까지 흐름이 한 번에 도는 걸 확인했습니다.</p>
<hr>
<h2 id="8-팀-협업-규칙">8. 팀 협업 규칙</h2>
<p>4명이 동시에 작업하다 보니 <strong>충돌을 줄이는 규칙</strong>도 코드 구조만큼 중요했습니다.</p>
<h3 id="작업을-두-종류로-나눴다">작업을 두 종류로 나눴다</h3>
<table>
<thead>
<tr>
<th>구분</th>
<th>범위</th>
<th>담당</th>
<th>브랜치</th>
</tr>
</thead>
<tbody><tr>
<td>웹 공통 구성</td>
<td>레이아웃, 공통 컴포넌트, API, DB 스키마 등</td>
<td>프론트/백엔드 역할대로</td>
<td><code>feature/fe-*</code>, <code>feature/be-*</code></td>
</tr>
<tr>
<td>게임 카드</td>
<td>게임 1개 전체</td>
<td><strong>1인 1게임</strong>, 프론트·백 구분 없이 혼자</td>
<td><code>feature/game-&lt;게임id&gt;</code></td>
</tr>
</tbody></table>
<h3 id="게임-카드는-추가만-한다">게임 카드는 &quot;추가만 한다&quot;</h3>
<p>게임 작업 PR에는 <strong>내 게임 파일 추가 + 등록 줄</strong>만 들어갑니다.
공통 컴포넌트나 다른 사람 게임은 건드리지 않고, 공통 부분이 바뀌어야 하면 따로 PR을 올립니다.
이렇게 하니 4명이 동시에 게임을 만들어도 서로 코드가 거의 겹치지 않습니다.</p>
<h3 id="브랜치-전략-github-flow">브랜치 전략: GitHub Flow</h3>
<ul>
<li><code>main</code> + <code>feature/*</code> 만 사용 (develop 브랜치 없음)</li>
<li><code>main</code> 직접 push 금지, 반드시 PR + 리뷰 1명 승인 후 <strong>Squash and merge</strong></li>
<li>배포 시점은 <code>v1.0.0</code> 같은 <strong>git 태그</strong>로 표시</li>
<li>커밋 메시지는 <code>feat:</code>, <code>fix:</code>, <code>docs:</code>, <code>chore:</code> 등 컨벤션 사용</li>
<li>작업 장소를 옮길 땐 미완성이어도 <code>wip:</code> 커밋으로 push → 어차피 Squash 되니 히스토리는 깔끔</li>
</ul>
<hr>
<h2 id="9-마치며--다음-계획">9. 마치며 &amp; 다음 계획</h2>
<p>아직 게임은 샘플 하나뿐이지만, <strong>게임을 추가하는 길은 다 닦아 놓은 상태</strong>입니다.</p>
<p>다음 단계는</p>
<ul>
<li><input disabled="" type="checkbox"> 팀원별 담당 게임 정하기 (카드 1~4번)</li>
<li><input disabled="" type="checkbox"> 각자 <code>feature/game-&lt;id&gt;</code> 브랜치에서 게임 개발</li>
<li><input disabled="" type="checkbox"> Cloudflare Pages / Workers / D1 실제 배포</li>
<li><input disabled="" type="checkbox"> PR마다 typecheck·lint·build 자동 검사(CI) 붙이기</li>
</ul>
<p>다음 글에서는 각자 만든 게임 이야기로 돌아오겠습니다. 🎮</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 111]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-111</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-111</guid>
            <pubDate>Tue, 22 Sep 2026 23:50:08 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h2 id="예습">예습</h2>
<h1 id="🔍-elasticsearch-입문-정리">🔍 Elasticsearch 입문 정리</h1>
<p>엘라스틱서치(Elasticsearch)를 처음 공부하면서 알아야 할 기본 개념들을 정리했다. 왜 쓰는지부터 핵심 용어, RDB와의 차이, 기본 REST API 사용법까지 다룬다.</p>
<h2 id="1-elasticsearch란">1. Elasticsearch란?</h2>
<p><strong>Elasticsearch</strong>는 대량의 데이터를 빠르게 검색하고 분석하기 위해 만들어진 <strong>분산 검색 엔진</strong>이다. Apache Lucene이라는 검색 라이브러리를 기반으로 만들어졌고, JSON 형태의 문서를 저장하고 검색할 수 있는 NoSQL 데이터 저장소 역할도 겸한다.</p>
<h3 id="왜-elasticsearch가-필요할까">왜 Elasticsearch가 필요할까</h3>
<p>일반적인 관계형 데이터베이스(MySQL, PostgreSQL 등)의 <code>LIKE &#39;%검색어%&#39;</code> 방식은 데이터가 많아질수록 느려지고, 오타나 유사어 검색, 관련도 순 정렬 같은 기능을 구현하기 어렵다.</p>
<ul>
<li><strong>속도</strong>: 대용량 데이터에서도 밀리초 단위로 검색 결과를 반환한다.</li>
<li><strong>전문 검색(Full-text search)</strong>: 형태소 분석, 오타 허용, 유사어 처리 등 텍스트 검색에 특화되어 있다.</li>
<li><strong>관련도 순 정렬</strong>: 단순히 조건에 맞는지가 아니라, &quot;얼마나 관련 있는지&quot; 점수(score)를 매겨 정렬해준다.</li>
<li><strong>분산 처리</strong>: 여러 서버에 데이터를 나눠 저장하고 검색해서, 데이터가 늘어나도 확장이 가능하다.</li>
</ul>
<h3 id="어디에-쓰일까">어디에 쓰일까</h3>
<ul>
<li>쇼핑몰의 상품 검색, 자동완성</li>
<li>로그 데이터 수집·분석 (ELK 스택: Elasticsearch + Logstash + Kibana)</li>
<li>커뮤니티/게시판의 게시글 검색</li>
<li>추천 시스템의 유사 콘텐츠 탐색</li>
</ul>
<hr>
<h2 id="2-rdb와-비교해서-이해하기">2. RDB와 비교해서 이해하기</h2>
<p>기존에 관계형 데이터베이스를 다뤄봤다면, 아래 대응 관계로 이해하면 감이 빨리 잡힌다.</p>
<table>
<thead>
<tr>
<th>RDB (MySQL 등)</th>
<th>Elasticsearch</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>Database</td>
<td>Index</td>
<td>데이터가 모이는 큰 단위</td>
</tr>
<tr>
<td>Table</td>
<td>Type (7.x 이후 사실상 폐지, Index가 그 역할까지 겸함)</td>
<td>데이터의 종류</td>
</tr>
<tr>
<td>Row</td>
<td>Document</td>
<td>데이터 한 건, JSON 형태</td>
</tr>
<tr>
<td>Column</td>
<td>Field</td>
<td>문서 안의 속성 하나</td>
</tr>
<tr>
<td>Schema</td>
<td>Mapping</td>
<td>필드별 데이터 타입 정의</td>
</tr>
<tr>
<td>SQL</td>
<td>Query DSL (JSON 기반 쿼리)</td>
<td>데이터를 조회하는 방법</td>
</tr>
</tbody></table>
<blockquote>
<p>💡 완전히 1:1로 대응되는 건 아니지만, RDB 경험이 있다면 이 표만 봐도 절반은 이해한 셈이다. 가장 큰 차이는 Elasticsearch는 <strong>관계(JOIN)를 지원하지 않는다</strong>는 점이다. 대신 검색과 텍스트 분석에 완전히 최적화되어 있다.</p>
</blockquote>
<hr>
<h2 id="3-핵심-개념-역인덱스inverted-index">3. 핵심 개념: 역인덱스(Inverted Index)</h2>
<p>Elasticsearch가 빠른 이유의 핵심은 <strong>역인덱스</strong> 구조에 있다.</p>
<p>일반적인 책의 색인(찾아보기)을 떠올리면 이해가 쉽다. 책 내용을 처음부터 끝까지 훑는 대신, 뒤에 있는 &quot;찾아보기&quot; 페이지에서 단어를 찾아 바로 몇 페이지에 있는지 알 수 있는 것과 같은 원리다.</p>
<pre><code>문서 1: &quot;빠른 갈색 여우가 뛴다&quot;
문서 2: &quot;느린 갈색 개가 걷는다&quot;</code></pre><p>이 문서들을 단어 단위로 쪼개서 인덱스를 만들면 이런 구조가 된다.</p>
<pre><code>&quot;갈색&quot;  → [문서1, 문서2]
&quot;빠른&quot;  → [문서1]
&quot;느린&quot;  → [문서2]
&quot;여우&quot;  → [문서1]
&quot;개&quot;    → [문서2]</code></pre><p>&quot;갈색&quot;을 검색하면 문서 전체를 뒤지지 않고, 이 색인표에서 바로 [문서1, 문서2]를 찾아낸다. 이게 바로 역인덱스이고, Elasticsearch 검색 속도의 핵심 원리다.</p>
<hr>
<h2 id="4-설치하고-첫-요청-보내보기">4. 설치하고 첫 요청 보내보기</h2>
<h3 id="설치-docker-권장">설치 (Docker 권장)</h3>
<p>가장 간단한 방법은 Docker로 실행하는 것이다.</p>
<pre><code class="language-bash">docker run -d --name elasticsearch \
  -p 9200:9200 -p 9300:9300 \
  -e &quot;discovery.type=single-node&quot; \
  -e &quot;xpack.security.enabled=false&quot; \
  docker.elastic.co/elasticsearch/elasticsearch:8.15.0</code></pre>
<p>실행 후 브라우저나 curl로 <code>http://localhost:9200</code>에 접속하면 버전 정보가 JSON으로 응답된다.</p>
<h3 id="기본-rest-api로-crud-해보기">기본 REST API로 CRUD 해보기</h3>
<p>Elasticsearch는 모든 조작을 <strong>HTTP + JSON</strong>으로 한다. RDB처럼 별도의 쿼리 언어를 배우기 전에, 우선 REST API 감각부터 익히는 게 좋다.</p>
<p><strong>인덱스(데이터베이스 격) 생성</strong></p>
<pre><code class="language-bash">curl -X PUT &quot;localhost:9200/products&quot;</code></pre>
<p><strong>문서 추가</strong></p>
<pre><code class="language-bash">curl -X POST &quot;localhost:9200/products/_doc/1&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &#39;{
    &quot;name&quot;: &quot;무선 이어폰&quot;,
    &quot;price&quot;: 89000,
    &quot;category&quot;: &quot;전자기기&quot;
  }&#39;</code></pre>
<p><strong>문서 조회</strong></p>
<pre><code class="language-bash">curl -X GET &quot;localhost:9200/products/_doc/1&quot;</code></pre>
<p><strong>검색 (여기가 진짜 핵심)</strong></p>
<pre><code class="language-bash">curl -X GET &quot;localhost:9200/products/_search&quot; \
  -H &quot;Content-Type: application/json&quot; \
  -d &#39;{
    &quot;query&quot;: {
      &quot;match&quot;: {
        &quot;name&quot;: &quot;이어폰&quot;
      }
    }
  }&#39;</code></pre>
<p><code>match</code> 쿼리는 &quot;이어폰&quot;이라는 단어가 포함된 문서를 찾아서, 관련도 순으로 점수(<code>_score</code>)와 함께 돌려준다. 이게 SQL의 <code>LIKE</code>와 결정적으로 다른 지점이다 — 단순 포함 여부가 아니라 <strong>얼마나 관련 있는지</strong>를 계산해서 정렬해준다.</p>
<p><strong>문서 삭제</strong></p>
<pre><code class="language-bash">curl -X DELETE &quot;localhost:9200/products/_doc/1&quot;</code></pre>
<hr>
<h2 id="5-앞으로-학습할-로드맵">5. 앞으로 학습할 로드맵</h2>
<p>처음 시작할 때는 아래 순서로 익히는 걸 추천한다.</p>
<ol>
<li><strong>기본 CRUD와 REST API 감각 익히기</strong> (오늘 다룬 내용)</li>
<li><strong>Mapping</strong> — 필드별 데이터 타입을 명시적으로 정의하는 방법 (자동 매핑의 한계와 함께)</li>
<li><strong>Query DSL</strong> — <code>match</code>, <code>term</code>, <code>bool</code>, <code>range</code> 등 다양한 쿼리 종류</li>
<li><strong>Analyzer(분석기)</strong> — 한글 형태소 분석기(예: Nori) 설정, 텍스트가 어떻게 토큰으로 쪼개지는지</li>
<li><strong>Aggregation(집계)</strong> — 검색뿐 아니라 통계·집계 기능</li>
<li><strong>클러스터, 샤드, 레플리카</strong> — 분산 시스템으로서의 동작 원리</li>
<li><strong>Kibana</strong> — Elasticsearch 데이터를 시각화하는 도구</li>
</ol>
<hr>
<h2 id="마무리">마무리</h2>
<ul>
<li>Elasticsearch는 &quot;빠르게 검색하고, 관련도 순으로 정렬해주는&quot; 데 특화된 도구라는 게 핵심이다.</li>
<li>RDB와 대응 관계(Index-Database, Document-Row, Field-Column)로 먼저 감을 잡고, 가장 큰 차이인 <strong>JOIN 미지원</strong>과 <strong>역인덱스 기반 검색</strong>을 기억해두면 이후 학습이 수월하다.</li>
<li>처음엔 복잡한 설정보다 REST API로 직접 CRUD와 검색 요청을 날려보면서 감을 익히는 게 가장 빠른 시작 방법이다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 110]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-110</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-110</guid>
            <pubDate>Mon, 21 Sep 2026 23:47:21 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h2 id="능력-단위-평가">능력 단위 평가</h2>
<ol>
<li>프로젝트 소개</li>
</ol>
<hr>
<p>CareMatch는 요양보호사·간병인·가사도우미와 요양시설을 연결하는 구인구직
매칭 웹 서비스다. 프론트엔드는 React(Vite) + TypeScript, 백엔드는 Spring
Boot 3 기반 REST API로 구성된 모노레포 프로젝트이며, DB는 PostgreSQL
(Neon), 파일 스토리지는 Cloudflare R2, 결제는 포트원(PortOne) V2, 배포는
백엔드 Render / 프론트엔드 Vercel을 사용한다. 주요 기능은 구인공고 등록·
검색·매칭점수 정렬, 온라인 지원, 인재/시설 프로필 관리, 알림, 포인트
결제, 카카오 소셜 로그인 등이다.</p>
<p>팀은 백엔드 2인(허혁, 경수), 프론트엔드 2인(신영, 동한)으로 구성되었고,
2026-09-07 프로젝트 시작부터 2026-09-18까지 150개 이상의 PR을 병합하며
개발했다.</p>
<ol start="2">
<li>본인의 담당 역할</li>
</ol>
<hr>
<p>팀장 겸 백엔드 개발자로 참여했다. 백엔드에서는 회원/인증, 알림, 결제/
포인트, 구인공고 정렬 등 서비스 핵심 도메인의 API와 DB 스키마를 설계·
구현했고, 프론트엔드 API 연동을 위한 CORS·에러코드 규격도 함께 맞췄다.</p>
<p>팀장으로서는 기술 구현 외에 프로젝트 초기 협업 규칙(브랜치 전략, 커밋
컨벤션, PR 절차)을 문서(CLAUDE.md)로 수립하고, 저장소를 backend/frontend/
docs 모노레포 구조로 설계했으며, GitHub Actions 기반 CI(빌드 체크,
리뷰어 자동 지정)를 구축했다. 매일 팀원들의 작업 내용을 README에 반영해
프로젝트 전체 진행 상황을 관리하는 역할도 겸했다.</p>
<ol start="3">
<li>본인이 직접 구현한 기능과 구현 방식</li>
</ol>
<hr>
<ul>
<li><p>회원가입·로그인 및 아이디/비밀번호 기반 JWT 인증 구현
: Spring Security + JWT로 인증 필터를 구성하고, MemberService/
  AuthController에서 가입·로그인 로직과 토큰 발급을 처리했다.</p>
</li>
<li><p>카카오 소셜 로그인 연동
: Spring Security OAuth2 클라이언트로 카카오·네이버·구글 3사를 공용
  지원하는 구조(CustomOAuth2UserService, OAuth2SuccessHandler 등)로
  설계하되, 프론트에는 카카오만 노출하도록 단일화했고, 동의항목에서
  account_email을 제외해 별도 심사 없이 바로 적용되게 처리했다.</p>
</li>
<li><p>비밀번호 찾기(재설정) 및 회원 탈퇴 기능 구현
: 이메일/휴대폰 인증코드 기반 검증 로직(VerificationService)을 API와
  화면 양쪽에 구현하고, 탈퇴 API와 마이페이지 탈퇴 버튼을 추가했다.</p>
</li>
<li><p>알림 도메인 신규 설계 및 구현
: 지원 결과(합격/반려), 시설 승인/반려, 문의 답변 시점에 서버가 알림을
  생성하도록 Notification 엔티티/서비스/API를 새로 만들고, 프론트
  헤더 배지 폴링(30초 주기)과 연동했다.</p>
</li>
<li><p>구인공고 매칭점수순(MATCH_SCORE) 정렬 기능 추가
: JobPostingService의 정렬 옵션에 노출등급 우선 정렬과 별도로 순수
  매칭 점수 기준 정렬을 신설했다.</p>
</li>
<li><p>포인트/결제(PortOne V2) 서버 검증 로직 구현
: 클라이언트가 보낸 결제 금액·상태를 그대로 신뢰하지 않고 서버가
  포트원 서버 API로 재조회해 검증하도록 PointChargeService를
  구현해 위변조를 방지했고, 동시요청으로 인한 포인트 이중 차감/이중
  지급 레이스 컨디션 버그를 찾아 수정했다.</p>
</li>
<li><p>데이터베이스 마이그레이션 체계 구축
: Flyway를 도입해 운영 DB(PostgreSQL/Neon) 스키마 변경을 V1~V12
  마이그레이션 파일로 관리하도록 구성했고, @Lob String 필드를
  @Column(TEXT)로 교체해 대용량 텍스트가 OID로 저장되던 문제를
  해결했다.</p>
</li>
<li><p>배포 환경 구성 및 트러블슈팅
: Render 배포 시 server.port가 PORT 환경변수를 참조하도록 수정해
  포트 바인딩 문제를 해결했고, Cloudflare R2(S3 호환) 기반 파일
  스토리지(자격증·사업자등록증·프로필사진·공고이미지)를 presigned
  URL 방식으로 구현했으며, 로컬↔Render 간 CORS 403 이슈를 해결했다.</p>
</li>
<li><p>관리자 운영 기능 구현
: 관리자 회원관리 화면에서 인증구직자 마크를 수동 부여/해제하는
  기능과, 인재 연락처 마스킹이 관리자 계정에서 해제되지 않던 버그를
  수정했다.</p>
</li>
</ul>
<ol start="4">
<li>팀원과의 협업 내용</li>
</ol>
<hr>
<p>GitHub Flow(main + feature/<em>)를 기반으로 팀원과 소스 코드를 공유·관리
했다. 프론트(신영, 동한)와 백엔드(경수) 담당자별로 브랜치 접두사(fe-</em>/
be-*)를 나누고, 기능 단위로 브랜치를 생성해 작업한 뒤 PR을 통해서만
main에 병합하도록 규칙을 세웠다. CODEOWNERS와 GitHub Actions로 PR 생성
시 담당 파트 팀원이 자동으로 리뷰어로 지정되도록 구성해 리뷰가 누락되지
않게 했다.</p>
<p>프론트엔드·백엔드 담당자 간에는 API 주소, 요청/응답 데이터 형식(DTO),
에러코드를 사전에 협의해 연동했고, API 변경 시점마다
FRONTEND_INTEGRATION_TODO.md 문서를 갱신해 프론트 담당자가 변경 사항을
바로 확인할 수 있도록 했다. 다른 팀원 브랜치의 병합 충돌 잔여물로 main
빌드가 깨졌던 상황(JobApply 페이지, labels.ts)을 발견해 직접 수정하고
원인을 문서로 남겼으며, 매일 팀원 작업 내역을 README에 반영해 팀 전체
진행 상황을 공유했다.</p>
<ol start="5">
<li>본인이 담당한 파일</li>
</ol>
<hr>
<p>[백엔드 - 회원/인증]</p>
<ul>
<li>backend/src/main/java/com/carematch/member/service/MemberService.java<ul>
<li>회원가입, 회원정보 조회/수정, 탈퇴 처리</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/member/controller/MemberController.java<ul>
<li>회원 관련 API 엔드포인트</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/member/domain/Member.java<ul>
<li>회원 엔티티</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/auth/controller/AuthController.java<ul>
<li>로그인/토큰 발급 API</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/auth/dto/AuthDtos.java<ul>
<li>인증 요청/응답 DTO</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/security/oauth2/CustomOAuth2UserService.java<ul>
<li>카카오/네이버/구글 OAuth2 사용자 정보 처리</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/security/oauth2/OAuth2SuccessHandler.java<ul>
<li>소셜 로그인 성공 후 JWT 발급 처리</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/security/CustomUserDetails.java<ul>
<li>Spring Security 인증 주체 구현</li>
</ul>
</li>
</ul>
<p>[백엔드 - 알림]</p>
<ul>
<li>backend/src/main/java/com/carematch/notification/service/NotificationService.java<ul>
<li>지원결과/승인/문의답변 알림 생성 로직</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/notification/controller/NotificationController.java<ul>
<li>알림 조회/읽음처리 API</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/notification/domain/Notification.java<ul>
<li>알림 엔티티</li>
</ul>
</li>
</ul>
<p>[백엔드 - 포인트/결제]</p>
<ul>
<li>backend/src/main/java/com/carematch/point/PointChargeService.java<ul>
<li>포트원 결제 서버 검증, 포인트 충전 처리</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/point/PointService.java<ul>
<li>포인트 차감/지급, 동시성 처리</li>
</ul>
</li>
</ul>
<p>[백엔드 - 구인공고]</p>
<ul>
<li>backend/src/main/java/com/carematch/jobposting/service/JobPostingService.java<ul>
<li>구인공고 조회/검색/정렬(매칭점수순 포함)</li>
</ul>
</li>
<li>backend/src/main/java/com/carematch/jobposting/controller/JobPostingController.java<ul>
<li>구인공고 API 엔드포인트</li>
</ul>
</li>
</ul>
<p>[백엔드 - 공통/설정]</p>
<ul>
<li>backend/src/main/java/com/carematch/common/exception/ErrorCode.java<ul>
<li>공통 에러코드 정의</li>
</ul>
</li>
<li>backend/src/main/resources/application.yml / application-prod.yml<ul>
<li>서버 포트, DB, CORS 등 환경 설정</li>
</ul>
</li>
<li>backend/src/main/resources/db/migration/V11__notification.sql,
V12__point_charge.sql<ul>
<li>Flyway 마이그레이션 스크립트</li>
</ul>
</li>
</ul>
<p>[프론트엔드 연동]</p>
<ul>
<li>frontend/src/api/notifications.ts, frontend/src/api/auth.ts<ul>
<li>알림/인증 API 요청 처리</li>
</ul>
</li>
<li>frontend/src/pages/OAuthCallback/index.tsx<ul>
<li>소셜 로그인 콜백 처리 화면</li>
</ul>
</li>
<li>frontend/src/pages/Notifications/index.tsx<ul>
<li>알림 목록 화면</li>
</ul>
</li>
</ul>
<p>[문서/협업]</p>
<ul>
<li>CLAUDE.md - 팀 협업 규칙(브랜치 전략, 커밋 컨벤션, PR 절차) 문서화</li>
<li>README.md - 팀원별 작업 내역 및 프로젝트 진행 상황 문서화</li>
<li>docs/FRONTEND_INTEGRATION_TODO.md - 프론트-백엔드 연동 체크리스트</li>
<li>docs/RENDER_ACCESS_RECOVERY.md - 배포 장애 트러블슈팅 문서</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 109]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-109</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-109</guid>
            <pubDate>Sun, 20 Sep 2026 23:45:04 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="🏥-carematch---요양보호사-매칭-플랫폼-프로젝트-회고">🏥 CareMatch - 요양보호사 매칭 플랫폼 프로젝트 회고</h1>
<p>국비과정 4인 팀 프로젝트로 진행한 <strong>CareMatch</strong>를 발표까지 마쳤다. 기획부터 배포, 발표까지의 과정을 정리해본다.</p>
<p>🔗 서비스 링크: <code>https://care-match-lake.vercel.app</code></p>
<hr>
<h2 id="1-서비스-소개">1. 서비스 소개</h2>
<p><strong>한 줄 소개</strong></p>
<blockquote>
<p>CareMatch는 요양보호사·간병인·가사도우미와 요양시설을 연결하는 구인구직 매칭 플랫폼입니다.</p>
</blockquote>
<p>고령화 사회로 접어들면서 요양 인력에 대한 수요는 늘고 있지만, 정작 구직자와 시설을 연결해주는 서비스는 파편화되어 있다는 문제의식에서 출발했다. 단순 매칭을 넘어 <strong>조건 기반 매칭 점수</strong>까지 제공하는 게 핵심 차별점이다.</p>
<p><strong>타겟 사용자</strong></p>
<ul>
<li><strong>구직자</strong> (요양보호사·간병인·가사도우미) — 일자리 검색, 지원, 프로필/자격증 관리, &quot;인증구직자&quot; 마크로 신뢰도 표시</li>
<li><strong>시설</strong> (요양원·방문요양센터 등) — 공고 등록, 인재 검색, 지원자 관리</li>
<li><strong>보호자회원</strong> — 구직 의사 없이 개인적으로 요양보호사를 찾는 소비자 계정</li>
</ul>
<hr>
<h2 id="2-핵심-기능">2. 핵심 기능</h2>
<h3 id="회원--로그인">회원 / 로그인</h3>
<ul>
<li>구직자 / 시설 / 보호자 회원가입을 분리했고, 시설은 사업자등록증 첨부 후 관리자 승인 전까지 대기 상태로 처리</li>
<li>아이디·비밀번호 로그인(JWT) + 카카오 소셜 로그인<ul>
<li>백엔드는 네이버·구글까지 3사 공용 구조로 만들어뒀지만, 심사 리스크를 줄이기 위해 프론트에는 카카오만 노출</li>
</ul>
</li>
<li>로그인 5회 연속 실패 시 15분 계정 잠금</li>
<li>비밀번호 찾기, 회원 탈퇴 시 재로그인 세션 전부 무효화</li>
<li>고령 사용자를 고려한 &quot;쉬운 화면 모드&quot; / 글자 크기 조절 — 기기 저장 + 로그인 시 서버 동기화로 다른 기기에서도 유지</li>
</ul>
<h3 id="구인공고-시설-→-구직자">구인공고 (시설 → 구직자)</h3>
<ul>
<li>등록·수정·마감·임시저장 지원</li>
<li>정렬 옵션: 추천순(노출등급+매칭점수) / 최신순 / 마감임박순 / 급여순 / 조회순 / 매칭점수순</li>
<li>카카오맵 연동으로 위치 기반 반경 검색, 지도 뷰포트 안의 공고 마커 표시 및 클러스터링</li>
<li>스크랩(찜) 기능, 유료 상단노출 등급(NORMAL / PREMIUM / SPECIAL)</li>
</ul>
<h3 id="매칭-시스템-핵심-차별점">매칭 시스템 (핵심 차별점)</h3>
<p>구직자가 등록한 희망조건(직종·지역·근무형태·근무시간대·희망급여)과 공고 조건을 비교해 <strong>0~100점 매칭 점수</strong>를 자동 계산한다.</p>
<ul>
<li>가중치: 직종 35 + 지역 30 + 근무형태/시간대 20 + 급여 15</li>
<li>공고 상세에는 왜 이 점수가 나왔는지 매칭 이유(사유 칩)까지 함께 보여준다 (예: &quot;직종 일치&quot;, &quot;지역 불일치&quot;)</li>
</ul>
<h3 id="인재정보-시설-→-구직자-찾기">인재정보 (시설 → 구직자 찾기)</h3>
<ul>
<li>승인된 시설회원만 지역/직종/경력/자격증/희망급여 등 조건으로 인재 검색 가능</li>
<li>개인정보 비공개 원칙 — 목록/상세 모두 이름을 마스킹하고, 연락처는 포인트로 &quot;열람(unlock)&quot;해야 확인 가능하며 한 번 열람하면 재열람은 무료</li>
</ul>
<h3 id="구직신청-인증구직자-마크">구직신청, 인증구직자 마크</h3>
<ul>
<li>공고 상세에서 온라인 지원/취소, 시설은 지원자 수락·반려 처리</li>
<li>승인된 자격증 + 승인된 경력인증을 각 1건 이상 보유해야 &quot;인증구직자&quot; 마크를 신청할 수 있고, 관리자 최종 승인 시 부여</li>
</ul>
<h3 id="포인트--결제">포인트 &amp; 결제</h3>
<ul>
<li>공고 등록, 인재 연락처 열람 시 포인트가 차감되는 구조</li>
<li><strong>포트원(PortOne) V2</strong>로 실제 결제 연동 — 서버가 결제 금액/상태를 포트원 서버 API로 재조회해 검증(클라이언트 위변조 방지)</li>
</ul>
<h3 id="알림-고객센터--관리자">알림, 고객센터 &amp; 관리자</h3>
<ul>
<li>지원 결과, 시설 승인/반려, 문의 답변, 마크 승인/반려 시점에 알림 생성 — 헤더 배지가 30초 주기로 폴링</li>
<li>공지사항/FAQ/1:1 문의, 관리자용 회원 관리·심사·통계 기능 일체 구현</li>
</ul>
<h3 id="파일-업로드">파일 업로드</h3>
<p>자격증, 경력인증 증빙, 사업자등록증, 프로필 사진 등은 <strong>presigned URL</strong> 방식으로 업로드해서, 서버를 거치지 않고 클라이언트가 스토리지에 직접 업로드하도록 만들어 서버 부하를 줄였다.</p>
<hr>
<h2 id="3-기술-스택">3. 기술 스택</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>기술</th>
</tr>
</thead>
<tbody><tr>
<td>프론트엔드</td>
<td>React 19, Vite, TypeScript, Tailwind CSS, React Router — Vercel 배포</td>
</tr>
<tr>
<td>백엔드</td>
<td>Spring Boot 3, Spring Security(JWT/OAuth2), Spring Data JPA — Render 배포(Docker)</td>
</tr>
<tr>
<td>DB</td>
<td>PostgreSQL(Neon), 로컬은 H2 인메모리</td>
</tr>
<tr>
<td>마이그레이션</td>
<td>Flyway (운영에만 자동 적용)</td>
</tr>
<tr>
<td>파일 스토리지</td>
<td>Cloudflare R2 (S3 호환, presigned URL)</td>
</tr>
<tr>
<td>결제</td>
<td>포트원(PortOne) V2</td>
</tr>
<tr>
<td>지도</td>
<td>카카오맵 SDK</td>
</tr>
<tr>
<td>CI</td>
<td>GitHub Actions (PR마다 프론트/백엔드 자동 빌드·린트·타입체크)</td>
</tr>
</tbody></table>
<p>전부 무료/저비용 티어로 구성해서 실제 서비스처럼 엔드투엔드 배포까지 완료한 게 포인트다.</p>
<hr>
<h2 id="4-아키텍처--협업-구조">4. 아키텍처 &amp; 협업 구조</h2>
<h3 id="전체-구조">전체 구조</h3>
<pre><code>[React (Vercel)]  ──HTTPS/JWT──▶  [Spring Boot API (Render, Docker)]
                                        │
                                        ├──▶ PostgreSQL (Neon)
                                        ├──▶ Cloudflare R2 (파일 presigned URL)
                                        └──▶ PortOne (결제 검증)</code></pre><p>모노레포로 <code>backend/</code>, <code>frontend/</code>, <code>docs/</code>를 분리했고, 프론트/백엔드가 완전히 분리된 SPA + REST API 구조에 JWT(Access/Refresh) 기반 인증을 적용했다.</p>
<h3 id="협업-프로세스">협업 프로세스</h3>
<p>4명이 겹치지 않고 개발하기 위해 아래 규칙을 세웠다.</p>
<ul>
<li><strong>브랜치 전략</strong>: GitHub Flow — <code>main</code>(직접 push 금지) + <code>feature/*</code>. 담당 영역별 접두사를 고정해서(<code>feature/fe-*</code>, <code>feature/be-*</code>) 브랜치 이름만 봐도 누가 어느 영역을 작업 중인지 파악 가능</li>
<li><strong>커밋 컨벤션</strong>: <code>feat:</code> / <code>fix:</code> / <code>refactor:</code> / <code>style:</code> / <code>docs:</code> / <code>chore:</code> 태그로 의도 구분, 작업 중간엔 <code>wip:</code> 커밋 후 나중에 Squash merge</li>
<li><strong>PR 규칙</strong>: 팀원 1명 이상의 리뷰 승인 후에만 머지, 승인/머지는 항상 사람이 GitHub에서 직접 진행</li>
<li><strong>CI 게이트</strong>: PR을 열면 GitHub Actions가 프론트(lint→tsc→build)/백엔드(gradle build+test)를 자동 실행해서 리뷰 전에 빌드 깨짐을 걸러냄</li>
<li><strong>배포 태깅</strong>: 배포 시점마다 <code>main</code>에 <code>vX.Y.Z</code> 태그만 남기고, <code>main</code> push 시 Vercel/Render가 자동 배포</li>
</ul>
<h3 id="백엔드-도메인-구조">백엔드 도메인 구조</h3>
<p><code>member</code>, <code>auth</code>, <code>jobposting</code>, <code>application</code>, <code>certificate</code>, <code>badge</code>, <code>contact</code>, <code>notification</code>, <code>point</code>, <code>terms</code>, <code>verification</code>, <code>support</code>, <code>storage</code>, <code>security</code> — 도메인 단위로 패키지를 나누고, 각 도메인 안에서 controller/service/repository/domain/dto를 분리해 기능 단위로 독립적으로 개발·리뷰할 수 있게 구성했다.</p>
<h3 id="보안설계-포인트">보안/설계 포인트</h3>
<ul>
<li>presigned URL 기반 업로드/다운로드 — 영구 공개 URL 없이 만료 시간 존재</li>
<li>인재 정보(연락처 등)는 승인된 시설회원만 접근 가능하며, 마스킹 + 포인트 열람 구조로 이중 보호</li>
<li>로그인 실패 잠금, Refresh Token 해시 저장(SHA-256)</li>
<li>결제/포인트처럼 &quot;확인 → 부수효과 → 저장&quot; 흐름은 <strong>비관적 락</strong>으로 동시요청 이중처리를 방지</li>
<li>개인정보(전화번호·거주지)는 엔티티에는 원본을, DTO 응답에서만 마스킹하는 원칙을 일관되게 적용</li>
</ul>
<hr>
<h2 id="5-개발-과정에서-겪은-문제들">5. 개발 과정에서 겪은 문제들</h2>
<p>실제로 부딪혔던 문제와 해결 과정을 정리하면 아래와 같다.</p>
<ul>
<li><strong>CORS</strong>: Vercel 프리뷰 배포마다 서브도메인이 바뀌어 고정 목록으로 막힘 → 패턴 매칭(<code>AllowedOriginPatterns</code>)으로 전환</li>
<li><strong>Render 배포 실패</strong>: 동적 포트 미바인딩 문제 → <code>server.port=${PORT:8080}</code>으로 해결</li>
<li><strong>Hibernate <code>@Lob</code> 이슈</strong>: PostgreSQL에서 문자열 컬럼이 <code>oid</code>로 생성되는 버그 → <code>TEXT</code> 컬럼 명시 + Flyway 마이그레이션 도입</li>
<li><strong>병합 충돌 잔여물 커밋 사고</strong>: 리뷰 없이 머지되며 충돌 마커가 그대로 커밋되어 배포 브랜치가 깨짐 → GitHub Actions CI 도입으로 재발 방지</li>
<li><strong>결제/포인트 동시요청 이중처리</strong>: &quot;확인 → 차감/적립 → 저장&quot; 흐름에서 예외를 catch하는 방식으로는 막을 수 없다는 걸 동시성 테스트로 직접 재현 → 비관적 락으로 재설계</li>
<li><strong>카카오 로그인 연동</strong>: 이메일 동의항목은 사업자 심사가 필요해서, 닉네임만으로 로그인 가능하도록 임시 이메일 placeholder로 우회</li>
</ul>
<hr>
<h2 id="6-마무리--향후-계획">6. 마무리 &amp; 향후 계획</h2>
<p><strong>현재 구현 완료 범위</strong>: 회원가입/로그인(소셜 포함)/비밀번호 찾기/회원 탈퇴, 구인공고 CRUD/검색/지도/매칭점수순 정렬, 매칭 점수 계산, 인재검색+연락처 열람, 구직신청, 인증구직자 마크, 포인트 실결제(포트원), 알림, 관리자 대시보드, 고객센터, 파일 업로드, 접근성 설정까지 — 엔드투엔드로 배포된 상태다.</p>
<p><strong>향후 과제</strong></p>
<ul>
<li>회원가입 인증코드가 아직 mock 상태 (서버 로그에만 찍히고 실제 이메일/SMS 미발송)</li>
<li>프론트 테스트 코드 0개, 에러 모니터링 미도입</li>
<li>시설 상세 정보(시설유형/담당자 직책 등) 확장</li>
<li>거동등급 표기 추가</li>
</ul>
<hr>
<h2 id="발표를-마치며">발표를 마치며</h2>
<p>기획부터 배포까지 4명이 나눠서 진행한 프로젝트를 실제로 발표까지 해보니, 기능 구현 자체보다도 <strong>CORS, 배포 환경, 동시성 문제처럼 실제 서비스 단계에서만 만날 수 있는 이슈들</strong>을 겪고 해결한 경험이 가장 값진 부분이었다. 특히 결제/포인트 동시요청 문제를 직접 테스트로 재현하고 비관적 락으로 재설계했던 과정은 이후 포트폴리오에서도 강조할 만한 부분인 것 같다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 108]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-108</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-108</guid>
            <pubDate>Thu, 17 Sep 2026 23:44:03 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="carematch-접근성-버그로-돌아본-완성-이후의-과제">CareMatch 접근성 버그로 돌아본 &#39;완성 이후&#39;의 과제</h1>
<h2 id="오늘-고친-것">오늘 고친 것</h2>
<p>CareMatch에는 사용자가 화면의 글자 크기를 키울 수 있는 접근성 토글 기능이 있습니다. 요양보호사·구직자 등 시니어 사용자 비중이 높은 서비스 특성상, 처음부터 넣어둔 기능입니다.</p>
<p>그런데 오늘 이 토글을 켜고 화면을 살펴보다가 두 가지 문제를 발견했습니다.</p>
<ol>
<li><strong>홈 화면 구인공고 테이블</strong>: 헤더 컬럼 폭이 <code>96px</code>, <code>104px</code>처럼 고정 px 값으로 박혀 있었습니다. 글자 크기가 커져도 컬럼 폭은 그대로였기 때문에 &quot;급여&quot;, &quot;근무 시간대&quot; 같은 텍스트가 줄바꿈되며 테이블 밖으로 삐져나왔습니다.</li>
<li><strong>검색 버튼 · 인재 카드</strong>: 검색바의 &quot;검색&quot; 버튼도 <code>w-[120px]</code> 같은 고정 폭이라 아이콘+텍스트가 버튼 밖으로 튀어나왔고, 메인 화면 &quot;최신 인재정보&quot; 카드의 갱신일 항목은 <code>truncate</code> 처리가 빠져 있어 날짜가 카드 밖으로 넘쳤습니다.</li>
</ol>
<p>원인은 하나로 요약됩니다. <strong>글자 크기 토글은 <code>rem</code> 기반으로 동작하는데, 레이아웃 폭은 <code>px</code>로 고정되어 있었다</strong>는 것. 텍스트만 커지고 그릇은 그대로였으니 당연히 넘칠 수밖에 없었습니다. 수정은 단순했습니다 — <code>96px</code> → <code>6rem</code>, <code>120px</code> → <code>7.5rem</code>처럼 동일한 크기의 <code>rem</code> 값으로 바꿔서 글자 크기와 컨테이너 폭이 함께 비례하도록 했고, 누락된 <code>truncate</code>를 자격증 항목과 동일하게 추가했습니다.</p>
<h2 id="왜-이게-그냥-버그-하나로-끝날-이야기가-아닌가">왜 이게 그냥 &quot;버그 하나&quot;로 끝날 이야기가 아닌가</h2>
<p>수정 자체는 30분도 안 걸렸습니다. 하지만 이 버그가 알려주는 건 더 근본적인 질문입니다.</p>
<blockquote>
<p>&quot;우리 프로젝트에 <code>px</code>로 폭이 고정된 곳이 이 세 군데뿐일까?&quot;</p>
</blockquote>
<p>정확히 말하면 아닙니다. 이번에 고친 건 <strong>우연히 눈에 띈 곳</strong>이었을 뿐, 같은 패턴(고정 px 폭 + rem 기반 폰트 스케일 조합)이 코드베이스 다른 곳에도 얼마든지 숨어 있을 수 있습니다. 즉 이번 커밋 두 개는 &quot;증상 치료&quot;였고, &quot;이 클래스의 버그가 왜 계속 나오는가&quot;라는 원인은 아직 손대지 않은 상태입니다.</p>
<p>프로젝트를 처음 만들 때는 기능을 붙이는 속도가 우선이라 이런 디테일이 뒤로 밀리기 쉽습니다. 하지만 데모/발표가 끝나고 &quot;완성&quot;이라는 딱지가 붙은 뒤에야 비로소 이런 것들을 정리할 여유가 생기는 경우가 많습니다. 그래서 이번 경험을 계기로, 프로젝트 완성 이후에 보완하면 좋을 주제들을 정리해봤습니다.</p>
<h2 id="완성-이후-보완-주제-리스트">완성 이후 보완 주제 리스트</h2>
<h3 id="1-고정-px-폭-전수-점검--디자인-토큰화">1. 고정 px 폭 전수 점검 + 디자인 토큰화</h3>
<p><code>grep -rn &quot;w-\[.*px\]&quot;</code> 같은 검색으로 프로젝트 전체에서 고정 px 폭을 쓰는 곳을 찾아, 폰트 스케일과 함께 늘어나야 하는 요소(버튼, 테이블 컬럼, 카드 내부 요소)를 전부 <code>rem</code> 단위로 통일하는 작업이 필요합니다. 더 나아가서는 Tailwind 설정에 <code>spacing</code>/<code>width</code> 커스텀 토큰을 <code>rem</code> 기준으로 정의해서, 애초에 <code>px</code> 하드코딩이 불가능하게 만드는 규칙(예: ESLint 커스텀 룰, 코드리뷰 체크리스트)을 두는 것도 고려할 만합니다.</p>
<h3 id="2-글자-크기-토글에-대한-회귀-테스트">2. 글자 크기 토글에 대한 회귀 테스트</h3>
<p>이번 버그는 사람이 눈으로 토글을 켜보다가 발견했습니다. 시각적 회귀 테스트(Playwright + 스크린샷 비교, 또는 Storybook의 접근성 애드온)를 붙여서, 글자 크기를 키운 상태의 주요 화면(홈, 검색 결과, 상세 카드)을 자동으로 스냅샷 비교하면 이런 문제를 배포 전에 잡을 수 있습니다.</p>
<h3 id="3-truncate-남용-여부-재검토">3. <code>truncate</code> 남용 여부 재검토</h3>
<p>이번엔 갱신일에 <code>truncate</code>를 &quot;추가&quot;해서 고쳤지만, 사실 <code>truncate</code>는 정보를 잘라서 안 보이게 하는 임시방편에 가깝습니다. 글자 크기를 키우는 사용자는 애초에 &quot;더 잘 보고 싶은&quot; 사용자인데, 정작 중요한 정보(날짜, 급여 등)가 말줄임표로 잘려버리면 목적에 어긋납니다. 완성 후에는 <code>truncate</code> 대신 <code>flex-wrap</code>이나 카드 레이아웃 자체를 세로로 풀어주는 방식처럼, 글자가 커져도 정보 손실 없이 자연스럽게 줄바꿈되는 패턴으로 바꾸는 걸 검토할 가치가 있습니다.</p>
<h3 id="4-접근성-qa를-기능이-아니라-품질-게이트로">4. 접근성 QA를 &quot;기능&quot;이 아니라 &quot;품질 게이트&quot;로</h3>
<p>글자 크기 확대는 CareMatch에서 부가 기능이 아니라, 실제 사용자층(시니어 구직자)을 위한 핵심 접근성 기능입니다. 그런데 지금까지는 새 컴포넌트를 만들 때 &quot;기본 크기에서 예쁜가&quot;만 확인하고, &quot;글자 크기를 최대로 키워도 안 깨지는가&quot;는 체크리스트에 없었습니다. PR 템플릿이나 코드리뷰 체크리스트에 &quot;폰트 스케일 확대 상태에서 확인했는가&quot; 항목을 넣는 것만으로도 이런 류의 버그 재발을 줄일 수 있습니다.</p>
<h3 id="5-저시력·고령-사용자를-위한-접근성-범위-확장">5. 저시력·고령 사용자를 위한 접근성 범위 확장</h3>
<p>글자 크기 확대는 시작일 뿐입니다. 명도 대비(다크모드/고대비 모드), 클릭 영역 크기(버튼이 손가락 터치에 충분히 큰가), 스크린리더 대응(<code>aria-label</code>이 실제로 의미 있게 채워져 있는가) 등도 완성 이후 순차적으로 점검할 만한 주제입니다.</p>
<h2 id="마무리">마무리</h2>
<p>기능이 다 완성되고 나면 &quot;이제 끝났다&quot;는 안도감이 들지만, 사실 진짜 사용자가 실제 기기·실제 설정으로 서비스를 켰을 때 드러나는 문제는 그때부터 시작입니다. 오늘의 버그 두 개는 작았지만, &quot;완성&quot;이라는 것이 기능 목록을 다 채우는 것이 아니라 그 기능이 다양한 사용자 조건에서도 일관되게 동작하는 것을 뜻한다는 걸 다시 한번 확인시켜 준 하루였습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 107]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-107</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-107</guid>
            <pubDate>Wed, 16 Sep 2026 23:47:03 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="carematch-프로젝트-회고">CareMatch 프로젝트 회고</h1>
<blockquote>
<p>요양보호사·간병인·가사도우미와 요양시설을 연결하는 구인구직 매칭 웹 서비스를 만들며 남기는 정리 글.
기간: 2026-09-07 ~ (발표 2026-09-21)</p>
</blockquote>
<hr>
<h2 id="1-프로젝트-개요">1. 프로젝트 개요</h2>
<p><strong>CareMatch</strong>는 요양보호사·간병인·가사도우미(구직회원)와 요양시설(시설회원), 그리고 개인적으로 요양보호사를 찾는 소비자(보호자회원)를 연결하는 구인구직 매칭 플랫폼이다. 여기에 운영을 위한 관리자 계정까지 더해 4종의 회원 유형이 존재한다.</p>
<ul>
<li>서비스(프론트): <a href="https://care-match-lake.vercel.app">https://care-match-lake.vercel.app</a></li>
<li>API 서버(백엔드): <a href="https://carematch-gtke.onrender.com">https://carematch-gtke.onrender.com</a></li>
<li>팀: 4인 (백엔드 2 · 프론트엔드 2)</li>
<li>저장소: 모노레포 (<code>backend/</code> · <code>frontend/</code> · <code>docs/</code>)</li>
</ul>
<p>10일 남짓한 기간 동안 331개 커밋, 146건의 PR 머지가 쌓였다. 매일 새 기능이 붙고, 그만큼 버그도 그날그날 잡아나간 프로젝트였다.</p>
<h2 id="2-기술-스택">2. 기술 스택</h2>
<table>
<thead>
<tr>
<th>구분</th>
<th>기술</th>
</tr>
</thead>
<tbody><tr>
<td>프론트엔드</td>
<td>React 19 · Vite · TypeScript · Tailwind CSS · React Router (Vercel 배포)</td>
</tr>
<tr>
<td>백엔드</td>
<td>Spring Boot 3 · Spring Security(JWT/OAuth2) · Spring Data JPA (Render 배포, Docker)</td>
</tr>
<tr>
<td>DB</td>
<td>PostgreSQL(Neon) — 로컬은 H2 인메모리</td>
</tr>
<tr>
<td>마이그레이션</td>
<td>Flyway (<code>prod</code>만 자동 적용, <code>local</code>/<code>test</code>는 H2 create-drop)</td>
</tr>
<tr>
<td>파일 스토리지</td>
<td>Cloudflare R2 (S3 호환, presigned URL)</td>
</tr>
<tr>
<td>결제</td>
<td>포트원(PortOne) V2 — 서버가 결제 결과를 재조회해 검증</td>
</tr>
<tr>
<td>지도</td>
<td>카카오맵 SDK — 반경 검색 + 클러스터링</td>
</tr>
</tbody></table>
<p>배포 스택은 처음에 &quot;여러 서비스를 써봤다&quot;는 방향으로 흩어놓기보다, Cloudflare R2를 실제로 구현하는 <strong>깊이</strong>를 포트폴리오 카드로 삼기로 팀 안에서 정리했다. 그 결과 프론트는 Vercel, 백엔드는 Render, DB는 Neon으로 각자 역할이 뚜렷한 스택이 됐다(파일 스토리지만 Cloudflare R2로 실제 presigned URL 흐름까지 구현).</p>
<h2 id="3-팀-구성과-역할">3. 팀 구성과 역할</h2>
<table>
<thead>
<tr>
<th>이름</th>
<th>역할</th>
<th>GitHub</th>
</tr>
</thead>
<tbody><tr>
<td>혁 (팀장)</td>
<td>백엔드</td>
<td>@heo-hyuk</td>
</tr>
<tr>
<td>신영</td>
<td>프론트엔드</td>
<td>@syyu21b</td>
</tr>
<tr>
<td>경수</td>
<td>백엔드</td>
<td>@HurKyungsoo</td>
</tr>
<tr>
<td>동한</td>
<td>프론트엔드</td>
<td>@Kim-dong-han</td>
</tr>
</tbody></table>
<p>역할별로 브랜치 접두사(<code>feature/be-*</code>, <code>feature/fe-*</code>)를 나누고, <code>main</code>은 직접 push를 막아 PR로만 병합하는 GitHub Flow를 초반부터 고정했다. 배포 시점은 별도 브랜치 대신 <code>main</code>에 태그(<code>vX.Y.Z</code>)로만 남기기로 했다.</p>
<h2 id="4-주요-기능">4. 주요 기능</h2>
<ul>
<li><strong>회원</strong>: 구직/시설/보호자(GENERAL) 3종 + 관리자, 아이디·비밀번호 로그인(JWT), 카카오 소셜 로그인(백엔드는 네이버·구글도 지원하는 3사 공용 구조지만 프론트는 카카오만 노출), 로그인 실패 잠금, 비밀번호 찾기, 회원 탈퇴</li>
<li><strong>구인공고</strong>: 등록·수정·마감, 추천순/최신순/매칭점수순 정렬, 내 주변 일자리(반경 검색 + 지도 클러스터링), 임시저장, 스크랩</li>
<li><strong>인재정보</strong>: 구직자 프로필, 인재 검색, 공고↔인재 매칭도 계산</li>
<li><strong>인증구직자 마크</strong>: 자격증·경력인증 각 1건 이상 보유 시 신청 가능, 관리자 승인(또는 수동 부여/해제)으로 부여</li>
<li><strong>구직신청</strong>: 온라인 지원/취소/수락·반려</li>
<li><strong>포인트/결제</strong>: 포트원 V2 실 결제 연동, 서버 측 결제 재검증으로 클라이언트 위변조 방지</li>
<li><strong>알림</strong>: 지원 결과·시설 승인·문의 답변 시 생성, 헤더 배지 폴링(30초)</li>
<li><strong>관리자 대시보드</strong>: 회원 검색, 포인트 내역, 시설 승인/반려, 1:1 문의 답변, 공지 CRUD</li>
<li><strong>접근성</strong>: 쉬운 화면 모드 / 글자 크기(기기+서버 동기화)</li>
</ul>
<h2 id="5-개발-흐름">5. 개발 흐름</h2>
<p>초기 며칠은 모노레포 구조 전환, CODEOWNERS·CI 세팅, 회원/인증 도메인처럼 다른 모든 기능이 딛고 설 기반을 다지는 데 썼다. 이후로는 구인공고 → 인재정보/구직신청 → 포인트·결제 → 인증구직자 마크 → 알림/관리자 순으로 도메인이 하나씩 늘었고, 마지막 며칠은 매칭점수 정렬, 모바일 반응형, 회원가입 중복 알림 같은 다듬기 작업과 README 갱신이 이어졌다.</p>
<p>PR은 총 146건, 거의 매 기능 단위로 쪼개 올렸고 리뷰 승인 후 머지하는 흐름을 지켰다. <code>wip:</code> 커밋으로 미완성 작업을 중간 저장했다가, main으로 올라갈 때는 Squash and merge로 압축해 히스토리를 깔끔하게 유지했다.</p>
<h2 id="6-트러블슈팅--겪고-넘은-것들">6. 트러블슈팅 — 겪고 넘은 것들</h2>
<p>기록해둘 만한 이슈만 추리면 이렇다.</p>
<ol>
<li><strong>JDK 버전 &amp; Gradle wrapper 누락</strong> — wrapper 미커밋으로 clone 직후 빌드 불가. <code>build.gradle</code>의 toolchain 고정을 걷어내고 <code>options.release = 17</code>로 전환, wrapper 커밋 + <code>.gitattributes</code>로 개행 문자 고정.</li>
<li><strong>SecurityConfig 순환참조</strong> — <code>SecurityConfig → OAuth2SuccessHandler → AuthService → PasswordEncoder(SecurityConfig 내부 빈)</code>로 자기 자신을 참조. <code>PasswordConfig</code>로 분리해 해결.</li>
<li><strong>CORS 차단</strong> — Vercel 프리뷰 서브도메인이 매번 바뀌어 고정 origin 목록이 무용지물. <code>setAllowedOriginPatterns</code>로 전환.</li>
<li><strong>모노레포 전환 경로 조정</strong> — Render Root Directory를 <code>backend</code>로, IntelliJ도 <code>backend/</code>만 Gradle 프로젝트로 재임포트.</li>
<li><strong>Render 포트 바인딩 실패</strong> — <code>$PORT</code> 동적 주입을 못 받아 배포 실패. <code>server.port: ${PORT:8080}</code> 추가.</li>
<li><strong>스키마 검증 실패 &amp; <code>@Lob</code>→<code>oid</code> 문제</strong> — 운영 DB가 비어 있는데 <code>ddl-auto: validate</code>였던 문제, 그리고 <code>@Lob String</code>이 Hibernate 6+PostgreSQL에서 <code>oid</code> 컬럼으로 생성되던 문제(H2에선 안 드러나 로컬 테스트로는 못 잡음). <code>@Column(columnDefinition = &quot;TEXT&quot;)</code>로 전환하고 Flyway를 도입해 이후 스키마 변경을 마이그레이션 파일 추가만으로 처리하게 정리.</li>
<li><strong>병합 충돌 마커가 그대로 커밋된 사고 (2회)</strong> — 자동 검사가 없던 시절, 사람이 화면을 직접 열어보기 전엔 <code>main</code>이 깨진 걸 몰랐던 사고. GitHub Actions CI(<code>ci-frontend.yml</code>, <code>ci-backend.yml</code>)를 PR 트리거로 붙이고서야 재발이 멈췄다.</li>
<li><strong>카카오 로그인 Redirect URI 등록 위치</strong> — 콘솔 UI 개편으로 로그인용 Redirect URI가 로그아웃 항목이 아니라 &quot;REST API 키 카드&quot; 안으로 이동해 있었음. <code>account_email</code> 동의항목은 카카오 심사(수일)가 필요해, 승인 전까지는 이메일 없이도 로그인이 되도록 임시 이메일 채움 처리로 우회.</li>
</ol>
<h2 id="7-협업-규칙에서-실제로-도움이-됐던-것">7. 협업 규칙에서 실제로 도움이 됐던 것</h2>
<ul>
<li><strong>CI 도입 시점</strong>: 이슈 #8/#10처럼 사람이 놓친 걸 CI가 대신 잡아준 이후로는 같은 유형의 사고가 재발하지 않았다. 규칙은 사고가 난 다음에 강화됐다는 점도 그대로 남겨둘 만하다.</li>
<li><strong>PR 단위를 작게 쪼갠 것</strong>: 기능 하나, 버그 하나 단위로 PR을 올리다 보니 리뷰 부담이 크지 않았고, 머지 후 문제가 생겨도 원인 커밋을 찾기 쉬웠다.</li>
<li><strong>wip 커밋 → squash 규칙</strong>: 작업 중간에 부담 없이 push해둘 수 있으면서도, main 히스토리는 기능 단위로 깔끔하게 남았다.</li>
</ul>
<h2 id="8-남은-과제--향후-계획">8. 남은 과제 / 향후 계획</h2>
<ul>
<li>발표(2026-09-21)까지는 계속 활성 개발 중 — 매칭점수 정렬, 인증구직자 마크 관리자 토글 등도 발표 직전까지 반영됐던 것처럼, 마지막까지 다듬기 작업이 이어질 예정</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 106]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-106</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-106</guid>
            <pubDate>Wed, 16 Sep 2026 00:02:21 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="care-match">Care Match</h1>
<blockquote>
<p>어제 글은 PR #127(결제/포인트 동시요청 이중 처리 버그 수정)까지였다. 그 뒤로 내가 직접 손댄 건 두 가지였다.</p>
</blockquote>
<h2 id="1-회원-탈퇴-기능-131-132">1. 회원 탈퇴 기능 (#131, #132)</h2>
<p>회원 탈퇴는 단순히 &quot;계정 삭제&quot; 버튼 하나가 아니라 몇 가지를 같이 고려해야 했다:</p>
<ul>
<li>탈퇴한 계정의 로그인 세션(Refresh Token)을 무효화할 것 — 비밀번호 찾기 때 만든 세션 무효화 로직을 그대로 재사용했다.</li>
<li>하드 삭제가 아니라 소프트 삭제 방향으로 처리 — 운영 데이터(구인공고, 지원 이력 등)와의 FK 관계를 그대로 끊지 않기 위함.</li>
</ul>
<p><code>#131 feat: 회원 탈퇴 API 추가</code> 로 백엔드를 먼저 올리고, <code>#132 feat: 마이페이지 설정에 회원 탈퇴 버튼 추가</code> 로 프론트까지 직접 붙였다.</p>
<h2 id="2-readme에-팀원별-작업-내역-정리-136-137">2. README에 &quot;팀원별 작업 내역&quot; 정리 (#136, #137)</h2>
<p>발표 이후 프로젝트를 정리하는 김에, 지금까지 4명이 각자 뭘 했는지 README에 한눈에 보이게 남겨두고 싶었다. Claude Code로 커밋 히스토리(9/7~9/16)를 전부 사람별로 묶어서 정리했다.</p>
<p>처음 버전(#136)은 사람마다 항목을 한 줄에 죽 나열하는 방식이었는데, 보고 나니 맥락 없이 나열만 된 느낌이라 다시 요청해서 주제별로 묶었다 — 초기 셋업 / 회원·인증 / 구인공고 / 포인트·결제 같은 소제목 아래로 항목을 다시 묶는 방식(#137).</p>
<p>재밌는 해프닝이 하나 있었다: 첫 번째 커밋(#136)이 이미 머지된 직후에 두 번째 커밋(재정리)을 같은 브랜치에 얹어서 푸시했는데, PR이 이미 닫힌 뒤라 그 커밋은 main에 반영이 안 되고 있었다. 결국 같은 브랜치로 PR을 하나 더 열어서(#137) 마저 반영했다 — &quot;머지됐다고 안심하지 말고 fetch해서 확인하자&quot;는 걸 다시 한번 느낀 순간.</p>
<hr>
<h2 id="3-회원관리에-인증마크-토글-버튼-추가">3. 회원관리에 인증마크 토글 버튼 추가</h2>
<p>정상적인 &quot;인증구직자&quot; 마크 경로는 자격증 심사 승인 + 경력인증 승인 + 본인 신청 + 관리자 최종승인, 총 네 단계를 거쳐야 한다. 테스트 계정 하나에 마크 달아보려고 이 과정을 매번 다 밟는 건 비효율적이라, 관리자 회원관리 화면에 &quot;마크 활성화/해제&quot; 버튼을 만들어 절차를 통째로 건너뛸 수 있게 했다.</p>
<pre><code>PATCH /api/admin/members/{memberId}/verified-badge
{ &quot;granted&quot;: true }</code></pre><p>구직회원이 아닌 계정에 시도하면 400(<code>BADGE_011</code>)으로 막고, 응답으로는 갱신된 회원 요약을 그대로 돌려줘서 화면이 별도 리로드 없이 즉시 갱신되게 했다.</p>
<h2 id="4-머지했는데-에러나는데--알고-보니-배포-지연">4. &quot;머지했는데 에러나는데?&quot; — 알고 보니 배포 지연</h2>
<p>PR 머지하고 바로 &quot;aaaa1&quot;에 눌러봤다는 연락이 왔는데 에러가 난다고 했다. 코드가 잘못됐나 싶어서 다시 열어봤는데 로직상 문제는 안 보였다.</p>
<p><code>/actuator/info</code>로 Render에 실제 배포된 커밋 해시를 찍어보니 방금 머지한 커밋보다 몇 커밋 전 상태였다. 프론트(Vercel)는 이미 새 버튼이 올라가 있었는데, 백엔드(Render)만 재배포가 안 끝나서 새로 만든 엔드포인트 자체가 없었던 것 — 그러니 버튼을 눌러도 404로 실패할 수밖에.</p>
<p>잠깐 기다렸다가 <code>/actuator/info</code>를 다시 찍어보니 최신 커밋으로 바뀌어 있었고, 그 뒤로는 정상 동작했다. 코드 버그가 아니라 단순 배포 타이밍 문제였다 — 그래도 &quot;머지 직후엔 실제 배포가 끝났는지 확인부터 하자&quot;는 걸 다시 배웠다.</p>
<h2 id="5-구인공고-정렬에-매칭점수순-추가">5. 구인공고 정렬에 매칭점수순 추가</h2>
<p>이어서 나온 요청: &quot;구인공고 정렬에 매칭점수순도 있으면 좋겠다.&quot; 기존 추천순(RECOMMENDED)은 유료 노출등급이 1순위고 매칭점수는 같은 등급 안에서만(그것도 페이지 단위 근사치로) 반영되는 구조였는데, 이번엔 등급과 무관하게 순수 매칭점수로만 줄 세우는 별도 옵션을 만들었다.</p>
<p>문제는 매칭점수가 DB 컬럼이 아니라 조회 시점에 계산되는 값이라 SQL <code>ORDER BY</code>로 정렬할 수 없다는 것. 조건에 맞는 공고를 전부(최대 1000건 안전상한) 메모리에 올려서 점수를 매기고, 정렬한 다음, 요청받은 페이지만큼 잘라내는 방식으로 구현했다.</p>
<h2 id="6-근데-등급-상관없이-점수가-1순위여야-하는데--진짜-버그-하나-더">6. &quot;근데 등급 상관없이 점수가 1순위여야 하는데?&quot; — 진짜 버그 하나 더</h2>
<p>만들어서 올렸더니 팀원이 다시 짚었다: &quot;매칭점수순인데 등급 상관없이 점수가 1순위여야 하는 거 아니야?&quot; 코드를 다시 보니 실제로 구멍이 있었다.</p>
<pre><code class="language-java">if (&quot;MATCH_SCORE&quot;.equals(sortKey) &amp;&amp; viewer != null) {
    return searchByMatchScore(...);
}
// viewer == null 이면 여기로 떨어져서
// resolveSort() 의 default 분기(exposurePriority 우선)를 타 버린다</code></pre>
<p>비로그인 상태거나 구직회원이 아닌 계정(관리자로 테스트하는 경우 포함)으로 매칭점수순을 선택하면, &quot;점수 매길 대상이 없다&quot;는 이유로 조용히 기존 추천순 로직(노출등급 우선)으로 빠지고 있었다. 그러니 관리자 계정으로 테스트하면 매칭점수순을 골라도 여전히 유료 노출 공고가 위에 뜨는 것처럼 보였을 것이다.</p>
<p><code>viewer</code> 유무와 무관하게 항상 매칭점수 전용 정렬 경로를 타도록 고쳤다. 대상 점수가 없으면(비로그인 등) 전원 무점수로 묶여 결과적으로 최신순과 같아지긴 하지만, 노출등급이 순서에 끼어드는 일은 어떤 경우에도 없게 만들었다.</p>
<h2 id="7-그-사이-팀원들도">7. 그 사이 팀원들도</h2>
<p>동한씨가 경력인증에 증빙 파일 첨부 기능(텍스트만 받던 걸 실제 서류 업로드로)을 붙였고, 구직신청 등록이 &quot;요청 값이 올바르지 않습니다&quot;로 실패하던 버그도 같이 고쳤다.</p>
<hr>
<h2 id="오늘-올린-pr">오늘 올린 PR</h2>
<table>
<thead>
<tr>
<th>PR</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>#131</td>
<td>회원 탈퇴 API 추가</td>
</tr>
<tr>
<td>#132</td>
<td>마이페이지 설정에 회원 탈퇴 버튼 추가</td>
</tr>
<tr>
<td>#136, #137</td>
<td>README에 팀원별 작업 내역(주제별 그룹) 추가</td>
</tr>
<tr>
<td>#147</td>
<td>관리자 회원관리에서 인증구직자 마크 수동 부여/해제</td>
</tr>
<tr>
<td>#148</td>
<td>구인공고 정렬에 매칭점수순 추가</td>
</tr>
<tr>
<td>#150</td>
<td>매칭점수순이 비구직자에겐 노출등급 우선으로 되돌아가던 버그 수정</td>
</tr>
<tr>
<td>#152</td>
<td>README에 오늘 작업분 반영</td>
</tr>
</tbody></table>
<p>&quot;이제 끝났다&quot; 싶을 때마다 한 걸음씩 더 나간 하루였다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 105]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-105</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-105</guid>
            <pubDate>Mon, 14 Sep 2026 23:48:41 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="carematch">CareMatch</h1>
<hr>
<h2 id="1-아침--발표-준비를-하다가-코드를-다-열어보게-됐다">1. 아침 — 발표 준비를 하다가 코드를 다 열어보게 됐다</h2>
<p>10분짜리 팀 발표를 앞두고 자료를 준비하면서, 결국 프로젝트 전체 구조를 다시 훑게 됐다. 백엔드 도메인 하나하나, 프론트 라우트 하나하나 들여다보면서 &quot;우리가 만든 게 정확히 뭔지&quot;를 발표 대본에 옮기는 작업이었는데, 이 과정 자체가 꽤 괜찮은 코드 리뷰였다.</p>
<p>발표자료를 정리하면서 자연스럽게 다음 질문으로 이어졌다.</p>
<blockquote>
<p>&quot;그래서 이거, 실제 운영되는 서비스랑 비교하면 뭐가 부족해?&quot;</p>
</blockquote>
<p>답을 찾으려고 코드를 더 깊이 파다 보니 흥미로운 것들이 나왔다.</p>
<ul>
<li><strong>인증코드 발송이 전부 mock</strong>이었다. 회원가입 시 이메일/휴대폰 인증코드를 실제로 보내지 않고 서버 로그에만 찍고 있었다. 운영 배포본도 마찬가지였다 — 즉 실사용자는 가입 인증코드를 받을 방법이 없는 상태였다.</li>
<li><strong>포인트 시스템이 완전한 스텁</strong>이었다. <code>StubPointService</code>가 <code>deduct()</code> 호출 시 항상 <code>true</code>를 반환하고, 잔액 조회는 항상 더미값 <code>9999</code>를 내려주고 있었다. 공고 등록도, 연락처 열람도 &quot;포인트를 쓴다&quot;는 흐름만 있고 실제로 차감되는 게 없었다.</li>
<li><strong>관리자 화면이 아예 없었다.</strong> 시설 승인/반려, 공지 관리 같은 API는 있는데 그걸 다룰 프론트 화면이 없어서, 관리자가 할 수 있는 게 사실상 Postman으로 API를 직접 호출하는 것뿐이었다.</li>
<li>그 외에도 알림 기능 부재, 프론트 테스트 0개, 에러 모니터링 없음 같은 것들이 정리됐다.</li>
</ul>
<p>이걸 정리해서 팀 발표 자료에 &quot;향후 계획&quot; 섹션으로 넣었다. 그리고 발표가 끝나자마자, 이 리스트가 그날 하루의 실제 작업 목록이 됐다.</p>
<hr>
<h2 id="2-비밀번호-찾기-기능을-만들다">2. 비밀번호 찾기 기능을 만들다</h2>
<p>가장 먼저 손댄 건 &quot;비밀번호 찾기&quot;였다. 없어서는 안 되는데 없던 기능이다.</p>
<p>설계 방향을 정할 때 고민했던 지점은 하나였다: <strong>이미 만들어져 있는 인증코드 인프라를 재사용할 것인가, 새로 만들 것인가.</strong> 재사용 쪽으로 정했다. 회원가입 때 쓰던 <code>POST /api/verifications/send</code>, <code>/verify</code> 를 그대로 쓰고, 그 위에 <code>POST /api/auth/password-reset</code> 하나만 새로 얹었다.</p>
<p>여기서 실수하기 쉬운 지점이 하나 있었다. &quot;이메일을 인증했다&quot;는 사실만으로 비밀번호를 바꿔주면, 그 이메일이 진짜 그 계정 소유자의 이메일인지 확인하는 절차가 빠지게 된다. 회원가입 로직에 이미 &quot;인증한 대상이 실제 가입 정보와 같은지&quot; 검증하는 코드가 있길래, 그 패턴을 그대로 가져와 재설정 로직에도 넣었다. 그리고 비밀번호를 바꾸면 기존에 로그인해둔 다른 기기의 세션(Refresh Token)도 전부 무효화하도록 했다 — 계정 탈취 시나리오를 생각하면 당연히 있어야 하는 처리다.</p>
<p>로컬에서 정상 케이스 하나, 실패 케이스 다섯 개(남의 인증 재사용 시도, 약한 비밀번호, 존재하지 않는 계정, 소셜 전용 계정, 세션 무효화 확인)를 curl로 직접 돌려보고 나서야 PR을 올렸다.</p>
<hr>
<h2 id="3-팀-전체">3. 팀 전체</h2>
<p>혼자 비밀번호 찾기를 만드는 사이, 팀원들도 각자 자리에서 움직이고 있었다. 몇 시간 사이에 머지된 PR 목록을 보면:</p>
<ul>
<li><strong>회원 유형 정리 + 포인트 충전 실연동</strong> — 아침에 &quot;포인트가 스텁&quot;이라고 짚었던 그 문제가, 신영씨가 포트원(PortOne) 결제를 실제로 붙이면서 반나절 만에 해결됐다. <code>Member.point</code>가 진짜 잔액 컬럼이 됐고, 결제 검증은 클라이언트가 보낸 값을 믿지 않고 서버가 포트원 API로 재조회하는 방식으로 설계돼 있었다.</li>
<li><strong>관리자 대시보드 전체 구현</strong> — 아침에 &quot;관리자 화면이 없다&quot;고 짚었던 것도, 회원관리/포인트충전관리/시설관리/문의관리/공지사항까지 통째로 화면이 생기면서 해결됐다.</li>
<li><strong>내 주변 일자리 지도 기능 개선</strong> — 반경 프리셋, 클러스터링, 지도⟷리스트 동기화까지 하루에만 대여섯 번의 커밋이 오갔다. (이 부분은 나중에 파일 하나가 800줄을 넘어가는 걸 보고 &quot;리팩터링 후보&quot;로 따로 적어뒀다.)</li>
</ul>
<p>나는 여기에 알림 기능을 얹었다. 지원 결과, 시설 승인/반려, 문의 답변 — 세 지점에서 서버가 알림 한 줄을 쌓고, 프론트는 30초 주기로 폴링해서 헤더 종 아이콘 배지를 갱신하는 방식이다. 실시간 푸시는 아니지만, 하루 만에 &quot;동작하는 알림&quot;을 만드는 데는 이 정도가 현실적인 선이라고 판단했다.</p>
<p>한 가지 재밌었던 순간: PR을 올리기 직전에 main을 다시 pull 받았더니, 이미 신영씨가 헤더 컴포넌트에 <code>user.unreadNotifications</code> 라는 필드를 <code>// TODO: 알림 API 연동 시 교체</code> 라는 주석과 함께 미리 만들어 두고 있었다. 같은 기능을 서로 다른 방향에서 준비하고 있었던 셈인데, 딱 맞아떨어져서 그 TODO 자리를 그대로 채우기만 하면 됐다.</p>
<hr>
<h2 id="4-버그-사냥--관리자-마이페이지-이상한-것-같은데">4. 버그 사냥 — &quot;관리자 마이페이지 이상한 것 같은데&quot;</h2>
<p>팀원 한 명이 지나가듯 던진 말 한마디로 오후의 방향이 바뀌었다. 관리자 대시보드 전체를 curl로 하나씩 두드려보기 시작했다.</p>
<p>회원관리, 시설 승인/반려, 공지 CRUD, 포인트 충전 내역 — 다 정상이었다. 그런데 <strong>문의 답변 등록</strong> 흐름에서 이상한 걸 발견했다.</p>
<pre><code>POST /api/admin/support/inquiries/1/replies
→ 응답: { &quot;replies&quot;: [{ &quot;id&quot;: null, &quot;content&quot;: &quot;답변드립니다&quot;, ... }] }</code></pre><p>답변을 달았는데 그 답변의 <code>id</code>가 <code>null</code>로 돌아온다. 원인을 찾아보니 <code>InquiryReply</code>는 IDENTITY 채번 전략을 쓰는데, 서비스 코드가 <code>inquiry.addReply(...)</code> 로 컬렉션에 추가만 하고 flush 없이 바로 응답 DTO를 만들고 있었다. Hibernate 입장에서는 아직 INSERT를 실행하지 않았으니 id를 모르는 게 당연했다.</p>
<p>이게 왜 문제냐면, 프론트가 이 <code>id</code>를 React 리스트의 <code>key</code>로 쓰고 있었다. 관리자가 한 문의에 답변을 연달아 두 번 등록하면(새로고침 없이), 두 번째 답변도 <code>id: null</code>이 되면서 React가 리스트 항목을 헷갈려 할 수 있는 상황이었다. <code>flush()</code> 한 줄로 해결했다.</p>
<hr>
<h2 id="5-전체-스캔해서-문제-있으면-알려줘--그리고-진짜-심각한-걸-찾았다">5. &quot;전체 스캔해서 문제 있으면 알려줘&quot; — 그리고 진짜 심각한 걸 찾았다</h2>
<p>문의 답변 버그를 고치고 나서, 팀원이 전체 코드 스캔을 요청했다. TODO 주석, 남은 console.log, 시크릿 커밋 여부 같은 걸 훑고 나서, 결제/포인트 로직을 조금 더 깊게 들여다봤다. 그날 새로 실연동된 포트원 결제 코드였기 때문에 가장 리스크가 높은 부분이라고 판단했다.</p>
<p>그리고 진짜 문제를 찾았다.</p>
<h3 id="5-1-포인트-이중-적립">5-1. 포인트 이중 적립</h3>
<pre><code class="language-java">// PointChargeService.complete()
PointCharge charge = pointChargeRepository.findByPaymentId(paymentId)...
if (!charge.isPending()) {
    return alreadyProcessedResponse; // 중복 방지용 체크
}
// ... 포트원 서버에 검증 요청 ...
charge.markPaid();
pointService.credit(memberId, charge.getAmount(), ...);</code></pre>
<p><code>isPending()</code> 체크가 있으니 중복 호출을 막는 것처럼 보이지만, 이 체크 자체에는 잠금이 없었다. 같은 결제 건에 대해 <code>complete</code>가 정확히 동시에 두 번 들어오면(네트워크 재시도, 혹은 의도적으로 요청을 두 번 보내는 것도 가능하다), 두 요청 모두 아직 커밋되지 않은 <code>PENDING</code> 상태를 보고 통과한 뒤, 둘 다 포인트를 적립해버릴 수 있다. 결제는 한 번 했는데 포인트는 두 배로 받는, 명백한 결제 취약점이었다.</p>
<p><code>Member</code>(잔액)에는 이미 비관적 락(<code>SELECT ... FOR UPDATE</code>)이 걸려 있었는데, 정작 &quot;이 결제를 이미 처리했는가&quot;를 판단하는 <code>PointCharge</code> 쪽에는 잠금이 없었던 게 원인이었다. 같은 방식으로 <code>PointCharge</code>에도 잠금을 걸어서 해결했다.</p>
<h3 id="5-2-더-흥미로웠던-건-그다음이었다">5-2. 더 흥미로웠던 건 그다음이었다</h3>
<p>연락처 열람(<code>ContactUnlockService.unlock()</code>)에도 비슷한 구조의 문제가 있었다. &quot;이력 확인 → 포인트 차감 → 이력 저장&quot; 순서였는데, 두 요청이 동시에 들어오면 이력 저장 단계에서 두 번째 요청이 유니크 제약 위반으로 실패하는 구조였다. 코드는 이 예외를 catch해서 조용히 넘어가도록 되어 있었다 — 언뜻 보면 안전해 보이는 처리다.</p>
<p>여기서 재밌는 삽질을 했다. 이 catch-and-continue 방식으로 고친 다음, &quot;진짜 동시에 두 번 호출해도 안전한가&quot;를 확인하려고 통합 테스트를 만들었다. 8개 스레드로 같은 인재를 동시에 열람 요청하게 만든 테스트였는데, 결과는 예상과 달랐다.</p>
<pre><code>org.springframework.transaction.UnexpectedRollbackException:
Transaction silently rolled back because it has been marked as rollback-only</code></pre><p><strong>Hibernate는 flush가 한 번이라도 실패하면, 애플리케이션 코드가 그 예외를 catch해서 무시하더라도 해당 트랜잭션 전체를 &quot;rollback-only&quot;로 표시해버린다.</strong> 그래서 메서드는 정상적으로 return 됐는데, 트랜잭션을 커밋하려는 순간 스프링이 &quot;이 트랜잭션은 롤백하기로 되어 있었다&quot;며 조용히 롤백해버리고 <code>UnexpectedRollbackException</code>을 던진 것이다. 결과적으로 이중 차감은 막았지만, 두 요청 중 하나는 처리되지 않은 예외로 끝나는 상태였다 — catch 블록이 있어도 아무 소용이 없었던 셈이다.</p>
<p>이 사실을 알고 나니 접근을 완전히 바꿔야 했다. 예외를 잡아서 우회하는 대신,애초에 경쟁이 생기지 않도록 시설 회원 row에 비관적 락을 걸어서 &quot;확인 → 차감 → 저장&quot; 구간 전체를 하나의 락 구간으로 묶었다. 같은 시설이 같은 인재를 동시에 두 번 열람 요청해도, 두 번째 요청은 첫 번째가 끝날 때까지 기다렸다가 &quot;이미 처리됐다&quot;는 걸 보고 무료로 처리된다. 예외에 기대지 않는 방식이라 이번엔 진짜 안전했다.</p>
<p>그리고 정확히 같은 catch-and-continue 패턴이 코드베이스 안에 하나 더 있었다 — <strong>찜하기(스크랩) 기능</strong>이었다. 돈이 오가는 곳은 아니라 심각도는 낮지만, 버튼을 빠르게 두 번 누르면 같은 방식으로 500 에러가 날 수 있는 구조였다. 같은 방식으로 고쳤다.</p>
<hr>
<h2 id="6-오늘-배운-것">6. 오늘 배운 것</h2>
<p>세 개의 버그가 전부 같은 근본 원인에서 나왔다는 게 인상 깊었다: <strong>&quot;확인하고 → 뭔가 하고 → 저장한다&quot;는 패턴은, 그 사이에 다른 요청이 끼어들 수 있다는 걸 항상 의심해야 한다.</strong> 그리고 &quot;예외를 catch해서 우회하면 안전하겠지&quot;라는 직관이 항상 맞는 건 아니라는 것도 배웠다. JPA/Hibernate를 쓴다면, 트랜잭션 안에서 발생한 예외를 catch하고 계속 진행하는 코드를 볼 때마다 &quot;이 트랜잭션, 정말 커밋까지 무사히 될까?&quot;를 한 번 더 의심해봐야 한다.</p>
<p>증명 방법도 하나 배웠다. 동시성 버그는 말로 설명하기보다 실제로 여러 스레드를 동시에 띄워서 재현하는 테스트를 짜는 게 제일 확실하다. 수정 전 코드로 테스트를 돌려서 실패하는 걸 직접 본 다음, 수정하고 나서 통과하는 걸 확인하는 것 — 이 과정이 없었다면 &quot;락을 걸었으니 됐다&quot;는 확신에 그쳤을 텐데, 실제로는 첫 번째 수정 시도(catch 방식)도 겉보기엔 그럴듯했지만 틀렸다는 걸 테스트가 아니었으면 몰랐을 것이다.</p>
<hr>
<h2 id="오늘의-pr-목록">오늘의 PR 목록</h2>
<table>
<thead>
<tr>
<th>PR</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>#115, #117</td>
<td>비밀번호 찾기(백엔드/프론트)</td>
</tr>
<tr>
<td>#116</td>
<td>회원 유형 정리 + 포인트 충전(포트원) 연동</td>
</tr>
<tr>
<td>#118, #120</td>
<td>알림 도메인(백엔드/프론트)</td>
</tr>
<tr>
<td>#121</td>
<td>관리자 대시보드</td>
</tr>
<tr>
<td>#122</td>
<td>관리자 계정 인재 연락처 마스킹 해제 버그 수정</td>
</tr>
<tr>
<td>#125</td>
<td>포인트 컬럼/테이블 마이그레이션 사후 기록</td>
</tr>
<tr>
<td>#126</td>
<td>문의 답변 id null 버그 수정</td>
</tr>
<tr>
<td>#127</td>
<td>결제/포인트 동시요청 이중 처리 버그 수정</td>
</tr>
</tbody></table>
<p>발표 하나 준비하려다가 하루 종일 코드를 고친 날이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 104]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-104</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-104</guid>
            <pubDate>Sun, 13 Sep 2026 23:50:38 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="carematch-개발일지">CareMatch 개발일지</h1>
<p>보안 사고 수습부터 카카오 로그인 연동까지, 발표 전 마지막 스프린트 하루 기록</p>
<blockquote>
<p>SBS 풀스택 팀 프로젝트 &quot;케어매치(CareMatch)&quot; — 요양보호사 구인구직 매칭 플랫폼</p>
</blockquote>
<h2 id="들어가며">들어가며</h2>
<p>오늘은 지난주 팀 공유 브랜치에 남겨뒀던 미완수 사항들을 정리하고, 발표 전 마지막으로 작업을 분배·진행한 날이다. 아침엔 단순히 &quot;오늘 할 일 나누기&quot;로 시작했는데, 중간에 보안 사고 수습, 원인을 알 수 없던 main 브랜치 빌드 깨짐 사건, 그리고 카카오 로그인 실연동까지 하루 만에 꽤 많은 일이 몰렸다. 시간순으로 기록해둔다.</p>
<hr>
<h2 id="1-아침-지난주-미완수-사항-정리-및-작업-분배">1. 아침: 지난주 미완수 사항 정리 및 작업 분배</h2>
<p>지난주 <code>share</code> 브랜치(팀원끼리 파일 공유용 브랜치)에 정리해둔 미완수사항 문서를 기준으로 팀 작업을 다시 배분했다. 크게 세 갈래였다.</p>
<ul>
<li><strong>보안 이슈</strong> — 실수로 공유 브랜치에 자격증명(구글 계정 비밀번호, admin 비밀번호, R2 Access/Secret Key)을 평문으로 올렸던 것을 뒤늦게 발견. 파일 내용 자체는 지웠지만 git 히스토리엔 그대로 남아있어 재발급이 필요한 상태였다.</li>
<li><strong>프론트-백엔드 연동 격차</strong> — 인재정보 연동, 구직 프로필 저장 등은 이미 끝났고, 남은 건 &quot;공고에 지원하기&quot; 기능 하나였다.</li>
<li><strong>신규 기능 로드맵</strong> — 안심번호, 신뢰도 등급, 결제 연동(PostOne) 등은 이번 스프린트 범위 밖으로 판단하고 보류.</li>
</ul>
<p>다음주 발표를 앞두고 있어서, 이번 주는 &quot;발표에서 보여줄 수 있는가&quot;를 기준으로 우선순위를 정했다. 목요일까지 작업을 마무리하고, 이후엔 발표 준비에 집중하기로 했다.</p>
<hr>
<h2 id="2-보안-사고-수습--브랜치-지우면-되는-거-아니야라는-생각의-함정">2. 보안 사고 수습 — &quot;브랜치 지우면 되는 거 아니야?&quot;라는 생각의 함정</h2>
<p>자격증명 유출을 처음 발견했을 때 든 생각은 &quot;그냥 브랜치 지우면 되지 않나?&quot;였다. 그런데 곰곰이 따져보니 이게 왜 안 되는지 명확해졌다.</p>
<ul>
<li>git 브랜치를 삭제해도 <strong>이미 pull 받은 팀원들의 로컬 저장소엔 히스토리가 그대로 남는다.</strong></li>
<li>원격 브랜치 삭제는 레퍼런스만 지우는 것이라, GitHub 쪽에 커밋 객체 자체는 일정 기간 dangling 상태로 남을 수 있다.</li>
<li>완전히 지우려면 <code>git filter-repo</code>나 BFG로 히스토리 자체를 재작성하고 강제 push까지 해야 하는데, 이게 오히려 지금 상황보다 훨씬 위험하고 번거롭다.</li>
</ul>
<p>결론은 <strong>&quot;이미 커밋된 자격증명은 유출된 것으로 간주하고, 값 자체를 재발급하는 것&quot;</strong>이 정석 대응이라는 것. 그래서:</p>
<ol>
<li><strong>Cloudflare R2 Access/Secret Key</strong> 재발급 → Render 환경변수 갱신 → 예전 키는 Cloudflare에서 폐기</li>
<li><strong>admin 계정</strong> — 비밀번호 변경 API가 없어서(원래 관리 화면에서 바꾸는 기능 자체가 없었음), DB에 직접 BCrypt 해시로 UPDATE 쿼리를 날려서 처리. 발표용으로 쓸 새 admin 계정도 별도로 하나 더 만들었다.</li>
<li><strong>구글 계정</strong> — 프로젝트 전용 임시 계정이라 프로젝트 종료 후 폐기 예정이라는 걸 확인하고, 다른 서비스 로그인에 연결돼 있지 않은 걸 확인한 뒤 이번엔 스킵.</li>
</ol>
<p>작은 실수 하나가 &quot;히스토리 지우기 vs 값 재발급하기&quot;라는, 생각보다 큰 판단을 요구한다는 걸 다시 느꼈다.</p>
<hr>
<h2 id="3-원인불명의-main-브랜치-빌드-깨짐--병합-충돌의-흔적">3. 원인불명의 main 브랜치 빌드 깨짐 — 병합 충돌의 흔적</h2>
<p>PR을 몇 개 머지하고 나서 습관적으로 &quot;머지 잘 됐나 확인해줘&quot;라고 점검을 요청했는데, 뜻밖에도 <strong>main 브랜치의 프론트엔드 빌드가 깨져 있는 걸</strong> 발견했다.</p>
<p>원인을 추적해보니, 자격증 업로드 기능 PR을 머지하는 과정에서 <code>main</code>과의 병합 충돌이 <strong>제대로 해소되지 않은 채 그대로 커밋</strong>되어 있었다. <code>&lt;&lt;&lt;&lt;&lt;&lt;&lt;</code>, <code>=======</code>, <code>&gt;&gt;&gt;&gt;&gt;&gt;&gt;</code> 마커 텍스트가 파일에 그대로 남아있었고, 양쪽 브랜치의 코드가 뒤섞여서 존재하지도 않는 변수를 참조하는 등 명백한 버그 상태였다.</p>
<p>더 흥미로웠던 건, 자격증 업로드 UI 블록이 <strong>엉뚱한 섹션에 잘못 붙어있었다</strong>는 점이다. 원인은 두 브랜치가 갈라진 시점 차이 — 자격증 업로드 브랜치는 프로필 로딩 로직이 리팩터링되기 전 시점에서 분기했기 때문에, git의 라인 기반 diff 알고리즘이 자격증 UI 블록을 원래 있어야 할 위치가 아니라 구조가 비슷한 다른 섹션에 붙여버린 것이었다.</p>
<p>두 브랜치의 원본 커밋을 각각 꺼내서 3-way merge를 다시 재현해보고, 양쪽 의도를 모두 살려서 수동으로 병합했다. <code>tsc</code>, <code>eslint</code>, 프로덕션 빌드까지 통과를 확인한 뒤에야 안심할 수 있었다. <strong>&quot;머지됐다&quot;와 &quot;제대로 머지됐다&quot;는 다른 이야기</strong>라는 걸 배운 사건이었다.</p>
<hr>
<h2 id="4-공고에-지원하기--기능이-반은-이미-있었다">4. &quot;공고에 지원하기&quot; — 기능이 반은 이미 있었다</h2>
<p>지난주 문서엔 &quot;공고에 지원하기 기능이 아직 없다&quot;고 적혀 있었다. 그런데 막상 코드를 다시 열어보니:</p>
<ul>
<li><strong>백엔드는 이미 완성돼 있었다.</strong> <code>POST /api/job-postings/{id}/applications</code> 엔드포인트가 지원 생성, 중복 지원 방지, 마감 공고 차단까지 다 처리하고 있었다.</li>
<li><strong>프론트엔드만 이 API를 안 쓰고 있었다.</strong> 공고 상세 페이지의 &quot;온라인으로 지원하기&quot; 버튼 두 곳 모두, 실제 지원 처리 없이 그냥 프로필 등록 화면으로 이동만 시키고 있었다.</li>
</ul>
<p>즉, 예상했던 &quot;백엔드+프론트 둘 다 새로 만들어야 하는 큰 작업&quot;이 실제로는 &quot;프론트 연동 하나만 하면 되는 작은 작업&quot;으로 줄어들었다. 이럴 때 문서만 믿고 작업 분량을 예단하면 안 된다는 걸 다시 느꼈다 — 실제 코드를 열어봐야 진짜 스코프가 보인다.</p>
<p>관심 공고 버튼(하트 아이콘)이 이미 같은 패턴(비로그인 시 로그인 화면 이동, 실패 시 롤백)으로 구현돼 있어서, 그 구조를 그대로 재사용해 지원 버튼을 만들었다. 이미 지원한 공고는 &quot;지원완료&quot;로 표시하고, 구직 프로필이 아직 없는 사용자는 프로필 등록 화면으로 안내하는 흐름까지 붙였다.</p>
<hr>
<h2 id="5-로그인은-카카오만--그리고-실제-연동까지">5. 로그인은 카카오만 — 그리고 실제 연동까지</h2>
<p>팀 결정으로 연동 로그인은 카카오만 남기고 네이버/구글 버튼을 숨기기로 했다. 이용자 연령대가 높은 서비스 특성상 카카오 로그인 위주로 가는 게 맞다는 판단이었다.</p>
<p>버튼을 숨기는 건 배열에서 항목 두 개를 지우는 간단한 작업이었지만, <strong>실제 카카오 로그인을 붙이는 건 또 다른 이야기</strong>였다.</p>
<h3 id="사업자등록증이-없어도-될까">사업자등록증이 없어도 될까?</h3>
<p>카카오 로그인에서 이메일 정보를 받아오려면 &quot;동의항목 심사&quot;를 거쳐야 하는데, 사업자등록증이 없어서 걱정했다. 찾아보니 다행히 <strong>&quot;비즈 앱 전환&quot;(사업자등록 필요) 대신 &quot;개인 개발자 본인인증&quot;으로도 신청이 가능</strong>했다. 다만 이것도 카카오 쪽 심사에 며칠이 걸리는 절차라, 발표 전까지 승인이 날 거란 보장이 없었다.</p>
<p>그래서 실용적인 선택을 했다 — <strong>이메일 동의항목은 이번엔 신청하지 않고, 닉네임만 받기로 한 것.</strong> 다행히 코드는 이미 이메일이 없는 경우를 대비해서 임시 이메일을 자동 생성하는 로직(<code>emailOrPlaceholder()</code>)을 갖추고 있어서, 승인 대기 없이 바로 서비스를 붙일 수 있었다. 카카오 이메일 심사는 발표 이후, 여유 있을 때 다시 신청하기로 했다.</p>
<h3 id="카카오-콘솔-ui가-바뀌어-있었다">카카오 콘솔 UI가 바뀌어 있었다</h3>
<p>문서와 예전 기억을 바탕으로 &quot;카카오 로그인 &gt; 보안&quot; 메뉴에서 Client Secret을 발급하면 된다고 안내했는데, 실제 콘솔엔 그 메뉴 자체가 없었다. 카카오가 UI를 개편해서:</p>
<ul>
<li>Redirect URI 등록 위치가 &quot;카카오 로그인&quot; 제품 설정에서 <strong>&quot;앱 &gt; 플랫폼 키 &gt; REST API 키 카드 안&quot;</strong>으로 이동해 있었다.</li>
<li>&quot;카카오 로그인 &gt; 고급&quot; 메뉴엔 로그인용이 아니라 <strong>로그아웃 리다이렉트 URI</strong>만 있어서 처음엔 헷갈렸다.</li>
</ul>
<p>스크린샷을 주고받으며 실제 화면을 보고서야 정확한 위치를 찾을 수 있었다. 문서나 기억에 의존하지 말고, <strong>막히면 바로 스크린샷으로 확인하는 게 제일 빠르다</strong>는 걸 새삼 느꼈다.</p>
<h3 id="결과">결과</h3>
<p>REST API 키, Client Secret 발급 → Render 환경변수(<code>OAUTH_KAKAO_CLIENT_ID</code>, <code>OAUTH_KAKAO_CLIENT_SECRET</code>, <code>OAUTH_REDIRECT_BASE</code>)에 반영 → 재배포 → 실제로 카카오 계정으로 로그인까지 테스트 완료. 배포 반영에 예상보다 시간이 걸려서 몇 분간 계속 확인했는데, 결국 정상적으로 실제 카카오 인증 화면으로 넘어가는 걸 확인했다.</p>
<hr>
<h2 id="6-사소하지만-헷갈렸던-것--서버에-연결할-수-없습니다">6. 사소하지만 헷갈렸던 것 — &quot;서버에 연결할 수 없습니다&quot;</h2>
<p>로컬에서 로그인 테스트를 하다가 &quot;서버에 연결할 수 없다&quot;는 에러를 만났다. 처음엔 혹시 오늘 건드린 Render 배포나 R2 키 작업 때문에 서버가 죽은 게 아닌가 걱정했는데, 확인해보니 원인은 훨씬 단순했다 — <strong>로컬 프론트가 로컬 백엔드(<code>localhost:8080</code>)를 보고 있는데, 로컬 백엔드를 안 띄운 상태</strong>였던 것. 배포된 백엔드는 멀쩡히 잘 돌아가고 있었다.</p>
<p><code>.env.local</code>의 API 주소를 배포 서버로 바꾸고 dev 서버를 재시작하니 바로 해결됐다. 별일 아니었지만, &quot;에러 메시지만 보고 성급하게 원인을 짐작하지 않고 하나씩 확인해보는 습관&quot;의 중요성을 다시 느낀 순간이었다.</p>
<hr>
<h2 id="오늘의-정리">오늘의 정리</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td>보안 사고 수습 (R2 키, admin 계정, DB 직접 조치)</td>
<td>✅ 완료</td>
</tr>
<tr>
<td>main 브랜치 빌드 깨짐 발견 및 수정</td>
<td>✅ 완료</td>
</tr>
<tr>
<td>공고에 &quot;지원하기&quot; 기능 프론트 연동</td>
<td>✅ 완료</td>
</tr>
<tr>
<td>로그인 카카오 단일화 (네이버/구글 숨김)</td>
<td>✅ 완료</td>
</tr>
<tr>
<td>카카오 로그인 실제 연동 (콘솔 설정 + Render 반영 + 테스트)</td>
<td>✅ 완료</td>
</tr>
</tbody></table>
<p>남은 건 카카오맵 연동 여부 결정, CI 파이프라인 추가 정도. 발표까지 남은 시간을 생각하면 이제부터는 새 기능보다 <strong>안정성 확인과 발표 시나리오 리허설</strong>에 무게를 둬야 할 것 같다.</p>
<p>하루 사이에 &quot;단순 작업 분배&quot;로 시작해서 &quot;보안 사고 대응 + 숨은 버그 발견 + 외부 서비스 연동&quot;까지 이어진 걸 보면, 계획대로만 흘러가지 않는 게 개발이라는 걸 다시 느낀다. 그래도 발표 전에 이런 것들을 미리 발견하고 고칠 수 있어서 다행이었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 103]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-103</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-103</guid>
            <pubDate>Thu, 10 Sep 2026 23:52:51 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="carematch-개발-일지">CareMatch 개발 일지</h1>
<p>요양보호사 구인구직 플랫폼 &quot;케어매치(CareMatch)&quot; 팀 프로젝트를 진행하면서, 하루 동안
Claude Code와 함께 백엔드 기능 하나를 새로 설계하고, 프로덕션 버그 두 개를 잡고,
팀 공유 문서까지 정리한 기록입니다. 실수도 하나 있었는데 그것도 그대로 남깁니다.</p>
<h2 id="오늘-한-일-요약">오늘 한 일 요약</h2>
<ol>
<li>Render 계정 복구 후 배포 상태 점검</li>
<li>&quot;요양보호사 등록 시 전화번호 인증&quot; 기능 설계·구현</li>
<li>프론트 버그 2건 발견 및 수정 (구·군 데이터 누락, <code>main</code> 빌드 깨짐)</li>
<li><code>git rm -rf</code> 사고 한 번, 안전한 복구 방법 재시도</li>
<li>팀 공유 브랜치에 회의용 &quot;미완수 사항&quot; 문서 작성 + 코드 기준 재검증</li>
</ol>
<hr>
<h2 id="1-render-계정-복구-체크리스트부터">1. Render 계정 복구 체크리스트부터</h2>
<p>어제 Render 대시보드 로그인이 막혀서 배포 상태를 아무도 확인할 수 없었던 사고가 있었다.
계정이 복구된 김에 만들어둔 체크리스트(<code>docs/RENDER_ACCESS_RECOVERY.md</code>)를 하나씩 따라갔다.</p>
<ul>
<li>배포된 커밋 해시를 대시보드 없이 확인할 방법이 없다는 게 마음에 걸려서, <code>/actuator/info</code>에
배포 커밋을 노출하는 작업을 추가했다. Docker 빌드 컨텍스트엔 <code>.git</code>이 없어서 Gradle
<code>git-properties</code> 플러그인은 못 쓰고, 대신 Render가 런타임에 자동 주입하는
<code>RENDER_GIT_COMMIT</code> 환경변수를 읽어서 노출하는 쪽으로 갔다. 별도 설정 없이도 다음부턴
<code>curl .../actuator/info</code> 한 줄로 배포 반영 여부를 확인할 수 있다.</li>
</ul>
<h2 id="2-카카오-로그인만-쓰면-본인인증-필요-없는-거-아냐">2. &quot;카카오 로그인만 쓰면 본인인증 필요 없는 거 아냐?&quot;</h2>
<p>이 질문에서 시작해서 실제로 코드를 봤더니, 소셜 로그인 가입자는 이미
<code>verified(true)</code>로 인증을 건너뛰도록 돼 있었다. 근데 카카오는 전화번호를 안 준다는 게 문제.
요양보호사 구인구직 특성상 시설(고용주)이 연락할 번호는 결국 필요하다.</p>
<p>논의 끝에 방향을 이렇게 잡았다:</p>
<ul>
<li><strong>가입 시점엔 전화번호를 요구하지 않는다</strong> (카카오는 애초에 못 줌 → 가입을 가볍게 유지)</li>
<li><strong>요양보호사로 등록(자격증 등록)하는 시점에 전화번호 입력 + 인증을 요구한다</strong></li>
<li>실제 SMS/이메일 발송 방식은 나중에 결정 — 지금은 게이트 로직만 구성</li>
</ul>
<p>구현은 기존에 이미 있던 <code>VerificationService.assertVerified()</code>를 재사용해서 생각보다 간단했다.
<code>CertificateService.register()</code>에 한 줄 추가하는 것으로 게이트를 걸었다.</p>
<pre><code class="language-java">@Transactional
public CertificateDetailResponse register(Long memberId, CreateCertificateRequest req) {
    JobSeekerProfile profile = jobSeekerProfileRepository.findByMemberId(memberId)
            .orElseThrow(...);
    verificationService.assertVerified(VerificationChannel.PHONE, profile.getMember().getPhone());
    // ...
}</code></pre>
<p>전화번호를 나중에 입력할 수 있게 <code>PUT /api/members/me/phone</code> 엔드포인트도 하나 추가했다.
테스트 전부 통과 확인하고 PR 올렸는데, 리뷰 붙이기 전에 팀원이 다른 PR(자격증 종류 enum
전환)을 먼저 머지해버려서 병합 충돌이 났다 — 근데 이게 단순 문서 충돌이 아니라
<code>CertificateService.java</code> 자체가 두 기능이 겹치는 지점이었다. 다행히 git이 자동 머지에
성공해서(건드린 줄이 안 겹침) 실제 충돌은 API 문서 한 곳뿐이었다.</p>
<h2 id="3-rm--rf-사고--그리고-안전하게-다시-하기">3. <code>rm -rf</code> 사고 — 그리고 안전하게 다시 하기</h2>
<p>팀 파일 공유용으로 <code>share</code>라는 브랜치를 만들고 &quot;안의 내용 다 지워달라&quot;는 요청을 받았다.
<code>git rm -r .</code>로 추적 파일을 지운 다음, 남은 빈 폴더를 정리한다고 <code>rm -rf backend frontend docs</code>를
실행했는데 — 이게 <code>.gitignore</code>에 걸려 있던 <strong>로컬 전용 파일</strong>(<code>.env.local</code>, 로컬 시드 SQL 등)까지
같이 날려버렸다.</p>
<p>다행히 <code>main</code> 자체는 전혀 영향받지 않았다(브랜치는 독립적인 스냅샷이라 커밋도 안 한 상태였다).
<code>git reset --hard</code>로 추적 파일은 바로 복구됐고, 사라진 로컬 파일 2개 중 하나는
<code>.env.example</code>에서 금방 재생성했다. 실수를 인정하고, 두 번째 시도는 <strong>격리된 git worktree</strong>에서
진행해서 메인 작업 디렉터리를 아예 안 건드리는 방식으로 안전하게 마쳤다.</p>
<blockquote>
<p>배운 것: &quot;정리&quot;라는 말이 나오면 <code>rm -rf</code> 대신 <code>git rm</code>(추적 파일만 지움)을 먼저 떠올리기.
그리고 위험한 브랜치 조작은 아예 별도 worktree에서.</p>
</blockquote>
<h2 id="4-지역-선택-버그--구멍-난-수정-vs-제대로-된-수정">4. 지역 선택 버그 — &quot;구멍 난&quot; 수정 vs 제대로 된 수정</h2>
<p>&quot;서울/경기 빼고 지역 선택하면 시군구가 안 나온다&quot;는 제보를 받고 코드를 봤더니,
<code>DISTRICT_OPTIONS</code>라는 프론트 상수에 시·도 3개(서울/경기/인천) 데이터만 들어있었다.
나머지 14개 시·도는 아예 값이 없어서 셀렉트박스가 비활성화되는 구조였다.</p>
<p>1차로 시·도당 대표 지역 5~8개씩 채워서 고쳤는데, &quot;아직도 빠진 지역이 너무 많다&quot;는
피드백을 받고 전국 행정구역 전체(서울 25개 구, 경기 31개 시·군의 세부 구까지)로
다시 채웠다. 반복되는 <code>.map()</code> 코드는 헬퍼 함수 하나로 정리했다.</p>
<pre><code class="language-ts">const toOptions = (names: string[]): SelectOption[] =&gt;
  names.map((name) =&gt; ({ value: name, label: name }))</code></pre>
<h2 id="5-main이-조용히-깨져-있었다">5. <code>main</code>이 조용히 깨져 있었다</h2>
<p>작업하다가 습관적으로 <code>git pull</code> 했는데, 그 김에 프론트 타입체크를 한번 돌려봤더니
<code>main</code>이 아예 빌드가 안 되고 있었다. 원인은 다른 PR을 머지하는 과정에서 병합 충돌을
해결하다가 마커 잔여물이 그대로 커밋된 것 — <code>&gt;&gt;&gt;&gt;&gt;&gt;&gt; main</code> 같은 줄 일부가 코드
중간에 낀 채로 남아있었다.</p>
<pre><code>export function payTypeToApi(payType: string): &#39;HOURLY&#39; | &#39;DAILY&#39; | &#39;MONTHLY&#39; {
  return LOCAL_PAY_TYPE_TO_API[payType] ?? &#39;MONTHLY&#39;
=======          # &lt;- 이게 그대로 남아있었다</code></pre><p>백엔드 테스트는 이 파일과 무관해서 안 걸렸고, 프론트 <code>typecheck</code>/<code>build</code>를 CI에서
자동으로 안 돌리다 보니 아무도 모른 채 <code>main</code>에 들어가 있었다. 저장소 전체를
충돌 마커 패턴으로 재검색해서 이 파일 하나뿐인 걸 확인하고 고친 뒤, 급하게 PR 올려서
바이패스 승인으로 바로 병합했다.</p>
<blockquote>
<p>이 사고 덕분에 &quot;CI에 프론트 빌드 체크가 없다&quot;는 게 그냥 아는 걸 넘어서 실제로
뼈아프게 느껴졌다. 다음 우선순위 후보.</p>
</blockquote>
<h2 id="6-팀-회의용-문서--그리고-확인해봤더니-틀렸던-순간">6. 팀 회의용 문서 — 그리고 &quot;확인해봤더니 틀렸던&quot; 순간</h2>
<p>하루 마무리로 지금까지 나온 미완수 항목들을 정리해서 팀 공유 브랜치(<code>share</code>)에
회의용 문서를 올렸다. &quot;R2 스토리지 아직 안 붙었다&quot;고 적었는데, 팀원이 &quot;근데 R2 연동은
됐는데?&quot;라고 지적해서 다시 확인해보니 — 진짜였다. 내가 갖고 있던 메모가 며칠 전
기준으로 멈춰 있었던 것. 실제 운영 API를 직접 호출해서 진짜 R2 presigned URL이
나오는 것까지 확인하고 문서를 정정했다.</p>
<p>팀원들이 같은 공유 브랜치에 올려둔 프롬프트 파일들(&quot;내 주변 일자리&quot; 기능 요청,
전체 기능 요구사항 정리)도 분석해서 기존 코드와 대조했다. 그 중 하나는 흥미로운
발견이었는데 — 팀원이 카카오맵 기반으로 새로 만들자고 프롬프트를 올렸는데,
백엔드에는 <strong>이미 다른 스펙으로</strong> 비슷한 기능(<code>GET /api/job-postings/nearby</code>,
가변 반경)이 구현돼 있었고, 프론트는 의도적으로 그 API를 안 쓰고 다른 방식(지역 선택)으로
만들어져 있었다. 그대로 새 프롬프트를 실행했으면 기존 구현과 완전히 어긋날 뻔했다.</p>
<p>그리고 공유 파일 중 하나에 평문 자격증명(R2 키, 관리자 비밀번호 등)이 그대로 올라가
있는 걸 발견해서 재발급을 권고하는 항목도 문서 맨 위에 올렸다.</p>
<p>문서를 올린 뒤에도 팀원들의 PR이 계속 머지되면서 몇 시간 만에 항목 3개가 저절로
&quot;완료&quot;로 바뀌었다 — 전체 코드를 다시 훑어서 재검증하고 문서를 다시 갱신했다.
회의 자료는 한 번 쓰고 끝나는 게 아니라 계속 코드 상태를 따라가야 한다는 걸 새삼 느꼈다.</p>
<hr>
<h2 id="오늘의-숫자">오늘의 숫자</h2>
<ul>
<li>PR: 6개 (#79, #80, #83, #84, #88, #90) + 팀원 PR 여러 개 리뷰/추적</li>
<li>발견한 프로덕션 버그: 1개 (<code>main</code> 빌드 깨짐, 병합 충돌 마커 잔여물)</li>
<li>스스로 낸 실수: 1개 (<code>rm -rf</code>로 로컬 파일 삭제) — git으로 즉시 복구</li>
<li>정정한 내 착각: 1개 (R2 스토리지 상태)</li>
<li>발견한 보안 이슈: 1개 (공유 브랜치에 평문 자격증명)</li>
</ul>
<h2 id="느낀-점">느낀 점</h2>
<p>하루 종일 코드를 &quot;믿지 말고 확인하자&quot;는 태도가 제일 크게 도움이 됐다. 문서에 적힌
내용, 내 기억, 팀원이 준 프롬프트 — 전부 한 번씩은 틀려 있었다. 실제로 grep 한 줄,
curl 한 번으로 진실을 확인하는 습관이 오늘 하루에만 몇 번씩 방향을 바로잡아줬다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 102]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-102</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-102</guid>
            <pubDate>Wed, 09 Sep 2026 23:51:33 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="carematch-개발일지">CareMatch 개발일지</h1>
<p>오늘은 새 기능 구현보다 <strong>git 운영, 코드 리뷰, 배포 환경 트러블슈팅</strong>에 집중한 하루였다. 팀원들이 쏟아내는 PR을 계속 pull 받아 확인하면서, 그 과정에서 발견한 문제들을 바로 고쳐서 다시 PR로 올리는 흐름으로 진행했다.</p>
<h2 id="1-tsconfigapptsbuildinfo--의미-없는-git-충돌의-원인-제거">1. <code>tsconfig.app.tsbuildinfo</code> — 의미 없는 git 충돌의 원인 제거</h2>
<p>TypeScript 증분 빌드 캐시 파일(<code>frontend/tsconfig.app.tsbuildinfo</code>)이 git에 추적되고 있었다. 로컬에서 빌드할 때마다 내용이 바뀌는 파일이라, <code>git pull</code> 할 때마다 &quot;로컬 변경사항이 있어 merge가 막힘&quot; 같은 상황이 반복됐다.</p>
<p><strong>해결</strong>: <code>frontend/.gitignore</code>에 <code>*.tsbuildinfo</code> 추가 + <code>git rm --cached</code>로 기존 추적 해제. 이후로는 이 파일 때문에 충돌이 재발하지 않았다.</p>
<h2 id="2-협업-규칙-보강--pr-만들기-전-main-최신화-확인">2. 협업 규칙 보강 — PR 만들기 전 <code>main</code> 최신화 확인</h2>
<p>팀 CLAUDE.md(AI 작업 규칙 문서)의 &quot;PR 생성 규칙&quot;에 <code>git fetch</code>/<code>pull</code>로 <code>origin/main</code>을 먼저 확인하는 단계를 1번으로 추가했다. 로컬이 원격보다 뒤처진 걸 모른 채 작업하다가 뒤늦게 충돌을 발견하는 상황을 막기 위함.</p>
<h2 id="3-코드-리뷰-근무-시간대workschedule-기능">3. 코드 리뷰: 근무 시간대(WorkSchedule) 기능</h2>
<p><code>feature/be-workschedule</code> 브랜치를 리뷰하면서 두 가지를 발견했다.</p>
<ul>
<li><strong>검증 누락</strong>: &quot;입주형(LIVE_IN)이 아니면 <code>workSchedule</code>은 필수&quot;라고 주석엔 적혀 있는데 실제 검증 코드가 없었다. <code>@AssertTrue</code>로 교차 필드 검증을 추가해서, <code>workType != LIVE_IN</code>인데 <code>workSchedule</code>이 비어 있으면 400 에러가 나가도록 고쳤다.</li>
<li><strong>테스트 픽스처 중복</strong>: 새 테스트 클래스가 기존 테스트 클래스의 <code>@DataJpaTest</code> 셋업(회원/시설 프로필 생성 로직)을 거의 그대로 복붙하고 있었다. 하나로 합쳐서 앞으로 필드가 추가될 때 두 픽스처가 따로 놀며 깨지는 위험을 없앴다.</li>
</ul>
<h2 id="4-머지-충돌보다-무서운-조용한-컴파일-에러">4. 머지 충돌보다 무서운 &quot;조용한&quot; 컴파일 에러</h2>
<p><code>feature/be-facility-type-applicant</code> 브랜치를 리뷰하다가, <strong>git이 충돌 없이 자동 머지하지만 실제로는 컴파일이 깨지는 상황</strong>을 발견했다.</p>
<p>원인은 <code>SearchCondition</code>이라는 record(불변 데이터 클래스)에 있었다. 이 브랜치가 <code>facilityTypes</code>라는 필드를 새로 추가하면서 생성자 인자가 12개 → 13개로 늘어났는데, 그 사이에 먼저 머지된 다른 PR이 이 record를 <strong>옛날 12개짜리 생성자</strong>로 호출하는 테스트 코드를 추가해버린 것. git은 서로 다른 줄을 건드렸으니 &quot;충돌 없음&quot;으로 판단하지만, Java 컴파일러는 인자 개수가 안 맞는다고 에러를 낸다.</p>
<pre><code>error: constructor SearchCondition in record SearchCondition cannot be applied to given types;
required: ...,List&lt;FacilityType&gt;,...  (13개)
found:    ...,&lt;null&gt;,...              (12개)</code></pre><p>이런 건 GitHub의 &quot;Merge conflict&quot; 표시로는 절대 안 잡힌다. 로컬에서 실제로 머지를 시도해보고 <code>./gradlew compileTestJava</code>를 돌려봐야 나온다. <strong>record/데이터 클래스에 필드를 추가할 때는 포지셔널 생성자를 쓰는 모든 곳을 실제로 컴파일까지 돌려서 확인해야 한다</strong>는 걸 다시 확인한 케이스.</p>
<h2 id="5-로컬-개발-↔-배포-백엔드render-cors-403">5. 로컬 개발 ↔ 배포 백엔드(Render) CORS 403</h2>
<p>프론트 팀원이 로컬(<code>localhost:5173</code>)에서 배포된 Render 백엔드로 API를 호출하면 CORS 403이 났다. 원인은 프론트 설정이 아니라 <strong>서버 쪽 화이트리스트</strong> 문제였다 — Render의 <code>CORS_ALLOWED_ORIGINS</code> 환경변수가 Vercel 도메인만 등록돼 있어서 <code>localhost</code>가 허용 목록에 없었던 것.</p>
<p>해결책은 두 가지:</p>
<ol>
<li>백엔드를 로컬에서 직접 실행(<code>./gradlew bootRun</code>) — 로컬 기본 CORS 설정엔 <code>localhost:5173</code>이 이미 포함됨</li>
<li>Render 환경변수에 <code>localhost:5173</code> 추가 (대시보드 접근 권한 필요)</li>
</ol>
<p>이 내용을 README 트러블슈팅 섹션에 8번 항목으로 정리해서 문서화해뒀다.</p>
<h2 id="6-진짜-문제-render-계정-접근-자체가-막힘">6. 진짜 문제: Render 계정 접근 자체가 막힘</h2>
<p>그런데 알고 보니 Render 대시보드 로그인(구글 계정)이 막혀서 팀원 누구도 환경변수를 못 건드리는 상황이었다. 로컬 백엔드로 우회하면 되지 않냐고 했는데, 회원가입 시 <strong>본인인증 코드를 확인할 방법이 없다</strong>는 게 다음 문제로 나왔다.</p>
<p>파고 보니 이것도 이미 목업(mock) 시스템으로 설계돼 있었다 — 실제 SMS/이메일 발송 없이 서버 로그로만 코드가 찍히는데, <code>local</code> 프로필에서 실행하면 API 응답(<code>devCodeHint</code>)에 코드가 그대로 실려서 프론트 화면에 토스트로 뜨도록 이미 구현돼 있었다. 문제는 Render(prod)에서는 이 값이 항상 <code>null</code>이라 로그 접근 권한 없인 확인 불가능하다는 것.</p>
<h2 id="7-임시-조치-본인인증-요구사항-끄기">7. 임시 조치: 본인인증 요구사항 끄기</h2>
<p>프론트 팀원들이 다른 기능 개발을 진행할 수 있도록, 회원가입 시 본인인증을 <strong>임시로 비활성화</strong>했다. 나중에 되돌리기 쉽게 하드코딩 삭제 대신 플래그로 처리했다.</p>
<ul>
<li>백엔드: <code>carematch.verification.required-for-signup</code> 설정 추가 (기본 <code>false</code>). <code>MemberService.registerJobSeeker</code>/<code>registerFacility</code>에서 이 플래그가 켜져 있을 때만 인증 여부를 검사하도록 감쌈. 기본값이 <code>false</code>라 <strong>Render 환경변수를 못 건드려도 머지 즉시 배포에 적용</strong>되는 게 포인트.</li>
<li>프론트: <code>Signup</code> 폼의 클라이언트 검증(<code>validate()</code>)도 별도로 <code>!verified</code>를 막고 있어서, 백엔드만 풀어선 화면에서 여전히 제출이 안 됐다. <code>VERIFICATION_REQUIRED</code> 상수로 같은 방식으로 감싸서 통일.</li>
</ul>
<p>재활성화는 두 플래그를 다시 <code>true</code>로 되돌리기만 하면 된다.</p>
<hr>
<h2 id="오늘의-한-줄-정리">오늘의 한 줄 정리</h2>
<blockquote>
<p><strong>&quot;머지가 됐다&quot;와 &quot;머지된 코드가 돌아간다&quot;는 다른 이야기다.</strong> git의 3-way merge는 텍스트 레벨에서만 충돌을 판단하기 때문에, record 생성자 인자 개수 변경처럼 &quot;같은 줄을 안 건드렸지만 의미적으로 깨지는&quot; 변경은 실제로 컴파일/테스트를 돌려봐야만 잡힌다. 그리고 배포 환경 접근 권한이 막히는 상황은 언제든 생길 수 있으니, &quot;환경변수 하나 바꾸면 되는 문제&quot;도 그 권한이 없을 때를 대비한 우회로(로컬 실행, 기본값 기반 토글)를 항상 같이 설계해두는 게 낫다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 101]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-101</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-101</guid>
            <pubDate>Tue, 08 Sep 2026 23:52:51 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="carematch-배포연동">CareMatch 배포연동</h1>
<blockquote>
<p>날짜: 2026-09-09
프로젝트: CareMatch — 요양보호사·간병인과 요양시설을 잇는 구인구직 매칭 서비스 (팀 포트폴리오)
스택: React(Vercel) + Spring Boot(Render, Docker) + PostgreSQL(Neon) + Cloudflare R2</p>
</blockquote>
<p>오늘 목표는 &quot;각자 로컬에서만 돌던 걸 실제 URL로 띄우는 것&quot;. 프론트는 Vercel, 백엔드는 Render,
DB는 Neon, 파일은 Cloudflare R2로 붙였다. 한 번에 된 게 하나도 없어서 기록으로 남긴다.</p>
<hr>
<h2 id="1-render-배포가-no-open-ports-detected로-실패">1. Render 배포가 <code>No open ports detected</code>로 실패</h2>
<h3 id="증상">증상</h3>
<pre><code>==&gt; No open ports detected, continuing to scan...
==&gt; Docs on specifying a port: https://render.com/docs/web-services#port-binding
==&gt; Exited with status 1</code></pre><p>Render는 컨테이너에 <code>PORT</code> 환경변수로 포트를 동적으로 주입하고(기본 10000), 그 포트로
서버가 리스닝하는지 확인한다. 안 하면 위 메시지와 함께 배포 실패.</p>
<h3 id="원인">원인</h3>
<p>처음엔 &quot;포트를 안 읽나?&quot; 싶었는데, Dockerfile ENTRYPOINT는 이미 <code>-Dserver.port=${PORT:-8080}</code>로
PORT를 참조하고 있었다. 진짜 원인은 <strong>앱이 포트를 열기 전에 죽는 것</strong>. <code>Exited with status 1</code>은
포트 설정 문제가 아니라 스프링 컨텍스트 초기화 실패 신호였다. (자세한 건 2·3번)</p>
<h3 id="해결">해결</h3>
<p>포트 설정은 코드 한 곳에서만 관리하도록 정리했다.</p>
<p><code>application.yml</code> (공통):</p>
<pre><code class="language-yaml">server:
  port: ${PORT:8080}   # 배포는 PORT 주입, 로컬은 8080</code></pre>
<p><code>Dockerfile</code> ENTRYPOINT:</p>
<pre><code class="language-dockerfile"># before
ENTRYPOINT [&quot;sh&quot;, &quot;-c&quot;, &quot;java $JAVA_OPTS -Dserver.port=${PORT:-8080} -jar app.jar&quot;]
# after
ENTRYPOINT [&quot;sh&quot;, &quot;-c&quot;, &quot;exec java $JAVA_OPTS -jar app.jar&quot;]</code></pre>
<ul>
<li><code>-Dserver.port</code> 제거 → <code>application.yml</code>이 담당 (진실의 출처 1개)</li>
<li><code>exec</code> 추가 → <code>java</code>가 PID 1이 되어 Render의 SIGTERM(무중단 배포·셧다운)이 정상 전달됨</li>
</ul>
<h3 id="배운-점">배운 점</h3>
<ul>
<li><code>Exited with status 1</code> + <code>No open ports</code>는 대부분 &quot;포트 설정&quot;이 아니라 &quot;기동 실패&quot;다.
로그의 스택트레이스부터 봐야 한다.</li>
<li>같은 값을 Dockerfile과 yml 두 군데서 잡으면 나중에 헷갈린다. 한 곳으로.</li>
</ul>
<hr>
<h2 id="2-lob-string이-postgresql에서-oid-컬럼으로-생성돼서-깨짐">2. <code>@Lob String</code>이 PostgreSQL에서 <code>oid</code> 컬럼으로 생성돼서 깨짐</h2>
<h3 id="증상-1">증상</h3>
<p>Render 로그:</p>
<pre><code>Caused by: org.hibernate.tool.schema.spi.SchemaManagementException:
           Schema-validation: missing table [application]</code></pre><p>그리고 스키마를 만들고 보니 일부 텍스트 컬럼 타입이 이상했다.</p>
<h3 id="원인-1">원인</h3>
<p><code>Faq.answer</code>, <code>Inquiry.content</code>, <code>InquiryReply.content</code>, <code>Notice.content</code>, <code>Terms.content</code> 5개가
<code>@Lob String</code>이었다. <strong>Hibernate 6 + PostgreSQL 조합에서 <code>@Lob String</code>은 <code>text</code>가 아니라
<code>oid</code>(large object 포인터) 컬럼으로 매핑된다.</strong> 이 상태로는 일반 문자열 insert/조회가 깨진다.
로컬은 H2라서 이 문제가 전혀 안 드러났다 — 배포하고 나서야 발견.</p>
<h3 id="해결-1">해결</h3>
<pre><code class="language-java">// before
@Lob
@Column(name = &quot;content&quot;, nullable = false)
private String content;

// after
@Column(name = &quot;content&quot;, nullable = false, columnDefinition = &quot;TEXT&quot;)
private String content;</code></pre>
<p>프로젝트에 이미 <code>JobPosting.description</code>이 <code>@Column(columnDefinition = &quot;TEXT&quot;)</code> 컨벤션을 쓰고
있어서 그쪽에 맞췄다. 스키마 스냅샷(<code>docs/schema/schema-postgresql.sql</code>)의 해당 5줄도
<code>oid</code> → <code>text</code>로 반영.</p>
<h3 id="배운-점-1">배운 점</h3>
<ul>
<li>&quot;로컬 H2 / 운영 PostgreSQL&quot; 이중화는 편하지만, 방언 차이(타입 매핑, 함수)가 배포 때 터진다.</li>
<li>긴 문자열은 <code>@Lob</code> 대신 <code>@Column(columnDefinition = &quot;TEXT&quot;)</code>가 안전하다.
(또는 <code>@JdbcTypeCode(SqlTypes.LONGVARCHAR)</code>)</li>
</ul>
<hr>
<h2 id="3-빈-운영-db--ddl-auto-validate-→-missing-table">3. 빈 운영 DB + <code>ddl-auto: validate</code> → <code>missing table</code></h2>
<h3 id="원인-2">원인</h3>
<p>운영 프로필은 <code>spring.jpa.hibernate.ddl-auto: validate</code>. 즉 <strong>스키마를 만들어 주지 않고
엔티티와 실제 테이블이 맞는지 검증만</strong> 한다. Neon에 갓 만든 빈 DB에는 테이블이 하나도 없으니
검증에서 바로 실패 → 기동 불가 → (1번의 <code>No open ports</code>).</p>
<h3 id="해결-이때는-수동">해결 (이때는 수동)</h3>
<ol>
<li>Neon 콘솔 → SQL Editor에서 전체 스키마 스크립트(<code>schema-postgresql.sql</code>) 1회 실행</li>
<li>시드 데이터 수동 insert:<ul>
<li>약관 3종(<code>terms</code>) — 회원가입 약관 동의 검증에 필요</li>
<li>관리자 계정 1개 — <code>LocalDataInitializer</code>는 <code>local</code> 전용이라 운영엔 시드가 안 들어감</li>
</ul>
</li>
</ol>
<p>관리자 비밀번호 해시는 프로젝트의 <code>BCryptPasswordEncoder</code>로 직접 생성해서 넣었다
(운영 DB에 평문을 넣을 수 없으니). 정책(8~64자, 영문·숫자·특수문자)에 맞는 랜덤 비번 생성 →
BCrypt 해시 → <code>INSERT INTO member (...)</code>.</p>
<h3 id="배운-점-2">배운 점</h3>
<ul>
<li><code>ddl-auto: validate</code>는 운영에서 옳은 선택이지만, <strong>스키마를 누가 만들 것인가</strong>를 같이 정해야 한다.
안 그러면 첫 배포에서 무조건 막힌다.</li>
<li>이 수동 과정이 배포마다 반복될 게 뻔해서 → 4번(Flyway)으로 이어졌다.</li>
</ul>
<hr>
<h2 id="4-매번-손으로-alter-table-치기-싫다-→-flyway-도입">4. 매번 손으로 <code>ALTER TABLE</code> 치기 싫다 → Flyway 도입</h2>
<h3 id="문제">문제</h3>
<p>백엔드에서 엔티티에 컬럼이 하나 추가될 때마다(<code>member</code>에 <code>easy_mode</code>, <code>font_scale</code> 추가 등)
배포 전에 Neon 콘솔에서 <code>ALTER TABLE</code>을 손으로 쳐야 했다. <code>validate</code>라서 안 치면 기동 실패.
팀 작업이라 놓치기 쉽고 위험.</p>
<h3 id="해결-flyway">해결: Flyway</h3>
<ul>
<li><p>의존성: <code>org.flywaydb:flyway-core</code> + <code>flyway-database-postgresql</code></p>
</li>
<li><p><code>backend/src/main/resources/db/migration/</code></p>
<ul>
<li><code>V1__baseline.sql</code> — Flyway 도입 시점의 스키마 (그때 운영 DB 상태와 동일)</li>
<li><code>V2__member_display_preference.sql</code> — 이후 추가된 컬럼</li>
</ul>
</li>
<li><p>프로필별 설정</p>
<pre><code class="language-yaml"># application.yml (공통)
spring.flyway.enabled: false     # local/test 는 H2 + create-drop 유지

# application-prod.yml
spring:
  flyway:
    enabled: true
    baseline-on-migrate: true    # 이미 스키마가 있는 Neon DB 위에 얹기
    baseline-version: 1          # V1 은 &quot;이미 적용됨&quot;으로 마킹만, 실행 안 함
  jpa.hibernate.ddl-auto: validate</code></pre>
</li>
</ul>
<h3 id="이미-운영-중인-db에-flyway를-처음-붙일-때-포인트">이미 운영 중인 DB에 Flyway를 처음 붙일 때 포인트</h3>
<p><code>baseline-on-migrate: true</code> + <code>baseline-version: 1</code>이면:</p>
<ol>
<li><code>flyway_schema_history</code> 테이블이 없으면 생성</li>
<li>스키마가 비어있지 않으므로 V1을 <strong>실행하지 않고</strong> &quot;baseline(=적용됨)&quot;으로만 기록</li>
<li>V2부터 실제 적용</li>
</ol>
<p>실제 배포 로그:</p>
<pre><code>Successfully baselined schema with version: 1
Migrating schema &quot;public&quot; to version &quot;2 - member display preference&quot;
Successfully applied 1 migration to schema &quot;public&quot;, now at version v2</code></pre><p>이후로는 <code>V3__*.sql</code> 파일만 PR에 같이 넣으면 배포 시 자동 적용. Neon 콘솔 열 일이 없어졌다.</p>
<h3 id="삽질-h2-스모크-테스트에서-걸린-것">삽질: H2 스모크 테스트에서 걸린 것</h3>
<p>로컬엔 PostgreSQL이 없어서, H2를 PostgreSQL 호환 모드로 띄워 마이그레이션이 실행되는지만
확인했다. 그때 V2가 실패:</p>
<pre><code class="language-sql">-- 실패 (H2는 한 ALTER TABLE에 ADD COLUMN 여러 개를 못 씀)
ALTER TABLE member
  ADD COLUMN easy_mode boolean NOT NULL DEFAULT false,
  ADD COLUMN font_scale varchar(10) NOT NULL DEFAULT &#39;NORMAL&#39;;

-- 통과 (문장 분리 — Postgres/H2 둘 다 OK)
ALTER TABLE member ADD COLUMN easy_mode boolean NOT NULL DEFAULT false;
ALTER TABLE member ADD COLUMN font_scale varchar(10) NOT NULL DEFAULT &#39;NORMAL&#39;;</code></pre>
<p>PostgreSQL은 다중 <code>ADD COLUMN</code>을 지원하지만, 마이그레이션 SQL은 문장별로 나눠 쓰는 게
이식성 면에서 안전하다는 걸 배웠다.</p>
<h3 id="배운-점-3">배운 점</h3>
<ul>
<li><code>ddl-auto: validate</code> + 수동 SQL은 팀이 커지면 반드시 사고 난다. 초기에 Flyway를 넣는 게 낫다.</li>
<li>이미 데이터가 있는 DB에 마이그레이션 도구를 처음 붙일 땐 <code>baseline</code> 개념을 이해해야 한다.</li>
<li>마이그레이션 SQL은 벤더 특화 문법을 피하고 문장을 잘게 쪼갠다.</li>
</ul>
<hr>
<h2 id="5-환경변수-넣었는데-왜-안-돼--프론트-백엔드-연동-토대가-없었다">5. &quot;환경변수 넣었는데 왜 안 돼?&quot; — 프론트-백엔드 연동 토대가 없었다</h2>
<h3 id="증상-2">증상</h3>
<p>Vercel에 <code>VITE_API_BASE_URL</code>을 넣고 재배포했는데 아무 변화가 없었다.</p>
<h3 id="원인-3">원인</h3>
<p>프론트에 <strong>그 변수를 읽는 코드 자체가 없었다.</strong> <code>import.meta.env.VITE_API_BASE_URL</code>을
참조하는 곳도, <code>fetch</code>/<code>axios</code> 호출도 하나도 없었다. 세션도 가짜(<code>DEMO_USERS</code> 하드코딩)였다.
즉 &quot;연동&quot;이라고 부를 게 없는 상태에서 환경변수만 넣은 것.</p>
<h3 id="해결-api-계층부터-깔기">해결: API 계층부터 깔기</h3>
<ul>
<li><code>lib/api-client.ts</code> — <code>apiFetch()</code> 래퍼<ul>
<li>baseURL(<code>VITE_API_BASE_URL</code>) + JSON 직렬화</li>
<li><code>Authorization: Bearer</code> 자동 첨부</li>
<li><strong>401 → refresh 토큰으로 <code>/api/auth/reissue</code> 1회 재시도 후 재요청</strong> (동시에 터진 401은
재발급 1번으로 합침)</li>
<li>실패는 <code>ApiError</code>(code/message/fieldErrors)로 통일</li>
</ul>
</li>
<li><code>lib/token-store.ts</code> — access/refresh 토큰 localStorage 보관 + 구독(탭 간 동기화)</li>
<li><code>api/auth.ts</code>, <code>api/members.ts</code> — 엔드포인트별 함수</li>
<li><code>hooks/use-app.tsx</code> — 가짜 세션 제거, 토큰 상태를 구독해 <code>/api/members/me</code>로 실제 세션 로드</li>
<li><code>/login</code>, <code>/oauth/callback</code> 화면</li>
<li><code>.env.example</code>(커밋) / <code>.env.local</code>(gitignore)</li>
</ul>
<p>라이브 백엔드에 실제로 호출해서 응답 형태가 타입 정의와 맞는지까지 확인했다.</p>
<h3 id="배운-점-4">배운 점</h3>
<ul>
<li>Vite 환경변수는 <strong>빌드 시점에 번들에 문자열로 박힌다.</strong> 그래서:<ul>
<li>변수를 바꾸면 재배포해야 반영됨</li>
<li>배포된 JS 번들을 열어보면 어떤 값이 박혔는지 grep으로 확인 가능</li>
</ul>
</li>
<li>&quot;환경변수를 넣었다&quot;와 &quot;그 값을 쓰는 코드가 있다&quot;는 별개다.</li>
</ul>
<hr>
<h2 id="6-vercel의-vite_-접두어-경고">6. Vercel의 <code>VITE_</code> 접두어 경고</h2>
<h3 id="상황">상황</h3>
<p>Vercel이 <code>VITE_API_BASE_URL</code>에 대해 이런 경고를 띄웠다:</p>
<blockquote>
<p>&quot;Remove the public framework prefix to keep this value private.
 Public prefixes expose values to the browser.&quot;</p>
</blockquote>
<h3 id="결론-무시해도-된다">결론: 무시해도 된다</h3>
<ul>
<li>Vite는 <strong><code>VITE_</code> 접두어가 붙은 변수만</strong> 클라이언트 코드에 노출한다. 접두어를 떼거나
&quot;Config&quot;(비공개)로 바꾸면 프론트가 값을 못 읽어서 연동이 통째로 깨진다.</li>
<li>값이 그냥 공개 API URL(<code>https://...onrender.com</code>)이라 브라우저에 노출돼도 안전하다.
시크릿(DB 비번, API 키)은 애초에 프론트 환경변수에 두지 않는다.</li>
<li>Vercel 경고는 &quot;공개 접두어에 시크릿 넣지 마라&quot;는 일반 주의문일 뿐, 공개 URL엔 해당 없음.</li>
</ul>
<hr>
<h2 id="7-cloudflare-r2-s3-호환-파일-스토리지-붙이기">7. Cloudflare R2 (S3 호환) 파일 스토리지 붙이기</h2>
<h3 id="배경">배경</h3>
<p>파일 업로드가 stub 구현체라서 가짜 URL(<code>https://files.example.invalid</code>)만 반환하고 있었다.
자격증 이미지·사업자등록증·프로필 사진이 실제로 저장이 안 됨.</p>
<h3 id="구현">구현</h3>
<ul>
<li><code>StubFileStorageService</code>는 그대로 두고 <code>R2FileStorageService</code> 추가</li>
<li><code>@ConditionalOnProperty(prefix=&quot;carematch.storage&quot;, name=&quot;provider&quot;, havingValue=&quot;r2&quot;)</code>로 전환
(stub은 <code>matchIfMissing=true</code>)</li>
<li>R2는 S3 호환이라 AWS SDK v2(<code>software.amazon.awssdk:s3</code>) 그대로 사용<ul>
<li>업로드: <code>S3Presigner.presignPutObject</code> (presigned PUT URL)</li>
<li>확인: <code>S3Client.headObject</code>로 존재/크기/타입 검증</li>
<li>다운로드: <code>presignGetObject</code> (TTL, 영구 공개 URL 금지)</li>
</ul>
</li>
</ul>
<h3 id="r2-특유의-함정">R2 특유의 함정</h3>
<pre><code class="language-java">S3Configuration serviceConfig = S3Configuration.builder()
        .pathStyleAccessEnabled(true)      // path-style
        .chunkedEncodingEnabled(false)     // presigned PUT 호환 (aws-chunked 인코딩 끔)
        .build();

S3Client.builder()
        .region(Region.of(&quot;auto&quot;))         // R2는 region을 무시하지만 SDK는 필수 → &quot;auto&quot;
        .endpointOverride(URI.create(endpoint))  // https://&lt;account_id&gt;.r2.cloudflarestorage.com
        .credentialsProvider(StaticCredentialsProvider.create(creds))
        .httpClientBuilder(UrlConnectionHttpClient.builder())  // 가벼운 동기 클라이언트
        .serviceConfiguration(serviceConfig)
        .build();</code></pre>
<ul>
<li><code>region</code>은 아무 값이나 필요 → <code>auto</code></li>
<li><code>endpointOverride</code>로 R2 계정 엔드포인트 지정</li>
<li><code>pathStyleAccessEnabled(true)</code> + <code>chunkedEncodingEnabled(false)</code>가 presigned PUT에서 중요</li>
</ul>
<h3 id="놓치기-쉬운-것-r2-버킷-cors">놓치기 쉬운 것: R2 버킷 CORS</h3>
<p>프론트는 백엔드가 준 presigned URL로 <strong>브라우저에서 R2로 직접 PUT/GET</strong>한다. 그래서
R2 버킷 자체에 CORS 정책(프론트 도메인 허용)을 Cloudflare 대시보드에서 따로 걸어야 한다.
백엔드(Spring)의 CORS 설정과는 완전히 별개다.</p>
<pre><code class="language-json">[
  {
    &quot;AllowedOrigins&quot;: [&quot;https://&lt;프론트 도메인&gt;&quot;, &quot;http://localhost:5173&quot;],
    &quot;AllowedMethods&quot;: [&quot;GET&quot;, &quot;PUT&quot;],
    &quot;AllowedHeaders&quot;: [&quot;content-type&quot;],
    &quot;ExposeHeaders&quot;: [&quot;ETag&quot;],
    &quot;MaxAgeSeconds&quot;: 3600
  }
]</code></pre>
<h3 id="e2e-검증">E2E 검증</h3>
<p>배포 후 실제로 돌려봤다:</p>
<pre><code>POST /api/files/upload-url   → 진짜 R2 presigned PUT URL 반환
PUT  &lt;presigned URL&gt;         → 200 (R2에 파일 기록됨)
POST /api/files/confirm      → {&quot;exists&quot;:true, &quot;sizeBytes&quot;:45, &quot;contentType&quot;:&quot;application/pdf&quot;}</code></pre><p>로그: <code>[R2] 초기화 완료 endpoint=https://... bucket=carematch-prod</code></p>
<h3 id="배운-점-5">배운 점</h3>
<ul>
<li>S3 호환 스토리지(R2/MinIO 등)는 AWS SDK를 그대로 쓰되 <code>endpointOverride</code> + <code>region(&quot;auto&quot;)</code> +
path-style + chunked encoding off 조합을 기억해두면 편하다.</li>
<li>브라우저 직접 업로드 구조에서는 <strong>스토리지 버킷의 CORS</strong>를 잊지 말 것. (백엔드 CORS와 별개)</li>
<li>리소스 이름 헷갈림 주의: API 토큰 이름과 버킷 이름을 다르게 만들었다가 <code>STORAGE_BUCKET</code>을
잘못 넣을 뻔했다.</li>
</ul>
<hr>
<h2 id="삽질-하나-더-wsl에-jdk가-없었다">삽질 하나 더: WSL에 JDK가 없었다</h2>
<p><code>./gradlew build</code>가 이렇게 실패했다:</p>
<pre><code>ERROR: JAVA_HOME is set to an invalid directory: /mnt/c/Users/SBS/.jdks/corretto-21.0.10</code></pre><p>WSL이 Windows의 <code>JAVA_HOME</code>(IntelliJ가 관리하는 Windows 경로)을 그대로 상속하는데,
그 경로는 Linux에선 존재하지 않는다. sudo 비번이 없어서 apt도 못 씀.</p>
<p>해결: Temurin JDK 21 tarball을 홈 디렉터리에 풀고 <code>~/.bashrc</code>에서 <code>JAVA_HOME</code>을 덮어씀.</p>
<pre><code class="language-bash">export JAVA_HOME=&quot;$HOME/.local/jdks/jdk-21.0.12.1+1&quot;
export PATH=&quot;$JAVA_HOME/bin:$PATH&quot;</code></pre>
<p>(빌드는 <code>options.release = 17</code>이라 JDK 21로 빌드해도 17 바이트코드가 나온다.)</p>
<hr>
<h2 id="오늘의-정리">오늘의 정리</h2>
<table>
<thead>
<tr>
<th>문제</th>
<th>핵심 원인</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>Render <code>No open ports</code></td>
<td>포트가 아니라 앱 기동 실패</td>
<td><code>server.port: ${PORT:8080}</code> + 로그 스택트레이스 확인</td>
</tr>
<tr>
<td>문자열 컬럼이 <code>oid</code></td>
<td>Hibernate 6 + PG에서 <code>@Lob String</code> → <code>oid</code></td>
<td><code>@Column(columnDefinition = &quot;TEXT&quot;)</code></td>
</tr>
<tr>
<td><code>missing table</code></td>
<td>빈 운영 DB + <code>ddl-auto: validate</code></td>
<td>스키마/시드 적재 → 이후 Flyway</td>
</tr>
<tr>
<td>배포마다 수동 ALTER</td>
<td>마이그레이션 도구 부재</td>
<td>Flyway + <code>baseline-on-migrate</code></td>
</tr>
<tr>
<td>환경변수 넣었는데 무반응</td>
<td>그 값을 쓰는 코드가 없었음</td>
<td>API 클라이언트 계층부터 구현</td>
</tr>
<tr>
<td>Vercel <code>VITE_</code> 경고</td>
<td>공개 접두어 일반 주의문</td>
<td>무시 (공개 URL은 안전, <code>VITE_</code> 필수)</td>
</tr>
<tr>
<td>파일이 저장 안 됨</td>
<td>stub 스토리지</td>
<td>R2 구현체 + <code>@ConditionalOnProperty</code> 전환</td>
</tr>
</tbody></table>
<h3 id="배포-연동에서-반복해서-느낀-것">배포 연동에서 반복해서 느낀 것</h3>
<ol>
<li><strong>에러 메시지를 표면 그대로 믿지 말 것.</strong> <code>No open ports</code>는 포트 문제가 아니었고,
<code>missing table</code>은 코드가 아니라 인프라 상태 문제였다.</li>
<li><strong>로컬(H2)과 운영(PostgreSQL)의 차이가 배포 시점에 몰려서 터진다.</strong> 타입 매핑, 스키마 관리,
방언 함수. 가능하면 운영과 같은 DB로 한 번은 돌려봐야 한다.</li>
<li><strong>&quot;설정했다&quot;와 &quot;그 설정을 쓰는 코드가 있다&quot;는 다르다.</strong> (환경변수, 스토리지 provider 등)</li>
<li><strong>인프라 경계마다 CORS가 따로 있다.</strong> 백엔드 CORS ≠ 스토리지 버킷 CORS.</li>
<li>검증은 반드시 <strong>라이브 환경에서 실제 요청</strong>으로. 빌드 통과 ≠ 동작.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 100]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-100</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-100</guid>
            <pubDate>Mon, 07 Sep 2026 23:49:13 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="팀-프로젝트-carematch">팀 프로젝트 CareMatch</h1>
<h3 id="-모노레포-전환과-배포-아키텍처-결정기--neon--render--vercel-그리고-cloudflare">-모노레포 전환과 배포 아키텍처 결정기 — Neon + Render + Vercel, 그리고 Cloudflare</h3>
<blockquote>
<p>CareMatch 프로젝트  · 2026-09-08</p>
</blockquote>
<p>오늘은 코드를 많이 짜기보다, <strong>어디에 어떻게 배포할지</strong>를 정리하는 데 시간을 썼다.
포트폴리오용 프로젝트라 &quot;그냥 되게&quot; 만드는 것보다 선택의 근거를 남겨두는 게 의미가 있을 것 같아서 글로 정리한다.</p>
<hr>
<h2 id="1-모노레포로-전환">1. 모노레포로 전환</h2>
<p>그동안 백엔드 저장소만 있었는데, 프론트가 곧 붙기 때문에 구조를 먼저 잡았다.</p>
<pre><code>backend/    # Spring Boot API 서버 (Render 배포)
frontend/   # React 앱 (배포 예정)
docs/       # API / ERD 문서 (공용)
.github/    # CODEOWNERS, 워크플로우
CLAUDE.md   # 프로젝트 규칙</code></pre><ul>
<li>기존 백엔드 소스를 통째로 <code>backend/</code> 아래로 이동</li>
<li><code>frontend/</code> 는 아직 초기화 전이라 플레이스홀더 README만 넣어둠</li>
<li>IntelliJ는 루트가 아니라 <code>backend/</code> 를 Gradle 프로젝트로 임포트해야 인식됨</li>
<li>Dockerfile 빌드 컨텍스트도 <code>backend/</code> 기준으로 맞춤 (Render Root Directory = <code>backend</code>)</li>
</ul>
<p>브랜치 전략은 GitHub Flow 유지: <code>main</code> + <code>feature/*</code>, 배포는 태그로 표시.
관련 PR: #16, #18, #19 머지 완료.</p>
<hr>
<h2 id="2-출발점-neon--render--vercel">2. 출발점: Neon + Render + Vercel</h2>
<p>처음 생각한 그림은 흔한 3단 구성이었다.</p>
<table>
<thead>
<tr>
<th>레이어</th>
<th>서비스</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>DB</td>
<td>Neon (PostgreSQL)</td>
<td>서버리스 Postgres, 무료 티어</td>
</tr>
<tr>
<td>백엔드</td>
<td>Render</td>
<td>Docker 런타임 지원, Spring Boot 배포 편함</td>
</tr>
<tr>
<td>프론트</td>
<td>Vercel</td>
<td>React 정적 호스팅 표준</td>
</tr>
</tbody></table>
<p>그런데 &quot;이 3개만 연결하면 끝인가?&quot; 를 따져보니 그렇지 않았다.</p>
<h3 id="3개-연결에서-실제로-필요한-설정">3개 연결에서 실제로 필요한 설정</h3>
<ul>
<li><strong>Render 환경변수</strong>: <code>SPRING_PROFILES_ACTIVE=prod</code>, <code>DB_URL</code>(Neon 접속 URL을 JDBC 형식으로), <code>DB_USERNAME</code>, <code>DB_PASSWORD</code>, <code>JWT_SECRET</code>(64바이트 이상 랜덤), <code>CORS_ALLOWED_ORIGINS</code>(프론트 도메인)</li>
<li><strong>Vercel 환경변수</strong>: API 베이스 URL = Render 주소</li>
<li><strong>Render 서비스 설정</strong>: Root Directory <code>backend</code>, Docker, Health Check Path <code>/actuator/health</code></li>
</ul>
<h3 id="놓치기-쉬운-함정">놓치기 쉬운 함정</h3>
<ol>
<li><strong>prod 프로필은 <code>ddl-auto: validate</code></strong> — Neon DB에 테이블이 하나도 없으면 부팅 자체가 실패한다.
마이그레이션 도구(Flyway/Liquibase)가 아직 없어서, 최초 1회 스키마를 만들어줘야 한다.</li>
<li><strong>Neon / Render 무료 플랜은 유휴 시 잠듦</strong> — Neon은 커넥션이 끊기고 Render는 cold start가 생긴다.
커넥션 풀(<code>DB_POOL_SIZE</code>)은 작게 잡는 게 안전하다.</li>
</ol>
<h3 id="아직-stub으로-남아-있는-외부-연동">아직 stub으로 남아 있는 외부 연동</h3>
<p>코드를 열어보니 인터페이스만 있고 구현체는 비어 있는 것들이 꽤 있었다.</p>
<table>
<thead>
<tr>
<th>기능</th>
<th>현재 상태</th>
<th>실제로 쓰려면</th>
</tr>
</thead>
<tbody><tr>
<td>소셜 로그인 (네이버·카카오·구글)</td>
<td>client-id <code>dummy</code></td>
<td>각 개발자 콘솔 앱 등록 + redirect URI 등록 + 키 6개 주입</td>
</tr>
<tr>
<td>파일 업로드</td>
<td><code>STORAGE_PROVIDER=stub</code>, 더미 URL</td>
<td>R2 / S3 등 오브젝트 스토리지 연결</td>
</tr>
<tr>
<td>인증코드 발송 (이메일·SMS)</td>
<td>로그만 출력</td>
<td>SMS / 메일 서비스 연동</td>
</tr>
<tr>
<td>1:1 문의 알림</td>
<td>로그만 출력</td>
<td>이메일 / 카카오 알림</td>
</tr>
<tr>
<td>포인트·결제</td>
<td>Stub, PG 미연동</td>
<td>범위 밖 (2차)</td>
</tr>
</tbody></table>
<p>즉 &quot;로그인 / 회원가입 / 공고 조회&quot; 데모 수준이면 3개 연결 + 함정 2개만 처리하면 되고,
소셜 로그인·파일 업로드·문자 인증까지 실제로 돌리려면 그만큼 외부 서비스를 더 붙여야 한다.</p>
<hr>
<h2 id="3-그럼-cloudflare-하나로-다-못-하나">3. &quot;그럼 Cloudflare 하나로 다 못 하나?&quot;</h2>
<p>포트폴리오라 이왕이면 다양한 걸 써보고 싶었고, Cloudflare 한 곳으로 통합하면 깔끔할 것 같았다.
그런데 지금 구조(Spring Boot + JPA + PostgreSQL)에는 잘 안 맞았다.</p>
<table>
<thead>
<tr>
<th>현재</th>
<th>Cloudflare 대응</th>
<th>가능 여부</th>
</tr>
</thead>
<tbody><tr>
<td>Vercel (React)</td>
<td><strong>Pages</strong></td>
<td>✅ 그대로 이전 가능</td>
</tr>
<tr>
<td>Render (Spring Boot Docker)</td>
<td>Workers / Containers</td>
<td>⚠️ Workers는 V8 아이솔레이트 런타임 → JVM 실행 불가. Containers로 Docker는 돌지만 상시 API 서버용으로는 미성숙</td>
</tr>
<tr>
<td>Neon (PostgreSQL)</td>
<td>D1 / Hyperdrive</td>
<td>❌ D1은 SQLite라 Hibernate PostgreSQL 다이얼렉트·JDBC와 안 맞음. Hyperdrive는 DB가 아니라 외부 Postgres 가속용</td>
</tr>
<tr>
<td>파일 스토리지</td>
<td><strong>R2</strong></td>
<td>✅ S3 호환, 잘 맞음</td>
</tr>
</tbody></table>
<p>핵심 걸림돌 두 가지:</p>
<ul>
<li><strong>Workers에서는 <code>.jar</code> 를 못 돌린다.</strong> 백엔드를 Cloudflare에서 제대로 굴리려면 Containers를 써야 하는데,
24/7 API 호스팅 용도로는 Render보다 불편하고 콜드스타트·비용 이슈가 있다.</li>
<li><strong>Cloudflare에는 관리형 Postgres가 없다.</strong> D1로 가려면 JPA를 걷어내고 persistence를 다시 짜야 한다. 사실상 백엔드 재작성.</li>
</ul>
<p>DB까지 Cloudflare로 완전히 넣으려면 Spring Boot 자체를 포기해야 한다는 결론.</p>
<hr>
<h2 id="4-결정-하이브리드-그리고-넓이보다-깊이">4. 결정: 하이브리드, 그리고 &quot;넓이보다 깊이&quot;</h2>
<p>고민하다가 방향을 바꿨다.</p>
<blockquote>
<p>&quot;여러 서비스 써봤다&quot;는 포트폴리오에서 생각보다 약한 카드다.
대시보드에서 배포 버튼 누른 수준이면 리뷰어는 금방 알아챈다.
Vercel을 Pages로 바꾸는 것도 거의 동일 기능 수평 이동이라 학습 신호가 거의 없다.</p>
</blockquote>
<p>그래서 호스팅을 옆으로 늘리는 대신, <strong>한두 곳을 깊게 파는</strong> 쪽으로 정했다.</p>
<h3 id="확정-스택">확정 스택</h3>
<table>
<thead>
<tr>
<th>레이어</th>
<th>서비스</th>
</tr>
</thead>
<tbody><tr>
<td>프론트</td>
<td>Cloudflare Pages</td>
</tr>
<tr>
<td>백엔드</td>
<td>Render (Spring Boot Docker)</td>
</tr>
<tr>
<td>DB</td>
<td>Neon (PostgreSQL)</td>
</tr>
<tr>
<td>파일 스토리지</td>
<td><strong>Cloudflare R2</strong></td>
</tr>
</tbody></table>
<p>Cloudflare를 쓰되 &quot;장식&quot;이 아니라 &quot;필연&quot;으로 쓰기로 했다.
마침 <code>FileStorageService</code> 인터페이스가 <strong>서명(만료) URL 기반 오브젝트 스토리지 교체</strong>를 전제로 설계돼 있었다.</p>
<pre><code class="language-java">public interface FileStorageService {
    UploadUrlResponse issueUploadUrl(FilePurpose purpose, String originalFilename, String contentType);
    FileMetadata confirmUpload(String fileKey);
    String issueDownloadUrl(String fileKey, Duration ttl);
}</code></pre>
<p>여기에 R2 구현체를 넣으면:</p>
<ul>
<li>기존 아키텍처에 자연스럽게 맞고</li>
<li>&quot;스텁 → 실제 벤더 교체&quot; 라는 확장 포인트를 코드로 증명하고</li>
<li>S3 호환 API·버킷 CORS·presign 만료 같은 실무 디테일을 다룬 흔적이 남는다</li>
</ul>
<p>이게 Vercel → Pages 갈아타기보다 훨씬 나은 &quot;Cloudflare 써봤다&quot; 라고 판단했다.</p>
<h3 id="로그인은-프론트-호스트와-무관">로그인은 프론트 호스트와 무관</h3>
<p>한 가지 확인한 것: OAuth를 전부 Spring Security(백엔드)가 처리한다.</p>
<ul>
<li>redirect URI = <code>https://&lt;render&gt;/login/oauth2/code/naver</code> → 백엔드 주소</li>
<li>프론트는 마지막에 토큰만 넘겨받는 정적 페이지</li>
</ul>
<p>그래서 프론트를 Vercel에 두든 Cloudflare Pages에 두든 로그인 흐름에는 영향이 없다.
프론트 쪽이 신경 쓸 건 CORS 화이트리스트에 도메인 등록하는 것 정도.
(<code>*.vercel.app</code> 이 아니라 <code>*.pages.dev</code> + 커스텀 도메인으로 값을 바꿔야 한다.)</p>
<hr>
<h2 id="5-다음-작업-내일">5. 다음 작업 (내일)</h2>
<p><code>feature/be-r2-storage</code> 브랜치에서 R2 구현 예정.</p>
<ul>
<li><code>StubFileStorageService</code> 는 그대로 두고 <code>R2FileStorageService</code> 추가
→ <code>@ConditionalOnProperty(carematch.storage.provider)</code> 로 <code>stub</code> / <code>r2</code> 전환</li>
<li>R2는 S3 호환이므로 AWS SDK for Java v2 의 <code>S3Presigner</code> 사용<ul>
<li><code>issueUploadUrl()</code> → <code>presignPutObject</code> (content-type + 만료)</li>
<li><code>confirmUpload()</code> → <code>HeadObjectRequest</code> 로 존재·크기·타입 검증</li>
<li><code>issueDownloadUrl()</code> → <code>presignGetObject</code> (TTL, 영구 공개 URL 금지)</li>
</ul>
</li>
<li>Render 신규 환경변수: <code>STORAGE_PROVIDER=r2</code>, <code>R2_ENDPOINT</code>, <code>R2_ACCESS_KEY_ID</code>, <code>R2_SECRET_ACCESS_KEY</code>, <code>STORAGE_BUCKET</code></li>
<li><strong>잊지 말 것</strong>: R2 버킷 자체에 CORS 정책(프론트 도메인에서 PUT/GET 허용)을 Cloudflare 대시보드에서 별도 설정</li>
</ul>
<p>그리고 이왕이면 Flyway 마이그레이션도 같이 도입하는 게 <code>ddl-auto: validate</code> 함정을 근본적으로 없애는 길이다.</p>
<hr>
<h2 id="오늘의-교훈">오늘의 교훈</h2>
<ol>
<li><strong>&quot;3개만 연결하면 끝&quot; 은 없다.</strong> 스텁으로 남은 연동, prod 프로필의 <code>validate</code>, 무료 플랜 유휴 등
실제 배포엔 항상 곁다리가 붙는다. 코드를 열어보고 나서야 전체 그림이 보였다.</li>
<li><strong>포트폴리오는 넓이가 아니라 깊이.</strong> 서비스 개수를 늘리는 것보다, 이미 설계된 확장 포인트 하나를
끝까지 구현하는 게 더 강한 신호다.</li>
<li><strong>아키텍처가 잘 잡혀 있으면 선택이 쉽다.</strong> <code>FileStorageService</code> 가 벤더 중립으로 설계돼 있었기 때문에
&quot;R2로 가자&quot; 는 결정에 코드 리스크가 거의 없었다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 99]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-99</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-99</guid>
            <pubDate>Sun, 06 Sep 2026 23:50:48 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="🏥-care-match-팀프로젝트-1일차">🏥 Care Match 팀프로젝트 1일차</h1>
<h3 id="--기획-및-역할-분담">- 기획 및 역할 분담</h3>
<h2 id="프로젝트-개요">프로젝트 개요</h2>
<ul>
<li><strong>프로젝트명</strong>: Care Match</li>
<li><strong>주제</strong>: 요양보호사 매칭 서비스</li>
<li><strong>기술 스택</strong><ul>
<li>프론트엔드: React (배포: Vercel)</li>
<li>백엔드: Spring (배포: Render)</li>
</ul>
</li>
<li><strong>팀 구성</strong>: 총 4명 (프론트엔드 2명, 백엔드 2명), 팀장 역할을 맡게 됨</li>
<li><strong>참조 서비스</strong>: 요양나라, 네이버 밴드 등</li>
</ul>
<hr>
<h2 id="오늘의-진행-내용">오늘의 진행 내용</h2>
<p>오늘은 팀프로젝트 첫날로, 본격적인 개발에 앞서 기획 단계를 진행했다.</p>
<h3 id="1-기획-방향-설정">1. 기획 방향 설정</h3>
<p>기존 요양보호사 매칭 관련 서비스(요양나라, 네이버 밴드 등)를 참고했는데, 사용성 측면에서 불편한 점이 크다고 판단했다. 그래서 이번 프로젝트의 1차 목표를 아래와 같이 정했다.</p>
<ul>
<li><strong>사용이 쉬운 서비스</strong></li>
<li><strong>시인성이 좋은 UI/UX</strong></li>
</ul>
<p>기존 서비스의 불편함을 개선하는 방향을 핵심 차별점으로 잡았다.</p>
<h3 id="2-팀-역할-분담">2. 팀 역할 분담</h3>
<p>4명을 프론트엔드 2명, 백엔드 2명으로 나누고, 각 파트 안에서도 세부 역할을 다시 분리했다.</p>
<table>
<thead>
<tr>
<th>파트</th>
<th>세부 분담</th>
</tr>
</thead>
<tbody><tr>
<td>프론트엔드</td>
<td>웹 담당 / 앱 담당으로 작업 분리</td>
</tr>
<tr>
<td>백엔드</td>
<td>기능(도메인 로직) 파트 / 회원·멤버 파트로 분리</td>
</tr>
</tbody></table>
<h3 id="3-1차-회의---방향성-조사">3. 1차 회의 - 방향성 조사</h3>
<p>첫 회의에서는 전체적인 틀과 필요한 기능을 어떻게 구성할지 조사하는 시간을 가졌다.</p>
<h3 id="4-2차-회의---세부-계획-확정">4. 2차 회의 - 세부 계획 확정</h3>
<p>이어진 2차 회의에서는 1차 조사 내용을 바탕으로 아래 내용을 구체화했다.</p>
<ul>
<li>프로젝트에 필요한 기능 목록</li>
<li>서비스의 목적과 디자인 방향성</li>
<li>파트별 상세 작업 분배</li>
</ul>
<h3 id="5-git-협업-환경-구축">5. Git 협업 환경 구축</h3>
<p>팀 프로젝트를 위해 Git 팀을 만들고 오가니제이션(Organization)을 구성했다. 여러 명이 함께 작업하는 저장소 환경을 처음 세팅해보는 과정이라 생각보다 손이 많이 갔다.</p>
<hr>
<h2 id="다음-일정">다음 일정</h2>
<p>내일 오전 11시에 각자 1차로 작업한 부분을 확인하고 점검하는 회의를 진행할 예정이다.</p>
<hr>
<h2 id="오늘의-소감">오늘의 소감</h2>
<p>첫날이라 코드보다는 기획과 방향성을 맞추는 데 시간을 많이 썼다. 팀장으로서 역할 분담과 회의 진행을 챙기는 게 새로운 경험이라, 내일 점검 회의에서 각자 작업물이 방향에 맞게 나왔는지 확인하는 게 중요할 것 같다.</p>
<p>특히 Git 오가니제이션과 팀 구성을 설정하는 부분이 생각보다 힘들었다. 여러 명이 함께 쓰는 저장소 환경을 처음 세팅해보니 신경 써야 할 게 많다는 걸 느꼈다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 98]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-98</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-98</guid>
            <pubDate>Fri, 04 Sep 2026 00:00:02 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="내가-만든-사이트가-구글-애드센스에서-가치-없는-콘텐츠로-거절당한-이유">내가 만든 사이트가 구글 애드센스에서 &quot;가치 없는 콘텐츠&quot;로 거절당한 이유</h1>
<p>사이드 프로젝트로 스키장 설황 정보 사이트를 만들고 있다. 이름은 스노우블러썸(snowbs.life). 전국 13개 스키장 실시간 날씨·설황·슬로프 현황을 한눈에 볼 수 있고, 커뮤니티·중고장터·크루 기능까지 붙여놨다.</p>
<p>기술 스택은 Next.js 14 App Router + TypeScript + Tailwind + Prisma + SQLite, 배포는 Cloudflare Pages로 돌리고 있다.</p>
<p>어느 정도 완성도가 됐다 싶어서 구글 애드센스를 신청했는데, 결과는 거절이었다.</p>
<hr>
<h2 id="거절-사유-가치가-별로-없는-콘텐츠">거절 사유: &quot;가치가 별로 없는 콘텐츠&quot;</h2>
<p>애드센스 대시보드에서 확인한 거절 사유는 아래였다.</p>
<blockquote>
<p><strong>정책 위반이 발견되었습니다.</strong><br>가치가 별로 없는 콘텐츠<br>사이트가 Google 게시자 네트워크의 사용 기준을 충족하지 않고 있습니다.</p>
</blockquote>
<p>처음엔 황당했다. 기능도 다 되고, 실제로 쓸 수 있는 서비스인데 왜 콘텐츠가 없다는 건지 이해가 안 됐다. 그런데 구글 크롤러 시점에서 사이트를 다시 봤더니 이유가 바로 보였다.</p>
<hr>
<h2 id="원인-분석--구글이-본-내-사이트는-이랬다">원인 분석 — 구글이 본 내 사이트는 이랬다</h2>
<h3 id="1-메인-인기글에-하하-ㅎㅎㅎ가-올라와-있었다">1. 메인 인기글에 &quot;하하&quot;, &quot;ㅎㅎㅎ&quot;가 올라와 있었다</h3>
<p>사이트 개발 중에 게시판 기능을 테스트하면서 관리자 계정으로 올린 글들을 삭제를 안 했다. 그게 메인페이지 &quot;커뮤니티 인기글&quot; 섹션에 그대로 노출되고 있었다.</p>
<ul>
<li>&quot;여러분 많이 떠들어 주세요 여러분의 사랑이 필요합니다.&quot;</li>
<li>&quot;하하&quot;</li>
<li>&quot;ㅎㅎㅎ&quot;</li>
</ul>
<p>댓글 수 0, 작성자 &quot;관리대장&quot;. 구글 크롤러 입장에서는 이게 사이트의 대표 콘텐츠로 보인다. 당연히 가치 없다고 판단할 수밖에 없다.</p>
<h3 id="2-가이드-글이-ai-생성-패턴이었다">2. 가이드 글이 AI 생성 패턴이었다</h3>
<p>가이드 섹션에 글을 꽤 열심히 채워뒀는데, 문제는 패턴이었다.</p>
<ul>
<li>2026.06.27부터 딱 <strong>하루에 하나씩</strong> 10개가 연속으로 올라와 있었다</li>
<li>작성자 닉네임이 글마다 전부 달랐다 — &quot;스키여행러&quot;, &quot;봄보더&quot;, &quot;뚜벅이보더&quot;, &quot;설질덕후&quot;, &quot;절약보더&quot;...</li>
<li>글 내용 자체는 나쁘지 않았지만, 이 패턴을 구글은 AI 대량 생성 콘텐츠(thin content)로 인식한다</li>
</ul>
<p>실제로 AI 도움을 받아 빠르게 채운 거라 이 판단이 완전히 틀린 것도 아니었다.</p>
<h3 id="3-비시즌이라-핵심-기능이-전부-죽어있었다">3. 비시즌이라 핵심 기능이 전부 죽어있었다</h3>
<p>스키장 사이트는 12월~3월 시즌에는 값어치가 있는데, 6월에 심사를 요청하니 메인 기능이 전부 이런 상태였다.</p>
<ul>
<li>슬로프 현황: 13개 리조트 전부 &quot;운영 종료 오프시즌&quot;</li>
<li>설황 데이터: 적설 0cm, 오픈 리조트 0개</li>
<li>상단 배너: &quot;❄️ 현재 오프시즌입니다&quot;</li>
</ul>
<p>구글 심사 담당자나 크롤러 입장에서는 이 사이트가 실제로 작동하는 서비스인지 의심할 수밖에 없다.</p>
<h3 id="4-contact-페이지가-없었다">4. Contact 페이지가 없었다</h3>
<p>이건 직접적인 거절 사유는 아닐 수 있지만, 애드센스 정책상 운영자 연락처 페이지가 없으면 신뢰도 점수에 영향을 준다. 개인정보처리방침과 이용약관은 있었지만 문의 페이지는 따로 없었다.</p>
<hr>
<h2 id="뭘-고쳐야-하는가">뭘 고쳐야 하는가</h2>
<p>분석 결과를 바탕으로 수정 항목을 우선순위 순으로 정리했다.</p>
<p><strong>즉시 해야 하는 것</strong></p>
<ol>
<li>관리자 테스트 글 전부 삭제 — DB에서 직접 처리</li>
<li>가이드 글 작성자 닉네임 통일 — 닉네임 10개를 2개 이하로 정리</li>
<li>가이드 글 게재 날짜 분산 — 하루 하나씩 연속은 너무 기계적으로 보임</li>
</ol>
<p><strong>추가로 해야 하는 것</strong></p>
<ol start="4">
<li>/contact 페이지 신설 — 이메일 하나라도 노출</li>
<li>가이드 글 5개 추가 — 현재 15개에서 20개 이상으로</li>
<li>오프시즌 배너 문구 개선 — 부정적 인상을 주는 &quot;현재 오프시즌&quot; 표현 정리</li>
</ol>
<hr>
<h2 id="배운-것">배운 것</h2>
<p>개발하면서 놓쳤던 부분이 명확해졌다. 기능 완성도에만 집중하다 보니 구글이 사이트를 어떤 시각으로 보는지를 전혀 고려하지 않았다.</p>
<p>구글 애드센스는 사이트 기능이 아니라 <strong>콘텐츠와 신뢰성</strong>을 본다. 아무리 실시간 API 연동이 잘 되어 있어도, 메인 화면에 &quot;하하&quot;가 올라와 있으면 그냥 가치 없는 사이트다.</p>
<p>테스트 데이터는 반드시 배포 전에 정리하고, AI로 콘텐츠를 생성할 거라면 날짜·작성자·양을 자연스럽게 분산해야 한다. 그리고 계절성 서비스는 비시즌에 심사 요청하는 것 자체가 불리하다.</p>
<p>수정 후 재신청 결과는 나중에 업데이트할 예정이다.</p>
<hr>
<h2 id="현재-상태">현재 상태</h2>
<ul>
<li>서비스: <a href="https://snowbs.life">snowbs.life</a></li>
<li>기술 스택: Next.js 14 App Router, TypeScript, Tailwind CSS, Prisma + SQLite, Cloudflare Pages</li>
<li>개발 기간: 2025.12 ~</li>
<li>애드센스 신청 결과: 거절 (2026.09) — 재신청 준비 중</li>
</ul>
<p>#사이드프로젝트 #애드센스 #웹개발 #NextJS #스노우블러썸</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Fullstack 97]]></title>
            <link>https://velog.io/@heo-hyuk/Fullstack-97</link>
            <guid>https://velog.io/@heo-hyuk/Fullstack-97</guid>
            <pubDate>Thu, 03 Sep 2026 00:27:44 GMT</pubDate>
            <description><![CDATA[<h1 id="풀스택">풀스택</h1>
<h1 id="어느-날-갑자기-api가-전부-죽었다--cloudflare-workersdev-서브도메인이-바뀌면-벌어지는-일">어느 날 갑자기 API가 전부 죽었다 — Cloudflare workers.dev 서브도메인이 바뀌면 벌어지는 일</h1>
<p>EasyPrompt 운영 기록</p>
<h2 id="증상-처음이에요만-되고-나머지는-다-안-됨">증상: &quot;처음이에요&quot;만 되고 나머지는 다 안 됨</h2>
<p>사용자한테서 제보가 왔다.</p>
<blockquote>
<p>&quot;프롬프트 찾기 들어가면 &#39;AI 처음이에요&#39; 빼고 나머지는 다
&#39;프롬프트를 불러오지 못했습니다&#39;라고만 떠요.&quot;</p>
</blockquote>
<p>EasyPrompt의 &quot;찾기&quot; 메뉴는 카드 4개로 되어 있다.</p>
<table>
<thead>
<tr>
<th>카드</th>
<th>경로</th>
<th>백엔드 호출</th>
</tr>
</thead>
<tbody><tr>
<td>AI 처음이에요</td>
<td><code>/guide</code></td>
<td>없음 (정적 페이지)</td>
</tr>
<tr>
<td>일반사용자용</td>
<td><code>/senior</code></td>
<td><code>GET /categories</code> → <code>GET /prompts</code></td>
</tr>
<tr>
<td>사무용 프롬프트 활용하기</td>
<td><code>/prompts</code></td>
<td><code>GET /categories</code> → <code>GET /prompts</code></td>
</tr>
<tr>
<td>AI 코딩 입문</td>
<td><code>/coding</code></td>
<td><code>GET /categories</code> → <code>GET /prompts</code></td>
</tr>
</tbody></table>
<p>증상을 듣자마자 범위가 좁혀졌다. 유일하게 멀쩡한 &quot;AI 처음이에요&quot;는
백엔드를 전혀 부르지 않는 순수 정적 페이지다. 나머지 셋은 전부
<code>GET /categories</code> 로 카테고리를 받아온 뒤 그 id로 <code>GET /prompts</code> 를
호출한다. <strong>셋이 동시에 죽었다는 건 공통 의존성인 백엔드 API가
문제라는 뜻이다.</strong> 프론트엔드 코드 문제가 아니다.</p>
<h2 id="진단-1-cors부터-의심했지만-아니었다">진단 1: CORS부터 의심했지만 아니었다</h2>
<p>Cloudflare Workers 백엔드는 CORS 허용 오리진을 딱 두 개만 열어둔다.</p>
<pre><code class="language-js">cors({
  origin: [&#39;http://localhost:5173&#39;, &#39;https://easyprompt.pages.dev&#39;],
  credentials: true,
})</code></pre>
<p>&quot;계정 분리 배포&quot; 작업을 최근에 했던 터라, 사이트가 다른 도메인에서
서빙되면서 CORS에 막히는 시나리오를 먼저 떠올렸다. 하지만 사용자가
보내준 브라우저 개발자도구 스크린샷이 이 가설을 바로 깼다.</p>
<ul>
<li>주소창: <code>https://easyprompt.pages.dev/senior</code> → <strong>이미 허용된 오리진</strong></li>
<li>Network 탭의 <code>categories</code> 요청: <strong>&quot;Provisional headers are shown&quot;</strong>,
응답 헤더 없음, 상태 코드 없음</li>
</ul>
<p>이 &quot;Provisional headers are shown&quot; 이 핵심이다. CORS 차단이라면
서버 응답은 도착하고 브라우저가 그걸 막는 형태라 상태 코드(200, 403 등)가
찍힌다. 응답 자체가 아예 없다는 건 <strong>요청이 서버에 도달조차 못 했다</strong>는
뜻이다. 네트워크 레벨, 즉 DNS나 연결 단계에서 실패했다.</p>
<h2 id="진단-2-dns를-찍어보니-도메인이-통째로-없었다">진단 2: DNS를 찍어보니 도메인이 통째로 없었다</h2>
<pre><code>$ nslookup backend-hono.asdf1378kk.workers.dev
*** Non-existent domain

$ nslookup asdf1378kk.workers.dev
*** Non-existent domain</code></pre><p>워커 주소만이 아니라 계정 서브도메인(<code>asdf1378kk.workers.dev</code>) 자체가
NXDOMAIN이다. 이 프로젝트는 2026-07-31부터 이 주소를 API 엔드포인트로
써 왔고, 그동안 잘 돌아갔다. 그런데 지금은 존재하지 않는다.</p>
<p>한편 확인해보니:</p>
<ul>
<li>Worker 스크립트 <code>backend-hono</code> 는 계정에 <strong>여전히 배포되어 있음</strong>
(마지막 배포 2026-08-03, <code>wrangler deployments list</code> 로 확인)</li>
<li>원격 D1 <code>easyprompt-db</code> 도 <strong>멀쩡함</strong>: 카테고리 7개, 프롬프트 74개</li>
</ul>
<p>코드도 데이터도 살아 있는데, <strong>바깥에서 접근할 주소만 사라진</strong> 상태였다.</p>
<h2 id="근본-원인-workersdev-서브도메인이-리네임됐다">근본 원인: workers.dev 서브도메인이 리네임됐다</h2>
<p>Cloudflare의 <code>*.workers.dev</code> 무료 서브도메인은 계정마다 하나씩 고른다.
<code>&lt;워커이름&gt;.&lt;계정서브도메인&gt;.workers.dev</code> 형태다. 이 계정의 서브도메인이
어느 시점엔가 <code>asdf1378kk</code> 에서 <code>heohyuk</code> 로 바뀌었다(계정/서브도메인
리네임 추정, 8월 말 &quot;계정 분리&quot; 작업 즈음). 서브도메인이 바뀌는 순간
기존 <code>*.asdf1378kk.workers.dev</code> 라우트는 <strong>전부 즉시 무효</strong>가 된다.
리다이렉트도 없다. 그냥 사라진다.</p>
<p><code>wrangler deploy</code> 를 다시 돌리자 실제 주소가 튀어나왔다.</p>
<pre><code>$ npx wrangler deploy
...
Deployed backend-hono triggers
  https://backend-hono.heohyuk.workers.dev      ← 진짜 주소
Current Version ID: a01b4d40-...</code></pre><pre><code>$ curl -s https://backend-hono.heohyuk.workers.dev/categories
[{&quot;id&quot;:1,&quot;name&quot;:&quot;경영기획&quot;,...}, ... 7개]    ← HTTP 200, 정상</code></pre><h2 id="복구-절차">복구 절차</h2>
<ol>
<li><p><strong>Worker 재배포</strong> — <code>backend-hono/</code> 에서 <code>npm install</code> 후
<code>npx wrangler deploy</code>. 새 주소 <code>backend-hono.heohyuk.workers.dev</code> 확인.
덤으로 2026-08-03 이후 배포 안 됐던 커밋 9개(시드 데이터, <code>point_reason</code>
컬럼 등)도 이때 함께 반영됐다.</p>
</li>
<li><p><strong>프론트엔드 설정의 URL 일괄 교체</strong> — <code>frontend/.env.local</code>,
<code>frontend/.env.example</code>, <code>README.md</code>(2곳), <code>backend-hono/README.md</code> 의
<code>backend-hono.asdf1378kk.workers.dev</code> → <code>backend-hono.heohyuk.workers.dev</code>.</p>
</li>
<li><p><strong>프론트엔드 재빌드 + 배포</strong> — <code>VITE_API_BASE_URL</code> 은 빌드 타임에
번들로 구워진다. <code>npm run build</code> 후
<code>wrangler pages deploy dist --project-name=easyprompt --branch=main</code>.
빌드된 JS 번들에 새 URL이 들어갔는지 <code>grep</code> 으로 확인.</p>
</li>
<li><p><strong>Cloudflare Pages 프로젝트 환경변수도 수정</strong> — 이게 함정이다.
Pages 대시보드(Settings → Variables and Secrets)에 저장된
<code>VITE_API_BASE_URL</code> 이 아직 옛 주소였다. 이걸 안 고치면 다음번
GitHub push 로 자동 빌드가 돌 때 <strong>옛 주소로 원복된다.</strong>
Production / Preview 둘 다 새 주소로 변경.</p>
</li>
</ol>
<h2 id="검증">검증</h2>
<p>커밋 <code>f95d08f</code> 를 push 하니 GitHub 연동 자동 빌드 <code>cc1b3d5f</code> 가
Production 으로 떴다. 확인 포인트:</p>
<ul>
<li>자동 빌드 번들의 API URL = <code>backend-hono.heohyuk.workers.dev</code> ✓
(대시보드 환경변수가 제대로 반영됐다는 증거)</li>
<li><code>easyprompt.pages.dev</code> 가 그 번들(<code>index-BDmTusTh.js</code>)을 서빙 ✓</li>
<li><code>/categories</code> → HTTP 200 ✓</li>
<li><code>/senior</code> 등 목록 페이지 정상 동작 ✓</li>
</ul>
<h2 id="배운-것">배운 것</h2>
<ul>
<li><p><strong>정적 페이지 하나가 살아 있으면 그게 진단 도구다.</strong> &quot;뭐는 되고 뭐는
안 되는지&quot;의 경계선이 곧 원인의 위치를 가리킨다. API 안 부르는
페이지만 멀쩡 → 프론트가 아니라 백엔드/네트워크.</p>
</li>
<li><p><strong>&quot;Provisional headers are shown&quot; = 응답을 못 받았다.</strong> CORS 차단과
네트워크 실패를 개발자도구에서 구분하는 가장 빠른 신호. CORS는
상태 코드가 찍히고, 네트워크 실패는 안 찍힌다.</p>
</li>
<li><p><strong>무료 <code>*.workers.dev</code> 주소를 프로덕션 API 엔드포인트로 쓰면
서브도메인 이름에 인프라가 묶인다.</strong> 계정 서브도메인은 대시보드에서
바꿀 수 있고, 바뀌면 기존 주소는 리다이렉트 없이 죽는다. 장기적으로는
커스텀 도메인(<code>api.example.com</code>)을 워커에 라우트로 붙이는 게 안전하다.</p>
</li>
<li><p><strong>빌드 타임 환경변수는 두 군데 있다.</strong> 레포의 <code>.env</code> 파일과 배포
플랫폼(Pages/Vercel/Netlify)의 프로젝트 설정. 수동 배포로 급한 불을
꺼도 플랫폼 설정을 안 고치면 다음 자동 빌드가 되돌린다. 둘 다 고쳐야
끝이다.</p>
</li>
<li><p><strong>코드가 배포돼 있다 ≠ 접근 가능하다.</strong> <code>wrangler deployments list</code>
에 배포 이력이 있어도 라우트(workers.dev든 커스텀 도메인이든)가
살아 있지 않으면 아무도 못 부른다. &quot;배포됨&quot;과 &quot;라우팅됨&quot;은 별개
상태다.</p>
</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>