<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>do_dev.log</title>
        <link>https://velog.io/</link>
        <description>우당탕탕</description>
        <lastBuildDate>Fri, 03 Jul 2026 08:53:15 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>do_dev.log</title>
            <url>https://velog.velcdn.com/images/do_dev/profile/2e4f0d9a-8f61-4b71-b293-9aa6bccafa51/social_profile.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. do_dev.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/do_dev" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Spring Batch  트랜잭션 매니저 충돌 트러블슈팅]]></title>
            <link>https://velog.io/@do_dev/Spring-Batch-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%A7%A4%EB%8B%88%EC%A0%80-%EC%B6%A9%EB%8F%8C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-77zp4m56</link>
            <guid>https://velog.io/@do_dev/Spring-Batch-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%A7%A4%EB%8B%88%EC%A0%80-%EC%B6%A9%EB%8F%8C-%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-77zp4m56</guid>
            <pubDate>Fri, 03 Jul 2026 08:53:15 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-batch--jpa--jdbctemplate-혼용-환경에서-트랜잭션-매니저-충돌-트러블슈팅">Spring Batch + JPA + JdbcTemplate 혼용 환경에서 트랜잭션 매니저 충돌 트러블슈팅</h1>
<h2 id="개요">개요</h2>
<p>우편번호 마스터 동기화 배치(<code>ZipcdSyncTasklet</code>)를 신규 개발 후 Jenkins에서 실행했더니,
<code>cmm_road_cd_d</code> 테이블은 정상적으로 bulk upsert 되는데 <code>cmm_road_addr_d</code>는 계속 실패했다.</p>
<p>에러 로그만 보면 SQL 파라미터 바인딩 문제처럼 보였지만, 실제 원인은 <strong>트랜잭션 매니저 충돌</strong>이었다.</p>
<hr>
<h2 id="프로젝트-구성">프로젝트 구성</h2>
<h3 id="트랜잭션-매니저-설정">트랜잭션 매니저 설정</h3>
<p>이 프로젝트는 JPA와 JdbcTemplate을 혼용하며, 두 곳에서 트랜잭션 매니저를 설정한다.</p>
<p><strong>1. DefaultDataSourceConfig — JPA 트랜잭션 매니저 (<code>@Primary</code>)</strong></p>
<pre><code class="language-java">@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    basePackages = {&quot;com.gsshop.greenhouse.crocus.domain&quot;},
    entityManagerFactoryRef = &quot;entityManagerFactory&quot;,
    transactionManagerRef = &quot;transactionManager&quot;
)
public class DefaultDataSourceConfig {

    @Primary
    @Bean(name = &quot;dataSource&quot;)
    @ConfigurationProperties(prefix = &quot;default.datasource&quot;)
    public DataSource dataSource() {
        return DataSourceBuilder.create().build();
    }

    @Primary
    @Bean(name = &quot;entityManagerFactory&quot;)
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(...) { ... }

    @Primary
    @Bean(name = &quot;transactionManager&quot;)
    public PlatformTransactionManager transactionManager(
            @Qualifier(&quot;entityManagerFactory&quot;) EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);  // ← JPA용 @Primary
    }
}</code></pre>
<p><strong>2. CrocusBatchApplication — 배치 인프라 설정</strong></p>
<pre><code class="language-java">@EnableBatchProcessing
public class CrocusBatchApplication {

    // JPA 트랜잭션 매니저 (도메인과 동일하게 @Primary)
    @Primary
    @Bean(name = &quot;transactionManager&quot;)
    public PlatformTransactionManager transactionManager(
            @Qualifier(&quot;entityManagerFactory&quot;) EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }

    // 배치 인프라(JobRepository, JobLauncher 등)용 설정
    @Bean
    public BatchConfigurer batchConfigurer() {
        return new BatchConfigurer() {
            private PlatformTransactionManager transactionManager;
            // ...

            @PostConstruct
            public void initialize() {
                if (this.transactionManager == null) {
                    // JobRepository 등 배치 인프라는 ResourcelessTransactionManager 사용
                    this.transactionManager = new ResourcelessTransactionManager();
                }
                MapJobRepositoryFactoryBean jrf =
                    new MapJobRepositoryFactoryBean(this.transactionManager);
                // ...
            }
        };
    }
}</code></pre>
<p>핵심 포인트: <strong>배치 인프라(JobRepository)는 <code>ResourcelessTransactionManager</code>를 쓰고, 실제 도메인 트랜잭션은 <code>JpaTransactionManager</code>(<code>@Primary</code>)를 쓴다.</strong></p>
<h3 id="호출-구조">호출 구조</h3>
<pre><code>ZipcdSyncTasklet.execute()                     ← @Transactional 없음
  └─ AddrDailyUpdateService.addrDailySync()    ← @Transactional 없음, try-catch로 예외 삼킴
       └─ AddressToZipcdServiceImpl
               .processRoadAddr()              ← @Transactional (JpaTransactionManager)
            └─ CmmRoadAddrDBulkRepository
                    .batchUpsert()             ← @Transactional + JdbcTemplate</code></pre><hr>
<h2 id="에러-로그">에러 로그</h2>
<pre><code>2026-07-03 12:54:55.474 ERROR
  ❌ batchUpdate 중 에러 발생!!
  org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback;
    nested exception is java.sql.SQLException: No value specified for parameter 13

2026-07-03 12:54:55.479 ERROR
  addrDailyUpdate Batch Exception: ... No value specified for parameter 13

2026-07-03 12:54:55.543 INFO
  ******************** BATCH END ********************   ← 배치는 정상 종료처럼 보임

2026-07-03 12:54:57.681 ERROR
  org.springframework.orm.jpa.JpaSystemException:
    Transaction was marked for rollback only; cannot commit</code></pre><hr>
<h2 id="삽질--코드-버그에-낚임">삽질 — 코드 버그에 낚임</h2>
<p>에러 메시지를 보고 &quot;파라미터 13 바인딩이 안 됐다&quot;는 것에 집중했다.</p>
<p>git 히스토리를 보니 실제로 중간 커밋에서 파라미터 13~16이 주석 블록 안에 들어간 버그가 있었다:</p>
<pre><code class="language-java">// 47c5a7d 커밋 상태 (12:52 커밋, 12:54 Jenkins 실행)
ps.setString(12, item.getUseYn());

/*
    ps.setTimestamp(13, toTimestamp(item.getCreatedAt()));  // ← 주석 안에 갇힘
    ps.setString(14, item.getCreatedBy());
    ps.setTimestamp(15, toTimestamp(item.getModifiedAt()));
    ps.setString(16, item.getModifiedBy());
*/</code></pre>
<p>이 버그는 5분 뒤 <code>ed61ba9</code> 커밋에서 수정됐다.
<strong>그런데 수정된 코드로 재배포해도 여전히 같은 에러가 반복됐다.</strong></p>
<hr>
<h2 id="실제-원인--트랜잭션-매니저-충돌">실제 원인 — 트랜잭션 매니저 충돌</h2>
<h3 id="step에-트랜잭션-매니저를-명시하지-않으면-어떻게-되나">Step에 트랜잭션 매니저를 명시하지 않으면 어떻게 되나</h3>
<p><code>StepBuilderFactory</code>에 <code>.transactionManager()</code>를 지정하지 않으면,
<code>TaskletStep</code>은 <code>StepBuilderFactory</code>에 주입된 트랜잭션 매니저를 그대로 쓴다.</p>
<p>이 프로젝트에서는 <code>BatchConfigurer</code>가 배치 인프라용으로 <code>ResourcelessTransactionManager</code>를 쓰도록 설정되어 있지만, <code>StepBuilderFactory</code>는 Spring Context의 <code>@Primary</code> 빈인 <strong><code>JpaTransactionManager</code></strong>를 주입받는다.</p>
<p>즉 Step의 기본 트랜잭션 매니저 = <code>JpaTransactionManager</code>.</p>
<h3 id="문제-흐름">문제 흐름</h3>
<pre><code>[Batch Step] JpaTransactionManager tx 시작
  └─ addrDailySync() — @Transactional 없음, try-catch 있음
       └─ processRoadAddr() @Transactional (REQUIRED)
            └─ 이미 JPA tx가 있으므로 기존 tx에 참여
            └─ batchUpsert() @Transactional (REQUIRED)
                 └─ 이미 JPA tx에 참여
                 └─ JdbcTemplate 실행 중 SQLException 발생
                    → Spring @Transactional AOP: tx를 rollback-only 마킹
                    → 예외 rethrow

       ← processRoadAddr() @Transactional AOP: RuntimeException
            → REQUIRED 참여자이므로 직접 rollback 불가
            → rollback-only 마킹 유지, 예외 rethrow

  ← addrDailySync() try-catch에서 예외를 잡아버림 (rethrow 안 함!)
       → systemLogService.errorWithNewTransaction() 호출 (REQUIRES_NEW로 별도 tx 커밋)
       → 정상 return

[Batch Step] TaskletStep이 JPA tx 커밋 시도
  → tx는 이미 rollback-only 상태
  → &quot;Transaction was marked for rollback only; cannot commit&quot; 💥</code></pre><p><strong>핵심</strong>: <code>addrDailySync()</code>가 예외를 잡고 <strong>rethrow하지 않아서</strong> Step은 &quot;성공한 것처럼&quot; 흘러갔고, 마지막에 Step의 JPA 트랜잭션이 커밋을 시도하다 터졌다.</p>
<h3 id="cmm_road_cd_d는-왜-됐나">cmm_road_cd_d는 왜 됐나</h3>
<pre><code class="language-java">// processRoadCd — @Transactional 없음
public List&lt;CmmRoadCdDDTO&gt; processRoadCd(String toDate) throws Exception {
    // ...
    for (List&lt;CmmRoadCdD&gt; chunk : Lists.partition(cmmRoadCdDList, BULK_CHUNK_SIZE)) {
        cmmRoadCdDBulkRepository.batchUpsert(chunk);  // 청크별 독립 tx
    }
}</code></pre>
<p><code>processRoadCd()</code>에는 <code>@Transactional</code>이 없다.
<code>cmmRoadCdDBulkRepository.batchUpsert()</code>의 <code>@Transactional</code>(REQUIRED)은 진행 중인 JPA tx가 없으므로 <strong>자기 자신이 tx를 열고 닫는다.</strong> 청크별로 독립적인 트랜잭션이 생성·완료되기 때문에 문제가 없었다.</p>
<p>반면 <code>processRoadAddr()</code>에는 <code>@Transactional</code>이 붙어 있어서, 내부의 <code>batchUpsert()</code>가 <strong>외부 Step tx에 참여</strong>하는 구조가 됐고 충돌이 발생했다.</p>
<hr>
<h2 id="해결--step에-트랜잭션-매니저-명시">해결 — Step에 트랜잭션 매니저 명시</h2>
<pre><code class="language-java">// CrocusBatchConfig.java

@Autowired
private PlatformTransactionManager transactionManager; // JPA용 @Primary

// Before — 트랜잭션 매니저 미지정 (StepBuilderFactory 기본값 사용)
@Bean
public Step zipcdSyncTaskletStep() {
    return stepBuilderFactory.get(&quot;zipcdSyncTaskletStep&quot;)
            .tasklet(zipcdSyncTasklet)
            .build();
}

// After — JPA 트랜잭션 매니저 명시
@Bean
public Step zipcdSyncTaskletStep(
        @Qualifier(&quot;transactionManager&quot;) PlatformTransactionManager txManager) {
    return stepBuilderFactory.get(&quot;zipcdSyncTaskletStep&quot;)
            .transactionManager(txManager)   // ← 명시
            .tasklet(zipcdSyncTasklet)
            .build();
}

