<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>seungje_labs</title>
        <link>https://velog.io/</link>
        <description>[⚙️ + 💡] 효율 속에서 창의성을 실험합니다. </description>
        <lastBuildDate>Tue, 03 Mar 2026 00:23:14 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>seungje_labs</title>
            <url>https://velog.velcdn.com/images/seungje_labs/profile/00774f73-64b5-4876-b451-5401aa971a69/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. seungje_labs. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/seungje_labs" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[코드를 짜는 시대는 끝났다 — Harness Engineering 개념부터 실전 적용까지]]></title>
            <link>https://velog.io/@seungje_labs/%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%A7%9C%EB%8A%94-%EC%8B%9C%EB%8C%80%EB%8A%94-%EB%81%9D%EB%82%AC%EB%8B%A4-Harness-Engineering-%EA%B0%9C%EB%85%90%EB%B6%80%ED%84%B0-%EC%8B%A4%EC%A0%84-%EC%A0%81%EC%9A%A9%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@seungje_labs/%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%A7%9C%EB%8A%94-%EC%8B%9C%EB%8C%80%EB%8A%94-%EB%81%9D%EB%82%AC%EB%8B%A4-Harness-Engineering-%EA%B0%9C%EB%85%90%EB%B6%80%ED%84%B0-%EC%8B%A4%EC%A0%84-%EC%A0%81%EC%9A%A9%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 03 Mar 2026 00:23:14 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&quot;엔지니어의 역할이 코드를 작성하는 사람에서, AI가 안전하게 코드를 작성할 수 있는 <strong>환경을 설계하는 사람</strong>으로 바뀐다.&quot;</p>
</blockquote>
<h2 id="충격적인-숫자들">충격적인 숫자들</h2>
<p>2026년 2월, OpenAI가 내부 실험 결과를 공개했다.</p>
<ul>
<li>엔지니어 <strong>3→7명</strong></li>
<li><strong>5개월</strong> 동안 <strong>100만 줄</strong> 코드 생산</li>
<li><strong>수동으로 작성한 코드: 0줄</strong></li>
<li>PR 약 <strong>1,500개</strong> 머지</li>
<li>엔지니어당 하루 <strong>3.5개 PR</strong></li>
</ul>
<p>가장 놀라운 건, 팀원이 늘어날수록 속도가 <strong>빨라졌다</strong>는 것이다. 소프트웨어 공학의 고전 법칙인 브룩스의 법칙(&quot;지연되는 프로젝트에 인력을 추가하면 더 늦어진다&quot;)을 정면으로 반박한 결과다. 에이전트 간에는 커뮤니케이션 오버헤드가 없기 때문이다.</p>
<p>OpenAI는 이 방법론에 <strong>Harness Engineering</strong>(하네스 엔지니어링)이라는 이름을 붙였다.</p>
<hr>
<h2 id="harness-engineering이-뭔데">Harness Engineering이 뭔데?</h2>
<p>&quot;하네스&quot;는 원래 말(馬)에 씌우는 장비다. 강력하지만 예측 불가능한 동물의 힘을 방향 있게 쓰기 위한 도구 — 고삐, 안장, 울타리.</p>
<p>AI 에이전트에 대입하면:</p>
<table>
<thead>
<tr>
<th>말 장비</th>
<th>AI 개발</th>
</tr>
</thead>
<tbody><tr>
<td>음성 명령 (&quot;돌아!&quot;)</td>
<td>프롬프트</td>
</tr>
<tr>
<td>지도, 이정표</td>
<td>컨텍스트 (문서, RAG, 도구)</td>
</tr>
<tr>
<td>고삐 + 안장 + 울타리</td>
<td><strong>하네스</strong> (제약, 린터, CI, 피드백 루프)</td>
</tr>
</tbody></table>
<p><strong>정의</strong>: 아키텍처 제약, 피드백 루프, 관측성, 문서 시스템, 자동 품질 관리를 포함하는 구조화된 환경을 구축하여 AI 코딩 에이전트가 프로덕션 수준에서 안정적으로 작동하게 하는 학문.</p>
<hr>
<h2 id="프롬프트-→-컨텍스트-→-하네스-3단계-진화">프롬프트 → 컨텍스트 → 하네스: 3단계 진화</h2>
<pre><code>┌──────────────────────────────────────┐
│      Harness Engineering (하네스)      │  시스템이 무엇을 막고, 측정하고, 고치는가?
│  ┌──────────────────────────────────┐ │
│  │    Context Engineering (컨텍스트)   │ │  에이전트가 무엇을 보는가?
│  │  ┌──────────────────────────────┐ │ │
│  │  │   Prompt Engineering (프롬프트) │ │ │  지시문을 어떻게 쓰는가?
│  │  └──────────────────────────────┘ │ │
│  └──────────────────────────────────┘ │
└──────────────────────────────────────┘</code></pre><ul>
<li><strong>프롬프트 엔지니어링</strong>: &quot;이렇게 해줘&quot; — 단발성 지시 최적화</li>
<li><strong>컨텍스트 엔지니어링</strong>: &quot;이것들을 참고해서 해줘&quot; — 도구, RAG, 메모리, 스키마 설계</li>
<li><strong>하네스 엔지니어링</strong>: &quot;이 울타리 안에서, 이 피드백을 받으며, 이 품질 기준으로 해줘&quot; — 제약 + 관측 + 자동 교정</li>
</ul>
<p>핵심 차이를 한 마디로:</p>
<blockquote>
<p><strong>컨텍스트는 에이전트가 &quot;무엇을 아는가&quot;를 다루고, 하네스는 &quot;무엇이 잘못될 수 있고 어떻게 막는가&quot;를 다룬다.</strong></p>
</blockquote>
<hr>
<h2 id="openai가-정의한-세-가지-기둥">OpenAI가 정의한 세 가지 기둥</h2>
<h3 id="1-context-engineering--에이전트의-눈">1. Context Engineering — 에이전트의 눈</h3>
<blockquote>
<p>&quot;에이전트가 볼 수 없는 것은 존재하지 않는다.&quot;</p>
</blockquote>
<p>Slack에만 있는 결정, Google Docs에만 있는 설계 문서는 에이전트에게 <strong>없는 것</strong>이나 마찬가지다.</p>
<p><strong>실천법:</strong></p>
<ul>
<li><code>AGENTS.md</code>는 백과사전이 아니라 <strong>목차</strong> (~100줄)</li>
<li>실제 지식은 <code>docs/</code> 디렉토리에 구조화</li>
<li><strong>ExecPlan</strong>(실행 계획서) 작성 — 초보자도 구현 가능한 수준의 자족적 설계 문서</li>
<li>ExecPlan이 잘 작성되면 에이전트가 <strong>7시간 이상 무인 작업</strong> 가능</li>
</ul>
<pre><code>프로젝트/
├── AGENTS.md          ← 100줄짜리 목차
├── docs/
│   ├── architecture.md
│   ├── conventions.md
│   ├── gotchas.md     ← 자주 틀리는 것들
│   └── plans/
│       └── feature-x.md  ← ExecPlan</code></pre><h3 id="2-architectural-constraints--보이지-않는-울타리">2. Architectural Constraints — 보이지 않는 울타리</h3>
<blockquote>
<p>&quot;기계적으로 강제할 수 없으면, 에이전트는 반드시 벗어난다.&quot;</p>
</blockquote>
<p>OpenAI는 6계층 의존성 아키텍처를 강제했다:</p>
<pre><code>Types → Config → Repo → Service → Runtime → UI
          (의존성 방향: 오직 왼→오른쪽만 허용)</code></pre><ul>
<li><strong>커스텀 린터</strong>가 위반을 감지하면 빌드 즉시 실패</li>
<li>이 린터 자체도 Codex 에이전트가 작성했다</li>
<li>에러 메시지가 단순 경고가 아니라 <strong>교육적 해결법</strong>을 포함 — &quot;매 실패 메시지가 다음 시도의 컨텍스트가 된다&quot;</li>
</ul>
<h3 id="3-garbage-collection--ai-쓰레기-청소부">3. Garbage Collection — AI 쓰레기 청소부</h3>
<p>코드가 100만 줄이 되면, 문서는 빠르게 낡는다. 처음엔 매주 금요일에 사람이 &quot;AI slop&quot;을 정리했지만, 확장이 안 됐다.</p>
<p>해결책: <strong>백그라운드 에이전트</strong>를 돌렸다.</p>
<ul>
<li>낡은 문서 감지 → 자동 정리 PR 생성</li>
<li>아키텍처 위반 탐지 → 자동 리팩토링 PR</li>
<li>품질 기준 위반 → 자동 수정</li>
</ul>
<blockquote>
<p><strong>&quot;인간의 취향을 한 번 캡처하고, 지속적으로 강제한다.&quot;</strong></p>
</blockquote>
<hr>
<h2 id="vibe-coding-vs-harness-engineering">Vibe Coding vs. Harness Engineering</h2>
<p>요즘 &quot;바이브 코딩&quot;이라는 말을 많이 듣는다. 대충 말하면 AI가 알아서 코드를 짜주는 것. 재미있고 빠르다. 하지만 프로덕션에서는?</p>
<p>2025년 8월, 18명의 CTO를 대상으로 한 설문에서 <strong>16명이 AI 생성 코드로 인한 프로덕션 장애</strong>를 경험했다고 답했다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Vibe Coding</th>
<th>Harness Engineering</th>
</tr>
</thead>
<tbody><tr>
<td><strong>느낌</strong></td>
<td>&quot;대충 말하면 알아서&quot;</td>
<td>&quot;시스템이 잘못된 코드를 거부&quot;</td>
</tr>
<tr>
<td><strong>안전장치</strong></td>
<td>없음</td>
<td>린터 + CI + 구조 테스트</td>
</tr>
<tr>
<td><strong>실패 대응</strong></td>
<td>다시 프롬프트</td>
<td>하네스에 규칙 추가 (재발 방지)</td>
</tr>
<tr>
<td><strong>적합한 곳</strong></td>
<td>프로토타입, 해커톤</td>
<td>프로덕션, 팀 개발</td>
</tr>
<tr>
<td><strong>코드 품질</strong></td>
<td>기도 메타</td>
<td>기계적 강제</td>
</tr>
</tbody></table>
<p>가장 생산적인 패턴은 둘의 <strong>결합</strong>이다: 하네스로 울타리를 치고, 그 안에서 바이브하게 코딩한다.</p>
<p>그리고 숫자가 증명한다. Can.ac 벤치마크에서 <strong>하네스만 바꿨을 뿐인데</strong>, 모델 가중치는 건드리지 않고:</p>
<ul>
<li>Grok Code Fast 1: <strong>6.7% → 68.3%</strong> 성공률</li>
<li>GPT-4 Turbo: <strong>26% → 59%</strong> 성공률</li>
</ul>
<p>모델보다 <strong>하네스가 더 큰 성능 변수</strong>라는 뜻이다.</p>
<hr>
<h2 id="핵심-원칙-5가지">핵심 원칙 5가지</h2>
<p>OpenAI의 100만 줄 실험에서 추출한 원칙들:</p>
<h3 id="1-에이전트가-볼-수-없는-것은-존재하지-않는다">1. 에이전트가 볼 수 없는 것은 존재하지 않는다</h3>
<p>모든 결정을 마크다운, 스키마, ExecPlan으로 레포에 기록한다. Slack 대화? Google Docs? 에이전트에겐 존재하지 않는 것이다.</p>
<h3 id="2-왜-실패했지가-아니라-어떤-능력이-부족한가">2. &quot;왜 실패했지?&quot;가 아니라 &quot;어떤 능력이 부족한가?&quot;</h3>
<p>에이전트 실패 = 디버깅 대상이 아니라 <strong>설계 입력</strong>. 실패할 때마다 하네스에 도구/규칙/문서를 추가한다.</p>
<h3 id="3-문서보다-기계적-강제">3. 문서보다 기계적 강제</h3>
<p>사람이 &quot;이렇게 해주세요&quot;라고 써놓는 것보다, 린터가 &quot;이건 안 됩니다&quot;라고 빌드를 실패시키는 게 100배 효과적이다.</p>
<h3 id="4-에이전트에게-눈을-줘라">4. 에이전트에게 눈을 줘라</h3>
<p>Chrome DevTools Protocol로 실제 UI를 보게 하고, 로그/메트릭으로 성능을 확인하게 한다.</p>
<h3 id="5-매뉴얼이-아니라-지도">5. 매뉴얼이 아니라 지도</h3>
<p>100줄짜리 조감도. 아키텍처 경계만 명확히. 압도적인 세부사항은 오히려 해롭다.</p>
<hr>
<p>여기까지가 개념이다. 이제 직접 해본 이야기를 하겠다.</p>
<hr>
<h2 id="실전-적용-flutter-앱과-nextjs-웹에-같은-하네스를-씌우다">실전 적용: Flutter 앱과 Next.js 웹에 같은 하네스를 씌우다</h2>
<h3 id="프로젝트-소개">프로젝트 소개</h3>
<p>나는 사이드 프로젝트로 반려동물 건강 기록 서비스를 만들고 있다. 두 개의 코드베이스가 하나의 서비스를 이룬다.</p>
<ul>
<li><strong>PetLog App</strong> (Flutter + Supabase): 보호자가 매일 쓰는 앱. 건강 기록, 접종 일정, 사료 관리</li>
<li><strong>PetLog Web</strong> (Next.js + Supabase): 수의사/관리자용 대시보드. 통계 시각화, 환자 이력 조회</li>
</ul>
<pre><code>같은 Supabase DB를 바라보는 두 코드베이스:

  [Flutter App]  ──→  Supabase  ←──  [Next.js Web]
   (데이터 생산)        (DB)         (데이터 소비)</code></pre><p>기술 스택이 완전히 다르다. 하나는 Dart, 하나는 TypeScript. 하지만 하네스의 구조는 <strong>동일하게</strong> 적용할 수 있었다.</p>
<h3 id="before-문서는-있었는데-왜-안-됐나">Before: 문서는 있었는데 왜 안 됐나</h3>
<p>두 프로젝트 모두 <code>CLAUDE.md</code>와 <code>docs/</code>는 이미 있었다. 아키텍처 규칙도, Golden Principles도 정의해뒀다.</p>
<pre><code class="language-markdown"># 기존 CLAUDE.md (발췌)
## Golden Principles
1. Clean Architecture 3레이어 준수: domain/ → data/ → presentation/
2. 상태관리는 Riverpod provider만
3. Feature 간 직접 import 금지 — core/를 통해서만 공유</code></pre>
<p>꽤 괜찮아 보인다. 문제는 이걸 가지고 &quot;기능 만들어줘&quot;라고 하면 에이전트가:</p>
<ol>
<li>문서를 읽긴 하는데, <strong>설계 없이 바로 코딩 시작</strong></li>
<li>중간에 아키텍처 위반해서 삽질 → 롤백</li>
<li>끝나고 나서야 &quot;아, 이거 주의사항이었는데&quot; 하고 깨달음</li>
<li>다음 세션에선 <strong>같은 실수 반복</strong></li>
</ol>
<p>원인은 명확했다. <strong>문서는 있었지만, &quot;문서를 언제, 어떤 순서로, 어떻게 써야 하는지&quot;를 정의하지 않았던 것이다.</strong></p>
<p>규칙은 있는데 <strong>절차가 없었다.</strong> 마치 교통법규는 있는데 신호등이 없는 교차로 같았다.</p>
<h3 id="after-4단계-워크플로우-프로토콜">After: 4단계 워크플로우 프로토콜</h3>
<p>CLAUDE.md에 <strong>에이전트의 행동 절차</strong>를 추가했다:</p>
<pre><code class="language-markdown">## 🔒 에이전트 워크플로우 프로토콜

### Phase 1: 분석 (자동)
→ 의도 파악, 코드베이스 탐색, docs/ 전체 읽기

### Phase 2: 설계 (자동 → 사용자 승인)
→ ExecPlan 작성 → 사용자에게 보여주고 승인 요청
→ ⚠️ 승인 없이 구현 시작 금지

### Phase 3: 구현 (단계별 체크포인트)
→ 매 단계마다 린트 + 빌드 + 테스트 확인 후 커밋

### Phase 4: 검증 보고
→ 완료 기준 체크 + 최종 요약</code></pre>
<p>핵심 발견: <strong>프로토콜은 동일하고, 체크포인트만 스택별로 다르다.</strong></p>
<table>
<thead>
<tr>
<th></th>
<th>PetLog App (Flutter)</th>
<th>PetLog Web (Next.js)</th>
</tr>
</thead>
<tbody><tr>
<td><strong>린트</strong></td>
<td><code>flutter analyze</code></td>
<td><code>npm run lint</code></td>
</tr>
<tr>
<td><strong>타입 체크</strong></td>
<td>Dart 내장</td>
<td><code>tsc --noEmit</code></td>
</tr>
<tr>
<td><strong>빌드</strong></td>
<td><code>flutter build</code></td>
<td><code>npm run build</code></td>
</tr>
<tr>
<td><strong>테스트</strong></td>
<td><code>flutter test</code></td>
<td><code>vitest</code></td>
</tr>
<tr>
<td><strong>아키텍처 검증</strong></td>
<td><code>scripts/check_architecture.sh</code></td>
<td>의존성 방향 확인</td>
</tr>
</tbody></table>
<p>프로토콜이라는 <strong>울타리</strong>는 같지만, 울타리 안의 <strong>도구</strong>는 각 스택에 맞게 교체한 것이다. 아키텍처 규칙도 마찬가지다:</p>
<pre><code>[Flutter App]                    [Next.js Web]
domain/ → data/ → presentation/  types/ → lib/ → components/ → app/
(역방향 import 금지)              (역방향 import 금지)</code></pre><p>표현은 다르지만, <strong>&quot;의존성은 한 방향으로만 흐른다&quot;</strong>는 원칙은 동일하다.</p>
<hr>
<h2 id="실전-시나리오-알림-기능-만들어줘">실전 시나리오: &quot;알림 기능 만들어줘&quot;</h2>
<p>이 한 마디를 입력하면 이제 이런 일이 벌어진다.</p>
<h3 id="phase-1-분석-자동--나는-기다리기만">Phase 1: 분석 (자동 — 나는 기다리기만)</h3>
<pre><code>[에이전트]
  CLAUDE.md 읽기 → 워크플로우 프로토콜 인식 ✓
  docs/ARCHITECTURE.md → Clean Architecture 3-layer 확인 ✓
  docs/CONVENTIONS.md → Riverpod + Freezed 패턴 확인 ✓
  docs/gotchas.md → &quot;domain에 Supabase import 금지&quot; 확인 ✓
  기존 features/ 패턴 분석 → notification/ 모듈 구조 설계 ✓</code></pre><h3 id="phase-2-설계-→-내-승인">Phase 2: 설계 → 내 승인</h3>
<pre><code>📋 ExecPlan을 작성했습니다.

목적: 접종 일정 D-3, D-1, 당일 푸시 알림 자동 발송
범위: FCM 기반 로컬 스케줄링 + Supabase Edge Function 트리거

영향 분석:
| 파일                                    | 변경   |
|-----------------------------------------|--------|
| lib/features/notification/domain/       | 신규   |
| lib/features/notification/data/         | 신규   |
| lib/features/notification/presentation/ | 신규   |
| supabase/functions/schedule-notify/     | 신규   |

아키텍처 결정:
- domain에 Supabase import 없음 (gotcha 준수)
- FCM 토큰 관리는 data 레이어에서만

구현 4단계:
1단계: Domain 모델 + Repository 인터페이스
2단계: Data 레이어 (Supabase + FCM 구현체)
3단계: Presentation (알림 설정 화면)
4단계: Edge Function + 통합 테스트

이대로 진행할까요?</code></pre><pre><code>나: &quot;좋아, 근데 알림 시간 커스텀도 추가해줘&quot;</code></pre><p>에이전트가 ExecPlan 수정 → 재확인 → 승인 후 구현 시작.</p>
<h3 id="phase-3-단계별-자동-구현">Phase 3: 단계별 자동 구현</h3>
<pre><code>━━━ 1단계: Domain 모델 ━━━
flutter analyze ✅ → flutter test ✅ → 커밋 💾

