<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>ho_d97.log</title>
        <link>https://velog.io/</link>
        <description>기록하면서 레베럽</description>
        <lastBuildDate>Tue, 05 May 2026 09:02:17 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>ho_d97.log</title>
            <url>https://velog.velcdn.com/images/ho_d97/profile/3fd04768-2991-4afb-92c4-ff71681bd97a/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. ho_d97.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/ho_d97" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[EC2 단일 서버에서 Blue-Green 무중단 배포 구현하기 — 4편: MOA 운영 적용기]]></title>
            <link>https://velog.io/@ho_d97/EC2-%EB%8B%A8%EC%9D%BC-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-Blue-Green-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-4%ED%8E%B8-MOA-%EC%9A%B4%EC%98%81-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
            <guid>https://velog.io/@ho_d97/EC2-%EB%8B%A8%EC%9D%BC-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-Blue-Green-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-4%ED%8E%B8-MOA-%EC%9A%B4%EC%98%81-%EC%A0%81%EC%9A%A9%EA%B8%B0</guid>
            <pubDate>Tue, 05 May 2026 09:02:17 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>POC 기반으로 MOA 서비스에 적용해보기
<a href="https://apps.apple.com/kr/app/moa-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%9B%94%EA%B8%89-%EC%B2%B4%EA%B0%90/id6759603878">IOS_MOA 설치</a>
[안드로이드 MOA 설치] (<a href="https://play.google.com/store/apps/details?id=com.moa.salary.app&amp;hl=ko">https://play.google.com/store/apps/details?id=com.moa.salary.app&amp;hl=ko</a>)</p>
</blockquote>
<hr>
<h2 id="1-배포-전략-왜-한번에-안-했는가">1. 배포 전략: 왜 한번에 안 했는가</h2>
<p>3편에서 Phase 1(스케줄러 안전장치)과 Phase 2(Blue-Green 인프라)를 <strong>분리 배포</strong>하기로 결정했었다.</p>
<pre><code>Phase 1 배포 → 2~3일 관찰 → Phase 2 배포</code></pre><p>한 PR로 합치면 빠르겠지만, 실제로 문제가 생겼을 때 <strong>원인 특정이 어려워진다</strong>. ShedLock 설정 문제인지, Nginx 전환 문제인지, deploy.sh 문제인지. 특히 ShedLock은 매일 00:00, 03:00에 돌아가는 배치에 적용되니까, 최소 하루는 운영에서 돌려봐야 확인 가능하다.</p>
<hr>
<h2 id="2-phase-1-적용--shedlock--graceful-shutdown">2. Phase 1 적용 — ShedLock + Graceful Shutdown</h2>
<h3 id="2-1-변경-사항">2-1. 변경 사항</h3>
<table>
<thead>
<tr>
<th>파일</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td><code>build.gradle.kts</code></td>
<td>ShedLock 의존성 2개 추가</td>
</tr>
<tr>
<td><code>SchedulingConfig.kt</code></td>
<td><code>@EnableSchedulerLock</code> + <code>LockProvider</code> Bean + <code>TaskScheduler</code> Bean</td>
</tr>
<tr>
<td>3개 스케줄러</td>
<td><code>@SchedulerLock</code> 어노테이션 추가</td>
</tr>
<tr>
<td><code>application-prod.yml</code></td>
<td><code>server.shutdown: graceful</code> + actuator health 노출</td>
</tr>
<tr>
<td>운영 MySQL</td>
<td><code>shedlock</code> 테이블 DDL 수동 실행</td>
</tr>
</tbody></table>
<h3 id="2-2-shedlock-설정-핵심">2-2. ShedLock 설정 핵심</h3>
<pre><code class="language-kotlin">@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = &quot;PT5M&quot;)
class SchedulingConfig {

    @Bean
    fun lockProvider(dataSource: DataSource): LockProvider =
        JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(JdbcTemplate(dataSource))
                .usingDbTime()
                .build()
        )

    @Bean
    fun taskScheduler(): TaskScheduler =
        ThreadPoolTaskScheduler().apply {
            poolSize = 2
            setThreadNamePrefix(&quot;scheduler-&quot;)
            setWaitForTasksToCompleteOnShutdown(true)
            setAwaitTerminationSeconds(60)
        }
}</code></pre>
<p>여기서 <code>.usingDbTime()</code>은 <strong>DB 서버의 <code>NOW()</code> 함수를 기준으로 락 시간을 계산</strong>한다는 뜻이다. Java의 <code>System.currentTimeMillis()</code> 대신 DB 시간을 쓰면, Blue/Green 두 컨테이너의 시스템 시계가 미세하게 어긋나더라도 <strong>같은 DB의 같은 시간 기준으로 경쟁</strong>하니까 안전하다.</p>
<p><code>setWaitForTasksToCompleteOnShutdown(true)</code>는 3편에서 다뤘던 &quot;Graceful Shutdown이 @Scheduled를 보호하지 않는 문제&quot;의 해결책이다. SIGTERM이 들어오면 실행 중인 스케줄러 작업이 끝날 때까지 최대 60초를 기다려준다.</p>
<h3 id="2-3-스케줄러별-락-파라미터">2-3. 스케줄러별 락 파라미터</h3>
<pre><code class="language-kotlin">// 매 1분 실행 — 55초 이내에 락 만료, 최소 30초 유지
@SchedulerLock(name = &quot;dispatchNotifications&quot;, lockAtMostFor = &quot;55s&quot;, lockAtLeastFor = &quot;30s&quot;)

// 매일 1회 실행 — 최대 2분 유지, 최소 1분 유지
@SchedulerLock(name = &quot;createDailyNotifications&quot;, lockAtMostFor = &quot;2m&quot;, lockAtLeastFor = &quot;1m&quot;)</code></pre>
<p><code>lockAtMostFor</code>를 cron 주기보다 짧게 잡는 게 포인트다. 1분 cron에 <code>lockAtMostFor: 60s</code>면 경계에서 다음 실행과 겹칠 수 있다. 55초로 잡으면 5초의 여유가 생긴다.</p>
<h3 id="2-4-pr-리뷰에서-받은-피드백">2-4. PR 리뷰에서 받은 피드백</h3>
<p>PR을 올렸더니 GitHub Copilot 리뷰에서 2가지를 지적받았다.</p>
<p><strong>지적 1: ShedLock 테이블 생성 스크립트가 없다</strong></p>
<blockquote>
<p>&quot;JdbcTemplateLockProvider는 shedlock 테이블이 DB에 존재해야 합니다. 이 PR에 마이그레이션이나 init 스크립트가 없어서 런타임에 실패할 수 있습니다.&quot;</p>
</blockquote>
<p>운영 DB에는 이미 수동으로 DDL을 실행해둔 상태였다. 하지만 로컬(H2)에서는 테이블이 없어서 개발 환경에서 스케줄러가 에러를 뱉는다.</p>
<p><code>application-local.yml</code>에 H2 전용 schema 초기화를 추가해서 해결했다.</p>
<pre><code class="language-yaml"># application-local.yml
spring:
  sql:
    init:
      mode: always
      schema-locations: classpath:db/local/schema.sql</code></pre>
<pre><code class="language-sql">-- src/main/resources/db/local/schema.sql
CREATE TABLE IF NOT EXISTS shedlock (
    name VARCHAR(64) NOT NULL PRIMARY KEY,
    lock_until TIMESTAMP(3) NOT NULL,
    locked_at TIMESTAMP(3) NOT NULL,
    locked_by VARCHAR(255) NOT NULL
);</code></pre>
<p><code>application-local.yml</code>에서만 경로를 지정하니까 <strong>운영(prod)에서는 절대 실행되지 않는다.</strong></p>
<p><strong>지적 2: cron 표현식이 바뀌었다</strong></p>
<blockquote>
<p>&quot;cron이 <code>0 20 0 * * *</code>에서 <code>0 0 0 * * *</code>로 변경됐습니다. 분산락 추가 PR인데 스케줄 변경이 섞여있으니 의도적인지 확인해주세요.&quot;</p>
</blockquote>
<p>의도한 변경이었지만, <strong>PR의 범위가 섞인 건 맞는 지적이었다.</strong> 한 PR에 &quot;분산락 추가&quot;와 &quot;스케줄 시간 변경&quot;이 동시에 들어가면, 나중에 문제 생겼을 때 어떤 변경 때문인지 추적이 어려워진다. 앞으로는 이런 부분을 분리해서 커밋해야겠다.</p>
<hr>
<h2 id="3-트러블슈팅-shedlock-타임존-9시간-밀림">3. 트러블슈팅: ShedLock 타임존 9시간 밀림</h2>
<p>Phase 1 배포 후 shedlock 테이블을 확인했는데, 이상한 게 보였다.</p>
<h3 id="3-1-증상">3-1. 증상</h3>
<pre><code class="language-sql">SELECT NOW() AS db_now, name, locked_at, lock_until FROM shedlock;</code></pre>
<pre><code>db_now                  name                       locked_at                  lock_until
2026-04-17 07:36:06     dispatchNotifications      2026-04-16 22:36:00.001    2026-04-16 22:36:30.001
2026-04-17 07:36:06     createPaydayNotifications  2026-04-16 18:00:00.001    2026-04-16 18:01:00.001
2026-04-17 07:36:06     createDailyNotifications   2026-04-16 15:00:00.001    2026-04-16 15:01:00.001</code></pre><p><code>NOW()</code>는 한국 시간 07:36인데, <code>locked_at</code>은 전날 22:36이다. <strong>정확히 9시간 차이</strong>. UTC로 저장된 거다.</p>
<p>KST로 변환해보면 3개 다 맞다:</p>
<table>
<thead>
<tr>
<th>스케줄러</th>
<th>locked_at (DB)</th>
<th>KST 변환</th>
<th>cron</th>
<th>일치?</th>
</tr>
</thead>
<tbody><tr>
<td>dispatchNotifications</td>
<td>04-16 22:36</td>
<td><strong>04-17 07:36</strong></td>
<td>매 분</td>
<td>✅</td>
</tr>
<tr>
<td>createPaydayNotifications</td>
<td>04-16 18:00</td>
<td><strong>04-17 03:00</strong></td>
<td>03:00</td>
<td>✅</td>
</tr>
<tr>
<td>createDailyNotifications</td>
<td>04-16 15:00</td>
<td><strong>04-17 00:00</strong></td>
<td>00:00</td>
<td>✅</td>
</tr>
</tbody></table>
<p>스케줄러는 정확한 시간에 실행되고 있었다. <strong>저장되는 시간만 UTC인 것.</strong></p>
<h3 id="3-2-원인-타임존이-레이어마다-다르다">3-2. 원인: 타임존이 레이어마다 다르다</h3>
<p>처음에는 &quot;Dockerfile에서 <code>-Duser.timezone=Asia/Seoul</code> 설정했으니까 다 KST 아닌가?&quot; 싶었다.
<strong>근데 아니었다.</strong></p>
<pre><code>┌─ Docker 컨테이너 ──────────────────────────────┐
│                                              │
│  OS (Alpine Linux): 타임존 = UTC               │
│                                              │
│  JVM: -Duser.timezone=Asia/Seoul             │
│    → LocalDateTime.now() = KST ✅            │
│    → @Scheduled cron 해석 = KST ✅            │
│    → 로그 시간 = KST ✅                        │
│                                              │
│  JDBC Driver: serverTimezone=Asia/Seoul      │
│    → Java↔MySQL 값 변환 기준만 설정              │
│    → MySQL 세션 타임존은 건드리지 않음 ❌           │
│                                              │
│  MySQL 세션:                                  │
│    → time_zone = SYSTEM = UTC                │
│    → NOW() = UTC ← ★ 여기가 9시간 차이의 원인     │
│                                              │
└──────────────────────────────────────────────┘</code></pre><p>핵심은 <strong>3개의 타임존 설정이 각각 다른 레이어에서 독립적으로 동작한다</strong>는 것이다.</p>
<table>
<thead>
<tr>
<th>설정</th>
<th>레이어</th>
<th>영향 범위</th>
</tr>
</thead>
<tbody><tr>
<td><code>-Duser.timezone=Asia/Seoul</code></td>
<td>JVM</td>
<td>Java 코드 내부의 시간 처리</td>
</tr>
<tr>
<td><code>serverTimezone=Asia/Seoul</code></td>
<td>JDBC 드라이버</td>
<td>Java↔MySQL 값 변환 기준</td>
</tr>
<tr>
<td><code>@@session.time_zone</code></td>
<td>MySQL 세션</td>
<td><code>NOW()</code> 함수의 반환값</td>
</tr>
</tbody></table>
<p><strong><code>-Duser.timezone</code>은 JVM 프로세스 메모리 안에서만 유효하다.</strong> JVM이 MySQL에 TCP 연결을 맺을 때, 이 설정은 MySQL 서버로 전달되지 않는다.</p>
<p><strong><code>serverTimezone=Asia/Seoul</code>은 MySQL 세션 타임존을 바꾸지 않는다.</strong>
JDBC 드라이버가 MySQL에서 TIMESTAMP 값을 읽고 쓸 때 &quot;이 값은 Asia/Seoul 기준이야&quot;라고 해석하는 힌트일 뿐이다. MySQL에 <code>SET time_zone</code> 같은 명령을 보내지 않는다.</p>
<p>결국 ShedLock이 <code>.usingDbTime()</code>으로 <code>NOW(3)</code>을 호출하면, <strong>Java 앱의 MySQL 세션 타임존(UTC)</strong>이 적용되어 UTC 값이 저장된다.</p>
<h3 id="3-3-기능-문제는-없다">3-3. 기능 문제는 없다</h3>
<p>ShedLock의 락 로직은 이렇게 동작한다:</p>
<pre><code class="language-sql">-- 락 획득 시도 (Java 앱 세션 = UTC)
SELECT * FROM shedlock WHERE name = &#39;dispatch...&#39; AND lock_until &lt;= NOW(3)

-- 락 갱신 (Java 앱 세션 = UTC)
UPDATE shedlock SET lock_until = NOW(3) + INTERVAL 55 SECOND WHERE ...</code></pre>
<p>READ와 WRITE가 <strong>같은 UTC 세션</strong>에서 실행되니까 비교가 정확하다. Blue/Green 두 컨테이너도 같은 MySQL에 같은 UTC 세션으로 붙으니까 락 경쟁에도 문제없다.</p>
<h3 id="3-4-판단-고치지-않는다">3-4. 판단: 고치지 않는다</h3>
<p>해결 방법은 3가지가 있었다:</p>
<ol>
<li><strong>조회할 때 <code>CONVERT_TZ</code>로 변환</strong> — 가장 안전, 코드 변경 없음</li>
<li><strong>JDBC URL에 <code>forceConnectionTimeZoneToSession=true</code> 추가</strong> — 근본 해결이지만 JPA의 다른 TIMESTAMP 컬럼에도 영향</li>
<li><strong>MySQL 서버 타임존 변경</strong> — 가장 파급 범위가 넓어서 위험</li>
</ol>
<p>&quot;보기 불편하다&quot;와 &quot;동작이 잘못됐다&quot;는 다른 문제다. ShedLock 동작에 문제가 없는데 타임존을 건드리면 <strong>다른 엔티티의 <code>createdAt</code>, <code>updatedAt</code> 등 모든 TIMESTAMP 컬럼에 영향</strong>을 줄 수 있다. Blue-Green 배포라는 원래 목표에 집중하기로 하고, 타임존 정리는 별도 작업으로 분리했다.</p>
<hr>
<h2 id="4-phase-1-운영-관찰">4. Phase 1 운영 관찰</h2>
<p>배포 후 2일간 모니터링했다.</p>
<pre><code class="language-sql">SELECT * FROM shedlock;</code></pre>
<table>
<thead>
<tr>
<th>확인 항목</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>dispatchNotifications (매 1분)</td>
<td>매 분 <code>locked_at</code> 갱신 확인 ✅</td>
</tr>
<tr>
<td>createDailyNotifications (00:00)</td>
<td>다음 날 아침 기록 확인 ✅</td>
</tr>
<tr>
<td>createPaydayNotifications (03:00)</td>
<td>다음 날 아침 기록 확인 ✅</td>
</tr>
<tr>
<td><code>/actuator/health</code></td>
<td><code>{&quot;status&quot;:&quot;UP&quot;}</code> ✅</td>
</tr>
</tbody></table>
<p>3개 스케줄러 모두 정상 동작. Phase 2 진행 가능으로 판단했다.</p>
<hr>
<h2 id="5-phase-2-적용--blue-green-인프라">5. Phase 2 적용 — Blue-Green 인프라</h2>
<h3 id="5-1-서버-세팅">5-1. 서버 세팅</h3>
<p>EC2 서버에서 직접 실행한 명령어들:</p>
<pre><code class="language-bash"># 상태 파일 생성 — 현재 blue가 돌고 있으므로 blue로 시작
echo &quot;blue&quot; | sudo tee /home/ubuntu/app/active-color
sudo touch /home/ubuntu/app/deploy-history

# Nginx upstream 파일 추가 (deploy.sh가 이 파일을 배포마다 덮어씀)
sudo tee /etc/nginx/conf.d/moa-upstream.conf &gt; /dev/null &lt;&lt;&#39;EOF&#39;
upstream moa_backend {
    server 127.0.0.1:8080;
}
EOF</code></pre>
<p>기존 Nginx SSL server 블록에서 <code>proxy_pass</code>를 변경했다:</p>
<pre><code class="language-nginx"># 변경 전
location / {
    proxy_pass http://localhost:8080;
}

# 변경 후
location / {
    proxy_pass http://moa_backend;
}</code></pre>
<p><code>localhost:8080</code> 하드코딩에서 <code>moa_backend</code> upstream으로 바꾸는 게 핵심이다. 이 시점에서는 upstream이 8080 고정이라 <strong>기존 배포와 완전히 동일하게 동작한다.</strong> Blue-Green은 workflow를 바꾸는 순간부터 시작된다.</p>
<h3 id="5-2-deploy-mainyml-변경">5-2. deploy-main.yml 변경</h3>
<p>기존의 &quot;stop → pull → run&quot; 방식에서 <code>deploy.sh</code> 호출로 교체했다.</p>
<pre><code class="language-yaml"># 변경 전
- name: Stop existing container and pull latest image
  run: |
    sudo docker stop moa-server || true
    sudo docker rm -f moa-server || true
    sudo docker pull godqhr721/moa_server:${{ needs.setup.outputs.docker_tag }}

- name: Run container
  run: |
    sudo docker run ... --name moa-server -p 8080:8080 ...

# 변경 후
- name: Checkout deploy scripts
  uses: actions/checkout@v4
  with:
    sparse-checkout: |
      scripts

- name: Run Blue-Green Deploy
  env:
    DOCKER_IMAGE: godqhr721/moa_server
  run: |
    chmod +x scripts/deploy.sh
    sudo -E bash scripts/deploy.sh &quot;${{ needs.setup.outputs.docker_tag }}&quot;</code></pre>
<p>workflow 최상단에 동시 배포 방지도 추가했다:</p>
<pre><code class="language-yaml">concurrency:
  group: deploy-production
  cancel-in-progress: false  # 진행 중인 배포는 절대 취소하지 않고 큐잉</code></pre>
<h3 id="5-3-smoke-test의-https-대응">5-3. Smoke Test의 HTTPS 대응</h3>
<p>deploy.sh 작성 중 놓칠 뻔한 부분이 있었다. MOA 서버의 Nginx 설정이 이렇게 되어있었다:</p>
<pre><code class="language-nginx">server {
    listen 80;
    return 301 https://$host$request_uri;  # HTTP → HTTPS 리다이렉트
}