@Bean
public Job zipcdSyncTaskletJob() {
    return jobBuilderFactory.get(&quot;zipcdSyncTaskletJob&quot;)
            .start(zipcdSyncTaskletStep(transactionManager))
            .build();
}</code></pre>
<p><code>.transactionManager(txManager)</code>를 명시하면 Step이 어떤 트랜잭션 매니저를 쓰는지 명확해지고, 예외 발생 시 Step 레벨에서 롤백 처리가 의도대로 동작한다.</p>
<hr>
<h2 id="트랜잭션-매니저-설정-방법-정리">트랜잭션 매니저 설정 방법 정리</h2>
<p>Spring Batch에서 트랜잭션 매니저를 제어하는 방법은 세 가지다.</p>
<h3 id="1-step-빌드-시-직접-지정-권장">1. Step 빌드 시 직접 지정 (권장)</h3>
<pre><code class="language-java">@Bean
public Step myStep(PlatformTransactionManager txManager) {
    return stepBuilderFactory.get(&quot;myStep&quot;)
            .transactionManager(txManager)   // ← 명시적 지정
            .tasklet(myTasklet)
            .build();
}</code></pre>
<p>Step마다 어떤 트랜잭션 매니저를 쓸지 명확하게 제어할 수 있다.</p>
<h3 id="2-batchconfigurer-커스터마이징">2. BatchConfigurer 커스터마이징</h3>
<pre><code class="language-java">@Bean
public BatchConfigurer batchConfigurer(PlatformTransactionManager txManager) {
    return new DefaultBatchConfigurer(dataSource) {
        @Override
        public PlatformTransactionManager getTransactionManager() {
            return txManager;   // ← 모든 Step의 기본값이 됨
        }
    };
}</code></pre>
<p>모든 Step에 동일한 트랜잭션 매니저를 적용하고 싶을 때 쓴다.</p>
<h3 id="3-enablebatchprocessing-기본-동작-의존-비권장">3. @EnableBatchProcessing 기본 동작 의존 (비권장)</h3>
<p>아무것도 안 하면 <code>@Primary</code> PlatformTransactionManager 빈이 주입된다.
어떤 매니저가 쓰이는지 명시적이지 않아서 이번처럼 의도치 않은 충돌이 발생할 수 있다.</p>
<hr>
<h2 id="교훈">교훈</h2>
<ul>
<li><strong>Spring Batch Tasklet Step을 만들 때는 <code>.transactionManager()</code>를 항상 명시하자.</strong></li>
<li><code>addrDailySync()</code>가 try-catch로 예외를 삼키는 구조가 문제를 더 복잡하게 만들었다. 예외를 삼킬 때는 트랜잭션 상태가 어떻게 되는지 반드시 고려해야 한다.</li>
<li>에러 로그에 명백한 코드 버그(파라미터 바인딩)가 보이면 거기에만 집중하게 된다. 트랜잭션 에러가 함께 찍히면 <strong>두 에러의 인과관계</strong>를 먼저 파악하는 게 중요하다.</li>
<li>마이그레이션 때도 같은 이슈로 고생했는데 또 당했다. 이번엔 기록으로 남긴다.</li>
</ul>
<hr>
<h2 id="관련-구성-요약">관련 구성 요약</h2>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>트랜잭션 매니저</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>Spring Batch 인프라 (JobRepository)</td>
<td><code>ResourcelessTransactionManager</code></td>
<td><code>BatchConfigurer</code> 설정</td>
</tr>
<tr>
<td><code>StepBuilderFactory</code> 기본값</td>
<td><code>JpaTransactionManager</code></td>
<td><code>@Primary</code> 빈 주입</td>
</tr>
<tr>
<td><code>zipcdSyncTaskletStep</code> (수정 후)</td>
<td><code>JpaTransactionManager</code></td>
<td><code>.transactionManager()</code> 명시</td>
</tr>
<tr>
<td><code>@Transactional</code> (Service)</td>
<td><code>JpaTransactionManager</code></td>
<td><code>@Primary</code>, <code>transactionManagerRef</code></td>
</tr>
<tr>
<td><code>JdbcTemplate</code></td>
<td><code>JpaTransactionManager</code> tx에 참여</td>
<td>동일 DataSource 사용</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[whofeeding.gg 개발기 1편]]></title>
            <link>https://velog.io/@do_dev/whofeeding.gg-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1%ED%8E%B8</link>
            <guid>https://velog.io/@do_dev/whofeeding.gg-%EA%B0%9C%EB%B0%9C%EA%B8%B0-1%ED%8E%B8</guid>
            <pubDate>Sun, 07 Jun 2026 09:54:44 GMT</pubDate>
            <description><![CDATA[<h1 id="whofeedinggg-개발기-1편--프로젝트-기획과-기능-정의">whofeeding.gg 개발기 1편 — 프로젝트 기획과 기능 정의</h1>
<blockquote>
<p>LoL 트롤 신고 커뮤니티 + 전적 통계 서비스를 직접 만들어보는 과정을 기록합니다.</p>
</blockquote>
<hr>
<h2 id="프로젝트-소개">프로젝트 소개</h2>
<p><strong>whofeeding.gg</strong>는 League of Legends 전적 통계와 트롤 신고 커뮤니티를 결합한 웹 서비스입니다.</p>
<p>서비스 이름은 LoL 게임 중 채팅에서 자주 사용되는 문구나 단어를 사용하고 싶었는데 클로드가 알려줘서 북미 서버 기준일지 모르겠지만 알려주어 &quot;who feeding?&quot;(누가 킬을 내주고 있냐?)에서 착안했습니다.</p>
<p>op.gg와 같은 전적 조회 서비스는 다양하게 존재하지만, 트롤 유저를 커뮤니티 기반으로 신고하고 기록할 수 있는 서비스는 찾기 어려웠습니다. 이 간극을 직접 채워보고자 프로젝트를 시작하게 되었습니다.</p>
<hr>
<h2 id="프로젝트-목표">프로젝트 목표</h2>
<ul>
<li>Riot Games API 연동, Redis 캐싱, JPA 기반 통계 처리 등 백엔드 핵심 기술을 실제 서비스 수준으로 구현</li>
<li>기획부터 배포까지 전 과정을 직접 경험하며 풀스택 개발 역량 확보</li>
<li>op.gg에 없는 커뮤니티 기반 트롤 신고 시스템으로 서비스 차별화</li>
</ul>
<hr>
<h2 id="기술-스택">기술 스택</h2>
<table>
<thead>
<tr>
<th>영역</th>
<th>기술</th>
<th>선택 이유</th>
</tr>
</thead>
<tbody><tr>
<td>Language</td>
<td>Java 21</td>
<td>실무 환경 대응 및 최신 LTS 버전</td>
</tr>
<tr>
<td>Backend Framework</td>
<td>Spring Boot 3.3</td>
<td>생산성과 확장성을 모두 갖춘 표준 프레임워크</td>
</tr>
<tr>
<td>ORM</td>
<td>Spring Data JPA</td>
<td>객체 중심 설계 및 반복 쿼리 최소화</td>
</tr>
<tr>
<td>Database</td>
<td>MySQL 8</td>
<td>관계형 데이터 처리 및 JSON 컬럼 지원</td>
</tr>
<tr>
<td>Build Tool</td>
<td>Gradle (Groovy)</td>
<td>Maven 대비 빠른 빌드 속도</td>
</tr>
<tr>
<td>Frontend</td>
<td>React</td>
<td>컴포넌트 기반 UI 구성 및 생태계</td>
</tr>
<tr>
<td>외부 API</td>
<td>Riot Games API</td>
<td>소환사/매치 데이터 수집</td>
</tr>
</tbody></table>
<p>추후 추가 예정: Redis (캐싱), Docker, AWS EC2, GitHub Actions CI/CD</p>
<hr>
<h2 id="레퍼런스-분석--opgg">레퍼런스 분석 — op.gg</h2>
<p>whofeeding.gg의 전적 기능은 op.gg를 레퍼런스로 삼아 설계했습니다.</p>
<p>op.gg가 제공하는 핵심 기능은 크게 네 가지입니다.</p>
<ul>
<li><strong>소환사 검색</strong> — Riot ID(닉네임 + 태그)로 검색</li>
<li><strong>전적 조회</strong> — 최근 매치 목록, KDA, CS, 딜량 등 상세 스탯</li>
<li><strong>챔피언 분석</strong> — 티어 목록, 승률/픽률/밴률, 추천 빌드 및 룬</li>
<li><strong>랭킹</strong> — 서버 전체 LP 순위, 챔피언 장인 랭킹</li>
</ul>
<p>이를 기반으로 whofeeding.gg만의 기능을 추가했습니다.</p>
<hr>
<h2 id="4대-핵심-기능">4대 핵심 기능</h2>
<h3 id="①-소환사-검색--전적">① 소환사 검색 &amp; 전적</h3>
<ul>
<li>Riot ID(닉네임 + 태그) 기반 소환사 검색</li>
<li>프로필 요약 (레벨, 아이콘, 마지막 업데이트 시각)</li>
<li>큐 타입별 랭크 정보 (솔로랭크 / 자유랭크 / 아레나 등 — 티어, LP, 승/패, 승률)</li>
<li>최근 매치 목록 (챔피언, KDA, CS/분, 딜량, 게임 시간, 승패)</li>
<li>매치 상세 페이지 (팀원 전체 스탯, 아이템 빌드 순서, 룬)</li>
<li>챔피언별 플레이 통계 (플레이 횟수, 승률, KDA)</li>
<li>선호 포지션 비율</li>
<li>자주 함께 플레이한 소환사</li>
</ul>
<h3 id="②-챔피언-분석">② 챔피언 분석</h3>
<ul>
<li>챔피언 티어 목록 (포지션별 필터)</li>
<li>챔피언별 승률 / 픽률 / 밴률</li>
<li>추천 빌드 (아이템 순서)</li>
<li>추천 룬 세트</li>
<li>카운터 챔피언 정보</li>
</ul>
<h3 id="③-랭킹">③ 랭킹</h3>
<ul>
<li>서버 전체 랭킹 (LP 기준)</li>
<li>챔피언 장인 랭킹 (숙련도 기준)</li>
</ul>
<h3 id="④-트롤-신고-커뮤니티-차별점">④ 트롤 신고 커뮤니티 (차별점)</h3>
<p>op.gg에 없는 whofeeding.gg만의 핵심 기능입니다.</p>
<ul>
<li>소환사 신고 게시글 작성 (증거 스크린샷 첨부 가능)</li>
<li>신고 목록 및 상세 페이지</li>
<li>공감 / 비공감</li>
<li>댓글</li>
<li>신고 누적 기반 자체 제재 시스템<ul>
<li>신고 10회 이상 → 요주의 소환사 배지</li>
<li>신고 30회 이상 → 트롤 인증 배지</li>
<li>커뮤니티 투표 기반 명예의 전당</li>
</ul>
</li>
</ul>
<p>Riot Games API는 계정 정지 이력을 외부에 제공하지 않습니다. whofeeding.gg는 커뮤니티가 직접 트롤 이력을 기록하고 공유하는 방식으로 이 공백을 채웁니다.</p>
<hr>
<h2 id="패키지-구조">패키지 구조</h2>
<p>도메인 중심으로 패키지를 구성했습니다.</p>
<pre><code>com.whofeeding
├── domain
│   ├── summoner      # 소환사
│   ├── match         # 매치 / 전적
│   ├── champion      # 챔피언 통계
│   └── report        # 트롤 신고
├── api               # Controller, DTO
├── client            # Riot API 연동
└── global            # 공통 예외처리, 설정</code></pre><hr>
<h2 id="개발-로드맵">개발 로드맵</h2>
<table>
<thead>
<tr>
<th>단계</th>
<th>내용</th>
<th>상태</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>프로젝트 기획 &amp; 기능 정의</td>
<td>✅ 완료</td>
</tr>
<tr>
<td>2</td>
<td>ERD 설계</td>
<td>🔄 진행 중</td>
</tr>
<tr>
<td>3</td>
<td>Riot API 연동</td>
<td>⬜ 예정</td>
</tr>
<tr>
<td>4</td>
<td>도메인 엔티티 구현 (JPA)</td>
<td>⬜ 예정</td>
</tr>
<tr>
<td>5</td>
<td>서비스 레이어 구현</td>
<td>⬜ 예정</td>
</tr>
<tr>
<td>6</td>
<td>REST API 구현</td>
<td>⬜ 예정</td>
</tr>
<tr>
<td>7</td>
<td>Redis 캐싱 적용</td>
<td>⬜ 예정</td>
</tr>
<tr>
<td>8</td>
<td>React 프론트엔드 구현</td>
<td>⬜ 예정</td>
</tr>
<tr>
<td>9</td>
<td>테스트 코드 작성</td>
<td>⬜ 예정</td>
</tr>
<tr>
<td>10</td>
<td>배포 (Docker + AWS EC2 + GitHub Actions)</td>
<td>⬜ 예정</td>
</tr>
</tbody></table>
<hr>
<h2 id="마치며">마치며</h2>
<p>다음 편에서는 ERD 설계를 다룹니다. 소환사, 매치, 챔피언, 신고 도메인의 테이블을 어떻게 설계했는지, 그 과정에서 어떤 고민을 했는지 정리할 예정입니다.</p>
<blockquote>
<p>2편 — ERD 설계로 이어집니다.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[쿼리 N번 → 1번: TODO 주석으로 남아있던 주문분배 N+1 ]]></title>
            <link>https://velog.io/@do_dev/%EC%BF%BC%EB%A6%AC-N%EB%B2%88-1%EB%B2%88-TODO-%EC%A3%BC%EC%84%9D%EC%9C%BC%EB%A1%9C-%EB%82%A8%EC%95%84%EC%9E%88%EB%8D%98-%EC%A3%BC%EB%AC%B8%EB%B6%84%EB%B0%B0-N1-%EC%A0%9C%EA%B1%B0%ED%95%98</link>
            <guid>https://velog.io/@do_dev/%EC%BF%BC%EB%A6%AC-N%EB%B2%88-1%EB%B2%88-TODO-%EC%A3%BC%EC%84%9D%EC%9C%BC%EB%A1%9C-%EB%82%A8%EC%95%84%EC%9E%88%EB%8D%98-%EC%A3%BC%EB%AC%B8%EB%B6%84%EB%B0%B0-N1-%EC%A0%9C%EA%B1%B0%ED%95%98</guid>
            <pubDate>Fri, 05 Jun 2026 08:38:19 GMT</pubDate>
            <description><![CDATA[<h1 id="쿼리-n번-→-1번-todo-주석으로-남아있던-주문분배-n1-제거하기">쿼리 N번 → 1번: TODO 주석으로 남아있던 주문분배 N+1 제거하기</h1>
<h2 id="배경">배경</h2>
<p>우리 시스템의 <strong>주문분배(Order Distribution)</strong> 는 주문이 들어왔을 때 어느 물류센터에서 출고할지를 결정하는 핵심 배치다.</p>
<p>분배 로직의 핵심은 간단하다.</p>
<blockquote>
<p>센터별 실제 가용재고 = 외부 시스템 재고 − 이미 분배된 주문 수량</p>
</blockquote>
<p>여기서 &quot;이미 분배된 주문 수량&quot;을 계산하는 <code>getAvailableQuantity</code> 메서드에 <strong>N+1 문제</strong>가 숨어 있었고, 주석으로도 명시되어 있었다.</p>
<pre><code class="language-java">//TODO 대량으로 내릴때 성능 개선이 꼭 필요하다.
private Long getAvailableQuantity(Long attrPrdCd, DistributionStockVO skuStockDTO, ...) {
    ...
    ditributedQty = gsOrderItemRepository.getDistributedAndUndistributedSkuQuantity(
        skuId, CenterCodeEnum.valueOf(skuStockDTO.getCenterCode())
    );
    ...
}</code></pre>
<hr>
<h2 id="문제-상황">문제 상황</h2>
<h3 id="호출-흐름">호출 흐름</h3>
<pre><code>getAvailableCenterInventory(attrPrdCd, skuIds, ...)
  └─ getSkuStockBySkuIdSet(skuIds)          // 재고 조회: 1번 (OK)
  └─ for each DistributionStockVO {          // SKU 수만큼 반복
       getAvailableQuantity(...)             // ← 여기서 DB 쿼리 1번씩
         └─ getDistributedAndUndistributedSkuQuantity(skuId, centerCode) // N번!
     }</code></pre><p>SKU가 10개면 DB 쿼리가 10번, 100개면 100번 날아간다.</p>
<h3 id="문제-쿼리">문제 쿼리</h3>
<pre><code class="language-java">// 기존: skuId 하나씩 개별 조회
public Long getDistributedAndUndistributedSkuQuantity(Long skuId, CenterCodeEnum centerCode) {
    sb.append(&quot;SELECT SUM(goics.quantity * sam.quantity) &quot;);
    sb.append(&quot;  FROM GsOrderItem goi, GsOrderItemCenterSku goics, SkuAttrMap sam &quot;);
    sb.append(&quot; WHERE sam.skuId = :skuId &quot;);      // ← 단건 조회
    sb.append(&quot;   AND goics.centerCode IN (&#39;DCXX&#39;, :centerCode) &quot;);
    // ...
}</code></pre>
<p>이 쿼리가 루프마다 실행되므로, SKU N개 → DB 쿼리 N번이 보장된다.</p>
<hr>
<h2 id="해결">해결</h2>
<h3 id="핵심-아이디어">핵심 아이디어</h3>
<blockquote>
<p><strong>루프 안에 있는 DB 조회를 루프 밖으로 꺼내고, <code>IN</code> 절로 한 번에 처리한다.</strong></p>
</blockquote>
<h3 id="1-배치-조회-메서드-추가">1) 배치 조회 메서드 추가</h3>
<p>기존 메서드는 그대로 두고, <code>Set&lt;Long&gt;</code> 을 받아 <code>Map&lt;skuId, qty&gt;</code> 를 반환하는 배치 버전을 추가했다.</p>
<pre><code class="language-java">// 인터페이스
Map&lt;Long, Long&gt; getDistributedSkuQuantityBatch(Set&lt;Long&gt; skuIds, CenterCodeEnum centerCode);
Map&lt;Long, Long&gt; getDistributedAndUndistributedSkuQuantityBatch(Set&lt;Long&gt; skuIds, CenterCodeEnum centerCode);</code></pre>
<pre><code class="language-java">// 구현 — GROUP BY로 skuId별 합계를 한 방에
public Map&lt;Long, Long&gt; getDistributedAndUndistributedSkuQuantityBatch(Set&lt;Long&gt; skuIds, CenterCodeEnum centerCode) {
    sb.append(&quot;SELECT sam.skuId, SUM(goics.quantity * sam.quantity) &quot;);
    sb.append(&quot;  FROM GsOrderItem goi, GsOrderItemCenterSku goics, SkuAttrMap sam &quot;);
    sb.append(&quot; WHERE sam.skuId IN :skuIds &quot;);    // ← IN절로 전체 조회
    sb.append(&quot;   AND goics.centerCode IN (&#39;DCXX&#39;, :centerCode) &quot;);
    sb.append(&quot; GROUP BY sam.skuId &quot;);
    // ...

    Map&lt;Long, Long&gt; result = new HashMap&lt;&gt;();
    for (Object[] row : (List&lt;Object[]&gt;) query.getResultList()) {
        if (row[1] != null) {
            result.put(Long.parseLong(row[0].toString()), Long.parseLong(row[1].toString()));
        }
    }
    return result;
}</code></pre>
<h3 id="2-서비스-리팩터링">2) 서비스 리팩터링</h3>
<p><code>getAvailableCenterInventory</code>에서 루프 <strong>전에</strong> 배치 조회를 한 번 실행하고, 루프 안에서는 Map 룩업만 한다.</p>
<pre><code class="language-java">// 기존 구조
for (DistributionStockVO skuStockDTO : skuStockBySkuIdSet) {
    // 루프마다 DB 쿼리 1번
    getAvailableQuantity(attrPrdCd, skuStockDTO, distributionTypeEnum);
}

// 개선 구조
// 1. 루프 전: 전체 skuId를 한 번에 배치 조회
Map&lt;String, Map&lt;Long, Long&gt;&gt; distributedQtyByCenterAndSku =
    batchFetchDistributedQuantities(attrPrdCd, skuStockBySkuIdSet, distributionTypeEnum);

// 2. 루프 안: Map 룩업만 (DB 호출 없음)
for (DistributionStockVO skuStockDTO : skuStockBySkuIdSet) {
    getAvailableQuantity(attrPrdCd, skuStockDTO, distributedQtyByCenterAndSku);
}</code></pre>
<p><code>batchFetchDistributedQuantities</code>는 센터 코드별로 skuId를 그룹핑해 배치 쿼리를 날린다. 분배 타입이 <code>REDISTRIBUTION</code>이냐 아니냐에 따라 다른 배치 메서드를 호출한다.</p>
<pre><code class="language-java">private Map&lt;String, Map&lt;Long, Long&gt;&gt; batchFetchDistributedQuantities(
        Long attrPrdCd, List&lt;DistributionStockVO&gt; skuStockList,
        DistributionTargetItem.DistributionTypeEnum distributionTypeEnum) {

    // 센터 코드별로 skuId 수집
    Map&lt;String, Set&lt;Long&gt;&gt; skuIdsByCenterCode = new HashMap&lt;&gt;();
    for (DistributionStockVO stock : skuStockList) {
        if (stock.getCenterCode().equals(GUNPO_CENTER_CODE)
                || stock.getCenterCode().equals(ICHEON_CENTER_CODE)) {
            skuIdsByCenterCode
                    .computeIfAbsent(stock.getCenterCode(), k -&gt; new HashSet&lt;&gt;())
                    .add(stock.getSkuId());
        }
    }

    // 센터 코드별로 배치 쿼리 1번씩
    Map&lt;String, Map&lt;Long, Long&gt;&gt; result = new HashMap&lt;&gt;();
    for (Map.Entry&lt;String, Set&lt;Long&gt;&gt; entry : skuIdsByCenterCode.entrySet()) {
        String centerCode = entry.getKey();
        CenterCodeEnum centerCodeEnum = CenterCodeEnum.valueOf(centerCode);
        Map&lt;Long, Long&gt; qtyMap;

        if (distributionTypeEnum == REDISTRIBUTION) {
            if (attrPrdCd.equals(96528474001L)) {  // 기존 긴급 로직 유지
                qtyMap = Collections.emptyMap();
            } else {
                qtyMap = gsOrderItemRepository.getDistributedSkuQuantityBatch(
                    entry.getValue(), centerCodeEnum);
            }
        } else {
            qtyMap = gsOrderItemRepository.getDistributedAndUndistributedSkuQuantityBatch(
                entry.getValue(), centerCodeEnum);
        }
        result.put(centerCode, qtyMap);
    }
    return result;
}</code></pre>
<p><code>getAvailableQuantity</code>는 DB 쿼리를 제거하고 Map 룩업으로 바뀐다.</p>
<pre><code class="language-java">// 변경 후 — DB 호출 없음
private Long getAvailableQuantity(Long attrPrdCd, DistributionStockVO skuStockDTO,
        Map&lt;String, Map&lt;Long, Long&gt;&gt; distributedQtyByCenterAndSku) {
    Long result = skuStockDTO.getAvailqty();
    Long skuId = skuStockDTO.getSkuId();
    String centerCode = skuStockDTO.getCenterCode();

    if (centerCode.equals(GUNPO_CENTER_CODE) || centerCode.equals(ICHEON_CENTER_CODE)) {
        Long distributedQty = distributedQtyByCenterAndSku
                .getOrDefault(centerCode, Collections.emptyMap())
                .get(skuId);   // ← Map 룩업 (O(1))

        if (distributedQty != null) {
            result -= distributedQty;
        }
    }
    if (centerCode.equals(ICHEON_CENTER_CODE)) {
        result -= getDC01DesignatedSkuQuantity(skuId);
    }
    return result;
}</code></pre>
<hr>
<h2 id="변경-전후-비교">변경 전후 비교</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>변경 전</th>
<th>변경 후</th>
</tr>
</thead>
<tbody><tr>
<td>분배된 수량 조회</td>
<td>SKU마다 SELECT 1번 (N번)</td>
<td>전체 SKU IN 쿼리 1번</td>
</tr>
<tr>
<td>센터 코드 수가 C개일 때</td>
<td>N × C 번</td>
<td>C 번</td>
</tr>
<tr>
<td>현재 운영 환경 (군포 단일)</td>
<td>SKU N번</td>
<td>1번</td>
</tr>
</tbody></table>
<p>현재는 군포 센터만 필터링해 사용하므로 <strong>N번 → 1번</strong>으로 줄어든다.</p>
<hr>
<h2 id="기존-특수-케이스-처리">기존 특수 케이스 처리</h2>
<p>개선 전 코드에는 특정 상품(<code>attrPrdCd = 96528474001L</code>)에 대해 REDISTRIBUTION 시 분배 수량을 0으로 처리하는 긴급 로직이 있었다.</p>
<pre><code class="language-java">if (attrPrdCd.equals(96528474001L)) {
    ditributedQty = 0L;  // 긴급 로직
}</code></pre>
<p>배치화 후에도 이 케이스는 유지된다. 해당 상품이면 배치 쿼리 자체를 건너뛰고 빈 맵을 넣어두면, <code>map.get(skuId)</code> 가 <code>null</code>을 반환하고 차감 없이 그대로 통과한다.</p>
<hr>
<h2 id="정리">정리</h2>
<p>반복문 안에서 I/O가 일어나고 있다면 <strong>항상</strong> 의심해야 한다.</p>
<ol>
<li>루프 안 DB 조회 → 루프 밖으로 꺼내 IN 쿼리 + Map 전처리</li>
<li>리턴 타입을 <code>Long</code> → <code>Map&lt;Long, Long&gt;</code> 으로 바꾸면 호출부 시그니처도 자연스럽게 정리된다</li>
<li>기존 특수 케이스(긴급 로직, null 처리)는 루프 밖에서도 동일하게 적용할 수 있다</li>
</ol>
<p>N+1은 작은 데이터일 땐 눈에 안 띄지만, 주문이 몰리는 시간대에 분배 배치가 느려지는 원인이 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GS리테일 WMS — 슈퍼마켓 TC 출고확정 시스템 개발기]]></title>
            <link>https://velog.io/@do_dev/GS%EB%A6%AC%ED%85%8C%EC%9D%BC-WMS-%EC%8A%88%ED%8D%BC%EB%A7%88%EC%BC%93-TC-%EC%B6%9C%EA%B3%A0%ED%99%95%EC%A0%95-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0</link>
            <guid>https://velog.io/@do_dev/GS%EB%A6%AC%ED%85%8C%EC%9D%BC-WMS-%EC%8A%88%ED%8D%BC%EB%A7%88%EC%BC%93-TC-%EC%B6%9C%EA%B3%A0%ED%99%95%EC%A0%95-%EC%8B%9C%EC%8A%A4%ED%85%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0</guid>
            <pubDate>Fri, 05 Jun 2026 07:00:54 GMT</pubDate>
            <description><![CDATA[<h1 id="gs리테일-wms--슈퍼마켓-tc-출고확정-시스템-개발기">GS리테일 WMS — 슈퍼마켓 TC 출고확정 시스템 개발기</h1>
<blockquote>
<p>대규모 물류센터의 출고확정 플로우를 처음부터 설계하고, 성능 병목을 찾아 개선하고, 재고 오판단 버그를 잡기까지의 기록입니다.</p>
</blockquote>
<hr>
<h2 id="배경">배경</h2>
<p>GS리테일 슈퍼마켓 물류센터에는 <strong>TC(Transfer Center, 크로스도킹 센터)</strong> 라는 운영 방식이 있습니다. DC(Distribution Center)와 달리 TC는 상품이 입고되자마자 출고 준비를 하는 구조인데, 재고가 시스템상으로 존재하는 시간이 극히 짧고 입고와 출고가 거의 동시에 일어납니다.</p>
<p>이 TC 환경에서 <strong>출고확정(Dispatch Confirm)</strong> 이란 — &quot;오늘 입고된 물량을 점포별로 배분해서 실제로 출고했다&quot; 는 사실을 시스템에 확정짓는 작업입니다. 이 한 번의 확정 작업 안에 재고 차감, ERP 인터페이스 송신, 출고결과 반영, RSHP 재고 이동, 미출 결재 생성까지 모두 일어납니다.</p>
<p>이 출고확정 시스템을 신규 개발하고, 이후 발생한 성능 문제와 재고 버그를 개선했습니다.</p>
<hr>
<h2 id="1-출고확정-플로우-설계">1. 출고확정 플로우 설계</h2>
<h3 id="전체-흐름">전체 흐름</h3>
<p>출고확정 API 하나를 호출하면 내부에서 아래 단계가 순서대로 실행됩니다.</p>
<pre><code>① 사전 검증 (9단계 Error/Warning)
② 미출 결재 문서 자동 생성
③ ShipmentItem 상태 → COMPLETED
④ TC 재고 → 출고 스테이지 Outbound (LPN 기반 FIFO 배분)
⑤ 출고결과 반영 + 출고마감 (병렬)
⑥ TC 잔여재고 → RSHP 로케이션 이동</code></pre><p>단계별로 실패하면 이전 단계를 보상(Compensate)하는 구조로 설계했습니다.</p>
<h3 id="사전-검증--error-vs-warning-분리">사전 검증 — Error vs Warning 분리</h3>
<p>확정 버튼을 누르기 전에 사용자에게 미리 알려주는 검증 단계가 있습니다. 여기서 중요한 설계 결정 하나는 <strong>Error와 Warning을 분리</strong>한 것입니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>항목</th>
<th>동작</th>
</tr>
</thead>
<tbody><tr>
<td>ERROR</td>
<td>출고수량 &gt; 원주문수량</td>
<td>확정 차단</td>
</tr>
<tr>
<td>ERROR</td>
<td>미출사유 누락</td>
<td>확정 차단</td>
</tr>
<tr>
<td>ERROR</td>
<td>TC 재고 부족</td>
<td>확정 차단</td>
</tr>
<tr>
<td>ERROR</td>
<td>RSHP 잔여재고 존재</td>
<td>확정 차단</td>
</tr>
<tr>
<td>WARNING</td>
<td>입출고 수량 차이</td>
<td>확인 후 진행 가능</td>
</tr>
<tr>
<td>WARNING</td>
<td>출고단위 불일치</td>
<td>확인 후 진행 가능</td>
</tr>
<tr>
<td>WARNING</td>
<td>수주수신 오류 이력</td>
<td>확인 후 진행 가능</td>
</tr>
<tr>
<td>WARNING</td>
<td>RSHP 이동 예정 물량 안내</td>
<td>정보성</td>
</tr>
</tbody></table>
<p>Error는 절대적인 차단 조건이고, Warning은 사용자가 인지하고 진행할 수 있는 조건입니다. 처음에는 전부 Error로 만들었다가, 현장 운영 피드백을 받고 이 구조로 개편했습니다.</p>
<p>이 검증 단계에서 조회해야 하는 데이터가 많기 때문에 <code>CompletableFuture</code>로 병렬 조회를 적용했습니다.</p>
<pre><code class="language-java">// 7개 조회를 동시에 실행
CompletableFuture&lt;Map&lt;InventoryCompareKey, Long&gt;&gt; tcInventoryFuture =
    CompletableFuture.supplyAsync(() -&gt;
        toInventoryMap(inventoryFacade.findInventoryGroupByLpnId(centerCode, TC_STAGE)), executor);

CompletableFuture&lt;Map&lt;InventoryCompareKey, Long&gt;&gt; rshpInventoryFuture =
    CompletableFuture.supplyAsync(() -&gt;
        toInventoryMap(inventoryFacade.findInventoryGroupByLpnId(centerCode, RSHP_STAGE)), executor);

CompletableFuture&lt;Map&lt;String, SkuCenterVO&gt;&gt; skuCenterFuture = ...;
CompletableFuture&lt;Map&lt;String, VendorSupplierVO&gt;&gt; vendorSupplierFuture = ...;
// ... 3개 더

CompletableFuture.allOf(tcInventoryFuture, rshpInventoryFuture, ...).join();</code></pre>
<h3 id="lpn-기반-fifo-재고-배분">LPN 기반 FIFO 재고 배분</h3>
<p>TC 재고는 LPN(License Plate Number) 단위로 관리됩니다. 출고 수량을 LPN에 배분할 때 <strong>선입선출 원칙</strong>을 지켜야 하는데, 이를 위해 아래 우선순위로 정렬했습니다.</p>
<pre><code class="language-java">// 유통기한 빠른 것 → 입고일 빠른 것 → 수량 배분
Comparator&lt;InventoryGroupByLpnIdVO&gt; inventoryPriority = Comparator
    .comparing(v -&gt; v.getExpiredDate() == null ? 1 : 0)
    .thenComparing(v -&gt; v.getExpiredDate() != null ? v.getExpiredDate() : LocalDate.MAX)
    .thenComparing(v -&gt; v.getReceivedDate() != null ? v.getReceivedDate() : LocalDate.MAX);</code></pre>
<p>배분은 LPN별 잔여수량 Map을 유지하면서 요청 수량이 소진될 때까지 순차 차감하는 방식입니다. 복수의 LPN에 걸쳐 한 품목이 나뉘어 있는 경우도 자연스럽게 처리됩니다.</p>
<h3 id="부분-실패-보상-트랜잭션">부분 실패 보상 트랜잭션</h3>
<p>재고 outbound는 SKU 단위로 병렬로 처리되는데, 일부 SKU가 실패해도 나머지 성공한 SKU는 정상 처리되어야 합니다. 이를 위해 실패한 SKU에 한해서만 ShipmentItem 상태를 이전으로 되돌리는 보상 로직을 구현했습니다.</p>
<pre><code class="language-java">// 실패한 SKU 목록
Set&lt;String&gt; failedSkuCodes = failures.stream()
    .map(x -&gt; x.groupKey().skuCode())
    .collect(Collectors.toSet());

// 실패 SKU의 ShipmentItem만 롤백
List&lt;SmRoomTcDispatchCompleteItemQRO&gt; failedSkuItems = confirmableItems.stream()
    .filter(x -&gt; failedSkuCodes.contains(x.getSkuCode()))
    .toList();
compensateShipmentItems(failedSkuItems);

// 성공 SKU는 계속 진행
List&lt;SmRoomTcDispatchCompleteItemQRO&gt; successItems = confirmableItems.stream()
    .filter(x -&gt; !failedSkuCodes.contains(x.getSkuCode()))
    .toList();</code></pre>
<h3 id="미출-결재-자동-생성">미출 결재 자동 생성</h3>
<p>출고수량 &lt; 원주문수량인 경우(미출), 사유 코드와 함께 Approval 시스템에 결재 문서를 자동 생성합니다. 운영자가 별도로 결재 요청을 올리지 않아도 출고확정 시점에 자동으로 연동됩니다.</p>
<h3 id="tc-잔여재고-→-rshp-자동-이동">TC 잔여재고 → RSHP 자동 이동</h3>
<p>TC 출고확정 후에는 &quot;입고수량 - 출고수량&quot; 에 해당하는 물량이 TC 로케이션에 남습니다. 이 잔여분은 RSHP(Receiving Shipment Hold Point) 로케이션으로 옮겨야 다음 날 작업에 영향을 주지 않습니다.</p>
<pre><code>입고수량(100) - 출고수량(80) = 20 → TC → RSHP 자동 이동</code></pre><p>이 계산과 이동도 출고확정 흐름의 마지막 단계로 자동 처리됩니다.</p>
<hr>
<h2 id="2-인터페이스-송신-병목-개선--80-처리시간-단축">2. 인터페이스 송신 병목 개선 — 80% 처리시간 단축</h2>
<h3 id="문제-발견">문제 발견</h3>
<p>출고확정이 완료된 이후 ERP(Oracle 레거시 시스템)로 출고 데이터를 인터페이스 송신하는 부분이 있습니다. 초기 구현은 아래와 같이 단순한 반복문이었습니다.</p>
<pre><code class="language-java">// AS-IS: N건 순차 처리
order = orderRepository.save(order);  // JPA dirty checking → N번 UPDATE

for (TeSmTcshpSndCO co : teSmTcshpSndCOS) {
    interfaceSendManager.sendAfterValid(targetTable, co, userCode);  // 1건씩 INSERT
}</code></pre>
<p>Wave 1회에 처리하는 주문아이템이 수백 건 규모이다 보니, 건수가 늘어날수록 처리시간이 선형으로 증가했습니다.</p>
<h3 id="개선-1--bulk-update-전환">개선 1 — Bulk UPDATE 전환</h3>
<p>JPA dirty checking으로 개별 UPDATE하던 방식을 PostgreSQL의 <code>UPDATE ... FROM (VALUES ...)</code> 구문으로 변경했습니다.</p>
<pre><code class="language-sql">-- 수백 건을 단 1번의 SQL로
UPDATE goods_dispatch_retailer_order_item
SET shipped_quantity = dt.shipped_quantity,
    insufficient_type = dt.insufficient_type,
    modified_at = dt.modified_at
FROM (
  VALUES
    (101, 80, NULL, &#39;2025-06-05 09:00:00&#39;::TIMESTAMP),
    (102, 50, &#39;STOCK_SHORTAGE&#39;, &#39;2025-06-05 09:00:00&#39;::TIMESTAMP),
    (103, 30, NULL, &#39;2025-06-05 09:00:00&#39;::TIMESTAMP)
    -- ... N건
) AS dt(id, shipped_quantity, insufficient_type, modified_at)
WHERE goods_dispatch_retailer_order_item.id = dt.id</code></pre>
<p><code>BulkUpdateManager</code>는 엔티티 클래스의 <code>@Table</code>, <code>@Column</code> 어노테이션을 리플렉션으로 읽어 SQL을 동적으로 생성합니다. 테이블명/컬럼명을 하드코딩하지 않아도 됩니다.</p>
<pre><code class="language-java">// 사용 방법
bulkUpdateManager.bulkUpdate(
    GoodsDispatchRetailerOrderItem.class,  // 엔티티 클래스 → 테이블명 자동 추론
    List.of(&quot;id&quot;),                          // WHERE 조건 필드
    updateRows                              // 변경할 값들
);</code></pre>
<h3 id="개선-2--인터페이스-송신-병렬화">개선 2 — 인터페이스 송신 병렬화</h3>
<p>인터페이스 INSERT도 4개 스레드로 병렬 처리하도록 변경했습니다.</p>
<pre><code class="language-java">// TO-BE: 4 thread 병렬 전송
final ExecutorService executor = Executors.newFixedThreadPool(4);

List&lt;CompletableFuture&lt;Void&gt;&gt; futures = teSmTcshpSndCOS.stream()
    .map(co -&gt; CompletableFuture.runAsync(
        () -&gt; interfaceSendManager.sendAfterValid(targetTable, co, userCode),
        executor
    ))
    .toList();

CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
    .get(300, TimeUnit.SECONDS);</code></pre>
<h3 id="처리시간-개선-추정">처리시간 개선 추정</h3>
<p>Aurora PostgreSQL 환경 기준, DB INSERT/UPDATE RTT를 <del>5</del>10ms로 가정했을 때:</p>
<table>
<thead>
<tr>
<th>처리 항목</th>
<th>AS-IS</th>
<th>TO-BE</th>
<th>개선</th>
</tr>
</thead>
<tbody><tr>
<td>OrderItem UPDATE (300건)</td>
<td>300 × 5ms = <strong>1.5초</strong></td>
<td>1번 SQL = <strong>~50ms</strong></td>
<td><strong>96% 감소</strong></td>
</tr>
<tr>
<td>인터페이스 INSERT (300건)</td>
<td>300 × 10ms = <strong>3초</strong></td>
<td>300건 ÷ 4thread × 10ms = <strong>~750ms</strong></td>
<td><strong>75% 감소</strong></td>
</tr>
<tr>
<td><strong>합계</strong></td>
<td><strong>~4.5초</strong></td>
<td><strong>~800ms</strong></td>
<td><strong>약 5.6배 단축</strong></td>
</tr>
</tbody></table>
<p>건수가 많아질수록 효과는 더 커집니다. Wave 규모가 500건이면 약 6배 단축입니다.</p>
<hr>
<h2 id="3-협력사-기준-누락으로-인한-재고-오판단-버그">3. 협력사 기준 누락으로 인한 재고 오판단 버그</h2>
<p>이 버그를 찾기까지 꽤 시간이 걸렸습니다. 현상은 분명한데 원인을 특정하기 어려운 케이스였습니다.</p>
<h3 id="증상">증상</h3>
<p>TC 출고확정 후 가끔 아래 현상이 발생했습니다.</p>
<ul>
<li>TC 로케이션에 남아 있어야 할 재고가 없어짐</li>
<li>RSHP로 이동되어야 할 물량이 처리되지 않음</li>
<li>동일 상품인데 협력사가 다른 경우에 집중적으로 발생</li>
</ul>
<h3 id="원인-분석">원인 분석</h3>
<p>재고를 집계할 때 key를 <code>skuCode</code>만으로 사용하고 있었습니다.</p>
<pre><code class="language-java">// AS-IS (버그): 상품코드만으로 재고 매핑
Map&lt;String, Long&gt; inventoryBySkuCode = tcInventoryList.stream()
    .collect(Collectors.groupingBy(
        InventoryGroupByLpnIdVO::getSkuCode,
        Collectors.summingLong(InventoryGroupByLpnIdVO::getTouchableQuantity)
    ));</code></pre>
<p>TC에는 동일한 상품이 <strong>다른 협력사</strong>로 입고될 수 있습니다. 예를 들어 SKU001이 A협력사와 B협력사에서 각각 입고되는 경우, 두 재고는 완전히 다른 것으로 취급해야 합니다. 그런데 상품코드만으로 집계하면 두 재고가 합쳐지면서 배분 로직이 엉키게 됩니다.</p>
<pre><code>상황:
  TC 재고 A협력사/SKU001 = 100개
  TC 재고 B협력사/SKU001 = 50개

출고 요청:
  B협력사/SKU001 = 60개

AS-IS 동작:
  SKU001 총재고 = 150개 → 출고 가능 판정 ✓
  BUT 배분 시 B협력사 재고 50개 소진 후 A협력사 재고 10개 추가 차감 ← 문제!
  → A협력사 재고 10개 영문도 모르게 소멸
  → B협력사 RSHP 이동 계산도 틀어짐</code></pre><h3 id="수정">수정</h3>
<p>재고 비교 단위에 협력사 코드를 포함하는 복합키를 도입했습니다.</p>
<pre><code class="language-java">// TO-BE: (센터, 협력사, 상품) 복합키
private record InventoryCompareKey(
    String centerCode,
    String vendorSupplierCode,  // ← 핵심 추가
    String skuCode
) {}

// 재고 집계 시 협력사 기준 엄격 적용
Map&lt;InventoryCompareKey, List&lt;InventoryGroupByLpnIdVO&gt;&gt; inventoryByKey =
    tcInventoryList.stream()
        .sorted(inventoryPriority)  // FIFO 정렬
        .collect(Collectors.groupingBy(x -&gt;
            new InventoryCompareKey(
                x.getCenterCode(),
                x.getVendorSupplierCode(),  // 협력사 분리
                x.getSkuCode()
            )
        ));</code></pre>
<p>출고 배분, TC→RSHP 이동 계산, 재고 부족 validation까지 모두 이 복합키 기준으로 동작하도록 일관되게 변경했습니다.</p>
<pre><code>수정 후 동작:
  A협력사/SKU001 재고 = 100개 (별도 관리)
  B협력사/SKU001 재고 = 50개 (별도 관리)

출고 요청 B협력사/SKU001 = 60개:
  → B협력사 재고 50개로 부족 판정 → 명확한 오류 메시지 출력
  → A협력사 재고는 영향 없음 ✓</code></pre><h3 id="교훈">교훈</h3>
<p>재고 시스템에서 &quot;같은 상품&quot;이라는 개념은 생각보다 훨씬 좁습니다. 물류 도메인에서는 동일 SKU라도 협력사, 입고일, 유통기한에 따라 완전히 다른 재고로 취급해야 하는 경우가 많습니다. 집계 단위를 설정할 때 도메인 전문가와 함께 &quot;무엇이 같고 무엇이 다른가&quot;를 명확히 정의하는 게 중요하다는 걸 이 버그를 통해 배웠습니다.</p>
<hr>
<h2 id="마무리">마무리</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>내용</th>
</tr>
</thead>
<tbody><tr>
<td>프로젝트</td>
<td>GS리테일 슈퍼마켓 WMS</td>
</tr>
<tr>
<td>역할</td>
<td>TC 출고확정 시스템 신규 개발</td>
</tr>
<tr>
<td>기간</td>
<td>2024년 하반기 ~ 2025년 상반기</td>
</tr>
<tr>
<td>기술 스택</td>
<td>Java 17, Spring Boot 3, Spring Data JPA, Aurora PostgreSQL, CompletableFuture</td>
</tr>
<tr>
<td>주요 성과</td>
<td>- 9단계 사전검증 + 보상 트랜잭션 출고확정 플로우 설계 <br>- Bulk UPDATE + 병렬 인터페이스 송신으로 <strong>처리시간 ~82% 단축</strong> <br>- 협력사 복합키 도입으로 <strong>재고 오판단 버그 해결</strong></td>
</tr>
</tbody></table>
<p>실제 물류 현장에서 수십만 건의 상품이 오가는 시스템을 처음부터 설계하고 운영하면서, 성능 숫자 뒤에 있는 도메인 이해가 얼마나 중요한지 크게 느낀 프로젝트였습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블슈팅] 스프링부트 배치 개선작업 (N+1, 병렬처리)]]></title>
            <link>https://velog.io/@do_dev/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EB%B0%B0%EC%B9%98-%EA%B0%9C%EC%84%A0%EC%9E%91%EC%97%85-N1-%EB%B3%91%EB%A0%AC%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@do_dev/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EB%B0%B0%EC%B9%98-%EA%B0%9C%EC%84%A0%EC%9E%91%EC%97%85-N1-%EB%B3%91%EB%A0%AC%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Thu, 28 May 2026 23:48:56 GMT</pubDate>
            <description><![CDATA[<h1 id="일수불-배치-성능-개선기--47분을-17분으로-줄인-n1-제거--병렬-처리">일수불 배치 성능 개선기 — 47분을 17분으로 줄인 N+1 제거 &amp; 병렬 처리</h1>
<h3 id="개요">개요</h3>
<p>회사에서 나는 매일 재고 입출고 및 기타상황에 따라 발생되는 입출고 데이터와 정산데이터를 통해 생성 비교되는 배치인 일수불데이터 배치와 매달 첫 영업일 마감작업을 위한 정합성 배치를 사용하며 운영업무를 진행해왔다. 
마감작업은 매달 첫 영업일 오후 특정시간대까지 마감데이터를 생성하여 타부서에 넘기고 그걸 토대로 협력사에게 대금이 지급되는 예민한 업무인데 마감데이터를 검증하기위한 검증 배치가 너무 오래걸리고 가끔은 일수불들을 다시 생성해야하는 최악의 경우도 발생되어 곤욕을 치루기도 했다.. 그래서 적어도 당일에 배치를 통한 시간 병목은 없애자라는 생각으로 우선 배치를 개선해보기로했고 아래 두 배치를 </p>
<p>일수불(일별 수입불출) 집계 배치와 정합성 검증 배치에서
두 가지 성능 문제를 발견하고 개선했다.</p>
<hr>
<h2 id="개요-1">개요</h2>
<p>회사에서 나는 매일 재고 입출고 및 기타 상황에 따라 발생되는 입출고 데이터와 정산 데이터를 통해 생성·비교되는 배치인 <strong>일수불(일별 수입불출) 데이터 배치</strong>와, 매달 첫 영업일 마감 작업을 위한 <strong>정합성 검증 배치</strong>를 운영해왔다.</p>
<p>마감 작업은 매달 첫 영업일 오후 특정 시간대까지 마감 데이터를 생성해 타부서에 넘기고, 그걸 토대로 협력사에 대금이 지급되는 예민한 업무다. 그런데 마감 데이터를 검증하기 위한 검증 배치가 너무 오래 걸리고, 가끔은 일수불을 다시 생성해야 하는 최악의 상황도 발생해 곤욕을 치르기도 했다.</p>
<p>적어도 <em>배치 자체로 인한 시간 병목은 없애자</em>는 생각으로 두 배치를 개선해보기로 했다.</p>
<ul>
<li>처리 대상: 약 <strong>20만 3천 건</strong></li>
<li>기존 소요 시간: <strong>47분</strong></li>
<li>개선 후 소요 시간: <strong>17분 (64% 단축)</strong></li>
</ul>
<hr>
<h2 id="문제-1-일수불-생성--n1-쿼리">문제 1. 일수불 생성 — N+1 쿼리</h2>
<h3 id="문제-상황">문제 상황</h3>
<p>집계된 VO N건을 저장할 때, <strong>건마다 SKU 정보를 개별로 DB 조회</strong>하고 있었다.</p>
<pre><code class="language-java">// insert() 내부 — VO 1건 처리할 때마다 실행
Optional&lt;Sku&gt; skuOpt = skuMRepository.findById(statsDailyCollectPayVO.getAttrPrdCd());</code></pre>
<p>루프에서 스레드풀(4개)에 태스크를 제출하는 구조였기 때문에,
결과적으로 <code>SELECT * FROM sku WHERE id = ?</code> 쿼리가 <strong>N번</strong> 날아갔다.</p>
<pre><code class="language-java">for (StatsDailyCollectPayVO vo : resultList) {
    executor.submit(new StatsDailyCollectPayRunnable(service, vo));
    // ↑ 각 태스크 내부에서 DB 조회 1번씩
}</code></pre>
<p>여기서 놓치기 쉬운 함정이 있다.<br>스레드를 4개로 병렬 처리해도 <strong>총 쿼리 수는 줄어들지 않는다.</strong><br>스레드는 실행을 병렬화할 뿐, 쿼리 횟수 자체를 줄이지 않기 때문이다.</p>
<table>
<thead>
<tr>
<th>데이터 건수</th>
<th>쿼리 수</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td>1,000건</td>
<td>1,000번</td>
<td>스레드 4개여도 쿼리 수는 동일</td>
</tr>
<tr>
<td>10,000건</td>
<td>10,000번</td>
<td></td>
</tr>
<tr>
<td>203,000건</td>
<td>203,000번</td>
<td>데이터 증가에 비례해서 늘어남</td>
</tr>
</tbody></table>
<p>데이터가 늘수록 배치 시간이 <strong>선형으로 증가</strong>하는 구조였다.</p>
<p>두 번째 문제도 숨어 있었다.<br>MD 영업그룹 코드를 캐싱하는 <code>mdSaleGrpMap</code>이 <strong>각 Runnable마다 새 Map으로 초기화</strong>되고 있었다.</p>
<pre><code class="language-java">// StatsDailyCollectPayRunnable 생성자
public StatsDailyCollectPayRunnable(StatsDailyCollectPayService service, StatsDailyCollectPayVO target) {
    this.service = service;
    this.target = target;
    this.mdSaleGrpMap = new ConcurrentHashMap&lt;&gt;(); // 스레드마다 빈 Map으로 시작
}</code></pre>
<p>같은 MD ID라도 다른 스레드가 이미 조회했다는 걸 모르고 외부 API를 또 호출했다.<br>캐시가 스레드 간에 공유되지 않으니 사실상 캐시 효과가 없었던 것이다.</p>
<h3 id="해결">해결</h3>
<p><strong>1) SKU 조회 — 루프 전에 IN 쿼리로 한 번에 벌크 조회</strong></p>
<pre><code class="language-java">// 루프 전: 전체 attrPrdCd를 Set으로 모은 뒤 IN 쿼리 1번
Set&lt;Long&gt; attrPrdCdSet = resultList.stream()
        .map(StatsDailyCollectPayVO::getAttrPrdCd)
        .filter(Objects::nonNull)
        .collect(Collectors.toSet());

Map&lt;Long, Sku&gt; skuMap = skuRepository.findByIdIn(attrPrdCdSet)
        .stream()
        .collect(Collectors.toMap(Sku::getId, s -&gt; s));

// 루프: DB 조회 없이 Map.get()으로 즉시 반환
for (StatsDailyCollectPayVO vo : resultList) {
    executor.submit(new StatsDailyCollectPayOptimizedRunnable(service, vo, mdSaleGrpMap, skuMap));
}</code></pre>
<pre><code class="language-java">// 변경 전
Optional&lt;Sku&gt; skuOpt = skuMRepository.findById(attrPrdCd); // DB 호출

// 변경 후
Sku sku = skuMap.get(attrPrdCd); // Map 조회 (DB 호출 없음)</code></pre>
<p><strong>쿼리 N번 → 1번</strong>으로 줄었고, 처리 시간이 데이터 증가에 관계없이 거의 일정해졌다.</p>
<p><strong>2) mdSaleGrpMap — 스레드 간 공유 + computeIfAbsent로 최초 1회만 호출</strong></p>
<pre><code class="language-java">// Processor에서 하나의 Map을 생성해 모든 Runnable에 전달
Map&lt;String, String&gt; mdSaleGrpMap = new ConcurrentHashMap&lt;&gt;();

for (StatsDailyCollectPayVO vo : resultList) {
    executor.submit(new StatsDailyCollectPayOptimizedRunnable(service, vo, mdSaleGrpMap, skuMap));
}</code></pre>
<pre><code class="language-java">// 변경 후: 같은 key는 최초 1회만 외부 API 호출
String saleGrpCd = mdSaleGrpMap.computeIfAbsent(mdId, k -&gt; {
    CmmMdidMDTO cmm = externalBegoniaCmmMdidMFacade.getByMdid(k);
    return (cmm != null) ? cmm.getSaleGrpCd() : null;
});</code></pre>
<table>
<thead>
<tr>
<th>항목</th>
<th>기존</th>
<th>개선 후</th>
</tr>
</thead>
<tbody><tr>
<td>SKU DB 쿼리</td>
<td>N번 (건마다)</td>
<td>1번 (IN 쿼리)</td>
</tr>
<tr>
<td>외부 API 캐시</td>
<td>스레드별 독립 Map</td>
<td>전체 공유 Map</td>
</tr>
</tbody></table>
<hr>
<h2 id="문제-2-정합성-검증--순차-실행--개별-저장">문제 2. 정합성 검증 — 순차 실행 &amp; 개별 저장</h2>
<h3 id="문제-상황-1">문제 상황</h3>
<p>정합성 검증은 외부 시스템(SMTC) 조회와 DB 조회를 <strong>순차 실행</strong>하고 있었다.</p>
<pre><code class="language-java">// 두 호출 사이에 의존성이 전혀 없는데 순서대로 실행
List&lt;RcvpayCheckDTO&gt; smtcList =
        externalBegoniaPrhFacade.getPrhDtRcvpayCheckList(targetDateStr); // 외부 API 호출
List&lt;StatsDailyCollectPay&gt; auroraList =
        statsDailyCollectPayRepository.findAllByPrdMoveDt(targetDate);   // DB 조회</code></pre>
<p>두 호출의 결과가 서로에게 필요하지 않음에도 직렬로 실행되어 응답 시간이 단순 합산되었다.</p>
<p>또한 검증 결과 로그를 DB에 저장할 때 <code>save()</code>를 3번 개별 호출했다.</p>
<pre><code class="language-java">systemLogRepository.save(makeSystemLog(&quot;smtcOnly&quot;, ...));   // 트랜잭션 1번
systemLogRepository.save(makeSystemLog(&quot;auroraOnly&quot;, ...)); // 트랜잭션 2번
systemLogRepository.save(makeSystemLog(&quot;notEqual&quot;, ...));   // 트랜잭션 3번</code></pre>
<h3 id="해결-1">해결</h3>
<p><strong>1) 두 조회를 CompletableFuture로 병렬 실행</strong></p>
<pre><code class="language-java">CompletableFuture&lt;List&lt;RcvpayCheckDTO&gt;&gt; smtcFuture = CompletableFuture.supplyAsync(
        () -&gt; externalBegoniaPrhFacade.getPrhDtRcvpayCheckList(targetDateStr));
CompletableFuture&lt;List&lt;StatsDailyCollectPay&gt;&gt; auroraFuture = CompletableFuture.supplyAsync(
        () -&gt; statsDailyCollectPayRepository.findAllByPrdMoveDt(targetDate));

// 둘 다 완료될 때까지 대기
List&lt;RcvpayCheckDTO&gt; smtcList = smtcFuture.join();
List&lt;StatsDailyCollectPay&gt; auroraList = auroraFuture.join();</code></pre>
<pre><code>Before: 외부 API(500ms) → DB 조회(300ms) = 800ms
After:  외부 API(500ms) ┐ 동시 실행
        DB 조회(300ms)  ┘ = 500ms (더 느린 쪽이 기준)</code></pre><p><strong>2) 개별 save() → saveAll() 1번</strong></p>
<pre><code class="language-java">systemLogRepository.saveAll(Arrays.asList(
        makeSystemLog(&quot;smtcOnlyCollectPayCheckService&quot;, smtcOnly.toString(), targetDateStr),
        makeSystemLog(&quot;auroraOnlyCollectPayCheckService&quot;, auroraOnly.toString(), targetDateStr),
        makeSystemLog(&quot;notEqualCollectPayCheckService&quot;, notEqual.toString(), targetDateStr)
));</code></pre>
<p>트랜잭션 3번 → 1번, DB 왕복 3번 → 1번으로 줄었다.</p>
<hr>
<h2 id="결과">결과</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>기존</th>
<th>개선 후</th>
</tr>
</thead>
<tbody><tr>
<td>처리 건수</td>
<td>203,000건</td>
<td>203,000건</td>
</tr>
<tr>
<td>소요 시간</td>
<td><strong>47분</strong></td>
<td><strong>17분</strong></td>
</tr>
<tr>
<td>단축 시간</td>
<td>—</td>
<td><strong>30분 (64% 감소)</strong></td>
</tr>
</tbody></table>
<hr>
<h2 id="회고--이런-문제를-빠르게-발견하려면">회고 — 이런 문제를 빠르게 발견하려면</h2>
<p>이번 개선에서 얻은 핵심 패턴은 하나다.</p>
<blockquote>
<p><strong>반복문 안에서 I/O가 일어나고 있는가?</strong></p>
</blockquote>
<ul>
<li>반복문 안 DB 조회 → N+1 의심, 루프 밖으로 꺼내서 벌크 처리</li>
<li>반복문 안 외부 API 호출 → 캐싱 또는 배치 처리 검토</li>
<li>멀티스레드인데 각 스레드가 독립적으로 같은 데이터를 조회 → 공유 캐시 도입</li>
</ul>
<p>20만 건이면 루프 안의 1ms짜리 작업 하나가 200초(3분 20초)다.<br><code>findById()</code> 하나가 아무리 빠르더라도 N번 불리면 누적된다.</p>
<p>반복문을 볼 때 <strong>&quot;이 안에 있는 코드 중 루프 밖으로 꺼낼 수 있는 게 있나?&quot;</strong> 를<br>먼저 확인하는 습관이 배치 성능 개선의 출발점이다.</p>
<hr>
<h2 id="정리">정리</h2>
<table>
<thead>
<tr>
<th>개선 항목</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>SKU 조회 (N건)</td>
<td>SELECT N번</td>
<td>SELECT IN 1번</td>
</tr>
<tr>
<td>MD 영업그룹 캐시</td>
<td>스레드별 독립 Map</td>
<td>전체 공유 Map</td>
</tr>
<tr>
<td>정합성 조회</td>
<td>순차 실행</td>
<td>CompletableFuture 병렬 실행</td>
</tr>
<tr>
<td>로그 저장</td>
<td>save() × 3</td>
<td>saveAll() × 1</td>
</tr>
<tr>
<td><strong>총 소요 시간</strong></td>
<td><strong>47분</strong></td>
<td><strong>17분</strong></td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[VSCode에서 Claude API 연결하는 법 (Continue.dev) — Claude 에이전트 팀 시리즈 1편]]></title>
            <link>https://velog.io/@do_dev/VSCode%EC%97%90%EC%84%9C-Claude-API-%EC%97%B0%EA%B2%B0%ED%95%98%EB%8A%94-%EB%B2%95-Continue.dev-Claude-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-%ED%8C%80-%EC%8B%9C%EB%A6%AC%EC%A6%88-1%ED%8E%B8</link>
            <guid>https://velog.io/@do_dev/VSCode%EC%97%90%EC%84%9C-Claude-API-%EC%97%B0%EA%B2%B0%ED%95%98%EB%8A%94-%EB%B2%95-Continue.dev-Claude-%EC%97%90%EC%9D%B4%EC%A0%84%ED%8A%B8-%ED%8C%80-%EC%8B%9C%EB%A6%AC%EC%A6%88-1%ED%8E%B8</guid>
            <pubDate>Fri, 22 May 2026 06:22:37 GMT</pubDate>
            <description><![CDATA[<hr>
<h2 id="date-2026-05-22">Date: 2026-05-22</h2>
<blockquote>
<p>이 시리즈는 Claude 에이전트 팀을 구성해서 React + Spring Boot 포트폴리오 웹사이트를 만드는 과정을 기록한다.
1편에서는 VSCode 안에서 Claude를 채팅으로 쓸 수 있도록 환경을 세팅한다.</p>
</blockquote>
<h2 id="왜-continuedev인가">왜 Continue.dev인가?</h2>
<p>GitHub Copilot Chat 대신 <strong>Continue.dev</strong>를 선택한 이유는 간단하다.</p>
<ul>
<li>원하는 모델(Claude)을 직접 연결할 수 있다</li>
<li>VSCode 채팅창에서 코드 파일을 컨텍스트로 넘기기 편하다</li>
<li>에이전트별 Custom Commands 등록이 가능하다</li>
<li>오픈소스라 설정을 내 마음대로 제어할 수 있다</li>
</ul>
<h2 id="전체-세팅-순서">전체 세팅 순서</h2>
<ol>
<li>Continue.dev 설치</li>
<li>Anthropic API 키 발급</li>
<li>Claude 모델 연결</li>
<li>동작 확인</li>
</ol>
<hr>
<h2 id="1단계--continuedev-설치">1단계 — Continue.dev 설치</h2>
<p>VSCode Extensions 탭에서 <code>Continue</code> 검색 후 설치한다.</p>
<p>또는 터미널에서:</p>
<pre><code class="language-bash">code --install-extension Continue.continue</code></pre>
<p>설치하면 VSCode 좌측 사이드바에 Continue 아이콘이 생긴다.</p>
<hr>
<h2 id="2단계--anthropic-api-키-발급">2단계 — Anthropic API 키 발급</h2>
<p><a href="https://console.anthropic.com">console.anthropic.com</a> 접속 → <strong>API Keys</strong> 탭 → <strong>Create Key</strong></p>
<blockquote>
<p>⚠️ 키는 생성 시 한 번만 보여준다. 반드시 복사해서 안전한 곳에 저장할 것.</p>
</blockquote>
<p>무료 크레딧이 있으니 처음엔 결제 없이 테스트 가능하다.</p>
<hr>
<h2 id="3단계--claude-모델-연결">3단계 — Claude 모델 연결</h2>
<p>Continue 설정 파일을 열어 Claude를 연결한다.</p>
<p><strong>방법 1: UI에서 설정</strong></p>
<p>Continue 사이드바 하단 설정 아이콘 → <code>Add Model</code> → <code>Anthropic</code> 선택 → API 키 입력</p>
<p><strong>방법 2: config.json 직접 수정 (추천)</strong></p>
<p><code>~/.continue/config.json</code> 파일을 열어 아래 내용을 추가한다.</p>
<pre><code class="language-json">{
  &quot;models&quot;: [
    {
      &quot;title&quot;: &quot;Claude Sonnet 4.6&quot;,
      &quot;provider&quot;: &quot;anthropic&quot;,
      &quot;model&quot;: &quot;claude-sonnet-4-6&quot;,
      &quot;apiKey&quot;: &quot;sk-ant-여기에_API_키_입력&quot;
    }
  ]
}</code></pre>
<blockquote>
<p>⚠️ API 키를 절대 Git에 커밋하지 말 것. <code>.gitignore</code>에 <code>config.json</code>을 추가하거나 환경변수로 관리한다.</p>
</blockquote>
<pre><code class="language-bash"># .gitignore
.continue/config.json</code></pre>
<hr>
<h2 id="4단계--동작-확인">4단계 — 동작 확인</h2>
<p>VSCode에서 <code>Cmd+L</code> (Mac) 또는 <code>Ctrl+L</code> (Windows) 로 Continue 채팅창을 연다.</p>
<p>아래처럼 입력해보자.</p>
<pre><code>안녕! 지금 어떤 모델이야?</code></pre><p>Claude가 응답하면 성공이다.</p>
<hr>
<h2 id="에이전트-팀을-위한-custom-commands-등록-보너스">에이전트 팀을 위한 Custom Commands 등록 (보너스)</h2>
<p>이 시리즈의 핵심인 <strong>에이전트 역할 전환</strong>을 편하게 하려면 Custom Commands를 등록해두면 좋다.</p>
<p><code>~/.continue/config.json</code>에 아래 내용을 추가한다.</p>
<pre><code class="language-json">{
  &quot;models&quot;: [...],
  &quot;customCommands&quot;: [
    {
      &quot;name&quot;: &quot;fe&quot;,
      &quot;description&quot;: &quot;Frontend Agent 모드로 전환&quot;,
      &quot;prompt&quot;: &quot;You are the Frontend Agent. Stack: React 18, TypeScript, Tailwind CSS. Only write frontend code. Always follow the conventions in CLAUDE.md.&quot;
    },
    {
      &quot;name&quot;: &quot;be&quot;,
      &quot;description&quot;: &quot;Backend Agent 모드로 전환&quot;,
      &quot;prompt&quot;: &quot;You are the Backend Agent. Stack: Spring Boot 3, Java 21, PostgreSQL. Only write backend code. Always follow the conventions in CLAUDE.md.&quot;
    },
    {
      &quot;name&quot;: &quot;qa&quot;,
      &quot;description&quot;: &quot;QA Agent 모드로 전환&quot;,
      &quot;prompt&quot;: &quot;You are the QA Agent. Review the code for bugs, security issues, and convention violations. Output a structured review report.&quot;
    }
  ]
}</code></pre>
<p>채팅창에서 <code>/fe</code>, <code>/be</code>, <code>/qa</code> 를 입력하면 해당 에이전트 모드로 바로 전환된다.</p>
<hr>
<h2 id="다음-편-예고">다음 편 예고</h2>
<p>2편에서는 실제 프로젝트 폴더를 만들고, <strong>에이전트 팀의 두뇌인 <code>CLAUDE.md</code></strong> 를 작성한다.
LLM에게 장기 기억이 없는 이유와, 그걸 <code>CLAUDE.md</code>로 보완하는 원리도 같이 설명한다.</p>
<blockquote>
<p>시리즈 전체 코드는 GitHub에서 공개 예정.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[트러블슈팅] M8i 인스턴스 업그레이드 후 Oracle DB 연결이 갑자기 끊기는 현상]]></title>
            <link>https://velog.io/@do_dev/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-M8i-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C-%ED%9B%84-Oracle-DB-%EC%97%B0%EA%B2%B0%EC%9D%B4-%EA%B0%91%EC%9E%90%EA%B8%B0-%EB%81%8A%EA%B8%B0%EB%8A%94-%ED%98%84%EC%83%81</link>
            <guid>https://velog.io/@do_dev/%ED%8A%B8%EB%9F%AC%EB%B8%94%EC%8A%88%ED%8C%85-M8i-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EC%97%85%EA%B7%B8%EB%A0%88%EC%9D%B4%EB%93%9C-%ED%9B%84-Oracle-DB-%EC%97%B0%EA%B2%B0%EC%9D%B4-%EA%B0%91%EC%9E%90%EA%B8%B0-%EB%81%8A%EA%B8%B0%EB%8A%94-%ED%98%84%EC%83%81</guid>
            <pubDate>Fri, 22 May 2026 02:07:46 GMT</pubDate>
            <description><![CDATA[<h3 id="date-2026-05-22">Date: 2026-05-22</h3>
<p>EC2 인스턴스 타입을 <code>C5</code>에서 <code>M8i</code>로 변경한 이후, Oracle DB의 idle 상태 TCP 연결이 7~10분 후 아무런 에러 없이 끊기는 현상이 발생했다. 원인은 <strong>최신 Nitro Card(Nitro v6)</strong> 의 기본 connection tracking idle timeout 변경이었다.</p>
<h2 id="현상">현상</h2>
<p>EC2 인스턴스를 <code>C5</code> → <code>M8i</code>로 변경 후, Oracle DB의 idle TCP 연결이 <strong>7~10분 후 알림 없이 끊기는 &quot;silent drop&quot;</strong> 발생. 애플리케이션 로그에는 아무런 에러가 찍히지 않는다.</p>
<h2 id="원인">원인</h2>
<h3 id="aws-ec2-security-group의-connection-tracking">AWS EC2 Security Group의 Connection Tracking</h3>
<p>AWS EC2의 Security Group은 <strong>stateful 방화벽</strong>이다. 연결 상태를 추적(connection tracking)해서 응답 트래픽을 자동으로 허용한다. 이 connection tracking에는 <strong>idle timeout</strong>이 존재하며, timeout이 만료되면 해당 연결 정보가 제거된다.</p>
<blockquote>
<p>연결 정보가 제거된 후 패킷이 전송되면, Security Group은 이를 <strong>새로운 연결로 인식하지 못하고 drop</strong>한다. 이것이 바로 &quot;silent drop&quot; 현상의 원인이다.</p>
</blockquote>
<h3 id="c5-vs-m8i-무엇이-달라졌나">C5 vs M8i: 무엇이 달라졌나</h3>
<table>
<thead>
<tr>
<th>인스턴스</th>
<th>Nitro 세대</th>
<th>TCP Established Timeout</th>
</tr>
</thead>
<tbody><tr>
<td><code>C5</code></td>
<td>4~5세대 Nitro Card</td>
<td>432,000초 (5일)</td>
</tr>
<tr>
<td><code>M8i</code></td>
<td>6세대 (Nitro v6)</td>
<td><strong>350초 (약 5분 50초)</strong></td>
</tr>
</tbody></table>
<blockquote>
<p>⚠️ <strong>영향 범위:</strong> M8i뿐 아니라 Nitro v6를 사용하는 <code>C8i</code>, <code>R8i</code> 등 최신 세대 인스턴스 모두 해당된다. 이는 성능 최적화를 위해 AWS가 의도적으로 변경한 기본값이며, AWS 공식 문서에서도 안내하고 있다.</p>
</blockquote>
<h3 id="장애-흐름">장애 흐름</h3>
<ul>
<li><strong>0:00</strong> — 애플리케이션 ↔ Oracle DB 사이에 TCP 연결 수립. HikariCP pool에 유지됨.</li>
<li><strong>0:00 ~ 5:50</strong> — 쿼리 없음. 연결은 idle 상태 유지.</li>
<li><strong>5:50 (350초)</strong> — Nitro v6 connection tracking 테이블에서 해당 연결 정보 제거됨.</li>
<li><strong>이후 쿼리 시도</strong> — Security Group이 패킷을 신규 연결로 인식하지 못함 → <strong>silent drop</strong> → 커넥션 오류 발생.</li>
</ul>
<h2 id="조치">조치</h2>
<ol>
<li><strong>M8i → 7세대 인스턴스로 롤백</strong> — 즉각적인 서비스 안정화 우선</li>
<li><strong>추후 최적 옵션 검토</strong> — Nitro v6 전환 시 keepalive 튜닝 등 워크로드 특성에 맞는 설정 사전 검토 후 재전환 예정</li>
</ol>
<h2 id="근본-해결책-hikaricp-keepalive-설정">근본 해결책: HikariCP Keepalive 설정</h2>
<p>Nitro v6 인스턴스를 계속 사용해야 한다면, 350초 안에 keepalive 패킷을 보내도록 설정한다. 충분한 여유를 두고 <strong>2분(120,000ms)</strong> 으로 설정하는 것을 권장한다.</p>
<h3 id="applicationproperties">application.properties</h3>
<pre><code class="language-properties">spring.datasource.hikari.keepalive-time=120000</code></pre>
<p>커스텀 prefix 사용 시:</p>
<pre><code class="language-properties"># ❌ 무시됨
legacy.datasource.keepalive-time=120000

# ✅ HikariCP에 실제 적용됨
legacy.datasource.hikari.keepalive-time=120000</code></pre>
<blockquote>
<p><code>.hikari.</code> 없이 설정하면 HikariCP에 <strong>적용되지 않고 무시</strong>된다. 반드시 <code>hikari</code> 네임스페이스를 포함해야 한다.</p>
</blockquote>
<h3 id="bean으로-직접-등록하는-경우">Bean으로 직접 등록하는 경우</h3>
<pre><code class="language-java">@Bean
@ConfigurationProperties(&quot;legacy.datasource.hikari&quot;)
public HikariDataSource legacyDataSource() {
    return new HikariDataSource();
}</code></pre>
<h2 id="핵심-정리">핵심 정리</h2>
<ul>
<li>✅ <strong>원인:</strong> Nitro v6(M8i, C8i, R8i 등)은 TCP Established idle timeout이 5일 → <strong>350초</strong>로 단축됨</li>
<li>✅ <strong>현상:</strong> Idle 커넥션이 350초 후 Security Group에서 제거 → 다음 쿼리 시 silent drop</li>
<li>✅ <strong>즉각 조치:</strong> 7세대 인스턴스로 롤백</li>
<li>✅ <strong>근본 해결:</strong> HikariCP <code>keepalive-time=120000</code> 설정으로 350초 이내 keepalive 전송</li>
</ul>
<blockquote>
<p>⚠️ <strong>참고:</strong> Nitro v6 인스턴스(M8i, C8i, R8i 등 최신 세대)로 전환 시 동일 현상 발생 가능. <strong>워크로드의 idle 커넥션 특성을 사전에 반드시 검토</strong>할 것.</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DCommerce] 6편 - k6 성능 테스트로 Redis 동시성 검증하기]]></title>
            <link>https://velog.io/@do_dev/DCommerce-6%ED%8E%B8-k6-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A1%9C-Redis-%EB%8F%99%EC%8B%9C%EC%84%B1-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@do_dev/DCommerce-6%ED%8E%B8-k6-%EC%84%B1%EB%8A%A5-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A1%9C-Redis-%EB%8F%99%EC%8B%9C%EC%84%B1-%EA%B2%80%EC%A6%9D%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 19 May 2026 07:04:58 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>5편까지 Redis 동시성 제어와 Kafka 비동기 처리를 구현했다. 이번 편에서는 k6를 사용해서 실제로 동시 주문이 들어왔을 때 재고가 정확히 차감되는지 성능 테스트로 검증했다.</p>
<hr>
<h2 id="k6란">k6란?</h2>
<p>k6는 JavaScript로 테스트 스크립트를 작성할 수 있는 오픈소스 성능 테스트 툴이다. Postman처럼 API를 호출하는데, 동시에 수백 명이 요청하는 상황을 시뮬레이션할 수 있다.</p>
<pre><code class="language-bash">brew install k6</code></pre>
<hr>
<h2 id="테스트-시나리오">테스트 시나리오</h2>
<p><strong>목표</strong>: 재고 100개인 상품에 200명이 동시에 주문했을 때, 100건만 성공하고 나머지는 재고 부족으로 차단되는지 검증</p>
<hr>
<h2 id="테스트-스크립트">테스트 스크립트</h2>
<pre><code class="language-javascript">import http from &#39;k6/http&#39;;
import { check, sleep } from &#39;k6&#39;;

export const options = {
    vus: 200,        // 동시 사용자 200명
    duration: &#39;10s&#39;, // 10초 동안
};

export default function () {
    const token = &#39;Bearer {JWT토큰}&#39;;

    const payload = JSON.stringify({
        productId: 2,
        quantity: 1,
    });

    const params = {
        headers: {
            &#39;Content-Type&#39;: &#39;application/json&#39;,
            &#39;Authorization&#39;: token,
        },
    };

    const res = http.post(&#39;http://localhost:8080/order&#39;, payload, params);

    check(res, {
        &#39;주문 성공 (201)&#39;: (r) =&gt; r.status === 201,
        &#39;재고 부족 (400)&#39;: (r) =&gt; r.status === 400,
    });

    sleep(1);
}</code></pre>
<hr>
<h2 id="테스트-결과">테스트 결과</h2>
<pre><code>동시 사용자: 200명
재고: 100개
총 요청: 2000건

주문 성공 (201): 100건  ✅
재고 부족 (400): 1802건 ✅
평균 응답 시간: 54ms
최대 응답 시간: 689ms
처리량: 182건/s</code></pre><p><strong>200명이 동시에 요청했지만 딱 100건만 성공했다. 재고를 초과한 주문은 0건.</strong></p>
<hr>
<h2 id="redis-동시성-제어-핵심-코드">Redis 동시성 제어 핵심 코드</h2>
<pre><code class="language-java">// StockService.java
public Long decreaseStock(Long productId, int quantity) {
    String key = &quot;stock:&quot; + productId;
    return redisTemplate.opsForValue().increment(key, -quantity);
}

public Long increaseStock(Long productId, int quantity) {
    String key = &quot;stock:&quot; + productId;
    return redisTemplate.opsForValue().increment(key, quantity);
}</code></pre>
<pre><code class="language-java">// OrderServiceImpl.java
Long remainStock = stockService.decreaseStock(
        createOrderRequestDTO.getProductId(), createOrderRequestDTO.getQuantity()
);

// 재고 부족 체크
if (remainStock &lt; 0) {
    stockService.increaseStock(createOrderRequestDTO.getProductId(), createOrderRequestDTO.getQuantity());
    throw new IllegalArgumentException(&quot;재고가 부족합니다.&quot;);
}</code></pre>
<p>Redis의 <code>increment</code> 연산은 원자적(Atomic)으로 동작하기 때문에 동시에 여러 요청이 들어와도 정확하게 재고가 차감된다.</p>
<hr>
<h2 id="redis가-없었다면">Redis가 없었다면?</h2>
<p>DB에서 재고를 읽고 차감하는 방식은 아래와 같은 문제가 생긴다.</p>
<pre><code>Thread A: 재고 조회 → 5개 남음
Thread B: 재고 조회 → 5개 남음  ← A가 차감하기 전에 읽음
Thread A: 재고 차감 → 4개
Thread B: 재고 차감 → 4개  ← 실제론 3개여야 하는데!</code></pre><p>이런 Race Condition을 Redis의 원자적 연산으로 해결했다.</p>
<hr>
<h2 id="마치며">마치며</h2>
<p>k6 성능 테스트를 통해 Redis 동시성 제어가 실제로 동작함을 검증했다.</p>
<p>5편에 걸쳐 DCommerce 프로젝트를 완성했다.</p>
<pre><code>1편 - Entity 설계 + 회원가입
2편 - JWT 로그인
3편 - 상품 + 주문 API
4편 - Redis 동시성 제어
5편 - Kafka 비동기 이벤트
6편 - k6 성능 테스트 (현재)</code></pre><p>처음에는 운영 업무만 하다 보니 직접 구조를 짜가며 해볼 기회가 많이 없었는데, 이번 프로젝트를 통해 Spring Boot + Redis + Kafka 조합을 직접 설계하고 구현하며 찍먹해볼 수 있었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DCommerce] 5편 - Kafka로 주문 이벤트 비동기 처리하기]]></title>
            <link>https://velog.io/@do_dev/DCommerce-5%ED%8E%B8-Kafka%EB%A1%9C-%EC%A3%BC%EB%AC%B8-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@do_dev/DCommerce-5%ED%8E%B8-Kafka%EB%A1%9C-%EC%A3%BC%EB%AC%B8-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EB%B9%84%EB%8F%99%EA%B8%B0-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 15 May 2026 08:34:00 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>4편에서 Redis로 동시성 문제를 해결했다. 이번 편에서는 Kafka를 도입해서 주문 완료 이벤트를 비동기로 처리하는 구조를 만들었다.</p>