━━━ 2단계: Data 레이어 ━━━
flutter analyze ✅ → check_architecture.sh ✅ → 커밋 💾

━━━ 3단계: Presentation ━━━
flutter analyze ❌ → &quot;presentation에서 Supabase 직접 import 감지&quot;
🔧 자동 수정 → data 레이어 경유로 변경
📝 gotchas.md 업데이트: &quot;NotificationSettingsPage에서 SupabaseClient 직접 사용 금지&quot;
flutter analyze ✅ → 커밋 💾

━━━ 4단계: Edge Function + 테스트 ━━━
flutter test ✅ (전체 통과) → 커밋 💾</code></pre><p>3단계에서 에이전트가 아키텍처를 위반했지만, <strong>check_architecture.sh가 즉시 잡았고</strong>, 에이전트가 스스로 수정한 뒤 <strong>gotchas.md에 기록</strong>까지 했다. 다음 세션에선 같은 실수를 하지 않는다.</p>
<h3 id="phase-4-검증-보고">Phase 4: 검증 보고</h3>
<pre><code>📊 구현 완료

완료 기준 체크:
✅ 접종 D-3, D-1, 당일 알림 스케줄링
✅ 알림 시간 커스텀 (오전 9시 / 오후 12시 / 오후 6시)
✅ FCM 토큰 갱신 로직
✅ 전체 테스트 통과
✅ check_architecture.sh 통과

새로 발견된 사항:
📝 gotchas.md에 1건 추가

커밋 4개 생성, 각 단계별 롤백 가능합니다.</code></pre><p><strong>내가 한 일: 한 줄 요청 + &quot;좋아&quot; 1번 + 방향 조정 1번.</strong> 에이전트의 자율 행동: 코드 분석, ExecPlan 설계, 4단계 구현, 아키텍처 위반 자동 수정, gotchas 기록, 검증 보고.</p>
<p>같은 기능을 Next.js 웹에 추가할 때도 <strong>같은 프로토콜</strong>이 작동한다. 체크포인트만 <code>flutter analyze</code> → <code>npm run lint</code>로 바뀔 뿐이다.</p>
<hr>
<h2 id="gotchasmd--하네스의-성장-엔진">gotchas.md — 하네스의 성장 엔진</h2>
<p>하네스를 세팅하는 건 시작일 뿐이다. 진짜 가치는 <strong>사용할수록 gotchas가 쌓인다</strong>는 것이다.</p>
<pre><code>1주차:  3건 → 에이전트가 자주 삽질
2주차:  8건 → 삽질 절반으로 감소
3주차: 12건 → 거의 한 번에 통과
1개월: 15건 → 에이전트가 나보다 규칙을 잘 지킴</code></pre><p>실제 기록 예시:</p>
<pre><code class="language-markdown">## Flutter
### domain에 Supabase import 금지
- domain/ 레이어에서 Supabase 패키지 직접 import하면 아키텍처 위반
- ✅ data/ 레이어에서만 Supabase 접근

### Riverpod provider 추가 후 코드 생성 필수
- provider 추가/수정 후 반드시 build_runner 실행

## Next.js
### &quot;use client&quot; 누락
- 클라이언트 훅 사용하는 컴포넌트에 선언 필수

### any 타입 금지
- 모든 데이터는 types/api.ts에 타입 정의 후 사용</code></pre>
<h3 id="체감-변화">체감 변화</h3>
<table>
<thead>
<tr>
<th>지표</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>기능 하나 완성까지</td>
<td>2~3세션 (삽질 포함)</td>
<td>1세션 (한 번에 통과)</td>
</tr>
<tr>
<td>아키텍처 위반</td>
<td>매번 발생 → 수동 수정</td>
<td>린터/스크립트가 자동 차단</td>
</tr>
<tr>
<td>같은 실수 반복</td>
<td>자주</td>
<td>거의 없음 (gotchas가 막음)</td>
</tr>
<tr>
<td>내 역할</td>
<td>코드 리뷰 + 수동 수정</td>
<td>방향 판단 + &quot;좋아&quot;</td>
</tr>
</tbody></table>
<hr>
<h2 id="한계와-비판도-있다">한계와 비판도 있다</h2>
<p>모든 것이 장밋빛은 아니다.</p>
<p><strong>검증 부재 문제</strong> — Martin Fowler 사이트의 Birgitta Böckeler가 지적: OpenAI 글에는 &quot;시스템이 실제로 올바르게 작동하는지 검증하는 방법&quot;에 대한 논의가 없다.</p>
<p><strong>레트로핏 문제</strong> — 성공 사례 대부분이 그린필드 프로젝트다. 10년 된 레거시에 하네스를 적용하는 건 완전히 다른 문제다.</p>
<p><strong>초기 투자 비용</strong> — OpenAI의 하네스를 구축하는 데 5개월이 걸렸다.</p>
<p><strong>과잉 엔지니어링 위험</strong> — 하네스에 너무 많은 로직을 넣으면 모델 업데이트 시 깨진다. Manus는 6개월 동안 5번 리팩토링했다.</p>
<hr>
<h2 id="배운-것들">배운 것들</h2>
<h3 id="1-규칙보다-절차가-중요하다">1. 규칙보다 절차가 중요하다</h3>
<p>Golden Principles를 아무리 잘 써놔도, <strong>&quot;언제 읽고, 어떤 순서로 적용하고, 위반하면 어떻게 하는지&quot;</strong>를 정의하지 않으면 에이전트는 규칙을 무시한다.</p>
<p>규칙 = 교통법규. <strong>프로토콜 = 신호등.</strong></p>
<h3 id="2-프로토콜은-한-번-만들면-어디든-복붙">2. 프로토콜은 한 번 만들면 어디든 복붙</h3>
<pre><code>CLAUDE.md = 프로젝트 고유 정보 + 범용 워크플로우 프로토콜
             (프로젝트마다 다름)   (모든 프로젝트에 동일)</code></pre><p>Flutter든 Next.js든 Spring Boot든 Swift든, &quot;분석 → 설계 → 승인 → 구현 → 검증&quot;은 변하지 않는다.</p>
<h3 id="3-승인-단계는-과잉이-아니라-시간-절약">3. 승인 단계는 과잉이 아니라 시간 절약</h3>
<p>승인 없이 바로 구현했다가 방향이 틀리면 전부 롤백해야 한다. <strong>30초짜리 &quot;좋아, 진행해&quot;가 30분짜리 삽질을 막아준다.</strong></p>
<h3 id="4-에이전트-실패--하네스-설계-입력">4. 에이전트 실패 = 하네스 설계 입력</h3>
<p>에이전트가 실수하면 &quot;왜 실패했지?&quot;가 아니라 <strong>&quot;어떤 규칙이 빠져있지?&quot;</strong>를 묻는다.</p>
<blockquote>
<p><strong>하네스는 쓸수록 강해진다.</strong> 에이전트의 모든 실수가 미래의 성공을 만드는 재료가 된다.</p>
</blockquote>
<hr>
<h2 id="당장-시작하는-법">당장 시작하는 법</h2>
<p>복잡하게 생각할 것 없다. 지금 작업 중인 프로젝트의 <code>CLAUDE.md</code>(또는 <code>AGENTS.md</code>)에 이것만 추가하면 된다:</p>
<pre><code class="language-markdown">## 🔒 워크플로우 프로토콜

모든 기능 구현 요청 시:
1. 분석: 관련 코드/문서 탐색 (자동)
2. 설계: 계획 작성 → 사용자 승인 (승인 없이 구현 시작 금지)
3. 구현: 단계별 빌드+테스트 확인 후 커밋
4. 검증: 완료 기준 체크 + 결과 보고</code></pre>
<p>이 8줄이 에이전트의 행동을 근본적으로 바꾼다.</p>
<p>그리고 빈 파일 하나:</p>
<pre><code class="language-markdown"># gotchas.md
&gt; 에이전트가 실패할 때마다 여기에 기록한다.</code></pre>
<p>이 파일은 지금은 비어있지만, 한 달 뒤에는 당신의 가장 가치 있는 엔지니어링 자산이 될 것이다.</p>
<hr>
<h2 id="마무리-패러다임이-바뀌었다">마무리: 패러다임이 바뀌었다</h2>
<p>하네스 엔지니어링의 본질은 이것이다:</p>
<blockquote>
<p><strong>병목은 환경이었다. 에이전트에게 코드를 짤 능력이 부족했던 게 아니라, 구조와 도구와 피드백과 명확한 제약이 부족했던 것이다.</strong></p>
</blockquote>
<p>우리는 지금 &quot;코드를 잘 짜는 사람&quot;에서 <strong>&quot;코드가 안전하게 생성되는 환경을 설계하는 사람&quot;</strong> 으로의 전환점에 서 있다.</p>
<p>프롬프트를 잘 쓰는 것보다, 컨텍스트를 잘 설계하는 것보다, 결국 <strong>하네스를 얼마나 견고하게 짓느냐</strong>가 개발자의 경쟁력이 되는 시대. 이미 시작됐다.</p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://openai.com/index/harness-engineering/">Harness engineering: leveraging Codex in an agent-first world | OpenAI</a></li>
<li><a href="https://openai.com/index/unlocking-the-codex-harness/">Unlocking the Codex harness | OpenAI</a></li>
<li><a href="https://openai.com/index/unrolling-the-codex-agent-loop/">Unrolling the Codex agent loop | OpenAI</a></li>
<li><a href="https://martinfowler.com/articles/exploring-gen-ai/harness-engineering.html">Harness Engineering | Martin Fowler</a></li>
<li><a href="https://www.infoq.com/news/2026/02/openai-harness-engineering-codex/">OpenAI Introduces Harness Engineering | InfoQ</a></li>
<li><a href="https://blog.can.ac/2026/02/12/the-harness-problem/">The Harness Problem (benchmarks) | Can.ac</a></li>
<li><a href="https://tonylee.im/en/blog/openai-harness-engineering-five-principles-codex">5 Harness Engineering Principles | Tony Lee</a></li>
<li><a href="https://www.techwithdarin.com/p/harness-engineering-the-moat-isnt">Harness Engineering: The Moat Isn&#39;t Code Anymore | TechWithDarin</a></li>
<li><a href="https://mtrajan.substack.com/p/harness-engineering-is-not-context">Harness Engineering Is Not Context Engineering | mtrajan</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[코드를 짜는 시대는 끝났다 — OpenAI의 Harness Engineering이 바꿔놓은 것들]]></title>
            <link>https://velog.io/@seungje_labs/%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%A7%9C%EB%8A%94-%EC%8B%9C%EB%8C%80%EB%8A%94-%EB%81%9D%EB%82%AC%EB%8B%A4-OpenAI%EC%9D%98-Harness-Engineering%EC%9D%B4-%EB%B0%94%EA%BF%94%EB%86%93%EC%9D%80-%EA%B2%83%EB%93%A4</link>
            <guid>https://velog.io/@seungje_labs/%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%A7%9C%EB%8A%94-%EC%8B%9C%EB%8C%80%EB%8A%94-%EB%81%9D%EB%82%AC%EB%8B%A4-OpenAI%EC%9D%98-Harness-Engineering%EC%9D%B4-%EB%B0%94%EA%BF%94%EB%86%93%EC%9D%80-%EA%B2%83%EB%93%A4</guid>
            <pubDate>Sat, 28 Feb 2026 11:49:07 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&quot;엔지니어의 역할이 코드를 작성하는 사람에서, AI가 안전하게 코드를 작성할 수 있는 <strong>환경을 설계하는 사람</strong>으로 바뀐다.&quot;</p>
</blockquote>
<h2 id="충격적인-숫자들">충격적인 숫자들</h2>
<p>2026년 2월, OpenAI가 내부 실험 결과를 공개했다.</p>
<ul>
<li>엔지니어 <strong>3→7명</strong></li>
<li><strong>5개월</strong> 동안 <strong>100만 줄</strong> 코드 생산</li>
<li><strong>수동으로 작성한 코드: 0줄</strong></li>
<li>PR 약 <strong>1,500개</strong> 머지</li>
<li>엔지니어당 하루 <strong>3.5개 PR</strong></li>
</ul>
<p>가장 놀라운 건, 팀원이 늘어날수록 속도가 <strong>빨라졌다</strong>는 것이다. 소프트웨어 공학의 고전 법칙인 브룩스의 법칙(&quot;지연되는 프로젝트에 인력을 추가하면 더 늦어진다&quot;)을 정면으로 반박한 결과다. 에이전트 간에는 커뮤니케이션 오버헤드가 없기 때문이다.</p>
<p>OpenAI는 이 방법론에 <strong>Harness Engineering</strong>(하네스 엔지니어링)이라는 이름을 붙였다.</p>
<hr>
<h2 id="harness-engineering이-뭔데">Harness Engineering이 뭔데?</h2>
<p>&quot;하네스&quot;는 원래 말(馬)에 씌우는 장비다. 강력하지만 예측 불가능한 동물의 힘을 방향 있게 쓰기 위한 도구 — 고삐, 안장, 울타리.</p>
<p>AI 에이전트에 대입하면:</p>
<table>
<thead>
<tr>
<th>말 장비</th>
<th>AI 개발</th>
</tr>
</thead>
<tbody><tr>
<td>음성 명령 (&quot;돌아!&quot;)</td>
<td>프롬프트</td>
</tr>
<tr>
<td>지도, 이정표</td>
<td>컨텍스트 (문서, RAG, 도구)</td>
</tr>
<tr>
<td>고삐 + 안장 + 울타리</td>
<td><strong>하네스</strong> (제약, 린터, CI, 피드백 루프)</td>
</tr>
</tbody></table>
<p><strong>정의</strong>: 아키텍처 제약, 피드백 루프, 관측성, 문서 시스템, 자동 품질 관리를 포함하는 구조화된 환경을 구축하여 AI 코딩 에이전트가 프로덕션 수준에서 안정적으로 작동하게 하는 학문.</p>
<hr>
<h2 id="프롬프트-→-컨텍스트-→-하네스-3단계-진화">프롬프트 → 컨텍스트 → 하네스: 3단계 진화</h2>
<p>지금까지 AI 코딩의 발전은 이런 계층 구조를 따라왔다.</p>
<pre><code>┌──────────────────────────────────────┐
│      Harness Engineering (하네스)      │  시스템이 무엇을 막고, 측정하고, 고치는가?
│  ┌──────────────────────────────────┐ │
│  │    Context Engineering (컨텍스트)   │ │  에이전트가 무엇을 보는가?
│  │  ┌──────────────────────────────┐ │ │
│  │  │   Prompt Engineering (프롬프트) │ │ │  지시문을 어떻게 쓰는가?
│  │  └──────────────────────────────┘ │ │
│  └──────────────────────────────────┘ │
└──────────────────────────────────────┘</code></pre><ul>
<li><strong>프롬프트 엔지니어링</strong>: &quot;이렇게 해줘&quot; — 단발성 지시 최적화</li>
<li><strong>컨텍스트 엔지니어링</strong>: &quot;이것들을 참고해서 해줘&quot; — 도구, RAG, 메모리, 스키마 설계</li>
<li><strong>하네스 엔지니어링</strong>: &quot;이 울타리 안에서, 이 피드백을 받으며, 이 품질 기준으로 해줘&quot; — 제약 + 관측 + 자동 교정</li>
</ul>
<p>핵심 차이를 한 마디로:</p>
<blockquote>
<p><strong>컨텍스트는 에이전트가 &quot;무엇을 아는가&quot;를 다루고, 하네스는 &quot;무엇이 잘못될 수 있고 어떻게 막는가&quot;를 다룬다.</strong></p>
</blockquote>
<hr>
<h2 id="openai가-정의한-세-가지-기둥">OpenAI가 정의한 세 가지 기둥</h2>
<h3 id="1-context-engineering--에이전트의-눈">1. Context Engineering — 에이전트의 눈</h3>
<blockquote>
<p>&quot;에이전트가 볼 수 없는 것은 존재하지 않는다.&quot;</p>
</blockquote>
<p>OpenAI 팀이 가장 먼저 깨달은 것: Slack에만 있는 결정, Google Docs에만 있는 설계 문서는 에이전트에게 <strong>없는 것</strong>이나 마찬가지다.</p>
<p><strong>실천법:</strong></p>
<ul>
<li><code>AGENTS.md</code>는 백과사전이 아니라 <strong>목차</strong> (~100줄)</li>
<li>실제 지식은 <code>docs/</code> 디렉토리에 구조화</li>
<li><strong>ExecPlan</strong>(실행 계획서) 작성 — 초보자도 구현 가능한 수준의 자족적 설계 문서</li>
<li>ExecPlan이 잘 작성되면 에이전트가 <strong>7시간 이상 무인 작업</strong> 가능</li>
</ul>
<pre><code>프로젝트/
├── AGENTS.md          ← 100줄짜리 목차
├── docs/
│   ├── architecture.md
│   ├── conventions.md
│   ├── gotchas.md     ← 자주 틀리는 것들
│   └── plans/
│       └── feature-x.md  ← ExecPlan</code></pre><h3 id="2-architectural-constraints--보이지-않는-울타리">2. Architectural Constraints — 보이지 않는 울타리</h3>
<blockquote>
<p>&quot;기계적으로 강제할 수 없으면, 에이전트는 반드시 벗어난다.&quot;</p>
</blockquote>
<p>OpenAI는 6계층 의존성 아키텍처를 강제했다:</p>
<pre><code>Types → Config → Repo → Service → Runtime → UI
          (의존성 방향: 오직 왼→오른쪽만 허용)</code></pre><ul>