server {
    listen 443 ssl;
    location / {
        proxy_pass http://moa_backend;
    }
}</code></pre>
<p>deploy.sh의 smoke test가 <code>http://localhost/api/v1/deploy-info</code>로 요청하면 <strong>301 리다이렉트 응답</strong>이 와서 body가 비어있다. 정상 배포인데 smoke test가 실패하면서 자동 롤백이 터지는 상황이 생긴다.</p>
<p><code>--resolve</code> 옵션으로 HTTPS를 localhost에서 직접 테스트하도록 수정했다:</p>
<pre><code class="language-bash">SMOKE_RESULT=$(curl -sf \
    --resolve &quot;${SMOKE_TEST_HOST}:443:127.0.0.1&quot; \
    &quot;https://${SMOKE_TEST_HOST}/api/v1/deploy-info&quot; 2&gt;/dev/null || true)</code></pre>
<p><code>--resolve moa-official.kr:443:127.0.0.1</code>은 DNS를 타지 않고 <strong>127.0.0.1:443에 직접 붙으면서</strong> SNI(Server Name Indication)는 <code>moa-official.kr</code>로 보낸다. 실제 사용자와 동일한 HTTPS 경로를 서버 로컬에서 테스트하는 것이다.</p>
<h3 id="5-4-첫-배포-시-좀비-컨테이너-문제">5-4. 첫 배포 시 좀비 컨테이너 문제</h3>
<p>첫 Blue-Green 배포에서 주의할 점이 있었다. 기존에는 <code>moa-server</code>라는 이름으로 컨테이너가 돌고 있는데, deploy.sh는 <code>moa-blue</code>, <code>moa-green</code>이라는 이름을 사용한다.</p>
<pre><code>active-color = blue → NEXT = green
  → moa-green(:8081) 생성
  → Nginx upstream → 8081 전환
  → &quot;이전 컨테이너 moa-blue를 정리하자&quot; → docker inspect moa-blue → 없음 → 스킵
  → 기존 moa-server(:8080)는 그대로 방치됨 💥</code></pre><p>두 번째 배포 때 <code>moa-blue</code>를 8080에 띄우려다가 <strong>기존 <code>moa-server</code>와 포트 충돌</strong>이 난다.</p>
<p>해결: 첫 배포 성공 확인 후 <strong>수동으로 <code>moa-server</code>를 제거</strong>했다.</p>
<pre><code class="language-bash"># 첫 배포 후 moa-green 정상 서빙 확인
curl https://moa-official.kr/api/v1/deploy-info
# → &quot;color&quot;:&quot;green&quot; 확인

# 좀비 컨테이너 제거
sudo docker stop moa-server &amp;&amp; sudo docker rm moa-server</code></pre>
<blockquote>
<p>위 내용을 실제 무중단 배포 환경을 제공할 때 주의사항이고 필수로 해야하는 과정이지만
나는 이 과정을 좀 늦게 알아차렸다. ㅋㅋㅋ...
일주일간 블루-그린 환경의 톰캣 컨테이너와 기조 컨테이너가 함께 실행되고 있었고 
다행히 ShedLock이 아주 잘 동작중이여서 문제가 발생하진 않았다!!!
로그를 보니 nignx가 8081 포트의 서버로만 요청을 보냈지만 실제로 배치 프로그램은 
Lock을 소유한 컨테이너에서 실행되는 걸 의도치 않게 확인할 수 있는 좋은 경험이었다~</p>
</blockquote>
<hr>
<h2 id="6-무중단-검증">6. 무중단 검증</h2>
<h3 id="6-1-방법">6-1. 방법</h3>
<p>터미널 2개로 진행했다.</p>
<p><strong>터미널 1 — 모니터링 (0.5초마다 요청)</strong></p>
<pre><code class="language-bash">while true; do
  RESULT=$(curl -s -o /tmp/resp.txt -w &quot;%{http_code}&quot; https://moa-official.kr/api/v1/deploy-info 2&gt;/dev/null)
  BODY=$(cat /tmp/resp.txt)
  VERSION=$(echo &quot;$BODY&quot; | grep -o &#39;&quot;version&quot;:&quot;[^&quot;]*&quot;&#39; | head -1)
  COLOR=$(echo &quot;$BODY&quot; | grep -o &#39;&quot;color&quot;:&quot;[^&quot;]*&quot;&#39; | head -1)
  echo &quot;$(date &#39;+%H:%M:%S&#39;) | HTTP ${RESULT} | ${VERSION} | ${COLOR}&quot;
  sleep 0.5
done</code></pre>
<p><strong>터미널 2 — 두 번째 배포 트리거</strong></p>
<pre><code class="language-bash">git commit --allow-empty -m &quot;test: zero-downtime verify&quot;
git push origin main</code></pre>
<h3 id="6-2-결과">6-2. 결과</h3>
<pre><code>14:30:01 | HTTP 200 | &quot;version&quot;:&quot;a1b2c3d&quot; | &quot;color&quot;:&quot;green&quot;
14:30:02 | HTTP 200 | &quot;version&quot;:&quot;a1b2c3d&quot; | &quot;color&quot;:&quot;green&quot;
14:30:02 | HTTP 200 | &quot;version&quot;:&quot;a1b2c3d&quot; | &quot;color&quot;:&quot;green&quot;
... (배포 진행 중 — 계속 200) ...
14:31:45 | HTTP 200 | &quot;version&quot;:&quot;a1b2c3d&quot; | &quot;color&quot;:&quot;green&quot;
14:31:46 | HTTP 200 | &quot;version&quot;:&quot;d4e5f6g&quot; | &quot;color&quot;:&quot;blue&quot;    ← 전환!
14:31:46 | HTTP 200 | &quot;version&quot;:&quot;d4e5f6g&quot; | &quot;color&quot;:&quot;blue&quot;
14:31:47 | HTTP 200 | &quot;version&quot;:&quot;d4e5f6g&quot; | &quot;color&quot;:&quot;blue&quot;</code></pre><p><strong>502가 단 하나도 없다.</strong> 모든 요청이 HTTP 200이고, version/color가 한 순간에 green→blue로 전환됐다.</p>
<table>
<thead>
<tr>
<th>확인 항목</th>
<th>결과</th>
</tr>
</thead>
<tbody><tr>
<td>전체 응답 HTTP 200</td>
<td>✅ 무중단</td>
</tr>
<tr>
<td>version 전환</td>
<td>✅ <code>a1b2c3d</code> → <code>d4e5f6g</code></td>
</tr>
<tr>
<td>color 전환</td>
<td>✅ green → blue</td>
</tr>
<tr>
<td>ShedLock 중복 실행</td>
<td>✅ 한쪽 컨테이너에서만 실행</td>
</tr>
</tbody></table>
<hr>
<h2 id="7-시리즈-전체-회고">7. 시리즈 전체 회고</h2>
<p>1편부터 4편까지, <strong>&quot;배포할 때마다 서버가 죽는다&quot;에서 &quot;배포해도 아무도 모른다&quot;</strong>까지 왔다.</p>
<h3 id="최종-아키텍처">최종 아키텍처</h3>
<pre><code>Client → Nginx(:443) → upstream(전환 가능)
                        ├─ moa-blue  (:8080)
                        └─ moa-green (:8081)

ShedLock → 스케줄러 중복 실행 방지
Graceful Shutdown → 배치 실행 중 안전 종료
deploy.sh → 9단계 자동 배포 (health check + smoke test + 자동 롤백)
rollback.yml → GitHub Actions UI에서 원클릭 롤백</code></pre><h3 id="내가-놓칠-뻔한-것들">내가 놓칠 뻔한 것들</h3>
<ol>
<li><strong>@Scheduled는 Graceful Shutdown 밖이다</strong> — HTTP 요청만 보호한다는 걸 모르면 배치가 중간에 끊긴다</li>
<li><strong>Smoke Test ≠ Health Check</strong> — 컨테이너가 살아있어도 Nginx 설정이 잘못되면 사용자는 502를 본다</li>
<li><strong>HTTPS 환경에서의 Smoke Test</strong> — <code>http://localhost</code>로 테스트하면 301만 오는데, 이걸 놓치면 정상 배포가 매번 롤백된다</li>
<li><strong>첫 배포의 좀비 컨테이너</strong> — <code>moa-server</code>와 <code>moa-blue</code>는 이름이 다르다</li>
<li><strong>JVM 타임존 ≠ MySQL 세션 타임존</strong> — <code>-Duser.timezone</code>이 DB 쿼리의 <code>NOW()</code>에는 영향을 주지 않는다</li>
</ol>
<h3 id="전체-시리즈-아키텍처-결정-흐름">전체 시리즈 아키텍처 결정 흐름</h3>
<table>
<thead>
<tr>
<th>결정 포인트</th>
<th>선택</th>
<th>근거</th>
</tr>
</thead>
<tbody><tr>
<td>무중단 전략</td>
<td>Blue-Green</td>
<td>EC2 1대, Nginx 이미 있음, 즉시 롤백 가능</td>
</tr>
<tr>
<td>스케줄러 중복 방어</td>
<td>ShedLock (DB)</td>
<td>추가 인프라(Redis) 없이 기존 MySQL 활용</td>
</tr>
<tr>
<td>FCM 멱등성</td>
<td>스케줄러 락만</td>
<td>현재 코드가 이미 배치 처리(saveAll)라 row-level claim 불필요</td>
</tr>
<tr>
<td>Graceful Shutdown</td>
<td>TaskScheduler 커스터마이즈</td>
<td><code>setWaitForTasksToCompleteOnShutdown(true)</code></td>
</tr>
<tr>
<td>배포 전략</td>
<td>Phase 분리</td>
<td>Phase 1(ShedLock) → 관찰 → Phase 2(Blue-Green)</td>
</tr>
<tr>
<td>타임존 9시간</td>
<td>현상 유지</td>
<td>동작 문제 없음, 타임존 변경은 ripple effect 큼</td>
</tr>
</tbody></table>
<h3 id="다음-단계">다음 단계</h3>
<p>현재 구조의 한계점도 명확히 알고 있다.</p>
<ul>
<li><strong>SPOF</strong>: EC2 서버 1대가 죽으면 끝이다. 트래픽이 늘면 ALB + Auto Scaling Group으로 가야 한다</li>
<li><strong>인프라 레벨 헬스체크만</strong>: <code>/actuator/health</code>는 비즈니스 로직 버그를 잡지 못한다. CI 테스트 강화가 선행되어야 한다</li>
<li><strong>수동 롤백</strong>: 메트릭 기반 자동 롤백은 아직 없다. CloudWatch 알람 + 자동 롤백까지 가면 더 안전해진다</li>
</ul>
<p>지금 규모(DAU 80명)에서는 이 정도면 충분하다. <strong>현재 규모에 맞는 적정 기술을 선택하고, 필요할 때 다음 단계로 전환할 수 있는 구조를 이해하는 것.</strong> 이 시리즈를 통해 그 과정을 경험할 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ClaudeCode가 작성한 테스트 코드와 함께 학습 - 2]]></title>
            <link>https://velog.io/@ho_d97/ClaudeCode%EA%B0%80-%EC%9E%91%EC%84%B1%ED%95%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C%EC%99%80-%ED%95%A8%EA%BB%98-%ED%95%99%EC%8A%B5-2</link>
            <guid>https://velog.io/@ho_d97/ClaudeCode%EA%B0%80-%EC%9E%91%EC%84%B1%ED%95%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C%EC%99%80-%ED%95%A8%EA%BB%98-%ED%95%99%EC%8A%B5-2</guid>
            <pubDate>Mon, 04 May 2026 04:46:52 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@ho_d97/ClaudeCode%EA%B0%80-%EC%9E%91%EC%84%B1%ED%95%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C%EC%99%80-%ED%95%A8%EA%BB%98-%ED%95%99%EC%8A%B5-1">ClaudeCode가 작성한 테스트 코드와 함께 학습 - 1</a>에서 내용이 너무 길어져서 남은 부분을 추가 정리해보자
이번 글에서는 실제 테스트 코드 내부 구현부분을 다루면서 1편과 동일하게 나의 생각을 정리해보자.</p>
<blockquote>
<p>해당 인라인 블럭으로 작성한 내용을 직접 고민하고 생각한 내용을 작성하는 부분입니다.
클로드 코드가 작성해준 내용과 구분하기 위함.</p>
</blockquote>
<h2 id="5-테스트-파일-구조-해부--claudecode의-답변">5. 테스트 파일 구조 해부 ( ClaudeCode의 답변)</h2>
<p><code>NotificationBatchServiceHolidayTest.kt</code>를 블록 단위로 뜯어봅니다.</p>
<h3 id="5-1-fake를-위한-자료구조">5-1. Fake를 위한 자료구조</h3>
<pre><code class="language-kotlin">private val holidayCalendar = mutableSetOf&lt;LocalDate&gt;()
private val workPolicies = mutableListOf&lt;WorkPolicyVersion&gt;()
private val notificationLogs = mutableListOf&lt;NotificationLog&gt;()
private val terms = mutableListOf&lt;Term&gt;()
private val agreements = mutableListOf&lt;TermAgreement&gt;()
private val settings = mutableListOf&lt;NotificationSetting&gt;()
private val fcmTokens = mutableListOf&lt;FcmToken&gt;()
private val dailySchedules = mutableListOf&lt;DailyWorkSchedule&gt;()</code></pre>
<p><strong>이게 인메모리 DB입니다.</strong> 프로덕션의 MySQL 테이블과 1:1 대응:</p>
<table>
<thead>
<tr>
<th>테스트 자료구조</th>
<th>프로덕션 테이블</th>
</tr>
</thead>
<tbody><tr>
<td><code>holidayCalendar</code></td>
<td><code>public_holiday</code></td>
</tr>
<tr>
<td><code>workPolicies</code></td>
<td><code>work_policy_version</code></td>
</tr>
<tr>
<td><code>notificationLogs</code></td>
<td><code>notification_log</code></td>
</tr>
<tr>
<td><code>terms</code></td>
<td><code>term</code></td>
</tr>
<tr>
<td><code>agreements</code></td>
<td><code>term_agreement</code></td>
</tr>
<tr>
<td><code>settings</code></td>
<td><code>notification_setting</code></td>
</tr>
<tr>
<td><code>fcmTokens</code></td>
<td><code>fcm_token</code></td>
</tr>
<tr>
<td><code>dailySchedules</code></td>
<td><code>daily_work_schedule</code></td>
</tr>
</tbody></table>
<p><strong>네이밍 선택</strong>: <code>publicHolidayRepo</code>, <code>notificationLogRepo</code> 같은 infrastructure 용어 대신 <code>holidayCalendar</code>, <code>notificationLogs</code> 같은 <strong>도메인 용어</strong>로 지었습니다. 테스트를 읽을 때 &quot;공휴일 달력에 신정을 추가하고 나면…&quot;처럼 자연어로 읽히게 하려는 의도.</p>
<blockquote>
<p>여기서 나는 너무 자료구조들이 존재하고 이는 즉 너무 많은 책임을 가지고 있는 것은 아닐까? 
하는 의문이 생겼고 바로 클로드 코드에게 코드리뷰를 부탁했다.</p>
</blockquote>
<h3 id="1차-분해--fake-8개는-동급이-아니다">1차 분해 — Fake 8개는 동급이 아니다</h3>
<p>SUT(System Under Test)는 <code>NotificationBatchService</code>인데, 그 자체가 의존하는 협력자는 8개가 아니다.  </p>
<pre><code>NotificationBatchService (SUT)  
├─ WorkPolicyVersionRepository      ← SUT 직접 의존  
├─ NotificationLogRepository        ← SUT 직접 의존  
├─ NotificationEligibilityService   ← 실물(in-process)로 함께 검증  
│   ├─ TermRepository  
│   ├─ TermAgreementRepository  
│   ├─ NotificationSettingRepository  
│   ├─ FcmTokenRepository  
│   └─ DailyWorkScheduleRepository  
└─ PublicHolidayService             ← 실물  
    └─ PublicHolidayRepository</code></pre><ul>
<li><strong>SUT가 직접 들고 있는 의존</strong>: 4개 (<code>WorkPolicyVersionRepository</code>, <code>NotificationLogRepository</code>, <code>NotificationEligibilityService</code>, <code>PublicHolidayService</code>).  </li>
<li><strong>SUT 자체로는 4개</strong>, 그 중 2개가 in-process 도메인 서비스라 같이 검증 범위에 끌어들였고, 그 결과 <strong>간접 의존 6개의 데이터 경계까지 합쳐 fake가 8개</strong>가 됐다.  </li>
</ul>
<p><code>NotificationBatchService.kt</code> 한 파일만 보면 의존성이 비대하지 않다. 8개라는 숫자는 <strong>세 서비스가 함께 참여하는 통합된 도메인의 데이터 경계 합산</strong>이다.  </p>
<blockquote>
<p><code>PublicHolidayService</code>, <code>NotificationEligibilityService</code>는 <strong>실물</strong>을 사용한다.  </p>
</blockquote>
<h3 id="fake--결합도-명제의-조건부-진실">&quot;Fake = 결합도&quot; 명제의 조건부 진실</h3>
<p>이 명제는 <strong>mockist 스타일에서는 거의 진실</strong>이다. mock이 많다 = SUT가 협력자에게 많은 명령을 내린다 = 결합도가 높다.  </p>
<p><strong>Classicist 스타일에선 다르게 읽어야 한다.</strong> classicist의 fake는 &quot;협력자의 동작 흉내&quot;가 아니라 &quot;외부 시스템(DB)의 얇은 어댑터&quot;이다. 그래서 fake 수는 <em>결합도가 아니라</em> <strong>외부로 노출된 데이터 경계의 수</strong>에 비례한다.</p>
<p>이 프로젝트의 도메인 데이터 경계를 세보면:  </p>
<ul>
<li>회원 자격 — 약관(2: term, agreement) + 알림 설정(1) = 3  </li>
<li>근무 정책 — work_policy_version + daily_schedule = 2  </li>
<li>공휴일 — 1  </li>
<li>알림 로그 — 1  </li>
<li>FCM 토큰 — 1  </li>
</ul>
<p>= <strong>8개</strong>. 정확히 일치한다. 즉 fake 8개는 &quot;SUT의 결합도가 폭발했다&quot;의 지표가 아니라 <strong>도메인이 잘 정규화돼서 테이블이 8개로 쪼개져 있다</strong>는 사실의 그림자일 뿐이다.  </p>
<p>이 차이를 명확히 의식하지 못하면, mockist 시절의 &quot;fake = 결합도&quot; 직관을 classicist 코드에 그대로 적용해서 잘못된 리팩터링 압력을 만든다.</p>
<h3 id="그래도-srp-의심점은-있다--어디-있는가">그래도 SRP 의심점은 있다 — 어디 있는가</h3>
<p>테스트 fake의 수보다 <strong>production 쪽</strong>에서 더 본격적인 의심점이 보인다.  </p>
<p><code>NotificationEligibilityService</code>가 5개 repository를 한 번에 들이고 있다. 자문해본다.  </p>
<p>Q: NotificationEligibilityService는 무엇을 하는가?<br>A: &quot;이 회원이 이 시점에 이 종류의 알림을 받을 자격이 있는가&quot; 한 가지 질문에 답한다.  </p>
<p>답이 한 줄로 압축되면 SRP 통과 — 5개 repo는 <em>하나의 자격 판정에 필요한 다섯 가지 입력</em>일 뿐이다. 
<strong>입력의 수와 책임의 수는 다른 차원</strong>이다.  </p>
<p>만약 답이 &quot;회원 정보를 합성하고, 약관을 검증하고, 토큰을 조회하고, 휴가를 판정하고…&quot; 처럼 <strong>and로 연결되는 여러 동사</strong>라면 SRP 위반 — 분리 후보.  </p>
<p><code>Eligibility</code>라는 이름이 한 단어로 응집되어 있다는 사실 자체가 <em>전자의 형태일 가능성이 높다</em>는 신호다. 다섯 입력은 한 답을 위한 다섯 검증 조건이지, 다섯 일이 아니다.  </p>
<p>다만 이건 production 코드를 직접 펼쳐서 한 번 더 확인할 가치는 있다. <em>눈으로 보고 책임이 한 문장에 압축되는지를 본인이 답할 수 있어야</em> 한다.</p>
<blockquote>
<p>SRP 의심점 리뷰를 받고 <code>NotificationEligibilityService</code> 다시 한번 직접 리뷰하면서 고민하는 시간을 가졌고 아래와 같은 코드를 볼 수 있었다.</p>
</blockquote>
<pre><code class="language-Kotlin">    if (!context.hasAgreedToAll(memberId, requiredCodes)) return null  
    if (!context.isSettingEnabled(memberId, NotificationSettingType.WORK)) return null  
    if (context.shouldSkipNotification(memberId)) return null  
    if (!context.hasFcmToken(memberId)) return null</code></pre>
<blockquote>
<p><code>createNotificationsIfEligible()</code> 메서드가 존재하는데 이는 알림을 생성해도 되는지 판단하는 
헬퍼 메서드로 사용되고 있다. </p>
<p>이번 리뷰를 통해 
<strong>and로 연결되는 여러 동사라면 SRP</strong> 위반이라는 피드백을 보고 
context 객체를 생성해주는 <code>NotificationEligibilityContext</code> 를 다시 보게 되었고 해당 객체는 회원별 적격성을 판단하기위한 정보를 로드하는 책임뿐 아니라 <code>shouldSkipNotification()</code> 과 같이 알림을 전송해도되는지 판단까지 하는 책임을 가지고 있다고 생각했고 이를 리팩토링 하기로 했다.</p>
<ul>
<li>추가적으로 호출자에서 직접 메서드를 호출해보고 판단하는게 이후 유지보수에서 일관성일 떨어지는 지점으로 만들 것 같아 해당 책임을 처리하는 클래스 분리를 고려하려고한다.</li>
</ul>
</blockquote>
<hr>
<blockquote>
<p>결론
단순히 많은 자료구조, 많은 클래스를 생성한다고 해서 SRP를 위반했다고 접근하는건 위험한 생각이다.
실제로 <code>NotificationBatchService</code> 가 직접 의존하는 객체는 4개였고 나머지 4개의 경우 단위테스트 작
성을 위해 연관된 실물 클래스들이 사용하는 간접 의존 객체였다.</p>
<p>고전파 방식의 테스트를 진행하기 위해서는 어쩌면 당연한 수순이었고 조금 더 본질을 생각할 수 있는 계기가 되었다.</p>
</blockquote>
<h3 id="5-2-repository-어댑터-claudecode-작성">5-2. Repository 어댑터 (ClaudeCode 작성)</h3>
<pre><code class="language-kotlin">private val publicHolidayRepo = mockk&lt;PublicHolidayRepository&gt;().apply {
    every { existsByDate(any()) } answers { firstArg&lt;LocalDate&gt;() in holidayCalendar }
}</code></pre>
<p><code>existsByDate</code>가 호출되면 <code>holidayCalendar</code> Set에서 <code>in</code> 연산(Kotlin의 <code>contains</code>)으로 포함 여부 확인. O(1).</p>
<p>더 복잡한 예시:</p>
<pre><code class="language-kotlin">private val workPolicyRepo = mockk&lt;WorkPolicyVersionRepository&gt;().apply {
    every { findLatestEffectivePoliciesPerMember(any()) } answers {
        val date = firstArg&lt;LocalDate&gt;()
        workPolicies
            .filter { !it.effectiveFrom.isAfter(date) }   // effectiveFrom &lt;= date
            .groupBy { it.memberId }
            .map { (_, versions) -&gt; versions.maxBy { v -&gt; v.effectiveFrom } }
    }
}</code></pre>
<p>이게 재현하는 실제 JPQL:</p>
<pre><code class="language-sql">select distinct w from WorkPolicyVersion w
join fetch w.workdays
where w.effectiveFrom = (
    select max(w2.effectiveFrom)
    from WorkPolicyVersion w2
    where w2.memberId = w.memberId and w2.effectiveFrom &lt;= :date
)</code></pre>
<p>Kotlin 번역:</p>
<ul>
<li><code>filter { !it.effectiveFrom.isAfter(date) }</code> → <code>effectiveFrom &lt;= date</code></li>
<li><code>groupBy { it.memberId }</code> → 회원별 그룹</li>
<li><code>map { ... maxBy { v.effectiveFrom } }</code> → 각 그룹에서 최신 버전 선택</li>
</ul>
<p>⚠️ <strong>중요한 한계</strong>: 이 Fake 재현 로직이 실제 JPQL과 <strong>의미가 같다는 보장</strong>이 없습니다. 재현 로직 자체가 버그일 수 있어요. 그래서 계층 2(<code>@DataJpaTest</code>)가 필요합니다 — <strong>실제 JPQL이 Fake와 같은 의미를 갖는다는 계약</strong>을 증명하는 테스트.</p>
<h3 id="5-3-실물-도메인-서비스">5-3. 실물 도메인 서비스</h3>
<pre><code class="language-kotlin">private val publicHolidayService = PublicHolidayService(publicHolidayRepo)
private val eligibilityService = NotificationEligibilityService(
    termRepo, agreementRepo, settingRepo, fcmTokenRepo, dailyScheduleRepo,
)
private val sut = NotificationBatchService(
    workPolicyRepo, notificationLogRepo, eligibilityService, publicHolidayService,
)</code></pre>
<p><strong>mock이 아니라 실물입니다.</strong> 이게 고전파의 핵심.</p>
<ul>
<li><code>PublicHolidayService.isHoliday</code>의 실제 로직이 돌아감</li>
<li><code>NotificationEligibilityService.loadContext</code>가 진짜 실행됨</li>
<li><code>NotificationBatchService.generateHolidayNotifications</code>도 진짜 호출됨</li>
</ul>
<p>→ <strong>세 서비스의 협력이 올바른지</strong>를 한 테스트가 검증.</p>
<p><code>sut</code>는 <strong>S</strong>ystem <strong>U</strong>nder <strong>T</strong>est 약자. 테스트 대상을 명확히 구분하는 관습.</p>
<h3 id="5-4-dsl-헬퍼">5-4. DSL 헬퍼</h3>
<pre><code class="language-kotlin">private fun 공휴일로_지정(date: LocalDate) {
    holidayCalendar += date
}

private fun 회원_등록(
    id: Long,
    알림_켜짐: Boolean = true,
    정책기준일: LocalDate = 신정,
    근무요일: Set&lt;Workday&gt; = Workday.WEEKDAYS,
) {
    workPolicies += WorkPolicyVersion(...)
    agreements += TermAgreement(memberId = id, termCode = TOS_CODE, agreed = true)
    settings += NotificationSetting(memberId = id, workNotificationEnabled = 알림_켜짐)
    fcmTokens += FcmToken(memberId = id, token = &quot;token-$id&quot;)
}</code></pre>
<p><strong>테스트 전용 도메인 언어(DSL)</strong>. 한글 함수명을 쓴 이유:</p>
<ol>
<li>이 프로젝트 컨벤션 (다른 테스트도 한글 백틱 네이밍)</li>
<li>기획자/PM도 테스트 시나리오를 읽을 수 있게</li>
<li>비즈니스 의도를 기계적 세팅 코드와 분리</li>
</ol>
<p><strong>기본값 적극 활용</strong>:</p>
<ul>
<li><code>알림_켜짐 = true</code>: 대부분 &quot;켜짐&quot;이므로 기본값</li>
<li><code>정책기준일 = 신정</code>: 대부분 공휴일 테스트</li>
<li><strong>기본값과 다를 때만 인자 지정</strong> → 각 테스트가 &quot;이 테스트에서 특별한 것&quot;만 명시적으로 드러냄</li>
</ul>
<blockquote>
<p>DSL이란? 
Domain-Specificic Language (도메인 특화 언어)라는 뜻을 가지고 있다.</p>
<p>단순 Kotlin 함수 호출인데, 이를 잘 작성해두면 읽을 때 도메인 시나리오처럼 읽혀 유비보수성을 높이는데 기여할 수 있다. </p>
<p>주로 모든 셋업이 테스트마다 반복되는 과정을 DSL을 사용하면 도움이된다.
공휴일_지정과 같은 용어로 함수를 지정하면 해당 과정이 뭘 위한 것인지 직관적으로 이해하는데 도움이 된다.
또한, 도메인 마다 다르게 해석될 수 있는 부분을 도메인 용어로 작성하여 도움을 줄 수 있다.</p>
<p>예를 들어 단순히 캘린더 자료구조에 데이터를 추가하는 행위가 약속을 추가하는 건지, 근무일을 추가하는 것인지 구분을 쉽게 할수 있다.</p>
<p>다만 동일한 개념이 자바에도 존재하지만 코틀린만큼 매끄럽지 않기 때문에 자바에서는 잘 사용되지 않는다고한다. </p>
</blockquote>
<p><strong>자바 시대부터 있던 Test Data Builder / Object Mother 패턴이 Kotlin의 표현력 좋은 문법(named/default args, 백틱 식별자)과 만나서 &quot;거의 자연어처럼 읽히는 형태&quot;로 진화한 것</strong> </p>
<h2 id="7-주니어가-자주-빠지는-함정-claudecode-작성">7. 주니어가 자주 빠지는 함정 (ClaudeCode 작성)</h2>
<h3 id="함정-1-커버리지-100를-위해-분기당-테스트-1개-쓰기">함정 1: 커버리지 100%를 위해 분기당 테스트 1개 쓰기</h3>
<p>❌ <strong>안티패턴</strong>: <code>if</code> 하나당 테스트 하나.</p>
<p>✅ <strong>옳은 접근</strong>: <strong>사용자 관점의 시나리오</strong>당 테스트 하나. 내부 분기가 2개든 10개든 외부에서 관찰 가능한 행동 단위로 묶음.</p>
<p>예) &quot;공휴일이어도 알림 꺼둔 회원 제외&quot;는 내부적으로 <code>isHoliday</code> 분기 + eligibility 필터 분기를 거치지만 테스트는 1개. 사용자 관점에선 &quot;OFF → 알림 없음&quot; 한 시나리오이기 때문.</p>
<h3 id="함정-2-verify로-모든-호출을-검증하기">함정 2: <code>verify</code>로 모든 호출을 검증하기</h3>
<p>❌ <strong>안티패턴</strong>:</p>
<pre><code class="language-kotlin">verify(exactly = 1) { workPolicyRepo.findLatestEffectivePoliciesPerMember(any()) }
verify(exactly = 1) { notificationLogRepo.findMemberIdsBy...(...) }
verify(exactly = 1) { eligibilityService.loadContext(any(), any()) }
verify(exactly = 1) { notificationLogRepo.saveAll(any()) }</code></pre>
<p>이건 &quot;<strong>구현을 박제</strong>&quot;하는 것. 내부가 조금만 바뀌어도 테스트가 깨져 거짓 양성이 쏟아짐.</p>
<p>✅ <strong>옳은 접근</strong>: <strong>최종 상태만 검증</strong>. &quot;어떻게 도달했는가&quot;는 구현 세부사항.</p>
<p><code>verify</code>가 정당한 순간은 <strong>&quot;호출 자체가 명세일 때&quot;</strong>뿐입니다. 예: FCM API에 올바른 토큰·제목·본문을 전달했는가 — 이건 호출이 곧 명세.</p>
<h3 id="함정-3-springboottest-남발">함정 3: <code>@SpringBootTest</code> 남발</h3>
<p>❌ <strong>안티패턴</strong>: 서비스 테스트마다 <code>@SpringBootTest</code>.</p>
<p>✅ <strong>옳은 접근</strong>:</p>
<table>
<thead>
<tr>
<th>대상</th>
<th>사용 어노테이션</th>
<th>소요 시간</th>
</tr>
</thead>
<tbody><tr>
<td>순수 비즈니스 로직</td>
<td>어노테이션 없음 (단위)</td>
<td>~10ms/테스트</td>
</tr>
<tr>
<td>Repository 쿼리</td>
<td><code>@DataJpaTest</code></td>
<td>~500ms/테스트</td>
</tr>
<tr>
<td>멱등성·트랜잭션 스모크</td>
<td><code>@SpringBootTest</code> (최소)</td>
<td>~2s/테스트</td>
</tr>
</tbody></table>
<p><code>@SpringBootTest</code>는 컨텍스트 로딩에 초 단위 걸립니다. 100개면 5분. <strong>속도는 테스트 문화의 복리</strong>입니다. 처음엔 체감 못해도 6개월 뒤에 팀 전체가 테스트 안 돌리는 조직이 됨.</p>
<h3 id="함정-4-given-when-then-주석-남발">함정 4: Given-When-Then 주석 남발</h3>
<p>❌ <strong>안티패턴</strong>:</p>
<pre><code class="language-kotlin">@Test
fun `test name`() {
    // Given
    val x = ...
    // When
    val result = sut.doSomething(x)
    // Then
    assertThat(result).isEqualTo(...)
}</code></pre>
<p>✅ <strong>옳은 접근</strong>: <strong>빈 줄</strong>로 섹션 구분. 주석 불필요.</p>
<pre><code class="language-kotlin">@Test
fun `test name`() {
    공휴일로_지정(신정)
    회원_등록(id = 1L)

    sut.generateNotificationsForDate(신정)

    assertThat(notificationLogs).hasSize(1)
}</code></pre>
<p>빈 줄 하나가 Given/When/Then을 자연스럽게 분리합니다. DSL 헬퍼 이름이 명확하면 주석은 소음.</p>
<h3 id="함정-5-테스트에-ifforwhen-사용">함정 5: 테스트에 <code>if</code>/<code>for</code>/<code>when</code> 사용</h3>
<p>❌ <strong>안티패턴</strong>:</p>
<pre><code class="language-kotlin">@Test
fun `다양한 케이스 한번에 검증`() {
    for (case in listOf(case1, case2, case3)) {
        if (case.isHoliday) {
            assertThat(...).isEqualTo(...)
        } else {
            assertThat(...).isEqualTo(...)
        }
    }
}</code></pre>
<p>✅ <strong>옳은 접근</strong>: <strong>한 테스트 = 한 시나리오</strong>. 조건부 로직이 테스트에 있다는 건 여러 테스트가 섞였다는 신호.</p>
<p>다중 케이스를 테이블로 처리하고 싶다면 JUnit 5 <code>@ParameterizedTest</code>. <code>if</code>/<code>for</code> 금지.</p>
<h3 id="함정-6-fake-재현-로직이-틀렸는지-확인-안-하기">함정 6: Fake 재현 로직이 틀렸는지 확인 안 하기</h3>
<p>우리 테스트의 <code>findLatestEffectivePoliciesPerMember</code> Fake:</p>
<pre><code class="language-kotlin">workPolicies
    .filter { !it.effectiveFrom.isAfter(date) }
    .groupBy { it.memberId }
    .map { (_, versions) -&gt; versions.maxBy { v -&gt; v.effectiveFrom } }</code></pre>
<p>이게 진짜 JPQL과 같은 결과를 낼지 <strong>단위 테스트 레벨에선 증명 못 합니다.</strong> 그래서 <strong><code>@DataJpaTest</code> 계약 테스트</strong>가 반드시 있어야 해요.</p>
<p>없으면? <strong>단위 테스트는 전부 녹색인데 프로덕션은 터지는 사고</strong>가 납니다.</p>
<h3 id="함정-7-한-테스트에서-여러-assertion-남발">함정 7: 한 테스트에서 여러 assertion 남발</h3>
<p>❌ <strong>안티패턴</strong>: 한 테스트에서 <code>assertThat(...)</code>가 10줄.</p>
<p>✅ <strong>옳은 접근</strong>: 한 테스트는 <strong>하나의 주장(assertion)</strong>. 여러 측면을 검증하고 싶으면 테스트를 분리. 단, <strong>같은 객체의 여러 속성</strong>을 검증하는 건 허용 (한 개념).</p>
<p>우리 테스트 ①에서 <code>memberId</code>, <code>notificationType</code>, <code>scheduledDate</code>, <code>scheduledTime</code>, <code>status</code>를 모두 검증한 건 &quot;생성된 NotificationLog 객체의 완전성&quot;이라는 <strong>하나의 개념</strong>에 대한 검증이므로 OK.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[ClaudeCode가 작성한 테스트 코드와 함께 학습 - 1]]></title>
            <link>https://velog.io/@ho_d97/ClaudeCode%EA%B0%80-%EC%9E%91%EC%84%B1%ED%95%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C%EC%99%80-%ED%95%A8%EA%BB%98-%ED%95%99%EC%8A%B5-1</link>
            <guid>https://velog.io/@ho_d97/ClaudeCode%EA%B0%80-%EC%9E%91%EC%84%B1%ED%95%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C%EC%99%80-%ED%95%A8%EA%BB%98-%ED%95%99%EC%8A%B5-1</guid>
            <pubDate>Sun, 03 May 2026 10:53:56 GMT</pubDate>
            <description><![CDATA[<p>실제 MOA 프로젝트에서 클로드 코드를 활용해 테스트 코드를 작성하고 이를 다시 분석하면서 테스트 코드 작성학습과 생각 정리를 위해 기록한다.</p>
<p>운영환경에서 테스트코드 작성 중요성을 체감하고 있고 이것이 가성비가 좋은 테스트인가? 라는 고민을 블라디미르 코리코프의 단위테스트 책을 읽고 시작하게 되었다.</p>
<p>일단 나의 경우 예시에 대입해서 내 생각을 정리하는 것을 좋아하는데, 최근 AI와 함께 좋은 예시를 만들어서 함께 토론하며 생각 정리하는데 정말 좋은 것 같다.</p>
<p>다시 본론으로 돌아와서 MOA 프로젝트에는 사용자들에게 FCM 을 이용해서 알림을 전송하는 기능이 존재하고, 여기에 공휴일엔 공휴일 전용 알림을 전송해 보고자 기능을 추가였고 이에 대한 테스트 코드를 작성하는 것이다.</p>
<p><a href="https://github.com/Nexters/moa-server">MOA_GIT_REPO</a></p>
<h2 id="1-무엇을-왜-테스트하는가">1. 무엇을 왜 테스트하는가</h2>
<h3 id="대상-코드">대상 코드</h3>
<p>매일 자정 KST에 <code>NotificationBatchScheduler.createDailyNotifications()</code>가 실행됩니다:</p>
<pre><code class="language-kotlin">@Scheduled(cron = &quot;0 0 0 * * *&quot;, zone = &quot;Asia/Seoul&quot;)
@SchedulerLock(name = &quot;createDailyNotifications&quot;, lockAtMostFor = &quot;2m&quot;, lockAtLeastFor = &quot;1m&quot;)
fun createDailyNotifications() {
    val today = LocalDate.now(ZoneId.of(&quot;Asia/Seoul&quot;))
    notificationBatchService.generateNotificationsForDate(today)
}</code></pre>
<p>그리고 <code>NotificationBatchService.generateNotificationsForDate</code>:</p>
<pre><code class="language-kotlin">fun generateNotificationsForDate(date: LocalDate) {
    if (publicHolidayService.isHoliday(date)) {
        generateHolidayNotifications(date)  // ← 우리 테스트 대상
    } else {
        generateWorkNotifications(date)
    }
}</code></pre>
<p>공휴일이면 <code>PUBLIC_HOLIDAY</code> 타입 <code>NotificationLog</code>를 적격 회원에게 09:00 예약으로 생성한다. 그다음 <code>NotificationDispatchScheduler</code>가 이 로그를 읽어 FCM으로 실제 발송합니다.</p>
<blockquote>
<p>실제로 내가 부분을 테스트 할 것인지 클로드 코드에게 알려준다면 더 좋은 퀄리티의 
테스트 코드를 작성할 수 있다고 생각합니다. </p>
<p>하지만 아직 테스트 코드를 많이 작성한 경험이 부족하고, 또한 잘못된 가성비가 좋지 않은 테스트 코드를 작성하게 시켜 오히려 좋지 않은 안정망이 만들어 질 수 있다는 핑계? 아닌 생각과 함께 모든 엣지 케이스를 고려하여 테스트 코드를 작성하게 시켰보았습니다.</p>
</blockquote>
<p>해당 기능에서 발생할 수 있는 모든 엣지케이스를 고려해서 그 이유와 함께 제시하라는 프롬프트를 입력하였고 아래와 같은 결과를 받을 수 있었습니다.</p>
<h3 id="이-기능이-깨지면-벌어지는-일-claudecode-답변">이 기능이 깨지면 벌어지는 일 (ClaudeCode 답변)</h3>
<table>
<thead>
<tr>
<th>실패 모드</th>
<th>사용자 경험</th>
<th>비즈니스 임팩트</th>
</tr>
</thead>
<tbody><tr>
<td>알림이 안 감</td>
<td>&quot;오늘 공휴일 알림 안 왔네&quot;</td>
<td>낮음</td>
</tr>
<tr>
<td>알림이 두 번 감</td>
<td>같은 시각 중복 푸시</td>
<td><strong>높음</strong> — 앱스토어 리뷰 폭락</td>
</tr>
<tr>
<td>평일에 공휴일 알림</td>
<td>업무일 아침 9시에 &quot;오늘은 공휴일&quot;</td>
<td><strong>매우 높음</strong> — 즉각적 혼란</td>
</tr>
<tr>
<td>약관 미동의자에게 발송</td>
<td>법적 분쟁 소지</td>
<td><strong>치명적</strong> — 개인정보보호법 이슈</td>
</tr>
</tbody></table>
<p>&quot;알림이 두 번 가는 경우&quot;가 실제로 일어날 확률은? <strong>있습니다.</strong> 이유:</p>
<ul>
<li>배치는 재시도됩니다 (k8s pod 리스타트, ShedLock TTL 만료, 수동 재실행)</li>
<li>멱등성은 <code>findMemberIdsByScheduledDateAndNotificationTypeAndMemberIdIn</code> 한 줄에 걸려 있고, 이 쿼리 이름이 오타나 리팩터로 깨지기 쉽습니다</li>
<li>분기(공휴일/평일) × 필터(약관/설정/토큰)의 조합이 많아 리팩터링 중 실수 여지가 큽니다</li>
</ul>
<p>다음으로 해당 문제들을 해결할 수 있도록 <code>런던파</code>와 <code>고전파</code> 의 관점에서 더 안정적이고 리팩토링 내성이 좋은 테스트 코드 작성을 요구했고, 다음과 같은 답변을 받을 수 있었습니다.</p>
<h2 id="3-런던파-vs-고전파--스타일-결정-claudecode-답변">3. 런던파 vs 고전파 — 스타일 결정 (ClaudeCode 답변)</h2>
<h3 id="두-학파의-배경">두 학파의 배경</h3>
<p>2000년대 초 TDD 커뮤니티에서 단위 테스트를 어떻게 짤 것인가에 대해 두 진영이 갈렸습니다.</p>
<p><strong>런던파 (London School / Mockist)</strong></p>
<ul>
<li>주창자: Steve Freeman, Nat Pryce — <em>Growing Object-Oriented Software, Guided by Tests</em> (2009)</li>
<li>철학: &quot;단위&quot;는 <strong>클래스 하나</strong>. 주변 협력자는 전부 mock.</li>
<li>검증: <strong>상호작용(interaction)</strong> — &quot;A가 B에게 올바른 메시지를 보냈는가?&quot;</li>
</ul>
<p><strong>고전파 (Classicist / Detroit School / Chicago School)</strong></p>
<ul>
<li>주창자: Kent Beck, Martin Fowler, Vladimir Khorikov — <em>Unit Testing: Principles, Practices, and Patterns</em> (2020)</li>
<li>철학: &quot;단위&quot;는 <strong>하나의 동작(behavior)</strong>. 여러 클래스가 협력해서 만드는 결과.</li>
<li>검증: <strong>최종 상태(state)</strong> — &quot;실행 후 세계가 어떻게 바뀌었는가?&quot;</li>
</ul>
<h3 id="같은-로직을-두-스타일로-비교">같은 로직을 두 스타일로 비교</h3>
<p>대상: <code>&quot;공휴일에 적격 회원이면 알림 로그를 저장한다&quot;</code></p>
<p><strong>런던파 버전</strong></p>
<pre><code class="language-kotlin">@Test
fun `공휴일에 적격 회원이면 로그를 저장한다`() {
    every { publicHolidayService.isHoliday(날짜) } returns true
    every { workPolicyRepo.findLatestEffectivePoliciesPerMember(날짜) } returns listOf(정책)
    every { notificationLogRepo.findMemberIdsBy...(...) } returns emptyList()
    every { eligibilityService.loadContext(...) } returns 적격_컨텍스트
    every { eligibilityService.findRequiredTermCodes() } returns emptySet()

    sut.generateNotificationsForDate(날짜)

    verify(exactly = 1) {
        notificationLogRepo.saveAll(match&lt;List&lt;NotificationLog&gt;&gt; {
            it.size == 1 &amp;&amp; it[0].memberId == 1L
        })
    }
}</code></pre>
<p><strong>고전파 버전 (우리 선택)</strong></p>
<pre><code class="language-kotlin">@Test
fun `공휴일에 적격 회원이면 로그를 저장한다`() {
    공휴일로_지정(신정)
    회원_등록(id = 1L)

    sut.generateNotificationsForDate(신정)

    assertThat(notificationLogs).hasSize(1)
}</code></pre>
<h3 id="왜-이-코드에-고전파를-선택했나">왜 이 코드에 고전파를 선택했나</h3>
<h4 id="이유-1-단위는-클래스가-아니라-파이프라인이다">이유 1: &quot;단위&quot;는 클래스가 아니라 파이프라인이다</h4>
<p><code>NotificationBatchService</code>는 혼자 결정하지 않습니다. <code>PublicHolidayService</code>, <code>NotificationEligibilityService</code>와 협력해서 &quot;공휴일 알림 생성&quot;이라는 <strong>하나의 비즈니스 동작</strong>을 만듭니다.</p>
<p>런던파로 짜면:</p>
<ul>
<li><code>NotificationBatchService</code> 단위 테스트: 협력자 모두 mock</li>
<li><code>PublicHolidayService</code> 단위 테스트: 따로 존재</li>
<li><code>NotificationEligibilityService</code> 단위 테스트: 따로 존재</li>
<li>→ 세 개가 <strong>연결됐을 때</strong> 올바른지는 아무도 보증 안 함 (이걸 통합 테스트가 대신 해야 하는데, 그러면 단위 테스트의 존재 이유가 약해짐)</li>
</ul>
<p>고전파로 짜면:</p>
<ul>
<li>한 테스트가 세 서비스를 실제로 돌려 최종 상태를 검증</li>
<li>→ 연결 자체가 피처라는 사실이 테스트에 녹아있음</li>
</ul>
<h4 id="이유-2-리팩터링-내성">이유 2: 리팩터링 내성</h4>
<p>런던파는 <strong>&quot;어떻게(how)&quot;를 박제</strong>합니다. <code>saveAll</code>이 몇 번 호출됐는지, 어떤 인자로 호출됐는지를 다 검증해요.</p>
<p>예시: 최적화로 <code>saveAll</code> 한 번 대신 <code>save</code> 여러 번 호출하도록 바꾸면?</p>
<ul>
<li>런던파: 전부 빨개짐 (하지만 동작은 같음) → <strong>거짓 양성</strong></li>
<li>고전파: 통과 (최종 저장 상태가 같으니까)</li>
</ul>
<p>피처를 안 바꾸고 구현만 바꿀 때 테스트가 깨지면, 팀은 테스트를 신뢰하지 않게 됩니다.</p>
<h4 id="이유-3-기존-프로젝트-컨벤션">이유 3: 기존 프로젝트 컨벤션</h4>
<p>이미 <code>src/test/kotlin/com/moa/service/calculator/CompensationCalculatorTest.kt</code>는 고전파 스타일입니다 (mock 없이 실물 계산기 사용). 컨벤션 일관성을 위해 같은 스타일을 유지합니다.</p>
<h3 id="그럼-런던파는-언제-쓰나">그럼 런던파는 언제 쓰나</h3>
<p><strong>외부 경계 어댑터</strong>에는 런던파가 더 자연스럽습니다:</p>
<ul>
<li><strong>FCM 클라이언트</strong>: &quot;올바른 토큰·제목·본문으로 FCM API를 호출했는가?&quot; → 호출 자체가 명세</li>
<li><strong>결제 PG 호출</strong>: 호출 인자가 계약</li>
<li><strong>이메일/Slack 발송</strong>: 상호작용 검증이 자연스러움</li>
</ul>
<p><strong>규칙</strong>: 내부 도메인 로직 = 고전파, 외부 경계 = 런던파. 실무에서 자주 쓰이는 분업입니다.</p>
<blockquote>
<p>나의 생각
테스트를 처음 공부할 때, 고전파 개념은 알 수 없었지만 <code>테스트 더블</code> 방식의 Fake  클래스를 정의하고 객체를 만들어 수행하는 것이 빌드 속도가 빠르면서 실제로 테스트하고자 하는 것을 정확하게 판단할 수 있다는 점과 외부 의존 라이브러리는 변경될 수 있어도 이에 따른 결과는 변경돼서는 안된다. (테스트 코드를 포함해서) 고전 파는 최종 상태를 테스트 하는 리팩토링 내성에서 긍정적으로 생각한다.</p>
<p>하지만 Fake 객체를 만들기 위해서 클래스를 만들고 이를 사용하고 등등 더 많은 코드가 작성되고 정말 중요한 기능이 아니라면 복잡도와 초기 생산성이 너무 낮아질 것 같았다. 하지만 최근엔 ClaudeCode, Codex를 활용하면 정말 빠르고 쉽게 개발할 수 있다는 점에서 고전파의 단점을 극복할 수 있어진건 아닐까? 싶다.</p>
</blockquote>
<p>나의 생각을 또 Claude에게 물어봤다.</p>
<p>내가 생각한 코드 양이 늘어나는 것과는 다른 고전파 단점을 알려줬다.</p>
<ul>
<li><strong>객체 그래프 셋업 복잡도</strong> — 한 테스트가 진짜 협력자들을 다 거치니까, 의미 있는 시나리오 하나 만들려면 많은 사전 상태가 필요해. (그래서 Object Mother, Test Data Builder 같은 패턴이 나옴)</li>
<li><strong>에지 케이스 강제 어려움</strong> — &quot;정확히 이 시점에 DB가 터지면?&quot; 같은 시나리오는 mock이 훨씬 편해.</li>
<li><strong>테스트 실행 속도</strong> — Fake로 어느 정도 해결되지만, 실물 의존성이 많아질수록 느려짐.</li>
<li><strong>Fake와 실물의 행동 불일치</strong> — in-memory repo가 실제 JPA repo와 미묘하게 다르게 동작하면 거짓 안심을 주게 됨. (Khorikov는 그래서 가능하면 Testcontainers 같은 실제 인프라 권장)</li>
</ul>
<h3 id="그렇다면-ai가-극복해주는-부분은-정확히-뭘까claudecode-답변">그렇다면 AI가 극복해주는 부분은 정확히 뭘까?(ClaudeCode 답변)</h3>
<p>&quot;AI로 빠르게 짜니까 고전파 단점이 해소된다&quot;는 통찰은 맞는데, <strong>어떤 단점이 해소되는지</strong>는 좀 더 정교하게 볼 필요가 있어:</p>
<ul>
<li>✅ <strong>해소됨</strong>: Fake 클래스 초기 작성, Test Builder 보일러플레이트, 비슷한 패턴의 테스트 케이스 양산</li>
<li>⚠️ <strong>여전히 사람 영역</strong>: 어떤 시나리오를 테스트할지 결정, Fake와 실물의 행동 일치 검증, 테스트가 실패했을 때 디버깅, 리팩터링 시 테스트 의도 보존</li>
</ul>
<p>오히려 AI 시대에 고전파가 더 유리해지는 <strong>다른 이유</strong>도 있어: AI가 짠 테스트는 종종 mock 설정이 과한 경향이 있고, 이런 테스트는 리팩터링할 때 우수수 깨져서 AI가 다시 고치고 또 깨지는 악순환을 만들어. <strong>상태 검증 위주의 고전파 테스트는 AI가 나중에 코드를 수정해도 잘 안 깨지는 견고함</strong>이 있어서, 사람-AI 협업에 더 잘 어울려.</p>
<h2 id="4-spring-data-jpa에서-고전파-구현하기-claudecode-답변">4. Spring Data JPA에서 고전파 구현하기 (ClaudeCode 답변)</h2>
<h3 id="문제-repository는-애매한-의존성">문제: Repository는 애매한 의존성</h3>
<p>고전파는 &quot;in-process 협력자는 실물을 쓰라&quot;고 합니다. 그런데 Repository는:</p>
<ul>
<li>인터페이스는 in-process</li>
<li>실제 구현은 DB(out-of-process)</li>
</ul>
<p>Khorikov가 이걸 명쾌히 정리했어요: <strong>&quot;Repository는 DB 어댑터다. DB는 out-of-process니까 Repository도 out-of-process로 취급하고 대역으로 교체하라.&quot;</strong></p>
<p>즉, 단위 테스트에서 Repository는 대역으로 대체. <strong>단, 상호작용 감시용 mock이 아니라 인메모리 Fake로.</strong></p>
<h3 id="테스트-대역test-double-5종">테스트 대역(Test Double) 5종</h3>
<table>
<thead>
<tr>
<th>종류</th>
<th>역할</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>Dummy</td>
<td>자리만 채움</td>
<td><code>null</code>, 빈 객체</td>
</tr>
<tr>
<td>Stub</td>
<td>정해진 값 반환</td>
<td><code>every { x() } returns 5</code></td>
</tr>
<tr>
<td>Spy</td>
<td>실물 + 호출 기록</td>
<td><code>spyk(obj)</code></td>
</tr>
<tr>
<td><strong>Mock</strong></td>
<td>호출 기대값 사전 설정 + 검증</td>
<td><code>verify { x() }</code></td>
</tr>
<tr>
<td><strong>Fake</strong></td>
<td>간단한 실제 동작 구현</td>
<td>인메모리 Map으로 DB 흉내</td>
</tr>
</tbody></table>
<p><strong>고전파 = Fake 위주, 런던파 = Mock 위주</strong>입니다.</p>
<h3 id="mockk-as-fake-패턴">&quot;MockK-as-Fake&quot; 패턴</h3>
<p>Spring Data JPA Repository는 <code>JpaRepository&lt;T, ID&gt;</code>를 확장해 수십 개 메서드를 상속합니다. 손으로 Fake 클래스를 만들면 다 구현해야 해서 현실적이지 않아요.</p>
<p>실무 해법: <strong>MockK를 Fake처럼 쓴다.</strong></p>
<pre><code class="language-kotlin">private val notificationLogs = mutableListOf&lt;NotificationLog&gt;()  // 인메모리 저장소

private val notificationLogRepo = mockk&lt;NotificationLogRepository&gt;().apply {
    // 쓰기: 리스트에 추가
    every { saveAll(any&lt;Iterable&lt;NotificationLog&gt;&gt;()) } answers {
        val added = firstArg&lt;Iterable&lt;NotificationLog&gt;&gt;().toList()
        notificationLogs += added
        added
    }
    // 읽기: 리스트에서 필터
    every {
        findMemberIdsByScheduledDateAndNotificationTypeAndMemberIdIn(any(), any(), any())
    } answers {
        val date = firstArg&lt;LocalDate&gt;()
        val type = secondArg&lt;NotificationType&gt;()
        val ids = thirdArg&lt;Collection&lt;Long&gt;&gt;()
        notificationLogs
            .filter { it.scheduledDate == date &amp;&amp; it.notificationType == type &amp;&amp; it.memberId in ids }
            .map { it.memberId }.distinct()
    }
}</code></pre>
<p>이건 mock이 아니라 <strong>fake</strong>입니다. 왜냐하면:</p>
<ul>
<li><code>verify { saveAll(...) }</code>로 <strong>호출 횟수를 감시하지 않음</strong></li>
<li>대신 <code>notificationLogs</code> 리스트의 <strong>최종 상태</strong>를 단언</li>
<li>라이브러리(MockK)는 같지만, <strong>사용 의도</strong>가 Fake</li>
</ul>
<blockquote>
<p>📖 Khorikov <em>Unit Testing</em> 6장: &quot;out-of-process 의존성을 in-memory로 대체하는 건 fake다.&quot; — 딱 이 패턴.</p>
</blockquote>
<h3 id="진짜-fake-클래스-대신-mockk를-쓰는-이유">진짜 Fake 클래스 대신 MockK를 쓰는 이유</h3>
<p><strong>Q: &quot;진짜 <code>FakeNotificationLogRepository</code> 클래스를 만드는 게 더 깔끔하지 않나요?&quot;</strong></p>
<p><strong>A</strong>: 깔끔하지만 유지비가 큽니다.</p>
<ul>
<li><code>JpaRepository</code>에 메서드가 추가되면 Fake 클래스도 같이 수정해야 함</li>
<li>MockK-as-Fake는 <strong>실제로 호출되는 메서드만 stub</strong>하면 됨</li>
<li>나머지 메서드를 호출하면 런타임에 &quot;stubbing 안 됨&quot; 에러 → <strong>쓰지 않는 메서드는 구현 안 해도 되는</strong> 미니멀 전략</li>
</ul>
<blockquote>
<p>일단 현재까지 내가 이해한 부분으로는 Mockk은 원래 런던파 방식으로 Mock 객체를 만들어서 </p>
<p>특정 메서드가 실행되었다고 치고 호출 횟수를 검증하는 방식으로 내부 구현이 리팩토링되면 테스트가 깨지는 단점을 가지고 있고, Fake 객체는 실제 테스트하고자 하는 레포지토리에 메서드가 추가되면 이를 인터페이스로 만들어두고 페이크 클래스는 구현하는 형태로 만들어지기에 신규 기능 추가 시 컴파일이 깨지는 단점이 있다.</p>
<p>오히려 테스트 커버리지를 엄격하게 요구하는 조직이라면 신규 기능 추가 시 테스트가 깨지는 것은 장점일 수도? 라는 생각과 함께 이런식으로 하네스를 만들어보는 것도? 좋지 않을까?</p>
<p>일단 MockK가 어떻게 위 단점들을 우회할 수 있는지 궁금해서 추가 질문을 했다.</p>
</blockquote>
<h3 id="mockk이-이걸-어떻게-우회하나-claude-답변">MockK이 이걸 어떻게 우회하나 (Claude 답변)</h3>
<p>핵심은 MockK이 만드는 객체가 <strong>&quot;타입은 만족하지만 행동은 비어있는 프록시&quot;</strong> 라는 것.</p>
<p>kotlin</p>
<pre><code class="language-kotlin">val repo = mockk&lt;NotificationLogRepository&gt;()</code></pre>
<p>이 시점에서 <code>repo</code>는:</p>
<ul>
<li>타입 시스템 입장에선 <code>NotificationLogRepository</code>를 완벽히 구현한 객체</li>
<li>실제로는 <strong>모든 메서드가 비어있음</strong> (호출하면 <code>MockKException</code> 던짐)</li>
<li>컴파일러는 &quot;어 30개 메서드 다 있네&quot; 하고 통과시킴</li>
</ul>
<p>이게 트릭이야. <strong>상속받은 메서드를 우리가 직접 구현할 필요가 없어</strong>졌어. 바이트코드 수준에서 프록시가 자동 생성돼서 모든 메서드 시그니처를 만족시키거든.</p>
<blockquote>
<p>Mock 객체 생성원리와 동작 방식을 공부하고 이해하고 있어야 좋을 것 같다.</p>
</blockquote>
<h3 id="그-다음에-필요한-메서드만-살려준다">그 다음에 필요한 메서드만 &quot;살려준다&quot;</h3>
<p>kotlin</p>
<pre><code class="language-kotlin">every { repo.saveAll(any&lt;Iterable&lt;NotificationLog&gt;&gt;()) } answers {
    val added = firstArg&lt;Iterable&lt;NotificationLog&gt;&gt;().toList()
    notificationLogs += added  // ← 외부 상태에 기록
    added
}</code></pre>
<p>여기서 핵심 문법 두 가지를 구분해야 해:</p>
<p><strong><code>returns</code> (정적 Stub)</strong></p>
<p>kotlin</p>
<pre><code class="language-kotlin">every { repo.count() } returns 5L</code></pre>
<p>호출되면 항상 5 반환. <strong>인자 무관, 상태 무관</strong>. 전형적인 Stub.</p>
<p><strong><code>answers</code> (동적 Fake)</strong></p>
<p>kotlin</p>
<pre><code class="language-kotlin">every { repo.findByMemberId(any()) } answers {
    val id = firstArg&lt;Long&gt;()
    notificationLogs.filter { it.memberId == id }
}</code></pre>
<p>호출 시점에 람다가 실행됨. <strong>인자를 받고, 외부 상태를 읽고, 결과를 계산</strong>해서 반환. 이게 Fake처럼 동작하는 핵심이야.</p>
<p><code>firstArg()</code>, <code>secondArg()</code>, <code>thirdArg()</code>는 호출 시 실제 들어온 인자를 꺼내오는 헬퍼야. 타입 추론을 위해 <code>firstArg&lt;Long&gt;()</code>처럼 제네릭을 명시해.</p>
<h3 id="왜-fake냐--사용-의도가-정한다">&quot;왜 Fake냐&quot; — 사용 의도가 정한다</h3>
<p>문서에서 강조한 부분:</p>
<p>kotlin</p>
<pre><code class="language-kotlin">// London 스타일 (Mock으로 쓰는 법)
verify(exactly = 1) { repo.saveAll(match { it.size == 1 }) }
//     ^^^^^^^ ─ &quot;saveAll이 1번 호출됐는지&quot; 검증 = 상호작용 검증

// Classicist 스타일 (Fake로 쓰는 법)
assertThat(notificationLogs).hasSize(1)
//                          ^^^^^^^^ ─ &quot;최종 상태가 어떤지&quot; 검증 = 상태 검증</code></pre>
<p><strong>똑같은 MockK 객체</strong>인데, 무엇을 단언하느냐에 따라 정체성이 갈려.</p>
<ul>
<li><code>verify { ... }</code> 쓰면 → Mock 역할 → 런던파</li>
<li>외부 컬렉션의 최종 상태만 보면 → Fake 역할 → 고전파</li>
</ul>
<p>라이브러리는 도구일 뿐이고, 학파를 결정하는 건 <strong>단언의 대상</strong>이야. 이게 문서에서 &quot;MockK-as-Fake&quot;라고 부르는 이유고.</p>
<h3 id="정리-두-문제를-어떻게-동시에-해결하나">정리: 두 문제를 어떻게 동시에 해결하나</h3>
<table>
<thead>
<tr>
<th>문제</th>
<th>진짜 Fake</th>
<th>순수 Mock</th>
<th>MockK-as-Fake</th>
</tr>
</thead>
<tbody><tr>
<td>30+ 메서드 구현 강제</td>
<td>❌ 다 구현해야 함</td>
<td>✅ 자동 프록시</td>
<td>✅ 자동 프록시</td>
</tr>
<tr>
<td>리팩터링 내성</td>
<td>✅ 상태 검증</td>
<td>❌ 호출 검증으로 깨짐</td>
<td>✅ 상태 검증</td>
</tr>
<tr>
<td>신규 메서드 추가</td>
<td>❌ 컴파일 에러</td>
<td>✅ 영향 없음</td>
<td>✅ 영향 없음</td>
</tr>
</tbody></table>
<p><strong>MockK이 제공하는 건 &quot;타입 시스템 만족&quot;이고, Fake 의미론은 우리가 <code>answers</code> + 외부 상태 + 상태 단언으로 만들어내는 것</strong>이야. 두 도구의 좋은 점만 결합한 셈.</p>
<h3 id="한-가지-함정">한 가지 함정</h3>
<p><code>every { repo.findByX(...) } answers { ... }</code>로 정의 안 한 메서드를 SUT가 호출하면 <strong>런타임에 <code>MockKException: no answer found</code></strong>이 터져.</p>
<p>이게 단점처럼 보이지만 사실 <strong>장점</strong>이야. &quot;내가 미처 생각 못한 호출 경로가 있다&quot;는 신호거든. 진짜 Fake였으면 빈 구현이 조용히 빈 결과를 반환해서 거짓 통과하는 일이 생길 수 있어. MockK은 시끄럽게 실패해서 알려줘.</p>
<p>(이 동작이 부담스러우면 <code>mockk&lt;T&gt;(relaxed = true)</code>로 완화할 수 있는데, 고전파 의도라면 <strong>strict가 정답</strong>이야. 모르는 호출이 있으면 알아야 하니까.)</p>
<blockquote>
<p> 나의 생각
이전에는 Mock 객체를 사용하면 무조건 런던파 방식이라는 틀린 생각을 가지고 있었고, 이번 테스트 코드는 이를 깨는 계기가 되었다. </p>
</blockquote>
<p>내용이 너무 길어지는 것 같아 실제 테스트 파일 구조 해부의 경우 다음 포스팅으로 진행하겠습니다~</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[EC2 단일 서버에서 Blue-Green 무중단 배포 구현하기 — 3편: MOA 적용 계획]]></title>
            <link>https://velog.io/@ho_d97/EC2-%EB%8B%A8%EC%9D%BC-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-Blue-Green-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-3%ED%8E%B8-MOA-%EC%A0%81%EC%9A%A9-%EA%B3%84%ED%9A%8D</link>
            <guid>https://velog.io/@ho_d97/EC2-%EB%8B%A8%EC%9D%BC-%EC%84%9C%EB%B2%84%EC%97%90%EC%84%9C-Blue-Green-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0-3%ED%8E%B8-MOA-%EC%A0%81%EC%9A%A9-%EA%B3%84%ED%9A%8D</guid>
            <pubDate>Fri, 01 May 2026 14:56:17 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>POC 기반으로 MOA 서비스에 적용해보기
<a href="https://apps.apple.com/kr/app/moa-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%9B%94%EA%B8%89-%EC%B2%B4%EA%B0%90/id6759603878">IOS_MOA 설치</a>
[안드로이드 MOA 설치] (<a href="https://play.google.com/store/apps/details?id=com.moa.salary.app&amp;hl=ko">https://play.google.com/store/apps/details?id=com.moa.salary.app&amp;hl=ko</a>)</p>
</blockquote>
<hr>
<h2 id="poc와는-다른-운영-환경">POC와는 다른 운영 환경</h2>
<p><a href="https://velog.io/@ho_d97/%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-Blue-Green-1">무중단 배포 Blue-Green 1편</a>에서 설계하고 <a href="https://velog.io/@ho_d97/%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-Blue-Green-1-68m5oc44">무중단 배포 Blue-Green 2편</a>에서 실제로 검증까지 성공했다. 
while loop 돌려도 502 하나 없이 블루→그린 전환되는 걸 확인했으니 이제 MOA에 적용만 하면 되는 줄 알았다.</p>
<p>근데 MOA 프로젝트에 적용하려고 생각하다 보니 MOA 프로젝트는 cron job 기반의 스케줄러가 실행되고 있다는게 생각났다.</p>
<pre><code class="language-kotlin">// 매 1분마다 FCM 알림 발송
@Scheduled(cron = &quot;0 * * * * *&quot;, zone = &quot;Asia/Seoul&quot;)
fun dispatchPendingNotifications() { ... }

// 매일 00:00 출퇴근 알림 배치 생성
@Scheduled(cron = &quot;0 0 0 * * *&quot;, zone = &quot;Asia/Seoul&quot;)
fun createDailyNotifications() { ... }

// 매일 03:00 월급 알림 배치 생성
@Scheduled(cron = &quot;0 0 3 * * *&quot;, zone = &quot;Asia/Seoul&quot;)
fun createPaydayNotifications() { ... }</code></pre>
<p>스케줄러가 3개 있다. Blue-Green 배포에서 뭐가 문제냐면, 전환 시점에 <strong>Blue와 Green 두 컨테이너가 동시에 잠깐 떠있다</strong>. 이 10~30초 구간에 스케줄러가 터지면 <strong>두 컨테이너에서 같은 작업이 2번 실행된다.</strong></p>
<p>특히 <code>dispatchPendingNotifications</code>는 매 분 돌아가니까 배포 중 거의 확실하게 중복 실행된다. 같은 FCM을 두 번 쏘면? 사용자가 알림을 두 번 받는다. 모아의 소중한 사용자들에게 나쁜 경험을 줄 수 없기에 고민을 시작했다. (클로드와 함께)</p>
<hr>
<h2 id="스케줄러-중복-실행-어떻게-막을까">스케줄러 중복 실행, 어떻게 막을까?</h2>
<p>방어할 방법은 여러 가지 있었다. 하나씩 비교해봤다.</p>
<blockquote>
<p>처음엔 무시하고 스케줄링 시간을 피해서 배포하면 되지 않을까? 라는 생각을 잠시...ㅋㅋ</p>
</blockquote>
<h3 id="선택지-1--환경변수로-특정-컬러에서만-스케줄러-실행">선택지 1 — 환경변수로 특정 컬러에서만 스케줄러 실행</h3>
<p>가장 단순한 방법. <code>DEPLOY_COLOR=blue</code>인 컨테이너에서만 <code>@Scheduled</code>를 돌리게 만드는 거다.</p>
<pre><code class="language-kotlin">@Component
@ConditionalOnProperty(name = &quot;scheduler.active&quot;, havingValue = &quot;true&quot;)
class NotificationDispatchScheduler { ... }</code></pre>
<p>근데 이 방법은 <strong>전환 시점 제어가 불가능하다.</strong> Blue가 스케줄러를 돌리고 있다가 Green으로 전환되는 순간, Green의 스케줄러가 켜지기 전까지 공백이 생긴다. 반대로 두 개 모두 켜지는 순간도 생길 수 있다.</p>
<p>게다가 배포마다 환경변수를 조절해야 하는데, 그럼 deploy.sh에 추가 로직이 들어가야 하고, 실수 한 번으로 모든 스케줄링이 중단될 수 있다. 너무 위험하다.</p>
<h3 id="선택지-2--redis-분산락">선택지 2 — Redis 분산락</h3>
<p>가장 많이 쓰는 방법. Redis + Redisson 조합. 성능도 좋고 안정적이다.</p>
<p>근데 MOA 서버에 Redis가 없다. 분산락 하나 쓰려고 Redis 인프라 구축하는 건 솔직히 <strong>배보다 배꼽이 크다.</strong>  사용자 80명 규모에서 Redis는 과하다는 게 1편에서 내린 결론이었는데, 여기서도 똑같다.</p>
<h3 id="선택지-3--db-분산락-shedlock">선택지 3 — DB 분산락 (ShedLock)</h3>
<p>이미 MySQL이 돌고 있으니까 테이블 하나 추가해서 락으로 쓰자는 방법으로, <code>ShedLock</code>이라는 라이브러리가 이걸 쉽게 해준다.</p>
<pre><code class="language-kotlin">@Scheduled(cron = &quot;0 * * * * *&quot;)
@SchedulerLock(name = &quot;dispatchNotifications&quot;, lockAtMostFor = &quot;55s&quot;)
fun dispatch() { ... }</code></pre>
<p>어노테이션 하나 붙이면 끝이다. 락 획득에 실패한 컨테이너는 자동으로 스킵한다. 추가 인프라도 없고, 라이브러리도 검증돼있다(GitHub star 2.5k+).</p>
<h3 id="결론-shedlock">결론: ShedLock</h3>
<ul>
<li>EC2 단일 서버 → Redis는 과함</li>
<li>환경변수 제어는 배포 시점 공백 문제 있음</li>
<li>ShedLock은 <strong>기존 MySQL + 어노테이션 하나</strong>로 끝</li>
</ul>
<p>1편에서 &quot;현재 규모에 맞는 적정 기술&quot;을 고민했던 것과 같은 맥락이다. 쿠버네티스를 안 쓰고 Nginx+Docker로 간 것처럼, 여기서도 Redis 안 쓰고 MySQL로 간다.</p>
<hr>
<h2 id="스케줄러-락만으로-정말-충분한가">스케줄러 락만으로 정말 충분한가</h2>
<p>ShedLock 붙이면 두 컨테이너 중 하나만 스케줄러를 실행한다. <em>동시 실행</em> 자체는 이걸로 막힌다.</p>
<p>다만 ShedLock을 설정하면서 발송 로직을 들여다보다가, 동시 실행 방어와는 별개의 <strong>외부 I/O 일관성</strong> 문제가 보였다. 이 부분은 직접 해결하면서 별도 글로 정리할 예정이다.</p>
<p>매 1분 cron에서 <code>lockAtMostFor: 55s</code>, <code>lockAtLeastFor: 30s</code>로 설정했다.</p>
<ul>
<li><code>lockAtMostFor</code>: 최대 55초 락 유지 (TTL). 프로세스가 죽어서 해제 못 해도 자동 만료된다</li>
<li><code>lockAtLeastFor</code>: 최소 30초는 유지. 빠르게 재실행되는 걸 막는다</li>
</ul>
<p>55초로 잡은 이유는 1분 cron 주기 내에 락이 만료되어야 다음 분에 다시 돌 수 있어서다. 60초로 잡으면 경계에서 문제가 생긴다.</p>
<hr>
<h2 id="graceful-shutdown이-scheduled까지-보호해줄까">Graceful Shutdown이 @Scheduled까지 보호해줄까?</h2>
<p>1편에서 이 주제를 짧게 다뤘는데, MOA에 적용하려니 다시 파봐야 했다.</p>
<pre><code class="language-yaml">server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s</code></pre>
<p>이 설정이 보호하는 건 <strong>HTTP 요청 처리 스레드</strong>다. SIGTERM 받으면:</p>
<ul>
<li>Tomcat/Netty가 새 HTTP 요청 거부</li>
<li>진행 중인 HTTP 요청은 완료까지 대기</li>
<li>30초 후 강제 종료</li>
</ul>
<p>근데 <code>@Scheduled</code>는 HTTP가 아니다. Spring의 <code>TaskScheduler</code>가 별도 스레드풀에서 돌린다. Graceful shutdown 대상에 안 들어간다.</p>
<p>시나리오:</p>
<pre><code>12:00:00 dispatchNotifications 실행 시작 (FCM 100건 발송 중)
12:00:05 배포 전환 → Blue에 SIGTERM
12:00:05 Graceful shutdown 시작
         → HTTP 요청 보호 (하지만 FCM 스케줄러는 보호 안 됨)
12:00:35 30초 후 강제 종료
         → FCM 50건만 보내고 죽음 💥</code></pre><h3 id="해결-taskscheduler-커스터마이즈">해결: TaskScheduler 커스터마이즈</h3>
<p>Spring 기본 <code>TaskScheduler</code>는 shutdown 시 진행 중 작업을 보호하지 않는데, 직접 빈을 등록하면 설정 가능하다.</p>
<pre><code class="language-kotlin">@Bean
fun taskScheduler(): TaskScheduler =
    ThreadPoolTaskScheduler().apply {
        poolSize = 2
        setThreadNamePrefix(&quot;scheduler-&quot;)
        setWaitForTasksToCompleteOnShutdown(true)  // ← 실행 중인 작업 완료 대기
        setAwaitTerminationSeconds(60)              // ← 최대 60초 대기
    }</code></pre>
<p>같이 <code>application-prod.yml</code>에서 shutdown 타임아웃도 늘려줬다:</p>
<pre><code class="language-yaml">spring:
  lifecycle:
    timeout-per-shutdown-phase: 60s  # 30초 → 60초</code></pre>
<p>그리고 <code>docker stop</code>의 타임아웃도 Spring보다 길게 잡아야 한다. Spring이 60초 기다리는데 Docker가 30초에 SIGKILL 보내면 의미가 없다.</p>
<pre><code class="language-bash"># deploy.sh에서
sudo docker stop --time 70 &quot;${CURRENT_CONTAINER}&quot;  # Spring 60초 + 여유 10초</code></pre>
<p>이렇게 해서 &quot;배치 실행 중 배포가 들어와도 배치를 끝까지 기다려주는&quot; 구조를 만들었다.</p>
<hr>
<h2 id="배포-전략--왜-한번에-안-하나">배포 전략 — 왜 한번에 안 하나</h2>
<p>Phase 1 (스케줄러 안전장치)과 Phase 2 (Blue-Green 인프라)를 한 번에 배포할까 고민했다. 한 PR로 묶으면 작업도 끝나고 깔끔하잖아.</p>
<p>근데 생각해보니까 위험 분산 관점에서 안 좋은 선택이었다.</p>
<pre><code>한번에 배포 시:
  버그 발생 → ShedLock 문제? Graceful Shutdown 문제?
             Nginx 전환 문제? deploy.sh 문제?
             → 원인 파악에 시간 소요 → MTTR 증가

분리 배포 시:
  Phase 1 배포 → 2~3일 관찰 → 정상 → Phase 2 진행
  Phase 2에서 문제 → 원인은 Phase 2에 국한 → 즉시 롤백 가능</code></pre><p>특히 ShedLock은 <strong>매일 한 번만 돌아가는 배치(00:00, 03:00)에 적용</strong>되니까, 로컬에서 확인했다고 끝이 아니다. 실제 운영 DB에서 매일 돌아갈 때 문제없는지 확인하려면 최소 하루는 기다려야 한다.</p>
<p>배포 순서는 이렇게 잡았다:</p>
<pre><code>1일차: Phase 1 배포 (ShedLock + Graceful Shutdown)
       ├─ build.gradle.kts + SchedulingConfig
       ├─ 3개 스케줄러에 @SchedulerLock
       ├─ application-prod.yml에 graceful shutdown
       └─ shedlock 테이블 DDL 수동 실행

2~3일 관찰
       ├─ SELECT * FROM shedlock — 락 기록 확인
       ├─ 00:00 배치 정상 실행 확인
       ├─ 03:00 배치 정상 실행 확인
       └─ /actuator/health 응답 OK

4일차: Phase 2 배포 (Blue-Green 인프라)
       ├─ 서버 디렉토리 + Nginx upstream
       ├─ deploy.sh + rollback.sh
       ├─ deploy-main.yml 수정
       └─ while loop로 무중단 검증</code></pre><hr>
<h2 id="db-스키마-변경--조심해야-할-것">DB 스키마 변경 — 조심해야 할 것</h2>
<p>Blue-Green의 본질적 특징 중 하나는 <strong>전환 시점에 두 버전이 같은 DB를 본다</strong>는 것이다. 지금은 스키마 변경 없지만 앞으로 배포할 때 조심해야 한다.</p>
<table>
<thead>
<tr>
<th>변경</th>
<th>안전?</th>
<th>이유</th>
</tr>
</thead>
<tbody><tr>
<td>nullable 컬럼 추가</td>
<td>✅</td>
<td>기존 코드는 그냥 무시</td>
</tr>
<tr>
<td>컬럼 삭제</td>
<td>❌</td>
<td>Blue 코드가 아직 참조 중</td>
</tr>
<tr>
<td>컬럼 이름 변경</td>
<td>❌</td>
<td>Blue의 쿼리 깨짐</td>
</tr>
<tr>
<td>NOT NULL 추가</td>
<td>❌</td>
<td>Blue가 해당 컬럼 없이 INSERT 가능</td>
</tr>
</tbody></table>
<p>위험한 변경은 <strong>2단계 배포</strong>로 처리:</p>
<pre><code>1차 배포: 새 컬럼 추가 + 코드에서 새/기존 양쪽 지원
2차 배포: 기존 컬럼 제거</code></pre><p>이건 Blue-Green의 단점이라기보다, 어떤 무중단 배포 방식이든 공통으로 신경 써야 할 부분이다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>POC에서는 안 보였던 것들이 운영 서비스에서는 튀어나왔다.</p>
<ol>
<li><strong>스케줄러 3개</strong> → ShedLock으로 중복 실행 방어</li>
<li><strong>FCM 멱등성 우려</strong> → 현재 코드가 이미 배치 처리라서 스케줄러 락만으로 충분</li>
<li><strong>@Scheduled는 graceful shutdown 밖</strong> → TaskScheduler 커스터마이즈</li>
<li><strong>한 번에 배포 시 위험</strong> → Phase 1 + 관찰 + Phase 2 분리 배포</li>
<li><strong>DB 스키마 변경</strong> → 하위 호환성 유지 필수</li>
</ol>
<p>특히 재밌었던 건 &quot;N+1 우려&quot;였다. 분산락 패턴을 보고 반사적으로 &quot;쿼리 많이 나가겠네&quot; 싶었는데, 현재 코드를 뜯어보니 이미 <code>saveAll()</code>로 배치 처리가 되어있었다. <strong>코드를 안 보고 패턴만 보고 판단하면 틀린다</strong>는 교훈.</p>
<blockquote>
<p>&quot;기존 코드가 이미 잘 해결하고 있는 문제를 추가 패턴으로 해결하려 하지 마라.&quot;</p>
</blockquote>
<hr>
<p>다음 편에서는 실제로 배포하면서 겪은 일들을 정리해볼 예정이다.
Phase 1 적용 → 운영 관찰 → Phase 2 적용 → 무중단 검증까지의 실전 기록을 작성해보자.</p>
<blockquote>
<p><a href="/docs/blog-part1-design.md">1편: 설계</a> | <a href="/docs/blog-part2-hands-on.md">2편: POC 실전</a> | <strong>3편: MOA 계획</strong> | 4편: MOA 적용 실전 (작성 예정)</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[무중단 배포 Blue-Green (2)]]></title>
            <link>https://velog.io/@ho_d97/%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-Blue-Green-1-68m5oc44</link>
            <guid>https://velog.io/@ho_d97/%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-Blue-Green-1-68m5oc44</guid>
            <pubDate>Mon, 27 Apr 2026 12:41:43 GMT</pubDate>
            <description><![CDATA[<h1 id="ec2-단일-서버에서-blue-green-무중단-배포-구현하기--2편-실전">EC2 단일 서버에서 Blue-Green 무중단 배포 구현하기 — 2편: 실전</h1>
<blockquote>
<p>MOA 운영 서비스에 적용하기 전, POC 프로젝트로 먼저 검증해본 기록
<a href="https://apps.apple.com/kr/app/moa-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%9B%94%EA%B8%89-%EC%B2%B4%EA%B0%90/id6759603878">IOS_MOA 설치</a>
[안드로이드 MOA 설치] (<a href="https://play.google.com/store/apps/details?id=com.moa.salary.app&amp;hl=ko">https://play.google.com/store/apps/details?id=com.moa.salary.app&amp;hl=ko</a>)</p>
</blockquote>
<hr>
<h2 id="서버-상황">서버 상황</h2>
<p>POC를 위해 이전에 다른 토이 프로젝트를 위해 만들어둔 홈서버를 사용해보고자 한다.
이미 Docker랑 Nginx는 이미 깔려 있었기에 빠르게 시작해볼 수 있었다.</p>
<p>하지만 이전에 설정한 도메인이 만료됐고 서비스도 안 돌아가는 상태라서, 기존 설정을 다 걷어내고 Blue-Green용으로 새로 세팅하기로 했다.</p>
<h3 id="기존-nginx-설정을-뜯어보니">기존 Nginx 설정을 뜯어보니</h3>
<pre><code class="language-nginx">server {
    listen 80 default_server;
    return 301 https://$host$request_uri;
    location / {
        try_files $uri $uri/ =404;   # ← return 301 때문에 여기 도달 불가 (dead code)
    }
}

server {
    listen 443 ssl;
    server_name www.shinhan-sosol.store;
    ssl_certificate /etc/letsencrypt/live/www.shinhan-sosol.store/fullchain.pem;

    location /api/ {
        proxy_pass http://localhost:8080/api/;   # ← 이전 프로젝트 백엔드
    }
}</code></pre>
<p>문제점이 몇 개 보였다.</p>
<ol>
<li><strong>SSL 인증서가 만료된 도메인</strong> — 도메인이 만료됐으니 의미 없는 설정</li>
<li><strong>proxy_pass가 localhost:8080으로 하드코딩</strong> — Blue-Green은 upstream을 써야 하니까 교체 필요</li>
<li><strong>dead code</strong> — <code>return 301</code> 아래의 <code>location / { try_files... }</code>는 절대 실행 안 된다</li>
</ol>
<p>전부 걷어내고 새로 구성하기로 했다.</p>
<hr>
<h2 id="서버-세팅--단계별">서버 세팅 — 단계별</h2>
<h3 id="1단계-기존-상태-확인">1단계: 기존 상태 확인</h3>
<pre><code class="language-bash">docker --version          # Docker 설치 확인
nginx -v                  # Nginx 설치 확인
sudo docker ps            # 돌아가는 컨테이너 확인
sudo ss -tlnp | grep -E &#39;:(80|8080|8081) &#39;  # 포트 사용 현황</code></pre>
<p>Docker, Nginx 모두 이미 깔려있었고, 돌아가는 컨테이너는 없었다.</p>
<h3 id="2단계-앱-디렉토리--상태-파일-생성">2단계: 앱 디렉토리 + 상태 파일 생성</h3>
<pre><code class="language-bash">mkdir -p /home/hp-server/Desktop/project/zero-v1/logs
echo &quot;blue&quot; &gt; /home/hp-server/Desktop/project/zero-v1/active-color
touch /home/hp-server/Desktop/project/zero-v1/deploy-history</code></pre>
<p><code>active-color</code> 파일에 <code>blue</code>를 넣어두면, 첫 배포 시 deploy.sh가 이걸 읽고 &quot;현재 blue니까 다음은 green&quot;으로 판단한다.</p>
<h3 id="3단계-기존-nginx-설정-제거">3단계: 기존 Nginx 설정 제거</h3>
<pre><code class="language-bash">sudo rm /etc/nginx/sites-enabled/default</code></pre>
<p>이전 프로젝트 설정이 통째로 들어있던 파일을 제거해줬다.</p>
<h3 id="4단계-blue-green-nginx-설정-생성">4단계: Blue-Green Nginx 설정 생성</h3>
<pre><code class="language-bash"># upstream 설정 — deploy.sh가 이 파일을 배포마다 덮어씀
sudo tee /etc/nginx/conf.d/moa-upstream.conf &gt; /dev/null &lt;&lt;&#39;EOF&#39;
upstream moa_backend {
    server 127.0.0.1:8080;
}
EOF

# server 블록 — 모든 요청을 upstream으로 프록시
sudo tee /etc/nginx/conf.d/zero-downtime.conf &gt; /dev/null &lt;&lt;&#39;EOF&#39;
server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://moa_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_connect_timeout 5s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}
EOF</code></pre>
<p>핵심은 <code>moa-upstream.conf</code>다.
이 파일의 <code>server 127.0.0.1:8080;</code>을 <code>server 127.0.0.1:8081;</code>로 바꾸고 <code>nginx -s reload</code> 하면 트래픽이 전환된다. deploy.sh가 이 작업을 자동으로 해준다.</p>
<h3 id="5단계-nginx-검증--적용">5단계: Nginx 검증 + 적용</h3>
<pre><code class="language-bash">sudo nginx -t        # 설정 문법 검증
sudo nginx -s reload # 적용
curl -I http://localhost</code></pre>
<p><code>curl</code> 결과로 <strong>502 Bad Gateway</strong>가 나왔는데, 이게 정상이다.
Nginx는 떠있지만 upstream인 8080에 아무 컨테이너도 없으니까 502가 맞다.
이제 첫 배포 후에 200이 나오면 된다.</p>
<h3 id="6단계-github-actions-self-hosted-runner-설치">6단계: GitHub Actions Self-Hosted Runner 설치</h3>
<p>리포지토리 Settings → Actions → Runners → New self-hosted runner에서 안내하는 명령어를 따라 했다.</p>
<pre><code class="language-bash"># runner 디렉토리에서
bash config.sh --url https://github.com/subsub97/blue-green --token &lt;토큰&gt;</code></pre>
<p>실행하면 이런 화면이 뜬다:</p>
<p><img src="https://velog.velcdn.com/images/ho_d97/post/0831fdc1-aae7-4592-a6d5-bda01a9ad279/image.png" alt="runner-start"></p>
<p>&quot;Connected to GitHub&quot;가 뜨면 성공이다. 이후 Runner group, name, labels 등을 물어보는데 전부 Enter(기본값)로 넘기면 된다.</p>
<p>Configure 끝나고 서비스 등록:</p>
<pre><code class="language-bash">sudo bash svc.sh install
sudo bash svc.sh start</code></pre>
<h3 id="7단계-github-secrets-설정">7단계: GitHub Secrets 설정</h3>
<p>리포지토리 Settings → Secrets and variables → Actions에서:</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>DOCKER_USERNAME</code></td>
<td><code>godqhr721</code></td>
</tr>
<tr>
<td><code>DOCKER_PASSWORD</code></td>
<td>Docker Hub 토큰</td>
</tr>
</tbody></table>
<p>여기까지 하면 서버 세팅은 끝이다. main에 push하면 자동으로 빌드→배포가 시작된다.</p>
<p>첫 배포가 돌아가면 GitHub Actions에서 이런 로그를 볼 수 있다:</p>
<p><img src="https://velog.velcdn.com/images/ho_d97/post/1e2c40a7-581a-48c0-b6a7-48334ecfef18/image.png" alt="GitHub Actions 첫 배포 결과"></p>
<p>Current: blue(:8080), Next: green(:8081)으로 첫 배포가 green 컨테이너에 올라간다. 배포 완료 후 API를 찍어보면:</p>
<p><img src="https://velog.velcdn.com/images/ho_d97/post/f9b00da9-cd1c-40ac-b24b-ebe3d8f6cff3/image.png" alt="deploy-info API 응답"></p>
<p>version에 git commit SHA, color에 green이 찍힌다. 이게 나오면 첫 배포는 성공이다.</p>
<hr>
<h2 id="트러블슈팅">트러블슈팅</h2>
<p>서버 세팅하면서 생각보다 잔에러가 많이 났다. 하나하나 정리해보자.</p>
<h3 id="1-chown-invalid-user-ubuntuubuntu">1. chown: invalid user &#39;ubuntu:ubuntu&#39;</h3>
<pre><code class="language-bash">$ sudo chown -R ubuntu:ubuntu /home/hp-server/Desktop/project/zero-v1/
chown: invalid user: &#39;ubuntu:ubuntu&#39;</code></pre>
<p><strong>원인</strong>: 이 서버의 유저명이 <code>ubuntu</code>가 아니라 <code>hp-server</code>였다. 스크립트나 가이드에서 <code>ubuntu</code>를 기본으로 쓰는 경우가 많은데, 실제 서버의 유저명에 맞춰야 한다.</p>
<p><strong>해결</strong>:</p>
<pre><code class="language-bash">sudo chown -R hp-server:hp-server /home/hp-server/Desktop/project/zero-v1/</code></pre>
<blockquote>
<p>서버 세팅 가이드를 그대로 복붙하면 이런 데서 막힌다. 자기 서버의 유저명은 <code>whoami</code>로 확인하자.</p>
</blockquote>
<hr>
<h3 id="2-must-not-run-with-sudo">2. must not run with sudo</h3>
<pre><code class="language-bash">$ sudo ./config.sh --url ...
Must not run with sudo</code></pre>
<p><strong>원인</strong>: GitHub Actions Runner의 <code>config.sh</code>는 <strong>root로 실행하면 안 된다.</strong> 보안 정책상 일반 유저로 실행해야 한다.</p>
<p><strong>해결</strong>: root로 접속해있었으면 일반 유저로 전환 후 실행한다.</p>
<pre><code class="language-bash">su - hp-server
cd ~/Desktop/project/zero-v1/actions-runner
bash config.sh --url https://github.com/... --token ...</code></pre>
<hr>
<h3 id="3-sudo-svcsh-command-not-found">3. sudo ./svc.sh: command not found</h3>
<pre><code class="language-bash">$ sudo ./svc.sh install
sudo: ./svc.sh: command not found</code></pre>
<p><strong>원인</strong>: <code>sudo</code>로 실행할 때 상대 경로가 제대로 해석이 안 되는 경우가 있다.</p>
<p><strong>해결</strong>: <code>bash</code>로 직접 절대 경로를 지정해서 실행한다.</p>
<pre><code class="language-bash">sudo bash /home/hp-server/Desktop/project/zero-v1/actions-runner/svc.sh install</code></pre>
<hr>
<h3 id="4-svcsh-no-such-file-or-directory">4. svc.sh: No such file or directory</h3>
<pre><code class="language-bash">$ ls
actions-runner-linux-x64-2.333.1.tar.gz  bin  config.sh  env.sh  externals  run.sh ...</code></pre>
<p><code>svc.sh</code>가 파일 목록에 없었다.</p>
<p><strong>원인</strong>: <code>svc.sh</code>는 <strong><code>config.sh</code>를 실행한 후에</strong> 생성된다. config 과정에서 Runner 설정이 완료되면 서비스 등록에 필요한 파일들이 생긴다.</p>
<p><strong>해결</strong>: <code>config.sh</code>를 먼저 실행하고 나면 <code>svc.sh</code>가 생성되어 있다.</p>
<hr>
<h3 id="5-configsh-no-such-file-or-directory">5. config.sh: No such file or directory</h3>
<pre><code class="language-bash">~/Desktop/project/zero-v1 $ bash config.sh --url ...
bash: config.sh: No such file or directory</code></pre>
<p><strong>원인</strong>: <code>config.sh</code>는 <code>actions-runner</code> 디렉토리 안에 있는데, 한 단계 상위에서 실행하고 있었다.</p>
<p><strong>해결</strong>:</p>
<pre><code class="language-bash">cd ~/Desktop/project/zero-v1/actions-runner
bash config.sh --url ...</code></pre>
<blockquote>
<p>에러 메시지가 &quot;No such file or directory&quot;이면 일단 <code>pwd</code>로 현재 위치부터 확인하자.</p>
</blockquote>
<hr>
<h3 id="6-permission-denied-on-configsh">6. Permission denied on config.sh</h3>
<pre><code class="language-bash">$ bash config.sh --url ...
touch: cannot touch &#39;.env&#39;: Permission denied
System.UnauthorizedAccessException: Access to the path &#39;..._diag&#39; is denied.</code></pre>
<p><strong>원인</strong>: <code>actions-runner</code> 디렉토리를 root로 압축 해제해서, 파일 소유자가 root였다. 일반 유저(hp-server)로 실행하니까 권한이 없었던 거다.</p>
<p><strong>해결</strong>:</p>
<pre><code class="language-bash">sudo chown -R hp-server:hp-server ~/Desktop/project/zero-v1/actions-runner</code></pre>
<p>소유권 변경 후 다시 <code>config.sh</code> 실행하니까 정상적으로 진행됐다.</p>
<hr>
<h3 id="7-curl에서-404-응답">7. curl에서 404 응답</h3>
<pre><code class="language-bash">$ curl http://58.227.208.141//api/deploy-info
# 404 Not Found</code></pre>
<p><strong>원인</strong>: URL에 슬래시가 두 개 들어갔다. <code>//api/deploy-info</code>가 아니라 <code>/api/deploy-info</code>여야 한다.</p>
<p><strong>해결</strong>:</p>
<pre><code class="language-bash">curl http://58.227.208.141/api/deploy-info</code></pre>
<blockquote>
<p>사소한 오타지만 404가 나오면 &quot;API가 안 되나?&quot; 하고 다른 데서 원인을 찾게 된다. URL부터 다시 확인하는 습관이 필요하다.</p>
</blockquote>
<hr>
<h2 id="이해하고-넘어가야-할-것들">이해하고 넘어가야 할 것들</h2>
<p>서버 세팅하면서 몇 가지 &quot;이거 어떻게 되는 거지?&quot; 싶었던 부분이 있었다. 정리해본다.</p>
<h3 id="q-deploysh는-어떻게-실행될까">Q. deploy.sh는 어떻게 실행될까?</h3>
<p>GitHub Actions의 deploy job이 self-hosted runner에서 실행한다.</p>
<pre><code>main에 push
  → GitHub Actions 트리거
    → build job (GitHub 서버): 테스트 + Docker 이미지 빌드
    → deploy job (내 서버의 runner): deploy.sh 실행</code></pre><p>방금 설치한 runner가 GitHub의 에이전트 역할을 해서, GitHub 리포에 있는 코드를 서버에 내려받고 실행해주는 거다.</p>
<h3 id="q-deploy_color는-어떻게-주입될까">Q. DEPLOY_COLOR는 어떻게 주입될까?</h3>
<p>Spring Boot의 <code>${DEPLOY_COLOR:local}</code>은 환경변수에서 값을 읽는다. 이 환경변수는 deploy.sh가 docker run할 때 넣어준다.</p>
<pre><code class="language-bash"># deploy.sh의 Step 1에서 상태 파일을 읽고 다음 컬러를 결정
NEXT=&quot;green&quot;  # (현재가 blue이므로)

# Step 3에서 컨테이너 시작할 때 환경변수로 주입
sudo docker run -e DEPLOY_COLOR=&quot;${NEXT}&quot; -e APP_VERSION=&quot;${DOCKER_TAG}&quot; ...</code></pre>
<p>컨테이너 자체는 자기가 blue인지 green인지 모른다. <strong>deploy.sh가 상태 파일을 보고 결정해서 환경변수로 알려주는 거다.</strong></p>
<hr>
<h2 id="무중단-검증">무중단 검증</h2>
<p>여기가 제일 중요한 부분이다. &quot;진짜 무중단인가?&quot;를 어떻게 확인했는지.</p>
<h3 id="방법-05초마다-요청을-계속-보내면서-배포">방법: 0.5초마다 요청을 계속 보내면서 배포</h3>
<p>터미널 2개를 열고:</p>
<p><strong>터미널 1</strong> — 모니터링 (0.5초마다 요청)</p>
<pre><code class="language-bash">while true; do
  RESULT=$(curl -s -o /tmp/resp.txt -w &quot;%{http_code}&quot; http://&lt;서버IP&gt;/api/deploy-info 2&gt;/dev/null)
  BODY=$(cat /tmp/resp.txt)
  VERSION=$(echo &quot;$BODY&quot; | grep -o &#39;&quot;version&quot;:&quot;[^&quot;]*&quot;&#39; | head -1)
  COLOR=$(echo &quot;$BODY&quot; | grep -o &#39;&quot;color&quot;:&quot;[^&quot;]*&quot;&#39; | head -1)
  echo &quot;$(date &#39;+%H:%M:%S&#39;) | HTTP ${RESULT} | ${VERSION} | ${COLOR}&quot;
  sleep 0.5
done</code></pre>
<p><strong>터미널 2</strong> — 코드 수정 후 push해서 배포 트리거</p>
<pre><code class="language-bash">git add . &amp;&amp; git commit -m &quot;test deploy v2&quot; &amp;&amp; git push</code></pre>
<h3 id="결과">결과</h3>
<pre><code>22:30:01 | HTTP 200 | &quot;version&quot;:&quot;abc1234&quot; | &quot;color&quot;:&quot;green&quot;
22:30:01 | HTTP 200 | &quot;version&quot;:&quot;abc1234&quot; | &quot;color&quot;:&quot;green&quot;
22:30:02 | HTTP 200 | &quot;version&quot;:&quot;abc1234&quot; | &quot;color&quot;:&quot;green&quot;
... (배포 진행 중 — 계속 200) ...
22:31:15 | HTTP 200 | &quot;version&quot;:&quot;abc1234&quot; | &quot;color&quot;:&quot;green&quot;
22:31:15 | HTTP 200 | &quot;version&quot;:&quot;def5678&quot; | &quot;color&quot;:&quot;blue&quot;    ← 여기서 전환!
22:31:16 | HTTP 200 | &quot;version&quot;:&quot;def5678&quot; | &quot;color&quot;:&quot;blue&quot;
22:31:16 | HTTP 200 | &quot;version&quot;:&quot;def5678&quot; | &quot;color&quot;:&quot;blue&quot;</code></pre><p>실제로 돌려본 결과:</p>
<p><img src="https://velog.velcdn.com/images/ho_d97/post/9eb854b2-5b3a-40a6-87ab-d7466610861a/image.png" alt="무중단 검증 — while loop 모니터링"></p>
<p><strong>502가 단 하나도 없다.</strong> 모든 요청이 HTTP 200이고, version과 color가 한 순간에 green→blue로 바뀌는 걸 볼 수 있다. 전환 사이에 요청 실패가 없었다.</p>
<p>이걸로 Blue-Green 무중단 배포가 정상 동작한다는 걸 확인했다.</p>
<h3 id="판단-기준">판단 기준</h3>
<table>
<thead>
<tr>
<th>결과</th>
<th>의미</th>
</tr>
</thead>
<tbody><tr>
<td>모든 응답이 HTTP 200</td>
<td><strong>무중단 성공</strong></td>
</tr>
<tr>
<td>version/color가 한 순간에 바뀜</td>
<td>Blue-Green 전환 정상</td>
</tr>
<tr>
<td>중간에 HTTP 502가 있음</td>
<td>무중단 실패 — 다운타임 발생</td>
</tr>
</tbody></table>
<hr>
<h2 id="정리">정리</h2>
<p>EC2 단일 서버에서 Nginx + Docker + GitHub Actions로 Blue-Green 무중단 배포를 구현하고 검증까지 완료했다.</p>
<p>핵심 포인트를 정리하면:</p>
<ol>
<li><p><strong>Nginx upstream 전환</strong>이 Blue-Green의 핵심이다. 새 컨테이너가 완전히 준비된 후에 트래픽을 전환하니까 다운타임이 0이다.</p>
</li>
<li><p><strong>Health Check만으로는 부족하다.</strong> Nginx를 경유하는 Smoke Test까지 해야 &quot;실제 사용자 관점에서 정상&quot;인지 확인할 수 있다.</p>
</li>
<li><p><strong>서버 세팅에서 삽질이 제일 많았다.</strong> 코드 구현보다 Runner 권한 문제, 경로 문제, 소유권 문제를 잡는 데 시간이 더 걸렸다.</p>
</li>
<li><p><strong>자동 롤백 구조</strong>가 있으니까 배포가 편하다. Health check 실패하면 기존 컨테이너가 계속 돌아가니까 사용자 영향 없이 롤백된다.</p>
</li>
</ol>
<p>이제 이 구조를 MOA 운영 서비스에 적용하면 되는데, POC에서는 안 다뤘지만 실제 MOA에 붙이면서 고민해야 할 것들이 있다.</p>
<hr>
<h2 id="다음--moa에-실제-적용하면서-다뤄볼-것들">다음 — MOA에 실제 적용하면서 다뤄볼 것들</h2>
<p>POC는 순수한 API 서버였다. 근데 MOA는 그렇게 단순하지 않다. 실제로 적용하면서 추가로 풀어야 할 문제들이 있는데, 미리 정리해둔다.</p>
<h3 id="배치-작업과-배포의-충돌">배치 작업과 배포의 충돌</h3>
<p>MOA에는 매일 12시에 돌아가는 알림 배치가 있다. 만약 이 배치가 FCM을 100건 보내는 도중에 배포가 들어오면?</p>
<pre><code>12:00:00  Blue에서 알림 배치 시작 (FCM 100건 발송 중...)
12:00:30  배포 → Nginx 전환 → Blue에 SIGTERM
12:00:32  graceful shutdown 시작
          → HTTP 요청은 보호됨
          → ★ 근데 @Scheduled 배치는 HTTP 요청이 아님
12:01:02  30초 후 강제 종료 → 배치가 50건만 보내고 죽음 💥</code></pre><p><code>server.shutdown: graceful</code>이 보호하는 건 HTTP 요청뿐이다. <code>@Scheduled</code> 배치는 graceful shutdown 대상이 아니라서, Spring이 컨텍스트를 닫으면 그냥 중간에 끊긴다.</p>
<p>이 문제를 어떻게 풀 건지 — 배치를 graceful shutdown 대상에 포함시키는 방법, 배치 자체를 멱등하게 만드는 방법, 배포 전 배치 실행 여부를 체크하는 방법 등을 MOA 적용편에서 다뤄보려 한다.</p>
<h3 id="그-외-moa-적용-시-고려사항">그 외 MOA 적용 시 고려사항</h3>
<ul>
<li><strong>DB 스키마 변경이 있는 배포</strong>: Blue와 Green이 동시에 같은 DB를 보는데, 컬럼 삭제 같은 breaking change가 있으면 Blue가 터진다. 하위 호환성을 유지하면서 2단계로 나눠 배포해야 한다.</li>
<li><strong>기존 CI/CD 파이프라인 전환</strong>: 현재 MOA의 <code>docker stop → docker run</code> 방식을 deploy.sh 기반으로 교체하면서 기존 workflow를 어디까지 건드릴지.</li>
<li><strong>SSL 환경에서의 Nginx 설정</strong>: POC는 HTTP로 테스트했는데, MOA는 HTTPS(Let&#39;s Encrypt)가 걸려있다. upstream 설정을 SSL server 블록 안에 넣는 구조로 바꿔야 한다.</li>
</ul>
<p>이런 부분들을 실제로 적용하면서 다음 글에 정리해보겠다.</p>
<blockquote>
<p>claude + gemini이와 함께 공부하며 작성했습니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[무중단 배포 Blue-Green (1)]]></title>
            <link>https://velog.io/@ho_d97/%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-Blue-Green-1</link>
            <guid>https://velog.io/@ho_d97/%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC-Blue-Green-1</guid>
            <pubDate>Sun, 12 Apr 2026 14:50:23 GMT</pubDate>
            <description><![CDATA[<h1 id="ec2-단일-서버에서-blue-green-무중단-배포-구현하기--1편-설계">EC2 단일 서버에서 Blue-Green 무중단 배포 구현하기 — 1편: 설계</h1>
<blockquote>
<p>MOA 운영 서비스에 적용하기 전, POC 프로젝트로 먼저 검증해본 기록
<a href="https://apps.apple.com/kr/app/moa-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%9B%94%EA%B8%89-%EC%B2%B4%EA%B0%90/id6759603878">IOS_MOA 설치</a>
[안드로이드 MOA 설치] (<a href="https://play.google.com/store/apps/details?id=com.moa.salary.app&amp;hl=ko">https://play.google.com/store/apps/details?id=com.moa.salary.app&amp;hl=ko</a>)</p>
</blockquote>
<hr>
<h2 id="배포할-때마다-서버가-죽는다">배포할 때마다 서버가 죽는다</h2>
<p>현재 MOA 서비스의 배포 방식은 이렇다.</p>
<pre><code class="language-bash">docker stop moa-server     # ← 여기서부터 서비스 중단
docker rm moa-server
docker pull new-image
docker run moa-server      # ← Spring Boot 기동까지 10~20초</code></pre>
<p><code>docker stop</code>을 하는 순간부터 새 컨테이너의 Spring Boot가 완전히 뜰 때까지 <strong>약 10~30초 동안 서비스가 죽어있다.</strong> 이 시간 동안 사용자가 앱을 열면 502 Bad Gateway가 뜬다.</p>
<p>주 1회 배포면 월간 다운타임이 약 2분인데, 솔직히 수치만 보면 대단한 건 아니다. 근데 문제는 <strong>&quot;배포 = 서비스 중단&quot;이라는 구조 자체</strong>다. 배포할 때마다 긴장하게 되고, 사용자가 적은 새벽에만 배포하게 되고, 그러다 보면 배포 주기가 점점 길어진다.</p>
<p>무중단 배포는 이 구조적 문제를 없애는 거다. 배포해도 사용자한테 영향이 없으니까 언제든 편하게 배포할 수 있다.</p>
<hr>
<h2 id="왜-blue-green인가">왜 Blue-Green인가</h2>
<p>무중단 배포 전략은 여러 가지가 있는데, 비교해보면 이렇다.</p>
<table>
<thead>
<tr>
<th>전략</th>
<th>핵심 원리</th>
<th>장점</th>
<th>단점</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Blue-Green</strong></td>
<td>동일 환경 2개, 트래픽을 한번에 전환</td>
<td>즉시 롤백, 구현 단순</td>
<td>순간 리소스 2배</td>
</tr>
<tr>
<td><strong>Rolling Update</strong></td>
<td>N개 인스턴스를 하나씩 교체</td>
<td>리소스 효율적</td>
<td>최소 2대 이상 필요</td>
</tr>
<tr>
<td><strong>Canary</strong></td>
<td>새 버전에 트래픽 일부만 보내고 점진 확대</td>
<td>위험 최소화</td>
<td>구현 복잡</td>
</tr>
<tr>
<td><strong>In-Place</strong></td>
<td>그냥 멈추고 교체</td>
<td>가장 단순</td>
<td>다운타임 발생</td>
</tr>
</tbody></table>
<p>MOA에서 Blue-Green을 선택한 이유는 명확했다.</p>
<ol>
<li><strong>EC2 서버가 1대</strong> → Rolling Update는 서버가 여러 대 있어야 가능하니까 불가</li>
<li><strong>Nginx가 이미 깔려있음</strong> → 추가 인프라 없이 upstream 설정만 바꾸면 됨</li>
<li><strong>DAU 80명</strong> → Canary의 &quot;일부 트래픽 검증&quot;이 통계적으로 무의미</li>
<li><strong>즉시 롤백</strong> → Nginx reload 한 번이면 1초 안에 이전 버전으로 복원</li>
</ol>
<p>쿠버네티스 쓰면 더 깔끔하겠지만, 120명 규모 서비스에 K8s는 과하다. 컨트롤 플레인만 해도 메모리 2GB 이상 잡아먹는데, 그 리소스와 학습 비용을 감당할 이유가 없었다. (이후 학습후 롤링 배포를 도전해보자!!)</p>
<hr>
<h2 id="전체-아키텍처">전체 아키텍처</h2>
<h3 id="before--단일-컨테이너">Before — 단일 컨테이너</h3>
<pre><code>Client → Nginx(:80) → moa-server(:8080)
                       배포 시 stop → start = 다운타임</code></pre><h3 id="after--blue-green">After — Blue-Green</h3>
<pre><code>Client → Nginx(:80) → upstream(전환 가능)
                       ├─ Blue  (:8080)  ← 현재 활성
                       └─ Green (:8081)  ← 대기/신규 배포</code></pre><p>핵심은 <strong>Nginx의 upstream 설정을 바꾸고 reload</strong>하는 것이다. 새 컨테이너가 완전히 준비된 후에 트래픽을 전환하니까, 클라이언트 입장에서는 끊김이 없다.</p>
<p><code>nginx -s reload</code>가 무중단인 이유도 재밌는데, Nginx는 Master-Worker 모델이라 reload 시 기존 Worker는 현재 처리 중인 커넥션만 마무리하고, 새 Worker가 새 설정으로 요청을 받는다. 그래서 어떤 커넥션도 끊기지 않는다.</p>
<hr>
<h2 id="배포-플로우-전체-그림">배포 플로우 전체 그림</h2>
<pre><code>GitHub에 push
  → GitHub Actions (build job)
    → 테스트 실행
    → Docker 이미지 빌드 + Docker Hub push
  → GitHub Actions (deploy job) — 서버의 self-hosted runner가 실행
    → deploy.sh 실행
      → Step 1: 현재 활성 컬러 확인 (blue/green)
      → Step 2: 새 이미지 pull
      → Step 3: 새 컨테이너 시작 (대기 포트에)
      → Step 4: Health check (최대 60초)
      → Step 5: Nginx upstream 전환 + reload
      → Step 6: Smoke test (nginx 경유 확인)
      → Step 7: 이전 컨테이너 graceful shutdown
      → Step 8: 상태 파일 + 배포 이력 업데이트
      → Step 9: Docker 이미지 정리</code></pre><p>전체 과정에서 <strong>클라이언트 요청이 실패하는 구간은 없다.</strong> Step 4까지는 기존 컨테이너가 트래픽을 처리하고, Step 5에서 전환한 후에는 새 컨테이너가 처리한다.</p>
<hr>
<h2 id="구현-코드-상세">구현 코드 상세</h2>
<h3 id="버전-확인-api--deployinfocontroller">버전 확인 API — DeployInfoController</h3>
<p>배포 후 &quot;진짜 새 버전이 뜬 건가?&quot;를 확인할 수 있는 API가 필요했다. <code>/api/deploy-info</code>를 호출하면 현재 버전, 컬러, 기동 시간을 반환한다.</p>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/api&quot;)
public class DeployInfoController {

    private final String version;
    private final String color;
    private final Instant startedAt;

    public DeployInfoController(
            @Value(&quot;${app.version:dev}&quot;) String version,
            @Value(&quot;${app.deploy-color:local}&quot;) String color) {
        this.version = version;
        this.color = color;
        this.startedAt = Instant.now();
    }

    @GetMapping(&quot;/deploy-info&quot;)
    public DeployInfoResponse deployInfo() {
        return DeployInfoResponse.of(version, color, startedAt);
    }
}</code></pre>
<p><code>APP_VERSION</code>과 <code>DEPLOY_COLOR</code>는 Docker 컨테이너 실행 시 환경변수로 주입된다. 로컬에서 <code>./gradlew bootRun</code>으로 실행하면 환경변수가 없으니까 기본값인 <code>&quot;dev&quot;</code>, <code>&quot;local&quot;</code>이 들어간다.</p>
<p>실제 배포 후 이 API를 호출하면 이런 응답이 온다:</p>
<p><img src="img/response-api.png" alt="deploy-info API 응답"></p>
<p>처음에 이 부분이 좀 헷갈렸는데, 결국 이런 흐름이다:</p>
<pre><code>deploy.sh에서 현재 상태 파일 읽음 → &quot;blue&quot;
→ 다음 컬러는 &quot;green&quot;
→ docker run -e DEPLOY_COLOR=green -e APP_VERSION=abc123 ...
→ Spring Boot가 환경변수 읽음 → ${DEPLOY_COLOR:local} → &quot;green&quot;</code></pre><h3 id="graceful-shutdown-설정--application-prodyaml">Graceful Shutdown 설정 — application-prod.yaml</h3>
<pre><code class="language-yaml">server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

management:
  endpoints:
    web:
      exposure:
        include: health
  endpoint:
    health:
      show-details: never</code></pre>
<p><code>server.shutdown: graceful</code> 이 없으면 SIGTERM 받았을 때 진행 중인 요청을 바로 끊어버린다. 이 설정을 넣으면 <strong>새 요청은 거부하되, 처리 중인 요청은 완료할 때까지 기다려준다.</strong> 최대 30초.</p>
<p><code>show-details: never</code>로 한 이유는 보안 때문이다. health endpoint는 인증 없이 접근 가능한데, 상세 정보에 DB 호스트나 디스크 경로 같은 인프라 정보가 포함될 수 있다.</p>
<h3 id="dockerfile--multi-stage-빌드">Dockerfile — Multi-stage 빌드</h3>
<pre><code class="language-dockerfile"># Stage 1: Build
FROM eclipse-temurin:21-jdk AS builder
WORKDIR /build
COPY gradle/ gradle/
COPY gradlew settings.gradle build.gradle ./
RUN chmod +x gradlew &amp;&amp; ./gradlew dependencies --no-daemon || true
COPY src/ src/
RUN ./gradlew bootJar --no-daemon -x test

# Stage 2: Runtime
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /build/build/libs/*.jar app.jar
RUN mkdir -p /app/logs
EXPOSE 8080
ENTRYPOINT [&quot;java&quot;, &quot;-jar&quot;, &quot;app.jar&quot;]</code></pre>
<p>2단계로 나눈 이유는 이미지 크기 때문이다. JDK(<del>450MB) 대신 JRE(</del>200MB)를 런타임에 쓰면 이미지가 절반 이하로 줄어든다. 배포마다 이미지가 쌓이니까 이 차이가 디스크 관리에서 꽤 중요하다.</p>
<p>Gradle 의존성을 먼저 다운받는 레이어를 분리한 것도 포인트인데, 소스 코드만 바뀌면 의존성 레이어는 Docker 캐시에서 가져오니까 빌드 시간이 확 줄어든다.</p>
<h3 id="배포-스크립트--deploysh">배포 스크립트 — deploy.sh</h3>
<p>이게 Blue-Green 배포의 핵심이다. 전체 코드는 길어서 주요 부분만 짚어보면:</p>
<p><strong>동시 배포 방지 (flock)</strong></p>
<pre><code class="language-bash">exec 200&gt;&quot;${LOCK_FILE}&quot;
if ! flock -n 200; then
    echo &quot;[ERROR] Another deployment is already running. Exiting.&quot;
    exit 1
fi</code></pre>
<p>누군가 SSH로 직접 deploy.sh를 실행하는 경우를 대비한 OS 수준의 파일 락이다. GitHub Actions의 concurrency 설정과 이중으로 방어한다.</p>
<blockquote>
<p>해당 부분에 대해서 claudeCode에게 리뷰를 받았고 지적 받았다.
혼자서는 생각해볼 수 없는 부분인데 참고할 수 있어서 너무 좋은듯</p>
</blockquote>
<p><strong>Health Check</strong></p>
<pre><code class="language-bash">for i in $(seq 1 ${HEALTH_CHECK_MAX_RETRY}); do
    HEALTH=$(curl -sf &quot;http://localhost:${NEXT_PORT}/actuator/health&quot; 2&gt;/dev/null || true)
    if echo &quot;${HEALTH}&quot; | grep -q &#39;&quot;status&quot;:&quot;UP&quot;&#39;; then
        echo &quot;[Step 4] Health check PASSED&quot;
        break
    fi
    if [ &quot;${i}&quot; -eq &quot;${HEALTH_CHECK_MAX_RETRY}&quot; ]; then
        # 실패 → 새 컨테이너 정지, 기존 유지
        sudo docker stop &quot;${NEXT_CONTAINER}&quot; || true
        sudo docker rm &quot;${NEXT_CONTAINER}&quot; || true
        exit 1
    fi
    sleep ${HEALTH_CHECK_INTERVAL}
done</code></pre>
<p>최대 30번 × 2초 = 60초 동안 health check를 시도한다. 실패하면 새 컨테이너를 정리하고 스크립트를 종료한다. <strong>기존 컨테이너는 건드리지 않으니까 사용자 영향 없이 자동 롤백</strong>되는 셈이다.</p>
<p><strong>Smoke Test</strong></p>
<pre><code class="language-bash">SMOKE_RESULT=$(curl -sf &quot;http://localhost/api/deploy-info&quot; 2&gt;/dev/null || true)
if echo &quot;${SMOKE_RESULT}&quot; | grep -q &quot;\&quot;version\&quot;:\&quot;${DOCKER_TAG}\&quot;&quot;; then
    echo &quot;[Step 6] Smoke test PASSED&quot;
else
    # Nginx를 이전 컬러로 되돌림
    ...
fi</code></pre>
<p>Health check와 Smoke test의 차이가 중요한데:</p>
<ul>
<li>Health check: 컨테이너에 <strong>직접</strong> 요청 (port 8081)</li>
<li>Smoke test: <strong>Nginx를 경유</strong>해서 요청 (port 80)</li>
</ul>
<p>Nginx upstream 설정이 잘못되면 health check는 통과해도 사용자는 502를 볼 수 있다. Smoke test는 실제 사용자 경로를 그대로 테스트하는 거다.</p>
<h3 id="github-actions--deployyml">GitHub Actions — deploy.yml</h3>
<pre><code class="language-yaml">name: Blue-Green Deploy

on:
  push:
    branches: [ main ]

concurrency:
  group: deploy-production
  cancel-in-progress: false  # 진행 중인 배포는 절대 취소하지 않음</code></pre>
<p><code>cancel-in-progress: false</code>가 핵심이다. <code>true</code>로 하면 빠른 연속 push 시 진행 중인 배포가 취소되는데, deploy.sh가 nginx 전환 중간에 죽으면 nginx가 존재하지 않는 컨테이너를 가리키게 될 수 있다. <code>false</code>로 하면 첫 번째 배포가 끝날 때까지 두 번째가 큐에서 대기한다.</p>
<p>deploy job은 self-hosted runner에서 실행되는데, 리포에서 <code>scripts/</code> 폴더만 sparse-checkout으로 가져와서 deploy.sh를 실행한다. 실제로 GitHub Actions에서 deploy가 돌아가면 이런 로그가 나온다:</p>
<p><img src="https://velog.velcdn.com/images/ho_d97/post/239bc3ae-0018-46cd-9680-408e73066c41/image.png" alt="GitHub Actions deploy 로그"></p>
<p>Current가 blue(:8080)이고 Next가 green(:8081)인 걸 볼 수 있다. 다음 배포 때는 반대로 green→blue가 된다.</p>
<pre><code class="language-yaml">deploy:
  needs: build
  runs-on:
    - self-hosted

  steps:
    - name: Checkout deploy scripts
      uses: actions/checkout@v4
      with:
        sparse-checkout: |
          scripts

    - name: Run Blue-Green Deploy
      env:
        DOCKER_IMAGE: ${{ secrets.DOCKER_USERNAME }}/zero-downtime
      run: |
        chmod +x scripts/deploy.sh
        sudo -E bash scripts/deploy.sh &quot;${{ needs.build.outputs.docker_tag }}&quot;</code></pre>
<h3 id="롤백-전략">롤백 전략</h3>
<p><strong>자동 롤백</strong>: Health check 실패하면 새 컨테이너 정리하고 끝. 기존 컨테이너가 계속 돌고 있으니까 다운타임 없음.</p>
<p><strong>수동 롤백</strong>: GitHub Actions UI에서 rollback.yml을 트리거하면 된다. 이전 태그를 입력하거나, 비워두면 deploy-history 파일에서 직전 성공 태그를 자동으로 찾아서 재배포한다.</p>
<pre><code class="language-yaml"># .github/workflows/rollback.yml
on:
  workflow_dispatch:
    inputs:
      docker_tag:
        description: &#39;Rollback할 Docker 태그. 비우면 직전 버전으로 롤백&#39;
        required: false</code></pre>
<p>롤백도 결국 deploy.sh를 재실행하는 거라서, health check, smoke test 등 동일한 안전장치가 적용된다.</p>
<hr>
<h2 id="놓치기-쉬운-것들">놓치기 쉬운 것들</h2>
<p>구현하면서 처음에 생각 못 했다가 나중에 추가한 부분들이 있다.</p>
<p><strong>1. Docker 이미지가 쌓인다</strong></p>
<p>배포마다 ~200MB 이미지가 pull된다. EC2 t2.micro 기본 디스크가 8GB인데, 25번 정도 배포하면 꽉 찬다. 디스크 풀 나면 docker pull이 실패하면서 배포가 막힌다. deploy.sh 마지막에 <code>docker image prune -f</code>를 넣어서 해결했다.</p>
<p><strong>2. 로그에서 blue/green 구분이 안 된다</strong></p>
<p>두 컨테이너가 같은 볼륨에 로그를 쓰는데, 태깅이 없으면 에러 로그가 이전 버전에서 난 건지 새 버전에서 난 건지 알 수가 없다. 로그 패턴에 <code>[${DEPLOY_COLOR}]</code>을 넣어서 해결했다.</p>
<p><strong>3. 배포 이력이 없으면 롤백이 느리다</strong></p>
<p>장애 나서 롤백하려는데 &quot;이전 버전 태그가 뭐였지?&quot; → GitHub Actions 로그 뒤져야 한다. deploy-history 파일에 매 배포마다 <code>timestamp|color|tag|status</code>를 기록해두니까 rollback.sh가 직전 태그를 바로 찾을 수 있다.</p>
<p><strong>4. 첫 배포에서 스크립트가 죽을 수 있다</strong></p>
<p>최초 배포 시 &quot;이전 컨테이너&quot;가 없는데, <code>docker stop</code>을 하면 에러가 난다. <code>set -e</code> 때문에 스크립트가 바로 죽는다. 모든 docker stop/rm 앞에 <code>docker inspect</code>로 존재 확인 + <code>|| true</code>로 방어했다.</p>
<hr>
<p>다음 글에서는 실제 서버에 세팅하면서 겪은 트러블슈팅과 무중단 검증 결과를 정리해보겠다.</p>
<blockquote>
<p><a href="/docs/blog-part2-hands-on.md">2편: 실전 — 서버 세팅, 트러블슈팅, 무중단 검증</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[N + 1 이슈 발생 … (1)]]></title>
            <link>https://velog.io/@ho_d97/jpan11</link>
            <guid>https://velog.io/@ho_d97/jpan11</guid>
            <pubDate>Mon, 02 Jun 2025 09:56:15 GMT</pubDate>
            <description><![CDATA[<p>JPA를 공부하면서 <code>N + 1</code> 이라는 것을 알고 있었다.
하지만 CRUD 작업에 급급했기에 직접 쿼리가 어떻게 발생하고 있는지 확인하지 않았다.
그냥 기능 구현에 급급했던것 같다. 이번에 새로운 직장인 휴가관리 프로젝트를 개인적으로 진행하면서 <code>N + 1</code> 상황을 직접 마주할 수 있었고 관련해서 정리하면서 성장하려고 한다.</p>
<hr>
<h2 id="문제-상황">문제 상황</h2>
<p>팀원 휴가를 조회하는 API를 만들고 있었다. 응답에는 작성자 이름(=유저 이름)이 포함돼야 한다.
하지만 휴가 테이블에는 이름을 직접 저장하지 않고, USER 테이블의 PK를 FK로 들고 있는 전형적인 설계다.
그래서 자바 세상(JPA)에서는 휴가 엔티티에서 참조를 통해 유저 이름에 접근했다.</p>
<pre><code>// VacationHistory.java (요약)
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = &quot;user_id&quot;)
private User user; // author, writer … 어떤 네이밍이 더 나을까요?</code></pre><blockquote>
<p>네이밍은 author, writer 중 하나면 충분히 직관적이라고 생각하지만, 더 좋은 의견이 있다면 댓글 부탁드립니다!</p>
</blockquote>
<h3 id="로그로-본-n--1">로그로 본 N + 1</h3>
<p>다음 스크린샷은 User 테이블을 추가 조회하는 쿼리가 연달아 날아가는 모습이다.</p>
<p>휴가 3건이면 쿼리가 3번, 앞으로 유저 수가 100명, 1000명 늘어나면 1000번….
이건 누가 봐도 비효율이다. “차라리 한 방에 가져오면 안 될까?”</p>
<p><img src="https://velog.velcdn.com/images/ho_d97/post/bfb73832-1b26-4fb4-af71-fe26e49cd59d/image.png" alt=""></p>
<h3 id="join-vs-fetch-join">JOIN VS FETCH JOIN</h3>
<blockquote>
<p>👉 결론부터: JOIN FETCH를 쓰면 N + 1을 없앨 수 있지만,
⁠⁠꼭 필요한 경우에만, 그리고 페이징 제약을 기억하면서 사용하자.</p>
</blockquote>
<p><strong>JOIN (일반 조인)</strong></p>
<pre><code>@Query(&quot;&quot;&quot;
SELECT vh
FROM VacationHistory vh
JOIN   vh.user u
WHERE  vh.startDate &gt;= :lastMonthLastWeek
  AND  vh.endDate   &lt;= :nextMonthFirstWeek
  AND  vh.department.id = :departmentId
ORDER BY vh.startDate
&quot;&quot;&quot;)
List&lt;VacationHistory&gt; findAllByDepartment(...);</code></pre><ul>
<li>SQL 차원에서는 JOIN 으로 묶여 한 번에 가져오는 것처럼 보인다.</li>
<li>하지만 영속성 컨텍스트에는 VacationHistory 만 저장되고, User 는 프록시 상태로 남는다.</li>
<li>vh.getUser().getName() 이 호출되는 순간 추가 SELECT 가 날아가 N + 1 발생.</li>
<li>연관 필드를 EAGER 로 바꿔도 별도 SELECT 나 조인 으로 한 번 더 불러오는 건 마찬가지일 수 있다.</li>
</ul>
<blockquote>
<p>JOIN을 사용해서 문제를 해결하기 위해서는 DTO 프로젝션 방법을 선택해야한다. (다음글에서 정리)</p>
</blockquote>
<p><strong>JOIN FETCH</strong></p>
<pre><code>@Query(&quot;&quot;&quot;
SELECT vh
FROM VacationHistory vh
JOIN FETCH vh.user u
WHERE  vh.startDate &gt;= :lastMonthLastWeek
  AND  vh.endDate   &lt;= :nextMonthFirstWeek
  AND  vh.department.id = :departmentId
ORDER BY vh.startDate
&quot;&quot;&quot;)</code></pre><ul>
<li>단일 쿼리에 VacationHistory와 User 를 모두 끌어와서 즉시 영속성 컨텍스트에 적재.</li>
<li>이후 vh.getUser() 를 호출해도 추가 쿼리가 없다 → N + 1 종결</li>
<li>내부적으로는 SQL JOIN 으로 구현되지만, JPA가 중복 행을 병합해 엔티티는 하나씩만 유지한다.</li>
</ul>
<h3 id="항상-fetch-join을-사용하면-안될까">항상 FETCH JOIN을 사용하면 안될까?</h3>
<p>👍 장점
N + 1 해결 &amp; 직렬화에도 유리</p>
<p>복잡한 매핑이라도 쿼리 1회로 데이터 확보</p>
<p>⚠️ 주의할 점
불필요한 데이터까지 한 번에 끌어와서 메모리 낭비할 수 있다.</p>
<p>컬렉션(fetch join) + 페이징을 동시에 쓰면,</p>
<p>(Hibernate 기준) DB단 LIMIT/OFFSET이 무력화되고</p>
<p>모든 데이터를 긁어온 뒤 메모리 페이징 → OOM 위험하다.</p>
<h3 id="마무리">마무리</h3>
<p>N + 1 문제가 발생한 것과 어떻게 문제를 해결할 수 있는지 간단하게 작성해보았다.
하지만 아직 구체적인 상황에 따라서 최적의 선택을 하는 방법까지 학습하지 못했다.
무조건 FETCH JOIN을 사용하는 것도 올바른 방법도 아니라고 생각한다. 관련된 내용을 정리해서 추가 글을 작성해야겠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[HTTPS는 왜 비대칭키와 대칭키 모두 사용할까?]]></title>
            <link>https://velog.io/@ho_d97/HttpsKey</link>
            <guid>https://velog.io/@ho_d97/HttpsKey</guid>
            <pubDate>Tue, 08 Apr 2025 16:11:16 GMT</pubDate>
            <description><![CDATA[<p>왜? https는 비대칭키를 사용해서 대칭키를 나누고 이를 사용할까?
그냥 비대칭키를 계속 사용하면 되지 않을까?
라는 질문을 받았다.</p>
<p>대답할 수 없어서 글을 쓰면서 다음엔 대답할 수 있는 개발자가 되어보자</p>
<p>먼저 결론부터 말하면 </p>
<ol>
<li>비대칭 키는 느리다.</li>
<li>클라이언트의 공개키를 서버가 기억해야 하므로 무상태성이 깨진다.</li>
</ol>
<h3 id="대칭키와-비대칭키-이해하기">대칭키와 비대칭키 이해하기</h3>
<p>먼저 대칭키랑 암호화, 복호화에 사용하는 키가 동일하다.</p>
<p>비대칭키/ 공개키 : 암호화, 복호화에 사용하는 키가 서로 다르다.</p>
<ul>
<li><p>대칭키</p>
<p>  세션키, 시크릿 키, 공유 키, 대칭키 ,단용키 라고도 부른다.</p>
<ul>
<li>장점<ul>
<li>구현이 용이하다.</li>
<li>데이터를 암호화하기 위한 연상이 빨라 대용량 데이터 암호화에 적합하다.</li>
<li>기밀성을 제공한다.</li>
</ul>
</li>
<li>단점<ul>
<li>강한 보안을 위해 키를 주기적으로 교환해주는 것이 좋다.</li>
<li>키 탈취 및 관리가 어렵다.</li>
<li>무결성 지원이 부분적으로만 가능하다.</li>
<li>부인 방지 기능을 제공하지 못한다.</li>
</ul>
</li>
</ul>
</li>
<li><p>비대칭키</p>
<ul>
<li><p>장점</p>
<ul>
<li>키 분배 및 키 관리가 용이하다.</li>
<li>기밀성, 무결성을 지원한다.</li>
<li>부인 방지 기능을 제공한다.</li>
<li>암호학적 문제를 해결할 수 있다.</li>
</ul>
</li>
<li><p>단점</p>
<ul>
<li>상대적으로 키의 길이가 길다.</li>
<li>연산속도가 느리다.</li>
</ul>
</li>
<li><p><em>수신자의 공개키로 암호화하면 이를 수신자의 개인키로 복호화해서 볼 수 있다. (수신자만 해독가능)*</em></p>
<h3 id="계속-헷갈리는-부분">계속 헷갈리는 부분</h3>
<p>서버는 개인키를 소유하고 있다 절대 노출되면 안되는 키다.</p>
<p>공개키는 누구나 사용할 수 있다.</p>
<p>그래서 클라이언트는 공개키로 암호화하고 서버에 보내면 이를 해독할 수 있는건 오직 개인키 뿐이니까
안전하게 요청을 보낼 수 있다.</p>
<p>자 그럼 서버에서 개인키로 암호화해서 클라이언트로 보내면 중간에 누가 탈취하면 누구나 공개키를 알 수 있으니까 응답 내용을 다른 사람이 볼 수 있는거 아닐까?</p>
</li>
<li><p><em>먼저 결론부터 말하면 서버는 민감 정보를 보내지 않는다.*</em></p>
<p>진짜 서버가 보낸 응답인지 검증할 뿐이다. 개인키로 암호화한 것만 공개키로 열리니까</p>
<p>이게 진짜 서버에서 왔다 변조가 안됐다는걸 보장할 수 있는거다.</p>
</li>
<li><p><em>그럼? 내용은 어떻게 암호화할건데요? (기밀성 보장은 어떻게?)*</em></p>
<p>내가 자주 헷갈렸던 부분은 서버에서 대칭키를 만들어서 개인키로 암호화하고 클라이언트랑 공유한다고 생각했다. 하지만 반대였다. 클라이언트가 공개키로 암호화해서 서버에 대칭키를 공유하는 것이다.</p>
<p>그럼 서버만 해독할 수 있는 공개키로 암호화 했기 때문에 안전하게 대칭키를 공유할 수 있다.</p>
</li>
<li><p><em>여기서 궁금한 건 그럼 비대칭 키만 이용해서는 계속 통신할 수 없을까?*</em></p>
<p>서버도 개인키/공개키를 가지고 있고 클라이언트도 개인키/공개키를 가지고 있다면 각자의 공개키로 암호화해서 보내는 방식으로 통신은 가능할 것이다.</p>
<p>근데 이게 대칭키를 공유해서 사용하는 방식보다 느리다고 한다.</p>
</li>
</ul>
<ol>
<li><p><strong>느리다. 비대칭 키 왜 느릴까?</strong> </p>
<p>구조적인 차이 때문이라고 한다.</p>
<table>
<thead>
<tr>
<th>항목</th>
<th>비대칭키 (RSA, ECC)</th>
<th>대칭키 (AES 등)</th>
</tr>
</thead>
<tbody><tr>
<td>암호화/ 복호화 속도</td>
<td>느림(1000~ 1000배 이상 느림)</td>
<td>매우 빠름</td>
</tr>
<tr>
<td>키 길이</td>
<td>길다( 2048 ~ 4096 bit)</td>
<td>짧다(128~256bit)</td>
</tr>
<tr>
<td>CPU 사용량</td>
<td>높음 (수학적으로 무거운 연산)</td>
<td>낮음</td>
</tr>
<tr>
<td>데이터 양</td>
<td>적은 데이터에 적합</td>
<td>큰 데이터에 적합</td>
</tr>
</tbody></table>
</li>
</ol>
<p>  <strong>WHY? 비대칭키랑 대칭키의 암호화 방식이 다를까?</strong></p>
<p>  둘 다 암호화한다는 목적이 같은데 왜 서로 다른 것을 사용하고 그래서 암호화, 복호화 속도까지 느리게할까?</p>
<p>  좋은게 좋은거라고 안전하고 빠른 방법을 둘 다 선택해서 사용하면 되는거 아닐까? 라는 의문이 든다.</p>
<p>  먼저 RSA / ECC 는 키 공유 + 인증 을 위한 알고리즘 이라고 한다.</p>
<p>  AES는 “데이터 보안”을 위한 알고리즘이다.</p>
<p>  이렇게 작성해두면 잘 와닿지 않는다.</p>
<p>  좀 풀어서 얘기하면 비대칭 키 방식은 우체통과 같다. 누구나 편지를 넣을 수 우체통 키를 가지고 있는 우체부만 편지를 꺼낼 수 있다. RSA/ ECC 는 우체통과 같은 구조를 만들어주는 알고리즘이다. 
  근데 이 알고리즘은 CPU 사용량이 높기에 많이 사용한다면 통신 비용이 증가할 것이다.</p>
<p>  이에 비해 단순한 구조르 가진 대칭키가 있다. 대칭키는 예를 들자면 현관문 전자 비밀번호 같은 것 이다. 비밀번호를 아는 누구나 출입 가능한다. 근데 한가지 문제점은 이 비밀번호르 어떻게 안전하게 공유할 것 이냐는 것이다. </p>
<p>  자 이 문제를 해결하기 위해서 HTTPS는 비대칭키랑 대칭키를 적절히 함께 사용하는 것이다.</p>
<ol>
<li><p><strong>서버는 클라이언트의 공개키를 알기 힘들다</strong></p>
<p> 기본적으로 HTTPS 에서는 클라이언트는 익명이다.</p>
<ul>
<li><p>브라우저가 서버에 접속할 때 → 클라이언트의 공개키는 전달되지 않음</p>
<p>  그렇기 때문에 암호화해서 줄 수가 없을 것이다.</p>
</li>
</ul>
</li>
</ol>
</li>
</ul>
<pre><code>    **그럼 클라이언트가 공개키를 서버에 보내면 되잖아?**

    키 관리가 복잡할 것 같다.

    서버는 클라이언트마다 키를 관리하고 그에 맞는 공개키로 암호화해서 보내야 하니까 
    무상태를 유지 할 수 없을 것이다.

    그리고 클라이언트가 보낸 공개키가 진짜냐?를 검증할 수 있는 방법이 없다. (근데 검증을 해야할까?)</code></pre><hr>
<center>
    ⭐틀린 내용 수정,지적은 언제나 환영합니다.⭐
<center>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] 필터  알아보기1]]></title>
            <link>https://velog.io/@ho_d97/Spring-filter1</link>
            <guid>https://velog.io/@ho_d97/Spring-filter1</guid>
            <pubDate>Mon, 18 Nov 2024 17:22:21 GMT</pubDate>
            <description><![CDATA[<p>해당 글은 <a href="https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2">스프링 MVC2편</a>
강의를 기반으로 작성합니다.</p>
<p>필터와 인터셉터의 차이를 명확하게 이해하고 사용하는 것은 중요하다. 추가적으로 AOP와 차이는 무엇일까? 
라는 의문에서 다시 한번 강의를 들으면서 이해하고 마지막엔 나의 생각으로 각 차이를 정리해보기 위해 강의를 다시 보면서 공부해보자.</p>
<p>나의 웹 사이트를 모든 회원이 로그인 한 사용자만 사용하도록 하고 싶다.</p>
<p>이러한 것을 <strong>공통 관심사항</strong>이라고 할 수 있다.</p>
<p>모든 컨트롤러에 관련된 서비스 또는 컨트롤러에 로그인 인증 코드를 작성한다면 너무 많은 
중복 코드가 작성되야한다.</p>
<p>근데 이런 공통 관심사를 AOP 를 이용해서 해결할 수 있지 않나?</p>
<p>일단 그래도 되겠지만 웹과 관련된 공통 관심사의 경우 서블릿 필터 또는 인터셉터를 사용한다.</p>
<h3 id="why">WHY?</h3>
<p>웹과 관련된 공통 관심사를 처리할 때는 HTTP의 헤더나 URL의 정보들이 필요한데,</p>
<p>서블릿 필터나 스프링 인터셉터는 <code>HTTPServletRequest</code> 등과 같은 기능을 제공한다.</p>
<p>(웹과 관련된 부가기능을 많이 제공해준다.)</p>
<h2 id="서블릿-필터">서블릿 필터!?</h2>
<p>필터는 서블릿이 지원하는 문지기와 같다. </p>
<h3 id="필터-흐름">필터 흐름</h3>
<aside>
    💡    HTTP 요청 → WAS → 필터 → 서블릿 → 컨트롤러

</aside>

<p>필터를 적용하면 필터가 호출 된 다음에 서블릿이 호출된다. </p>
<p><strong>모든 고객의 요청 로그를 남기는 요구사항이 있다면 필터를 사용하면 된다.</strong></p>
<p><strong>필터는 특정 URL 패턴에 적용할 수 있다. <code>/*</code>  로 지정하는 경우 모든 요청에 필터가 적용된다.</strong></p>
<p>스프링을 사용하는 경우 서블릿은 스프링의 디스패처 서블릿이라고 생각하자.</p>
<h3 id="필터-작동-흐름">필터 작동 흐름</h3>
<ul>
<li><p>정상 로그인 유저</p>
<p>  HTTP 요청 → WAS → 필터 → 서블릿 → 컨트롤러  </p>
</li>
</ul>
<ul>
<li><p>비 로그인 사용자</p>
<p>  HTTP 요청 → WAS → 필터 (검증 후 부적절한 경우, 서블릿 호출 X) </p>
</li>
</ul>
<p><strong>필터에서 적절하지 않은 요청이라고 판단한 경우 서블릿과 컨트롤러까지 요청을 전달하지 않는다.</strong></p>
<h3 id="필터-체인">필터 체인</h3>
<p>필터를 체인형태로 엮어서 여러개의 필터를 사용할 수 있다.</p>
<p>로그 필터 ,로그인 필터 등등 (순서까지 정할 수 있음) </p>
<p>HTTP 요청 → WAS → 필터 1 → 필터 2 → 필터 N → 서블릿 → 컨트롤러</p>
<pre><code class="language-java">public interface Filter {

    public default void init(FilterConfig filterConfig) throws ServletException {}

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException;

    public default void destroy() {}

}</code></pre>
<p>필터 인터페이스를 구현하고 등록하면 서블릿 컨테이너가 필터를 <strong>싱글톤 객체로 생성하고, 관리</strong>한다.</p>
<ul>
<li><p><code>init()</code>  : 필터 초기화 메서드, 서블릿 컨테이너가 생성될 때 호출한다.</p>
</li>
<li><p><code>doFilter()</code> : 고객의 요청이 올 때 마다 해당 메서드가 호출된다. 필터의 로직을 구현하면 된다.</p>
<p>  필터의 로직을 구현 해두었다면 해당 로직이 실행된다.</p>
</li>
<li><p><code>destory()</code>  : 필터 종료 메서드, 서블릿 컨테이너가 종료될 때 호출된다.</p>
</li>
</ul>
<h2 id="모든-요청을-로그-찍기">모든 요청을 로그 찍기</h2>
<p>LogFilter 클래스 생성</p>
<p>구현체로 Filter</p>
<pre><code class="language-java">LogFilter implements Filter()</code></pre>
<p><code>Filter()</code>  를 구현하면 <code>init()</code>  <code>doFilter()</code> <code>destory()</code> 메소드를 오버라이드 해서 사용가능하다.</p>
<ul>
<li><p>doFilter 자세히 보기</p>
<pre><code class="language-java">    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        log.info(&quot;log filter doFilter&quot;);
    }</code></pre>
<p>  doFilter의 인자로 있는 <code>ServletRequest</code> 의 경우 <code>HttpServletRequest</code> 의 부모 클래스이다. </p>
<p>  따라서 부모 클래스에는 기능이 많이 없기 때문에 HTTP 요청을 처리하기에 적합한 <code>HttpServletRequest</code>
  으로 다운캐스팅하여 사용하자.</p>
</li>
</ul>
<h3 id="필터-만들어서-사용해보기">필터 만들어서 사용해보기</h3>
<p>필터를 사용하기 위해서는 필터로직을 생성하고 필터를 등록하는 과정이 필요하다.</p>
<ul>
<li><p>필터 생성하기</p>
<pre><code class="language-java">  package com.example.springmvc.basic.reqeust.filter;

  import jakarta.servlet.FilterChain;
  import jakarta.servlet.FilterConfig;
  import jakarta.servlet.ServletException;
  import jakarta.servlet.ServletRequest;
  import jakarta.servlet.ServletResponse;
  import jakarta.servlet.http.HttpServletRequest;
  import java.io.IOException;
  import java.util.UUID;
  import lombok.extern.slf4j.Slf4j;

  @Slf4j
  public class LogFilter implements Filter{
      @Override
      public void init(FilterConfig filterConfig) throws ServletException {
          log.info(&quot;log filter init&quot;);
      }

      @Override
      public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
              throws IOException, ServletException {
          log.info(&quot;log filter doFilter&quot;);

          HttpServletRequest httpRequest = (HttpServletRequest) request;
          String requestURI = httpRequest.getRequestURI();

          String uuid = UUID.randomUUID().toString();

          try {
              log.info(&quot;REQUEST [ {} ][{}]&quot;, uuid, requestURI);
              // 다음 필터가 존재하는 경우 다음 필터를 실행.
              chain.doFilter(request, response);
          } catch (Exception e) {
              throw e;
          } finally {
              log.info(&quot;RESPONSE [{}] [{}]&quot;,uuid, requestURI);
          }
      }

      @Override
      public void destroy() {
          log.info(&quot;log filter destroy&quot;);
      }
  }
</code></pre>
</li>
</ul>
<ul>
<li><p>필터 등록하기</p>
<p>  필터를 사용하기 위해서는 생성 이후에 등록을 해줘야 한다.</p>
<pre><code class="language-java">  package com.example.springmvc.basic.reqeust.filter;

  import jakarta.servlet.Filter;
  import org.springframework.boot.web.servlet.FilterRegistrationBean;
  import org.springframework.context.annotation.Bean;
  import org.springframework.context.annotation.Configuration;

  @Configuration
  public class WebConfig {

      @Bean
      public FilterRegistrationBean logFilter() {
          FilterRegistrationBean&lt;Filter&gt; filterRegistrationBean = new FilterRegistrationBean&lt;&gt;();
          filterRegistrationBean.setFilter(new LogFilter());
          filterRegistrationBean.setOrder(1);
          filterRegistrationBean.addUrlPatterns(&quot;/*&quot;);

          return filterRegistrationBean;
      }
  }
</code></pre>
<p>  WebConfig 클래스를 만들고 <code>@Bean</code> 어노테이션으로 등록하고
  <code>FilterRegistrationBean</code>  을 등록해서 사용해준다.</p>
</li>
</ul>
<ul>
<li><code>FilterRegistrationBean</code> : Spring에서 제공하는 클래스로 필터를 등록하는데 사용한다.
필터의 설정을 정의하고, Spring 컨테이너에 필터를 등록하는 역할을 함</li>
<li><code>setFilter()</code> : 등록할 필터를 지정한다.</li>
<li><code>setOrder()</code> : 필터의 우선순위를 지정한다.</li>
<li><code>addUrlPatterns()</code> : 지정된 URL 에 접근 시 필터를 실행시킨다.</li>
</ul>
<p>필터를 만들고 WebConfig 클래스에서 해당 필터들을 등록해서 사용할 수 있다.</p>
<h3 id="필터의-동작-흐름">필터의 동작 흐름</h3>
<p>위처럼 생성하고 등록해서 사용한다면 스프링부트 애플리케이션을 실행한 경우</p>
<p><code>init()</code> 만 실행된다. 
애플리케이션 실행 시 딱 한번만 실행되기 때문에 필터의 초기화 작업을 수행한다.</p>
<p>HTTP requset가 발생한 경우
<code>doFilter()</code> 에서 작성한 로직이 실행된다.
필터 내부로직으로 만들어둔 try문의 requset 로그가 출력되는 것을 확인 가능했다.
이후에 <code>Controller</code> 로직이 실행된 이후 response가 발생하면 
finally에 작성해두었던 uuid가 출력된다.</p>
<p><strong>주로 인증,로깅 요청 변환등을 처리한다.</strong></p>
<p>스프링부트 애플리케이션이 종료되는 경우
<code>destroy()</code> 내부에 작성한 &quot;log filter destroy&quot; 문구가 출력된다.
리소스를 해제하거나 정리 작업을 수행한다.
예를 들어 열린 연결을 닫거나, 사용한 메모리를 정리할 수 있다.</p>
<p>정리하자면</p>
<ul>
<li>애플리케이션 실행 시 <code>init()</code></li>
<li>HTTP 요청 시 <code>dofilter()</code></li>
<li>애플리케이션 종료 시 <code>destroy()</code>
와 같은 상황에 실행되는 것을 확인했고 목적에 맞게 사용하자...</li>
</ul>
<p>아직 <code>init()</code> 과 <code>destroy()</code> 는 어떻게 활용,응용해서 사용할 지 감이 안잡힌다.
Chat GPT는 아래와 같은 상황에 사용하는 것을 추천했다.</p>
<ul>
<li><p>init() 활용: 필터 초기화 시 필요한 설정을 로드하는 데 사용할 수 있습니다. 예를 들어, 데이터베이스 연결 정보나 외부 API의 키 등을 초기화할 수 있습니다.</p>
</li>
<li><p>destroy() 활용: 필터가 사용한 리소스를 정리하는 데 사용합니다. 예를 들어, 데이터베이스 연결을 닫거나, 로그 파일을 마무리하는 작업을 할 수 있습니다.</p>
</li>
</ul>
<p>실무에서 HTTP 요청 시 같은 요청의 로그에 모두 같은 식별자를 자동으로 남기기 위해서는 logback mdc를 검색해서 사용해보자</p>
<hr>
<center>
    ⭐틀린 내용 수정,지적은 언제나 환영합니다.⭐
<center>]]></description>
        </item>
        <item>
            <title><![CDATA[[vue] 구구단 만들기]]></title>
            <link>https://velog.io/@ho_d97/vue-gugudan</link>
            <guid>https://velog.io/@ho_d97/vue-gugudan</guid>
            <pubDate>Sun, 22 Sep 2024 05:14:19 GMT</pubDate>
            <description><![CDATA[<p>인프런 &quot;웹 게임을 만들며 배우는 vue&quot; 강의를 보며 작성하는 내용입니다.
<a href="https://www.inflearn.com/course/web-game-vue">https://www.inflearn.com/course/web-game-vue</a></p>
<hr>
<p>간단한 구구단 게임을 할 수 있는 UI를 만들면서 Vue에서 데이터 사용법과 ref를 알아보았다.</p>
<h2 id="구구단-만들기">구구단 만들기</h2>
<p>단순히 값을 입력하면 두 수의 곱 연산결과를 반환하는 페이지를 만들자</p>
<pre><code class="language-html">    &lt;script&gt;
      const app = new Vue({
        el: &#39;#root&#39;,
        data: {},
        methods: {},
      });
    &lt;/script&gt;</code></pre>
<p>위 부분을 작성해야한다.</p>
<p>여기서 <code>data</code> 는 뭘까? </p>
<p>뷰에서 바뀌는 부분은 모드 데이터로 만들자고 생각하자 (변수를 데이터?)</p>
<pre><code class="language-html">&lt;script&gt;
      const app = new Vue({
        el: &#39;#root&#39;,
        data: {
          first: &#39;&#39;,
          second: &#39;&#39;,
          value: &#39;&#39;,
          result: &#39;&#39;,
        },
        methods: {},
      });
    &lt;/script&gt;</code></pre>
<p>4개의 데이터를 빈 값으로 초기화 했다. </p>
<p>그럼 이 데이터들은 어떻게 사용할 수 있을까?</p>
<ol>
<li><p>HTML Text에서 사용</p>
<p> <code>{{ first }}</code> 중괄호 두개를 사용하면 사용할수 있다.</p>
</li>
<li><p>조건절에서 사용</p>
<p> <code>v-if</code>  와 같이 사용한다면  → <code>v-if=&quot;first&quot;</code>   <code>” ”</code> 따옴표 내부에 넣어 사용가능</p>
</li>
<li><script>

 내부에서 사용 `this.first`  와 같이 `this` 를 붙여 사용 가능하다.


</li>
</ol>
<pre><code class="language-html">&lt;div id=&quot;root&quot;&gt;
  &lt;div&gt;{{first}} 곱하기 는?&lt;/div&gt;
  &lt;form&gt;
    &lt;input type=&quot;number&quot; /&gt;
    &lt;button&gt;입력&lt;/button&gt;
  &lt;/form&gt;
  &lt;div id=&quot;result&quot;&gt;&lt;/div&gt;
&lt;/div&gt;</code></pre>
<p>first 데이터가 빈 값이 아니라면 곱하기 는? 앞에 값을 출력할 것이다.</p>
<aside>
💡

<p>리액트에서 State가 Vue 에서는 data와 같다.</p>
</aside>

<h2 id="input-태그에서-받는-값을-data에-반영하고-싶다">input 태그에서 받는 값을 Data에 반영하고 싶다.</h2>
<pre><code class="language-html">&lt;form&gt;
  &lt;input type=&quot;number&quot;/&gt;
  &lt;button&gt;입력&lt;/button&gt;
&lt;/form&gt;</code></pre>
<p>form 내부에 <code>input</code> 태그를 통해 값을 입력 받고있다. </p>
<p>이렇게 입력  받은 값을 어떻게 나의 Data에 선언한 변수들에 적용시켜보자</p>
<p><strong><code>v-model</code> 사용하기</strong></p>
<pre><code class="language-html">&lt;form&gt;
  &lt;input type=&quot;number&quot; v-model=&quot;value&quot; /&gt;
  &lt;button&gt;입력&lt;/button&gt;
&lt;/form&gt;

&lt;div&gt;{{value}}&lt;/div&gt;</code></pre>
<p>v-model을 사용하면 input 태그에 입력되는 값이 변경될 때마다</p>
<p>아래 div 박스에 동일한 값이 그려진다.  <strong>(양방향 바인딩)</strong></p>
<h3 id="ref와-구구단-완성하기">ref와 구구단 완성하기</h3>
<p><strong><code>form</code>  태그에 <code>submit</code> 이벤트 추가하기</strong></p>
<pre><code class="language-html">&lt;form v-on:submit=&quot;onSubmitForm&quot;&gt;
  &lt;input type=&quot;number&quot; v-model=&quot;value&quot; /&gt;
  &lt;button&gt;입력&lt;/button&gt;
&lt;/form&gt;

&lt;script&gt;
  const app = new Vue({
    el: &#39;#root&#39;,
    data: {
      first: Math.ceil(Math.random() * 9),
      second: Math.ceil(Math.random() * 9),
      value: &#39;&#39;,
      result: &#39;&#39;,
    },
    methods: {
      onSubmitForm(e) {
        e.preventDefault();
      },
    },
  });
&lt;/script&gt;</code></pre>
<pre><code>🚨  `e.preventDefault()` 를 통해 submit 이벤트가 발생할 때 페이지가 새로고침 되지 않도록 방지한다. </code></pre><p>새로고침 되는 경우 숫자가 랜덤으로 재생성되기 때문에 원하는 동작이 불가능</p>
<h3 id="focus-적용하기">Focus 적용하기</h3>
<p>구구단 문제의 답을 입력하고 자동으로 input 박스에 focus가 잡힌다면 사용자가 편리하게 사용할 수 있을 것이다.</p>
<p><strong>ref를 사용하자</strong></p>
<p>입력 창에 foucs를 잡히게 하고 싶기 때문에 아래와 같이 적용하자</p>
<p><code>&lt;input type=&quot;number ref=&quot;answer&quot; v-model=&quot;value&quot;&gt;</code></p>
<p>이렇게 answer라는 값을 ref에 지정하면 vue는 <code>input</code> 태크를 answer 로 지정해서 사용가능하다.</p>
<pre><code>🚨 ref를 이용해서 포커싱까지는 좋지만 값을 넣어주지말자 화면에 출력되는 값과 실제 데이터랑 오차 발생할 수 있다.</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[Vue] v-if  조건문 알아보기]]></title>
            <link>https://velog.io/@ho_d97/Vue-v-if-%EC%A1%B0%EA%B1%B4%EB%AC%B8-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@ho_d97/Vue-v-if-%EC%A1%B0%EA%B1%B4%EB%AC%B8-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Sun, 22 Sep 2024 05:09:57 GMT</pubDate>
            <description><![CDATA[<p>인프런 &quot;웹 게임을 만들며 배우는 vue&quot; 강의를 보며 작성하는 내용입니다.
<a href="https://www.inflearn.com/course/web-game-vue">https://www.inflearn.com/course/web-game-vue</a></p>
<hr>
<h3 id="vue-라이브러리-추가하기">Vue 라이브러리 추가하기</h3>
<p>공식문서를 확인하면 아래와 같은 스크립트 문을 추가하라는 것을 확인할 수 있다.</p>
<p><code>&lt;script src=&quot;https://cdn.jsdelivr.net/npm/vue@2&quot;&gt;&lt;/script&gt;</code></p>
<h3 id="버튼을-눌러서-화면-바꾸기">버튼을 눌러서 화면 바꾸기</h3>
<p>script문을 사용해서 버튼 클릭으로 텍스트 노출 관리하기</p>
<p><strong>Script</strong></p>
<pre><code class="language-html">  &lt;script&gt;
    const app = new Vue({
      el: &#39;#root&#39;,
      data: {
        liked: false,
      },
      methods: {
        onClickButton() {
          this.liked = true;
        },
      },
    });
  &lt;/script&gt;</code></pre>
<p>버튼 클릭시 true로 고정되는게 싫은 경우 
<code>this.liked = !this.liked;</code> 
로 바꾼다면 동적으로 사용가능하다.</p>
<p><strong>Body</strong></p>
<p>아래와 같이 v를 붙여서 vue가 통제할 수 있게 할 수 있다.</p>
<ul>
<li><code>v-on</code></li>
<li><code>v-if</code></li>
<li><code>v-else</code></li>
</ul>
<pre><code class="language-html">&lt;body&gt;
  &lt;div id=&quot;root&quot;&gt;
    &lt;div v-if=&quot;liked&quot;&gt;좋아요 눌렀음&lt;/div&gt;
    &lt;button v-else v-on:click=&quot;onClickButton&quot;&gt;Like&lt;/button&gt;
  &lt;/div&gt;
&lt;/body&gt;</code></pre>
<p>위 코드에서 알고 기억할 것은 <code>id=&quot;root&quot;</code> 내부에  <code>v-if</code> , <code>v-else</code> 등을 사용해서 조건문을 사용할 수 있었다. 이들의 스코프는 <strong>동등한 형제 태그면서 인접</strong>해야한다. </p>
<h3 id="인접하지-않은-경우">인접하지 않은 경우</h3>
<pre><code class="language-html">&lt;div v-if=&quot;liked&quot;&gt;좋아요 눌렀음&lt;/div&gt;
&lt;div&gt;사이 가르기&lt;/div&gt;
&lt;button v-else v-on:click=&quot;onClickButton&quot;&gt;Like&lt;/button&gt;</code></pre>
<p>위와 같이 형제 사이가 떨어지면 사이 가르기만 화면에 그려지는 것을 확인할 수 있다.</p>
<h3 id="v-else-if-도-사용가능하다고">v-else-if 도 사용가능하다고!</h3>
<pre><code class="language-html">&lt;div v-if=&quot;liked&quot;&gt;좋아요 눌렀음&lt;/div&gt;
&lt;div v-else-if=&quot;liked&quot;&gt;좋아요 눌렀음&lt;/div&gt;
&lt;div v-else-if=&quot;liked&quot;&gt;좋아요 눌렀음&lt;/div&gt;
&lt;div v-else-if=&quot;liked&quot;&gt;좋아요 눌렀음&lt;/div&gt;
&lt;div v-else-if=&quot;liked&quot;&gt;좋아요 눌렀음&lt;/div&gt;
&lt;button v-else v-on:click=&quot;onClickButton&quot;&gt;Like&lt;/button&gt;</code></pre>
<p><code>v-else-if</code> 또한 사용 가능하다.</p>
<p>이때 <code>liked</code> 가 위치한 “” 따옴표 내부에는 JS code 를 넣을 수 있다.</p>
<pre><code class="language-html">  &lt;body&gt;
    &lt;div id=&quot;root&quot;&gt;
      &lt;div v-if=&quot;1+2 === 3&quot;&gt;좋아요 눌렀음&lt;/div&gt;
      &lt;button v-else v-on:click=&quot;onClickButton&quot;&gt;Like&lt;/button&gt;
    &lt;/div&gt;
  &lt;/body&gt;</code></pre>
<p><code>v-if</code> 의 조건이 참이기 때문에 <strong>좋아요 눌렀음</strong>이 화면에 랜더링된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블 슈팅] @Getter가 필요해!]]></title>
            <link>https://velog.io/@ho_d97/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-Getter%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%B4</link>
            <guid>https://velog.io/@ho_d97/%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-Getter%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%B4</guid>
            <pubDate>Wed, 28 Aug 2024 14:31:27 GMT</pubDate>
            <description><![CDATA[<p>프로젝트를 진행하면서 자주하는 실수를 정리하려고한다.</p>
<ol>
<li><p>ErrorResult 객체를 만들어서 응답하는 과정에서 </p>
<p> <code>org.springframework.web.HttpMediaTypeNotAcceptableException: No acceptable representation</code></p>
<p> 오류가 계속 발생했다.</p>
<p> 결론 : Jackson Mapper를 사용하기 위해서는 Getter가 필요했다.</p>
<p> 나는 아래와 같이 예외처리 핸들러를 작성했었다.</p>
<pre><code class="language-java">     @ExceptionHandler(VerificationCodeRequestException.class)
     public ResponseEntity&lt;ErrorResult&gt; handleVerificationCodeRequestException(VerificationCodeRequestException e) {
         log.error(&quot;인증번호 발급 오류 {}&quot;, e.getMessage());

         ErrorResult errorResult = ErrorResult.builder()
                 .error(&quot;인증번호 발급 오류&quot;)
                 .message(e.getMessage())
                 .build();

         log.error(&quot;ErrorResult : {}&quot;, errorResult);

         return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(errorResult);
     }</code></pre>
<p> 문제 해결전</p>
<pre><code class="language-java"> package com.example.Auth.exception;

 import lombok.Builder;

 @Builder
 public class ErrorResult {
     private final String error;
     private final String message;
 }
</code></pre>
<p> 문제 없이 ErrorResult가 생성되는 것도 확인했고 두번째 에러 로그에도 정상적으로 출력이된다.</p>
<p> 디버깅시  return도 정상적으로 나갔다. 하지만 </p>
</li>
</ol>
<p><img src="https://velog.velcdn.com/images/ho_d97/post/df2cd910-2196-4ea7-b1c6-8acebc599cba/image.png" alt=""></p>
<p>  401 에러가 아닌 500에러가 계속 발생한다.</p>
<p>   구글링과 여러 삽질을 계속하다가 문득 아 <code>@Getter</code>가 없는데 직렬화가 가능할까?</p>
<p>  라는 생각이 들었고 해당 어노테이션을 추가해줬다.</p>
<p>   자주하는 실수인데 항상 찾는데 오래걸린다.</p>
<hr>
<center>
    ⭐틀린 내용 수정,지적은 언제나 환영합니다.⭐
<center>]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] 프록시? 즉시로딩? 지연로딩?!?]]></title>
            <link>https://velog.io/@ho_d97/JPA4-za9zvsmz</link>
            <guid>https://velog.io/@ho_d97/JPA4-za9zvsmz</guid>
            <pubDate>Thu, 15 Aug 2024 02:04:44 GMT</pubDate>
            <description><![CDATA[<h3 id="프록시">프록시</h3>
<p>JPA를 사용하면 객체는 연관관계가 있는 다른 객체를 필드로 가져 참조하는 방식으로 관계를 형성한다.</p>
<p>특징</p>
<ul>
<li><p>프록시 객체는 처음 사용할 때 한 번만 사용함</p>
</li>
<li><p>프록시 객체를 초기화할 때, 프록시 객체가 실제 엔티티로 바뀌는 것은 아니다.
초기화되면 프록시 객체를 통해서 실제 엔티티에 접근 가능</p>
</li>
<li><p>프록시 객체는 원본 엔티티를 상속받음 , 따라서 타입 체크 시 주의해야 한다. (== 비교가 아닌 instanceof 사용)</p>
</li>
<li><p>영속성 컨텍스트에 찾는 엔티티가 이미 있으면 em.getReference()를 호출해도 실제 엔티티를 반환한다.</p>
</li>
<li><p>영속성 컨텍스트의 도움을 받을 수 없는 준영속 상태일 때 , 프록시를 초기화하면 문제 발생</p>
<p>  could not init proxy error, Lazyinit error 등등</p>
<ul>
<li><p>프록시 인스턴스의 초기화 여부 확인</p>
<p>  PersistenceUnitUtil.isLoaded(Object entity)</p>
</li>
<li><p>프록시 클래스 확인 방법</p>
<p>  entity.getClass().get</p>
</li>
</ul>
</li>
</ul>
<h3 id="프록시와-식별자">프록시와 식별자</h3>
<p>엔티티를 프록시로 조회할 때 PK 값을 파라미터로 전달하는데 <strong>프록시 객체는 이 식별자 값을 보관</strong>한다.</p>
<pre><code class="language-java">School school = em.getReference(School.class, &quot;School1&quot;); 
school.getId(); //초기화되지 않음</code></pre>
<p>이미 PK를 가지고 있었기 때문에 프록시 객체는 실제 객체로 초기화되지 않는다.</p>
<p>🚨단 엔티티 접근 방식을 프로퍼티 <code>(@Access(AccessType.PROPERTY))</code> 로 설정한 경우에만 초기화하지 않는다.</p>
<p><code>(@Access(AccessType.FIELD))</code> 로 설정하면 JPA는 <code>getId()</code>  메소드가 id 만 조회하는 메소드인지 다른 필드까지 활용하는 것인지 모르기 때문에 프록시 객체를 초기화한다.</p>
<p>프록시는 다음 코드처럼 연관관계를 설정할 때 유용하다.</p>
<pre><code class="language-java">Member member = em.find(Member.class, &quot;member1&quot;);
Team team = em.getReference(Team.class, &quot;team1&quot;); // SQL을 실행하지 않음
member.setTeam(team);</code></pre>
<p>연관관계를 설정할 때는 식별자 값만 사용하기 때문에 프록시를 사용하면 데이터베이스 접근 횟수를 줄일 수 있다.이러한 경우에는 엔티티 접근 방식을 필드로 설정해도 프록시를 초기화하지 않는다.</p>
<h3 id="즉시-로딩과-지연-로딩">즉시 로딩과 지연 로딩</h3>
<p><strong>프록시 객체는 주로 연관된 엔티티를 지연 로딩할 때 사용한다.</strong></p>
<ul>
<li><p>지연 로딩이 뭘까?</p>
<p>  연관된 엔티티를 실제 사용할 때 조회하는 것이다.</p>
<p>  Member 객체가 Team을 가지고 있다면 단순 Member의 name이 필요해서 <code>find()</code> 했는데
  필요 없는 Team 객체까지 <code>find()</code> 하는 쿼리를 날린다면 추가 비용이 발생해서 아쉽기 때문에 이를 방지할 수 있다.</p>
</li>
<li><p>즉시 로딩은 뭘까?</p>
<p>  엔티티를 조회할 때 연관된 엔티티도 함께 조회</p>
<p>  지연 로딩과는 다르게 Member 객체를 <code>find()</code> 해서 가져오는 시점에 Team 객체를 가져오는 쿼리를 
  같이 날려서 바로 가져온다. (실무에서는 즉시 로딩 비추천이라고 한다. 이유는 추가적으로 공부해보자)</p>
</li>
</ul>
<h3 id="즉시-로딩">즉시 로딩</h3>
<p>즉시 로딩(EAGER LOADING)을 사용하려면 <code>@ManyToOne</code>의  fetch 속성을 FetchType.EAGER 로 지정하여 사용할 수 있다.</p>
<pre><code class="language-java">@Entity
public class Member {
@ManyToOne(fetch = FetchType.EAGER)
@JoinColumn(name = &quot;TEAM_ID&quot;)
    private Team team;
}</code></pre>
<p>위 예제 코드와 같이 코드를 작성하고 아래와 같이 get 요청을 해보자</p>
<pre><code class="language-java">Member member = em.find(Member.class, &quot;member1&quot;);
Team team = member.getTeam();</code></pre>
<p>이 코드는 EAGER 설정이 되어있기 때문에 회원을 조회하는 순간 팀도 함께 조회한다.
이때 회원과 팀 두 테이블을 조회해야 하기 때문에 쿼리를 2번 실행할 것 같지만, 대부분의 <strong>JPA 구현체는
즉시 로딩을 최적화하기 위해 가능하면 조인 쿼리를 사용</strong>하기 때문에 여기서는 회원과 <strong>팀을 조인해서
쿼리 한번으로 두 엔티티르 모두 조회한</strong>다.</p>
<p>이후 <code>member.getTeam()</code> 을 호출하면 이미 로딩된 Team1 엔티티를 반환한다.</p>
<aside>
💡 NULL 제약조건과 JPA 조인 전략
NULL 제약조건을 조인하는 FK에 설정하지 않는다면 조인 성능이 상대적으로 떨어지는 외부조인 쿼리를 날린다. 하지만 NOT NULL 조건을 명시한다면 내부 조인으로 쿼리를 날려서 성능과 최적화에 더 유리하다.
`@JoinColumn(nullable = true); NULL 허용` → 외부 조인 사용
`@JoinColumn(nullable = false); NULL 불허` → 내부 조인 사용
`@ManyToOne.optional = false` → 내부 조인 사용

</aside>

<h3 id="지연-로딩">지연 로딩</h3>
<p>지연 로딩을 사용하려면 FetchType.LAZY 로 지정한다.</p>
<h3 id="컬렉션에-fetchtypeeager-사용-시-주의점">컬렉션에 FetchType.EAGER 사용 시 주의점</h3>
<ul>
<li><p>컬렉션을 하나 이상 즉시로딩하는 것은 권장하지 않는다.</p>
<p>  컬렉션과 조인한다는 것은 데이터베이스 테이블로 보면 일대다 조인이다.</p>
<p>  일대다 조인은 결과 데이터가 다 쪽에 있는 수만큼 증가하게 되는데, 문제는 
  서로 다른 컬렉션을 2개 이상 조인할 때 발생한다.</p>
<p>  예를 들어 A 테이블을 N, M 두 테이블과 일대다 조인하면 SQL 실행 결과가 N 곱하기 M
  이 되면서 너무 많은 데이터를 반환할 수 있어 성능 저하 이슈가 발생할 수 있다.</p>
</li>
<li><p>컬렉션 즉시 로딩은 항상 외부 조인을 사용한다.</p>
<p>  다대일 관계인 회원 테이블과 팀 테이블을 조인할 때 회원 테이블의 외래 키에 Not null 제약 조건을 
  걸어두면 모든 회원은 팀에 소속되므로 항상 내부조인을 사용해도 된다. 하지만 반대로 팀 테이블에서 
  회원 테이블로 일대다 관계를 조인할 때 회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는
  문제가 발생한다.</p>
<p>  데이터베이스 제약조건으로 이런 상황을 막을 수 없기 때문에 <strong>JPA는 일대다 관계를 즉시 로딩할 때 항상
  외부 조인을 사용한다.</strong></p>
</li>
</ul>
<aside>
💡 **추가적인 나의 생각**

<p>예를 들어서 주문이라는 클래스는 두 개의 컬렉션을 가지고 있다고 가정하자.</p>
<p>그럼 주문 객체를 DB에서 가져올 때 (조회할 때) 항상 A 컬렉션의 N개와B 컬렉션의 M 개
를 모두 가져오기 때문에 N * M 개를 가져와야 한다. 
하지만 이 문제를 LAZY 설정으로 하면 해결할 수 있을까? N * M 개의 조회를 해야 한다는 사실은
바뀌지 않을 것이다. 하지만 Order 클래스의 컬렉션 정보가 필요한 게 아닌 순수 Order 객체의 필
드 값이 필요하다면 N * M 개를 조회하지 않기에 조금은 효율적이지 않을까 싶다.</p>
<p>같은 접근으로 외부조인 문제도 LAZY조건도 외부조인을 해야하는 것을 피할 수 없지만
필요할 때만 하는 방식으로 성능적인 면에서 더 유연하고 효율적이다.</p>
<p><strong>N * M 을 해결하기 위해서는 각 개별 쿼리를 만들어주는 것으로 해결 가능할 것 같다.</strong></p>
</aside>

<hr>
<p>참고</p>
<ol>
<li>인프런 JPA 기본편 - 김영한</li>
<li>자바 ORM 표준 JPA 프로그래밍 - 김영한</li>
</ol>
<center>
    ⭐틀린 내용 수정,지적은 언제나 환영합니다.⭐
<center>]]></description>
        </item>
        <item>
            <title><![CDATA[[Kafka] Kafka 삽질하기(1)]]></title>
            <link>https://velog.io/@ho_d97/Kafka1</link>
            <guid>https://velog.io/@ho_d97/Kafka1</guid>
            <pubDate>Tue, 06 Aug 2024 13:07:32 GMT</pubDate>
            <description><![CDATA[<h2 id="kafka-테스트-해보기">Kafka 테스트 해보기</h2>
<p>구체적으로 카프카를 사용하는 이유와 주요 기능, 핵심 용어들을 정리하기에 앞서 먼저 환경을 설치하고 콘솔을 통해 producer와 consumer를 사용해보기 위해 겪은 시행착오 정리하기</p>
<h3 id="homebrew를-이용한-kafka-설치">Homebrew를 이용한 Kafka 설치</h3>
<p>터미널에 <code>brew install kafka</code> brew를 이용해서 카프카를 설치할 수 있다.</p>
<h3 id="kafka-실행하기">kafka 실행하기</h3>
<ol>
<li><code>brew services start zookeeper</code></li>
<li><code>brew services start kafka</code></li>
</ol>
<p>위 같은 명령어를 입력해서 zookeeper와 kafka를 실행했다.
<strong>하지만 이 방법으로는 이제는 실행되지 않는 것 같았다.</strong></p>
<h3 id="topic-만들기">Topic 만들기</h3>
<p>Kafka를 설치하면 테스트를 해볼 수 있게 다양한 스크립트를 제공해준다.
이런 스크립트를 사용해서 여러가지를 할 수 있지만 스크립트를 사용하기 위해서는 kafka가 설치된 경로를 알아야 했다.
일단 경로를 알아보기전에 Kafka에서 Topic이 뭔지 간단하게 알아보자</p>
<p><strong>Topic이 뭔가요?</strong>
<code>Topic</code> 은 데이터가 들어갈 수 있는 공간을 의미한다.
쉽게 데이터베이스 같은 것이다. 데이터베이스도 데이터를 보관해주듯이
이 <code>Topic</code> 은 여러개 생성될 수 있고 내부에 여러개의 파티션을 두어
파티션 별로 데이터를 나누어 담을 수 있다.
이러한 토픽은 이름을 가질 수 있다. 이름을 정해주는 방법과 생성한 토픽을
사용하는 것을 이번 글과 다음 글에서 볼 수 있다.
<strong>한줄 요약 : <code>Topic</code> 은 데이터가 들어갈 수 있는 이름이 있는 공간이다. (like DB Table)</strong></p>
<p><strong>Kafka가 설치된 경로 알아보기</strong>
<strong><code>brew info kafka</code></strong>  를 터미널에 입력하면 아래와 같이 확인할 수 있다.<img src="https://velog.velcdn.com/images/ho_d97/post/cfaaffaa-c8fa-421a-90d8-42e9cc6f5684/image.jpg" alt=""></p>
<ul>
<li><p>경로 이동하기 <code>/opt/homebrew/Cellar/kafka/3.8.0/bin</code></p>
</li>
<li><p>kafka-topics 스크립트를 이용해서 토픽 만들기</p>
<p>  <code>./kafka-topics --create --zookeeper [localhost:2181](http://localhost:2181) --replication-factor 1 -partitions 1 --topic hoho</code></p>
<p>  위 명령어를 입력했을 때  아래와 같은오류가 발생했다.<img src="https://velog.velcdn.com/images/ho_d97/post/04ab305a-c45c-482b-b243-8c21bfe24550/image.png" alt=""></p>
</li>
</ul>
<p>4년전 유튜브를 보면서 따라 하다가 zookeeper 옵션을 사용할 수 없다는 에러를 확인하고 아하 뭔가 이건 버전 차이 이슈가 분명하다는 생각에 공식문서 GetStart를 참고하러 공식문서로 달려갔다!
<a href="https://kafka.apache.org/quickstart">https://kafka.apache.org/quickstart</a> &lt;- kafka 튜토리얼
위에 서버 실행하는 명령어부터 뭔가 달랐지만 괜찮아!  나는 brew로 설치했잖아 하면서 가볍게 넘겼다.
뭔가 지금  Topic을 Create하는 명령어를 찾았다.
<code>bin/kafka-topics.sh --create --topic quickstart-events --bootstrap-server localhost:9092</code></p>
<p>내가 입력한 명령어와 차이는 zookeeper 옵션이 아닌 bootstrap-server를 사용했다는 점과 2181포트가 아닌 9092 포트가 다르다는 점을 인지했다.
그럼? 바로~ 구글링 했다. 쉽게 3.0 이상 버전부터는 zookeeper를 사용할 수 없고 bootstrap-server 옵션을 사용해줘야 한다고 했다.
또한 9092 포트를 사용해야 한다는 반응이 대다수였고 공식문서 또한 그렇게 알려주었기에 믿고 따라 했다.</p>
<ul>
<li>다시 만난 bootstrap-server is not a recognized option
이번엔 zookeeper가 아닌 이름만 바뀌어서 같은 오류가 발생했다...
이건 정말 뭔가 단단히 잘 못 됐다.
자 침착하고 그럼 나는 zookeeper,kafka 실행부터 현재까지 공식문서와 뭐가 다를까 천천히 살펴보았다. brew service start를 했다는 점이었다.
아... 그렇다면 공식문서에서 bootstrap-server 옵션을 사용하라고 안내 중이기에 사라진 옵션은 아닐 것이고 서버 실행이 잘 못 되었을 것이라는 생각이 들었다.</li>
</ul>
<p><strong>서버를 공식문서 버전으로 실행하기</strong></p>
<ul>
<li><p>zookeeper 재실행하기</p>
<p>  <code>/opt/homebrew/bin/zookeeper-server-start /opt/homebrew/etc/zookeeper/zoo.cfg</code></p>
</li>
<li><p>kafka 재실행하기</p>
<p>  <strong><code>/opt/homebrew/bin/kafka-server-start /opt/homebrew/etc/kafka/server.properties</code></strong></p>
</li>
</ul>
<p> 공식문서에서 정확히 위와 같은 경로를 제공하는 것은 아니지만 <code>zoo.cfg</code>, <code>sever.properties</code> 를 실행하라는 의미로 이해했고
 brew가 설치해둔 경로에서 위 두 파일을 초보자인 나는 매우 매우 어렵게 찾을 수 있었다.
 (공식문서를 볼 때 보이는 것만 믿지 말고 내 환경에서는 어떻게 저 명령어를 사용할 수 있을까 하는 과정이 필요함을 느꼈다.)
실행해보니 뭔가 쫘르르르르르 나오면서 진짜 실행된 것 같음을 느꼈고 이번엔 정말 실행이 되었다.</p>
<h3 id="어렵게-만든-topic에-값-넣고-실습하기">어렵게 만든 topic에 값 넣고 실습하기</h3>
<p><img src="https://velog.velcdn.com/images/ho_d97/post/dc07abc2-d5ea-48f9-98f8-5d0eaa03bd59/image.png" alt=""></p>
<p>위와 같은 감격스러운 코드를 볼 수 있었다.
내가 이것을 실습한 이유는 EC2 환경에서 사용하는 것이 목적이었기에 바로 진행해서 결과물을 얻을 수 있었다.
2편에서 EC2 환경세팅 삽질을 기록해보자</p>
<hr>
<center>
    ⭐틀린 내용 수정,지적은 언제나 환영합니다.⭐
<center>]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring] Bean-Validation 유효성 검사]]></title>
            <link>https://velog.io/@ho_d97/SpringBean-Validation</link>
            <guid>https://velog.io/@ho_d97/SpringBean-Validation</guid>
            <pubDate>Mon, 05 Aug 2024 09:52:35 GMT</pubDate>
            <description><![CDATA[<h1 id="유효성-검사란">유효성 검사란?</h1>
<p>사용자가 보낸 데이터 값이 백엔드에서 취급하는 데이터와 같은 형태인지 확인하는 것이다.
이렇게 유효성 검사를 하지 않으면 비즈니스 로직에서 오류가 발생하기 때문에 사전 차단하기 위함이다.</p>
<h3 id="valid-동작원리">@Valid 동작원리</h3>
<p>모든 요청은 필터 → 디스패처 서블릿 → 핸들러 → 컨트롤러의 경로를 통해 전달되는데
전달 과정에서 컨트롤러 메소드의 객체를 만들어주는 <code>ArgumentResolver</code> 가 동작한다.</p>
<p><code>@Valid</code> 또한 <code>ArgumentResolver</code> 에 의해 처리된다.</p>
<p>유효성을 검사하는 과정에서 오류가 있다면 <code>MethodArgumentNotValidException</code> 예외가 발생하고, 디스패처 서블릿에 기본으로 등록된 예외 리졸버인 <code>DefaultHandlerExceptionResolver</code> 에 의해 400 BadRequest 에러가 발생한다.</p>
<p><code>@Valid</code> 는 기본적으로 컨트롤러에서만 동작하며 기본적으로 다른 계층에서는 검증되지 않는다.</p>
<p>다른 계층에서 파라미터를 검증하기 위해서는 <code>@Validated</code> 와 결합하여야 한다.</p>
<h3 id="의존성-추가">의존성 추가</h3>
<pre><code>implementation &#39;org.springframework.boot:spring-boot-starter-validation&#39;</code></pre><p>왜 의존성을 추가해서 사용해야 하죠?</p>
<p>Hibernate Validator는 Jakarta Bean Validation 의 구현체이기 때문에 의존성을 추가하지 않는다면 어노테이션 추가는 가능하지만 사용하지 못할 것이다. (<strong>Jakarta Bean Validation</strong>  명세만 해둔 인터페이스)</p>
<h3 id="스프링-부트-3에서-유효성-검증-기능-변화">스프링 부트 3에서 유효성 검증 기능 변화</h3>
<p><strong>@Constraint</strong> 애노테이션들이 기본적으로 지원되기 시작했다.</p>
<p>따라서 <code>@Valid</code> 또는 <code>@Validated</code> 를 붙여줄 필요없이 기본적인 유효성 검증이 동작한다.</p>
<h3 id="validated">@Validated?</h3>
<p>입력 파라미터의 유효성 검증은 컨트롤러에서 최대한 처리하고 넘겨주는 것이 좋다. 하지만 개발을 하다 보면 항상 컨트롤러에서만 가능하지는 않을 것이다.</p>
<p>컨트롤러가 아닌 다른 곳에서는 어떻게 할까?</p>
<p>Spring에서는 <strong>AOP 기반으로 메소드의 요청을 가로채서 유효성 검증을 진행</strong>해주는 @Validated를 제공하고 있다.  아래 코드와 같이 클래스에 <code>@Validated</code> 를 사용하고 유효성을 검사할 파라미터에 <code>@Valid</code> 를 붙여준다.</p>
<pre><code class="language-jsx">@Service
@Validated
public class FooService {
        public void addFoo(@Valid AddFooRequest addFooRequest) {
            ...
        }
}</code></pre>
<p><strong>유효성 검사가 실패한다면?</strong></p>
<p>이전 <code>@Valid</code>  에서는 <code>MethodArgumentNotValidException</code> 예외가 발생하지만 <code>@Validated</code> 의 경우에는 <code>ConstraintViolationException</code> 예외가 발생한다.</p>
<p>발생하는 예외 이름이 다른 것으로 보아 동작원리가 다르겠다는 짐작이 든다.</p>
<h3 id="둘-차이는-무엇일까">둘 차이는 무엇일까?</h3>
<p><code>@Validated</code> 는 스프링 전용 검증 애노테이션, <code>@Valid</code> 는 자바 표준 검증 애노테이션이다.
둘 중 아무거나 사용해도 동일하지만, <code>@Validated</code> 는 내부에 <code>groups</code> 라는 기능을 포함하고 있다.</p>
<h3 id="bean-validation---groups">Bean Validation - groups</h3>
<p>동일한 모델 객체를 등록할 때와 수정할 때 각각 다르게 검증하는 방법을 알아보자</p>
<p>groups를 사용하기 위해서는 인터페이스를 만들어야 한다.</p>
<p>각 그룹별로 인터페이스 만들기</p>
<p>@NotNull(groups = UpdateCheck.class)</p>
<p>@NotBlank(groups= { SaveCheck.class, UpdateCheck.class})</p>
<p>그룹화 하면 각각 맞는 상황에 검증 로직을 실행한다.</p>
<p>하지만 사용하기 위해서는 해당하는 메소드 <code>@validated(value = UpdateCheck.class)</code> 를 컨트롤러에 적용해야 한다.</p>
<p>정리하면서도 느꼈지만, 너무 복잡하고 코드라 지저분해지기 때문에 자주 사용하지 않는다.</p>
<ul>
<li>어떻게 해결할 수 있을까?</li>
</ul>
<p>등록 폼을 받는 객체와 수정 폼을 받는 객체를 따로 분리해서 받는다면 이런 문제를 해결할 수 있다.</p>
<p><strong>DTO를 사용해서 검증하는 객체를 분리하자!</strong></p>
<h3 id="bean-validation---http-메시지-컨버터">Bean Validation - HTTP 메시지 컨버터</h3>
<p>클라이언트 사이드의 경우 <code>@RequestBody</code> 로 받는 JSON 객체 옆에 <code>@Valid</code> 어노테이션을 붙여서 사용 가능하다. 이 경우 세 가지의 경우로 분류할 수 있다.</p>
<ul>
<li><p>성공 요청</p>
</li>
<li><p>실패 요청</p>
<p>  클라이언트로부터 Request를 받아서 객체로 DTO 객체로 전환하는 데 실패하면 컨트롤러 내부 코드는 실행되지 못하고 에러를 반환한다.</p>
</li>
<li><p>검증 오류 요청</p>
<p>  Http 요청으로 전달받은 데이터를 DTO 객체로 변환하는 것까지는 성공하였지만, 유효성 검사에  실패하면
  <code>Errors</code> , <code>Result</code>  등을 사용하여 에러를 처리할 수 있다.</p>
</li>
</ul>
<aside>
💡 **@ModelAttribute vs @RequestBody**

<p>HTTP 요청 파라미터를 처리하는 <code>@ModelAttribute</code> 는 각각의 필드 단위로 세밀하게 적용되기 때문에 특정 필드의 타입이 맞지 않더라고 나머지 필드는 정상처리할 수 있다. </p>
<p>하지만 <code>HttpMessageConverter</code> 는 각 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공하지 못한 경우 <code>@valid</code>, <code>@Validate</code> 가 적용된다.</p>
<blockquote>
<p><code>@ModelAttribute</code> 는 특정 필드가 바인딩 되지 않아도 <strong>나머지는 정상 바인딩되어 검증  적용 가능</strong>
<code>@RequestBody</code> 는 HttpMessageConverter 단계에서 JSON 데이터를 객체로 변경하지 못하면 이후 단계 자체가 진행되지 않고 예외가 발생한다. <strong>나머지 필드도 검증 불가능</strong> </p>
</blockquote>
</aside>]]></description>
        </item>
        <item>
            <title><![CDATA[[Java] Generics]]></title>
            <link>https://velog.io/@ho_d97/JavaGenerics</link>
            <guid>https://velog.io/@ho_d97/JavaGenerics</guid>
            <pubDate>Sun, 04 Aug 2024 05:26:16 GMT</pubDate>
            <description><![CDATA[<h1 id="generic">Generic</h1>
<p>라이브러리에서 또는 공식문서에서 메소드 사용법을 찾아보면 <code>&lt;T&gt;</code>  , <code>&lt;E&gt;</code> , <code>&lt;?&gt;</code> 등등 알 수 없는 알파벳이나 물음표 같은 문자를 볼 수 있었고 타입위치에 알 수 없는 문자들을 볼 수 있었다.</p>
<p>먼저 <strong>제네릭은 객체,메소드 생성,사용 시에 타입(클래스)을 받아서 동적으로 결정할 수 있게 하는 기능</strong> 이다.</p>
<p>이 말을 줄여서 글에서 제네릭은 아무거나 담을 수 있다. 라고 표현할 수도 있다.</p>
<p>사실 이름은 알 수 없는 문자가 아니고 이것들이 바로 제네릭 타입이다.</p>
<p><code>T</code> , <code>E</code>  이 문자들을 제네릭 타입이라고 부르고, 어떠한 클래스가 오더라도 받아줄 수 있다.</p>
<p>여기서 <code>T</code>, <code>E</code> 모두 제네릭 변수일 뿐 <code>K</code> , <code>U</code>  등등 원하는 문자를 사용하면 되지만,
제네릭 타입 네이밍 컨벤션이 있기 때문에 사용시 찾아보고 용도에 맞게 네이밍을 하면,
자유도가 높은 제네릭을 조금이나마 추측할 수 있기에 미래의 나도 동료도 편할 거 같다.</p>
<p>여기서 <code>T</code> , <code>E</code> 관련해서는 설명했지만, 아직 <code>?</code> 를 설명하지 않았다.
일단  <code>?</code> 는 제네릭은 아니다. 이름은 와일드카드라는 녀석이지만 와일드카드를 이해하기 위해서는
제네릭을 잘 알고 넘어가야하기 때문에 제네릭을 정리하고 <strong>와일드카드</strong> 설명해보자</p>
<h3 id="공변-불공변">공변? 불공변?</h3>
<p>제네릭은 공부하기위해서 많은 글을 찾아보면 공변과 불공변을 먼저 알아야한다는 글이 많았다.</p>
<p>공변, 불공변 나한테는 너무 와닿지않고 이해가 안됐고 한 줄로 요약하자면</p>
<p>공변 → 자식 타입의 객체가 부모 타입 업캐스팅해서 품어준다.</p>
<p>불공변 → 아무리 자식 타입의 객체라도 부모 타입으로 업캐스팅해서 품어주지 않는다.</p>
<p>이 해석을 읽는다면 틀렸는데? 아닌데? 라는 생각이 나조차 동의하고 하지만 구체적으로 표현하자니 더 이해가 안되는 어려운 말이 될 것 같아서 나는 이렇게 공변, 불공변을 정리하기로 했다.(좋은 표현이 있다면 부탁드립니다.)</p>
<h3 id="왜-제네릭을-사용해야-하지">왜 제네릭을 사용해야 하지?</h3>
<p>제네릭을 사용하지 않고 Object를 사용할 수 있다.</p>
<p>왜냐하면 Object 또한 모든 객체를 파라미터로 전달할 수 있다.
제네릭과 똑같이 다 담아줄 수 있다는 장점이 있다.</p>
<p>하지만 Object로 강제 업캐스팅 되어 내가 사용하고 싶었던 Person 객체의 메소드를 바로 사용할 수 없다.
또한 나는 Person, User와 같은 객체 모두를 받고 싶어서 Object 타입으로 지정했는데
내가 아닌 다른 개발자가 , Integer 등 의도와 맞지 않게 사용할 수 있다.</p>
<ul>
<li>의도하지 않은 객체가 들어올 수 있다. (타입이 안정되지 않는다.)</li>
<li>항상 Object로 업캐스팅해서 저장한 다음 정상적으로 사용하기 위해서는 다시 다운캐스팅해서 사용해야 한다.</li>
</ul>
<h3 id="제네릭-메소드">제네릭 메소드</h3>
<p>글로 정리를 하는 것보다 그림으로 정리하는게 명확하게 이해할 수 있을거 같아서 그림 그리기</p>
<img src="https://velog.velcdn.com/images/ho_d97/post/d796802f-dcc3-4654-aa4c-ff75ba53be81/image.png" width=600px>
주황이든 빨강이든 지정된 T로 반환한다.

<p>예를 들어서 빨간색이 <code>으로 지정해뒀고 주황색이 새로 덮어쓰지 않고 사용한다면 반환 타입은</code> 이 된다. </p>
<p><code>genericMethod(1,2)</code> 이렇게 사용하는 경우 메소드 내부의 <code>T</code> 는 클래스 선언할 때 지정한 타입을 따라간다.
하지만 <code>&amp;amp;lt;T&amp;gt;</code>  메소드 제네릭을 활용하면 클래스 선언할 때 지정돤 타입 A와는 다른  타입B를 사용할 수 있다.</p>
<p>하지만 <code>genericMethod()</code> 를 사용할 때 </p>
<p><strong>메소드 제네릭 : 클래스에서 사용한 제네릭과는 독립적으로 메소드에서 사용할 타입을 메소드 사용 시 동적으로 결정할 수 있게 하는 것</strong></p>
<h3 id="제네릭의-범위를-지정할-수-있다">제네릭의 범위를 지정할 수 있다.</h3>
<p>제네릭은 아무 타입이나 받아주기 때문에 너무 자유로운 영혼이다.
너무 자유롭다 보니 특히 메소드를 제네릭 타입으로 받아서 사용하려고 할 때 어떤 객체를 
받아서 사용할 지 예측할 수 없어 객체의 고유의 메소드를 사용하는 게 곤란해졌다.</p>
<p>모든 객체,클래스의 조상인 Object의 메소드 빼고는 지정할 수가 없기 떄문에 다음과 같이 사용할 수 있다.</p>
<p><code>extends</code> , <code>super</code> 를 사용해서 제네릭의 범위를 제한할 수 있다.</p>
<p>예제코드</p>
<pre><code class="language-java">class Studnet&lt; T extends Person&gt; {}</code></pre>
<p>이렇게 extends를 사용하면 Person 클래스를 포함한  자식 타입의 클래스만 <code>T</code> 위치에 올 수 있다.</p>
<p>만약 Person의 자식 클래스가 아니라면 컴파일에러를 발생시켜 컴파일 시점에 오류를 확인할 수 있다.</p>
<p>다음글에서는 제네릭을 더욱 유연하고 효율적으로 사용할 수 있게하는 와일드카드를 정리해야겠다.</p>
<hr>
<center>
    ⭐틀린 내용 수정,지적은 언제나 환영합니다.⭐
<center>]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] 객체와 테이블은 다르다!]]></title>
            <link>https://velog.io/@ho_d97/JPA4</link>
            <guid>https://velog.io/@ho_d97/JPA4</guid>
            <pubDate>Tue, 30 Jul 2024 23:28:28 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>인프런 JPA 기본편 - 김영한 강의 학습내용 정리</p>
</blockquote>
<p>자바세상과 데이터베이스 세상은 다르기 때문에 자바언어로 테이블관계로 
변환하려고 생각하면 생각보다 까다롭고 복잡하다.
어떻게 자바세상에서 연관관계르 만들면 좋을지 공부해보자</p>
<h1 id="연관관계-매핑">연관관계 매핑</h1>
<p>객체지향스러운 연관관계를 가질 수 있는 코드로 DB를 사용하자</p>
<p>방향  :  단방향, 양방향</p>
<p>다중성 : 다대일 , 일대다 , 일대일</p>
<p><strong>연관관계의 주인</strong> : 객체 양방향 연관관계는 관리 감독이 필요 (이게 어렵다고하는데 가장 중요하다고 본다.)</p>
<p>객체를 테이블에 맞추는 모델링 나또한 이렇게 했다.</p>
<p>하지만 객체는 참조를 활용해서 연관관계를 만들어야한다.</p>
<h3 id="객체로-관계-만들어보기-단방향-연관관계">객체로 관계 만들어보기 (단방향 연관관계)</h3>
<pre><code class="language-java">@Entity
public class User {
    @Id @GeneratedValue
    private Long id

    //객체를 가져보자
    @ManyToOne // 각 USER는 동일한 팀에 속할 수 있기 때문
    @JoinColumn(name = &quot;team_id&quot;) //ManyToOne 관계를 가질 때 조인할 컬럼을 명시
    private Team team; </code></pre>
<p>다른 테이블의 Pk 값을 가지는것이 아닌 관계와 어떤 객체와 관계인지를 어노테이션으로 명시하면된다.</p>
<h3 id="양방향-연관관계와-연관관계의-주인">양방향 연관관계와 연관관계의 주인</h3>
<p>테이블  관계는 다대다 관계를 잘 정리하는게 중요하다.</p>
<p>위 단방향관계에서는 user → Team 을 확인할 수 있지만 Team → user는 불가능 했다.</p>
<p>엥 아닌데 user가 Team을 알고있기 Join하면 서로를 알 수 있을텐데? </p>
<p>테이블 연관관계에서는 가능했다. Team PK로 user의 Team FK 와 조인하면 됐다.
하지만 객체지향 세상에서는 불가능하다.</p>
<p>그렇다면 어떻게 양방향으로 만들어 볼까?</p>
<pre><code class="language-java">//Team에도 User로 갈 수 있게 List&lt;User&gt;를 추가하자

@Getter
@Entity
public class Tema {

    @Id @GenerateValue
    private Long id;

    @OneToMany(maapedBy = &quot;team&quot;) 
    private List&lt;User&gt; users = new ArrayList&lt;&gt;();
</code></pre>
<p><strong>User와는 다르게 <code>@JoinColumn</code> 대신 <code>mappedBy</code> 를 사용했다. 뭐가 다른거지?</strong></p>
<p>mappedBy는 너무 중요하다.</p>
<p>이것을 이해하기 위해서는 객체와 테이블이 관계를 맺는 차이를 이해하는 것이 중요하다.</p>
<p><strong>자바세상과 테이블세상의 차이가 중요함</strong></p>
<ul>
<li><p>객체 연관관계 2개</p>
<ul>
<li><p>회원 → 팀</p>
</li>
<li><p>팀 → 회원</p>
<p>단방향 연관관계가 둘</p>
<p>위에서는 양방향으로 만들어볼까? 라고 했지만 단방향 관계를 각각 만들어서
억지로 양방향관계를 만든것이다.</p>
</li>
</ul>
</li>
<li><p>테이블 연관관계</p>
<ul>
<li><p>Team ↔ User</p>
<p>상대방의 PK를 참조키로 가지고있다면 Join하여 관계를 형성할 수 있다.</p>
<p>외래 키하나도 해결이 가능한 특징이있다.</p>
</li>
</ul>
</li>
</ul>
<p>객체는 둘 중 하나로 외래 키를 관리해야 한다. </p>
<p>현재 User는 팀을 가지고 있고 Team 또한 User 객체를 가지고 있다.</p>
<p>이때 어떠한 user가 팀을 바꾸고 싶을 때 user의 팀을 바꿔야할 지 Team에 속한 user를 
바꿀지 고민이된다.</p>
<p><strong>복잡하게 생각하지 말고 둘 중하나로 외래키를 관리하면 된다.</strong></p>
<h3 id="연관관계의-주인-정하기">연관관계의 주인 정하기</h3>
<p>양방향 매핑 규칙</p>
<ul>
<li>객체의 두 관계중 하나를 연관관계의 주인으로 지정</li>
<li>연관관계의 주인만이 외래 키를 관리(수정, 등록)</li>
<li>주인이 아닌쪽은 읽기만 가능</li>
<li>주인은 <code>mappedBy</code> 속성 사용 X</li>
<li>주인이 아니면 <code>mappedBy</code> 속성으로 주인 지정</li>
</ul>
<p><strong>누구를 주인으로 해야하는데?</strong></p>
<p>테이블 관계에서 외래 키가 있는 곳(테이블)을 주인으로 정하자</p>
<p>왜냐하면 Team은 <code>List&lt;User&gt;  user</code> 를 가지고 있는데 여기서 팀 객체에 
set 설정을 했는데 update 쿼리는 user가 가지고 있는 Team명을 바꿔야하기에 
직관적이지 않다.</p>
<p>DB 입장에서 보면 외래키가 있는 곳이 다 인경우가 많다. 여기서 다는 1 : 다 관계를 의미한다.</p>
<h3 id="양방향-매핑-시-가장-많이-하는-실수">양방향 매핑 시 가장 많이 하는 실수</h3>
<p>(연관관계의 주인에 값을 입력하지 않는다)</p>
<p>현재 위에서 정리한 코드를 보면 <code>User</code> 는 Team 객체와의 관계 형성에서 mappedBy를 사용하지 않았다. 따라서 <code>User</code> 객체를 주인이고 <code>mappedBy</code> 를 사용한 <code>Team</code> 객체는 주인이 아니다.</p>
<p>이런 경우에 Team이 소유하고 있는 <code>List&lt;User&gt;</code> 에 <code>User</code> 객체를 Add 하더라고 이것은 데이터베이스에 반영되지 않는다.</p>
<p>데이터베이스에 반영하기위해서는 연관관계의 주인인 <code>User</code> 이기 때문에 <code>User</code> 에만 반영하면 되지만
객체지향적으로 보았을 때는 그리 좋지 않다. 
그리고 팀을 바꾸는 (팀을 업데이트) 하는 로직과 그팀의 유저 명단을 출력하는 것이 한 로직에 있다면
DB에 동기화되지 않고 영속성 컨텍스트에만 적용되어 있는 상태기 때문에 팀을 수정한 유저는 조회할 수 없다는 문제가 발생하다.</p>
<p>이러한 문제를 해결하기 위해서 항상 양쪽을 모두 처리하도록하자. Team 입장에서는 <code>List&lt;User&gt;.add(User)</code> 를 수행하면 된다.</p>
<p>⭕️ 순수 객체 상태를 고려해서 양쪽에 모두 값을 넣자  미래에 발생할 수 있는 문제를 줄이자!! ⭕️</p>
<p><strong>💡아래 방법 활용하기</strong></p>
<pre><code class="language-java">//연관 관계 주인 User 클래스
public void setTeam(Team team) {
    // 기존 team List에서 삭제하는 연산을 추가해도 된다.
    this.team = team;
    team.getUsers().add(this);
    }

// 또는 Team 객체에 addMember() 메소드를 두어 처리 가능
// 하지만 둘 중 하나만 사용할 것</code></pre>
<p>setTeam()을 호출할 때 한번에 처리할 수 있도록!! 
메소드 명을 <code>changeTeam()</code>  과 같이 할 수 있다.</p>
<p><strong>양방향 매핑 시에 무한 루프를 조심하자</strong></p>
<ul>
<li><p>toString()</p>
<p>  toString() 으로 모든 필드멤버를 출력하지 말고 
  양방향 관계의 객체는 빼도록 하자.</p>
</li>
<li><p>lombok</p>
<p>  롬복에서 toString() 만드는거는 최대한 사용하지말고 사용하더라도
  exclude를 활용하자. </p>
</li>
<li><p>JSON 생성 라이브러리</p>
<p>  엔티티를 직접 컨트롤러에서 리스폰스로 보내는 경우 JSON으로 변환하는 과정에 장애 발생</p>
</li>
</ul>
<p>등의 상황에서 순환참조, 무한 루프가 발생할 수 있다.</p>
<h3 id="정리">정리</h3>
<ul>
<li><p>단방향 매핑만으로도 이미 연관관계 매핑은 완료</p>
<p>  최초 설계할 때 일단 단방향 매핑으로 설계를 끝내자</p>
<p>  테이블 설계할 때 객체적으로 생각하지말자 양방향 관계가 생겨서 꼬임</p>
</li>
<li><p>양방향 매핑은 반대 방향으로 조회 기능이 추가된 것 뿐 어렵게 생각하지 말자</p>
<p>  🚨 순환참조가 발생할 수 있는 로직이 있는지 잘 확인하자.</p>
</li>
<li><p>JPQL에서 역방향으로 탐색할 일이 많다.</p>
</li>
<li><p>단방향 매핑을 잘 하고 양방향은 필요할 때 추가해도 된다.</p>
</li>
</ul>
<p><strong>단방향으로 데이터베이스 설계를 끝내고 양방향은 개발단계에서 고려해도 늦지 않는다.</strong></p>
<ul>
<li>비즈니스 로직을 기준으로 연관관계의 주인을 선택 ❌❌❌</li>
<li>연관관계의 주인은 외래 키를 가지고 있는 위치를 기준 ⭕️⭕️⭕️</li>
</ul>
<hr>
<center>
    ⭐틀린 내용 수정,지적은 언제나 환영합니다.⭐
<center>]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] 영속성 (JPA 알아보기2)]]></title>
            <link>https://velog.io/@ho_d97/JPA-%EC%98%81%EC%86%8D%EC%84%B1-JPA-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B02</link>
            <guid>https://velog.io/@ho_d97/JPA-%EC%98%81%EC%86%8D%EC%84%B1-JPA-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B02</guid>
            <pubDate>Mon, 29 Jul 2024 07:51:47 GMT</pubDate>
            <description><![CDATA[<p>JPA를 공부하면서 영속성이라는 단어를 너무 많이 접했고 JPA를 잘 사용하기 위해서는
영속성을 찾아보는 게 중요할 것 같아서 공부해보자</p>
<h2 id="영속성">영속성</h2>
<p>영속성 컨텍스트를 이해하면 JPA가 내부적으로 어떻게 돌아가는지 이해하는데 도움이 된다.</p>
<p>클라이언트의 요청이 올 때마다 EntitiyManagerFactory에서 EntityManager를 개별적으로 만들어준다.</p>
<p>예를 들어서  (여기서  em은 entityManager)</p>
<p><code>클라1  요청 → emFactory em1 생성 → 클라1 em1 사용 → connection 이용 → DB 접근</code></p>
<p><code>클라2  요청 → emFactory em2 생성 → 클라2 em2 사용 → connection 이용 → DB 접근</code></p>
<p>위와 같은 과정으로 클라이언트들은 각각 다른 <code>entityManager</code> 를 사용한다.</p>
<p><strong>그래서 영속성 컨텍스트가 뭔데?</strong></p>
<p>먼저 영속석 컨텍스트는  <strong>Entity를 영구 저장하는 환경</strong> 이라는 뜻이다.</p>
<p><code>EntityManager.persist()</code> 메서드를 호출해서 인자로 entity를 넣어주면 DB에 저장해주는구나! 라고 생각했다. 하지만 이것은 DB에 저장하는 것이 아니라 영속성 컨텍스트에 저장해주는 것이다.</p>
<p>그래서 <strong>EntityManager는 하나의 PersistenceContext</strong>를 가지고 있는데, 이 PersistenceContext에 관리되고 있는 객체라면 영속 상태이고 관리되고 있지 않다면 비영속 상태로 구분할 수 있다.</p>
<p><strong>영속과 비영속</strong></p>
<ul>
<li><p>비영속 : 영속성 컨텍스트와 전혀 관계가 없는 상태</p>
<pre><code class="language-java">  User user = new User();
  user.setName(&quot;ho&quot;);</code></pre>
<p>  기존 자바 세상과 동일하기 때문에 만들고 애플리케이션 재실행하면 다시 찾을 수 없음</p>
</li>
<li><p>영속 : 영속성 컨텍스트에 관리되는 상태</p>
<p>  entityManager를 사용한다.</p>
<pre><code class="language-java">  User user = new User();
  user.setName(&quot;ho&quot;);

  EntityManager em = em.createEntityManager();
  //객체를 영속상태로 만드는 메소드
  em.persist(user);</code></pre>
<p>  EntityManager의 persist 메소드를 사용해서 영속성 컨텍스트에서 관리하여 영속상태로 만든다.</p>
<p>  이후 <code>트랜잭션 commit</code>  명령어가 실행되면 영속성 컨텍스트에 있는 객체들은 DB에 저장된다.</p>
</li>
</ul>
<h3 id="왜-영속성-컨텍스트를-거쳐야-할까">왜 영속성 컨텍스트를 거쳐야 할까?</h3>
<p>영속성 컨텍스트라는 곳을 거치지 않고 바로  DB에 저장하면 좋지 않을까? 라는 의문이 생긴다.</p>
<p>왜 사용해야 하는지 알아보자</p>
<ul>
<li><p>캐시 기능 제공</p>
<p>  <code>em.persist()</code> 메서드를 통해서 영속성  컨텍스트에 저장하는 작업을 수행한다고 했다. 
  이렇게 저장된 객체들은 영속성 컨텍스트 내부에 아래와 같이 캐싱 된다.
<img src="https://velog.velcdn.com/images/ho_d97/post/737c5885-94b5-458f-be22-80de010b06ea/image.png" alt="">이렇게 캐싱되어 User1을 조회하는 경우 DB까지 접근하지 않고 빠르게 제공할 수 있다.</p>
</li>
</ul>
<blockquote>
<p>💡 캐시로 효과적인 성능향상이 가능할까?
큰 성능향상을 기대하기는 어렵다. entityManager는 한 번에 트랜잭션에 사용되고 종료될 때 해당 entityManager 객체도 사라지기 때문에 누적해서 캐싱되는 것은 아니다.
비즈니스 로직이 매우 복잡한 경우는 장점이 될 수 있다.</p>
</blockquote>
<ul>
<li><p>영속 엔티티의 동일성 보장</p>
<p>  User1를 찾아오고 또 같은 트랜잭션 내에서 User1을 새로 find 하는 작업을 수행해도 서로 같은 객체를 보장해준다. 캐싱 되어있는 객체를 찾아오기 때문에 너무 당연할 수 있다.</p>
 <br>
</li>
<li><p>트랜잭션을 지원하는 쓰기 지연</p>
<p>  <code>em.persist()</code> 메소드를 호출하면 캐시에 저장 뿐 아니라 영속 컨텍스트 내부에 있는 쓰기 지연 SQL 저장소에 insert 쿼리문을 생성해서 쌓아둔다. 트랜잭션이 끝나고 <code>commit()</code> 이 호출되면 저장소에 있는 쿼리들은 DB로 전송된다.
  <img src="https://velog.velcdn.com/images/ho_d97/post/9e5fc9e2-9c8e-4ab4-b43b-a9b1630152da/image.png" alt=""></p>
<p>매 작업마다 DB에 접근해서 반영한다면 성능저하가 발생할 수 있는데 이를 최소화 할 수 있도록 한다.</p>
<br></li>
<li><p>엔티티 수정 (변경 감지)</p>
<p>  만약 <code>User foundUser = em.find(User.class, 1L);</code>  라는 명령어를 통해서 
  foundUser 실행 후 동일 트랜잭션 내에서 setter() 를 사용하면<code>foundUser.setName(”이름 바꾸기”)</code> 엔티티매니저를 사용하지 않고 update쿼리를 하지 않더라도 setter 호출 이후 커밋이 된다면 JPA가 update 쿼리를 대신 작성해주어<code>이름 바꾸기</code> 라는 변경된 이름으로 DB에 저장된다.</p>
<ul>
<li><p>어떻게 가능하지?</p>
<p>  그 비밀은 영속 컨텍스트 내부에 있다.
  위 자료 1차 캐시 내부에는  @ID,Enity 컬럼만 존재하지만 스냅 샷 컬럼이 추가로 존재한다.</p>
<p>  이 스냅 샷 컬럼에는 객체가 컨택스트에 처음들어 왔을 때의 상태를 스냅 샷으로 찍어두는데  이후 변경이 발생한다면 스냅 샷의 내용과 비교해서 변경을 감지 변경되었다면 update Query를 쓰기 지연 SQL 저장소에 저장하기 때문에 commit 시점에 setter로 적용한 변경까지 DB에 적용할 수 있다.</p>
</li>
</ul>
</li>
</ul>
<p>영속성 컨텍스트를 설명하면서 commit 시점에 DB에 저장된다고 예시를 많이 들었지만 커밋뿐만 아니라 다른 상황에서도 쓰기 지연 SQL 저장에 누적된 쿼리들이 DB로  반영할 수 있다.</p>
<h3 id="영속성-컨텍스트의-객체를-db에-반영하는-방법">영속성 컨텍스트의 객체를 DB에 반영하는 방법</h3>
<ul>
<li><p><code>em.flush()</code>  - 직접 호출</p>
</li>
<li><p>트랜잭션 커밋 - 플러시 자동 호출</p>
</li>
<li><p>JPQL 쿼리 실행- 플러시 자동 호출</p>
<p>  <code>em.persist(entity)</code> 이렇게 persist에 등록하더라도 바로 DB에 반영되지 않는다고 했다.
  만약 아래 같은 코드를 작성하면 어떻게 될까?</p>
<pre><code class="language-java">  em.persist(userA);
  em.persist(userB);

  // JPQL 실행
  query = em.createQuery(&quot;select u from User u&quot;, User.class);

  //query 실행해서 User 조회하기
  List&lt;User&gt; users = query.getResultList();</code></pre>
<p>  현재 위 코드를 보면 userA, userB를 persist()하고 트랜잭션 커밋, flush() 작업은 발생하지 않았다.
  그럼 userA,B는 DB에 저장되지 않은 상황이기 때문에 userA,B의 정보를 데이터베이스에서 가져오지 못할 것이다.</p>
<p>  하지만 <strong>정답은 userA,B의 값을 조회할 수 있는데, 이유는 JPQL은 쿼리를 실행하기 전에 <code>flush()</code> 를 수행하고 쿼리를 실행하기 때문에 조회할 수 있다.</strong></p>
</li>
</ul>
<blockquote>
<p>🚨flush 특징🚨</p>
</blockquote>
<ul>
<li>영속성 컨텍스트를 비우지 않음</li>
<li>영속성 컨텍스트의 변경내용을 데이터베이스에 동기화</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] JPA 알아보기 1]]></title>
            <link>https://velog.io/@ho_d97/JPA2</link>
            <guid>https://velog.io/@ho_d97/JPA2</guid>
            <pubDate>Fri, 26 Jul 2024 14:19:15 GMT</pubDate>
            <description><![CDATA[<p>이전 글에서는 JPA를 왜 사용해야하는지 간단하게 알아보았다.
이 글은 JPA의 문법을 다루는 글도 JPA는 이렇게 써야한다. 라는 글은 아니다.
초보 개발인 내가 JPA를 어떻게 시작하고 어떤 생각을 했는지를 정리하는 글이다.</p>
<h3 id="jpa-orm">JPA? ORM?</h3>
<p>데이터베이스로 흔히 사용하는 MySQL과 같은 관계형 데이터베이스(RDBMS)는 SQL 문을 사용합니다. SQL을 한 번이라도 경험해본 적이 있다면, 프로그래밍 언어와는 많이 다르지만, 사람의 언어와 비슷하다고 느낄 수 있다. 데이터베이스는 이러한 SQL 쿼리를 통해 데이터를 저장하고 수정하며 관리할 수 있다.</p>
<p>자바, 파이썬, 자바스크립트 등의 프로그래밍 언어만으로도 데이터베이스를 쉽게 사용할 수 있도록 도와주는 기술이 바로 ORM(Object-Relational Mapping)이다. 자바의 경우, ORM을 제공하는 것이  JPA(Java Persistence API) 이다.</p>
<p>간단히 말해, JPA를 사용하면 자바 코드로 데이터베이스의 테이블을 관리하고, 데이터베이스와 상호작용할 수 있게 된다. ORM을 통해 SQL 쿼리를 직접 작성하지 않고도 객체 지향 프로그래밍의 장점을 활용하여 데이터베이스 작업을 할 수 있다.</p>
<h3 id="jpa-사용하기">JPA 사용하기</h3>
<p>먼저 나는 SpringBoot를 사용해서 프로젝트를 생성해두었다. 
분명 라이브러리 보물 창고인 Maven Repository에서 JPA를 쉽게 찾을 수 있다.
<img src="https://velog.velcdn.com/images/ho_d97/post/8150ff1b-98df-472c-af71-01f40fc79a66/image.jpg" height="300px">
바로 이 녀석을 build.gradle에 추가해주면  코끼리가 열심히 세팅해준다.</p>
<p>이제 이 JPA만 있으면 자바 세상에서 객체 지향적인 설계만 고려하는 자바 개발자가 될 수 있다.
<del>어쩌면 디비 관계랑 같이 생각하다가  설계를 살짝 잘 못 했네요. 라는 보험을 잃어버린걸 지도…</del></p>
<h3 id="java--↔-d">Java  ↔ D</h3>
<img src="https://velog.velcdn.com/images/ho_d97/post/b06748b8-4e51-483a-a061-500e0fd48a08/image.png" height="300px">
이런 구조로 Java는 저장하고 싶은 객체지향적인 코드를 JPA 에게 알려주면 JPA는 JDBC가 데이터베이스에
보내기 쉽게 SQL 쿼리로 바꿔서 서로 통신하여 데이터베이스를 자바 코드만을 이용해서 DB를 사용할 수 있다.

<h3 id="그래서-jpa-어떻게-사용하는-거야">그래서 JPA 어떻게 사용하는 거야?</h3>
<p>바로 구글에 JPA 공식문서를 검색해서 들어갔다.
🔗 ‣<a href="https://docs.spring.io/spring-data/jpa/reference/jpa/getting-started.html">https://docs.spring.io/spring-data/jpa/reference/jpa/getting-started.html</a>
Getting Started를 따라해보았다.</p>
<p>튜토리얼에서 알려주지는 않지만  <code>application.properties</code>에 JDBC설정과
ddl 권한을 추가해줘야 테스트를 해볼 수 있다.
<img src="https://velog.velcdn.com/images/ho_d97/post/dddae725-d137-4c0a-914f-5093bf782fd3/image.png" height="200px" width="200px">
데이터베이스에 아주 잘 저장이 되었다.
근데 저 person_seq라는 테이블도 같이 생겼다.
이건 <code>@GeneratedValue(strategy = GenerationType.AUTO)</code> 이 어노테이션 때문에 생긴 테이블 같았다. <code>application.properties</code> ddl권한을 지우고 DB에 직접 person 테이블을 만들어주고 id값에 자동증가 옶션을 적용했더니  <code>person_seq</code> 테이블은 생기지 않았다. 
JPA한테 시키면 불필요한 테이블이 생겨 데이터베이스가 복잡해질 수 있을 거 같다.</p>
<p><strong>튜토리얼 코드</strong></p>
<pre><code class="language-java">//튜토리얼에서 제공하는 코드
interface PersonRepository extends Repository&lt;Person, Long&gt; {

  Person save(Person person);

  Optional&lt;Person&gt; findById(long id);
}</code></pre>
<p><code>Repository</code> 라는 인터페이스를 상속 받아야 했다. 
난 저런 인터페이스가 어떻게 있지? 하고 찾아보았는데 아까 메이븐 레포지토리에서 받아서
추가해준 라이브러리에 아래와 같이 존재했다.
<img src="https://velog.velcdn.com/images/ho_d97/post/a602a64b-606d-4b18-9cfc-56b440dffc95/image.png" height="300px" width="300px"></p>
<p><code>ListCrudRepository</code> 또는 <code>JpaRepository</code> 를 상속받아서 사용하면 더 많은 메소드를 사용할 수 있는 것 같았다. 실제로 두개  의 레퍼지토리를 확인해보니 <code>Repository</code>를 상속받았고 메소드들을 더 추가되어있다.</p>
<p>간단한 CRUD 기능은 튜토리얼에서 언급하는 레포지토리를 상속받아 사용해도 괜찮을 것 같았다.</p>
<p>지금까지의 정보만으로도 간단한 게시판 기능을 만드는데 필요한 CRUD는 충분히 할 수 있을 것 같다.
하지만 JPA를 찾아보면서 Persistence (영속성)이라는 단어를 많이 접했고 이 부분에 대해서 
다음 글에서 더 정리해봐야겠다.</p>
<hr>
<center>
    ⭐틀린 내용 수정,지적은 언제나 환영합니다.⭐
<center>]]></description>
        </item>
        <item>
            <title><![CDATA[[JPA] JPA는 왜 필요할까?]]></title>
            <link>https://velog.io/@ho_d97/JPA1</link>
            <guid>https://velog.io/@ho_d97/JPA1</guid>
            <pubDate>Thu, 25 Jul 2024 13:09:03 GMT</pubDate>
            <description><![CDATA[<p>JPA를 왜 사용해야 하는지에 앞서 먼저 JPA Java Persistence API의 약자로
자바에서 객체를 데이터베이스에 저장하고 관리하기 위한 API를 제공한다.</p>
<p>여기서 중요한점은 <strong>객체를 데이터베이스에 저장하고 관리</strong>한다는 점이다.</p>
<h2 id="db-세상과-java-세상">DB 세상과 Java 세상</h2>
<h3 id="db-세상">DB 세상</h3>
<p><img src="https://velog.velcdn.com/images/ho_d97/post/e7073f75-98f3-4337-851b-0ab37f618682/image.png" alt=""></p>
<p>DB는 하나하나의 데이터로 구성된 데이터 세상이다.
그래서 예를 들어 주문 테이블과 음식 테이블 간 관계를 만든다고하면</p>
<p>주문 테이블은 음식 테이블의 음식번호 (PK) 데이터 하나만 알고 있으면, 이를 통해 음식 테이블에 정보를 사용할 수 있다.</p>
<h3 id="java-세상">Java 세상</h3>
<p>Java 세상은 하나하나의 객체들로 구성된 객체 세상이다.
여기서 객체는 여러 데이터로 구성되어있다.</p>
<p>그래서 자바에 상에서 주문 객체와 음식 객체 간 관계를 만든다고 하면
주문 객체는 음식의 데이터를 가지고 있어야 하는 것이 아닌 음식 객체를 가져야 한다.</p>
<pre><code class="language-java">class Order {
    String id;
    Integer price;
    List&lt;Food&gt; foods; 
}</code></pre>
<p>객체들의 상호작용으로 돌아가는 Java 세상에서는 foodId만을 가지고 있는 것이 아니라 Food 객체 자체를 가지고 있어야 한다.</p>
<h3 id="jpa가-필요한-이유">JPA가 필요한 이유</h3>
<p>이렇게 DB와 자바는 취급하는 기본단위가 서로 다르기 때문에
자바 코드를 단순히 SQL로 바꾼다고 데이터베이스에 저장되지 않는다.
왜냐하면 DB에서는 객체의 모든 정보가 필요한 것이 아니라 음식 ID 라는 데이터 하나만 있으면 된다.</p>
<p>하지만 JAVA, DB 둘 중 하나가 상대의 타입에 맞춰준다면 쉽게 해결될 수 있겠지만, 이거는 불가능하다. 
상대 특성에 맞춘다면 자신 고유의 장점과 철학이 사라질 것이다. 모호한 기술을 개발자들은 사용하기 싫을 것이다. 
이때 서로의 특성을 살려 자바는 객체지향 설계를 할 수 있게 유지해주고 데이터베이스는 데이터 위주의 설계를 유지할 수 있게 중개자 역할을 하는 JPA를 사용해서 서로 맞춰줄 수 있게 할 수 있다.
다음 글에서 JPA를 어떻게 사용할 수 있는지 알아보도록 하자!</p>
<hr>
<center>
    ⭐틀린 내용 수정,지적은 언제나 환영합니다.⭐
<center>]]></description>
        </item>
    </channel>
</rss>