<hr>
<h2 id="왜-kafka인가">왜 Kafka인가?</h2>
<p>주문이 완료되면 이메일 발송, 포인트 적립, 배송 시작 등 여러 작업이 필요하다. 이걸 동기로 처리하면 문제가 생긴다.</p>
<ul>
<li>이메일 서버가 느리면 주문 API도 느려짐</li>
<li>포인트 서비스가 죽으면 주문도 실패</li>
</ul>
<p>Kafka를 쓰면 주문 서비스는 메시지만 던지고 끝. 나머지 서비스들이 알아서 처리한다.</p>
<hr>
<h2 id="kafka-핵심-개념">Kafka 핵심 개념</h2>
<p><strong>Topic</strong> — 메시지를 저장하는 공간. 우리 프로젝트에서는 <code>order-complete</code>.</p>
<p><strong>Producer</strong> — 메시지를 Topic에 발행하는 쪽. 주문 서비스가 담당.</p>
<p><strong>Consumer</strong> — Topic을 구독해서 메시지를 읽는 쪽. 이메일/포인트 서비스가 담당.</p>
<p><strong>Consumer Group</strong> — 같은 Topic을 여러 서비스가 독립적으로 구독할 수 있음.</p>
<p><strong>Offset</strong> — Consumer가 어디까지 읽었는지 기록. 장애 복구 시 이어서 읽기 가능.</p>
<hr>
<h2 id="구현">구현</h2>
<h3 id="1-의존성-추가">1. 의존성 추가</h3>
<pre><code class="language-groovy">implementation &#39;org.springframework.kafka:spring-kafka&#39;</code></pre>
<h3 id="2-kafkaconfig">2. KafkaConfig</h3>
<pre><code class="language-java">@Configuration
public class KafkaConfig {