<li><strong>커스텀 린터</strong>가 위반을 감지하면 빌드 즉시 실패</li>
<li>흥미로운 건, 이 린터 자체도 Codex 에이전트가 작성했다는 것</li>
<li>에러 메시지가 단순 경고가 아니라 <strong>교육적 해결법</strong>을 포함 — &quot;매 실패 메시지가 다음 시도의 컨텍스트가 된다&quot;</li>
</ul>
<h3 id="3-garbage-collection--ai-쓰레기-청소부">3. Garbage Collection — AI 쓰레기 청소부</h3>
<p>코드가 100만 줄이 되면, 문서는 빠르게 낡는다. 처음엔 매주 금요일에 사람이 &quot;AI slop&quot;(에이전트가 만든 저품질 코드)을 정리했지만, 확장이 안 됐다.</p>
<p>해결책: <strong>백그라운드 에이전트</strong>를 돌렸다.</p>
<ul>
<li>낡은 문서 감지 → 자동 정리 PR 생성</li>
<li>아키텍처 위반 탐지 → 자동 리팩토링 PR</li>
<li>품질 기준 위반 → 자동 수정</li>
</ul>
<blockquote>
<p><strong>&quot;인간의 취향을 한 번 캡처하고, 지속적으로 강제한다.&quot;</strong></p>
</blockquote>
<hr>
<h2 id="vibe-coding-vs-harness-engineering">Vibe Coding vs. Harness Engineering</h2>
<p>요즘 &quot;바이브 코딩&quot;이라는 말을 많이 듣는다. 대충 말하면 AI가 알아서 코드를 짜주는 것. 재미있고 빠르다. 하지만 프로덕션에서는?</p>
<p>2025년 8월, 18명의 CTO를 대상으로 한 설문에서 <strong>16명이 AI 생성 코드로 인한 프로덕션 장애</strong>를 경험했다고 답했다.</p>
<table>
<thead>
<tr>
<th></th>
<th>Vibe Coding</th>
<th>Harness Engineering</th>
</tr>
</thead>
<tbody><tr>
<td><strong>느낌</strong></td>
<td>&quot;대충 말하면 알아서&quot;</td>
<td>&quot;시스템이 잘못된 코드를 거부&quot;</td>
</tr>
<tr>
<td><strong>안전장치</strong></td>
<td>없음</td>
<td>린터 + CI + 구조 테스트</td>
</tr>
<tr>
<td><strong>실패 대응</strong></td>
<td>다시 프롬프트</td>
<td>하네스에 규칙 추가 (재발 방지)</td>
</tr>
<tr>
<td><strong>적합한 곳</strong></td>
<td>프로토타입, 해커톤</td>
<td>프로덕션, 팀 개발</td>
</tr>
<tr>
<td><strong>코드 품질</strong></td>
<td>기도 메타</td>
<td>기계적 강제</td>
</tr>
</tbody></table>
<p>가장 생산적인 패턴은 둘의 <strong>결합</strong>이다: 하네스로 울타리를 치고, 그 안에서 바이브하게 코딩한다.</p>
<hr>
<h2 id="숫자가-증명하는-것">숫자가 증명하는 것</h2>
<p>가장 충격적인 실험 결과는 Can.ac 벤치마크에서 나왔다:</p>
<blockquote>
<p><strong>하네스(에디트 도구 인터페이스)만 바꿨을 뿐인데</strong>, 모델 가중치는 건드리지 않고:</p>
<ul>
<li>Grok Code Fast 1: <strong>6.7% → 68.3%</strong> 성공률</li>
<li>GPT-4 Turbo: <strong>26% → 59%</strong> 성공률</li>
</ul>
</blockquote>
<p>모델보다 <strong>하네스가 더 큰 성능 변수</strong>라는 뜻이다.</p>
<hr>
<h2 id="엔지니어의-새로운-역할">엔지니어의 새로운 역할</h2>
<p>하네스 엔지니어링에서 엔지니어의 일은:</p>
<ol>
<li><strong>환경 설계</strong> — 에이전트가 작동할 제약과 가이드라인 구축</li>
<li><strong>의도 명세</strong> — ExecPlan으로 &quot;무엇을&quot;, &quot;왜&quot;를 명확히 기술</li>
<li><strong>피드백 루프 구축</strong> — 실패가 자동으로 학습되는 시스템</li>
<li><strong>아키텍처 게이트키퍼</strong> — 라인별 리뷰가 아닌 방향성 판단</li>
</ol>
<p>OpenAI 팀은 엔지니어 1명당 <strong>4~8개 에이전트를 동시 실행</strong>했다:</p>
<pre><code>엔지니어 (1명)
├── 에이전트 A: 기능 구현
├── 에이전트 B: 코드 리뷰
├── 에이전트 C: 보안 점검
├── 에이전트 D: 문서 정리
├── 에이전트 E: 테스트 작성
└── 에이전트 F: 버그 수정</code></pre><p>에이전트끼리 서로 리뷰하고, 인간은 최종 방향성만 판단한다. OpenAI는 이걸 내부적으로 <strong>&quot;Ralph Wiggum Loop&quot;</strong> 라고 불렀다.</p>
<hr>
<h2 id="핵심-원칙-5가지">핵심 원칙 5가지</h2>
<p>OpenAI의 100만 줄 실험에서 추출한 5가지 원칙:</p>
<h3 id="1-에이전트가-볼-수-없는-것은-존재하지-않는다">1. 에이전트가 볼 수 없는 것은 존재하지 않는다</h3>
<p>모든 결정을 마크다운, 스키마, ExecPlan으로 레포에 기록한다. Slack 대화? Google Docs? 에이전트에겐 존재하지 않는 것이다.</p>
<h3 id="2-왜-실패했지가-아니라-어떤-능력이-부족한가">2. &quot;왜 실패했지?&quot;가 아니라 &quot;어떤 능력이 부족한가?&quot;</h3>
<p>에이전트 실패 = 디버깅 대상이 아니라 <strong>설계 입력</strong>. 실패할 때마다 하네스에 도구/규칙/문서를 추가한다.</p>
<h3 id="3-문서보다-기계적-강제">3. 문서보다 기계적 강제</h3>
<p>사람이 &quot;이렇게 해주세요&quot;라고 써놓는 것보다, 린터가 &quot;이건 안 됩니다&quot;라고 빌드를 실패시키는 게 100배 효과적이다.</p>
<h3 id="4-에이전트에게-눈을-줘라">4. 에이전트에게 눈을 줘라</h3>
<p>Chrome DevTools Protocol로 실제 UI를 보게 하고, 로그/메트릭으로 성능을 확인하게 한다. &quot;startup이 800ms 이내인지 확인해&quot;처럼 측정 가능한 지시가 가능해진다.</p>
<h3 id="5-매뉴얼이-아니라-지도">5. 매뉴얼이 아니라 지도</h3>
<p>100줄짜리 조감도. 아키텍처 경계만 명확히. 압도적인 세부사항은 오히려 해롭다.</p>
<hr>
<h2 id="한계와-비판도-있다">한계와 비판도 있다</h2>
<p>모든 것이 장밋빛은 아니다. 주요 비판들:</p>
<h3 id="검증-부재-문제">검증 부재 문제</h3>
<p>Martin Fowler 사이트의 Birgitta Böckeler가 지적: OpenAI 글에는 <strong>&quot;시스템이 실제로 올바르게 작동하는지 검증하는 방법&quot;에 대한 논의가 없다</strong>. 에이전트는 작업 완료라고 표시하지만, 실제로 동작하는지 확인하지 않는 경우가 많다.</p>
<h3 id="레트로핏-문제">레트로핏 문제</h3>
<p>성공 사례 대부분이 <strong>그린필드 프로젝트</strong>(처음부터 새로 시작)다. 10년 된 레거시 코드베이스에 하네스를 적용하는 건 완전히 다른 문제다.</p>
<h3 id="초기-투자-비용">초기 투자 비용</h3>
<p>OpenAI의 하네스를 구축하는 데 <strong>5개월</strong>이 걸렸다. 빠른 시작이 아니다.</p>
<h3 id="과잉-엔지니어링-위험">과잉 엔지니어링 위험</h3>
<p>하네스에 너무 많은 &quot;똑똑한&quot; 로직을 넣으면, 모델이 업데이트될 때 시스템이 깨진다. Manus는 6개월 동안 하네스를 5번 리팩토링했다.</p>
<hr>
<h2 id="당장-시작할-수-있는-체크리스트">당장 시작할 수 있는 체크리스트</h2>
<p>대규모 팀이 아니어도, 개인 개발자로서 적용할 수 있는 것들:</p>
<h3 id="문서-인프라">문서 인프라</h3>
<ul>
<li><input disabled="" type="checkbox"> <code>CLAUDE.md</code> / <code>AGENTS.md</code>를 ~100줄 목차 형태로 재구조화</li>
<li><input disabled="" type="checkbox"> <code>docs/</code> 디렉토리에 아키텍처, 규칙, 알려진 이슈 분리</li>
<li><input disabled="" type="checkbox"> 새 기능마다 ExecPlan(실행 계획서) 먼저 작성</li>
</ul>
<h3 id="기계적-강제">기계적 강제</h3>
<ul>
<li><input disabled="" type="checkbox"> 린터에 프로젝트 규칙 추가 (SwiftLint, ESLint 등)</li>
<li><input disabled="" type="checkbox"> 아키텍처 레이어 위반을 잡는 구조적 테스트 1개 이상</li>
<li><input disabled="" type="checkbox"> CI에서 린터 + 테스트 통과 필수</li>
</ul>
<h3 id="피드백-루프">피드백 루프</h3>
<ul>
<li><input disabled="" type="checkbox"> 에이전트 실패 시 즉시 <code>docs/gotchas.md</code> 업데이트</li>
<li><input disabled="" type="checkbox"> 에러 메시지에 해결법 포함 (교육적 에러)</li>
<li><input disabled="" type="checkbox"> 작은 작업 단위마다 커밋 (세이브 포인트)</li>
</ul>
<h3 id="마인드셋-전환">마인드셋 전환</h3>
<ul>
<li><input disabled="" type="checkbox"> &quot;왜 실패했지?&quot; → <strong>&quot;어떤 능력이 부족한가?&quot;</strong></li>
<li><input disabled="" type="checkbox"> 라인별 리뷰 → <strong>아키텍처 게이트키핑</strong></li>
<li><input disabled="" type="checkbox"> 계획과 실행 <strong>반드시 분리</strong></li>
</ul>
<hr>
<h2 id="마무리-패러다임이-바뀌었다">마무리: 패러다임이 바뀌었다</h2>
<p>하네스 엔지니어링의 본질은 이것이다:</p>
<blockquote>
<p><strong>병목은 환경이었다. 에이전트에게 코드를 짤 능력이 부족했던 게 아니라, 구조와 도구와 피드백과 명확한 제약이 부족했던 것이다.</strong></p>
</blockquote>
<p>우리는 지금 &quot;코드를 잘 짜는 사람&quot;에서 <strong>&quot;코드가 안전하게 생성되는 환경을 설계하는 사람&quot;</strong> 으로의 전환점에 서 있다.</p>
<p>프롬프트를 잘 쓰는 것보다, 컨텍스트를 잘 설계하는 것보다, 결국 <strong>하네스를 얼마나 견고하게 짓느냐</strong>가 개발자의 경쟁력이 되는 시대. 이미 시작됐다.</p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://openai.com/index/harness-engineering/">Harness engineering: leveraging Codex in an agent-first world | OpenAI</a></li>
<li><a href="https://openai.com/index/unlocking-the-codex-harness/">Unlocking the Codex harness | OpenAI</a></li>
<li><a href="https://openai.com/index/unrolling-the-codex-agent-loop/">Unrolling the Codex agent loop | OpenAI</a></li>
<li><a href="https://martinfowler.com/articles/exploring-gen-ai/harness-engineering.html">Harness Engineering | Martin Fowler</a></li>
<li><a href="https://www.infoq.com/news/2026/02/openai-harness-engineering-codex/">OpenAI Introduces Harness Engineering | InfoQ</a></li>
<li><a href="https://blog.can.ac/2026/02/12/the-harness-problem/">The Harness Problem (benchmarks) | Can.ac</a></li>
<li><a href="https://tonylee.im/en/blog/openai-harness-engineering-five-principles-codex">5 Harness Engineering Principles | Tony Lee</a></li>
<li><a href="https://www.techwithdarin.com/p/harness-engineering-the-moat-isnt">Harness Engineering: The Moat Isn&#39;t Code Anymore | TechWithDarin</a></li>
<li><a href="https://mtrajan.substack.com/p/harness-engineering-is-not-context">Harness Engineering Is Not Context Engineering | mtrajan</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[예약 시스템에서 동시성 버그를 잡기까지 — 비관적 락이 실패한 이유와 3중 방어의 탄생]]></title>
            <link>https://velog.io/@seungje_labs/FitLink-%EC%98%88%EC%95%BD-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%EC%A0%84%EC%B2%B4-%EB%B6%84%EC%84%9D-%EB%A6%AC%ED%8F%AC%ED%8A%B8</link>
            <guid>https://velog.io/@seungje_labs/FitLink-%EC%98%88%EC%95%BD-%EB%8F%99%EC%8B%9C%EC%84%B1-%EC%A0%9C%EC%96%B4-%EC%A0%84%EC%B2%B4-%EB%B6%84%EC%84%9D-%EB%A6%AC%ED%8F%AC%ED%8A%B8</guid>
            <pubDate>Wed, 25 Feb 2026 04:49:45 GMT</pubDate>
            <description><![CDATA[<p>이번에도 블로그 독자들의 시선을 확 사로잡을 수 있도록, <strong>&#39;문제 직면 → 좌절(삽질) → 원리 깨달음 → 완벽한 해결&#39;</strong>이라는 스토리텔링 구조로 재작성해 보았습니다.</p>
<p>이전 포스팅과 결이 이어지도록 독백체(~다, ~했다)를 사용했고, 기술적인 깊이는 유지하되 읽기 편하게 비유를 적극적으로 활용했습니다. 바로 복사해서 Velog에 올리시면 됩니다!</p>
<hr>
<h1 id="spring-boot-예약-동시성-제어-삽질기-비관적-락만-믿었다가-통수-맞은-썰">[Spring Boot] 예약 동시성 제어 삽질기: 비관적 락만 믿었다가 통수 맞은 썰</h1>
<blockquote>
<p>&quot;분명히 예약 확인 로직을 짰는데, 왜 1자리 남은 예약에 2명이 성공하는 거지?&quot;</p>
</blockquote>
<p>FitLink 프로젝트의 예약 시스템을 구현하면서 만난 가장 큰 산, 바로 <strong>동시성(Concurrency) 문제</strong>다.</p>
<p>일상생활로 치면 이런 거다. 영화관에 좌석이 딱 1개 남았다. A와 B가 동시에 &quot;오, 1자리 남았네!&quot; 하고 예매 버튼을 누른다. 시스템은 두 사람 모두에게 &quot;예매 완료!&quot;를 띄워준다. 하나의 좌석에 두 명의 주인이 생겨버린 대참사다.</p>
<p>우리는 이걸 <strong>Check-Then-Act(확인 후 실행)</strong> 패턴의 함정이라고 부른다.
&quot;빈자리 확인(Check)&quot;과 &quot;예약 생성(Act)&quot; 사이에 찰나의 시간차가 존재하고, 그 틈에 다른 스레드가 비집고 들어오는 것이다.</p>
<p>이 문제를 해결하기 위해 도입했던 3중 방어선과, 그 과정에서 겪은 짜릿한 실패(?)의 기록을 공유한다.</p>
<hr>
<h2 id="1-🔒-1차-시도-비관적-락pessimistic-lock이면-다-될-줄-알았다">1. 🔒 1차 시도: 비관적 락(Pessimistic Lock)이면 다 될 줄 알았다</h2>
<p>가장 먼저 떠올린 해결책은 JPA의 <code>@Lock</code>이었다. DB 레벨에서 행을 꽉 잠가버리면(SELECT FOR UPDATE), 두 스레드가 동시에 접근해도 한 명은 대기할 테니 완벽하다고 생각했다.</p>
<pre><code class="language-java">// 냅다 락부터 걸어버리기
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query(&quot;SELECT r FROM Reservation r WHERE r.trainer.id = :trainerId AND r.reservationDate = :date ...&quot;)
List&lt;Reservation&gt; findOverlappingReservations(...);
</code></pre>
<p>호기롭게 동시성 테스트를 돌렸다. 스레드 2개가 동시에 같은 시간에 예약을 시도하는 시나리오.
당연히 1명은 성공하고 1명은 실패할 줄 알았다.</p>
<blockquote>
<p><strong>결과: Success = 2, Fail = 0 (테스트 실패 ❌)</strong></p>
</blockquote>
<p>어? 왜 둘 다 성공하지?
로그를 뜯어보고 나서야 엄청난 착각을 하고 있었다는 걸 깨달았다. 바로 <strong>&#39;빈 행(Empty Row) 문제&#39;</strong>다.</p>
<p><code>SELECT FOR UPDATE</code>는 <strong>&#39;이미 DB에 존재하는 행&#39;</strong>을 잠그는 기술이다. 그런데 첫 예약이 들어오는 시점에는 해당 시간대의 예약 데이터가 아예 없다. 잠글 행 자체가 없으니, 두 스레드 모두 빈 결과를 받고 유유히 락을 통과해 버린 것이다.</p>
<p><em>(참고로 MySQL InnoDB는 갭 락(Gap Lock) 덕분에 빈 행도 막아주지만, 테스트 환경인 H2나 PostgreSQL에서는 얄짤없이 뚫린다.)</em></p>
<hr>
<h2 id="2-🛡️-최후의-방어선-db-unique-제약조건과-saveandflush">2. 🛡️ 최후의 방어선: DB Unique 제약조건과 saveAndFlush</h2>
<p>애플리케이션 레벨의 Check-Then-Act는 동시성에 너무 취약했다. 결국 믿을 건 <strong>DB 엔진</strong>뿐이었다. DB는 데이터의 고유성 확인과 INSERT를 <strong>하나의 원자적(Atomic) 연산</strong>으로 처리하기 때문이다.</p>
<h3 id="1-unique-제약조건-걸기">(1) Unique 제약조건 걸기</h3>
<p>Reservation 엔티티에 <code>트레이너 ID + 날짜 + 시작 시간</code> 조합으로 유니크 제약조건을 걸었다.</p>
<pre><code class="language-java">@Table(uniqueConstraints = {
    @UniqueConstraint(columnNames = {&quot;trainer_id&quot;, &quot;reservation_date&quot;, &quot;start_time&quot;})
})
</code></pre>
<p>이제 두 스레드가 락을 통과해서 동시에 INSERT를 시도하더라도, 한 놈은 무조건 DB 단에서 <code>Unique constraint violation</code> 에러를 맞고 튕겨 나간다.</p>
<h3 id="2-save가-아니라-saveandflush인-이유">(2) save()가 아니라 saveAndFlush()인 이유</h3>
<p>DB가 튕겨내는 건 좋은데, 이 에러를 우아하게 잡아서 사용자에게 &quot;이미 예약된 시간입니다&quot;라고 알려줘야 한다.</p>
<p>처음엔 평소처럼 <code>repository.save()</code>를 쓰고 try-catch로 묶었다. 그런데 예외가 안 잡히고 밖으로 터져버렸다. 왜 그럴까?</p>
<pre><code class="language-java">// ❌ save()의 배신
try {
    repository.save(reservation); // 영속성 컨텍스트에만 저장 (SQL 안 날림)
} catch (Exception e) {
    // 여기서 에러 안 터짐!
} 
// 메서드가 끝나는 시점(@Transactional 커밋)에 INSERT가 날아가서 밖에서 터짐!
</code></pre>
<p>이럴 때 필요한 게 <strong><code>saveAndFlush()</code></strong>다.</p>
<pre><code class="language-java">// ✅ saveAndFlush()로 즉시 SQL 날리기
try {
    repository.saveAndFlush(reservation); // &quot;지금 당장 DB에 쿼리 쏴!&quot;
} catch (DataIntegrityViolationException e) {
    throw new BusinessException(ErrorCode.RESERVATION_TIME_CONFLICT);
}
</code></pre>
<p>DB 제약조건 위반 에러를 즉시 캐치해서, 우리가 정의한 비즈니스 예외로 깔끔하게 변환(Translation)할 수 있게 되었다.</p>
<hr>
<h2 id="3-🧪-진짜-동시성-테스트는-어떻게-짤까-feat-countdownlatch">3. 🧪 진짜 동시성 테스트는 어떻게 짤까? (feat. CountDownLatch)</h2>
<p>해결책을 만들었으니 검증을 해야 한다.
그런데 일반적인 단위 테스트(<code>@ExtendWith(MockitoExtension.class)</code>)로는 절대 동시성을 테스트할 수 없다. Mock 객체는 DB의 락이나 Unique 제약조건을 흉내 내지 못하기 때문이다.</p>
<p>반드시 <strong>실제 DB와 멀티 스레드 환경</strong>이 필요하다.</p>
<h3 id="주의-1-transactional을-과감히-버려라">주의 1: @Transactional을 과감히 버려라</h3>
<p>이전 포스팅에서 다뤘듯, 스프링의 <code>@Transactional</code>은 <strong>ThreadLocal</strong>을 기반으로 동작한다.
테스트 클래스에 <code>@Transactional</code>을 붙여버리면, 메인 스레드가 만든 테스트 데이터(트레이너, 회원 등)를 새로 생성된 작업 스레드들이 읽지 못하는 불상사가 발생한다. 동시성 테스트에서는 무조건 빼고, 데이터 정리는 <code>@BeforeEach</code>에서 수동으로(<code>deleteAll()</code>) 해줘야 한다.</p>
<h3 id="주의-2-3단계-countdownlatch로-진짜-동시를-만들어라">주의 2: 3단계 CountDownLatch로 &#39;진짜 동시&#39;를 만들어라</h3>
<p>단순히 <code>ExecutorService</code>로 스레드를 여러 개 던진다고 해서 완벽한 동시 출발이 보장되진 않는다. 육상 경기를 하듯 <strong>정확한 타이밍 제어</strong>가 필요하다.</p>
<pre><code class="language-java">int threadCount = 2;
CountDownLatch readyLatch = new CountDownLatch(threadCount); // 선수 입장 확인
CountDownLatch startLatch = new CountDownLatch(1);           // 출발 총성
CountDownLatch doneLatch = new CountDownLatch(threadCount);  // 결승선 통과 확인

executorService.submit(() -&gt; {
    readyLatch.countDown(); // &quot;저 준비됐어요!&quot;
    startLatch.await();     // 총소리 울릴 때까지 대기 🔫

    try {
        reservationService.createByTrainer(...); // ★ 진짜 동시 접근 발생 ★
        successCount.incrementAndGet();
    } finally {
        doneLatch.countDown(); // &quot;경기 끝!&quot;
    }
});

readyLatch.await();      // 선수들 다 올 때까지 메인 스레드 대기
startLatch.countDown();  // 탕! (모든 스레드 동시 출발)
doneLatch.await();       // 모두 끝날 때까지 대기 후 검증
</code></pre>
<p>이 정도로 타이밍을 쥐어짜야 비로소 의미 있는 동시성 테스트 결과를 얻을 수 있다.</p>
<hr>
<h2 id="🎯-결론-실패가-알려준-3중-방어선">🎯 결론: 실패가 알려준 &#39;3중 방어선&#39;</h2>
<p>만약 처음부터 정답(DB Unique 제약조건)만 쏙 빼서 적용했다면 금방 끝났을 거다.
하지만 비관적 락을 먼저 시도했다가 <strong>&#39;빈 행 문제&#39;</strong>로 시원하게 통수를 맞아본 덕분에, 락의 한계와 DB 엔진별 동작의 차이를 뼛속까지 이해하게 되었다.</p>
<p>현재 우리 시스템의 동시 예약 제어는 다음 3중 방어선으로 이루어져 있다.</p>
<ol>
<li><strong>비관적 락 (@Lock):</strong> 이미 존재하는 예약이 있을 때 순서를 보장 (1차 방어)</li>
<li><strong>DB Unique 제약:</strong> 빈 행 상태에서 들어오는 동시 INSERT를 원자적으로 차단 (최후의 보루)</li>
<li><strong>saveAndFlush + Exception Catch:</strong> DB 에러를 우아한 비즈니스 예외로 변환 (사용자 경험)</li>
</ol>
<p>코드는 의도한 대로 완벽하게 1승 1패(1 성공, 1 예외)를 기록하며 테스트를 통과했다.
동시성, 겪어보기 전엔 무서웠는데 막상 부딪혀보니 이거... 꽤 재밌다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CRUD 짜면서 이런 고민까지 해야 돼? (feat. JVM, GC, ThreadLocal)]]></title>
            <link>https://velog.io/@seungje_labs/Spring-Boot-%EB%8B%A8%EC%88%9C-CRUD-%EB%92%A4%EC%97%90-%EC%88%A8%EA%B2%A8%EC%A7%84-JVM-GC-%EC%8A%A4%EB%A0%88%EB%93%9C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%98-%EB%B9%84%EB%B0%80</link>
            <guid>https://velog.io/@seungje_labs/Spring-Boot-%EB%8B%A8%EC%88%9C-CRUD-%EB%92%A4%EC%97%90-%EC%88%A8%EA%B2%A8%EC%A7%84-JVM-GC-%EC%8A%A4%EB%A0%88%EB%93%9C-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EC%9D%98-%EB%B9%84%EB%B0%80</guid>
            <pubDate>Tue, 24 Feb 2026 08:55:25 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>&quot;방금 짠 이 CRUD 코드, 왜 이렇게 짰는지 설명할 수 있어요?&quot;</p>