    @Bean
    public ProducerFactory&lt;String, String&gt; producerFactory(
            @Value(&quot;${spring.kafka.bootstrap-servers}&quot;) String bootstrapServers) {
        Map&lt;String, Object&gt; config = new HashMap&lt;&gt;();
        config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        config.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        config.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        return new DefaultKafkaProducerFactory&lt;&gt;(config);
    }

    @Bean
    public KafkaTemplate&lt;String, String&gt; kafkaTemplate(ProducerFactory&lt;String, String&gt; producerFactory) {
        return new KafkaTemplate&lt;&gt;(producerFactory);
    }

    @Bean
    public ConsumerFactory&lt;String, String&gt; consumerFactory(
            @Value(&quot;${spring.kafka.bootstrap-servers}&quot;) String bootstrapServers) {
        Map&lt;String, Object&gt; config = new HashMap&lt;&gt;();
        config.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        config.put(ConsumerConfig.GROUP_ID_CONFIG, &quot;order-group&quot;);
        config.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        config.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
        config.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, &quot;earliest&quot;);
        return new DefaultKafkaConsumerFactory&lt;&gt;(config);
    }

    @Bean
    public ConcurrentKafkaListenerContainerFactory&lt;String, String&gt; kafkaListenerContainerFactory(
            ConsumerFactory&lt;String, String&gt; consumerFactory) {
        ConcurrentKafkaListenerContainerFactory&lt;String, String&gt; factory =
                new ConcurrentKafkaListenerContainerFactory&lt;&gt;();
        factory.setConsumerFactory(consumerFactory);
        return factory;
    }
}</code></pre>
<h3 id="3-ordereventproducer">3. OrderEventProducer</h3>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class OrderEventProducer {

    private final KafkaTemplate&lt;String, String&gt; kafkaTemplate;

    public void sendOrderComplete(Long orderId) {
        kafkaTemplate.send(&quot;order-complete&quot;, String.valueOf(orderId));
    }
}</code></pre>
<h3 id="4-ordereventconsumer">4. OrderEventConsumer</h3>
<pre><code class="language-java">@Component
public class OrderEventConsumer {

    private static final Logger log = LoggerFactory.getLogger(OrderEventConsumer.class);

    @KafkaListener(topics = &quot;order-complete&quot;, groupId = &quot;order-group&quot;)
    public void handleOrderComplete(String orderId) {
        log.info(&quot;주문 완료 이벤트 수신 — orderId: {}&quot;, orderId);
    }
}</code></pre>
<h3 id="5-orderservice에-이벤트-발행-추가">5. OrderService에 이벤트 발행 추가</h3>
<pre><code class="language-java">orderRepository.save(order);
orderItemRepository.save(orderItem);
orderEventProducer.sendOrderComplete(order.getId()); // 주문 완료 이벤트 발행</code></pre>
<h3 id="6-applicationproperties">6. application.properties</h3>
<pre><code class="language-properties">spring.kafka.bootstrap-servers=localhost:9092

spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.apache.kafka.common.serialization.StringSerializer

spring.kafka.consumer.group-id=order-group
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.auto-offset-reset=earliest</code></pre>
<hr>
<h2 id="동작-확인">동작 확인</h2>
<p>주문 API 호출 후 Kafka Topic에 메시지가 쌓이는 것을 확인했다.</p>
<pre><code class="language-bash">kafka-console-consumer --topic order-complete --bootstrap-server localhost:9092 --from-beginning</code></pre>
<p>orderId가 순서대로 출력되는 것을 확인했다.</p>
<hr>
<h2 id="at-least-once-전략">At Least Once 전략</h2>
<p>Kafka Consumer 전략은 크게 세 가지다.</p>
<table>
<thead>
<tr>
<th>전략</th>
<th>설명</th>
<th>특징</th>
</tr>
</thead>
<tbody><tr>
<td>At Most Once</td>
<td>offset 먼저 커밋</td>
<td>메시지 유실 가능</td>
</tr>
<tr>
<td>At Least Once</td>
<td>처리 후 offset 커밋</td>
<td>중복 가능, 멱등성 필요</td>
</tr>
<tr>
<td>Exactly Once</td>
<td>딱 한 번만 처리</td>
<td>구현 복잡, 성능 저하</td>
</tr>
</tbody></table>
<p>우리 프로젝트는 <strong>At Least Once</strong> 전략을 채택했다. 중복 처리 방지는 orderId 기반 체크로 멱등성을 보장할 수 있다.</p>
<blockquote>
<p>&quot;At Least Once 전략을 사용하고, 중복 처리 방지를 위해 orderId 기반 멱등성을 보장했습니다.&quot;</p>
</blockquote>
<hr>
<h2 id="마치며">마치며</h2>
<p>이번 편에서 Kafka를 도입해서 주문 완료 이벤트를 비동기로 발행하는 구조를 완성했다.
다음 편에서는 성능 테스트와 README, GitHub 정리를 진행할 예정이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DCommerce] 4편 - Redis로 재고 동시성 문제 해결하기]]></title>
            <link>https://velog.io/@do_dev/DCommerce-4%ED%8E%B8-Redis%EB%A1%9C-%EC%9E%AC%EA%B3%A0-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@do_dev/DCommerce-4%ED%8E%B8-Redis%EB%A1%9C-%EC%9E%AC%EA%B3%A0-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 07 May 2026 16:52:27 GMT</pubDate>
            <description><![CDATA[<h1 id="dcommerce-4편---redis로-재고-동시성-문제-해결하기">[DCommerce] 4편 - Redis로 재고 동시성 문제 해결하기</h1>
<h2 id="왜-동시성-문제가-발생할까">왜 동시성 문제가 발생할까?</h2>
<p>기존 코드는 이런 흐름이었다:</p>
<pre><code>1. DB에서 재고 조회
2. 재고 확인
3. 재고 차감
4. DB 저장</code></pre><p>동시에 2명이 주문하면:</p>
<pre><code>재고: 1개

A: 재고 조회 → 1개 있음
B: 재고 조회 → 1개 있음 (A가 아직 차감 안 함)
A: 재고 차감 → 0개
B: 재고 차감 → -1개 💥 overselling 발생!</code></pre><p>재고가 마이너스가 되는 <strong>overselling 문제</strong>가 발생한다.</p>
<hr>
<h2 id="redis로-해결하는-이유">Redis로 해결하는 이유</h2>
<p>Redis의 <code>increment</code> 연산은 <strong>원자적(Atomic)</strong> 으로 처리된다.</p>
<pre><code>동시에 100명이 요청해도 Redis는 순서대로 하나씩 처리
→ 재고가 절대 음수가 되지 않음</code></pre><hr>
<h2 id="redis-설정">Redis 설정</h2>
<h3 id="의존성-추가">의존성 추가</h3>
<pre><code class="language-gradle">implementation &#39;org.springframework.boot:spring-boot-starter-data-redis&#39;</code></pre>
<h3 id="applicationyml">application.yml</h3>
<pre><code class="language-yaml">spring:
  data:
    redis:
      host: localhost
      port: 6379</code></pre>
<h3 id="redisconfig">RedisConfig</h3>
<pre><code class="language-java">@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate&lt;String, String&gt; redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate&lt;String, String&gt; template = new RedisTemplate&lt;&gt;();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        return template;
    }
}</code></pre>
<hr>
<h2 id="stockservice-구현">StockService 구현</h2>
<p>Redis에 재고를 관리하는 서비스.
키 형식은 <code>stock:{productId}</code> 로 상품별로 관리한다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class StockService {

    private final RedisTemplate&lt;String, String&gt; redisTemplate;

    private String getStockKey(Long productId) {
        return &quot;stock:&quot; + productId;
    }

    // 재고 초기화 (상품 등록 시 호출)
    public void initStock(Long productId, int quantity) {
        redisTemplate.opsForValue().set(getStockKey(productId), String.valueOf(quantity));
    }

    // 재고 차감 (원자적 연산 핵심!)
    public Long decreaseStock(Long productId, int quantity) {
        return redisTemplate.opsForValue().increment(getStockKey(productId), -quantity);
    }

    // 재고 증가 (차감 실패 시 롤백용)
    public void increaseStock(Long productId, int quantity) {
        redisTemplate.opsForValue().increment(getStockKey(productId), quantity);
    }

    // 재고 조회
    public int getStock(Long productId) {
        String value = redisTemplate.opsForValue().get(getStockKey(productId));
        return value == null ? 0 : Integer.parseInt(value);
    }
}</code></pre>
<hr>
<h2 id="상품-등록-시-redis-재고-초기화">상품 등록 시 Redis 재고 초기화</h2>
<pre><code class="language-java">public void createProduct(ProductCreateRequestDTO dto) {
    Product savedProduct = productRepository.save(product);
    stockService.initStock(savedProduct.getId(), savedProduct.getStockQuantity());
}</code></pre>
<hr>
<p><img src="https://velog.velcdn.com/images/do_dev/post/bb530189-17c8-4a9e-a3c7-9c1b034e5234/image.png" alt=""></p>
<h2 id="주문-시-redis-재고-차감">주문 시 Redis 재고 차감</h2>
<pre><code class="language-java">@Transactional
public void createOrder(Long memberId, CreateOrderRequestDTO dto) {
    // Redis에서 원자적으로 재고 차감
    Long remainStock = stockService.decreaseStock(
            dto.getProductId(),
            dto.getQuantity()
    );

    // 재고 부족 시 롤백
    if (remainStock &lt; 0) {
        stockService.increaseStock(dto.getProductId(), dto.getQuantity());
        throw new IllegalArgumentException(&quot;재고가 부족합니다.&quot;);
    }
}</code></pre>
<p><strong>왜 차감 후 체크하나?</strong>
Redis increment는 차감 후 결과를 반환한다.
음수가 되면 재고 초과이므로 즉시 롤백하고 예외를 던진다.</p>
<hr>
<h2 id="동시성-테스트">동시성 테스트</h2>
<p>재고 7개 상품에 10개 동시 요청:</p>
<pre><code class="language-bash">for i in {1..10}; do
  curl -s -X POST http://localhost:8080/order \
    -H &quot;Authorization: Bearer $TOKEN&quot; \
    -H &quot;Content-Type: application/json&quot; \
    -d &#39;{&quot;productId&quot;: 1, &quot;quantity&quot;: 1}&#39; &amp;