</blockquote>
<p>멘토링을 진행하면서 Spring Boot로 백엔드 서버를 만들고 있다.
솔직히 처음엔 &#39;CRUD 쯤이야...&#39; 라고 생각했다.
Controller 만들고, Service에서 비즈니스 로직 쓱쓱 짜고, Repository로 DB 접근하고. 컴파일 통과, 테스트 통과. 깔끔하게 끝인 줄 알았다.</p>
<p>그런데 멘토님과 대화를 나누다 보니, 내가 짠 이 몇 줄의 코드가 단순히 &#39;돌아가는 것&#39;에서 끝나는 게 아니었다.
이 평범해 보이는 Service 코드가 <strong>실제 JVM 위에서, 수많은 스레드에 의해, 메모리(GC)의 감시를 받으며</strong> 돌아간다는 걸 간과하고 있었던 거다.</p>
<p>오늘은 &quot;그냥 남들이 다 그렇게 짜니까&quot; 넘어갔던 단순 CRUD 뒤에 숨겨진 프레임워크와 자바의 핵심 동작 원리를 정리해 보려고 한다.</p>
<hr>
<h2 id="1-🧵-service는-왜-무상태stateless여야-할까-스레드와-메모리">1. 🧵 Service는 왜 무상태(Stateless)여야 할까? (스레드와 메모리)</h2>
<p>보통 비즈니스 로직을 짤 때 <code>@Service</code>를 붙여서 클래스를 만든다.
그런데 만약 이 Service 안에 데이터(상태)를 저장하는 전역 변수를 두면 어떻게 될까?</p>
<pre><code class="language-java">@Service
public class TrainerProfileService {
    // 🚨 대참사의 시작
    private String currentUserName; 

    public void updateProfile(String name) {
        this.currentUserName = name;
        // ... 로직 처리
    }
}
</code></pre>
<p>Spring Boot(기본 내장 Tomcat)는 사용자의 요청이 들어올 때마다 스레드 풀(Thread Pool)에서 스레드를 하나씩 꺼내서 할당하는 <strong>Thread-per-Request</strong> 방식을 쓴다.</p>
<p>문제는 스프링 컨테이너가 이 <code>@Service</code> 객체를 <strong>싱글톤(Singleton)</strong>으로 딱 하나만 만들어서 관리한다는 거다.
만약 100명의 사용자가 동시에 프로필을 수정한다고 치자. 100개의 스레드가 <strong>단 1개의 Service 객체(메모리 주소)에 동시에 접근해서</strong> 저 <code>currentUserName</code>을 바꾸려고 난리를 칠 거다. 당연히 데이터가 뒤섞이는 동시성 이슈가 터진다.</p>
<h3 id="스택stack과-힙heap의-차이">스택(Stack)과 힙(Heap)의 차이</h3>
<ul>
<li><strong>클래스의 필드 변수:</strong> JVM의 <strong>Heap 영역</strong>에 저장된다. 힙은 모든 스레드가 모여 노는 &#39;공용 운동장&#39;이다. 여기서 값을 바꾸면 모두가 영향을 받는다.</li>
<li><strong>메서드 내부의 지역 변수:</strong> JVM의 <strong>Thread Stack 영역</strong>에 저장된다. 스택은 스레드마다 주어지는 &#39;개인실&#39;이다. 스레드끼리 절대 섞이지 않는다.</li>
</ul>
<blockquote>
<p><strong>💡 결론</strong>
여러 스레드가 동시에 접근하는 Service는 반드시 <strong>무상태(Stateless)</strong>로 설계해야 한다.
객체의 의존성(Repository 등)은 <code>final</code>로 박아둬서 불변성을 보장하고, 변경되는 데이터는 무조건 메서드의 파라미터(지역 변수)로만 주고받아야 안전하다.</p>
</blockquote>
<hr>
<h2 id="2-🗑️-조회만-했는데-서버가-뻗는다고-jpa와-gc">2. 🗑️ 조회만 했는데 서버가 뻗는다고? (JPA와 GC)</h2>
<p>JPA를 쓰면 보통 <code>@Entity</code> 객체로 데이터를 조회해온다.
그런데 대량의 데이터를 리스트로 뽑아올 때, 무지성으로 Entity로 다 끌고 오면 어떻게 될까?</p>
<h3 id="entity로-조회할-때-벌어지는-일">Entity로 조회할 때 벌어지는 일</h3>
<p>JPA는 Entity를 DB에서 가져오는 순간, <strong>&quot;아 이건 내가 계속 관리해야 하는 애구나!&quot;</strong> 하고 영속성 컨텍스트(1차 캐시)에 올려버린다. 심지어 나중에 수정될까 봐 원본 스냅샷까지 메모리에 복사해 둔다.</p>
<p>만약 데이터 수만 건을 Entity로 가져오면?
이 무거운 객체들이 JVM의 <strong>Heap 메모리</strong>를 가득 채운다. 트랜잭션이 길어져서 이 객체들이 오랫동안 살아남으면, 가비지 컬렉터(GC)가 얘네를 Old 영역으로 보내버리고, 결국 무거운 <strong>Major GC(Stop-The-World)</strong>가 터지면서 서버가 순간적으로 멈춰버릴 수 있다.</p>
<h3 id="dto로-직접-꽂아버리면-projection">DTO로 직접 꽂아버리면? (Projection)</h3>
<p>반면에 JPQL이나 QueryDSL을 써서 Entity가 아니라 내가 만든 순수 <strong>DTO</strong>로 바로 매핑해서 가져오면 얘기가 달라진다.</p>
<p>JPA는 이걸 보고 <strong>&quot;이건 내 관리 대상(Entity)이 아니네?&quot;</strong> 하고 영속성 컨텍스트에 올리지 않는다.
덕분에 이 DTO 객체들은 클라이언트에게 응답을 주는 즉시 가비지(Garbage)가 되고, 가볍고 빠른 <strong>Minor GC</strong>가 슉슉 청소해 버린다. 메모리 낭비가 0에 수렴하는 거다.</p>
<blockquote>
<p><strong>💡 결론</strong>
데이터를 수정할 일이 없는 단순 리스트 조회(Read-Only) API에서는, 무작정 Entity를 쓰지 말고 <strong>DTO 직접 조회(Projection)</strong>를 활용해서 영속성 컨텍스트를 우회하자. 이게 곧 메모리 관리이자 성능 최적화다.</p>
</blockquote>
<hr>
<h2 id="3-🎩-transactional의-배신-마법이-아니었어">3. 🎩 @Transactional의 배신 (마법이 아니었어)</h2>
<p>우리는 너무나 자연스럽게 Service 메서드 위에 <code>@Transactional</code>을 붙인다.
트랜잭션이 유지되려면 시작부터 끝까지 <strong>&#39;같은 DB 커넥션&#39;</strong>을 물고 있어야 하는데, 코드를 보면 Service에서 Repository로 커넥션 객체를 파라미터로 넘겨주는 부분이 전혀 없다.</p>
<p>어떻게 커넥션이 유지되는 걸까? 스프링이 마법이라도 부리는 걸까?</p>
<h3 id="구원자-threadlocal">구원자: ThreadLocal</h3>
<p>스프링은 자바의 <strong><code>ThreadLocal</code></strong>이라는 녀석을 쓴다. 이건 <strong>&quot;현재 실행 중인 스레드만 열어볼 수 있는 전용 사물함&quot;</strong>이다.</p>
<ol>
<li><strong>트랜잭션 시작:</strong> 스프링이 DB 커넥션을 획득한 뒤, 현재 일하고 있는 <strong>스레드의 ThreadLocal 사물함</strong>에 커넥션을 몰래 넣어둔다.</li>
<li><strong>Repository 실행:</strong> 개발자가 쿼리를 날리면, 내부적으로 파라미터를 넘기지 않아도 <strong>자기 사물함(<code>ThreadLocal</code>)을 열어서 아까 넣어둔 커넥션을 꺼내 쓴다.</strong> (대박..)</li>
<li><strong>종료:</strong> 커밋이나 롤백이 끝나면 ThreadLocal을 깨끗하게 비운다.</li>
</ol>
<h3 id="🚨-여기서-주의할-점-진짜-중요">🚨 여기서 주의할 점 (진짜 중요)</h3>
<p>만약 이 원리를 모른 채로, <code>@Transactional</code>이 붙은 메서드 안에서 성능 좀 높이겠다고 <strong>비동기 스레드(<code>CompletableFuture</code> 등)</strong>를 새로 파서 Repository를 호출하면 어떻게 될까?</p>
<p>새로 만든 스레드의 <code>ThreadLocal</code> 사물함은 당연히 텅 비어있다!
스프링이 기존 스레드 사물함에 넣어둔 커넥션을 찾을 수 없으니, <strong>기존 트랜잭션과 완전히 분리되어 버리는 대참사</strong>가 일어난다.</p>
<blockquote>
<p><strong>💡 결론</strong>
<code>@Transactional</code>은 마법이 아니다. <strong>하나의 스레드 내부에서만 커넥션을 공유해 주는 <code>ThreadLocal</code> 기반의 기술</strong>이라는 걸 반드시 알고 써야 한다.</p>
</blockquote>
<hr>
<h2 id="🎯-마무리">🎯 마무리</h2>
<p>&quot;기능이 돌아가면 끝난 거 아냐?&quot; 라고 생각했던 오만함이 싹 사라졌다.</p>
<p>단순해 보이는 CRUD 코드 한 줄이라도,
<strong>어떤 메모리 영역(Stack/Heap)에 올라가는지,</strong> <strong>가비지 컬렉터(GC)에 어떤 영향을 주는지,</strong> <strong>스레드(ThreadLocal)와 트랜잭션이 어떻게 엮여서 돌아가는지</strong> 알고 짜는 것과 모르고 짜는 것은 하늘과 땅 차이인 것 같다.</p>
<p>이제 그냥 생각 없이 키보드부터 두드리는 짓은 못 할 것 같다. (오히려 좋아)
앞으로는 &quot;왜 이렇게 짰어?&quot;라는 질문에 당당하게 이유를 설명할 수 있는 코드를 짜야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[테스트 코드, 뭘 어떻게 짜야 하는 건데?]]></title>
            <link>https://velog.io/@seungje_labs/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EB%AD%98-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%A7%9C%EC%95%BC-%ED%95%98%EB%8A%94-%EA%B1%B4%EB%8D%B0</link>
            <guid>https://velog.io/@seungje_labs/%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EB%AD%98-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%A7%9C%EC%95%BC-%ED%95%98%EB%8A%94-%EA%B1%B4%EB%8D%B0</guid>
            <pubDate>Sun, 22 Feb 2026 05:16:16 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>PT 예약 시스템을 만들면서 처음 테스트 코드를 작성한 과정을 정리했다. &quot;테스트 코드를 짜본 적이 없는&quot; 상태에서 시작해, Entity → Service까지 34개의 테스트를 작성하면서 배운 것들.</p>
</blockquote>
<hr>
<h2 id="테스트는-입력값-유효성-체크가-아니다">테스트는 입력값 유효성 체크가 아니다</h2>
<p>처음 테스트를 작성하려니 떠오르는 건 이런 것뿐이었다.</p>
<ul>
<li>&quot;시작 시간이 종료 시간보다 늦으면?&quot;</li>
<li>&quot;종료 시간이 시작 시간보다 빠르면?&quot;</li>
</ul>
<p>둘 다 <strong>입력값 유효성 체크</strong>다. 물론 이것도 테스트하지만, 테스트의 전부가 아니다.</p>
<p>테스트가 검증하는 건 크게 <strong>3가지</strong>다:</p>
<h3 id="1-올바르게-만들어지는가-생성">1. 올바르게 만들어지는가? (생성)</h3>
<pre><code class="language-java">TrainerSchedule schedule = TrainerSchedule.create(
    trainer, DayOfWeek.MONDAY, LocalTime.of(9, 0), LocalTime.of(18, 0));

assertThat(schedule.getIsAvailable()).isTrue();  // 생성하면 항상 true인가?</code></pre>
<p>&quot;내가 의도한 대로 객체가 만들어지는지&quot; 검증한다.</p>
<h3 id="2-올바르게-동작하는가-행위-←-핵심">2. 올바르게 동작하는가? (행위) ← 핵심</h3>
<pre><code class="language-java">// 09:00~18:00 근무 스케줄에서
assertThat(schedule.isWithinWorkingHours(LocalTime.of(9, 0))).isTrue();   // 시작 정각은?
assertThat(schedule.isWithinWorkingHours(LocalTime.of(18, 0))).isFalse(); // 종료 정각은?</code></pre>
<p><strong>비즈니스 로직이 기대한 대로 동작하는지</strong> 검증한다. 이게 테스트의 핵심이다.</p>
<h3 id="3-잘못된-상황에서-안전한가-방어">3. 잘못된 상황에서 안전한가? (방어)</h3>
<pre><code class="language-java">assertThatThrownBy(() -&gt; TrainerSchedule.create(
    trainer, DayOfWeek.MONDAY, LocalTime.of(18, 0), LocalTime.of(9, 0)))
    .isInstanceOf(IllegalArgumentException.class);</code></pre>
<p>예외 상황에서 <strong>터져야 할 게 터지는지</strong> 검증한다.</p>
<hr>
<h2 id="테스트를-짜려면-비즈니스-로직을-이해해야-한다">테스트를 짜려면 비즈니스 로직을 이해해야 한다</h2>
<p>당연한 말 같지만, 테스트 코드를 처음 접하면 놓치기 쉽다.</p>
<p><code>isWithinWorkingHours()</code> 메서드를 보자:</p>
<pre><code class="language-java">public boolean isWithinWorkingHours(LocalTime time) {
    if (!this.isAvailable) return false;
    return !time.isBefore(this.startTime) &amp;&amp; time.isBefore(this.endTime);
}</code></pre>
<p>이 코드를 이해하면 자연스럽게 질문이 나온다:</p>
<table>
<thead>
<tr>
<th>질문</th>
<th>테스트 시나리오</th>
</tr>
</thead>
<tbody><tr>
<td>09:00 정각은 포함이야?</td>
<td><code>[start</code> — 시작 시간 포함</td>
</tr>
<tr>
<td>18:00 정각은?</td>
<td><code>end)</code> — 종료 시간 미포함</td>
</tr>
<tr>
<td>08:59는?</td>
<td>시작 전이니까 false</td>
</tr>
<tr>
<td>17:59는?</td>
<td>종료 직전이니까 true</td>
</tr>
<tr>
<td>휴무일이면?</td>
<td>isAvailable이 false면 무조건 false</td>
</tr>
</tbody></table>
<p><strong>&quot;이러면 어떻게 되지?&quot;라는 질문 = 테스트 시나리오</strong>다.</p>
<hr>
<h2 id="entity--repository--service-테스트의-차이">Entity / Repository / Service 테스트의 차이</h2>
<p>테스트 파일을 보면 <code>entity/</code>, <code>repository/</code>, <code>service/</code> 폴더가 있다. 뭐가 다른 걸까?</p>
<h3 id="entity-테스트--순수-로직">Entity 테스트 — 순수 로직</h3>
<pre><code>Mock: 없음 | DB: 없음 | 속도: 가장 빠름</code></pre><pre><code class="language-java">// &quot;09:00은 근무시간인가?&quot; — 순수 Java 객체만으로 검증
TrainerSchedule schedule = TrainerSchedule.create(...);
assertThat(schedule.isWithinWorkingHours(LocalTime.of(9, 0))).isTrue();</code></pre>
<p><strong>객체 자체의 규칙</strong>을 검증한다. <code>new</code> 해서 메서드 호출하고 결과 확인. DB도 없고 Mock도 없다.</p>
<h3 id="repository-테스트--쿼리-검증">Repository 테스트 — 쿼리 검증</h3>
<pre><code>Mock: 없음 | DB: 있음 (H2) | 속도: 중간</code></pre><pre><code class="language-java">@DataJpaTest  // 실제 H2 DB가 뜬다
class MembershipRepositoryTest {
    @Test
    void ACTIVE_수강권을_찾는다() {
        membershipRepository.save(membership);  // 실제 DB에 저장
        Optional&lt;Membership&gt; found = membershipRepository
            .findByMemberIdAndStatus(1L, MembershipStatus.ACTIVE);  // 실제 SQL 실행
        assertThat(found).isPresent();
    }
}</code></pre>
<p><strong>JPA 쿼리 메서드</strong>가 의도한 데이터를 가져오는지 검증한다. 실제 DB(H2)가 뜬다.</p>
<h3 id="service-테스트--비즈니스-흐름">Service 테스트 — 비즈니스 흐름</h3>
<pre><code>Mock: 있음 (Repository를 가짜로) | DB: 없음 | 속도: 빠름</code></pre><pre><code class="language-java">@Mock MembershipRepository membershipRepository;  // 가짜
@InjectMocks MembershipService membershipService;