done
wait

redis-cli GET stock:1</code></pre>
<h3 id="결과">결과</h3>
<pre><code>시작 재고: 7개
동시 요청: 10개
결과 재고: 0개 ✅
성공: 7개 / 실패(재고 부족): 3개 ✅</code></pre><p><img src="https://velog.velcdn.com/images/do_dev/post/e372629a-e1b8-46ee-a5aa-f66eeabbd2b3/image.png" alt=""></p>
<p>재고가 음수가 되지 않고 정확히 0에서 멈췄다.</p>
<hr>
<h2 id="정리-포인트">정리 포인트</h2>
<blockquote>
<p>&quot;Redis의 <code>increment</code> 연산은 싱글스레드 기반의 원자적 연산으로, 동시 요청이 몰려도 순서대로 처리되어 overselling을 방지합니다. 10개 동시 요청 테스트에서 재고 초과분은 정상적으로 차단됨을 확인했습니다.&quot;</p>
</blockquote>
<hr>
<h2 id="오늘-배운-것">오늘 배운 것</h2>
<ul>
<li>Redis Key-Value 구조와 활용법</li>
<li><code>increment</code> 원자적 연산으로 동시성 문제 해결</li>
<li>overselling 문제와 해결 방법</li>
<li>재고 차감 후 음수 체크 + 롤백 패턴</li>
<li>동시성 테스트 방법 (curl 병렬 요청)</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[DCommerce] 3편 - 상품 API + 주문 API 구현하기]]></title>
            <link>https://velog.io/@do_dev/DCommerce-3%ED%8E%B8-%EC%83%81%ED%92%88-API-%EC%A3%BC%EB%AC%B8-API-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@do_dev/DCommerce-3%ED%8E%B8-%EC%83%81%ED%92%88-API-%EC%A3%BC%EB%AC%B8-API-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 29 Apr 2026 07:34:22 GMT</pubDate>
            <description><![CDATA[<h1 id="dcommerce-3편---상품-api--주문-api-구현하기">[DCommerce] 3편 - 상품 API + 주문 API 구현하기</h1>
<h2 id="요구사항-정의">요구사항 정의</h2>
<h3 id="상품-api">상품 API</h3>
<pre><code>1. 상품 등록 — 상품명, 가격, 재고수량 입력받아 등록
2. 상품 목록 조회 — 전체 상품 목록 조회
3. 상품 단건 조회 — 상품 ID로 특정 상품 조회, 없으면 예외</code></pre><h3 id="주문-api">주문 API</h3>
<pre><code>1. 주문 생성 — 로그인한 회원만 가능 (JWT 필요)
              상품 ID + 수량으로 주문 생성
              재고 부족 시 예외
              주문 생성 시 재고 차감
2. 내 주문 목록 조회 — 로그인한 회원의 주문 목록 조회</code></pre><hr>
<h2 id="정적-팩토리-메서드-static-factory-method">정적 팩토리 메서드 (Static Factory Method)</h2>
<p><code>new</code> 키워드 대신 <strong>static 메서드로 객체를 생성</strong>하는 패턴.</p>
<pre><code class="language-java">// 일반 생성자
ProductListResponseDTO dto = new ProductListResponseDTO(product.getId(), product.getName(), ...);

// 정적 팩토리 메서드
ProductListResponseDTO dto = ProductListResponseDTO.from(product);</code></pre>
<h3 id="장점">장점</h3>
<p><strong>첫째, 이름을 줄 수 있다</strong></p>
<pre><code class="language-java">// 이게 뭘 만드는지 한눈에 알아?
new ProductListResponseDTO(1L, &quot;상품명&quot;, 1000, 10);

// 이건?
ProductListResponseDTO.from(product);  // Product로부터 DTO 만든다</code></pre>
<p><strong>둘째, Entity 변경이 한 곳에 집중된다</strong></p>
<p><code>Product</code> 필드가 추가되거나 바뀌면 <code>from()</code> 메서드 하나만 고치면 된다.</p>
<p><strong>셋째, Setter가 필요 없어진다</strong></p>
<p>Setter 없이 객체를 불변으로 만들 수 있다.</p>
<blockquote>
<p>&quot;정적 팩토리 메서드는 객체 생성의 의도를 명확히 하고, 변환 로직을 한 곳에 모아 유지보수성을 높이기 위해 사용합니다.&quot;</p>
</blockquote>
<hr>
<h2 id="상품-api-구현">상품 API 구현</h2>
<h3 id="productservice">ProductService</h3>
<pre><code class="language-java">@RequiredArgsConstructor
@Service
public class ProductServiceImpl implements ProductService {

    private final ProductRepository productRepository;

    @Override
    public void createProduct(ProductCreateRequestDTO dto) {
        if (dto.getPrice() &lt; 0) {
            throw new IllegalArgumentException(&quot;가격이 음수입니다.&quot;);
        }
        if (dto.getStockQuantity() &lt; 0) {
            throw new IllegalArgumentException(&quot;재고가 0보다 작습니다.&quot;);
        }

        Product product = Product.builder()
                .name(dto.getName())
                .price(dto.getPrice())
                .stockQuantity(dto.getStockQuantity())
                .build();

        productRepository.save(product);
    }

    @Override
    public List&lt;ProductListResponseDTO&gt; getProductList() {
        List&lt;Product&gt; all = productRepository.findAll();
        List&lt;ProductListResponseDTO&gt; result = new ArrayList&lt;&gt;();
        for (Product product : all) {
            result.add(ProductListResponseDTO.from(product));
        }
        return result;
    }

    @Override
    public ProductDetailResponseDTO getProductDetail(Long id) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -&gt; new IllegalArgumentException(&quot;존재하지 않는 상품입니다.&quot;));
        return ProductDetailResponseDTO.from(product);
    }
}</code></pre>
<p><strong>포인트: <code>orElseThrow()</code> 활용</strong>
<code>Optional.get()</code>을 바로 쓰면 NPE 위험이 있다.
<code>orElseThrow()</code>로 값이 없을 때 명확한 예외를 던져야 한다.</p>
<h3 id="productcontroller">ProductController</h3>
<pre><code class="language-java">@RequiredArgsConstructor
@RestController
@RequestMapping(&quot;/product&quot;)
public class ProductController {

    private final ProductService productService;

    @PostMapping
    public ResponseEntity&lt;Void&gt; createProduct(@RequestBody ProductCreateRequestDTO dto) {
        productService.createProduct(dto);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }

    @GetMapping
    public ResponseEntity&lt;List&lt;ProductListResponseDTO&gt;&gt; getProductList() {
        return ResponseEntity.ok(productService.getProductList());
    }

    @GetMapping(&quot;/{productId}&quot;)
    public ResponseEntity&lt;ProductDetailResponseDTO&gt; getProductDetail(@PathVariable Long productId) {
        return ResponseEntity.ok(productService.getProductDetail(productId));
    }
}</code></pre>
<p><strong>포인트: 등록 API는 201 Created 반환</strong></p>
<blockquote>
<p>&quot;등록 API는 자원이 생성됐음을 명확히 하기 위해 200 OK 대신 201 Created를 반환했습니다.&quot;</p>
</blockquote>
<hr>
<h2 id="주문-api-구현">주문 API 구현</h2>
<h3 id="재고-차감-설계--도메인-메서드">재고 차감 설계 — 도메인 메서드</h3>
<p>재고 차감 로직을 <code>Product</code> Entity 안에 메서드로 만들었다.</p>
<pre><code>재고 차감 로직 → Product 도메인 메서드
재고 차감 호출 → OrderService</code></pre><p>Entity가 자기 상태를 스스로 변경하는 방식 — Setter 없이 안전하게 처리한다.</p>
<pre><code class="language-java">// Product.java
public void decreaseStockQuantity(int quantity) {
    if (this.stockQuantity &lt; quantity) {
        throw new IllegalArgumentException(&quot;재고가 부족합니다.&quot;);
    }
    this.stockQuantity -= quantity;
}</code></pre>
<h3 id="orderservice">OrderService</h3>
<pre><code class="language-java">@RequiredArgsConstructor
@Service
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final OrderItemRepository orderItemRepository;

    @Transactional
    @Override
    public void createOrder(Long memberId, CreateOrderRequestDTO dto) {
        // 1. 회원 조회
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -&gt; new IllegalArgumentException(&quot;등록된 회원이 아닙니다.&quot;));

        // 2. 상품 조회
        Product product = productRepository.findById(dto.getProductId())
                .orElseThrow(() -&gt; new IllegalArgumentException(&quot;존재하지 않는 상품입니다.&quot;));

        // 3. 주문 생성
        Order order = Order.builder()
                .member(member)
                .orderPrice((long) (dto.getQuantity() * product.getPrice()))
                .orderStatus(OrderStatus.ORDERED)
                .orderedAt(LocalDateTime.now())
                .build();

        // 4. 주문아이템 생성 (주문 당시 가격 스냅샷)
        OrderItem orderItem = OrderItem.builder()
                .order(order)
                .product(product)
                .quantity(dto.getQuantity())
                .orderPrice(product.getPrice())
                .build();

        // 5. 재고 차감
        product.decreaseStockQuantity(dto.getQuantity());

        // 6. 저장
        orderRepository.save(order);
        orderItemRepository.save(orderItem);
    }
}</code></pre>
<p><strong>포인트 1: @Transactional</strong></p>
<p>재고 차감, 주문 저장, 주문아이템 저장이 하나의 트랜잭션으로 묶여야 한다.
중간에 하나라도 실패하면 전부 롤백된다.</p>
<pre><code class="language-java">// Spring 거로 임포트해야 함
import org.springframework.transaction.annotation.Transactional;
// Jakarta 거 아님!</code></pre>
<p><strong>포인트 2: OrderItem에 가격 스냅샷 저장</strong></p>
<p>상품 가격이 나중에 바뀌어도 주문 당시 가격을 유지하기 위해
<code>OrderItem</code>에 <code>orderPrice</code>를 따로 저장한다.</p>
<h3 id="ordercontroller">OrderController</h3>
<pre><code class="language-java">@RequiredArgsConstructor
@RequestMapping(&quot;/order&quot;)
@RestController
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity&lt;Void&gt; createOrder(
            HttpServletRequest request,
            @RequestBody CreateOrderRequestDTO dto) {
        Long memberId = (Long) request.getAttribute(&quot;memberId&quot;);
        orderService.createOrder(memberId, dto);
        return ResponseEntity.status(HttpStatus.CREATED).build();
    }
}</code></pre>
<p><strong>포인트: memberId는 JWT에서 꺼낸다</strong></p>
<p><code>JwtInterceptor</code>에서 토큰 검증 후 <code>request.setAttribute(&quot;memberId&quot;, memberId)</code>로 저장해뒀기 때문에
Controller에서 <code>request.getAttribute(&quot;memberId&quot;)</code>로 꺼내 쓸 수 있다.</p>
<hr>
<h2 id="api-테스트">API 테스트</h2>
<h3 id="상품-등록">상품 등록</h3>
<pre><code>POST http://localhost:8080/product
Content-Type: application/json

{
    &quot;name&quot;: &quot;맥북 프로&quot;,
    &quot;price&quot;: 3000000,
    &quot;stockQuantity&quot;: 10,
    &quot;description&quot;: &quot;M3 Pro 칩셋&quot;
}</code></pre><h3 id="주문-생성">주문 생성</h3>
<pre><code>POST http://localhost:8080/order
Authorization: Bearer {JWT토큰}
Content-Type: application/json

{
    &quot;productId&quot;: 1,
    &quot;quantity&quot;: 2
}</code></pre><hr>
<h2 id="builderdefault-주의사항">@Builder.Default 주의사항</h2>
<p><code>@Builder</code>와 컬렉션 초기화를 함께 쓸 때 경고가 발생한다:</p>
<pre><code class="language-java">// 경고 발생
private List&lt;OrderItem&gt; orderItemList = new ArrayList&lt;&gt;();

// 해결
@Builder.Default
private List&lt;OrderItem&gt; orderItemList = new ArrayList&lt;&gt;();</code></pre>
<p><code>@Builder</code>는 기본값 초기화를 무시하기 때문에 <code>@Builder.Default</code>를 붙여야
빌더로 생성할 때도 빈 리스트가 초기화된다.</p>
<hr>
<h2 id="오늘-배운-것">오늘 배운 것</h2>
<ul>
<li>정적 팩토리 메서드 패턴과 장점</li>
<li><code>orElseThrow()</code>로 안전한 Optional 처리</li>
<li>도메인 메서드로 Entity 상태 변경 (Setter 없이)</li>
<li><code>@Transactional</code>로 원자적 트랜잭션 처리</li>
<li>JWT에서 memberId 꺼내는 방법</li>
<li>201 Created vs 200 OK 차이</li>
<li><code>@Builder.Default</code> 사용법</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[2편 - 로그인 API + JWT 발급 구현하기]]></title>
            <link>https://velog.io/@do_dev/2%ED%8E%B8-%EB%A1%9C%EA%B7%B8%EC%9D%B8-API-JWT-%EB%B0%9C%EA%B8%89-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@do_dev/2%ED%8E%B8-%EB%A1%9C%EA%B7%B8%EC%9D%B8-API-JWT-%EB%B0%9C%EA%B8%89-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 24 Apr 2026 03:40:56 GMT</pubDate>
            <description><![CDATA[<h1 id="로그인-api--jwt-발급-구현하기">로그인 API + JWT 발급 구현하기</h1>
<h2 id="jwt란">JWT란?</h2>
<p>JWT(JSON Web Token)는 로그인 인증에 사용되는 토큰 방식이야.</p>
<p><strong>핵심 포인트: 서버는 토큰을 저장하지 않는다.</strong></p>
<p>일반적인 세션 방식은 서버가 로그인 상태를 저장하지만, JWT는 다르다.
서버는 <code>Secret Key</code>로 서명만 해서 클라이언트에게 토큰을 발급하고,
이후 요청마다 그 서명이 유효한지만 검증한다. DB 조회 없이.</p>
<p>그래서 JWT의 장점은 <strong>Stateless(무상태)</strong> — 서버가 상태를 저장하지 않아도 된다.</p>
<h3 id="jwt-동작-흐름">JWT 동작 흐름</h3>
<pre><code>① 클라이언트 → 서버: email + password 전송
② 서버 → DB: 유저 조회
③ DB → 서버: 유저 정보 반환
④ 서버: 비밀번호 검증 + JWT 생성 (서명)
⑤ 서버 → 클라이언트: JWT 반환
⑥ 이후 API 요청 시: Authorization: Bearer {token} 헤더에 포함
⑦ 서버: 서명 검증 (DB 조회 없이!)</code></pre><h3 id="jwt-구조">JWT 구조</h3>
<p>JWT는 3개 파트로 구성된다:</p>
<pre><code>header.payload.signature

eyJhbGc... . eyJ1c2VySWQiOjF9 . SflKxwRJSMeKKF2QT4...</code></pre><ul>
<li><code>header</code> — 알고리즘 정보 (HS256)</li>
<li><code>payload</code> — 담을 데이터 (userId, email, 만료시간)</li>
<li><code>signature</code> — 위 두 개를 Secret Key로 서명한 값</li>
</ul>
<h3 id="서명이란">서명이란?</h3>
<p>서명은 <strong>&quot;내가 만든 토큰이 맞다&quot;는 증명</strong>이다.</p>
<p>누군가 payload를 <code>{ userId: 99 }</code>로 바꾸면? Secret Key가 없으니 signature를 다시 못 만든다.
서버가 검증할 때 &quot;이 서명 이상한데?&quot; 하고 바로 거부한다.</p>
<blockquote>
<p>&quot;payload가 중간에 변조되지 않았다는 걸 Secret Key로 보증하는 것&quot;</p>
</blockquote>
<hr>
<h2 id="의존성-추가">의존성 추가</h2>
<p><code>build.gradle</code>에 JWT 라이브러리 추가:</p>
<pre><code class="language-gradle">implementation &#39;io.jsonwebtoken:jjwt-api:0.11.5&#39;
runtimeOnly &#39;io.jsonwebtoken:jjwt-impl:0.11.5&#39;
runtimeOnly &#39;io.jsonwebtoken:jjwt-jackson:0.11.5&#39;</code></pre>
<p><code>application.yml</code>에 Secret Key 설정:</p>
<pre><code class="language-yaml">jwt:
  secret: mysecretkey12345678901234567890123456789012
  expiration: 86400000  # 24시간 (ms)</code></pre>
<hr>
<h2 id="jwtprovider-구현">JwtProvider 구현</h2>
<p>JWT 생성, 검증, userId 추출 3가지 기능을 담당하는 유틸 클래스:</p>
<pre><code class="language-java">@Component
public class JwtProvider {

    @Value(&quot;${jwt.secret}&quot;)
    private String secretKey;

    @Value(&quot;${jwt.expiration}&quot;)
    private long expiration;

    // 토큰 생성
    public String generateToken(Long userId, String email) {
        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim(&quot;email&quot;, email)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(Keys.hmacShaKeyFor(secretKey.getBytes()), SignatureAlgorithm.HS256)
                .compact();
    }

    // 토큰 검증
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder()
                    .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
                    .build()
                    .parseClaimsJws(token);
            return true;
        } catch (Exception e) {
            return false;
        }
    }

    // userId 추출
    public Long getUserId(String token) {
        String subject = Jwts.parserBuilder()
                .setSigningKey(Keys.hmacShaKeyFor(secretKey.getBytes()))
                .build()
                .parseClaimsJws(token)
                .getBody()
                .getSubject();
        return Long.parseLong(subject);
    }
}</code></pre>
<p><strong>왜 try-catch로 검증하나?</strong>
토큰이 만료되거나 서명이 틀리면 <code>Jwts</code>가 예외를 던진다.
그걸 잡아서 <code>false</code>를 반환하는 방식으로 유효성을 검증한다.</p>
<hr>
<h2 id="로그인-api-구현">로그인 API 구현</h2>
<h3 id="memberrepository">MemberRepository</h3>
<pre><code class="language-java">@Repository
public interface MemberRepository extends JpaRepository&lt;Member, Long&gt; {
    boolean existsMemberByEmail(String email);
    Optional&lt;Member&gt; findByEmail(String email);  // 로그인용 추가
}</code></pre>
<h3 id="memberservice">MemberService</h3>
<pre><code class="language-java">@Override
public LoginResponseDTO login(LoginRequestDTO loginRequestDTO) {
    // 1. 이메일로 유저 조회
    Member member = memberRepository.findByEmail(loginRequestDTO.getEmail())
            .orElseThrow(() -&gt; new IllegalArgumentException(&quot;가입되지 않은 회원입니다.&quot;));

    // 2. 비밀번호 확인
    if (!passwordEncoder.matches(loginRequestDTO.getPassword(), member.getPassword())) {
        throw new IllegalArgumentException(&quot;비밀번호가 틀렸습니다.&quot;);
    }

    // 3. JWT 발급
    String token = jwtProvider.generateToken(member.getId(), member.getEmail());

    return new LoginResponseDTO(member.getId(), token);
}</code></pre>
<p><strong>포인트 1: <code>orElseThrow()</code> 활용</strong>
<code>Optional.isPresent()</code> 체크보다 훨씬 깔끔하다. 값이 없으면 바로 예외를 던진다.</p>
<p><strong>포인트 2: <code>passwordEncoder.matches()</code> 사용</strong>
비밀번호는 BCrypt로 암호화되어 저장되므로 평문과 직접 비교하면 안 된다.
<code>matches(평문, 암호화된값)</code>으로 비교해야 한다.</p>
<h3 id="loginresponsedto">LoginResponseDTO</h3>
<pre><code class="language-java">@Getter
@AllArgsConstructor
public class LoginResponseDTO {
    private Long memberId;
    private String token;
}</code></pre>
<h3 id="membercontroller">MemberController</h3>
<pre><code class="language-java">@PostMapping(&quot;/login&quot;)
public ResponseEntity&lt;LoginResponseDTO&gt; login(@RequestBody LoginRequestDTO loginRequestDTO) {
    LoginResponseDTO response = memberService.login(loginRequestDTO);
    return ResponseEntity.ok(response);
}</code></pre>
<hr>
<h2 id="jwtinterceptor-설정">JwtInterceptor 설정</h2>
<p>로그인, 회원가입 URI는 토큰 검증 없이 통과시키도록 설정:</p>
<pre><code class="language-java">@Component
@RequiredArgsConstructor
public class JwtInterceptor implements HandlerInterceptor {

    private final JwtProvider jwtProvider;

    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response, Object handler) throws Exception {
        String uri = request.getRequestURI();

        // 로그인, 회원가입은 토큰 검증 스킵
        if (uri.startsWith(&quot;/member/login&quot;) || uri.startsWith(&quot;/member/signUp&quot;)) {
            return true;
        }

        String authHeader = request.getHeader(&quot;Authorization&quot;);
        if (authHeader == null || !authHeader.startsWith(&quot;Bearer &quot;)) {
            response.setStatus(401);
            return false;
        }

        String token = authHeader.substring(7);
        if (!jwtProvider.validateToken(token)) {
            response.setStatus(401);
            return false;
        }

        Long memberId = jwtProvider.getUserId(token);
        request.setAttribute(&quot;memberId&quot;, memberId);
        return true;
    }
}</code></pre>
<hr>
<h2 id="테스트-결과">테스트 결과</h2>
<pre><code>POST http://localhost:8080/member/login

{
    &quot;email&quot;: &quot;test@test.com&quot;,
    &quot;password&quot;: &quot;1234&quot;
}</code></pre><p>응답:</p>
<pre><code class="language-json">{
    &quot;memberId&quot;: 1,
    &quot;token&quot;: &quot;eyJhbGciOiJIUzI1NiJ9.eyJ...&quot;
}</code></pre>
<hr>
<h2 id="주의사항-ddl-auto-설정">주의사항: ddl-auto 설정</h2>
<p>개발 중 <code>ddl-auto: create</code>로 설정하면 서버 재시작 시 테이블이 드롭되고 데이터가 날아간다.
데이터를 유지하려면 <code>update</code>로 변경해야 한다.</p>
<pre><code class="language-yaml">spring:
  jpa:
    hibernate:
      ddl-auto: update  # create → update</code></pre>
<table>
<thead>
<tr>
<th>옵션</th>
<th>동작</th>
</tr>
</thead>
<tbody><tr>
<td>create</td>
<td>매번 테이블 드롭 후 재생성 (데이터 삭제)</td>
</tr>
<tr>
<td>update</td>
<td>변경사항만 반영, 데이터 유지</td>
</tr>
<tr>
<td>validate</td>
<td>테이블 구조 검증만 (변경 없음)</td>
</tr>
<tr>
<td>none</td>
<td>아무것도 안 함</td>
</tr>
</tbody></table>
<hr>
<h2 id="오늘-배운-것">오늘 배운 것</h2>
<ul>
<li>JWT 구조와 Stateless 인증 방식</li>
<li>서명(Signature)의 역할 — 변조 방지</li>
<li>JwtProvider 구현 (생성/검증/추출)</li>
<li><code>passwordEncoder.matches()</code>로 암호화 비밀번호 비교</li>
<li><code>Optional.orElseThrow()</code> 패턴</li>
<li><code>ddl-auto: create</code> vs <code>update</code> 차이</li>
</ul>
<hr>
<p><em>다음 포스팅: 상품 API + 주문 API 구현</em></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Spring Boot 프로젝트 설계 — ERD와 첫 API 만들기]]></title>
            <link>https://velog.io/@do_dev/Spring-Boot-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%84%A4%EA%B3%84-ERD%EC%99%80-%EC%B2%AB-API-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@do_dev/Spring-Boot-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%84%A4%EA%B3%84-ERD%EC%99%80-%EC%B2%AB-API-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Fri, 24 Apr 2026 00:54:05 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-boot-프로젝트-설계--erd와-첫-api-만들기">Spring Boot 프로젝트 설계 — ERD와 첫 API 만들기</h1>
<h2 id="프로젝트-개요">프로젝트 개요</h2>
<p>대용량 주문 처리 API 서버를 만들면서 Java 백엔드 기술을 정리합니다.</p>
<p><strong>기술 스택</strong></p>
<ul>
<li>Spring Boot 3.2.x</li>
<li>Java 17</li>
<li>MySQL + JPA</li>
<li>Redis </li>
<li>Kafka </li>
<li>JWT 인증 </li>
</ul>
<hr>
<h2 id="erd-설계">ERD 설계</h2>
<p>주문 시스템에 필요한 테이블 5개를 설계했습니다.</p>
<pre><code>Member ─ 1:N ─ Order ─ 1:N ─ OrderItem ─ N:1 ─ Product
                 │
                1:1
                 │
             Delivery</code></pre><table>
<thead>
<tr>
<th>테이블</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>members</td>
<td>회원 정보</td>
</tr>
<tr>
<td>orders</td>
<td>주문 정보</td>
</tr>
<tr>
<td>order_items</td>
<td>주문 상품 목록</td>
</tr>
<tr>
<td>products</td>
<td>상품 정보</td>
</tr>
<tr>
<td>deliveries</td>
<td>배송 정보</td>
</tr>
</tbody></table>
<h3 id="order_items에-price를-따로-저장하는-이유">order_items에 price를 따로 저장하는 이유</h3>
<p>주문 당시 가격을 스냅샷으로 저장합니다.
상품 가격이 나중에 변경되더라도 주문 이력은 결제 시점 금액을 유지해야 하기 때문입니다.</p>
<hr>
<h2 id="entity-설계">Entity 설계</h2>
<h3 id="memberjava">Member.java</h3>
<pre><code class="language-java">@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String email;

    @Column(nullable = false)
    private String password;

    private String userName;

    @OneToMany(mappedBy = &quot;member&quot;)
    private List&lt;Order&gt; orderList;

    @CreationTimestamp
    private LocalDateTime createdAt;
}</code></pre>
<h3 id="orderjava">Order.java</h3>
<pre><code class="language-java">@Getter
@Table(name = &quot;orders&quot;)
@Entity
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;ORDER_ID&quot;)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;member_id&quot;)
    private Member member;

    @OneToMany(mappedBy = &quot;order&quot;)
    private List&lt;OrderItem&gt; orderItemList = new ArrayList&lt;&gt;();

    @OneToOne(mappedBy = &quot;order&quot;, fetch = FetchType.LAZY)
    private Delivery delivery;

    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus;

    private Long orderPrice;

    @Enumerated(EnumType.STRING)
    private PaymentStatus paymentStatus;

    @Enumerated(EnumType.STRING)
    private OrderType orderType;

    private LocalDateTime orderedAt;
    private LocalDateTime paidAt;
}</code></pre>
<h3 id="orderitemjava">OrderItem.java</h3>
<pre><code class="language-java">@Getter
@Table(name = &quot;ORDER_ITEM&quot;)
@Entity
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;ORDER_ITEM_ID&quot;)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;order_id&quot;)
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;product_id&quot;)
    private Product product;

    private int quantity;
    private int orderPrice;
}</code></pre>
<h3 id="productjava">Product.java</h3>
<pre><code class="language-java">@Getter
@Table(name = &quot;product&quot;)
@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;PRODUCT_ID&quot;)
    private Long id;

    private String name;
    private int price;
    private int stockQuantity;
    private LocalDateTime createdAt;
}</code></pre>
<h3 id="deliveryjava">Delivery.java</h3>
<pre><code class="language-java">@Getter
@Table(name = &quot;deliveries&quot;)
@Entity
public class Delivery {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;order_id&quot;)
    private Order order;

    private String address;

    @Enumerated(EnumType.STRING)
    private DeliveryEnum status;

    private LocalDateTime createdAt;
}</code></pre>
<hr>
<h2 id="연관관계-정리">연관관계 정리</h2>
<h3 id="mappedby란">mappedBy란?</h3>
<p>양방향 연관관계에서 연관관계의 주인을 지정합니다.
주인이 아닌 쪽은 외래키를 관리하지 않고 조회만 가능합니다.</p>
<pre><code class="language-java">// Order가 주인 — member_id 외래키 관리
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = &quot;member_id&quot;)
private Member member;

// Member는 주인 아님 — 읽기만 가능
@OneToMany(mappedBy = &quot;member&quot;)
private List&lt;Order&gt; orderList;</code></pre>
<h3 id="fetchtypelazy를-쓰는-이유">FetchType.LAZY를 쓰는 이유</h3>
<p>연관된 Entity를 실제로 사용할 때 쿼리를 날립니다.
EAGER는 연관 Entity를 무조건 즉시 로딩해서 불필요한 쿼리가 발생할 수 있습니다.</p>
<hr>
<h2 id="회원가입-api">회원가입 API</h2>
<h3 id="memberrepositoryjava">MemberRepository.java</h3>
<pre><code class="language-java">@Repository
public interface MemberRepository extends JpaRepository&lt;Member, Long&gt; {
    boolean existsMemberByEmail(String email);
}</code></pre>
<h3 id="memberservicejava">MemberService.java</h3>
<pre><code class="language-java">public Long signUp(SignUpRequest signUpRequest) {
    // 이메일 중복 체크
    boolean isAlreadyMember = memberRepository.existsMemberByEmail(signUpRequest.getEmail());
    if (isAlreadyMember) {
        throw new IllegalArgumentException(&quot;이미 가입된 회원 이메일입니다.&quot;);
    }

    // 비밀번호 암호화
    String encodePassword = passwordEncoder.encode(signUpRequest.getPassword());

    // 빌더 패턴으로 객체 생성
    Member member = Member.builder()
            .email(signUpRequest.getEmail())
            .password(encodePassword)
            .userName(signUpRequest.getUserName())
            .build();

    memberRepository.save(member);
    return member.getId();
}</code></pre>
<h3 id="membercontrollerjava">MemberController.java</h3>
<pre><code class="language-java">@RequiredArgsConstructor
@RestController
@RequestMapping(&quot;/member&quot;)
public class MemberController {

    private final MemberService memberService;

    @PostMapping(&quot;/signUp&quot;)
    public ResponseEntity&lt;Long&gt; signUp(@RequestBody SignUpRequest signUpRequest) {
        memberService.signUp(signUpRequest);
        return ResponseEntity.ok().build();
    }
}</code></pre>
<h3 id="api-호출-테스트">API 호출 테스트</h3>
<pre><code>POST http://localhost:8080/member/signUp
Content-Type: application/json