@Test
void 다른_트레이너_소속이면_예외() {
    given(memberRepository.findById(2L)).willReturn(Optional.of(otherMember));

    assertThatThrownBy(() -&gt; membershipService.createByTrainer(...))
        .isInstanceOf(BusinessException.class);
}</code></pre>
<p>Repository는 가짜(Mock)로 대체하고, <strong>서비스 로직의 흐름</strong>을 검증한다. &quot;A 다음에 B 하고, 조건 안 맞으면 예외&quot; 같은 시나리오.</p>
<h3 id="한-줄-비교">한 줄 비교</h3>
<table>
<thead>
<tr>
<th>레이어</th>
<th>뭘 검증?</th>
<th>DB?</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>Entity</td>
<td>객체의 규칙</td>
<td>X</td>
<td>&quot;09:00은 근무시간?&quot;</td>
</tr>
<tr>
<td>Repository</td>
<td>쿼리 동작</td>
<td>O</td>
<td>&quot;ACTIVE 수강권 조회?&quot;</td>
</tr>
<tr>
<td>Service</td>
<td>비즈니스 흐름</td>
<td>X</td>
<td>&quot;남의 회원이면 거부?&quot;</td>
</tr>
</tbody></table>
<hr>
<h2 id="테스트-코드의-기본-구조-given--when--then">테스트 코드의 기본 구조: given / when / then</h2>
<p>모든 테스트는 이 3단계를 따른다:</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;마지막 1회를 차감하면 상태가 EXPIRED로 전이된다&quot;)
void it_expires_when_last_count_is_used() {
    // given — 준비: 1회짜리 수강권
    Membership membership = createMembership(1);

    // when — 실행: 차감
    membership.decreaseCount();

    // then — 검증: 0회, EXPIRED
    assertThat(membership.getRemainingCount()).isEqualTo(0);
    assertThat(membership.getStatus()).isEqualTo(MembershipStatus.EXPIRED);
}</code></pre>
<ul>
<li><strong>given</strong>: 테스트할 상황을 만든다</li>
<li><strong>when</strong>: 테스트 대상 동작을 실행한다</li>
<li><strong>then</strong>: 결과가 기대한 대로인지 확인한다</li>
</ul>
<p>예외를 검증할 때는 when &amp; then이 합쳐진다:</p>
<pre><code class="language-java">// when &amp; then — &quot;이걸 하면 이 예외가 터져야 한다&quot;
assertThatThrownBy(() -&gt; membership.decreaseCount())
    .isInstanceOf(IllegalStateException.class)
    .hasMessageContaining(&quot;남은 PT 횟수가 없습니다&quot;);</code></pre>
<hr>
<h2 id="테스트가-설계를-개선한다">테스트가 설계를 개선한다</h2>
<p>이번에 가장 크게 배운 점이다.</p>
<p><code>TrainerSchedule.create()</code>에 시작 시간이 종료 시간보다 늦은 경우를 테스트하려고 했는데:</p>
<pre><code class="language-java">// 이 테스트를 짜려고 보니... create()에 guard가 없다!
TrainerSchedule.create(trainer, DayOfWeek.MONDAY,
    LocalTime.of(18, 0), LocalTime.of(9, 0));  // 18:00~09:00 — 에러 안 남</code></pre>
<p><strong>테스트를 작성하면서 프로덕션 코드의 빈틈을 발견</strong>한 거다. 결국 guard를 추가했다:</p>
<pre><code class="language-java">public static TrainerSchedule create(...) {
    if (!endTime.isAfter(startTime)) {
        throw new IllegalArgumentException(&quot;종료 시간은 시작 시간보다 이후여야 합니다.&quot;);
    }
    // ...
}</code></pre>
<p><code>Membership.create()</code>에서도 같은 일이 있었다. 날짜 검증과 횟수 검증이 없었는데, 테스트를 짜려다 보니 &quot;이거 guard가 없네?&quot;를 발견했다.</p>
<p><strong>테스트 코드는 검증 도구이자 설계 리뷰 도구</strong>다.</p>
<hr>
<h2 id="실전에서-작성한-테스트-목록">실전에서 작성한 테스트 목록</h2>
<h3 id="entity-테스트-mock-없음-순수-로직">Entity 테스트 (Mock 없음, 순수 로직)</h3>
<table>
<thead>
<tr>
<th>엔티티</th>
<th>테스트 수</th>
<th>주요 검증</th>
</tr>
</thead>
<tbody><tr>
<td>MembershipTest</td>
<td>19개</td>
<td>생성, 차감/복구, 일시정지, 사용 가능 여부, 상태 전이, Clock 패턴</td>
</tr>
<tr>
<td>TrainerScheduleTest</td>
<td>13개</td>
<td>생성/휴무/변경, <code>[start, end)</code> 경계값 7개</td>
</tr>
</tbody></table>
<h3 id="service-테스트-mockito-repository-mock">Service 테스트 (Mockito, Repository Mock)</h3>
<table>
<thead>
<tr>
<th>서비스</th>
<th>테스트 수</th>
<th>주요 검증</th>
</tr>
</thead>
<tbody><tr>
<td>MembershipServiceTest</td>
<td>11개</td>
<td>생성 검증 5가지 + 목록 조회 + 요약 집계</td>
</tr>
<tr>
<td>MemberServiceTest</td>
<td>5개</td>
<td>cross-domain 조회, 수강권 유/무 혼합</td>
</tr>
</tbody></table>
<hr>
<h2 id="빠르게-기억할-치트시트">빠르게 기억할 치트시트</h2>
<pre><code>테스트 = &quot;이러면 어떻게 되지?&quot;를 코드로 증명하는 것

given  → 상황 만들기
when   → 실행하기
then   → 결과 확인하기

Entity 테스트   → 객체의 규칙 (Mock X, DB X)
Repository 테스트 → 쿼리 동작 (Mock X, DB O)
Service 테스트   → 비즈니스 흐름 (Mock O, DB X)

assertThat(값).isEqualTo(기대값)     → 같은지
assertThat(값).isTrue() / isFalse()  → 참/거짓
assertThatThrownBy(() -&gt; 실행)       → 예외 터지는지
verify(mock).메서드()                → Mock이 호출됐는지
verify(mock, never()).메서드()       → Mock이 호출 안 됐는지</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[API설계하면서 부딪힌 3가지 — URL 설계, 검증 경계, 크로스 도메인 의존]]></title>
            <link>https://velog.io/@seungje_labs/API%EC%84%A4%EA%B3%84%ED%95%98%EB%A9%B4%EC%84%9C-%EB%B6%80%EB%94%AA%ED%9E%8C-3%EA%B0%80%EC%A7%80-URL-%EC%84%A4%EA%B3%84-%EA%B2%80%EC%A6%9D-%EA%B2%BD%EA%B3%84-%ED%81%AC%EB%A1%9C%EC%8A%A4-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%9D%98%EC%A1%B4</link>
            <guid>https://velog.io/@seungje_labs/API%EC%84%A4%EA%B3%84%ED%95%98%EB%A9%B4%EC%84%9C-%EB%B6%80%EB%94%AA%ED%9E%8C-3%EA%B0%80%EC%A7%80-URL-%EC%84%A4%EA%B3%84-%EA%B2%80%EC%A6%9D-%EA%B2%BD%EA%B3%84-%ED%81%AC%EB%A1%9C%EC%8A%A4-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%9D%98%EC%A1%B4</guid>
            <pubDate>Sun, 22 Feb 2026 03:25:01 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>사이드 프로젝트로 PT(Personal Training) 예약 관리 시스템을 만들고 있다. Spring Boot + Next.js 구성이다.</p>
<p>이번 글에서는 실제 기능을 구현하면서 마주친 <strong>설계 시행착오</strong>를 정리한다. 완성된 결과보다 <strong>왜 그렇게 결정했고, 어떤 트레이드오프가 있었는지</strong>에 집중한다.</p>
<hr>
<h2 id="1-apitrainersmemembers--그-url이-정말-맞아">1. <code>/api/trainers/me/members</code> — 그 URL이 정말 맞아?</h2>
<h3 id="상황">상황</h3>
<p>트레이너가 자기 회원 목록을 조회하는 API가 필요했다. 세 가지 후보가 있었다.</p>
<pre><code>1. GET /api/trainers/members
2. GET /api/trainers/{trainerId}/members
3. GET /api/trainers/me/members</code></pre><h3 id="내-선택-3번">내 선택: 3번</h3>
<p>이유는 두 가지였다.</p>
<ul>
<li><strong><code>me</code>는 JWT에서 추출</strong>하니까 다른 트레이너의 ID를 넣을 여지 자체가 없다. IDOR(Insecure Direct Object Reference) 취약점이 구조적으로 차단된다.</li>
<li><strong>URL만 봐도 &quot;나의 회원&quot;이라는 의미가 명확하다.</strong></li>
</ul>
<p>실제로 이 패턴은 널리 쓰인다:</p>
<ul>
<li>Microsoft Graph: <code>GET /v1.0/me</code></li>
<li>Spotify: <code>GET /v1/me/playlists</code></li>
<li>Discord: <code>GET /users/@me</code></li>
<li>GitHub: <code>GET /user/repos</code> (singular, ID 없이)</li>
<li>Google People: <code>GET /v1/people/me</code></li>
</ul>
<h3 id="그런데-이건-restful한가">그런데 이건 RESTful한가?</h3>
<p><strong>엄밀히 말하면 아니다.</strong> Roy Fielding의 REST 원칙에서 URI는 하나의 리소스를 식별해야 한다. 그런데 <code>/me</code>는 토큰에 따라 다른 리소스를 가리키는 <strong>컨텍스트 의존적 별칭(alias)</strong>이다. 같은 <code>GET /me</code>가 사용자마다 다른 결과를 반환하니까, 하나의 URI = 하나의 리소스 원칙에 위배된다.</p>
<p>Kevin Dunglas(API Platform 메인테이너)는 이렇게 말했다:</p>
<blockquote>
<p>&quot;Creating an endpoint like /api/users/me isn&#39;t stateless, can create cache problems if you rely on some reverse proxies and break the REST pattern.&quot;</p>
</blockquote>
<p><strong>캐싱 문제</strong>도 있다. CDN이나 리버스 프록시가 URL을 캐시 키로 쓰면, User A의 <code>/me</code> 응답이 User B에게 전달될 수 있다. <code>Vary: Authorization</code> 헤더로 해결은 가능하지만, 기본 CDN 설정에서는 동작하지 않는 경우가 많다.</p>
<h3 id="내가-인지한-트레이드오프">내가 인지한 트레이드오프</h3>
<table>
<thead>
<tr>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td>IDOR 구조적 차단</td>
<td>순수 REST 원칙 위배</td>
</tr>
<tr>
<td>의미가 명확</td>
<td>HTTP 캐싱 복잡도 증가</td>
</tr>
<tr>
<td>클라이언트가 ID를 몰라도 됨</td>
<td>Admin 기능 추가 시 <code>/trainers/{id}/members</code> 별도 필요</td>
</tr>
<tr>
<td>OAuth 부트스트랩 문제 해결</td>
<td>기존 엔드포인트와 <strong>일관성 깨짐</strong></td>
</tr>
</tbody></table>
<p>기존 프로젝트의 다른 엔드포인트들(<code>/api/reservations</code>, <code>/api/memberships</code>)은 <code>me</code> 패턴을 안 쓰고, JWT에서 email을 추출해서 내부적으로 처리한다. 여기만 다른 패턴을 쓰는 셈이다.</p>
<p>일관성 문제를 알면서도 진행한 이유? <strong>새로운 패턴을 경험해보고 싶었기 때문이다.</strong> 실무에서 쓸지 말지는 트레이드오프를 직접 느껴본 뒤에 판단해도 늦지 않다.</p>
<blockquote>
<p><strong>OCTO REST API Cookbook</strong>의 권장사항: <code>/me</code>는 <strong>초기 부트스트랩용으로만 제한적으로 사용</strong>하고, 이후에는 정규 URI(<code>/users/{id}/...</code>)를 쓰는 하이브리드 접근이 좋다.</p>
</blockquote>
<hr>
<h2 id="2-프론트에서-체크하면-되지-않나--검증은-어디서-해야-하는가">2. &quot;프론트에서 체크하면 되지 않나?&quot; — 검증은 어디서 해야 하는가</h2>
<h3 id="상황-1">상황</h3>
<p>이용권(Membership)을 등록하는 API를 만들면서, 종료일이 시작일보다 앞서는 경우(날짜 역전)를 어디서 막을지 고민했다.</p>
<p>처음엔 당연히 <strong>&quot;프론트에서 체크할 문제&quot;</strong>라고 생각했다. Date Picker 컴포넌트에서 제한하면 되니까.</p>
<h3 id="왜-그게-위험한가">왜 그게 위험한가</h3>
<p>OWASP Input Validation Cheat Sheet에 이런 문장이 있다:</p>
<blockquote>
<p>&quot;Input validation <strong>must</strong> be implemented on the server-side before any data is processed, as any JavaScript-based input validation can be circumvented by an attacker who disables JavaScript or uses a web proxy.&quot;</p>
</blockquote>
<p>프론트 검증은 <strong>UX(사용자 편의)</strong>를 위한 것이고, 백엔드 검증은 <strong>데이터 무결성</strong>을 위한 것이다. 이 둘은 목적이 다르다.</p>
<p>실제로 프론트 검증만 믿다가 터진 사례들이 있다:</p>
<ul>
<li><strong>OTP 우회</strong>: 클라이언트에서만 OTP를 검증 → 공격자가 API 응답의 상태 코드를 401에서 200으로 변조해서 인증 우회</li>
<li><strong>결제 금액 조작</strong>: JavaScript 변수에 저장된 가격을 Burp Suite로 변조 → 서버가 그대로 수용</li>
<li><strong>HackerOne 최소 바운티 우회</strong>: 보안 플랫폼인 HackerOne 자체에서 클라이언트 입력을 신뢰해서 최소 금액 제한이 뚫림</li>
</ul>
<h3 id="검증-피라미드">검증 피라미드</h3>
<p>Frank de Jonge의 &quot;Where Does Validation Live?&quot;에서 제안하는 3계층 검증:</p>
<pre><code>[Frontend]     형태 검증, 즉각적 UX 피드백 (보안 아님)
     ↓
[Controller/DTO]  구조적 검증: @NotNull, @Min, 타입 체크
     ↓
[Service]      비즈니스 규칙: 권한, 중복, 정책
     ↓
[Entity]       불변식(invariant): 도메인 모델이 스스로를 보호
     ↓
[Database]     제약조건: NOT NULL, UNIQUE, FK (최후의 방어선)</code></pre><p>Greg Young은 2009년 &quot;Always Valid&quot; 글에서 이렇게 주장했다:</p>
<blockquote>
<p>Entity는 <strong>절대로 유효하지 않은 상태로 존재해서는 안 된다.</strong> 유효하지 않은 상태의 Entity는 설계 결함이다.</p>
</blockquote>
<p>Vladimir Khorikov는 이걸 비유로 설명한다:</p>
<blockquote>
<p>&quot;삼각형은 변이 3개인 도형이다. 4번째 변을 추가하면, 그건 더 이상 삼각형이 아니라 사각형이다.&quot;</p>
</blockquote>
<h3 id="내가-적용한-구조">내가 적용한 구조</h3>
<table>
<thead>
<tr>
<th>검증</th>
<th>위치</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td><code>endDate &lt; startDate</code></td>
<td><strong>Entity</strong> <code>create()</code></td>
<td>어디서 생성하든 잘못된 수강권이 만들어지면 안 됨</td>
</tr>
<tr>
<td><code>totalCount ≤ 0</code></td>
<td><strong>Entity</strong> <code>create()</code></td>
<td>도메인 불변식 — 0회짜리 수강권은 존재할 수 없음</td>
</tr>
<tr>
<td>트레이너 소유 회원 확인</td>
<td><strong>Service</strong></td>
<td>비즈니스 규칙 (인가)</td>
</tr>
<tr>
<td>ACTIVE 수강권 중복</td>
<td><strong>Service</strong></td>
<td>비즈니스 정책 — 한 회원에 ACTIVE 수강권 1개만</td>
</tr>
<tr>
<td>필드 null/empty</td>
<td><strong>DTO</strong> <code>@NotNull</code>, <code>@Min</code></td>
<td>구조적 검증</td>
</tr>
</tbody></table>
<pre><code class="language-java">// Entity — 도메인 모델이 스스로를 보호한다
public static Membership create(Member member, Trainer trainer,
                                Integer totalCount,
                                LocalDate startDate, LocalDate endDate) {
    if (endDate.isBefore(startDate)) {
        throw new IllegalArgumentException(&quot;종료일은 시작일보다 이후여야 합니다.&quot;);
    }
    if (totalCount &lt;= 0) {
        throw new IllegalArgumentException(&quot;총 PT 횟수는 1 이상이어야 합니다.&quot;);
    }
    return Membership.builder()
            .member(member)
            .trainer(trainer)
            .totalCount(totalCount)
            .remainingCount(totalCount)
            .startDate(startDate)
            .endDate(endDate)
            .status(MembershipStatus.ACTIVE)
            .build();
}</code></pre>
<p>Jimmy Bogard는 반대 의견도 있다 — &quot;Validate commands, not entities&quot;(Entity가 아니라 커맨드에서 검증해라). 하지만 DDD 커뮤니티의 주류 의견은 <strong>Entity가 자신의 불변식을 보호해야 한다</strong>는 쪽이다. 특히 복잡한 도메인에서.</p>
<p>핵심은 이거다:</p>
<blockquote>
<p><strong>프론트 검증과 백엔드 검증은 중복이 아니다. 서로 다른 목적을 위한 서로 다른 방어선이다.</strong></p>
</blockquote>
<hr>
<h2 id="3-controller는-trainer에-service는-member에--크로스-도메인-의존의-딜레마">3. Controller는 trainer에, Service는 member에 — 크로스 도메인 의존의 딜레마</h2>
<h3 id="상황-2">상황</h3>
<p>회원 목록 API의 패키지 구조를 이렇게 잡았다:</p>
<ul>
<li><code>TrainerMemberController</code> → <code>domain/trainer/controller/</code></li>
<li><code>MemberService</code> → <code>domain/member/service/</code></li>
</ul>
<p>이유는 명확했다:</p>
<ul>
<li>Controller는 <strong>URL 경로</strong>를 따른다 → <code>/api/trainers/me/members</code>니까 trainer 패키지</li>
<li>Service는 <strong>데이터 소유자</strong>를 따른다 → 회원 데이터를 다루니까 member 패키지</li>
</ul>
<p>Martin Fowler가 경고한 <strong>빈약한 도메인 모델(Anemic Domain Model)</strong> 안티패턴을 피하려면, 데이터를 소유한 주체가 비즈니스 로직도 가져야 한다.</p>
<h3 id="그런데-의존성-그래프를-보면">그런데 의존성 그래프를 보면</h3>
<pre><code>TrainerMemberController (trainer 도메인)
    └── MemberService (member 도메인)
            ├── MemberRepository (member 도메인)       ← 같은 도메인, OK
            ├── MembershipRepository (membership 도메인) ← 크로스!
            └── TrainerRepository (trainer 도메인)      ← 크로스!</code></pre><p>3개의 도메인이 하나의 Service에서 만난다. 이건 문제인가?</p>
<h3 id="이게-문제가-되는-시점">이게 문제가 되는 시점</h3>
<p><strong>지금은</strong> 문제없다. 프로젝트가 작고, 도메인 간 경계가 명확하다.</p>
<p><strong>문제가 되는 시점</strong>은:</p>
<ul>
<li><code>MembershipRepository</code>의 API가 바뀌면 <code>MemberService</code>도 깨진다 (다른 도메인의 내부 구현에 의존)</li>
<li><code>member</code>와 <code>trainer</code> 패키지가 서로를 참조하기 시작하면 <strong>순환 의존</strong>이 생긴다</li>
<li>Spring Modulith를 도입하면 <strong>다른 모듈의 하위 패키지 접근이 차단</strong>된다</li>
</ul>
<h3 id="해결-패턴들">해결 패턴들</h3>
<p>Philipp Hauer의 &quot;Package by Feature&quot; 글과 Spring Modulith 가이드에서 제안하는 패턴들:</p>
<p><strong>패턴 A: 인터페이스로 의존성 역전</strong></p>
<pre><code class="language-java">// member 모듈이 인터페이스를 정의
public interface MembershipInfoProvider {
    MembershipSummary getActiveMembership(Long memberId);
}