{
    &quot;email&quot;: &quot;test@test.com&quot;,
    &quot;password&quot;: &quot;1234&quot;,
    &quot;userName&quot;: &quot;홍길동&quot;
}</code></pre><hr>
<h2 id="오늘-배운-것">오늘 배운 것</h2>
<ul>
<li>JPA Entity 설계 및 연관관계 매핑 (1:N, N:1, 1:1)</li>
<li>mappedBy로 연관관계 주인 지정</li>
<li>@Builder + @NoArgsConstructor 함께 쓰는 법</li>
<li>Entity에 @Setter를 쓰면 안 되는 이유</li>
<li>이메일 중복체크 + 비밀번호 암호화 적용</li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[[데이터베이스] Key 개념 정리 (PK, FK, Unique, Composite Key)]]></title>
            <link>https://velog.io/@do_dev/dbkey</link>
            <guid>https://velog.io/@do_dev/dbkey</guid>
            <pubDate>Thu, 05 Mar 2026 14:37:48 GMT</pubDate>
            <description><![CDATA[<h3 id="1-primary-key-pk">1. Primary Key (PK)</h3>
<p>Primary Key는 테이블에서 각 행(row)을 유일하게 식별하는 컬럼입니다.</p>
<p>즉 중복될 수 없고 NULL도 허용되지 않습니다.</p>
<p>예시</p>
<pre><code>CREATE TABLE member (
    id BIGINT PRIMARY KEY,
    email VARCHAR(255),
    name VARCHAR(100)
);</code></pre><p>여기서 id가 PK입니다.</p>
<h4 id="pk-특징">PK 특징</h4>
<blockquote>
<ul>
<li>유일성 (Uniqueness)</li>
</ul>
</blockquote>
<ul>
<li>같은 값이 존재할 수 없습니다.</li>
<li>NOT NULL</li>
<li>테이블당 하나만 존재</li>
<li>Index 자동 생성</li>
<li>대부분의 데이터베이스에서는 Primary Key를 생성하면 자동으로 Index가 생성됩니다.</li>
</ul>
<table>
<thead>
<tr>
<th>구분</th>
<th>PK</th>
<th>FK</th>
</tr>
</thead>
<tbody><tr>
<td>의미</td>
<td>기본키</td>
<td>외래키</td>
</tr>
<tr>
<td>역할</td>
<td>행 식별</td>
<td>다른 테이블 참조</td>
</tr>
<tr>
<td>중복</td>
<td>불가능</td>
<td>가능</td>
</tr>
<tr>
<td>NULL</td>
<td>불가능</td>
<td>가능</td>
</tr>
<tr>
<td>개수</td>
<td>테이블당 1개</td>
<td>여러개 가능</td>
</tr>
</tbody></table>
<h3 id="2-foreign-key-fk">2. Foreign Key (FK)</h3>
<p>Foreign Key는 다른 테이블의 PK를 참조하는 컬럼입니다.
즉 테이블 간 관계(Relation) 를 만들기 위한 키입니다.</p>
<pre><code>CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    member_id BIGINT,
    FOREIGN KEY (member_id) REFERENCES member(id)
);</code></pre><table>
<thead>
<tr>
<th>id</th>
<th>member_id</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>1</td>
</tr>
<tr>
<td>2</td>
<td>1</td>
</tr>
<tr>
<td>3</td>
<td>2</td>
</tr>
</tbody></table>
<blockquote>
<p>orders.member_id → member.id 관계가 Foreign Key입니다.</p>
</blockquote>
<h4 id="fk의-역할">FK의 역할</h4>
<p>Foreign Key는 데이터 무결성(Data Integrity) 을 보장합니다.</p>
<table>
<thead>
<tr>
<th>구분</th>
<th>PK</th>
<th>FK</th>
</tr>
</thead>
<tbody><tr>
<td>의미</td>
<td>기본키</td>
<td>외래키</td>
</tr>
<tr>
<td>역할</td>
<td>행 식별</td>
<td>다른 테이블 참조</td>
</tr>
<tr>
<td>중복</td>
<td>불가능</td>
<td>가능</td>
</tr>
<tr>
<td>NULL</td>
<td>불가능</td>
<td>가능</td>
</tr>
<tr>
<td>개수</td>
<td>테이블당 1개</td>
<td>여러개 가능</td>
</tr>
</tbody></table>
<h3 id="3-unique-key">3. Unique Key</h3>
<p>Unique Key는 중복을 허용하지 않는 제약조건입니다.
즉 식별자 역할이 아니라 데이터 중복 방지 목적으로 사용됩니다.</p>
<h4 id="uk-특징">UK 특징</h4>
<blockquote>
<ul>
<li>중복 불가능</li>
</ul>
</blockquote>
<ul>
<li>NULL 허용 (DB에 따라 여러 개 가능)</li>
<li>여러 개 생성 가능</li>
<li>Index 생성</li>
<li>식별자 역할은 아님</li>
</ul>
<pre><code>CREATE TABLE member (
    id BIGINT PRIMARY KEY,
    email VARCHAR(255) UNIQUE
);</code></pre><table>
<thead>
<tr>
<th>구분</th>
<th>PK</th>
<th>UNIQUE</th>
</tr>
</thead>
<tbody><tr>
<td>NULL</td>
<td>불가능</td>
<td>가능</td>
</tr>
<tr>
<td>개수</td>
<td>1개</td>
<td>여러개</td>
</tr>
<tr>
<td>역할</td>
<td>기본 식별자</td>
<td>중복 방지</td>
</tr>
</tbody></table>
<h3 id="4-composite-key-복합키">4. Composite Key (복합키)</h3>
<p>Composite Key는 여러 컬럼을 합쳐서 하나의 PK로 사용하는 것입니다.</p>
<h4 id="jpa에서-composite-key-매핑">JPA에서 Composite Key 매핑</h4>
<p>JPA에서는 복합키를 사용할 때 두 가지 방법이 있습니다.</p>
<p>1️⃣ @IdClass</p>
<pre><code>@Entity
@IdClass(OrderItemId.class)
public class OrderItem {

    @Id
    private Long orderId;

    @Id
    private Long productId;

}
</code></pre><pre><code>public class OrderItemId implements Serializable {

    private Long orderId;
    private Long productId;

}</code></pre><p>2️⃣ @EmbeddedId </p>
<pre><code>@Entity
public class OrderItem {

    @EmbeddedId
    private OrderItemId id;

}</code></pre><pre><code>@Embeddable
public class OrderItemId implements Serializable {

    private Long orderId;
    private Long productId;

}</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[주니어 탈출기] IoC와 DI 부수기 ]]></title>
            <link>https://velog.io/@do_dev/%EC%A3%BC%EB%8B%88%EC%96%B4-%ED%83%88%EC%B6%9C%EA%B8%B0-IoC%EC%99%80-DI-%EB%B6%80%EC%88%98%EA%B8%B0</link>
            <guid>https://velog.io/@do_dev/%EC%A3%BC%EB%8B%88%EC%96%B4-%ED%83%88%EC%B6%9C%EA%B8%B0-IoC%EC%99%80-DI-%EB%B6%80%EC%88%98%EA%B8%B0</guid>
            <pubDate>Sun, 31 Aug 2025 11:01:12 GMT</pubDate>
            <description><![CDATA[<h1 id="spring-ioc와-di-깊게-파헤치기-컨테이너-빈-의존성-주입">Spring IoC와 DI 깊게 파헤치기: 컨테이너, 빈, 의존성 주입</h1>
<h2 id="1-들어가기-why">1. 들어가기 (Why?)</h2>
<ul>
<li>이 개념을 공부해야 하는 이유  </li>
<li>실무에서 IoC/DI가 중요한 상황 설명  </li>
</ul>
<hr>
<h2 id="2-기본-개념-정리-what">2. 기본 개념 정리 (What?)</h2>
<ul>
<li><p><strong>IoC (Inversion of Control)</strong>  </p>
<ul>
<li>정의  </li>
<li>특징  </li>
</ul>
</li>
<li><p><strong>DI (Dependency Injection)</strong>  </p>
<ul>
<li>정의  </li>
<li>주입 방식 (생성자, Setter, 필드)  </li>
</ul>
</li>
<li><p><strong>Spring Container &amp; Bean</strong>  </p>
<ul>
<li>Bean 개념  </li>
<li>ApplicationContext 역할  </li>
</ul>
</li>
</ul>
<hr>
<h2 id="3-동작-과정-how">3. 동작 과정 (How?)</h2>
<ul>
<li>IoC 컨테이너가 Bean을 생성하고 관리하는 흐름  </li>
<li>의존성 주입이 일어나는 과정  </li>
<li>Bean LifeCycle (생성 → 주입 → 초기화 → 소멸)  </li>
</ul>
<hr>
<h2 id="4-내부-동작-원리-deep-dive">4. 내부 동작 원리 (Deep Dive)</h2>
<ul>
<li>ApplicationContext 동작 과정  </li>
<li>Bean 등록 및 주입 과정 설명  </li>
<li>AOP/프록시와 연결되는 지점  </li>
</ul>
<hr>
<h2 id="5-실무-인사이트-real-world">5. 실무 인사이트 (Real World)</h2>
<ul>
<li>IoC/DI가 테스트 코드에 미치는 영향  </li>
<li>아키텍처 설계 확장성에 기여하는 사례  </li>
<li>실제 프로젝트에서 부딪힌 문제 (예: 순환 참조)  </li>
</ul>
<hr>
<h2 id="6-자주-하는-실수--베스트-프랙티스">6. 자주 하는 실수 &amp; 베스트 프랙티스</h2>
<ul>
<li>필드 주입(@Autowired) 남용의 문제점  </li>
<li>생성자 주입 권장 이유  </li>
<li>순환 참조 문제 해결 방법  </li>
<li>Bean 등록 중복 방지 전략  </li>
</ul>
<hr>
<h2 id="7-정리-summary">7. 정리 (Summary)</h2>
<ul>
<li>IoC 핵심 요약  </li>
<li>DI 핵심 요약  </li>
<li>오늘 글을 통해 얻을 수 있는 교훈 한 줄  </li>
</ul>
<hr>
<h2 id="8-참고-자료">8. 참고 자료</h2>
<ul>
<li><a href="https://docs.spring.io/spring-framework/reference/core/">Spring 공식 문서</a>  </li>
<li><em>토비의 스프링</em> (이일민 저)  </li>
<li>기타 참고 블로그/자료  </li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[백준  11050번 이항계수 JAVA 풀이
]]></title>
            <link>https://velog.io/@do_dev/%EB%B0%B1%EC%A4%80-11050%EB%B2%88-%EC%9D%B4%ED%95%AD%EA%B3%84%EC%88%98-JAVA-%ED%92%80%EC%9D%B4</link>
            <guid>https://velog.io/@do_dev/%EB%B0%B1%EC%A4%80-11050%EB%B2%88-%EC%9D%B4%ED%95%AD%EA%B3%84%EC%88%98-JAVA-%ED%92%80%EC%9D%B4</guid>
            <pubDate>Sun, 31 Aug 2025 10:56:45 GMT</pubDate>
            <description><![CDATA[<h1 id="boj-11050--이항-계수">BOJ 11050 | 이항 계수</h1>
<ul>
<li><strong>문제 번호</strong>: 11050  </li>
<li><strong>푼 날짜</strong>: 2025-08-31  </li>
</ul>
<hr>
<h2 id="📌-문제-요약">📌 문제 요약</h2>
<p>이항 계수가 무엇인지 이해하고 공식화 하면 되는 문제!</p>
<hr>
<h2 id="💡-접근-아이디어">💡 접근 아이디어</h2>
<ol>
<li>이항계수 공식을 적용하자 </li>
<li>헷갈려서 나누는 분모를 자꾸 여러번틀림 </li>
</ol>
<hr>
<h2 id="🛠️-java-구현">🛠️ Java 구현</h2>
<pre><code class="language-java">package BaekJoon.basic.bruteForce;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Q11050 {

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

        StringTokenizer st = new StringTokenizer(br.readLine());
        int a = Integer.parseInt(st.nextToken());
        int b = Integer.parseInt(st.nextToken());

        if(b ==1){
            System.out.println(a);
            return;
        }else if(b==0){
            System.out.println(1);
            return ;
        }

        int ans = 1;

        for(int i=0; i&lt;b; i++){
            ans *= a-i ;
        }

        ans = ans/makeFactorial(b);

        System.out.println(ans);
    }

    public static int makeFactorial(int n ){
        int result = 1 ;
        for(int i=1; i&lt;=n; i++){
            result *= i;
        }
        return result;
    }
}

</code></pre>
<h2 id="스스로-피드백-해보기">스스로 피드백 해보기</h2>
<h4 id="구글링해서-공식을-다시-알게됐고-오랜만에-팩토리얼-사용">&gt; 구글링해서 공식을 다시 알게됐고 오랜만에 팩토리얼 사용</h4>
]]></description>
        </item>
        <item>
            <title><![CDATA[백준 1032번 명령 프롬프트 JAVA 풀이  ]]></title>
            <link>https://velog.io/@do_dev/B1BOJ-1032-%EB%AA%85%EB%A0%B9-%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8</link>
            <guid>https://velog.io/@do_dev/B1BOJ-1032-%EB%AA%85%EB%A0%B9-%ED%94%84%EB%A1%AC%ED%94%84%ED%8A%B8</guid>
            <pubDate>Sun, 31 Aug 2025 10:04:21 GMT</pubDate>
            <description><![CDATA[<h1 id="boj-1032--명령-프롬프트">BOJ 1032 | 명령 프롬프트</h1>
<ul>
<li><strong>문제 번호</strong>: 1032  </li>
<li><strong>푼 날짜</strong>: 2025-08-31  </li>
</ul>
<hr>
<h2 id="📌-문제-요약">📌 문제 요약</h2>
<p>배열을 입력받아 n개의 배열에서 다른값을 가진 인덱스에 ? 를 넣어주면되는 문제 </p>
<hr>
<h2 id="💡-접근-아이디어">💡 접근 아이디어</h2>
<ol>
<li>숫자를 입력받아보자</li>
<li>숫자만큼 String[] 배열을 입력받아보자 </li>
<li>배열을 비교하고 다르면 ? 를 넣는것을 구현해보자 </li>
</ol>
<hr>
<h2 id="🛠️-java-구현">🛠️ Java 구현</h2>
<pre><code class="language-java">package BaekJoon.basic.bruteForce;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Q1032 {

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));

        int n = Integer.parseInt(br.readLine());

        String[] arr = new String[n];

        for (int i = 0; i &lt; n; i++) {
            arr[i] = br.readLine();
        }

        int len = arr[0].length();

        StringBuilder sb = new StringBuilder();

        for (int i = 0; i &lt; len; i++) {
            char c = arr[0].charAt(i);
            boolean isSame = true;

            for (int j = 1; j &lt; n; j++) {
                if (arr[j].charAt(i) != c) {
                    isSame = false;
                    break; 
                }
            }

            if (isSame) {
                sb.append(c);
            } else {
                sb.append(&quot;?&quot;);
            }
        }


        System.out.println(sb);

    }
}

</code></pre>
<h2 id="스스로-피드백-해보기">스스로 피드백 해보기</h2>
<h4 id="두달만에-문제를-풀어서-브론즈1부터-시도해보았다">&gt; 두달만에 문제를 풀어서 브론즈1부터 시도해보았다</h4>
<h4 id="입출력-연습으로-느껴져서-할만했다">&gt; 입출력 연습으로 느껴져서 할만했다</h4>
<h4 id="근데-2번-틀렸다-flag로-구분할때-좀-헷갈려서-index를-초과되게-append되어-에러가-났었고-break로-불필요한-구분제거했다">&gt; 근데 2번 틀렸다 flag로 구분할때 좀 헷갈려서 index를 초과되게 append되어 에러가 났었고 break로 불필요한 구분제거했다</h4>
]]></description>
        </item>
        <item>
            <title><![CDATA[백준 1107번 리모컨 JAVA 풀이
]]></title>
            <link>https://velog.io/@do_dev/%EB%B0%B1%EC%A4%80-1107%EB%B2%88-%EB%A6%AC%EB%AA%A8%EC%BB%A8-JAVA-%ED%92%80%EC%9D%B4</link>
            <guid>https://velog.io/@do_dev/%EB%B0%B1%EC%A4%80-1107%EB%B2%88-%EB%A6%AC%EB%AA%A8%EC%BB%A8-JAVA-%ED%92%80%EC%9D%B4</guid>
            <pubDate>Tue, 10 Jun 2025 02:19:49 GMT</pubDate>
            <description><![CDATA[<h1 id="boj-1107--리모컨">BOJ) 1107 | 리모컨</h1>
<ul>
<li><strong>문제 번호</strong>: 1107</li>
<li><strong>푼 날짜</strong>: 2025-06-10  </li>
</ul>
<hr>
<h2 id="문제-링크">문제 링크</h2>
<p><a href="https://www.acmicpc.net/problem/1107">https://www.acmicpc.net/problem/1107</a></p>
<h2 id="📌-문제-요약">📌 문제 요약</h2>
<hr>
<h2 id="💡-접근-아이디어">💡 접근 아이디어</h2>
<hr>
<h2 id="다음에-떠-올려-봐야할-것">다음에 떠 올려 봐야할 것</h2>
<hr>
<h2 id="🛠️-java-구현">🛠️ Java 구현</h2>
<pre><code class="language-java">package BaekJoon.basic;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Q1476 {
    public static void main(String[] args) throws IOException {

        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringBuilder sb = new StringBuilder();

        StringTokenizer st = new StringTokenizer(br.readLine());
        int e = Integer.parseInt(st.nextToken());
        int s = Integer.parseInt(st.nextToken());
        int m = Integer.parseInt(st.nextToken());

        int year = 0;

        while(true) {
            year++;  
            if ((year - e) % 15 == 0 &amp;&amp; (year - s) % 28 == 0 &amp;&amp; (year - m) % 19 == 0) {
                break;
            }
        }

        System.out.println(year);

    }
}
</code></pre>
<h2 id="스스로-피드백-해보기">스스로 피드백 해보기</h2>
<h4 id="구현력이-아직-매우-부족하다">&gt; 구현력이 아직 매우 부족하다.</h4>
]]></description>
        </item>
        <item>
            <title><![CDATA[백준 1476번 날짜계산 JAVA 풀이
]]></title>
            <link>https://velog.io/@do_dev/Temp-Title</link>
            <guid>https://velog.io/@do_dev/Temp-Title</guid>
            <pubDate>Mon, 09 Jun 2025 00:21:27 GMT</pubDate>
            <description><![CDATA[<h1 id="boj-1476--날짜-계산">BOJ 1476 | 날짜 계산</h1>
<ul>
<li><strong>문제 번호</strong>: 1476  </li>
<li><strong>푼 날짜</strong>: 2025-06-08  </li>
</ul>
<hr>
<h2 id="📌-문제-요약">📌 문제 요약</h2>
<p>각 주기가 있고 15, 28, 19..
각 주기에 값이 주어지면 공통 주기로 인해 몇년인지 구하는 문제</p>
<hr>
<h2 id="💡-접근-아이디어">💡 접근 아이디어</h2>
<ol>
<li>1 16 16 의 첫번째 테스트 케이스 분석</li>
<li>16년 일때 지구의 값이 1인데 16%15가 1임을 확인</li>
<li>각 주기의 나머지를 통해 년도를 구해본다. </li>
<li>주어진 주기 E,S,M 의 값의 나머지가 모두 0이면 해당 년도가 정답이다. </li>
</ol>
<hr>
<h2 id="🛠️-java-구현">🛠️ Java 구현</h2>
<pre><code class="language-java">package BaekJoon.basic;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.StringTokenizer;

public class Q1476 {
    public static void main(String[] args) throws IOException {

        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringBuilder sb = new StringBuilder();

        StringTokenizer st = new StringTokenizer(br.readLine());
        int e = Integer.parseInt(st.nextToken());
        int s = Integer.parseInt(st.nextToken());
        int m = Integer.parseInt(st.nextToken());

        int year = 0;

        while(true) {
            year++;  
            if ((year - e) % 15 == 0 &amp;&amp; (year - s) % 28 == 0 &amp;&amp; (year - m) % 19 == 0) {
                break;
            }
        }

        System.out.println(year);

    }
}
</code></pre>
<h2 id="스스로-피드백-해보기">스스로 피드백 해보기</h2>
<h4 id="구현력이-아직-매우-부족하다">&gt; 구현력이 아직 매우 부족하다.</h4>
]]></description>
        </item>
        <item>
            <title><![CDATA[백준 3085번 사탕게임 JAVA 풀이]]></title>
            <link>https://velog.io/@do_dev/%EB%B0%B1%EC%A4%80-%EA%B8%B0%EC%B4%88-%EC%8B%9C%EB%A6%AC%EC%A6%88</link>
            <guid>https://velog.io/@do_dev/%EB%B0%B1%EC%A4%80-%EA%B8%B0%EC%B4%88-%EC%8B%9C%EB%A6%AC%EC%A6%88</guid>
            <pubDate>Sun, 08 Jun 2025 07:18:07 GMT</pubDate>
            <description><![CDATA[<h2 id="다시-풀게된-문제-🎯">다시 풀게된 문제 🎯</h2>
<h1 id="boj-3085--사탕-게임-🍬">BOJ 3085 | 사탕 게임 🍬</h1>
<ul>
<li><strong>문제 번호</strong>: 3085  </li>
<li><strong>푼 날짜</strong>: 2025-06-08  </li>
</ul>
<hr>
<h2 id="📌-문제-요약">📌 문제 요약</h2>
<p>N×N 보드에 다양한 색 사탕이 있고,<br>인접한 사탕을 <strong>한 번 교환</strong>할 수 있다.<br>이때 <strong>가장 긴 연속 같은 색 사탕</strong> 길이를 구하는 문제.</p>
<hr>
<h2 id="💡-접근-아이디어">💡 접근 아이디어</h2>
<ol>
<li>보드의 모든 칸 <code>(i,j)</code>에 대해  </li>
<li>우측 <code>(i,j+1)</code> 또는 아래 <code>(i+1,j)</code>와 교환  </li>
<li>교환 후 현재 보드에서 <code>calculateMaxCandies()</code> 호출 → 최대 연속 길이 계산  </li>
<li>원상복구  </li>
<li>최댓값 갱신  </li>
</ol>
<p><code>calculateMaxCandies()</code>는 다음과 같이 구현:</p>
<ul>
<li>각 행과 각 열에 대해  <ul>
<li>인접 같은 컬러면 <code>count++</code>, 다른 컬러면 <code>최대값 갱신</code> 후 <code>count = 1</code> 재설정</li>
</ul>
</li>
</ul>
<hr>
<h2 id="🛠️-java-구현">🛠️ Java 구현</h2>
<pre><code class="language-java">package BaekJoon.basic;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class Q3085 {

    public static void main(String[] args) throws IOException {

        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        StringBuilder sb = new StringBuilder();

        int maxLen = 0;

        int N = Integer.parseInt(br.readLine());

        char[][] board = new char[N][N];

        for (int i = 0; i &lt; N; i++) {
            board[i] = br.readLine().toCharArray();
        }

        for(int i=0; i&lt;N; i++) {
            for(int j=0; j&lt;N; j++){

                if(j &lt;N-1){
                    swap(board,i,j,i,j+1);
                    maxLen=Math.max(maxLen,calculateMaxLength(board,N));
                    swap(board,i,j,i,j+1);
                }

                if(i &lt; N-1){
                    swap(board,i,j, i+1,j);
                    maxLen = Math.max(maxLen,calculateMaxLength(board,N));
                    swap(board,i,j,i+1,j);
                }

            }
        }

        System.out.println(maxLen);

    }

    public static void swap(char[][] board, int x1, int y1 , int x2, int y2){
        char temp = board[x1][y1];
        board[x1][y1] = board[x2][y2];
        board[x2][y2] = temp;
    }

    public static int calculateMaxLength (char[][] board, int N) {
        int maxLen = 0;

        for(int i=0; i&lt; N ; i++){
            int countRow = 1;
            int countCol = 1;

            for(int j=1; j&lt;N; j++) {
                if (board[i][j] == board[i][j - 1]) {
                    countRow++;
                } else {
                    maxLen = Math.max(maxLen, countRow);
                    countRow = 1;
                }

                if (board[j][i] == board[j - 1][i]) {
                    countCol++;
                } else {
                    maxLen = Math.max(countCol, maxLen);
                    countCol = 1;
                }
            }

            maxLen= Math.max(maxLen,countRow);
            maxLen = Math.max(maxLen,countCol);
        }

        return maxLen

    }
}</code></pre>
<h2 id="스스로-피드백-해보기">스스로 피드백 해보기</h2>
<h4 id="구현력이-아직-매우-부족하다">&gt; 구현력이 아직 매우 부족하다.</h4>
<h4 id="문제를-제대로-읽지않는다">&gt; 문제를 제대로 읽지않는다.</h4>
<h4 id="정의-내리는것을-귀찮아하지말아라">&gt; 정의 내리는것을 귀찮아하지말아라.</h4>
]]></description>
        </item>
    </channel>
</rss>