// membership 모듈이 구현
@Service
public class MembershipInfoProviderImpl implements MembershipInfoProvider {
    // MembershipRepository는 여기서만 사용
}</code></pre>
<p><strong>패턴 B: 이벤트 기반</strong></p>
<pre><code class="language-java">// 직접 호출 대신, 이벤트를 발행하고 구독하는 방식
// Spring Modulith가 권장하는 모듈 간 통신
applicationEventPublisher.publishEvent(new MemberQueryEvent(memberId));</code></pre>
<p><strong>패턴 C: 모듈 합치기</strong>
<code>member</code>와 <code>membership</code>이 항상 함께 쓰인다면, 애초에 하나의 바운디드 컨텍스트(Bounded Context)일 수 있다.</p>
<h3 id="내가-인지한-트레이드오프-1">내가 인지한 트레이드오프</h3>
<p>현재는 가장 단순한 방법(직접 의존)을 선택했다. PT 예약 시스템 규모에서 인터페이스 분리나 이벤트 기반은 과설계(over-engineering)다.</p>
<p>하지만 이 의존 관계를 <strong>인지하고 있는 것</strong>과 <strong>모르고 지나가는 것</strong>은 다르다. 나중에 도메인이 커지면, 위 패턴들이 필요한 시점이 온다.</p>
<blockquote>
<p>Eric Evans는 <strong>Bounded Context</strong> 사이에는 <strong>Anti-Corruption Layer</strong>를 두라고 했다. 지금은 모노리스 안에서의 패키지 경계지만, 마이크로서비스로 분리한다면 이 크로스 도메인 의존이 가장 먼저 문제가 된다.</p>
</blockquote>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>설계 판단</th>
<th>선택</th>
<th>인지한 리스크</th>
</tr>
</thead>
<tbody><tr>
<td>URL 패턴</td>
<td><code>/api/trainers/me/members</code></td>
<td>REST 원칙 위배, 프로젝트 내 일관성 깨짐</td>
</tr>
<tr>
<td>검증 위치</td>
<td>Entity + Service + DTO (계층별)</td>
<td>검증 로직 분산, 하지만 각 레이어의 목적이 다름</td>
</tr>
<tr>
<td>패키지 구조</td>
<td>Controller ≠ Service 패키지</td>
<td>크로스 도메인 의존, 순환 의존 가능성</td>
</tr>
</tbody></table>
<p>세 가지 판단 모두 <strong>정답이 아닐 수 있다.</strong> 하지만 <strong>왜 그렇게 결정했고, 어떤 문제가 생길 수 있는지 알고 있다.</strong> 그게 중요하다고 생각한다.</p>
<hr>
<h2 id="참고-자료">참고 자료</h2>
<p><strong>URL 설계 / <code>/me</code> 패턴</strong></p>
<ul>
<li><a href="https://octo-woapi.github.io/cookbook/resources-depending-on-authenticated-user.html">OCTO REST API Cookbook — Resources depending on authenticated user</a></li>
<li><a href="https://github.com/api-platform/core/issues/477">API Platform — Best Practice for adding a /me route (GitHub Issue #477)</a></li>
<li><a href="https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven">Roy Fielding — REST APIs must be hypertext-driven</a></li>
<li><a href="https://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api">Vinay Sahni — Best Practices for a Pragmatic RESTful API</a></li>
</ul>
<p><strong>검증 / 보안</strong></p>
<ul>
<li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html">OWASP — Input Validation Cheat Sheet</a></li>
<li><a href="https://top10proactive.owasp.org/the-top-10/c3-validate-input-and-handle-exceptions/">OWASP Proactive Controls C3 — Validate Input</a></li>
<li><a href="https://enterprisecraftsmanship.com/posts/always-valid-domain-model/">Vladimir Khorikov — Always-Valid Domain Model</a></li>
<li><a href="http://codebetter.com/gregyoung/2009/05/22/always-valid/">Greg Young — Always Valid (2009)</a></li>
<li><a href="https://blog.frankdejonge.nl/where-does-validation-live/">Frank de Jonge — Where Does Validation Live?</a></li>
<li><a href="https://deepstrike.io/blog/client-site-vulnerabilities">DeepStrike — Client-Side Validation: Security Flaws and Real Exploits</a></li>
</ul>
<p><strong>패키지 구조 / DDD</strong></p>
<ul>
<li><a href="https://phauer.com/2020/package-by-feature/">Philipp Hauer — Package by Feature</a></li>
<li><a href="https://martinfowler.com/bliki/BoundedContext.html">Martin Fowler — BoundedContext</a></li>
<li><a href="https://martinfowler.com/bliki/AnemicDomainModel.html">Martin Fowler — Anemic Domain Model</a></li>
<li><a href="https://plilja.se/spring-modulith-and-breaking-dependency-cycles/">Spring Modulith and Breaking Dependency Cycles</a></li>
<li><a href="https://learn.microsoft.com/en-us/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/domain-model-layer-validations">Microsoft — Domain Model Layer Validations</a></li>
</ul>
<hr>
<blockquote>
<p>프로젝트: FitLink — PT 예약 관리 시스템
기술 스택: Spring Boot 3.3 + Java 21 + Next.js 16 + Zustand + TanStack Query</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Firebase + 소셜 로그인 완전 정복 (구글, 애플, 카카오) 🔥]]></title>
            <link>https://velog.io/@seungje_labs/Firebase-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5-%EA%B5%AC%EA%B8%80-%EC%95%A0%ED%94%8C-%EC%B9%B4%EC%B9%B4%EC%98%A4</link>
            <guid>https://velog.io/@seungje_labs/Firebase-%EC%86%8C%EC%85%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EC%99%84%EC%A0%84-%EC%A0%95%EB%B3%B5-%EA%B5%AC%EA%B8%80-%EC%95%A0%ED%94%8C-%EC%B9%B4%EC%B9%B4%EC%98%A4</guid>
            <pubDate>Fri, 08 Aug 2025 01:43:01 GMT</pubDate>
            <description><![CDATA[<h2 id="🆕-2025년-최신-버전-완벽-대응">🆕 2025년 최신 버전 완벽 대응!</h2>
<h2 id="개요-📝">개요 📝</h2>
<p>Flutter 앱에서 Firebase Authentication과 함께 구글, 애플, 카카오 로그인을 모두 구현한 경험을 정리했다. 각 플랫폼별 특징과 주의사항, 그리고 실제 구현 과정에서 마주한 문제점들까지 기록해보자.</p>
<p><strong>⚡ 이 글의 특징:</strong></p>
<ul>
<li><strong>최신 버전 완벽 대응</strong>: <code>firebase_core: 4.0.0</code>, <code>firebase_auth: 6.0.0</code>, <code>google_sign_in: 7.1.1</code>, <code>sign_in_with_apple: 7.0.1</code>, <code>kakao_flutter_sdk: 1.9.6</code></li>
<li>바이럴 코딩(AI 도구)에만 의존했다가 겪은 시행착오 공유</li>
<li><strong>모든 플랫폼별 최신 API 변경사항</strong> 상세 분석 및 대응 방법 제시</li>
</ul>
<h2 id="전체-아키텍처-설계-🏗️">전체 아키텍처 설계 🏗️</h2>
<h3 id="1-기본-구조">1. 기본 구조</h3>
<pre><code>Firebase Auth (중심축)
├── Google Sign-In
├── Apple Sign-In  
└── Kakao Sign-In → Firebase Custom Token</code></pre><h3 id="2-로그인-플로우-설계">2. 로그인 플로우 설계</h3>
<ol>
<li><strong>사용자 선택</strong> → 로그인 방식 선택</li>
<li><strong>각 플랫폼 인증</strong> → 플랫폼별 토큰 획득</li>
<li><strong>Firebase 연동</strong> → Firebase Auth로 통합 관리</li>
<li><strong>사용자 정보 저장</strong> → Firestore에 프로필 데이터 저장</li>
</ol>
<h2 id="구현-방법-🛠️">구현 방법 🛠️</h2>
<h3 id="1-구글-로그인-구현">1. 구글 로그인 구현</h3>
<p>가장 간단하고 Firebase와 연동이 쉬운 방식이다. <strong>하지만 7.1.1 버전부터 API가 크게 변경되었으니 주의!</strong></p>
<h4 id="🔄-바이럴-코딩의-함정과-api-변화">🔄 <strong>바이럴 코딩의 함정과 API 변화</strong></h4>
<p>처음엔 GitHub Copilot과 ChatGPT가 추천한 구 버전 코드를 그대로 사용했다가 계속 오류가 발생했다. 알고보니 <code>google_sign_in</code> 7.1.1부터 API가 완전히 바뀌었던 것!</p>
<p><strong>구 버전 (7.0.0 이전) 방식:</strong></p>
<pre><code class="language-dart">// ❌ 이제 안 되는 방식
final GoogleSignInAccount? googleUser = await GoogleSignIn().signIn();</code></pre>
<p><strong>신 버전 (7.1.1+) 방식:</strong></p>
<pre><code class="language-dart">// ✅ 새로운 방식
final GoogleSignIn signIn = GoogleSignIn.instance;
await signIn.initialize();
final GoogleSignInAccount? googleUser = await signIn.authenticate();</code></pre>
<p>💡 <strong>교훈</strong>: AI 도구들은 항상 최신 API를 반영하지 못한다. 공식 문서와 changelogs 확인이 필수!</p>
<h4 id="의존성-추가-최신-버전">의존성 추가 (최신 버전)</h4>
<pre><code class="language-yaml"># pubspec.yaml - 2025년 8월 기준 최신 안정 버전
dependencies:
  firebase_core: 4.0.0
  firebase_auth: 6.0.0
  google_sign_in: 7.1.1  # 🔥 7.1.1부터 API 대폭 변경!</code></pre>
<h4 id="핵심-코드">핵심 코드</h4>
<pre><code class="language-dart">Future&lt;void&gt; signInWithGoogle() async {
  try {
    final GoogleSignIn signIn = GoogleSignIn.instance;
    await signIn.initialize();
    final GoogleSignInAccount? googleUser = await signIn.authenticate();
    if (googleUser == null) {
      debugPrint(&#39;Google 로그인 취소 또는 사용자 없음&#39;);
      return;
    }
    final GoogleSignInAuthentication googleAuth = await googleUser.authentication;
    final credential = GoogleAuthProvider.credential(
      idToken: googleAuth.idToken,
    );
    await _auth.signInWithCredential(credential);
    notifyListeners();
  } catch (e, st) {
    debugPrint(&#39;Google 로그인 오류: $e\n$st&#39;);
    rethrow;
  }
}</code></pre>
<p>✅ <strong>장점</strong>: Firebase 공식 지원, 구현 간단<br>⚠️ <strong>주의점</strong>: iOS에서는 URL Scheme 설정 필요</p>
<h3 id="2-애플-로그인-구현">2. 애플 로그인 구현</h3>
<p>iOS 13+ 필수 요구사항이며, 개인정보 보호에 특화되어 있다. <strong>7.0.1 버전에서 안정성이 크게 개선되었다!</strong></p>
<h4 id="🔄-최신-버전-701-주요-개선사항">🔄 <strong>최신 버전 7.0.1 주요 개선사항</strong></h4>
<p>바이럴 코딩으로 찾은 예전 글들은 대부분 5.x 버전 기준이었는데, 7.0.1에서는 다음과 같은 개선이 있었다:</p>
<ul>
<li>iOS 17 완벽 지원</li>
<li>에러 핸들링 개선</li>
<li>성능 최적화</li>
</ul>
<h4 id="의존성-추가-최신-버전-1">의존성 추가 (최신 버전)</h4>
<pre><code class="language-yaml"># pubspec.yaml - 2025년 8월 기준 최신 안정 버전
dependencies:
  sign_in_with_apple: 7.0.1  # 🔥 7.0.1 최신 버전 사용!</code></pre>
<h4 id="🚀-바이럴-코딩-성공-사례-ios-설정">🚀 <strong>바이럴 코딩 성공 사례: iOS 설정</strong></h4>
<p>애플 로그인은 오히려 바이럴 코딩이 빛을 발한 케이스였다! GitHub Copilot이 Info.plist 설정을 완벽하게 제안해주었다:</p>
<pre><code class="language-xml">&lt;!-- ✅ iOS Info.plist - 바이럴 코딩으로 빠르게 완성! --&gt;
&lt;key&gt;CFBundleURLTypes&lt;/key&gt;
&lt;array&gt;
    &lt;!-- Google 로그인 --&gt;
    &lt;dict&gt;
        &lt;key&gt;CFBundleURLSchemes&lt;/key&gt;
        &lt;array&gt;
            &lt;string&gt;com.googleusercontent.apps.36163303930-1shl4p0i3fm7jsd01ir542pd0jnq5g0a&lt;/string&gt;
        &lt;/array&gt;
    &lt;/dict&gt;
    &lt;!-- Apple 로그인: 실제 번들 ID 사용 --&gt;
    &lt;dict&gt;
        &lt;key&gt;CFBundleURLSchemes&lt;/key&gt;
        &lt;array&gt;
            &lt;string&gt;com.byseungje.myApp&lt;/string&gt;
        &lt;/array&gt;
    &lt;/dict&gt;
    &lt;!-- Kakao 로그인 --&gt;
    &lt;dict&gt;
        &lt;key&gt;CFBundleURLSchemes&lt;/key&gt;
        &lt;array&gt;
            &lt;string&gt;kakaoa{네이티브 앱 키}&lt;/string&gt;
        &lt;/array&gt;
    &lt;/dict&gt;
&lt;/array&gt;</code></pre>
<p>💡 <strong>iOS는 바이럴 코딩 천국</strong>: URL Scheme부터 Xcode 설정까지 AI가 거의 완벽하게 도와줬다!</p>
<h4 id="핵심-코드-701-버전">핵심 코드 (7.0.1 버전)</h4>
<pre><code class="language-dart">Future&lt;UserCredential?&gt; signInWithApple() async {
  try {
    final appleCredential = await SignInWithApple.getAppleIDCredential(
      scopes: [
        AppleIDAuthorizationScopes.email,
        AppleIDAuthorizationScopes.fullName,
      ],
    );

    final oauthCredential = OAuthProvider(&quot;apple.com&quot;).credential(
      idToken: appleCredential.identityToken,
      accessToken: appleCredential.authorizationCode,
    );

    return await FirebaseAuth.instance.signInWithCredential(oauthCredential);
  } catch (e) {
    debugPrint(&#39;Apple Sign-In Error: $e&#39;);
    return null;
  }
}</code></pre>
<p>✅ <strong>장점</strong>: 개인정보 보호 우수, iOS 생태계 완벽 지원, <strong>바이럴 코딩으로 빠른 개발 가능</strong><br>⚠️ <strong>주의점</strong>: 이메일 숨기기 기능으로 실제 이메일 접근 어려움</p>
<h3 id="3-카카오-로그인-구현">3. 카카오 로그인 구현</h3>
<p>한국 서비스라면 필수! 하지만 Firebase 직접 연동이 안 되어 Custom Token 방식을 사용해야 한다. <strong>1.9.6 버전에서 Flutter 3.8+ 완벽 지원!</strong></p>
<h4 id="🔄-최신-버전-196-주요-개선사항">🔄 <strong>최신 버전 1.9.6 주요 개선사항</strong></h4>
<p>예전 AI 도구들이 추천한 1.7.x 버전과 달리, 1.9.6에서는:</p>
<ul>
<li>Flutter 3.8+ 완벽 호환</li>
<li>새로운 카카오 정책 대응</li>
<li>AndroidManifest 설정 방식 변화</li>
</ul>
<h4 id="의존성-추가-최신-버전-2">의존성 추가 (최신 버전)</h4>
<pre><code class="language-yaml"># pubspec.yaml - 2025년 8월 기준 최신 안정 버전
dependencies:
  kakao_flutter_sdk: 1.9.6  # 🔥 1.9.6 최신 버전 사용!</code></pre>
<h4 id="🔥-androidmanifestxml-설정---중요">🔥 <strong>AndroidManifest.xml 설정 - 중요!</strong></h4>
<p>코파일럿의 추천을 따라 intent-filter만 추가했다가 계속 오류가 발생했다. 카카오 디벨로퍼 문서를 다시 읽어보니 <strong>새로운 activity 전체를 추가</strong>해야 하는 것이었다!</p>
<pre><code class="language-xml">&lt;!-- 카카오 로그인을 위한 AuthCodeHandlerActivity --&gt;
&lt;activity android:name=&quot;com.kakao.sdk.flutter.AuthCodeCustomTabsActivity&quot;&gt;
    &lt;intent-filter android:label=&quot;flutter_web_auth&quot;&gt;
        &lt;action android:name=&quot;android.intent.action.VIEW&quot; /&gt;
        &lt;category android:name=&quot;android.intent.category.DEFAULT&quot; /&gt;
        &lt;category android:name=&quot;android.intent.category.BROWSABLE&quot; /&gt;

        &lt;!-- Redirect URI: &quot;kakao${NATIVE_APP_KEY}://oauth&quot; --&gt;
        &lt;data android:scheme=&quot;kakaoa9f72088774650dd0f2413f8df7a4c92&quot; android:host=&quot;oauth&quot;/&gt;
    &lt;/intent-filter&gt;
&lt;/activity&gt;</code></pre>
<p>💡 <strong>교훈</strong>: intent-filter 추가가 끝이 아닌, 새로운 activity 추가였다. 새로운 기능 구현에 앞서 문서 정독은 필수라는 걸 다시 한번 깨달았다.</p>
<h4 id="🚨-바이럴-코딩의-최대-함정-oidc-vs-custom-token">🚨 <strong>바이럴 코딩의 최대 함정: OIDC vs Custom Token</strong></h4>
<p>카카오 로그인 구현 중 가장 큰 삽질이었다. Firebase와 카카오를 연동하려고 할 때마다 AI들이 계속 OIDC 방식을 추천했는데, 막상 구현해보면 오류가 발생했다.</p>
<p><strong>AI가 자꾸 추천한 OIDC 방식:</strong></p>
<pre><code class="language-dart">// ❌ AI가 추천했지만 실제로는 안 되는 방식
final provider = OAuthProvider(&quot;oidc.kakao&quot;);
// 계속 오류 발생...</code></pre>
<p><strong>실제로 작동하는 Custom Token 방식:</strong></p>
<pre><code class="language-dart">// ✅ 결국 이 방식으로 해결
Future&lt;UserCredential?&gt; signInWithKakao() async {
  try {
    OAuthToken token = await UserApi.instance.loginWithKakaoTalk();

    // Firebase Custom Token 생성을 위해 서버로 카카오 토큰 전송
    final customToken = await createFirebaseCustomToken(token.accessToken);

    return await FirebaseAuth.instance.signInWithCustomToken(customToken);
  } catch (e) {
    debugPrint(&#39;Kakao Sign-In Error: $e&#39;);
    return null;
  }
}</code></pre>
<p>💡 <strong>교훈</strong>: AI가 이론적으로 가능하다고 추천해도, 실제 카카오-Firebase 연동은 Custom Token이 정답이었다!</p>
<h4 id="firebase-functions-custom-token-생성">Firebase Functions (Custom Token 생성)</h4>
<pre><code class="language-javascript">// functions/src/index.ts
exports.createCustomToken = functions.https.onCall(async (data, context) =&gt; {
  const { kakaoAccessToken } = data;

  // 카카오 사용자 정보 검증
  const userInfo = await verifyKakaoToken(kakaoAccessToken);

  // Firebase Custom Token 생성
  const customToken = await admin.auth().createCustomToken(userInfo.id, {
    provider: &#39;kakao&#39;,
    email: userInfo.kakao_account?.email,
    name: userInfo.properties?.nickname
  });

  return { customToken };
});</code></pre>
<h2 id="통합-provider-설계-🎯">통합 Provider 설계 🎯</h2>
<p>모든 로그인 방식을 하나의 Provider로 관리해서 일관성을 유지했다.</p>
<pre><code class="language-dart">class AuthProvider extends ChangeNotifier {
  User? _user;
  LoginType? _loginType;

  Future&lt;bool&gt; signIn(LoginType type) async {
    try {
      switch (type) {
        case LoginType.google:
          await signInWithGoogle();
          break;
        case LoginType.apple:
          await signInWithApple();
          break;
        case LoginType.kakao:
          await signInWithKakao();
          break;
      }

      if (FirebaseAuth.instance.currentUser != null) {
        _user = FirebaseAuth.instance.currentUser;
        _loginType = type;
        notifyListeners();
        return true;
      }
      return false;
    } catch (e) {
      debugPrint(&#39;로그인 실패: $e&#39;);
      return false;
    }
  }
}</code></pre>
<h2 id="바이럴-코딩-현실-체크-🤖-vs-📖">바이럴 코딩 현실 체크 🤖 vs 📖</h2>
<p>이번 프로젝트를 통해 바이럴 코딩(AI 도구 활용)의 명암을 확실히 경험했다.</p>
<h3 id="🟢-바이럴-코딩이-빛난-순간들">🟢 바이럴 코딩이 빛난 순간들</h3>
<ol>
<li><strong>iOS 설정</strong>: Info.plist URL Scheme 설정을 완벽하게 제안</li>
<li><strong>구글 로그인 기본 틀</strong>: 전체적인 구조와 Provider 패턴 제안</li>
<li><strong>에러 핸들링</strong>: try-catch 구조와 디버그 출력 패턴</li>
</ol>
<h3 id="🔴-바이럴-코딩이-삽질을-유발한-순간들">🔴 바이럴 코딩이 삽질을 유발한 순간들</h3>
<ol>
<li><strong>구글 로그인 API</strong>: 7.1.1 신버전 API를 모르고 구버전 추천</li>
<li><strong>안드로이드 카카오 설정</strong>: intent-filter만 추가하라고 해서 계속 오류</li>
<li><strong>카카오-Firebase 연동</strong>: OIDC 추천했지만 실제로는 Custom Token 필요</li>
</ol>
<h3 id="💡-결론-바이럴-코딩--문서-검증--최강">💡 결론: 바이럴 코딩 + 문서 검증 = 최강</h3>
<ul>
<li>AI 도구로 빠르게 프로토타입 생성</li>
<li>오류 발생 시 반드시 공식 문서로 검증</li>
<li>최신 버전 변경사항은 AI가 놓치는 경우 많음</li>
</ul>
<h2 id="주요-이슈--해결책-🚨">주요 이슈 &amp; 해결책 🚨</h2>
<h3 id="1-카카오-로그인-androidmanifest-설정-바이럴-코딩-최대-함정">1. 카카오 로그인 AndroidManifest 설정 (바이럴 코딩 최대 함정)</h3>
<ul>
<li><strong>문제</strong>: GitHub Copilot이 <code>intent-filter</code>만 추가하라고 해서 계속 오류 발생</li>
<li><strong>AI 추천</strong>: 기존 MainActivity에 <code>&lt;intent-filter&gt;</code> 태그만 추가</li>
<li><strong>실제 해결</strong>: 완전히 새로운 <code>&lt;activity&gt;</code> 태그 전체를 추가해야 함</li>
<li><strong>교훈</strong>: 안드로이드 manifest 설정은 AI보다 카카오 디벨로퍼 공식 문서가 정확</li>
</ul>
<h3 id="2-google-sign-in-711-api-변화-바이럴-코딩-시차-문제">2. Google Sign-In 7.1.1 API 변화 (바이럴 코딩 시차 문제)</h3>
<ul>
<li><strong>문제</strong>: ChatGPT, Copilot 모두 구버전 API(<code>GoogleSignIn().signIn()</code>) 추천</li>
<li><strong>실제 해결</strong>: 7.1.1부터 <code>initialize()</code> + <code>authenticate()</code> 방식으로 변경</li>
<li><strong>교훈</strong>: AI는 최신 Breaking Changes를 즉시 반영하지 못함</li>
</ul>
<h3 id="3-apple-sign-in-701-버전-호환성-바이럴-코딩-성공-사례">3. Apple Sign-In 7.0.1 버전 호환성 (바이럴 코딩 성공 사례)</h3>
<ul>
<li><strong>문제</strong>: 구 버전 5.x 코드가 iOS 17에서 간헐적 오류 발생  </li>
<li><strong>AI 도움</strong>: Copilot이 7.0.1 버전 업그레이드와 Info.plist 설정을 완벽 제안</li>
<li><strong>결과</strong>: 업그레이드 후 iOS에서 가장 안정적으로 작동</li>
</ul>
<h3 id="4-kakao-sdk-196-flutter-38-대응">4. Kakao SDK 1.9.6 Flutter 3.8+ 대응</h3>
<ul>
<li><strong>문제</strong>: 바이럴 코딩으로 찾은 1.7.x 버전이 최신 Flutter와 호환 문제</li>
<li><strong>해결</strong>: pub.dev에서 직접 확인 후 1.9.6 업데이트로 완벽 호환성 확보</li>
</ul>
<h3 id="5-카카오-firebase-oidc-vs-custom-token-ai의-이론-vs-현실">5. 카카오-Firebase OIDC vs Custom Token (AI의 이론 vs 현실)</h3>
<ul>
<li><strong>문제</strong>: AI들이 계속 OIDC 방식(<code>OAuthProvider(&quot;oidc.kakao&quot;)</code>) 추천</li>
<li><strong>현실</strong>: 실제로는 Custom Token 방식만 안정적으로 작동</li>
<li><strong>교훈</strong>: AI가 이론적으로 가능하다고 해도 실제 구현에서는 다를 수 있음</li>
</ul>
<h3 id="5-ios-apple-sign-in-인증서-설정">5. iOS Apple Sign-In 인증서 설정</h3>
<ul>
<li><strong>문제</strong>: Xcode에서 Sign-In with Apple capability 누락</li>
<li><strong>해결</strong>: Capabilities 탭에서 Sign-In with Apple 활성화</li>
</ul>
<h3 id="4-로그인-상태-지속성">4. 로그인 상태 지속성</h3>
<ul>
<li><strong>문제</strong>: 앱 재시작 시 로그인 상태 유지 안됨</li>
<li><strong>해결</strong>: Firebase Auth의 authStateChanges 스트림 활용</li>
</ul>
<h2 id="성능-최적화-팁-⚡">성능 최적화 팁 ⚡</h2>
<ol>
<li><strong>지연 로딩</strong>: 각 로그인 SDK는 필요할 때만 초기화</li>
<li><strong>토큰 캐싱</strong>: 로그인 토큰을 안전하게 캐싱해서 재로그인 빈도 줄이기</li>
<li><strong>에러 핸들링</strong>: 사용자에게 친화적인 에러 메시지 제공</li>
</ol>
<h2 id="마무리-🎉">마무리 🎉</h2>
<p>Firebase와 소셜 로그인을 모두 구현하면서 각 플랫폼의 특징을 이해할 수 있었다. 특히 카카오 로그인에서 문서를 제대로 읽지 않아 삽질한 경험과, 구글 로그인에서 바이럴 코딩만 믿다가 API 변경사항을 놓친 경험이 가장 기억에 남는다.</p>
<p><strong>🚨 바이럴 코딩의 현실적 한계:</strong></p>
<ul>
<li>AI 도구들은 최신 버전 변경사항을 즉시 반영하지 못함 (특히 Google Sign-In 7.1.1)</li>
<li>이론적으로 가능한 방식과 실제 작동하는 방식이 다름 (카카오 OIDC vs Custom Token)</li>
<li>플랫폼별 세부 설정은 공식 문서가 더 정확함 (안드로이드 manifest)</li>
<li>하지만 iOS 설정과 전체 구조 설계에서는 큰 도움이 됨</li>
</ul>
<p><strong>핵심 교훈</strong>: </p>
<ul>
<li>공식 문서를 꼼꼼히 읽자 📖</li>
<li>AI 도구로 빠른 프로토타입 → 공식 문서로 검증 🔍</li>
<li>각 플랫폼별 특성을 이해하고 구현하자 💪</li>
<li><strong>최신 버전 변경사항은 반드시 직접 확인하자</strong> 🔄</li>
<li><strong>바이럴 코딩 ≠ 만능</strong>, 적재적소에 활용하는 지혜 필요 🧠</li>
</ul>
<h2 id="🔮-앞으로의-계획">🔮 앞으로의 계획</h2>
<p>이 글은 <strong>2025년 8월 기준 최신 안정 버전</strong>으로 작성되었다:</p>
<ul>
<li><code>firebase_core: 4.0.0</code></li>
<li><code>firebase_auth: 6.0.0</code> </li>
<li><code>google_sign_in: 7.1.1</code></li>
<li><code>sign_in_with_apple: 7.0.1</code></li>
<li><code>kakao_flutter_sdk: 1.9.6</code></li>
</ul>
<p>하지만 Flutter와 Firebase 생태계는 빠르게 변화한다. </p>
<p><strong>📝 지속적인 업데이트 다짐:</strong></p>
<ul>
<li>🔄 <strong>월 1회 버전 체크</strong>: 새로운 버전 출시 시 즉시 테스트 및 글 업데이트</li>
<li>🚨 <strong>Breaking Changes 대응</strong>: 주요 API 변경 시 48시간 내 마이그레이션 가이드 추가</li>
<li>📦 <strong>새 플랫폼 추가</strong>: 네이버, 페이스북 등 추가 소셜 로그인 구현 시 즉시 공유</li>
<li>💬 <strong>커뮤니티 피드백</strong>: 댓글로 받은 최신 이슈들 반영해서 지속 개선</li>
</ul>
<blockquote>
<p><strong>⚠️ 주의</strong>: 이 글을 참고하실 때는 현재 사용 중인 패키지 버전을 확인하고, 필요시 최신 공식 문서를 함께 참조해주세요!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter Android에서 Pretendard Variable 폰트 흐림 현상 해결하기]]></title>
            <link>https://velog.io/@seungje_labs/Flutter-Android%EC%97%90%EC%84%9C-Pretendard-Variable-%ED%8F%B0%ED%8A%B8-%ED%9D%90%EB%A6%BC-%ED%98%84%EC%83%81-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@seungje_labs/Flutter-Android%EC%97%90%EC%84%9C-Pretendard-Variable-%ED%8F%B0%ED%8A%B8-%ED%9D%90%EB%A6%BC-%ED%98%84%EC%83%81-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 07 Aug 2025 12:30:48 GMT</pubDate>
            <description><![CDATA[<h2 id="🚨-문제-상황">🚨 문제 상황</h2>
<p>Flutter 앱에서 Pretendard Variable 폰트를 사용했는데, <strong>Android에서만 텍스트가 흐릿하게 렌더링</strong>되는 문제가 발생했습니다.</p>
<pre><code class="language-yaml"># 문제가 있던 설정
fonts:
  - family: Pretendard
    fonts:
      - asset: assets/fonts/PretendardVariable.ttf</code></pre>
<p>!Before</p>
<h2 id="🔍-원인-분석">🔍 원인 분석</h2>
<p>Variable 폰트(<code>.ttf</code>)는 하나의 파일에 여러 weight를 포함하고 있어 편리하지만, <strong>Android의 폰트 렌더링 엔진에서 최적화 문제</strong>를 일으킬 수 있습니다.</p>
<ul>
<li>iOS: Variable 폰트 렌더링 ✅</li>
<li>Android: Variable 폰트 렌더링 ❌ (흐림 현상)</li>
</ul>
<h2 id="✅-해결-방법">✅ 해결 방법</h2>
<h3 id="1-static-폰트-파일-다운로드">1. Static 폰트 파일 다운로드</h3>
<p><a href="https://github.com/orioncactus/pretendard">Pretendard GitHub</a>에서 Static 폰트들을 다운로드합니다.</p>
<pre><code class="language-bash"># assets/fonts/ 폴더에 다운로드
curl -L -o &quot;Pretendard-Light.otf&quot; &quot;https://github.com/orioncactus/pretendard/raw/main/packages/pretendard/dist/otf/Pretendard-Light.otf&quot;
curl -L -o &quot;Pretendard-Regular.otf&quot; &quot;https://github.com/orioncactus/pretendard/raw/main/packages/pretendard/dist/otf/Pretendard-Regular.otf&quot;
curl -L -o &quot;Pretendard-Medium.otf&quot; &quot;https://github.com/orioncactus/pretendard/raw/main/packages/pretendard/dist/otf/Pretendard-Medium.otf&quot;
curl -L -o &quot;Pretendard-SemiBold.otf&quot; &quot;https://github.com/orioncactus/pretendard/raw/main/packages/pretendard/dist/otf/Pretendard-SemiBold.otf&quot;</code></pre>
<h3 id="2-pubspecyaml-수정">2. pubspec.yaml 수정</h3>
<pre><code class="language-yaml">fonts:
  - family: Pretendard
    fonts:
      - asset: assets/fonts/Pretendard-Light.otf
        weight: 300
      - asset: assets/fonts/Pretendard-Regular.otf
        weight: 400
      - asset: assets/fonts/Pretendard-Medium.otf
        weight: 500
      - asset: assets/fonts/Pretendard-SemiBold.otf
        weight: 600</code></pre>
<h3 id="3-프로젝트-클린-빌드">3. 프로젝트 클린 빌드</h3>
<pre><code class="language-bash">flutter clean
flutter pub get
flutter run</code></pre>
<h2 id="🎯-결과">🎯 결과</h2>
<p>!After</p>
<p><strong>Android에서도 선명하고 깔끔한 Pretendard 폰트 렌더링</strong>이 가능해졌습니다!</p>
<h2 id="💡-핵심-포인트">💡 핵심 포인트</h2>
<h3 id="variable-vs-static-폰트-선택-기준">Variable vs Static 폰트 선택 기준</h3>
<table>
<thead>
<tr>
<th>플랫폼</th>
<th>Variable 폰트</th>
<th>Static 폰트</th>
</tr>
</thead>
<tbody><tr>
<td><strong>웹</strong></td>
<td>✅ 권장 (용량 효율)</td>
<td>⚠️ 여러 파일 필요</td>
</tr>
<tr>
<td><strong>Flutter Mobile</strong></td>
<td>❌ 렌더링 이슈</td>
<td>✅ 권장 (안정성)</td>
</tr>
</tbody></table>
<h3 id="추가-최적화-팁">추가 최적화 팁</h3>
<pre><code class="language-dart">// 텍스트 렌더링 최적화
Text(
  &#39;텍스트 내용&#39;,
  style: TextStyle(
    fontFamily: &#39;Pretendard&#39;,
    fontSize: 20.sp,
    fontWeight: FontWeight.w600,
    letterSpacing: -0.5, // 한글 최적화
  ),
  textScaleFactor: 1.0, // 시스템 폰트 크기 무시
)</code></pre>
<h2 id="🔗-참고-자료">🔗 참고 자료</h2>
<ul>
<li><a href="https://github.com/orioncactus/pretendard">Pretendard GitHub</a></li>
<li><a href="https://docs.flutter.dev/cookbook/design/fonts">Flutter 폰트 설정 가이드</a></li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter Android 배포 준비의 핵심: key.properties, keystore, 그리고 서명의 모든 것]]></title>
            <link>https://velog.io/@seungje_labs/Flutter-Android-%EB%B0%B0%ED%8F%AC-%EC%A4%80%EB%B9%84%EC%9D%98-%ED%95%B5%EC%8B%AC-key.properties-keystore-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%84%9C%EB%AA%85%EC%9D%98-%EB%AA%A8%EB%93%A0-%EA%B2%83</link>
            <guid>https://velog.io/@seungje_labs/Flutter-Android-%EB%B0%B0%ED%8F%AC-%EC%A4%80%EB%B9%84%EC%9D%98-%ED%95%B5%EC%8B%AC-key.properties-keystore-%EA%B7%B8%EB%A6%AC%EA%B3%A0-%EC%84%9C%EB%AA%85%EC%9D%98-%EB%AA%A8%EB%93%A0-%EA%B2%83</guid>
            <pubDate>Thu, 07 Aug 2025 08:26:10 GMT</pubDate>
            <description><![CDATA[<h3 id="flutter-앱을-android로-배포하려고-하면-반드시-거치는-단계가-있다">Flutter 앱을 Android로 배포하려고 하면, 반드시 거치는 단계가 있다.</h3>
<p>바로 <code>key.properties</code> 설정과 <code>keystore</code>를 통한 <strong>앱 서명(Signing)</strong> 과정이다.</p>
<p>많은 튜토리얼이 &quot;그냥 이대로 따라하세요&quot;라고 하지만,<br>나는 이번에 배포하면서 <strong>왜 이런 게 필요한지</strong>, <strong>이 구조가 뭘 의미하는지</strong>가 정말 궁금했다.<br>그래서 하나하나 뜯어보며 정리해봤다.  </p>
<hr>
<h2 id="📦-keyproperties가-뭐길래-꼭-만들라고-할까">📦 key.properties가 뭐길래 꼭 만들라고 할까?</h2>
<p>Flutter 공식 가이드에서 Android 배포를 위해 아래와 같은 파일을 만들라고 한다:</p>
<pre><code class="language-properties">storePassword=abc12345
keyPassword=abc12345
keyAlias=upload
storeFile=../upload-keystore.jks</code></pre>
<p>이 파일은 앱 서명에 필요한 비밀번호와 키 정보를 담고 있고,
이를 통해 Gradle이 앱을 릴리즈 모드로 빌드할 때 자동으로 서명할 수 있도록 도와준다.</p>
<p>❗ 중요한 건, 이 파일은 git에 절대 올리지 말아야 하며 .gitignore에 반드시 추가해줘야 한다.</p>
<hr>
<h2 id="🔐-keystorejks는-뭐고-어떻게-만들지">🔐 keystore.jks는 뭐고, 어떻게 만들지?</h2>
<p>서명을 하려면 “키”가 필요하다.
이 키는 keystore.jks 파일 안에 들어있고, 우리가 직접 생성해야 한다.</p>
<pre><code class="language-bash">keytool -genkey -v \
 -keystore upload-keystore.jks \
 -alias upload \
 -keyalg RSA \
 -keysize 2048 \
 -validity 10000</code></pre>
<p>이 명령어는 Java가 설치돼 있으면 터미널에서 바로 실행할 수 있다.</p>
<p>입력값:
    •    keystore 파일명
    •    alias (키 이름)
    •    비밀번호 (2개: storePassword, keyPassword)
    •    유효 기간 등</p>
<hr>
<h2 id="⚙️-buildgradlekts는-왜-필요한가">⚙️ build.gradle.kts는 왜 필요한가?</h2>
<p>key.properties를 만든 것만으로 끝이 아니다.
실제 앱 빌드시 그 정보를 사용하는 코드가 android/app/build.gradle.kts 안에 존재해야 한다.</p>
<pre><code class="language-yml">signingConfigs {
    create(&quot;release&quot;) {
        keyAlias = keystoreProperties[&quot;keyAlias&quot;] as String
        keyPassword = keystoreProperties[&quot;keyPassword&quot;] as String
        storeFile = file(keystoreProperties[&quot;storeFile&quot;] as String)
        storePassword = keystoreProperties[&quot;storePassword&quot;] as String
    }
}</code></pre>
<p>이 설정은 Gradle에게 릴리즈 빌드 시 어떤 키로 서명할지 알려주는 역할을 한다.</p>
<hr>
<h2 id="🛠️-릴리즈-빌드할-때-서명은-어떻게-동작할까">🛠️ 릴리즈 빌드할 때 서명은 어떻게 동작할까?</h2>
<pre><code>flutter build apk --release</code></pre><p>이 명령어를 실행하면, Flutter는 내부적으로 Gradle을 실행하고
signingConfigs.release 설정을 참조해서 앱을 keystore.jks로 서명한다.</p>
<h4 id="🔍-반대로-디버그-빌드-flutter-run-flutter-build-apk---debug-는-android가-자체-제공하는-디버그-키로-자동-서명된다">🔍 반대로 디버그 빌드 (flutter run, flutter build apk --debug) 는 Android가 자체 제공하는 디버그 키로 자동 서명된다.</h4>
<hr>
<h2 id="🔄-google-play가-서명을-대신-해준다는데-왜-내가-keystore를-만들어야-할까">🔄 Google Play가 서명을 대신 해준다는데… 왜 내가 keystore를 만들어야 할까?</h2>
<p>key.properties를 세팅하다가, 이번에 우연히 Google Play App Signing이라는 개념을 처음 제대로 알게 됐다.
‘어? 구글이 알아서 서명까지 해준다고? 그럼 내가 굳이 keystore 만들 필요 없지 않나?’ 싶었다.</p>
<p>하지만 이걸 알아보면 볼수록, 앱 서명의 흐름과 키 관리의 중요성에 대해 반드시 이해해야겠다는 생각이 들었다.</p>
<p>💡 App Signing 구조 이해</p>
<p>Google Play는 앱을 대신 서명해주는 구조를 제공한다.
하지만 여전히 우리가 만든 keystore.jks는 필요하다.</p>
<p>왜냐하면…</p>
<p>용어    설명
업로드 키 (Upload key)    우리가 직접 만든 키. 이걸로 서명된 APK만 Google Play에 업로드 가능
서명 키 (App Signing key)    Google이 따로 보관하는 키. 실제 사용자에게 배포되는 APK는 이 키로 다시 서명됨</p>
<p>즉,
    •    우리가 만든 키는 앱을 Google Play에 업로드하는 권한을 가진 인증서 역할
    •    Google은 실제 배포 전에 서명 키로 다시 서명해서 배포</p>
<h3 id="🔐-중요한-점-업로드-키를-분실하면">🔐 중요한 점: 업로드 키를 분실하면?</h3>
<p>➡️ Google에 업로드 자체가 불가능하다.(재발급 가능!)
➡️ 앱을 다시 새로 올려야 하는 경우도 발생.</p>
<p>따라서 이 키는 GitHub에 올리지 않되, 꼭 안전한 곳에 백업해두는 것이 중요하다.</p>
<hr>
<h2 id="🧾-해시-키sha-1-sha-256는-왜-필요한가">🧾 해시 키(SHA-1, SHA-256)는 왜 필요한가?</h2>
<p>앱이 서명되면, 서명된 키로부터 해시 값(SHA-1, SHA-256 등) 을 뽑아낼 수 있다.
이 값은 외부 인증 연동 시 꼭 필요하다.</p>
<p>🔐 주요 사용처
    •    Google 로그인, Firebase 인증
    •    카카오 / 네이버 로그인
    •    Facebook 로그인</p>
<p>🔧 출력 방법</p>
<pre><code class="language-bash">keytool -exportcert \
  -alias upload \
  -keystore upload-keystore.jks \
  -storepass abc12345 \
  -keypass abc12345 \
  | openssl sha1 -binary | openssl base64</code></pre>
<p>결과로 나오는 SHA1: 또는 SHA256: 값을 콘솔에 복사해서, Firebase 콘솔이나 카카오 디벨로퍼에 등록하면 된다.</p>
<hr>
<p>🔒 key.properties 보안 관리 팁
    •    .gitignore에 반드시 추가
    •    key.properties.template 같은 샘플은 공유 가능
    •    CI/CD(GitHub Actions, Bitrise 등)에서는 secrets 기능을 이용해 주입</p>
<p>예: GitHub Actions에서 환경 변수로 등록 후, key.properties를 런타임에 생성</p>
<hr>
<p>✅ 마무리하며</p>
<p>Flutter로 Android 앱을 배포할 때 반드시 알아야 할 것들:</p>
<p>항목    설명
keystore.jks    내가 만든 서명 키 저장소
key.properties    민감한 정보를 안전하게 분리
build.gradle.kts    릴리즈 빌드 시 서명에 적용
Google Play App Signing    실제 배포는 Google이 서명하되, Upload Key는 필요
해시 키 출력    인증 연동 필수 정보
보안 관리    백업 + 비공개 저장 + CI/CD 연동 고려</p>
<p>단순히 “하라는 대로 따라하기”를 넘어서,
서명의 구조를 이해하고, 리스크를 줄이며, 제대로 된 배포를 할 수 있는 개발자가 되자. 🧠</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[일을 나누는 게 아니라, 시야를 나누는 것!]]></title>
            <link>https://velog.io/@seungje_labs/%ED%94%8C%EB%9E%AB%ED%8F%BC%EC%9D%84-%ED%98%BC%EC%9E%90-%EB%A7%8C%EB%93%A4%EB%A9%B0-%EA%B9%A8%EB%8B%AC%EC%9D%80-%EA%B2%83</link>
            <guid>https://velog.io/@seungje_labs/%ED%94%8C%EB%9E%AB%ED%8F%BC%EC%9D%84-%ED%98%BC%EC%9E%90-%EB%A7%8C%EB%93%A4%EB%A9%B0-%EA%B9%A8%EB%8B%AC%EC%9D%80-%EA%B2%83</guid>
            <pubDate>Tue, 22 Jul 2025 07:15:51 GMT</pubDate>
            <description><![CDATA[<h1 id="오픈플랜을-시작하며">오픈플랜을 시작하며</h1>
<p>요즘은 나 자신에게 자주 묻는다.<br>지금 나는 어떤 걸 만들고 싶을까?<br>누구와 만들고 싶을까?</p>
<p>10년 동안 개발자로 일해왔다.<br>수많은 프로젝트를 겪고, 크고 작은 서비스를 만들었다.<br>하지만 언젠가부터<br>‘어떻게’ 만드는지는 익숙해졌는데,<br>‘왜’ 만드는지에 대한 감각은 흐려지고 있었다.</p>
<hr>
<p>얼마 전, 플랫폼 서비스를 직접 만들어본 일이 있다.<br>기획, 설계, 마케팅, 영업까지 —<br>모든 걸 혼자 해보는 도전이었다.<br>잘 해내고 싶었다. 진짜로 끝까지 가보고 싶었다.</p>
<p>그래서 밤을 새우며 기획서를 쓰고,<br>디자인 툴을 독학하고,<br>SNS 광고 문구를 고치고 또 고쳤다.<br>개발은 익숙했지만,<br>그 외의 것들은 낯설고 버거웠다.</p>
<hr>
<p>그 과정에서 다행히 두 명의 지인이 도와줬다.<br>한 명은 디자인을, 또 한 명은 기획을 함께 봐줬다.<br>둘 다 본업이 있는 사람들이었지만,<br>틈틈이 시간을 내어 함께 회의하고, 조언도 아끼지 않았다.</p>
<p>그때 처음 알게 됐다.<br><strong>혼자서 할 수 없다는 건 ‘일의 양’ 때문만은 아니라는 걸.</strong></p>
<p>함께하면,<br>같은 문제도 다른 각도로 바라보게 되고<br>놓치고 있던 흐름이 문득 보이기도 했다.<br><strong>일을 나눈 게 아니라, 시야를 나눈 것</strong>이었다.</p>
<hr>
<p>그 경험이 나를 바꿨다.<br>‘혼자 만드는 것’이 아니라<br>‘여럿이 함께 탐색하는 실험’에 마음이 끌리기 시작했다.</p>
<p>그래서 오픈플랜이라는 이름을 붙였다.<br>어떤 거창한 철학이 담긴 건 아니다.<br>그냥, 열린 계획.<br>여러 사람의 시선이 모이는 작은 실험실 같은 모임.</p>
<hr>
<p>내가 만든 글 하나로 몇 명이 모였다.<br>직업도, 관심사도, 말투도 다 달랐다.<br>누군가는 UI/UX를 탐구하고 싶다고 했고,<br>누군가는 데이터를 만지고 싶다고 했고,<br>누군가는 “왜 이런 서비스는 아직 없을까?” 하는 질문을 품고 있었다.</p>
<p>그게 좋았다.<br>전문가가 아니어도,<br>공부 중이거나 막 관심이 생긴 단계여도 괜찮았다.<br>그 다양한 시선들이,<br>내가 혼자선 보지 못한 것들을 보여줬다.</p>
<hr>
<p>우리는 아직 아무것도 확실하지 않다.<br>어떤 플랫폼을 만들지,<br>어디까지 갈 수 있을지,<br>우리도 잘 모른다.</p>
<p>하지만 이 모임은 분명<br>오래 닫혀 있던 나의 호기심을 다시 열었고,<br>머릿속에만 맴돌던 아이디어에 온기를 불어넣었다.</p>
<hr>
<p>이 글은 그 시작의 기록이다.<br>누군가 이 글을 읽고,<br>비슷한 고민을 하고 있었다면 —<br>언젠가 우리는 같은 테이블에 앉아 있을지도 모른다.</p>
<p>그럼, 다음 이야기는 또 다음에.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Flutter로 첫 앱 프로젝트 만들기 💡]]></title>
            <link>https://velog.io/@seungje_labs/Flutter%EB%A1%9C-%EC%B2%AB-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@seungje_labs/Flutter%EB%A1%9C-%EC%B2%AB-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 16 Jul 2025 13:19:09 GMT</pubDate>
            <description><![CDATA[<p>Flutter로 앱 개발을 시작하려면 가장 먼저 해야 할 일은?
바로 <code>flutter create</code> 명령어를 통해 프로젝트를 생성하는 것이다.
이 글에서는 이 명령어의 원리와 옵션들을 예시와 함께 정리해본다 ✍️</p>
<hr>
<h2 id="✅-기본-사용법">✅ 기본 사용법</h2>
<pre><code class="language-bash">flutter create 프로젝트명</code></pre>
<p>예:</p>
<pre><code class="language-bash">flutter create byseungjeapp</code></pre>
<p>위 명령어 하나로 아래와 같은 멀티 플랫폼 프로젝트가 생성된다:</p>
<ul>
<li>Android</li>
<li>iOS</li>
<li>웹</li>
<li>macOS</li>
<li>Windows (설정 시)</li>
</ul>
<p>하지만 여기서 진짜 중요한 건 <strong>&quot;이 명령어가 무엇을 만들어 주느냐&quot;</strong> 이다.</p>
<hr>
<h2 id="📦-flutter-create가-실제로-생성하는-것">📦 flutter create가 실제로 생성하는 것</h2>
<p><code>flutter create</code> 명령어는 단순히 파일을 생성하는 수준이 아니다.
Flutter SDK 내부 템플릿 시스템을 기반으로 다음을 자동 구성한다:</p>
<ul>
<li><code>android/</code>, <code>ios/</code>: 플랫폼별 네이티브 프로젝트 (Gradle, Xcode 기반)</li>
<li><code>lib/main.dart</code>: 앱 진입점 (Flutter 앱의 시작 지점)</li>
<li><code>test/</code>: 테스트 디렉토리</li>
<li><code>pubspec.yaml</code>: 의존성, 앱 정보, 에셋 관리 파일</li>
<li><code>.gitignore</code>, <code>README.md</code>, <code>analysis_options.yaml</code> 등 개발 편의 파일</li>
</ul>
<p>즉, <strong>Flutter 앱을 만들기 위한 골격을 완성해주는 작업</strong>이다.</p>
<hr>
<h2 id="🔧-자주-쓰는-옵션-정리">🔧 자주 쓰는 옵션 정리</h2>
<p>앱을 생성하면서 한 번에 설정하면 좋은 옵션들을 정리했다 👇</p>
<h3 id="📌---org">📌 <code>--org</code></h3>
<p>앱의 패키지 이름을 정한다.
보통 도메인을 반대로 써서 고유 식별자를 만든다.</p>
<pre><code class="language-bash">--org com.byseungje</code></pre>
<p>💡 결과: <code>com.byseungje.byseungjeapp</code> 같은 형태의 패키지 ID 생성
👉 Android의 <code>applicationId</code>, iOS의 <code>bundleIdentifier</code>에 영향을 준다</p>
<hr>
<h3 id="📌---project-name">📌 <code>--project-name</code></h3>
<p>프로젝트 이름 지정 (디렉토리명 = 앱명 기준).
⚠️ 소문자와 <code>_</code>(언더스코어)만 허용됨</p>
<pre><code class="language-bash">--project-name byseungjeapp</code></pre>
<p>💡 CLI 내부에서 이 값이 class 이름, 디렉토리 명 등에 영향을 줌</p>
<hr>
<h3 id="📌---description">📌 <code>--description</code></h3>
<p>앱 설명. <code>pubspec.yaml</code>에 자동 반영된다.
(패키지로 배포할 때 중요한 메타 정보!)</p>
<pre><code class="language-bash">--description &quot;flutter를 생성하는 명령어에 대한 설명!&quot;</code></pre>
<hr>
<h3 id="📌---platforms">📌 <code>--platforms</code></h3>
<p>필요한 플랫폼만 선택해서 생성 가능
필요 없는 플랫폼을 제거하면 불필요한 리소스와 설정을 줄일 수 있다.</p>
<pre><code class="language-bash">--platforms=android,ios</code></pre>
<p>💡 예: macOS, 웹을 지원하지 않을 경우 명시적으로 제외하는 것이 좋음</p>
<hr>
<h2 id="💡-실제-사용-예">💡 실제 사용 예</h2>
<p>나는 아래와 같이 명령어를 사용해서 프로젝트를 생성했다:</p>
<pre><code class="language-bash">flutter create \
  --org com.byseungje \
  --project-name byseungjeapp \
  --description &quot;flutter를 생성하는 명령어에 대한 설명!&quot; \
  --platforms=android,ios \
  byseungjeapp</code></pre>
<hr>
<h2 id="📁-생성된-디렉토리-구조">📁 생성된 디렉토리 구조</h2>
<p>실행하면 아래와 같은 디렉토리 구조가 생성된다:</p>
<pre><code>byseungjeapp/
├── android/          # 안드로이드 네이티브 프로젝트
├── ios/              # iOS 네이티브 프로젝트
├── lib/              # Flutter 코드 (main.dart 위치)
├── test/             # 테스트 코드
└── pubspec.yaml      # 앱 설정과 의존성 관리</code></pre><hr>
<h2 id="🧠-왜-이런-옵션들이-중요한가">🧠 왜 이런 옵션들이 중요한가?</h2>
<p>생성형 AI 시대에 단순 명령어는 쉽게 찾을 수 있다.
하지만 <strong>왜 그 명령어를 써야 하는지</strong>, <strong>어떤 설정이 앱의 구조에 어떤 영향을 주는지</strong>를 아는 건 여전히 중요하다.</p>
<ul>
<li><code>--org</code>: 나중에 앱 배포 시 충돌 방지 및 고유 식별</li>
<li><code>--project-name</code>: 앱 코드의 기본 네이밍 컨벤션과 연결</li>
<li><code>--description</code>: 패키지 관리와 메타데이터 관리를 위해</li>
<li><code>--platforms</code>: 빌드 시간과 앱 크기 최적화</li>
</ul>
<hr>
<h2 id="✨-마무리">✨ 마무리</h2>
<p><code>flutter create</code>는 단순한 시작 명령이 아니다.
앱 구조, 플랫폼, 패키지 ID 같은 중요한 <strong>기초 설계</strong>를 설정하는 단계다.</p>
<p>🚀 처음부터 정확히 설정하면 나중에 수정할 일이 줄어든다.
🎯 특히 <code>--org</code>, <code>--platforms</code>는 나중에 변경하기 복잡하니 신중하게 설정하자!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[HTTP 요청 헤더로 디바이스 정보 구분하기]]></title>
            <link>https://velog.io/@seungje_labs/HTTP-%EC%9A%94%EC%B2%AD-%ED%97%A4%EB%8D%94%EB%A1%9C-%EB%94%94%EB%B0%94%EC%9D%B4%EC%8A%A4-%EC%A0%95%EB%B3%B4-%EA%B5%AC%EB%B6%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@seungje_labs/HTTP-%EC%9A%94%EC%B2%AD-%ED%97%A4%EB%8D%94%EB%A1%9C-%EB%94%94%EB%B0%94%EC%9D%B4%EC%8A%A4-%EC%A0%95%EB%B3%B4-%EA%B5%AC%EB%B6%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 22 May 2025 08:17:33 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>“서버는 어떻게 요청이 앱에서 왔는지, 웹에서 왔는지 알 수 있을까?”</p>
</blockquote>
<p>최근 프로젝트에서 <strong>요청을 구분해서 로깅하거나 통계 처리</strong>를 하다 보니, 사용자 디바이스 종류(iOS/Android/Web 등)를 서버에서 식별할 필요가 생겼습니다. 이럴 때 핵심적으로 사용하는 게 바로 <strong>HTTP 헤더</strong>입니다.</p>
<hr>
<h2 id="1-http-헤더란">1. HTTP 헤더란?</h2>
<p>HTTP 헤더는 클라이언트와 서버 간의 요청/응답에서 <strong>부가 정보를 담는 메타데이터</strong>입니다. 모든 HTTP 요청은 헤더와 함께 오고, 이 정보를 기반으로 서버는 다양한 처리를 할 수 있습니다.</p>
<pre><code>GET /hello HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)
Authorization: Bearer eyJhbGciOi...</code></pre><hr>
<h2 id="2-주요-요청-헤더">2. 주요 요청 헤더</h2>
<table>
<thead>
<tr>
<th>헤더 키</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>User-Agent</code></td>
<td>브라우저나 앱의 종류 및 OS 정보</td>
</tr>
<tr>
<td><code>Authorization</code></td>
<td>JWT, OAuth 토큰 등 인증 정보</td>
</tr>
<tr>
<td><code>Content-Type</code></td>
<td>전송 데이터 형식 (예: JSON)</td>
</tr>
<tr>
<td><code>Accept</code></td>
<td>클라이언트가 받을 수 있는 데이터 타입</td>
</tr>
</tbody></table>
<hr>
<h2 id="3-user-agent로-웹앱-구분하기">3. <code>User-Agent</code>로 웹/앱 구분하기</h2>
<p><code>User-Agent</code>에는 클라이언트 정보가 포함되어 있어, <strong>간단한 문자열 비교만으로 디바이스 종류를 구분</strong>할 수 있습니다.</p>
<p>예:</p>
<pre><code class="language-java">String ua = request.getHeader(&quot;User-Agent&quot;);

if (ua.contains(&quot;Dart&quot;) || ua.contains(&quot;PostmanRuntime&quot;)) {
    return &quot;앱 요청&quot;;
} else if (ua.contains(&quot;Mozilla&quot;) || ua.contains(&quot;Chrome&quot;) || ua.contains(&quot;Safari&quot;)) {
    return &quot;웹 요청&quot;;
}</code></pre>
<blockquote>
<p>참고: Flutter에서 Dio로 요청하면 <code>Dart</code> 문자열이 포함됩니다.</p>
</blockquote>
<hr>
<h2 id="4-더-정밀한-기기-구분-커스텀-헤더">4. 더 정밀한 기기 구분: 커스텀 헤더</h2>
<p><code>User-Agent</code> 만으로는 iOS/Android/웹 정도만 구분할 수 있고, <strong>기기 모델명이나 앱 버전은 알 수 없습니다.</strong><br>이럴 때는 <strong>앱에서 커스텀 HTTP 헤더</strong>를 만들어서 보내면 됩니다.</p>
<h3 id="flutter-예시">Flutter 예시</h3>
<pre><code class="language-dart">final dio = Dio();
dio.options.headers[&quot;X-Device-Model&quot;] = &quot;iPhone13,4&quot;;
dio.options.headers[&quot;X-App-Version&quot;] = &quot;1.2.3&quot;;</code></pre>
<h3 id="서버-spring-boot">서버 (Spring Boot)</h3>
<pre><code class="language-java">String model = request.getHeader(&quot;X-Device-Model&quot;);
String version = request.getHeader(&quot;X-App-Version&quot;);</code></pre>
<p>이렇게 하면 로그나 통계 시스템에서 <strong>앱 버전별 이슈 파악</strong>, <strong>기기별 오류 분석</strong>도 가능해져 실무에서 매우 유용합니다.</p>
<hr>
<h2 id="5-실무-팁">5. 실무 팁</h2>
<ul>
<li>모든 API 요청에 <code>X-Platform-Type</code>, <code>X-App-Version</code>, <code>X-Device-Model</code> 같은 헤더를 포함하세요.</li>
<li>디바이스 분류 (<code>0: 앱</code>, <code>1: 웹</code>, <code>-1: 미분류</code>) 정도는 공통 유틸로 분리해 관리하면 편리합니다.</li>
<li>로그 시스템에 이 정보를 함께 저장하면 사용자 경험 개선에 큰 도움이 됩니다.</li>
</ul>
<hr>
<h2 id="마무리">마무리</h2>
<p>서버는 기본적으로 사용자의 디바이스 정보를 <strong>직접 알 수 없습니다.</strong><br>하지만 HTTP 헤더를 잘 활용하면 요청을 구분하고, 디바이스 특성에 맞게 처리하는 유연한 백엔드 시스템을 만들 수 있습니다.</p>
]]></description>
        </item>
    </channel>
</rss>