<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>heeun_98.log</title>
        <link>https://velog.io/</link>
        <description>기록하는 공간</description>
        <lastBuildDate>Wed, 24 Jun 2026 08:44:02 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>heeun_98.log</title>
            <url>https://velog.velcdn.com/images/heeun_98/profile/54311054-48a9-4e74-802e-1d8147026981/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. heeun_98.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/heeun_98" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Transactional Outbox 개선기 (2) — 커넥션 점유와 복구 로직 걷어내기]]></title>
            <link>https://velog.io/@heeun_98/Transaction-Outbox-%EB%8F%84%EC%9E%85%EA%B8%B0-2</link>
            <guid>https://velog.io/@heeun_98/Transaction-Outbox-%EB%8F%84%EC%9E%85%EA%B8%B0-2</guid>
            <pubDate>Wed, 24 Jun 2026 08:44:02 GMT</pubDate>
            <description><![CDATA[<p>1편에서는 <code>@Transactional</code> 안에서 Admin 서버로 직접 API를 호출하던 구조를 Transactional Outbox 패턴으로 바꾸고, AFTER_COMMIT 즉시 발송과 30초 폴링 재시도, 그리고 SKIP LOCKED 병렬 조회까지 구현했다. 마지막에 두 가지 문제가 여전히 남아 있었다. 하나는 인스턴스 수와 TPS에 따라 BATCH_SIZE를 매번 손봐야 한다는 것이고, 다른 하나는 폴링 스케줄러가 여전히 트랜잭션 내부에서 API를 호출한다는 것이었다.</p>
<p>2편은 이 두 문제를 이어받아, 1편에서 만든 폴링 구조(V1)를 V2와 V3로 다시 손본 과정의 기록이다.</p>
<h3 id="무엇이-문제였나-v1-복기">무엇이 문제였나 (V1 복기)</h3>
<p>1편에서 만든 폴링 스케줄러를 V1이라 부르자. 구조를 다시 떠올려보면, <code>process()</code> 메서드가 하나의 트랜잭션 안에서 조회 → 외부 API 호출 → 삭제를 모두 처리한다.</p>
<pre><code class="language-java">@Transactional
public void process() {
    // 1. Outbox 조회 (SKIP LOCKED)
    List&lt;OutboxEvent&gt; events = outboxRepository.findByCreatedAtBeforeWithSkipLocked(threshold);
    // 2. 각 이벤트마다 Admin API 호출
    ProcessingResult result = processAllEvents(events);
    // 3. 성공한 이벤트 삭제
    deleteProcessedEvents(result.toDelete());
}</code></pre>
<p>1편 마지막에서 &quot;단일 스레드로 호출되지만 이 역시 트랜잭션 내부에서 API 호출을 한다&quot;고 적었던 바로 그 부분이다. 이게 왜 문제인지, 이번엔 숫자로 따져봤다.</p>
<p>트랜잭션이 열려 있는 동안 스케줄러 스레드는 DB 커넥션을 쥐고 있다. 그런데 그 트랜잭션 한가운데에 외부 API 호출이 들어가 있으니, Admin 서버가 느려지거나 죽으면 응답(혹은 timeout)을 기다리는 내내 커넥션을 붙잡게 된다. 문제는 이 점유 시간이 조회한 이벤트 수에 비례한다는 점이다.</p>
<p>1편의 가정대로 미션 기록이 초당 1건씩 쌓이고, 외부 API timeout을 3초로 잡았다고 하자. 최악의 경우 한 번의 <code>process()</code>에서 30건을 처리하므로, <code>3초(timeout) × 30건</code>, 즉 최대 90초 동안 커넥션 하나를 붙잡는다. 물론 이건 모든 호출이 timeout까지 가는 최악의 가정이다.</p>
<p>여기에 더 고약한 문제가 겹친다. 스케줄링 주기는 30초인데 한 번 처리에 최대 90초가 걸린다면, 이전 실행이 끝나기 전에 다음 실행이 시작된다.</p>
<pre><code>0초   →  90초 : 커넥션 1번 점유
30초  → 120초 : 커넥션 2번 점유 (SKIP LOCKED로 다른 레코드 처리)
60초  → 150초 : 커넥션 3번 점유
...</code></pre><p>SKIP LOCKED 덕분에 각 실행이 서로 다른 레코드를 잡으니 중복 처리는 없지만, 그 대신 커넥션 점유가 겹겹이 쌓인다. Admin 장애가 길어질수록 풀려나지 않는 커넥션이 늘어나고, 결국 커넥션 풀 고갈로 이어진다. 스케줄러 하나의 장애가 PeakTime 서비스 전체의 DB 커넥션을 마르게 할 수 있는 구조였던 것이다.</p>
<p>원인은 1편에서부터 일관되게 같았다. DB 커넥션을 쥔 채로 외부 API를 기다린다는 것. 1편에서는 <code>@TransactionalEventListener</code>의 즉시 발송 경로에서 이 문제를 트랜잭션 밖으로 빼는 데 성공했지만, 폴링 재시도 경로에는 여전히 트랜잭션이 남아 있었다. 이번엔 이 폴링 쪽을 손봐야 했다.</p>
<h3 id="v2-트랜잭션-밖으로-api-호출을-꺼내다">V2: 트랜잭션 밖으로 API 호출을 꺼내다</h3>
<p>가장 직관적인 해결책은 즉시 발송 경로에서 했던 것처럼, 폴링 경로에서도 외부 API 호출을 트랜잭션 밖으로 빼는 것이었다. 그래서 하나였던 트랜잭션을 둘로 쪼갰다.</p>
<pre><code>Tx1: 이벤트 조회 + 상태를 PROCESSING으로 변경 → 커밋 (커넥션 반환)
     ─ 외부 API 호출 (트랜잭션 없음, DB 커넥션 잡지 않음) ─
Tx2: 성공 시 이벤트 삭제 / 실패 시 상태를 READY로 복구</code></pre><p>핵심은 가운데 외부 API 호출 구간이다. 이 시점에는 트랜잭션이 닫혀 있으므로, Admin 서버가 아무리 느려도 DB 커넥션을 점유하지 않는다. V1의 가장 큰 문제였던 커넥션 장기 점유는 이렇게 해결됐다.</p>
<p>그런데 이 구조는 새로운 문제를 데려왔다. 외부 API를 호출하기 전에 이벤트를 PROCESSING 상태로 커밋해버린다는 점이다. 만약 Tx1을 커밋한 직후 외부 API를 호출하기도 전에 서버가 다운되거나, 외부 API는 성공했는데 Tx2(삭제)가 실패한다면 어떻게 될까? 두 경우 모두 이벤트가 <code>PROCESSING</code> 상태에 갇혀버린다. 누가 처리 중인지 알 수 없고, 스스로 <code>READY</code>로 돌아오지도 못한다. 이미 커밋된 상태라 롤백으로 되돌릴 수도 없다.</p>
<p>그래서 갇힌 이벤트를 되살릴 복구 스케줄러(<code>ProcessingRecoveryScheduler</code>)가 필요해졌다. 이 복구 로직은 이벤트가 일정 시간(예: 90초) 이상 <code>PROCESSING</code> 상태에 머물러 있으면 멈춘 것으로 간주하고 <code>READY</code>로 되돌리는 방식으로 동작한다.</p>
<p>여기서 마음에 걸리는 지점이 있었다. 이 복구 로직은 결국 &quot;얼마나 오래 머물렀으면 죽은 것으로 볼 것인가&quot;라는 timeout 기준에 의존한다. 이 임계값이 너무 짧으면 정상 처리 중인 이벤트를 멋대로 되살려 중복 호출이 발생하고(1편에서 멱등성으로 고민했던 바로 그 중복 요청이다), 너무 길면 실제로 멈춘 이벤트가 한참 방치된다. 적절한 값을 찾는 건 결국 추측과 튜닝의 영역이었다.</p>
<p>정리하면 V2는 커넥션 점유는 해결했지만, 그 대가로 <code>PROCESSING</code>이라는 중간 상태와 그것을 감시하는 복구 스케줄러라는 상태 관리 복잡도를 떠안았다. 문제를 옮겨놓은 것에 가까웠다.</p>
<table>
<thead>
<tr>
<th align="left">구분</th>
<th align="left">V1 (1편 폴링 구조)</th>
<th align="left">V2</th>
</tr>
</thead>
<tbody><tr>
<td align="left">커넥션 점유</td>
<td align="left">외부 API 포함 전체 점유</td>
<td align="left">조회/삭제 시에만 점유</td>
</tr>
<tr>
<td align="left">트랜잭션</td>
<td align="left">1개</td>
<td align="left">2개로 분리</td>
</tr>
<tr>
<td align="left">복구 로직</td>
<td align="left">불필요</td>
<td align="left">필요 (timeout 기준)</td>
</tr>
<tr>
<td align="left">상태 관리</td>
<td align="left">단순 (조회/삭제)</td>
<td align="left">PROCESSING 상태 추가</td>
</tr>
</tbody></table>
<h3 id="왜-복구-스케줄러가-필요했을까">왜 복구 스케줄러가 필요했을까</h3>
<p>복구 스케줄러의 timeout 임계값을 몇 초로 잡을지 고민하다가, 문득 질문이 바뀌었다. 복구 스케줄러가 왜 필요했지? <code>PROCESSING</code> 상태를 DB에 커밋했기 때문이다. 한번 커밋해버리니 롤백으로 되돌릴 수 없고, 그래서 갇힌 데이터를 누군가 따로 되살려야 했다.</p>
<p>그럼 <code>PROCESSING</code>을 DB에 커밋하지 않으면 되지 않을까? 처리할 이벤트 목록을 DB가 아닌 다른 곳에 잠깐 들고 있을 수만 있다면, &quot;처리 중&quot;이라는 상태를 영속화할 필요도, 그것을 감시할 복구 스케줄러도 필요 없어진다. 그 &quot;다른 곳&quot;으로 Redis를 떠올렸다.</p>
<p>그리고 이 발상은 1편에서 남긴 첫 번째 숙제와도 맞닿아 있었다. 1편에서는 <code>SKIP LOCKED</code>로 여러 인스턴스가 레코드를 나눠 가졌지만, 그러려면 인스턴스 수와 TPS에 맞춰 <code>BATCH_SIZE</code>를 매번 계산해 박아넣어야 했다. 만약 처리할 목록을 Redis라는 공용 공간에 올려두고 모든 인스턴스가 거기서 알아서 하나씩 집어가게 하면, 인스턴스가 몇 대든 배치 사이즈를 신경 쓸 필요가 없어진다.</p>
<h3 id="redis-set을-중간-큐로">Redis SET을 중간 큐로</h3>
<p>핵심 아이디어는 DB와 외부 API 호출을 완전히 분리하고, 그 사이에 Redis SET을 중간 큐로 두는 것이다. 처리 흐름을 적재(Produce)와 소비(Consume) 두 단계로 나눴다.</p>
<p align="center">
  <img src="https://velog.velcdn.com/images/heeun_98/post/250d7097-172b-4745-b33b-d8c59d018c70/image.png" width="400">
</p>

<p>폴러는 이 두 단계를 순서대로 호출하는 얇은 진입점이다. 한 인스턴스가 Redis SET에 이벤트를 장전하고, 그다음 모든 인스턴스가 그 SET을 함께 비운다.</p>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxPoller {

    private final OutboxProducer producer;
    private final OutboxConsumer consumer;

    @Scheduled(fixedDelay = 3_000)
    public void poll() {
        // 1. 장전 (Producer면 장전, 아니면 대기)
        producer.loadEventsToRedisWithSync();

        // 2. 소비 (모든 인스턴스가 동시에 병렬 처리)
        consumer.consumeAll();
    }
}</code></pre>
<h4 id="produce--분산락으로-한-인스턴스만-적재">Produce — 분산락으로 한 인스턴스만 적재</h4>
<p>먼저 Produce 단계에서는 분산락으로 한 인스턴스만 적재한다. 1편에서 다중 인스턴스 환경의 중복 발행을 막을 때, 분산 락을 한 번 후보로 올렸다가 &quot;한 대만 일하게 되어 병렬성을 못 살린다&quot;는 이유로 SKIP LOCKED를 택했었다. 이번에는 분산 락을 조회 단계에만 국한해서 쓴다. 락을 획득한 대표 인스턴스 하나만 Outbox 테이블을 조회해 SADD로 Redis SET에 적재하고, 나머지는 락 대기 상태로 빠진다.</p>
<p>여기서 한 가지 풀어야 할 문제가 있었다. 락을 얻지 못한 인스턴스를 단순히 대기시키기만 하면, 그 인스턴스는 적재가 끝나기도 전에 다음 소비 단계로 넘어가 텅 빈 SET을 보게 될 수 있다. 그래서 락(<code>RLock</code>)에 더해 카운트다운 래치(<code>RCountDownLatch</code>)를 함께 사용했다.</p>
<pre><code class="language-java">public boolean loadEventsToRedisWithSync() {
    RLock lock = redissonClient.getLock(PRODUCER_LOCK_KEY);
    RCountDownLatch latch = redissonClient.getCountDownLatch(PRODUCER_LATCH_KEY);

    boolean isProducer;
    try {
        isProducer = lock.tryLock(0, 30, TimeUnit.SECONDS);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return false;
    }

    if (isProducer) {
        // 락을 잡은 인스턴스: Producer로서 Redis SET에 장전
        boolean success = false;
        try {
            latch.trySetCount(1);
            doLoadEventsToRedis();
            success = true;
        } catch (Exception e) {
            log.error(&quot;Redis 장전 실패&quot;, e);
        } finally {
            latch.countDown();   // 장전 완료를 알림
            lock.unlock();
        }
        return success;
    } else {
        // 락을 못 잡은 인스턴스: 장전이 끝날 때까지 대기
        try {
            latch.await(30, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return false;
    }
}</code></pre>
<p>동작을 정리하면 이렇다. <code>tryLock(0, ...)</code>은 대기 시간을 0으로 줘서, 락을 즉시 못 잡으면 기다리지 않고 바로 실패를 반환한다. 그래서 가장 먼저 도착한 한 인스턴스만 <code>isProducer == true</code>가 되어 Producer가 되고, 나머지는 곧장 <code>else</code> 분기로 빠진다.</p>
<p>여기서 일반적인 락과 다른 점이 드러난다. 보통 락은 &quot;못 잡았으면 잡힐 때까지 기다렸다가 임계 영역에 들어간다.&quot; 즉 모두가 결국 같은 작업(적재)을 차례로 수행한다. 하지만 여기서는 락을 못 잡은 인스턴스가 임계 영역(적재)에 끝내 들어가지 않는다. 적재는 오직 Producer 한 대만 하고, 나머지는 그 작업이 끝나기를 기다리기만 한다. 락을 &quot;누가 임계 영역에 들어가느냐&quot;가 아니라 &quot;누가 Producer 역할을 맡느냐&quot;를 정하는 용도로 쓴 셈이다.</p>
<p>그 &quot;기다리기&quot;를 담당하는 게 카운트다운 래치다. Producer는 적재를 시작하기 전에 래치 카운트를 1로 세팅하고, 적재가 끝나면(<code>finally</code>) <code>countDown()</code>으로 0으로 만든다. 락을 못 잡은 인스턴스들은 <code>latch.await()</code>에서 이 카운트가 0이 될 때까지 멈춰 있다가, Producer의 적재가 끝나는 순간 한꺼번에 풀려난다. 덕분에 모든 인스턴스가 &quot;SET이 채워진 시점&quot;에 맞춰 소비 단계로 진입하게 된다. 락이 &quot;한 명만 적재하게&quot; 만든다면, 래치는 &quot;나머지가 그 적재를 기다리게&quot; 만드는 셈이다.</p>
<p>조회를 한 인스턴스로 몰아도 병렬성이 죽지 않는 이유는, 무거운 작업인 외부 API 호출을 조회와 분리했기 때문이다. DB 조회는 짧고, 정작 오래 걸리는 API 호출은 뒤이은 Consume 단계에서 모든 인스턴스가 나눠서 한다.</p>
<p>여기서 Redis 자료구조로 SET을 고른 이유가 있다. SET은 같은 값을 중복으로 담지 않으므로, 혹시 같은 이벤트가 두 번 적재되려 해도 SADD가 멱등하게 동작해 중복 장전이 자연스럽게 방지된다. 분산 락으로 1차 방어, SET의 멱등성으로 2차 방어를 한 셈이다.</p>
<h4 id="consume--모든-인스턴스가-spop으로-병렬-소비">Consume — 모든 인스턴스가 SPOP으로 병렬 소비</h4>
<p>다음으로 Consume 단계에서는 모든 인스턴스가 SPOP으로 병렬 소비한다. 래치가 풀리면 모든 인스턴스가 Redis SET에서 이벤트를 하나씩 꺼낸다. 이때 사용하는 SPOP은 SET에서 임의의 원소를 꺼내면서 동시에 제거하는 원자적 연산이다. 덕분에 여러 인스턴스가 동시에 SPOP을 호출해도 같은 이벤트를 두 번 꺼내가는 일이 없다. 1편에서 SKIP LOCKED로 얻었던 &quot;서로 겹치지 않게 나눠 가져간다&quot;는 성질을, 이번엔 Redis가 대신 보장해주는 것이다.</p>
<p>각 인스턴스는 꺼낸 이벤트로 Admin API를 호출한다. 이 호출 과정에는 트랜잭션도, DB 커넥션도 전혀 관여하지 않는다. Admin 서버가 아무리 느려도 DB 커넥션 풀에는 영향이 없다.</p>
<p>이 구조가 가져다준 변화는 세 가지다.</p>
<p>첫째, 외부 API 호출 시 DB 커넥션을 전혀 잡지 않는다. 1편부터 이어진 커넥션 장기 점유 문제가 구조적으로 사라졌다. 폴링 경로에 남아 있던 마지막 트랜잭션까지 걷어낸 것이다. 1편의 두 번째 숙제가 여기서 해결됐다.</p>
<p>둘째, 복구 스케줄러가 필요 없다. &quot;처리 중&quot; 상태를 DB에 커밋하지 않기 때문이다. 이벤트는 Redis SET에 있거나(아직 처리 전), SPOP으로 꺼내져 처리되거나 둘 중 하나다. V2에서 가장 거슬렸던 timeout 임계값 고민이 문제의 근원과 함께 통째로 사라졌다.</p>
<p>셋째, 인스턴스 수와 무관하게 자동으로 분산 처리된다. SPOP이 원자적으로 분배해주므로, 인스턴스를 늘리면 늘린 만큼 병렬로 소비한다. 1편에서 BATCH_SIZE를 인스턴스 수와 TPS로 계산해 박아야 했던 부담이 사라졌다. 1편의 첫 번째 숙제가 여기서 해결됐다.</p>
<h3 id="세-버전-비교">세 버전 비교</h3>
<table>
<thead>
<tr>
<th align="left">버전</th>
<th align="left">커넥션 점유</th>
<th align="left">트랜잭션</th>
<th align="left">복구 로직</th>
<th align="left">분산 처리</th>
</tr>
</thead>
<tbody><tr>
<td align="left">V1</td>
<td align="left">외부 API 포함 전체 점유</td>
<td align="left">1개</td>
<td align="left">불필요</td>
<td align="left">SKIP LOCKED</td>
</tr>
<tr>
<td align="left">V2</td>
<td align="left">조회/삭제 시에만 점유</td>
<td align="left">2개 분리</td>
<td align="left">필요</td>
<td align="left">SKIP LOCKED</td>
</tr>
<tr>
<td align="left">V3</td>
<td align="left">삭제 시에만 개별 점유</td>
<td align="left">없음</td>
<td align="left">불필요</td>
<td align="left">SPOP 자동 분배</td>
</tr>
</tbody></table>
<p>표를 보면 V1에서 V3로 가면서 점유, 트랜잭션, 복구 로직이 차례로 줄어든다. 특히 V3에 와서는 트랜잭션과 복구 로직이 모두 사라졌다. 복잡도를 더해 문제를 막는 게 아니라, 문제의 원인 자체를 구조에서 걷어낸 결과다. 그리고 그 과정에서 1편 말미에 남겼던 두 숙제, 배치 사이즈 수동 조정과 폴링 경로의 트랜잭션도 함께 정리됐다.</p>
<h3 id="정리하며">정리하며</h3>
<p>1편을 마치며 &quot;이 두 가지 문제점에 대해서는 추가로 고민해봐야겠다&quot;고 적었는데, 그 고민을 따라온 것이 이번 2편이었다.</p>
<p>이번 개선에서 가장 인상 깊었던 건 V2에서 V3로 넘어가는 발상의 전환이었다. V2 단계에서 나는 &quot;복구 스케줄러의 timeout을 몇 초로 잡아야 할까&quot;를 고민하고 있었다. 그건 주어진 구조 안에서 더 나은 파라미터를 찾는 질문이었다. 하지만 한 발 물러서서 &quot;애초에 이 복구 스케줄러가 왜 필요하지?&quot;라고 물었을 때, 비로소 진짜 원인이 보였다. PROCESSING 상태를 DB에 커밋한다는 결정이었다. 그 결정을 바꾸자, 그 위에 쌓아 올렸던 복구 로직과 timeout 고민이 한꺼번에 사라졌다.</p>
<p>돌이켜보면 1편의 BATCH_SIZE 튜닝도, 2편 V2의 timeout 튜닝도 결국 같은 종류의 고민이었다. 주어진 구조를 그대로 둔 채 &quot;숫자를 얼마로 맞출까&quot;를 묻는 것. 문제를 만났을 때 &quot;이걸 어떻게 막을까&quot;를 먼저 떠올리기 쉽고, 그래서 보호 장치를 덧대거나 매직 넘버를 조정하는 방향으로 가게 된다. 하지만 때로는 &quot;이 문제가 왜 생기는 구조지&quot;를 묻고 그 원인을 제거하는 편이 훨씬 단순하고 단단한 답을 준다는 걸 이번에 배웠다. 좋은 설계는 더 정교한 방어막을 쌓는 게 아니라, 방어할 필요 자체를 없애는 쪽에 가깝다는 생각이 들었다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Transactional Outbox 적용기 (1) ]]></title>
            <link>https://velog.io/@heeun_98/Transaction-Outbox-%EB%8F%84%EC%9E%85%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/Transaction-Outbox-%EB%8F%84%EC%9E%85%EA%B8%B0</guid>
            <pubDate>Sun, 17 May 2026 11:28:47 GMT</pubDate>
            <description><![CDATA[<p>PeakTime 서비스에서는 미션 완료 시 Admin 서버로 완료 기록을 전송한다. Admin은 이 기록으로 미션 수행 랭킹을 집계하고, 상위권 사용자에게 보상을 제공해야 한다. Admin과 API 서버가 별도의 DB를 사용하는 구조이기 때문에, API 서버가 데이터를 직접 전송해야 한다.</p>
<h3 id="문제-상황">문제 상황</h3>
<p>PeakTime의 주요 기능은 미션을 수행하고 그 기록을 남기는 것이다. 아래 코드는 미션을 수행할 때마다 호출되는데, 미션이 수행될 때마다 <code>@Transactional</code> 내부에서 Admin 서버로 미션 완료 기록을 요청한다.</p>
<pre><code class="language-java">@Transactional
public UserMissionCompletionResponse completeDailyMission(Long userId, Long missionId, UserMissionCompletionRequest request) {
    User user = findUser(userId);
    LocalDate today = LocalDate.now();

    DailyMission dailyMission = getDailyMission(missionId, today);
    Mission mission = dailyMission.getMission();

    validateSameMission(missionId, user);

    SolarTerm solarTerm = getCurrentSolarTerm(today);

    UserMissionCompletion completion = userMissionCompletionRepository.save(
            UserMissionCompletion.create(user, mission, solarTerm, MissionType.DAILY,
                    request.objectKey(), request.memo())
    );

    dailyMissionRepository.incrementParticipantCount(dailyMission.getId());

    // 문제: @Transactional 내부에서 HTTP 호출
    // → DB 커넥션을 계속 점유한 채로 외부 API 응답 대기
    // → 커넥션 풀 고갈 위험
    sendMissionLogToAdmin(user, missionId, solarTerm, completion);

    return UserMissionCompletionResponse.from(completion);
}</code></pre>
<p>그렇다면 트랜잭션 안에서 Admin으로 API를 호출하면 어떤 문제가 발생할까? 크게 두 가지다. 하나는 외부 API 호출에는 성공했지만 이후 트랜잭션이 롤백되는 경우이고, 다른 하나는 외부 API 응답이 지연될 때 커넥션이 고갈되는 경우다.</p>
<p>PeakTime은 사용자마다 미션 기록 횟수를 Admin 페이지에서 순위에 맞게 보여줘야 한다. 그런데 사용자가 미션 기록을 수행해 Admin 서버 요청에는 성공했지만 이후 트랜잭션 내에서 롤백이 일어나면, Admin에 보이는 횟수와 실제 사용자 기록 횟수의 정합성이 깨진다.</p>
<p>또한 미션 기록은 PeakTime의 핵심 기능이다. 기록이 많아져 트랜잭션 호출이 잦아지는데 그때마다 <code>@Transactional</code> 내부에서 커넥션을 장기간 점유한다면, 커넥션이 고갈될 수 있다.</p>
<p>문제 상황을 다시 정리하면 두 가지다. API 호출과 트랜잭션 안의 도메인 로직이 원자적으로 묶여 있지 않다는 것, 그리고 Admin 서버 상황에 따라 DB 커넥션을 장기간 점유한다는 것이다.</p>
<p>그렇다면 어떻게 원자적으로 묶을 수 있을까? 여기서 Transactional Outbox 패턴이 등장한다.</p>
<h3 id="transactional-outbox-패턴이란">Transactional Outbox 패턴이란</h3>
<p>핵심 아이디어는 데이터베이스의 원자성을 활용하는 것이다.</p>
<p>하나의 트랜잭션은 원자성을 보장하기 때문에 반드시 전부 롤백되거나 전부 커밋된다. 따라서 핵심 비즈니스 로직과 외부로 보낼 데이터(이벤트)를 하나의 트랜잭션으로 묶어 처리하면 된다.</p>
<p>구체적으로는, 외부로 보내야 할 데이터나 이벤트를 메인 데이터베이스의 별도 테이블(Outbox)에 함께 저장하고, 별도의 워커(Worker)가 그 테이블의 레코드를 읽어 외부로 요청을 보내는 방식이다.</p>
<pre><code>Before (트랜잭션 내 HTTP 호출):
completeMission() → DB 저장 + HTTP 호출 (Admin 서버)  ← DB 커넥션 점유한 채 대기

After (이벤트 + Outbox):
completeMission() → DB 저장 + Outbox 저장 + 이벤트 발행 (같은 트랜잭션)</code></pre><h3 id="api-요청은-언제-하는-게-좋을까">API 요청은 언제 하는 게 좋을까</h3>
<p>API를 보내는 방법은 두 가지가 있다. 하나는 커밋 이후 비동기로 해당 이벤트를 조회해 API를 요청하는 방법이고, 다른 하나는 주기적으로 Outbox 테이블을 조회해 API를 요청하는 방법이다.</p>
<p>커밋 직후 비동기로 호출하면, 주기적으로 폴링해서 보내는 것보다 결과를 더 빨리 알 수 있다. 그래서 비동기 호출을 마다할 이유는 없다고 판단했다. 그리고 여기서 실패한 경우는 스케줄링을 통해 Outbox를 조회한 뒤 다시 처리하면 되지 않을까? 우선 비동기 요청을 처리하기 위한 로직을 구현해보았다.</p>
<pre><code class="language-java">@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
public void handle(MissionCompletedEvent event) {
    UserMissionCompletion completion = findCompletion(event.getCompletionId());
    MissionLogPayload payload = createPayload(completion);

    SendResult result = adminClient.sendMissionLog(payload, event.getOutboxId());

    handleSendResult(event, result, completion);
}</code></pre>
<p>이 메서드에는 의도적으로 @Transactional을 붙이지 않았다. 이벤트를 조회해 페이로드를 만들고, Admin으로 전송한 뒤 그 결과(<code>SendResult</code>)에 따라 Outbox를 삭제하거나 재시도를 예약하는 것이 전부라, 별도의 트랜잭션이 없어도 괜찮다고 판단했다. 만약 트랜잭션이 있다면, 앞서 본 &quot;메서드 내부에서 API를 호출하는&quot; 것과 똑같은 문제가 발생한다. 이 이벤트 리스너는 커밋 이후에 실행되므로 요청 수와 동일한 횟수로 호출되는데, 요청이 많아지면 결국 트랜잭션 내부에서 외부 API를 호출하는 꼴이 되어 커넥션 고갈로 이어질 수 있다.</p>
<p>전송 결과 분기는 앞서 정의한 실패 케이스(영구/일시)에 따라 <code>handleSendResult</code>에서 처리한다. 성공이나 영구적 실패라면 더 이상 재시도가 무의미하므로 Outbox에서 삭제하고, 일시적 실패라면 레코드를 남겨 폴링 스케줄러가 나중에 다시 시도하도록 둔다.</p>
<p>위 코드에서는 <code>@Async</code>로 별도의 스레드 풀에서 비동기로 처리된다. 만약 여기에 트랜잭션이 걸려 있다면, 작업 중인 워커 스레드는 커넥션과 1대1로 묶일 수밖에 없다. 그러면 스레드 풀이 고갈되기 전에 커넥션 풀이 먼저 고갈될 가능성이 생긴다. 문득 든 생각은, 그렇다면 스레드 풀의 개수로 커넥션 소모량을 제어할 수 있지 않을까 하는 것이었다. 다만 지금 구조에서는 트랜잭션이 필요하지 않으니 이 고민은 넘어가기로 했다.</p>
<p>그런데 이 비동기 구조에서는 다음과 같은 상황들이 발생할 수 있다.</p>
<ul>
<li><code>sendLogToAdmin</code>에서 외부 API 요청은 성공했지만, Outbox 테이블에서 delete에 실패한 경우</li>
<li>Admin 쪽 로직에서 통계 기록은 성공했지만, 네트워크 문제로 알 수 없는 예외가 발생한 경우</li>
<li>실제로 API 요청이 실패해 Outbox 테이블에 레코드가 그대로 남은 경우</li>
</ul>
<h3 id="실패-케이스-나누기">실패 케이스 나누기</h3>
<p>Admin 서버에 요청을 보낸 뒤, API 서버 입장에서는 그 요청이 실제로 성공했는지 알 수 없을 때가 있다. 외부 서버는 정상적으로 처리했더라도 응답이 돌아오는 과정에서 네트워크 문제로 예외가 날 수 있고, 타임아웃으로도 예외가 발생할 수 있다. 흔히 이런 경우를 &quot;알 수 없는 예외 상황&quot;이라 부른다.</p>
<p>알 수 없는 예외 상황을 어떻게 다룰지는 정책에 따라 다르겠지만, 정합성이 중요한 상황에서는 재시도가 필수적이다. PeakTime은 Admin 서버로 보낸 미션 기록을 집계해 순위를 매기고 그것을 기반으로 보상을 제공하기 때문에, 재시도가 반드시 필요하다고 판단했다. 그래서 실패 상황을 두 가지로 정의했다.</p>
<p align="center">
  <img src="https://velog.velcdn.com/images/heeun_98/post/0c0ba4a4-a489-4910-819c-974e8f508f47/image.png" width="500">
</p>

<p><strong>먼저 영구적 실패(Permanent Failure)는 비즈니스 로직이나 검증에 의한 실패다.</strong> Admin 서버의 도메인 규칙을 위반했거나 요청 페이로드가 잘못되어 4xx 에러(Client Error)를 반환하는 경우가 여기에 해당한다. 이 케이스는 같은 요청을 아무리 재시도해도 계속 같은 실패 응답을 받기 때문에, 재시도할 이유가 없다.</p>
<p><strong>다음으로 일시적 실패(Transient Failure)는 일시적인 네트워크 오류나 타임아웃에 의한 실패다.</strong> 네트워크 단절, 커넥션 타임아웃, 혹은 Admin 서버 자체의 일시적인 문제로 인한 5xx 에러(Server Error)가 이에 해당한다. 시스템 환경이 정상화되면 재시도를 통해 성공할 가능성이 있는 케이스다.</p>
<p>이 분류에 맞게 RestClient로 예외 처리를 구현했다. 아래는 Admin 서버로 요청을 보낸 뒤, 예외의 성격에 따라 실패 케이스를 분류하는 메서드다. 기본적으로 4xx 에러는 재시도가 무의미한 영구적 실패로 분류하지만, 충돌 등으로 일시적으로 발생할 수 있는 409 Conflict만큼은 예외적으로 일시적 실패로 간주해 재시도 대상에 포함했다.</p>
<pre><code class="language-java">public SendResult sendMissionLogWithResult(MissionLogPayload payload, Long eventId) {
    try {
        AdminApiResponse response = adminRestClient.post()
                .uri(&quot;/api/stats/mission-log&quot;)
                .contentType(MediaType.APPLICATION_JSON)
                .body(payload)
                .retrieve()
                .body(AdminApiResponse.class);

        log.info(&quot;미션 로그 전송 성공: missionId={}&quot;, payload.missionId());
        return new SendResult.Success(eventId);

    } catch (HttpClientErrorException.Conflict e) {
        // 409 Conflict: 동시에 같은 요청이 처리 중 → 일시 실패 (재시도 필요)
        log.info(&quot;미션 로그 처리 중 (409 Conflict): missionId={}&quot;, payload.missionId());
        return new SendResult.TransientFailure(eventId, &quot;409 Conflict: 처리 중&quot;);

    } catch (HttpClientErrorException e) {
        // 4xx (409 제외): 클라이언트 에러 → 영구 실패 (재시도 무의미)
        log.warn(&quot;미션 로그 클라이언트 에러 (영구 실패): missionId={}, status={}&quot;,
                payload.missionId(), e.getStatusCode());
        return new SendResult.PermanentFailure(eventId, &quot;4xx: &quot; + e.getStatusCode());

    } catch (HttpServerErrorException e) {
        // 5xx: 서버 에러 → 일시 실패 (재시도 가능)
        log.warn(&quot;미션 로그 서버 에러 (일시 실패): missionId={}, status={}&quot;,
                payload.missionId(), e.getStatusCode());
        return new SendResult.TransientFailure(eventId, &quot;5xx: &quot; + e.getStatusCode());

    } catch (Exception e) {
        // 네트워크 오류, 타임아웃 등 → 일시 실패 (재시도 가능)
        log.error(&quot;미션 로그 전송 실패 (일시 실패): {}&quot;, e.getMessage());
        return new SendResult.TransientFailure(eventId, e.getMessage());
    }
}</code></pre>
<p>각 예외의 성격에 맞게 <code>SendResult</code> 내부 클래스에 상태와 이벤트 ID를 담아 반환하고, 이후 스케줄러나 배치 영역에서 이 반환값을 기반으로 Outbox 테이블의 데이터를 삭제하거나 다음 재시도를 예약한다.</p>
<h3 id="after_commit--폴링-재시도">AFTER_COMMIT + 폴링 재시도</h3>
<p>정리하면, 커밋 이후 비동기로 API 요청을 보내고(즉시 발송), 여기서 실패해 Outbox 테이블에 남은 레코드는 30초마다 폴링 재시도로 다시 요청을 보낸다. 동작 흐름은 아래와 같다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/5ddb5d6b-84b8-4aa6-b781-9db5e84be839/image.png" alt="AFTER_COMMIT 즉시 발송과 폴링 재시도 동작 흐름"></p>
<p>먼저 재시도를 담당하는 스케줄러다. 스케줄러에서 커넥션 점유 시간을 측정하기 위해, 실제 처리는 별도의 빈으로 분리해 호출하는 형태로 구현했다.</p>
<pre><code class="language-java">@Scheduled(fixedDelay = 30000)  // 30초마다
public void pollAndProcess() {
    long startTime = System.currentTimeMillis();
    log.info(&quot;[Task Start] 시작 시각: {}&quot;, LocalDateTime.now());

    // @Tx 시작
    processer.process();

    long endTime = System.currentTimeMillis();
    log.info(&quot;[Task End] 끝 시각: {}&quot;, LocalDateTime.now());

    // 소요 시간 계산 (끝 시각 - 시작 시각)
    long duration = endTime - startTime;
    log.info(&quot;[Performance Result] 작업 완료! 소요 시간: {} ms (약 {} 초)&quot;, duration, duration / 1000.0);
}</code></pre>
<p>그리고 실제 외부 요청을 처리하는 부분이다.</p>
<pre><code class="language-java">@Transactional
public void process() {
    LocalDateTime threshold = LocalDateTime.now().minusSeconds(3);
    List&lt;OutboxEvent&gt; events = outboxRepository.findByCreatedAtBeforeWithSkipLocked(threshold);

    if (events.isEmpty()) {
        log.info(&quot;기록해야 하는 미션이 존재하지 않습니다.&quot;);
        return;
    }

    log.info(&quot;Outbox 폴링: {}건 처리 시작&quot;, events.size());
    ProcessingResult result = processAllEvents(events);

    // TX: 성공 시 Outbox 테이블에서 삭제
    deleteProcessedEvents(result.toDelete());
    logUnknownEvents(result.unknownCount());
}</code></pre>
<h3 id="중복-요청은-언제-발생할까-멱등성">중복 요청은 언제 발생할까 (멱등성)</h3>
<p>앞서 말했듯이 클라이언트 입장에서는 서버 쪽에서 네트워크 에러나 타임아웃이 발생하면 재시도를 해야 한다. 그런데 재시도를 하다 보면 한 번만 수행되어야 할 작업이 여러 번 수행될 수 있다. 현재 구조에서도 이런 중복이 발생할 여지가 있었다.</p>
<table align="center">
  <tr>
    <td>
      <img src="https://velog.velcdn.com/images/heeun_98/post/f6eeb902-d9bd-4118-9257-de9e3d78898c/image.png" width="100%">
    </td>
    <td>
      <img src="https://velog.velcdn.com/images/heeun_98/post/ba6177d0-75fe-4990-8710-f55c077a13a8/image.png" width="100%">
    </td>
  </tr>
</table>

<p>위에서 살펴본 상황들 외에도, 즉시 발송하는 비동기 이벤트와 폴링 스케줄러가 동시에 실행되어 같은 요청이 두 번 나가는 경우도 가능하다. 근본적으로는 Admin 서버에서 멱등성 키로 중복 요청을 막아야 하지만, 불필요한 요청 자체를 줄이기 위해 Outbox 테이블에서 생성된 지 3초 이상 지난 레코드만 조회하도록 했다. 즉시 발송이 처리 중인 따끈따끈한 레코드를 폴링이 곧바로 집어가지 않도록 한 것이다.</p>
<pre><code class="language-java">LocalDateTime threshold = LocalDateTime.now().minusSeconds(3);
List&lt;OutboxEvent&gt; events = outboxRepository.findByCreatedAtBeforeWithSkipLocked(threshold);</code></pre>
<h3 id="다중-인스턴스-환경에서의-중복-발행">다중 인스턴스 환경에서의 중복 발행</h3>
<p>서버를 2대 이상으로 스케일 아웃한 다중 인스턴스 환경에서는, 각 서버의 <code>@Scheduled</code> 메서드가 동시에 실행되면서 동일한 Outbox 이벤트를 중복으로 조회하고 API를 중복 요청하는 문제가 생길 수 있다.</p>
<p>이런 스케줄러 중복 실행을 막기 위해, 흔히 한 인스턴스만 작업을 수행하도록 고정하는 분산 락을 떠올리곤 한다. 하지만 분산 락을 쓰면 특정 시점에 오직 한 대의 서버만 스케줄러를 실행할 수 있어서, 다중 인스턴스 구조가 가진 병렬 처리의 이점을 살리지 못하고 나머지 서버의 리소스가 놀게 된다는 아쉬움이 있었다.</p>
<p>고민 끝에 선택한 해결책은 MySQL의 <code>SELECT ... FOR UPDATE SKIP LOCKED</code> 구문이다.</p>
<pre><code class="language-java">@Query(value = &quot;SELECT * FROM outbox_event WHERE created_at &lt; :threshold ORDER BY created_at LIMIT 5 FOR UPDATE SKIP LOCKED&quot;,
        nativeQuery = true)
List&lt;OutboxEvent&gt; findByCreatedAtBeforeWithSkipLocked(@Param(&quot;threshold&quot;) LocalDateTime threshold);</code></pre>
<p>이 쿼리는 다른 트랜잭션이 이미 락을 획득한 행이 있으면, 그 행을 얻으려 대기하지 않고 그냥 건너뛴 뒤 락이 걸리지 않은 다음 행을 즉시 가져온다.</p>
<p>이를 Outbox 이벤트 조회에 적용하면 세 가지 이점을 얻는다. 첫째, 각 서버 인스턴스가 서로 겹치지 않는 레코드를 나눠 가져가므로 동일한 이벤트에 대한 중복 API 요청이 원천적으로 차단된다. 둘째, 모든 서버 인스턴스가 대기 없이 동시에 자기 할당량을 가져와 처리하므로 병렬 스케줄러를 제대로 구현할 수 있다. 셋째, 행 단위 락 경쟁으로 인한 스케줄러 간 대기가 없고, 분산 시스템의 고질적인 문제인 데드락 위험에서도 자유로워진다.</p>
<p>조회 건수는 트래픽을 기준으로 정했다. 미션 기록 요청이 1초에 1번 온다고 휴리스틱하게 가정하면, 스케줄러 주기인 30초 동안 최악의 경우 Outbox 테이블에 30건의 레코드가 쌓인다. 서버가 2대라면 이 30건을 병렬로 나눠 처리해야 하므로, 한 인스턴스당 <code>BATCH_SIZE</code>를 15건씩 조회하도록 설정했다.</p>
<h3 id="정리">정리</h3>
<p>이번 작업을 통해 Transactional Outbox 패턴과 실패 케이스 분류 등, 서버 간 API 통신에서 발생할 수 있는 문제들을 처음으로 깊이 고민해볼 수 있었다.</p>
<p>다만 아쉬운 점도 남았다. 다중 서버 환경에서 병렬로 처리하기 위해 TPS와 서버 개수를 기준으로 배치 사이즈를 정했는데, <strong>이 방식은 서버 인스턴스를 증설할 때마다 배치 사이즈를 다시 손봐야 한다.</strong> 또한 TPS가 올라가 최악의 상황에 Outbox 테이블에 쌓이는 레코드가 많아질 때도 마찬가지로 배치 사이즈를 조정해야 한다.</p>
<p>그리고 현재 스케줄러는 30초마다 호출되는데, 단일 스레드로 동작하긴 하지만 이 역시 트랜잭션 내부에서 API를 호출한다. 이 두 가지 문제점은 다음 글에서 이어서 고민해보려 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[배포 직후 첫 로그인 1500ms → 320ms, 첫 요청 지연 잡기]]></title>
            <link>https://velog.io/@heeun_98/%ED%95%99%EA%B5%90-API%EC%97%90-%EC%BB%A4%EB%84%A5%EC%85%98-%EC%9E%AC%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/%ED%95%99%EA%B5%90-API%EC%97%90-%EC%BB%A4%EB%84%A5%EC%85%98-%EC%9E%AC%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 23 Apr 2026 13:10:43 GMT</pubDate>
            <description><![CDATA[<p>기존 미팀 프로젝트에서는 미팀 자체 로그인과 소셜 로그인을 사용했지만, 대학생 중심으로 페르소나를 좁히기 위해 세종대학교 포털 로그인을 통해 학생 인증을 하기로 했다. 사용자가 미팀을 통해 로그인하면 학교 API에 요청을 보내 재학생인지를 검증한다. 이렇게 학교 API로 요청을 보낼 때 응답 시간을 측정해 보고, 그중 TCP 커넥션을 맺는 데 드는 시간이 전체 응답 시간에서 얼마나 차지하는지 알고 싶었다.</p>
<h3 id="학교-api-응답시간-분석">학교 API 응답시간 분석</h3>
<p>운영 서버에서 학교 포털 서버로 <code>curl</code> 명령어를 통해 응답 시간을 분석해 보았다.</p>
<pre><code>{
  &quot;DNS 조회 시간&quot;: 0.001779s,
  &quot;TCP 연결 시간&quot;: 0.012955s,
  &quot;SSL/TLS 핸드쉐이크&quot;: 0.030608s,
  &quot;첫 데이터 수신(TTFB)&quot;: 0.039398s,
  &quot;-----------------&quot;: &quot;-----------------&quot;,
  &quot;전체 소요 시간&quot;: 0.039451s
}</code></pre><p>위 결과는 누적 시간이므로, 이전 단계까지 걸린 시간을 빼서 각 구간의 순수 소요 시간을 구하고, 전체 소요 시간에서 차지하는 비율을 계산해 보았다.</p>
<table>
<thead>
<tr>
<th align="left">분석 구간</th>
<th align="left">누적 시간 (Cumulative)</th>
<th align="left">순수 소요 시간 (Duration)</th>
<th align="left">비중 (%)</th>
<th align="left">비고</th>
</tr>
</thead>
<tbody><tr>
<td align="left"><strong>DNS 조회</strong></td>
<td align="left">0.0017s</td>
<td align="left"><strong>1.7ms</strong></td>
<td align="left">4.3%</td>
<td align="left">도메인 IP 변환</td>
</tr>
<tr>
<td align="left"><strong>순수 TCP 연결</strong></td>
<td align="left">0.0129s</td>
<td align="left"><strong>11.2ms</strong></td>
<td align="left">28.4%</td>
<td align="left">3-way Handshake</td>
</tr>
<tr>
<td align="left"><strong>순수 SSL 핸드쉐이크</strong></td>
<td align="left">0.0306s</td>
<td align="left"><strong>17.7ms</strong></td>
<td align="left">44.9%</td>
<td align="left">보안 터널 구축 (가장 무거운 작업)</td>
</tr>
<tr>
<td align="left"><strong>순수 서버 처리 (TTFB)</strong></td>
<td align="left">0.0393s</td>
<td align="left"><strong>8.7ms</strong></td>
<td align="left">22.1%</td>
<td align="left">API 로직 및 DB 조회</td>
</tr>
<tr>
<td align="left"><strong>전체 합계</strong></td>
<td align="left"><strong>0.0394s</strong></td>
<td align="left"><strong>39.4ms</strong></td>
<td align="left"><strong>100%</strong></td>
<td align="left">-</td>
</tr>
</tbody></table>
<p>전체 시간 39.4ms 중 데이터를 주고받기 위한 준비 단계(DNS + TCP + SSL)에만 <strong>30.6ms</strong>가 소요되었다. 즉, 전체 요청 시간의 <strong>약 77%가 네트워크 연결을 맺는 데</strong> 쓰이고 있다. 실제 서버가 비즈니스 로직을 처리하는 시간(8.7ms)보다 연결을 만드는 시간이 약 3.5배 더 길다는 것을 알 수 있다.</p>
<p>물론 순수 서버 처리 시간(TTFB)에는 실제 데이터를 주고받는 네트워크 시간이 일부 포함되어 있다. 하지만 그 부분을 감안하더라도, 대부분의 시간이 커넥션을 맺는 데 쓰이고 있다는 사실은 분명했다.</p>
<h3 id="학교-서버는-커넥션을-유지할까">학교 서버는 커넥션을 유지할까?</h3>
<p>HTTP/1.1부터는 지속적인 연결(persistent connection)을 지원한다. <code>Connection: keep-alive</code> 헤더를 통해 커넥션을 재사용할 수 있다는 것을 알 수 있다. 그런데 HTTP/1.1부터는 keep-alive가 기본값이라 헤더를 생략해도 된다고 하는데, 왜 학교 서버는 굳이 헤더에 명시적으로 넣어줄까? 찾아보니 서버 앞단의 보안 장비나 로드밸런서에서 이 헤더를 명시적으로 붙여주는 경우가 있다고 한다.</p>
<pre><code>curl -i -s -A &quot;Mozilla/5.0&quot; \
-e &quot;https://portal.sej****.ac.kr&quot; \
--data-urlencode &quot;mainLogin=N&quot; \
--data-urlencode &quot;id=*******&quot; \
--data-urlencode &quot;password=*********&quot; \
&quot;https://***********************&quot; | head -n 25
HTTP/1.1 200 OK
Server: Apache
Date: Mon, 27 Apr 2026 10:27:11 GMT
Content-Type: text/html; charset=UTF-8
Content-Length: 2060
Connection: keep-alive
X-ORACLE-DMS-ECID: aa7c22e1-262d-48bd-abe4-24bf5a67be31-0097724e
X-ORACLE-DMS-RID: 0</code></pre><p>응답 헤더를 보면 학교 서버가 커넥션을 유지한다는 것은 알 수 있다. 하지만 <strong>몇 초 동안</strong> 유지하는지는 알 수 없다. 만약 커넥션을 1초만 유지한다고 가정하면, 그 짧은 시간 안에 다음 요청이 들어와 커넥션을 재사용할 일은 거의 없을 것이다. 트래픽이 아주 많다면 1초 동안에도 많은 요청이 커넥션을 재사용하겠지만, 현재 미팀 서비스의 트래픽 규모에서는 사실상 무의미한 유지 시간이 된다.</p>
<p><code>Keep-Alive: timeout=5, max=100</code>처럼 헤더에 timeout과 max 요청 수를 명시적으로 알려주는 경우도 있지만, 학교 서버는 이를 생략했거나 의도적으로 공개하지 않은 것으로 보인다. 결국 미팀 서버에서 의미 있는 성능 개선을 하려면, <strong>학교 서버가 커넥션을 얼마나 유지하는지를 먼저 알아야</strong> 커넥션을 안정적으로 재사용할 수 있다고 판단했다.</p>
<h3 id="학교-서버의-timeout-측정하기">학교 서버의 timeout 측정하기</h3>
<p>학교 서버와의 커넥션 유지 시간을 직접 측정해 보기로 했다. 터미널 1에서는 <code>openssl</code> 명령어로 443 포트에 연결을 맺어 유지하고, 터미널 2에서는 <code>while</code> 스크립트로 1초마다 그 연결이 살아있는지 확인했다.</p>
<pre><code>while true; do
  # openssl이 443 포트로 연결을 맺고 있는지 확인
  STATUS=$(lsof -i :443 | grep openssl)

  if [ -z &quot;$STATUS&quot; ]; then
    echo &quot;---------------------------------------&quot;
    echo &quot;X [$(date +%T)] 커넥션이 끊겼습니다. (실험 종료)&quot;
    echo &quot;---------------------------------------&quot;
    break
  fi

  echo &quot;O [$(date +%T)] 연결 유지 중...&quot;
  sleep 1
done
O [19:50:33] 연결 유지 중...
O [19:50:34] 연결 유지 중...
O [19:50:35] 연결 유지 중...
O [19:50:36] 연결 유지 중...
O [19:50:38] 연결 유지 중...
--- 생략
O [19:55:49] 연결 유지 중...
O [19:55:50] 연결 유지 중...
---------------------------------------
X [19:55:51] 커넥션이 끊겼습니다. (실험 종료)
---------------------------------------</code></pre><p>대략 <strong>5분 20초(320초)</strong> 동안 커넥션이 유지되는 것을 확인할 수 있었다. 예상보다 학교 서버는 꽤 오랜 시간 동안 커넥션을 유지하도록 설정되어 있었다. 재사용 가능한 시간이 충분히 길기 때문에, 학교 서버로 요청이 많이 몰리더라도 커넥션을 재사용해 연결 비용을 줄일 수 있겠다고 판단했다.</p>
<h3 id="짧은-시간-안에-요청이-많을-경우">짧은 시간 안에 요청이 많을 경우</h3>
<p>그렇다면 짧은 시간 안에 로그인 요청이 한꺼번에 몰리면 어떻게 될까? <strong>요청 속도 &gt; 커넥션 반환 속도</strong>인 상황이라면, 앞선 요청이 커넥션을 반납하기 전에 다음 요청이 들어오므로 커넥션을 재사용할 수 없다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/ff10347c-c852-45c6-a949-97caa47ee081/image.png" alt=""></p>
<p>이런 상황에서는 커넥션을 재사용하지 못하고 매번 새로운 커넥션을 만들어 요청을 보내게 된다. 물론 지금은 한 요청이 커넥션을 점유하는 시간이 짧기 때문에 이런 상황은 비교적 낮은 확률로 발생한다. 위 그림의 요청1, 요청2, 요청3처럼 동시 요청이 들어오면, 그 동시 요청 수만큼 외부 서버와 커넥션을 새로 맺게 된다.</p>
<p>이는 문제가 될 수 있다. 짧은 시간에 다량의 커넥션 생성과 요청이 발생하면 외부 API 서버 입장에서는 DDoS 공격처럼 보일 수 있고, 끊긴 커넥션이 <code>TIME_WAIT</code> 상태로 쌓이면서 포트 자원이 고갈되는 문제도 생길 수 있다. 따라서 <strong>외부 서버와 소통하는 커넥션을 잘 관리하는 것</strong>이 중요하다.</p>
<p>실제로 동시 요청이 발생했을 때 서버가 새로운 커넥션을 생성해 병렬로 처리하는지 확인하기 위해 실험을 진행했다. Apache Bench로 10명의 사용자가 동시에 로그인을 시도하는 상황을 시뮬레이션했고, <code>netstat</code> 명령어로 실시간 <code>ESTABLISHED</code> 상태의 TCP 커넥션 개수를 확인했다.</p>
<pre><code>ab -n 200 -c 10 -k -p post_data.txt -T &quot;application/json&quot; http://localhost:8080/api/v1/auth/login/sejong &amp; sleep 0.2; netstat -an | grep 8080 | grep ESTABLISHED</code></pre><pre><code>Benchmarking localhost (be patient)
tcp6       0      0  ::1.8080               ::1.53425              ESTABLISHED
tcp6       0      0  ::1.53425              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53424              ESTABLISHED
tcp6       0      0  ::1.53424              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53423              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53422              ESTABLISHED
tcp6       0      0  ::1.53423              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53422              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53421              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53420              ESTABLISHED
tcp6       0      0  ::1.53421              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53420              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53419              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53418              ESTABLISHED
tcp6       0      0  ::1.53419              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53418              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53417              ESTABLISHED
tcp6       0      0  ::1.8080               ::1.53416              ESTABLISHED
tcp6       0      0  ::1.53417              ::1.8080               ESTABLISHED
tcp6       0      0  ::1.53416              ::1.8080               ESTABLISHED</code></pre><p>예상대로 동시 요청을 보낼 경우 새로운 커넥션을 생성해 학교 로그인 API로 요청을 보낸다. 이렇게 <strong>동시 요청이 많으면 학교 서버 쪽에서 DDoS 공격으로 인지할 수 있기 때문에, 학교 서버와 맺는 커넥션 수를 제한하고 재사용하는 편이 좋겠다고</strong> 판단했다.</p>
<h3 id="커넥션-풀을-사용하자">커넥션 풀을 사용하자</h3>
<p>학교 서버는 대략 320초 동안 커넥션을 유지한다는 것을 확인했다. 그렇다면 클라이언트(미팀 서버)에서 320초 이상 커넥션을 붙들고 있으면 어떻게 될까? 이미 서버 쪽에서 끊어버린 커넥션을 들고 요청을 보내봤자 오류가 발생할 것이다. 즉, 클라이언트만 연결이 살아있다고 착각한 채로 요청을 보내고, 결국 사용자에게 오류 응답을 내려주게 된다. 게다가 <strong>앞선 그림처럼 커넥션 풀 없이 동시 요청이 들어오면 keep-alive의 이점을 전혀 누릴 수 없다.</strong></p>
<p>커넥션을 효율적으로 유지하기 위해 두 가지 방법을 생각해 보았다.</p>
<ol>
<li>keep-alive가 끝나기 전(약 320초)에 주기적으로 API를 재요청해서 커넥션을 살려두기</li>
<li>커넥션 풀을 미리 만들어 두고, 320초가 되기 전에 유휴 커넥션을 정리하며 관리하기</li>
</ol>
<p>1번은 주기적으로 요청을 보내 커넥션을 계속 살려두는 방식인데, 처음에는 이 방법을 시도하려 했다. 하지만 학교 서버의 keep-alive <code>max</code> 값(커넥션당 허용 요청 수)을 알 수 없기 때문에, 계속 요청을 보내다 보면 허용 요청 수를 초과해 오히려 연결이 끊어질 수 있다. 게다가 320초가 되기 전마다 빠짐없이 요청을 보내야 한다는 부담도 있었다.</p>
<p>학교 서버는 keep-alive 헤더에 timeout과 max 값을 함께 내려주지 않기 때문에 <code>max</code> 값은 알 수 없고, <code>timeout</code>은 직접 실험했을 때 약 320초로 측정되었다. 이 값을 기준으로 커넥션 풀 파라미터를 설정하기로 했다.</p>
<p>HikariCP에서 <code>idleTimeout</code>은 유휴 커넥션이 사용되지 않을 때 풀에서 제거하기까지의 시간을 뜻한다. 같은 맥락으로, 외부 HTTP 통신에 사용하는 Apache HttpClient에서는 <code>evictIdleConnections()</code>로 유휴 커넥션의 타임아웃을 지정할 수 있다. <strong>현재 학교 서버의 keep-alive timeout이 약 320초로 측정되었으므로, 그보다 짧은 시간 안에 미팀 서버(클라이언트)가 먼저 커넥션을 끊어줘야</strong> 죽은 커넥션으로 요청을 보내는 상황을 막을 수 있다.</p>
<p>또한 DDoS로 오인되는 상황을 방지하기 위해 최대 커넥션 개수를 5개로 제한했다. 학교 서버로의 로그인 요청 평균 응답 시간이 약 200ms로 측정되었으므로, 커넥션 1개당 초당 약 5건(1000 / 200)을 처리할 수 있다. 따라서 5개의 커넥션이면 초당 약 <code>5 × 5 = 25건</code>의 로그인 요청을 감당할 수 있고, 현재 미팀 서비스의 규모에서는 이 정도면 충분하다고 판단했다.</p>
<h3 id="배포-직후-학교-서버-api-요청-시">배포 직후 학교 서버 API 요청 시</h3>
<p>커넥션 풀을 사용하더라도 기본적으로 keep-alive로 커넥션이 유지되고 있는 상태라면, 평상시 성능에는 큰 변화가 없을 것이다. 그래서 변화가 두드러질 만한 <strong>배포 직후</strong> 상황을 들여다보기로 했다. 배포 직후 학교 API로 2번씩, 총 10번 요청을 보내 보았다. 그런데 <strong>첫 번째 요청이 두 번째 요청보다 약 6배 이상 느린</strong> 상황을 확인할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/8a869d98-dc49-4167-b139-b82dc1db5f97/image.png" alt=""></p>
<p>위 그래프에서 볼 수 있듯이, 첫 요청은 약 1500ms, 두 번째 요청은 약 250ms가 소요되었다.</p>
<p>첫 요청 시에는 학교 서버와 아직 커넥션을 맺지 않은 상태이므로, 두 번째 요청과 달리 <strong>TCP 연결 + SSL 핸드셰이크</strong> 비용이 전체 응답 시간에 포함된다. 여기까지는 자연스럽다. 하지만 그 비용이 두 요청의 차이인 <code>1500 - 250 = 1250ms</code>나 된다는 점이 이상했다. 커넥션 수립에만 1초가 넘게 걸린다는 건 상식적으로 너무 컸다.</p>
<p>그래서 미팀 서버(클라이언트)에서 학교 서버로 <code>curl</code> 명령어를 보내, TCP 연결 + SSL 핸드셰이크에 실제로 걸리는 지연 시간을 직접 측정해 보았다.</p>
<ul>
<li>첫 요청</li>
</ul>
<pre><code>DNS: 0.083265s
TCP: 0.091098s
SSL: 0.119399s
Total: 0.127828s</code></pre><ul>
<li>두 번째 요청</li>
</ul>
<pre><code>DNS: 0.002056s
TCP: 0.018932s
SSL: 0.046125s
Total: 0.054546s</code></pre><p>물론 그래프는 <code>내 로컬 → 미팀 서버 → 학교 서버</code>로 요청한 상황이고, <code>curl</code>은 미팀 서버 내부에서 학교 서버로 직접 보낸 것이라 경로가 다르다. 즉 내 로컬과 미팀 서버 사이의 네트워크 비용은 빠져 있다. 하지만 그 차이를 감안하더라도, <strong>커넥션 수립과 SSL 핸드셰이크에 1250ms가 걸렸을 가능성은 0에 가깝다.</strong> 첫 요청의 커넥션 비용은 길어야 100~200ms 수준이었다.</p>
<p>그렇다면 나머지 시간은 어디로 갔을까? 적어도 커넥션 수립 비용만큼은, 배포 직후에 미리 커넥션을 만들어 두면 첫 요청에서 조금이나마 줄일 수 있을 것이다.</p>
<h3 id="커넥션-풀-워밍업-해보기">커넥션 풀 워밍업 해보기</h3>
<p>직접 눈으로 확인하고 싶어, 배포 시점에 <code>ApplicationRunner</code>를 통해 학교 API로 요청을 보내 커넥션을 미리 만들어 두었다.</p>
<pre><code class="language-java">@Slf4j
@Component
@RequiredArgsConstructor
public class WarmUpRunner implements ApplicationRunner {

    private final SejongPortalClient sejongPortalClient;

    @Value(&quot;${app.warmup.enabled:true}&quot;)
    private boolean warmupEnabled;

    @Override
    public void run(ApplicationArguments args) {
        if (!warmupEnabled) {
            log.info(&quot;=== Warm-up 비활성화됨 (app.warmup.enabled=false) ===&quot;);
            return;
        }

        log.info(&quot;=== 애플리케이션 Warm-up 시작 ===&quot;);
        long startTime = System.currentTimeMillis();

        try {
            // 세종대 포털 커넥션 풀 워밍업
            sejongPortalClient.warmUp();
            log.info(&quot;세종대 포털 커넥션 풀 Warm-up 완료&quot;);
        } catch (Exception e) {
            log.warn(&quot;세종대 포털 Warm-up 실패 (서비스는 정상 동작): {}&quot;, e.getMessage());
        }

        long elapsed = System.currentTimeMillis() - startTime;
        log.info(&quot;=== 애플리케이션 Warm-up 완료 ({}ms) ===&quot;, elapsed);
    }
}</code></pre>
<p>커넥션을 미리 만들어 둔 뒤, 앞과 같은 조건으로(2번씩 총 10번) 학교 서버에 요청을 보내 보았다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/0d470fdd-f374-4f71-bb71-803d7fc5804b/image.png" alt=""></p>
<p>HTTP 커넥션을 미리 생성해 두자 첫 요청이 눈에 띄게 줄었다. <strong>기존에는 첫 요청이 두 번째 요청보다 약 6배 이상 느렸지만, 약 4.5배로 단축되었다.</strong></p>
<p>분명 개선이긴 하지만, 여전히 첫 요청은 두 번째 요청보다 4.5배나 느리다. 커넥션 비용을 미리 지불했는데도 격차가 이만큼 남아 있다는 건, 첫 요청을 무겁게 만드는 진짜 원인이 네트워크가 아니라 <strong>다른 곳</strong>에 있다는 뜻이었다.</p>
<h3 id="배포-이후-사용되는-클래스-warm-up">배포 이후 사용되는 클래스 Warm-up</h3>
<p>남은 비용은 네트워크가 아니라 <strong>애플리케이션 내부</strong>, 정확히는 스프링 초기화와 JVM에 있었다.</p>
<p>JVM은 애플리케이션이 실행되는 도중에 필요한 클래스를 그때그때 로딩한다(lazy class loading). 또한 자주 실행되는 코드를 JIT 컴파일러가 기계어로 최적화하기 전까지는 바이트코드를 인터프리터 방식으로 한 줄씩 느리게 실행한다. 즉, <strong>처음 실행되는 코드 경로는 항상 가장 느리다.</strong></p>
<p>로그인 요청 하나가 처리되는 과정을 떠올려 보면, 컨트롤러 → 서비스 → HTTP 클라이언트로 이어지는 동안 수많은 클래스가 처음으로 로딩되고 초기화된다. 요청 바디를 직렬화·역직렬화하는 Jackson, 검증 로직, 트랜잭션/AOP 프록시 객체 생성, HttpClient 내부 클래스들까지. 첫 요청은 커넥션 비용뿐 아니라 <strong>이 모든 &quot;처음&quot; 비용을 혼자서 떠안는다.</strong> 커넥션 풀만 미리 데웠을 때 첫 요청이 여전히 4.5배나 느렸던 이유가 바로 여기에 있었다. 네트워크 지연을 걷어내고 보니, 남은 실질적인 원인은 첫 요청 시점에 발생하는 <strong>스프링 내부 초기화 + JVM 클래스 로딩 오버헤드</strong>였다.</p>
<p>그래서 워밍업의 범위를 넓혔다. 단순히 커넥션만 미리 맺는 것이 아니라, <strong>배포 시점에 실제 로그인 API 로직을 모의로 한 번 호출</strong>하도록 했다. 이렇게 하면 커넥션 풀이 채워지는 동시에, 로그인 경로에서 사용되는 클래스들이 미리 로딩되고 JIT가 핫스팟을 데워두게 된다. 사용자가 처음 로그인할 때 떠안던 &quot;처음&quot; 비용을, 사용자가 도착하기 전에 서버 스스로 미리 치르게 한 것이다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/1a0da9ac-8bc5-461c-96b0-6d09054706d0/image.png" alt=""></p>
<p>결과는 명확했다. 실제 로그인 로직까지 워밍업하자 <strong>첫 요청 응답 시간이 약 1500ms → 320ms로 단축</strong>되었다. 두 번째 요청(약 250ms)과의 격차도 기존 약 6배에서 <strong>약 1.2배 수준까지 좁혀졌다.</strong> 배포 직후 첫 사용자가 마주하던 1초 이상의 지연이, 후속 요청과 거의 차이 없는 수준으로 사라진 것이다.</p>
<p>결국 첫 요청을 무겁게 만들던 비용은 <strong>네트워크 커넥션 비용 + 스프링 초기화·JVM 클래스 로딩 비용</strong> 두 갈래였고, 실제 로그인 로직을 모의 호출하는 한 번의 워밍업으로 두 가지를 모두 미리 데워둠으로써 사용자가 처음 마주하는 응답을 후속 요청 수준으로 끌어올릴 수 있었다.</p>
<h3 id="마치며">마치며</h3>
<p>이번 작업은 &quot;학교 API 응답이 느린데, 그 시간이 정확히 어디서 새는 걸까?&quot;라는 단순한 궁금증에서 시작했다. 그 궁금증을 따라가다 보니 다음과 같은 흐름으로 정리되었다.</p>
<ul>
<li><strong>측정</strong>: 학교 API 응답 시간의 약 77%가 비즈니스 로직이 아니라 커넥션을 맺는 데(DNS + TCP + SSL) 쓰이고 있었다.</li>
<li><strong>확인</strong>: 학교 서버는 커넥션을 약 320초간 유지하므로, 재사용 가치가 충분했다.</li>
<li><strong>관리</strong>: 다만 동시 요청이 몰리면 커넥션이 우후죽순 생겨 DDoS로 오인되거나 <code>TIME_WAIT</code>이 쌓일 수 있어, 커넥션 풀로 최대 5개까지만 재사용하도록 제한하고 320초보다 짧게 유휴 커넥션을 정리하도록 설정했다.</li>
<li><strong>워밍업</strong>: 배포 직후 첫 요청이 후속 요청보다 약 6배 느린 문제를 파고든 결과, 원인이 네트워크 커넥션과 스프링 초기화·JVM 클래스 로딩 두 갈래임을 확인했다. 커넥션 풀만 데웠을 땐 여전히 4.5배가 남았지만, 배포 시점에 실제 로그인 API를 모의 호출해 커넥션과 클래스를 함께 워밍업하자 첫 요청이 <strong>1500ms → 320ms로 줄며 후속 요청과 약 1.2배 차이</strong>까지 좁혀졌다.</li>
</ul>
<p>돌아보면 가장 의미 있었던 건 성능 수치 자체보다, <strong>&quot;느리다&quot;를 &quot;어느 구간이 몇 ms 느리다&quot;로 쪼개어 본 과정</strong>이었다. 막연히 &quot;커넥션이 비싸겠지&quot;라고 짐작하고 끝냈다면, 첫 요청의 1250ms 격차가 사실은 커넥션이 아니라 스프링·JVM 워밍업 문제였다는 사실은 끝내 몰랐을 것이다. 직접 <code>curl</code>로 커넥션 비용을 재보고 &quot;이 숫자로는 1250ms가 설명이 안 된다&quot;고 의심한 덕분에 진짜 원인에 닿을 수 있었다.</p>
<p>물론 현재 미팀의 트래픽 규모에서 이 차이가 체감될 일은 많지 않다. 하지만 외부 의존성과 통신하는 구간을 측정하고, 의심하고, 근거를 가지고 파라미터를 정하는 이 습관 자체가, 트래픽이 커졌을 때 흔들리지 않는 서비스를 만드는 밑바탕이 된다고 생각한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[원자적 쿼리로 동시성 문제 해결해 보기]]></title>
            <link>https://velog.io/@heeun_98/%EC%9B%90%EC%9E%90%EC%A0%81-%EC%BF%BC%EB%A6%AC%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B0%80%EB%8A%A5%ED%95%9C%EC%A7%80</link>
            <guid>https://velog.io/@heeun_98/%EC%9B%90%EC%9E%90%EC%A0%81-%EC%BF%BC%EB%A6%AC%EB%A1%9C-%EB%8F%99%EC%8B%9C%EC%84%B1-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0%EA%B0%80%EB%8A%A5%ED%95%9C%EC%A7%80</guid>
            <pubDate>Tue, 07 Apr 2026 15:40:25 GMT</pubDate>
            <description><![CDATA[<h3 id="문제상황">문제상황</h3>
<p>미팀 프로젝트에서는 사용자가 프로젝트에 좋아요를 토글 형식으로 누르는 상황이다.
좋아요는 한번만 누를수 있고, 한번더 누를 경우 좋아요가 취소가 된다.
하나의 프로젝트에 대해서 좋아요 요청시 동시성 문제가 발생한다고 판단했고, 이를 해결하기 위해 낙관적 락, 비관적 락의 성능비교를 통해 비관적 락을 선택하였다.
충돌이 적은 상황에서는 당연하게도 무의미한 성능차이가 발생하였고,
충돌이 많은 상황에서는 비관적 락 81.03ms, 낙관적 락 103.99ms로 측정되어 비관적 락이 더 안정적인 성능을 보였다.
하지만 여기서 좋아요 post 요청시 동시성 문제가 발생한 이유는 조회 시점과 업데이트 시점의 차이가 존재하여 발생하는 문제였고, <strong>굳이 조회를 하지않고 DB 에서 원자적 쿼리를 통해 해결할 수 있지 않을까?</strong> 라는 생각이 들었다. <strong>update 시점에 where절을 사용하여 조회와 동시에 update를 진행하면, select for update 시점에 얻는 LOCK 보다 LOCK 소유시간이 적기 때문에 처리량이 올라가는것은 당연할 것이다.</strong>  </p>
<h4 id="db-배타적-락x락-은-언제-걸까">DB 배타적 락(X락) 은 언제 걸까</h4>
<p><strong>write 쿼리(INSERT, UPDATE, DELETE)는 기본적으로 X락(Exclusive Lock)을 획득한다.</strong>데이터베이스에서 write 를 한 시점에 해당 row(레코드)에 대한 X Lock 을 획득한다. 이후 commit 일이 일어나기 전까지 소유하고 있다가 commit 이후에 Lock을 반납한다.</p>
<p>만약 트랜잭션A 가 Lock을 획득하면 commit 이 일어나기 전까지 다른 트랜잭션B는 Lock 을 획득하기 위해 대기한다. 트랜잭션 B는 설정한 LOCK_TIMEOUT    동안에 Lock 을 소유하기 위해 대기하고, LOCK_TIMEOUT 이후에는 타임아웃 오류가 발생한다.</p>
<p>조회시점에 X락을 획득하는 쿼리도 존재한다.
SELECT FROM UPDATE 쿼리가 X락을 획득하는 쿼리이다.</p>
<pre><code class="language-java">// ProjectLikeService.java (현재)

@Transactional
public ToggleLikeResponse toggleWithPessimistic(Long projectId, String email) {

    // 1. Project 조회 (SELECT FOR UPDATE)
    Project project = projectRepository.findByIdForUpdate(projectId)  // ← X Lock 획득
            .orElseThrow(...);

    Member member = memberRepository.findByEmail(email)
            .orElseThrow(...);

    Optional&lt;ProjectLike&gt; existing =
            projectLikeRepository.findByMemberAndProject(member, project);

    if (existing.isPresent()) {
        projectLikeRepository.delete(existing.get());
        project.decreaseLike();  // like_count - 1

        return new ToggleLikeResponse(
                projectId,
                false,
                project.getLikeCount()
        );
    }

    projectLikeRepository.save(ProjectLike.create(member, project));
    project.increaseLike();  // like_count + 1

    return new ToggleLikeResponse(
            projectId,
            true,
            project.getLikeCount()
    );
}

// COMMIT 시점에 X Lock 해제
</code></pre>
<p>여기서 그럼 원자적 쿼리 방식으로 Race Condition을 해결할 수 있을것 같다는 생각을 했다. 이렇게 할 경우 X Lock 의 소유 시점이 위의 select 시점에 X Lock 의 소유시점보다 짧기 때문에 DB에서의 처리량이 올라갈 것이라고 판단했다.
아래의 코드는 위의 비관적 락에서 원자적 쿼리 방식으로 진행한 코드이다.</p>
<pre><code class="language-java">
// ProjectLikeService.java (원자적 쿼리 방식)

@Transactional
public ToggleLikeResponse toggleWithAtomicQuery(Long projectId, String email) {

    // 1. Project 존재 확인 (Lock 없음)
    if (!projectRepository.existsById(projectId)) {
        throw new CustomException(ErrorCode.PROJECT_NOT_FOUND);
    }

    Member member = memberRepository.findByEmail(email)
            .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));

    // 2. 좋아요 존재 여부 확인
    boolean exists =
         projectLikeRepository.existsByMemberIdAndProjectId(
                    member.getId(),
                    projectId
            );

    if (exists) {
        // 삭제 + 원자적 감소
        projectLikeRepository.deleteByMemberIdAndProjectId(
                member.getId(),
                projectId
        );

        projectRepository.decreaseLikeCount(projectId);  // X Lock 최소

        int likeCount = projectRepository.countLikeById(projectId);

        return new ToggleLikeResponse(
                projectId,
                false,
                likeCount
        );
    }

    // 삽입 + 원자적 증가
    projectLikeRepository.save(
            ProjectLike.create(member.getId(), projectId)
    );

    projectRepository.increaseLikeCount(projectId);  // X Lock 최소

    int likeCount = projectRepository.countLikeById(projectId);

    return new ToggleLikeResponse(
            projectId,
            true,
            likeCount
    );
}

</code></pre>
<p>원자적 쿼리를 사용함으로 Project를 조회하는 쿼리는 존재하지 않는다.
countLikeById(projectId) 를 통해서 DB 내에서 조회와 업데이트를 진행할수 있다. 아래 코드는 원자적 쿼리이다.</p>
<pre><code class="language-java">// ProjectRepository.java

public interface ProjectRepository extends JpaRepository&lt;Project, Long&gt; {

    // 원자적 증가 (like_count + 1)
    @Modifying
    @Query(&quot;&quot;&quot;
        UPDATE Project p
        SET p.likeCount = p.likeCount + 1
        WHERE p.id = :projectId
    &quot;&quot;&quot;)
    void increaseLikeCount(@Param(&quot;projectId&quot;) Long projectId);

    // 원자적 감소 (like_count - 1)
    @Modifying
    @Query(&quot;&quot;&quot;
        UPDATE Project p
        SET p.likeCount = p.likeCount - 1
        WHERE p.id = :projectId
    &quot;&quot;&quot;)
    void decreaseLikeCount(@Param(&quot;projectId&quot;) Long projectId);
}
</code></pre>
<h3 id="원자적-쿼리에서-발생할-수-있는-문제점">원자적 쿼리에서 발생할 수 있는 문제점</h3>
<p>사용자가 매우 빠르게 좋아요 요청을 두번을 보내거나, 네트워크 끊김으로 인해 좋아요가 빠르게 2번 요청이 왔다고 가정하자.
<strong>실수로 중복 요청(일명 ‘따닥’)이 발생했을때를 말한다.</strong>
이미 좋아요를 한 상태로 좋아요를 취소하려고 POST 요청을 보냈지만 2번의 요청이 빠른 찰나에 온 상황이다.</p>
<p>먼저 온 요청을 <strong>요청1</strong>, 두번째로 온 요청을 <strong>요청2</strong>라고 하자.</p>
<p>먼저 요청1은 아래와 같이 좋아요 존재 여부를 ProjectLike 테이블에서 확인할것이다.
( 해당 ROW 가 존재 유무 확인)</p>
<pre><code class="language-java"> // 2. 좋아요 존재 여부 확인
    boolean exists =
         projectLikeRepository.existsByMemberIdAndProjectId(
                    member.getId(),
                    projectId
            );</code></pre>
<p>요구사항은 좋아요를 취소하는(토글 방식) 상황이므로 TRUE 를 return 할 것이다.
TRUE이므로 아래와 같이 if 문에 진입을 하고 DELETE 를 통해서 해당 ROW을 삭제를 할것이다. 그 후 원자적 쿼리를 수행한다.( X LOCK 획득)</p>
<pre><code class="language-java">if (exists) {
        // 삭제 + 원자적 감소
        projectLikeRepository.deleteByMemberIdAndProjectId(
                member.getId(),
                projectId
        );
        projectRepository.decreaseLikeCount(projectId);  // X Lock 최소

        int likeCount = projectRepository.countLikeById(projectId);
        return new ToggleLikeResponse(
                projectId,
                false,
                likeCount
        );
    }
</code></pre>
<p>여기서 요청1이 DELETE 를 수행하기 전에 요청2가 좋아요 존재 여부 확인을 한다고 가정해보자. 빠른 2번의 요청이 올 경우 충분히 발생 가능한 상황이라고 판단했기 때문이다.</p>
<pre><code class="language-java"> // 2. 좋아요 존재 여부 확인
    boolean exists =
         projectLikeRepository.existsByMemberIdAndProjectId(
                    member.getId(),
                    projectId
            );</code></pre>
<p>요청 1이 DELETE 를 하기 전 이므로 요청 2는 LIKE 테이블에 ROW가 존재한다고 판단하고 TRUE를 return 할 것이다.
그 후, 요청 2 역시 if (exists) 절에 진입을 할것이다.
여기서 요청1의 DELETE 여부와 상관없이 요청2 는 <code>decreaseLikeCount</code> 쿼리를 날릴것이다.
이 상황에서 원자적 쿼리로 count = count - 1 을 총 2번 수행을 하기 때문에
총 count 값이 2가 감소할것이다. 요청을 2번 보내면, 좋아요 수가 -1 이 되고, 한번 더 누를경우 다시 +1 이 되어 결과론 적으로는 좋아요 수 변화가 있으면 안되지만, 빠른 요청이 2번 올 경우 위 요구사항을 만족하지 못한다.
비록 좋아요 수가 비니지스 적으로 정확하게 일치하지 않아도 사용자 경험이 저하되지 않지만, 이러한 문제는 좋아요 뿐만 아니라 사용자에게 보상을 주거나 포인트가 차감될 경우 충분히 발생할수 있는 문제라고 생각했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[절기 미션 추천 아키텍처]]></title>
            <link>https://velog.io/@heeun_98/%EC%A0%88%EA%B8%B0-%EB%AF%B8%EC%85%98-%EC%B6%94%EC%B2%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</link>
            <guid>https://velog.io/@heeun_98/%EC%A0%88%EA%B8%B0-%EB%AF%B8%EC%85%98-%EC%B6%94%EC%B2%9C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98</guid>
            <pubDate>Mon, 06 Apr 2026 05:03:02 GMT</pubDate>
            <description><![CDATA[<h1 id="절기별-미션-추천-서비스---시스템-아키텍처">절기별 미션 추천 서비스 - 시스템 아키텍처</h1>
<h2 id="1-시스템-개요">1. 시스템 개요</h2>
<p>절기가 변경될 때마다 사용자에게 미션을 제공하고 알림을 발송하는 시스템</p>
<h3 id="11-미션-유형">1.1 미션 유형</h3>
<table>
<thead>
<tr>
<th>유형</th>
<th>설명</th>
<th>대상</th>
</tr>
</thead>
<tbody><tr>
<td><strong>오늘의 미션</strong></td>
<td>큐레이터 픽, 관리자가 미리 지정</td>
<td>모든 유저 공통</td>
</tr>
<tr>
<td><strong>개인화 미션</strong></td>
<td>온보딩 데이터 기반 추천</td>
<td>유저별 맞춤</td>
</tr>
</tbody></table>
<h3 id="12-핵심-설계-원칙-mvp">1.2 핵심 설계 원칙 (MVP)</h3>
<ul>
<li><strong>오늘의 미션</strong>: 관리자가 절기별 15일치 사전 지정 (수동 큐레이션)</li>
<li><strong>개인화 미션</strong>: 태그 기반 매칭 (45개 미션 풀)</li>
<li><strong>LLM</strong>: 미션 생성 보조 (LLM이 리스트 생성 → 관리자가 선별)</li>
<li><strong>날씨 API</strong>: MVP 제외 → 2차 고도화</li>
</ul>
<hr>
<h2 id="2-전체-아키텍처">2. 전체 아키텍처</h2>
<h3 id="21-mvp-아키텍처">2.1 MVP 아키텍처</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────────────────┐
│                                                                             │
│                        ┌─────────────────────┐                              │
│                        │      Client App     │                              │
│                        │   (iOS / Android)   │                              │
│                        └──────────┬──────────┘                              │
│                                   │                                         │
│                                   ▼                                         │
│                        ┌─────────────────────┐                              │
│                        │    Load Balancer    │                              │
│                        │      (Nginx)        │                              │
│                        └──────────┬──────────┘                              │
│                                   │                                         │
│                 ┌─────────────────┼─────────────────┐                       │
│                 ▼                 ▼                 ▼                       │
│       ┌──────────────┐  ┌──────────────┐  ┌──────────────┐                  │
│       │  API Server  │  │  API Server  │  │ Admin Server │                  │
│       │  (Spring)    │  │  (Spring)    │  │  (Spring)    │                  │
│       └───────┬──────┘  └───────┬──────┘  └───────┬──────┘                  │
│               │                 │                 │                         │
│               └─────────────────┼─────────────────┘                         │
│                                 │                                           │
│         ┌───────────────────────┼───────────────────────┐                   │
│         ▼                       ▼                       ▼                   │
│  ┌─────────────┐        ┌─────────────┐        ┌──────────────┐             │
│  │    MySQL    │        │    Redis    │        │   LLM API    │             │
│  │  (Main DB)  │        │  (Cache)    │        │ (Claude/GPT) │             │
│  └─────────────┘        └─────────────┘        └──────────────┘             │
│                                                       ↑                     │
│                                                       │                     │
│                                              Admin Server에서 호출           │
│                                              (미션 생성 보조용)              │
│                                                                             │
│  ┌──────────────────────────────────────────────────────────────┐           │
│  │                      Scheduler (Batch)                       │           │
│  │                                                              │           │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐          │           │
│  │  │ 오늘의미션  │  │ 절기 체크   │  │ 알림 발송   │          │           │
│  │  │ 활성화 Job  │  │ Job        │  │ Job        │          │           │
│  │  └─────────────┘  └─────────────┘  └─────────────┘          │           │
│  └──────────────────────────────────────────────────────────────┘           │
│                                 │                                           │
│                                 ▼                                           │
│                        ┌──────────────┐                                     │
│                        │  FCM / APNs  │                                     │
│                        │ (Push 알림)  │                                     │
│                        └──────────────┘                                     │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘</code></pre><h3 id="22-2차-고도화-아키텍처-추가-요소">2.2 2차 고도화 아키텍처 (추가 요소)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                     2차 고도화 시 추가                           │
│                                                                 │
│   ┌─────────────┐                                               │
│   │ Weather API │                                               │
│   │ (날씨 조회) │                                               │
│   └──────┬──────┘                                               │
│          │                                                      │
│          ▼                                                      │
│   ┌─────────────────────────────────────────┐                  │
│   │            Scheduler 추가 Job            │                  │
│   │  - WeatherCheckJob (06:00)              │                  │
│   │  - 날씨 기반 미션 자동 교체              │                  │
│   └─────────────────────────────────────────┘                  │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="3-핵심-프로세스">3. 핵심 프로세스</h2>
<h3 id="31-phase-1-미션-사전-준비-관리자-작업">3.1 Phase 1: 미션 사전 준비 (관리자 작업)</h3>
<p><strong>LLM으로 미션 리스트 생성 → 관리자가 선별/검수 → DB 저장</strong></p>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                      Admin Server Flow                          │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                 1. LLM으로 미션 리스트 생성               │   │
│  │                                                          │   │
│  │   관리자가 절기 + 조건 입력                               │   │
│  │   ┌─────────────────────────────────────────────────┐   │   │
│  │   │ &quot;청명 절기에 맞는 미션 50개 생성해줘&quot;            │   │   │
│  │   │ &quot;조건: 날씨에 덜 민감하고, 감성적인 미션&quot;        │   │   │
│  │   └─────────────────────────────────────────────────┘   │   │
│  │                          │                               │   │
│  │                          ▼                               │   │
│  │   ┌─────────────────────────────────────────────────┐   │   │
│  │   │              LLM API 호출                        │   │   │
│  │   │         (Claude / GPT 등)                       │   │   │
│  │   └─────────────────────────────────────────────────┘   │   │
│  │                          │                               │   │
│  │                          ▼                               │   │
│  │   ┌─────────────────────────────────────────────────┐   │   │
│  │   │           미션 후보 리스트 50개 반환             │   │   │
│  │   └─────────────────────────────────────────────────┘   │   │
│  └─────────────────────────────────────────────────────────┘   │
│                           │                                     │
│                           ▼                                     │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                  2. 관리자 선별/검수                     │   │
│  │                                                          │   │
│  │   LLM이 생성한 50개 중 45개 선별                         │   │
│  │   ┌─────────────────────────────────────────────────┐   │   │
│  │   │ [✓] 맑은 하늘 아래 심호흡하기                    │   │   │
│  │   │ [✓] 봄 향기 느끼기                              │   │   │
│  │   │ [✗] 벚꽃 축제 가기        ← 날씨 민감, 제외     │   │   │
│  │   │ [✓] 제철 음식 먹어보기                          │   │   │
│  │   │ ...                                             │   │   │
│  │   └─────────────────────────────────────────────────┘   │   │
│  │                                                          │   │
│  │   - 부적절한 미션 삭제                                   │   │
│  │   - 미션 문구 수정                                       │   │
│  │   - 태그 부여 (장소/활동/강도)                           │   │
│  └─────────────────────────────────────────────────────────┘   │
│                           │                                     │
│                           ▼                                     │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │               3. 오늘의 미션 스케줄 지정                 │   │
│  │                                                          │   │
│  │   선별된 45개 중 15개를 오늘의 미션으로 지정             │   │
│  │   ┌──────┬─────────────────────────┐                    │   │
│  │   │ 날짜 │ 오늘의 미션              │                    │   │
│  │   ├──────┼─────────────────────────┤                    │   │
│  │   │ 4/4  │ 맑은 하늘 아래 심호흡    │                    │   │
│  │   │ 4/5  │ 봄 향기 느끼기           │                    │   │
│  │   │ ...  │ ...                     │                    │   │
│  │   └──────┴─────────────────────────┘                    │   │
│  └─────────────────────────────────────────────────────────┘   │
│                           │                                     │
│                           ▼                                     │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │                     4. DB 저장                           │   │
│  │                                                          │   │
│  │   - Mission 테이블: 미션 풀 (45개)                       │   │
│  │   - MissionTag 테이블: 태그 정보                         │   │
│  │   - DailyMissionSchedule 테이블: 오늘의 미션 스케줄      │   │
│  └─────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────┘</code></pre><p><strong>미션 선별 가이드라인:</strong></p>
<table>
<thead>
<tr>
<th>원칙</th>
<th>설명</th>
<th>예시</th>
</tr>
</thead>
<tbody><tr>
<td>날씨 무관</td>
<td>실내/실외 모두 가능</td>
<td>&quot;봄 향기 느끼기&quot; (창문 열어도 OK)</td>
</tr>
<tr>
<td>감성 중심</td>
<td>행위보다 감각/경험</td>
<td>&quot;초록색 찾아 사진 찍기&quot;</td>
</tr>
<tr>
<td>재사용 가능</td>
<td>매년 사용 가능한 형태</td>
<td>&quot;제철 음식 먹어보기&quot;</td>
</tr>
</tbody></table>
<hr>
<h3 id="32-phase-2-오늘의-미션-활성화-매일-0000">3.2 Phase 2: 오늘의 미션 활성화 (매일 00:00)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                  Daily Mission Scheduler Flow                   │
│                                                                 │
│  ┌───────────────┐                                              │
│  │ 매일 00:00    │                                              │
│  │ DailyMission  │                                              │
│  │ ActivateJob   │                                              │
│  └───────┬───────┘                                              │
│          │                                                      │
│          ▼                                                      │
│  ┌───────────────────────────────────────────┐                  │
│  │ 1. DailyMissionSchedule에서 오늘 날짜 조회 │                  │
│  │ 2. 해당 미션을 &quot;오늘의 미션&quot;으로 활성화    │                  │
│  │ 3. Redis 캐시 갱신                        │                  │
│  └───────────────────────────────────────────┘                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h3 id="33-phase-3-절기-변경-감지-및-알림-발송">3.3 Phase 3: 절기 변경 감지 및 알림 발송</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                   Solar Term Scheduler Flow                     │
│                                                                 │
│  ┌───────────────┐                                              │
│  │ 매일 00:00    │                                              │
│  │ SolarTermCheck│                                              │
│  │ Job           │                                              │
│  └───────┬───────┘                                              │
│          │                                                      │
│          ▼                                                      │
│  ┌───────────────┐     NO                                       │
│  │ 오늘 = 절기   │ ──────────→ 종료                             │
│  │ 시작일?      │                                               │
│  └───────┬───────┘                                              │
│          │ YES                                                  │
│          ▼                                                      │
│  ┌───────────────────────────────────────────┐                  │
│  │ 절기 변경 알림 발송                        │                  │
│  │                                           │                  │
│  │ &quot;청명이 시작됐어요! 새로운 미션을 확인하세요&quot; │                  │
│  └───────────────────────────────────────────┘                  │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h3 id="34-phase-4-개인화-미션-조회-실시간">3.4 Phase 4: 개인화 미션 조회 (실시간)</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│              Personalized Mission Recommendation                │
│                                                                 │
│  ┌─────────┐         ┌─────────────────────────────────────┐   │
│  │ Client  │ ──────→ │           API Server                │   │
│  │ 요청    │ GET     │                                     │   │
│  │         │/missions│  1. 사용자 온보딩 태그 조회          │   │
│  └─────────┘         │  2. 현재 절기 미션 풀 조회           │   │
│                      │  3. 태그 매칭 점수 계산              │   │
│                      │  4. 이미 완료/스킵한 미션 제외       │   │
│                      │  5. 상위 N개 반환                   │   │
│                      └─────────────────────────────────────┘   │
│                                                                 │
│  [태그 매칭 로직]                                               │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ score = 0                                               │   │
│  │ if (user.location == mission.location) score += 2       │   │
│  │ if (user.activity == mission.activity) score += 3       │   │
│  │ if (user.intensity == mission.intensity) score += 1     │   │
│  │ return missions.sortByScore().limit(N)                  │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="4-미션-제공-구조">4. 미션 제공 구조</h2>
<h3 id="41-화면별-미션-구성">4.1 화면별 미션 구성</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                        메인 화면                                │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │              오늘의 미션 (큐레이터 픽)                    │   │
│  │                                                          │   │
│  │   &quot;맑은 하늘 아래 심호흡하기&quot;                             │   │
│  │                                                          │   │
│  │   - 모든 유저 동일                                       │   │
│  │   - 관리자가 미리 지정한 미션                            │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │              개인화 미션 (A/B 테스트)                     │   │
│  │                                                          │   │
│  │   [A안] 리스트로 여러 개                                  │   │
│  │   ┌─────────────────────────────────────┐               │   │
│  │   │ - 봄나물 비빔밥 만들기               │               │   │
│  │   │ - 창문 열고 환기하기                 │               │   │
│  │   │ - 산책하며 새소리 듣기               │               │   │
│  │   └─────────────────────────────────────┘               │   │
│  │                                                          │   │
│  │   [B안] 1개 + 새로고침                                   │   │
│  │   ┌─────────────────────────────────────┐               │   │
│  │   │ 봄나물 비빔밥 만들기          🔄    │               │   │
│  │   └─────────────────────────────────────┘               │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><h3 id="42-미션-데이터-흐름">4.2 미션 데이터 흐름</h3>
<pre><code>┌───────────────────────────────────────────────────────────────────────────┐
│                           미션 데이터 흐름                                 │
│                                                                           │
│  [사전 준비]                              [실시간]                         │
│                                                                           │
│  ┌──────────┐                                                            │
│  │  LLM     │                                                            │
│  │  API     │                                                            │
│  └────┬─────┘                                                            │
│       │ 미션 후보 50개 생성                                               │
│       ▼                                                                   │
│  ┌──────────┐                                                            │
│  │ 관리자   │                                                            │
│  │ 선별/검수│                                                            │
│  └────┬─────┘                                                            │
│       │ 45개 선정 + 태그 부여                                             │
│       ▼                                                                   │
│  ┌──────────┐                                                            │
│  │ Mission  │                                                            │
│  │ 테이블   │                                                            │
│  │ (풀)     │                                                            │
│  └────┬─────┘                                                            │
│       │                                                                   │
│       ├────────────────┐                                                  │
│       │                ▼                                                  │
│       │         ┌───────────────┐                                        │
│       │         │ DailyMission  │                                        │
│       │         │ Schedule      │                                        │
│       │         │ (15일치 지정) │                                        │
│       │         └───────┬───────┘                                        │
│       │                 │                                                 │
│       │                 │ 매일 00:00                                      │
│       │                 ▼                                                 │
│       │         ┌───────────────┐      ┌──────────┐                      │
│       │         │ 오늘의 미션   │ ───→ │ 모든유저 │                      │
│       │         │ 활성화        │      └──────────┘                      │
│       │         └───────────────┘                                        │
│       │                                                                   │
│       │ 사용자 요청 시                                                    │
│       ▼                                                                   │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────┐                    │
│  │ 태그 매칭    │ ─→ │ 개인화 미션  │ ─→ │ 해당유저 │                    │
│  │ (실시간)     │    │ 추천         │    └──────────┘                    │
│  └──────────────┘    └──────────────┘                                    │
│                                                                           │
└───────────────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="5-서버-구성">5. 서버 구성</h2>
<h3 id="51-api-server">5.1 API Server</h3>
<table>
<thead>
<tr>
<th>역할</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>사용자 인증</td>
<td>JWT 기반 인증/인가</td>
</tr>
<tr>
<td>오늘의 미션 조회</td>
<td>당일 활성화된 미션 반환</td>
</tr>
<tr>
<td>개인화 미션 조회</td>
<td>태그 매칭 기반 실시간 추천</td>
</tr>
<tr>
<td>미션 완료/스킵</td>
<td>상태 업데이트</td>
</tr>
<tr>
<td>알림 조회</td>
<td>인앱 알림 목록</td>
</tr>
</tbody></table>
<h3 id="52-admin-server">5.2 Admin Server</h3>
<table>
<thead>
<tr>
<th>역할</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>LLM 연동</strong></td>
<td>미션 후보 리스트 생성 요청</td>
</tr>
<tr>
<td>미션 관리</td>
<td>미션 선별/검수, CRUD</td>
</tr>
<tr>
<td>태그 관리</td>
<td>미션별 태그 설정</td>
</tr>
<tr>
<td>스케줄 관리</td>
<td>오늘의 미션 일정 지정</td>
</tr>
<tr>
<td>통계 조회</td>
<td>미션 완료율 등</td>
</tr>
</tbody></table>
<h3 id="53-scheduler-batch-server">5.3 Scheduler (Batch Server)</h3>
<table>
<thead>
<tr>
<th>Job</th>
<th>실행 시점</th>
<th>역할</th>
</tr>
</thead>
<tbody><tr>
<td><strong>DailyMissionActivateJob</strong></td>
<td>매일 00:00</td>
<td>오늘의 미션 활성화</td>
</tr>
<tr>
<td><strong>SolarTermCheckJob</strong></td>
<td>매일 00:00</td>
<td>절기 변경 여부 확인</td>
</tr>
<tr>
<td><strong>NotificationJob</strong></td>
<td>절기 변경 시</td>
<td>절기 시작 알림 발송</td>
</tr>
<tr>
<td><strong>MissionReminderJob</strong></td>
<td>매일 09:00</td>
<td>미완료 미션 리마인드 (선택)</td>
</tr>
</tbody></table>
<hr>
<h2 id="6-데이터-모델">6. 데이터 모델</h2>
<h3 id="61-핵심-테이블">6.1 핵심 테이블</h3>
<pre><code class="language-sql">-- 미션 풀
CREATE TABLE mission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    title VARCHAR(200) NOT NULL,
    description TEXT,
    solar_term VARCHAR(20),          -- 절기 (청명, 곡우 등)
    season VARCHAR(20),              -- 계절 (봄, 여름 등)
    status ENUM(&#39;DRAFT&#39;, &#39;ACTIVE&#39;) DEFAULT &#39;DRAFT&#39;,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 미션 태그 (개인화 매칭용)
CREATE TABLE mission_tag (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    mission_id BIGINT NOT NULL,
    tag_category VARCHAR(50),        -- location, activity, intensity
    tag_value VARCHAR(50),           -- indoor/outdoor, cooking/exercise, light/active
    FOREIGN KEY (mission_id) REFERENCES mission(id)
);

-- 오늘의 미션 스케줄 (관리자 지정)
CREATE TABLE daily_mission_schedule (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    solar_term VARCHAR(20) NOT NULL,
    target_date DATE NOT NULL,       -- 노출 날짜
    mission_id BIGINT NOT NULL,
    created_by BIGINT,               -- 지정한 관리자
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (mission_id) REFERENCES mission(id),
    UNIQUE KEY (target_date)
);

-- 사용자 온보딩 프로필
CREATE TABLE user_profile (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    location_pref VARCHAR(20),       -- indoor / outdoor / both
    activity_pref VARCHAR(50),       -- cooking / exercise / culture / rest
    intensity_pref VARCHAR(20),      -- light / moderate / active
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES user(id)
);

-- 사용자 미션 이력
CREATE TABLE user_mission (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT NOT NULL,
    mission_id BIGINT NOT NULL,
    mission_type ENUM(&#39;DAILY&#39;, &#39;PERSONALIZED&#39;),
    status ENUM(&#39;RECOMMENDED&#39;, &#39;COMPLETED&#39;, &#39;SKIPPED&#39;),
    recommended_at TIMESTAMP,
    completed_at TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES user(id),
    FOREIGN KEY (mission_id) REFERENCES mission(id)
);

-- 절기 정보
CREATE TABLE solar_term (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(20) NOT NULL,       -- 청명, 곡우 등
    start_date DATE NOT NULL,
    end_date DATE NOT NULL,
    season VARCHAR(20),              -- 봄, 여름 등
    year INT NOT NULL
);</code></pre>
<h3 id="62-태그-체계">6.2 태그 체계</h3>
<table>
<thead>
<tr>
<th>카테고리</th>
<th>값</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><strong>location</strong></td>
<td>indoor</td>
<td>실내</td>
</tr>
<tr>
<td></td>
<td>outdoor</td>
<td>실외</td>
</tr>
<tr>
<td></td>
<td>both</td>
<td>무관</td>
</tr>
<tr>
<td><strong>activity</strong></td>
<td>food</td>
<td>음식/요리</td>
</tr>
<tr>
<td></td>
<td>nature</td>
<td>자연/산책</td>
</tr>
<tr>
<td></td>
<td>culture</td>
<td>문화/감상</td>
</tr>
<tr>
<td></td>
<td>exercise</td>
<td>운동</td>
</tr>
<tr>
<td></td>
<td>rest</td>
<td>휴식</td>
</tr>
<tr>
<td><strong>intensity</strong></td>
<td>light</td>
<td>가벼움</td>
</tr>
<tr>
<td></td>
<td>moderate</td>
<td>보통</td>
</tr>
<tr>
<td></td>
<td>active</td>
<td>활발</td>
</tr>
</tbody></table>
<hr>
<h2 id="7-api-목록">7. API 목록</h2>
<h3 id="71-client-api">7.1 Client API</h3>
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>GET</td>
<td><code>/api/missions/today</code></td>
<td>오늘의 미션 조회</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/missions/personalized</code></td>
<td>개인화 미션 목록</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/missions/personalized/refresh</code></td>
<td>개인화 미션 새로고침 (B안)</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/missions/{id}/complete</code></td>
<td>미션 완료</td>
</tr>
<tr>
<td>POST</td>
<td><code>/api/missions/{id}/skip</code></td>
<td>미션 스킵</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/solar-terms/current</code></td>
<td>현재 절기 정보</td>
</tr>
<tr>
<td>GET</td>
<td><code>/api/notifications</code></td>
<td>알림 목록</td>
</tr>
</tbody></table>
<h3 id="72-admin-api">7.2 Admin API</h3>
<table>
<thead>
<tr>
<th>Method</th>
<th>Endpoint</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>POST</td>
<td><code>/admin/missions/generate</code></td>
<td>LLM으로 미션 후보 생성</td>
</tr>
<tr>
<td>GET</td>
<td><code>/admin/missions/candidates</code></td>
<td>생성된 미션 후보 목록</td>
</tr>
<tr>
<td>POST</td>
<td><code>/admin/missions/approve</code></td>
<td>미션 선별/승인</td>
</tr>
<tr>
<td>PUT</td>
<td><code>/admin/missions/{id}</code></td>
<td>미션 수정</td>
</tr>
<tr>
<td>DELETE</td>
<td><code>/admin/missions/{id}</code></td>
<td>미션 삭제</td>
</tr>
<tr>
<td>POST</td>
<td><code>/admin/daily-schedule</code></td>
<td>오늘의 미션 스케줄 지정</td>
</tr>
</tbody></table>
<hr>
<h2 id="8-타임라인">8. 타임라인</h2>
<pre><code>┌───────────────────────────────────────────────────────────────────────────┐
│                              운영 타임라인                                 │
│                                                                           │
│  [절기 시작 전 - 관리자 작업]                                              │
│  ┌─────────────────────────────────────────────────────────────────────┐ │
│  │ 1. Admin에서 LLM 호출 → 미션 후보 50개 생성                         │ │
│  │ 2. 관리자가 선별/검수 → 45개 확정                                   │ │
│  │ 3. 태그 부여 (장소/활동/강도)                                       │ │
│  │ 4. 오늘의 미션 15일치 스케줄 지정                                   │ │
│  │                                                                     │ │
│  │ ※ 1년치 한번에 작업 가능 (24절기 × 45개 = 1,080개)                 │ │
│  │ ※ 이후 매년 재사용 (필요시 일부 수정)                               │ │
│  └─────────────────────────────────────────────────────────────────────┘ │
│                                                                           │
│  [매일 00:00]                                                             │
│  ┌─────────────────────────────────────────────────────────────────────┐ │
│  │ 1. 오늘의 미션 활성화 (DailyMissionActivateJob)                     │ │
│  │ 2. 절기 변경 체크 (SolarTermCheckJob)                               │ │
│  │    → 변경 시 푸시 알림 발송                                         │ │
│  └─────────────────────────────────────────────────────────────────────┘ │
│                                                                           │
│  [사용자 앱 실행 시]                                                      │
│  ┌─────────────────────────────────────────────────────────────────────┐ │
│  │ 1. 오늘의 미션 조회 (캐시)                                          │ │
│  │ 2. 개인화 미션 조회 (실시간 태그 매칭)                               │ │
│  └─────────────────────────────────────────────────────────────────────┘ │
│                                                                           │
└───────────────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="9-기술-스택">9. 기술 스택</h2>
<table>
<thead>
<tr>
<th>영역</th>
<th>기술</th>
<th>용도</th>
</tr>
</thead>
<tbody><tr>
<td><strong>API Server</strong></td>
<td>Spring Boot 3.x</td>
<td>REST API</td>
</tr>
<tr>
<td><strong>Admin Server</strong></td>
<td>Spring Boot 3.x</td>
<td>관리자 기능</td>
</tr>
<tr>
<td><strong>Scheduler</strong></td>
<td>Spring Scheduler</td>
<td>배치 처리</td>
</tr>
<tr>
<td><strong>Database</strong></td>
<td>MySQL 8.0</td>
<td>메인 데이터 저장</td>
</tr>
<tr>
<td><strong>Cache</strong></td>
<td>Redis</td>
<td>오늘의 미션 캐싱, 세션</td>
</tr>
<tr>
<td><strong>LLM</strong></td>
<td>Claude API / OpenAI</td>
<td>미션 생성 보조 (Admin에서 사용)</td>
</tr>
<tr>
<td><strong>Push</strong></td>
<td>FCM / APNs</td>
<td>모바일 푸시 알림</td>
</tr>
<tr>
<td><strong>Infra</strong></td>
<td>AWS (EC2, RDS, ElastiCache)</td>
<td>클라우드</td>
</tr>
<tr>
<td><strong>CI/CD</strong></td>
<td>GitHub Actions + Docker</td>
<td>배포 자동화</td>
</tr>
<tr>
<td><strong>Monitoring</strong></td>
<td>Grafana + Prometheus + Loki</td>
<td>모니터링</td>
</tr>
</tbody></table>
<hr>
<h2 id="10-장애-대응">10. 장애 대응</h2>
<table>
<thead>
<tr>
<th>상황</th>
<th>대응 방안</th>
</tr>
</thead>
<tbody><tr>
<td><strong>스케줄러 장애</strong></td>
<td>오늘의 미션 활성화 안 됨 → 수동 트리거 API 제공</td>
</tr>
<tr>
<td><strong>오늘의 미션 없음</strong></td>
<td>스케줄 미지정 → 전날 미션 유지 or 기본 미션</td>
</tr>
<tr>
<td><strong>LLM API 장애</strong></td>
<td>미션 생성 불가 → 이전 절기 미션 복사 후 수정</td>
</tr>
<tr>
<td><strong>FCM 장애</strong></td>
<td>실패 건 별도 저장 → 재발송 배치</td>
</tr>
<tr>
<td><strong>DB 장애</strong></td>
<td>Read Replica 활용, Redis 캐시</td>
</tr>
</tbody></table>
<hr>
<h2 id="11-2차-고도화-계획">11. 2차 고도화 계획</h2>
<table>
<thead>
<tr>
<th>기능</th>
<th>설명</th>
<th>우선순위</th>
</tr>
</thead>
<tbody><tr>
<td><strong>날씨 API 연동</strong></td>
<td>비 오면 실내 미션으로 자동 교체</td>
<td>높음</td>
</tr>
<tr>
<td><strong>A/B 테스트 결과 반영</strong></td>
<td>개인화 미션 노출 방식 확정</td>
<td>높음</td>
</tr>
<tr>
<td><strong>미션 이력 기반 추천</strong></td>
<td>완료/스킵 패턴 학습</td>
<td>낮음</td>
</tr>
</tbody></table>
<h3 id="111-날씨-연동-시-변경-사항">11.1 날씨 연동 시 변경 사항</h3>
<pre><code>┌─────────────────────────────────────────────────────────────────┐
│                      날씨 연동 추가 시                           │
│                                                                 │
│  [테이블 추가]                                                   │
│  - mission_tag에 weather 카테고리 추가 (sunny/rainy/all)        │
│  - daily_mission_schedule에 fallback_mission_id 추가           │
│                                                                 │
│  [스케줄러 추가]                                                 │
│  - WeatherCheckJob (06:00)                                     │
│    → 날씨 API 조회 → Redis 캐싱                                 │
│    → 비 예보 시 오늘의 미션 자동 교체                            │
│                                                                 │
│  [개인화 미션 로직 수정]                                         │
│  - 태그 매칭 시 날씨 태그도 고려                                 │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘</code></pre><hr>
<h2 id="12-결정된-사항">12. 결정된 사항</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>결정</th>
</tr>
</thead>
<tbody><tr>
<td>오늘의 미션 방식</td>
<td>LLM 생성 → 관리자 선별 (수동 큐레이션)</td>
</tr>
<tr>
<td>LLM 활용</td>
<td>MVP에서 사용 (미션 생성 보조)</td>
</tr>
<tr>
<td>날씨 API</td>
<td>MVP 제외 → 2차</td>
</tr>
<tr>
<td>개인화 미션 노출</td>
<td>A/B 테스트로 결정</td>
</tr>
<tr>
<td>미션 풀 크기</td>
<td>절기당 45개 (LLM 50개 생성 → 45개 선별)</td>
</tr>
</tbody></table>
<hr>
<h2 id="13-미결-사항-tbd">13. 미결 사항 (TBD)</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>선택지</th>
<th>현재 상태</th>
</tr>
</thead>
<tbody><tr>
<td>예상 사용자 수</td>
<td>1천 / 1만 / 10만+</td>
<td>미정</td>
</tr>
<tr>
<td>알림 발송 시점</td>
<td>오전 / 점심 / 사용자 설정</td>
<td>미정</td>
</tr>
<tr>
<td>개인화 미션 개수</td>
<td>A안 3~5개 / B안 1개</td>
<td>A/B 테스트</td>
</tr>
<tr>
<td>A/B 테스트 비율</td>
<td>50:50 / 70:30</td>
<td>미정</td>
</tr>
<tr>
<td>LLM 서비스 선택</td>
<td>Claude / GPT / 기타</td>
<td>미정</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CI/CD] 무중단 배포로 전환하기]]></title>
            <link>https://velog.io/@heeun_98/CICD-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/CICD-%EB%AC%B4%EC%A4%91%EB%8B%A8-%EB%B0%B0%ED%8F%AC%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 25 Mar 2026 16:07:27 GMT</pubDate>
            <description><![CDATA[<h3 id="다운-타임이-존재">다운 타임이 존재</h3>
<p>기존 workflow는 기존 도커 컨테이너를 중단 후 ECR로 부터 pull 을 받아서 재실행하는 구조였다. workflow 는 다음과 같다.</p>
<pre><code class="language-shell">
  name: Deploy to EC2                                                                                                                            

  on:                                                                                                                                            
    push:                                                                                                                                        
      branches:                                                                                                                                  
        - master                                                                                                                                 
  jobs:                                                                                                                                          
    Deploy:                                                                                                                                      
      runs-on: ubuntu-latest                                                                                                                     
      steps:                                                                                                                                     
        - name: Github Repository 파일 불러오기                                                                                                  
          uses: actions/checkout@v4                                                                                                              

        - name: JDK 17버전 설치                                                                                                                  
          uses: actions/setup-java@v4                                                                                                            
          with:                                                                                                                                  
            distribution: temurin                                                                                                                
            java-version: 17                                                                                                                     

        - name: Grant execute permission for gradlew                                                                                             
          run: chmod +x ./gradlew                                                                                                                

        - name: 테스트 및 빌드하기                                                                                                               
          run: ./gradlew clean build                                                                                                             

        - name: AWS Resource 에 접근할 수 있게 AWS credentials 설정                                                                              
          uses: aws-actions/configure-aws-credentials@v4                                                                                         
          with:                                                                                                                                  
            aws-region: ap-northeast-2                                                                                                           
            aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}                                                                                  
            aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}                                                                          

        - name: ECR에 로그인                                                                                                                     
          id: login-ecr                                                                                                                          
          uses: aws-actions/amazon-ecr-login@v2                                                                                                  

        - name: Docker 이미지 생성                                                                                                               
          run: docker build -t meeteam-server .                                                                                                  

        - name: Docker 이미지에 Tag 붙이기                                                                                                       
          run: docker tag meeteam-server ${{ steps.login-ecr.outputs.registry }}/meeteam-server:latest                                           

        - name: ECR에 Docker 이미지 Push                                                                                                         
          run: docker push ${{ steps.login-ecr.outputs.registry }}/meeteam-server:latest                                                         

        - name: SSH 로 EC2 에 접속하기                                                                                                           
          uses: appleboy/ssh-action@v1.0.3                                                                                                       
          with:                                                                                                                                  
            host: ${{ secrets.EC2_HOST }}                                                                                                        
            username: ${{ secrets.EC2_USERNAME }}                                                                                                
            key: ${{ secrets.EC2_PRIVATE_KEY }}                                                                                                  
            script_stop: true                                                                                                                    
            script: |                                                                                                                            
              set -e                                                                                                                             
              cd /home/ubuntu/meeteam                                                                                                            
              docker stop meeteam-server || true                                                                                                 
              docker rm meeteam-server || true                                                                                                   
              docker compose pull meeteam-server                                                                                                 
              docker compose up -d meeteam-server 


</code></pre>
<p> <code>docker stop meeteam-server || true</code>
 여기서 서비스가 중단되고
<code>docker compose up -d meeteam-server</code>
 에서 서비스가 다시 시작되는 구조이다.</p>
<p> 해당 타임에는 중단타임이 존재하여 사용자는 서비스 이용을 못하는 구조였다.
 중단타임을 측정해보니 대략 5분 정도의 시간 동안은 서버에 내려가 있다.</p>
<h3 id="blue-green-무중단-배포로-전환">Blue/ Green 무중단 배포로 전환</h3>
 <p align="center">
  <img src="https://velog.velcdn.com/images/heeun_98/post/3a985a17-c1db-4db5-998a-9999cb98ab7f/image.png" width="70%">
</p>



<p> 무중단 배포 방식에는 여러 방식이 존재하지만, 미팀에서는 단일 인스턴스를 사용하므로
 Blue / Green 배포를 통해 무중단 배포로 전환을 하기로 했다.</p>
<p> 배포 전에는 443포트에 바인딩된 Nginx 프로세스와 8080 포트에 바인딩된 톰캣 프로세스가 실행중이다.
 Nginx로 들어온 요청은 8080 포트로 포트포워딩 한다. 배포가 시작되고, 새로운 톰캣 프로세스를 실행하고 
 8081로 바인딩한다. 이후 Nginx 는 포트포워딩을 8080 포트에서 8081 포트로 전환한다.</p>
<p> Nginx 가 포트전환을 위해서는 설정 변경이 필요하다. 설정 변경을 위해서 Nginx 가 Reload를 해야한다.
 Reload는 ReStart 와 다르게 설정만 다시 읽는 과정으로 서비스는 끊기지 않고 설정만 바뀐다.</p>
<p> Reload 하는 과정에서는 프로세스 내부에서</p>
<p> 기존 worker ---- 요청 처리 중
        ↓
새 설정 로드
        ↓
새 worker 생성
        ↓
기존 worker 종료 (요청 끝나면)</p>
<p>다음과 같은 과정이 일어난다. 여기서 worker 교체 순간이 완전 0ms 는 아니다. 즉 아주 짧은 타이밍에 실패가 발생할 수 있다. 아주 찰나에 순간이지만 다운 타임이 얼마나 발생하고, 사용자의 요청이 얼마나 실패하는지 확인하기 위해 테스트를 진행해 보았다.
테스트는 Jmeter 를 이용하여 300개의 동시 요청을 배포 전부터 배포 후까지 무한히 반복했다.</p>
<p align="center">
  <img src="https://velog.velcdn.com/images/heeun_98/post/94efd2d8-eb26-438e-95cb-3c615f78e2bd/image.png" width="500">
</p>


<p>빨간색 점은 성공한 요청이고 초록색은 실패한 요청이다.
아주 짧은 찰나의 순간에 하나의 요청만 실패했다. 이는 사용자 경험에 크게 영향을 미치지 않을것이라고 생각했다.
단 하나의 요청만 실패한 요청이였기에, 언제 사용자의 요청을 실패하는지 찾아보았지만, 너무 많은 요청이 존재해서 찾지 못했다.</p>
<h3 id="추가적인-고민">추가적인 고민</h3>
<p>프로세스가 전환되는 과정에서 새로운 톰캣 프로세스가 올라가고 기존 프로세스는 종료 될때, 순간적으로 N개 의 톰캣이 올라가 있다.
여기서 DB 의 Connection 이 부족해 새로운 프로세스가 올라갈때
커넥션풀을 생성하지 못할수도 있을것이다.</p>
<p>예를들어, DB의 Max connection 이 40 일때, 각각의 커넥션 풀의 기본값이 30이면 2개의 프로세스가 올라가 있는 동안, 일시적인 순간에는 60개의 커넥션이 필요할텐데, 그럼 30 * 2 &lt; 50 이므로 새로운 프로세스가 커넥션 풀 생성에 실패할수도 있을것이다.
미팀 프로젝트에서는 minimum pool size 가 크지 않고, 배포시에 2개의 프로세스 밖에 존재하지 않으므로 문제가 없을것이지만, 많은 프로세스가 올라갈 경우에는
스레드풀의 개수또한 신경써야할것이라고 생각한다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[인수테스트를 중심으로 안정망 구축하기 ]]></title>
            <link>https://velog.io/@heeun_98/%EC%9D%B8%EC%88%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91%EC%8B%AC%EC%9C%BC%EB%A1%9C-%EC%95%88%EC%A0%95%EB%A7%9D-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/%EC%9D%B8%EC%88%98-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%A4%91%EC%8B%AC%EC%9C%BC%EB%A1%9C-%EC%95%88%EC%A0%95%EB%A7%9D-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 16 Feb 2026 05:56:43 GMT</pubDate>
            <description><![CDATA[<h3 id="문제-상황-및-배경">문제 상황 및 배경</h3>
<p>몇개월 전 진행했던 프로젝트인 미팀을 몇개월 만에 다시 살려보고자 남아있는 팀원들이랑 방향성에 대해 논의해 보았다.
우선적으로 테스트 코드가 전무하니, 테스트를 구축과 동시에 프로덕션 코드를 리펙토링 하기로 하였다.
팀원이 많이 이탈했기 때문에, 팀원들이 작성한 코드마저 현재 남은 인원으로 리펙토링을 진행해야 했고 이 방대한 양의 프로덕션 코드를 리펙토링을 할 생각을 하니 시작하기도 전에 막막함과 피로감이 스멀스멀 올라왔다. 
현재 우리 서비스는 전혀 예상하지 못한 곳에서 장애가 존재했고, 프로젝트 기간중 상당한 시간을 예상하지 못한 장애를 해결하는데 시간을 보냈다.
예상하지 못한 장애를 미리 예방하고 싶고, 그러기 위해서는 단위테스트와 통합테스트가 구축이 되어야 한다고 판단했다.
하지만 현재 미팀 서비스의 프로덕션 코드에 단위테스트와 통합테스트를 도입하기에는, 클래스마다 역할과 책임은 분리 되어있지 않았기에, 테스트를 구축하는 작업 역시 많이 리소스가 필요했다.</p>
<h3 id="인수테스트를-통한-안정망-구축">인수테스트를 통한 안정망 구축</h3>
<hr>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/2360e538-d95f-4082-a3f3-747eded84f32/image.png" alt=""></p>
<p>이를 해결하기위해 먼저 <strong>API 기반의 E2E 인수테스트</strong>를 우선적으로 구축하고, 구축한 안정망을 통하여 프로덕션 코드를 리펙토링을 진행하기로 결심했다.
<strong>현재 상황에서는 프로덕션 코드와 관계 없이 테스트를 구축할수 있고</strong> 이 구축한 테스트의 보호 안에서 하나의 모듈의 대한 리펙토링을 진행후에 수시로 테스트를 진행하여 회귀 버그 또한 테스트를 통해 확인할 수 있다고 판단했다.
위의 사진과 같이 우선적으로 테스트를 통과를 목표를 잡고 <strong>모든 인수테스트가 통과하면 그 이후로는 자신감있게 리펙토링을 진행할 수 있다.</strong> 내가 생각한 작업 순서를 다음과 같다</p>
<ul>
<li>현재 프로젝트 시스템 분석</li>
<li>시스템 분석을 통한 리스크 분석 및 리스크 매트릭스 작성</li>
<li>리스크 매트릭스를 바탕으로 인수조건 도출</li>
<li>인수조건을 바탕으로 Gherkin 시나리오 작성</li>
<li>Gherkin 시나리오를 바탕으로 인수테스트 구축</li>
<li>인수테스트 통과를 목표로 프로덕션 코드 수정</li>
<li>프로덕션 코드 리펙토링</li>
</ul>
<hr>
<h3 id="시스템-분석--리스크-분석">시스템 분석 &amp; 리스크 분석</h3>
<p>프로젝트를 진행하지 몇개월의 시간이 흘렀고, AI 도구 또한 많이 발전되었다고 생각해 AI 에이전트의 도움을 받아 시스템을 분석하고, 이를 바탕을 리스크를 분석해 보았다.
먼저 리스크 평가 기준으로 <strong>비즈니스 임팩트, 변경 빈도, 버그 발생 가능성</strong> 등을 종합적으로 고려하여 리스크 매트릭스를 작성하였다.</p>
<table>
  <tr>
    <td width="50%">
      <img src="https://velog.velcdn.com/images/heeun_98/post/62789454-c7a9-4a79-bafb-2c95da08a6c4/image.png" width="100%"/>
    </td>
    <td width="50%">
      <img src="https://velog.velcdn.com/images/heeun_98/post/589b3613-d4b1-43b1-b238-cc6c5d89ad38/image.png" width="100%"/>
    </td>
  </tr>
</table>
AI 도구에게 우선순위를 받기 전, 현재 프로젝트에서 종합적으로 먼저 수정과 리펙토링을 해야되는 부분은 인증/인가와 관련된 부분이라고 판단했다.
AI 에이전트 역시 내가 생각한것과 같이 가장 우선순위가 높은 시나리오로 평가했다.


<h3 id="인수-조건-도출하기--gherkin-시나리오-작성하기">인수 조건 도출하기 &amp; Gherkin 시나리오 작성하기</h3>
<p>먼저 우선순위가 높은 순서에 맞게 인수조건을 도출하였다.
인수조건을 바탕으로 Gherkin 시나리오를 작성할 계획이라 인수조건을 명확하게 작성 해야한다고 판단했다.
인수조건들은 <strong>5W1H(누가, 무엇을, 언제, 어디서, 왜, 어떻게)</strong> 원칙으로 구체화하여 명확한 인수 조건을 정의했다.</p>
<p>이 정의한 인수조건을 바탕으로 Gherkin 시나리오를 작성했다.</p>
<img src="https://velog.velcdn.com/images/heeun_98/post/bbb49ce0-9d3a-41ee-9863-c72b0c397606/image.png" width="70%"/>
<p align="center">
  <small>feature 파일</small>
</p>


<br>
위의 사진은 feature 파일에 Gherkin 시나리오를 작성한 결과물이다.
하나의 시나리오에 맞는 테스트 코드를 인수테스트로 작성하였다.<br>
이 또한 AI 에이전트를 이용하여 작성하였다. 위의 feature 파일은 최종 시나리오지만, 처음에 작성한 시나리오에는 다음과 같은 문제가 있었다.

<ul>
<li>내부 구현과 관련된 용어</li>
<li>비개발 용어</li>
<li>시나리오 자체가 지나치게 세부적</li>
</ul>
<p>feature 파일은 목적에 따라 사용 범위가 다르겠지만, 이 문서는 팀 전체가 공유할 수 있는 팀 컨벤션으로서 역할을 할 수도 있다고 생각한다. 나는 이 문서를 통해 프론트엔드 개발자와 요구사항에 대한 싱크를 맞추는데 사용하였다.
위에 작성한 것이 항상 문제가 되는건 아니지만, feature 파일의 역할에 따라 문제가 될수도 있다고 생각한다.
또한, 지나치게 세부적인 시나리오를 만들어서 테스트를 작성해야하는지도 의문이다.
인수테스트의 목적은 결국 사용자 시나리오에 맞게 검증하는 용도라는 생각이 드는데, 모든 예외케이스에 대해서 작성한다거나 동시성 문제와 같은 문제는 오히려 통합테스트 or 단위테스트 or 슬라이스 테스트가 더 역할에 부합할것이다.</p>
<p><strong>Cucumber 라이브러리</strong>를 통해서 시나리오의 하나의 스텝마다 메서드를 1대1 대응 시켜서 테스트 코드를 작성할수 있었다. 이 라이브러리는 feature 에서 작성한 &quot;값&quot;들을 메서드 파라미터로 넘겨서 사용할수 있고, feature 파일 내부에서 곧 바로 테스트를 실행시킬수 있다.(플러인 설치)</p>
<p>시나리오를 작성할때는 비개발 용어와, 내부 구현과 관련없는 시나리오로 작성하려고 노력하였다.</p>
<p><strong>&quot;인증 토큰을 발급받는다&quot;</strong> 와 같은 스텝은 개발을 모르는 사람이라면 이해하기 어렵지만, 현재 미팀에서는 인가/인증과 관련된 로직에 알수 없는 버그들이 존재하기 때문에 해당 도메인에는 최대한 구체적으로 시나리오를 작성하려고 노력하였다.
또한, 내부 개발과 관련된 용어를 사용할경우에 프로덕션 코드의 수정이 발생할 경우
시나리오의 수정 또한 발생할수 있기 때문에 최대한 <strong>비지니스 용어를 이용하여 시나리오를 작성</strong>하는게 좋을것이다.</p>
<h3 id="인수테스트-작성하기">인수테스트 작성하기</h3>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/45df0106-e92f-4764-946e-3d1b3e58d79f/image.png" alt=""></p>
<p>인수테스트를 작성할때는 RestAssured 를 이용하여 테스트를 작성하였다.
추후에 프로덕션 코드를 리펙토링을 해야하기 때문에 E2E 기반 블랙박스 테스트를 하는것이 좋다고 판단하였고, 리펙토링을 하는 과정에서 코드가 수정되어도 영향을 받지 않기 때문에 이 방법을 선택하였다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/545ba76a-5b7b-485f-a94a-6af0654d96e9/image.png" alt=""></p>
<p>위의 사진과 같은 API 호출을 통한 부분은 별도의 도메인과 관련된 API 클래스에서 호출할수 있도록 위임을 하였다.
API 호출에 대한 부분을 스텝 메서드에 둘 경우 가독성 또한 떨어진다고 판단하였고, 테스트 코드를 보는 입장에서는 로그인 요청 구현방식 보다는 로그인 요청을 한다는 사실이 더 중요할것이다.
즉, 스텝에서는 한글 메서드명을 통해서 해당 메서드가 로그인을 요청한다는 사실만 알도록 하였다.</p>
<h3 id="testcontext-이용하기">TestContext 이용하기</h3>
<hr>
<p>Cucumber 를 사용하지 않고 하나의 테스트 메서드에 인수 테스트를 작성할 경우 지역변수를 사용하여 값을 공유하면 될것이다. 하지만 Cucumber 를 사용할 경우 스텝 별로 메서드를 작성하기 때문에 값을 공유할 무언가가 필요하다.
Context 라는 개념은 무엇인가를 담아서 저장하는 개념으로 사용한다.
나는 TestContext 를 이용하여 스텝 간의 값을 전달거나 공유하도록 하였다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/04c806e4-dbe7-48db-bd9a-3b620779de44/image.png" alt=""></p>
<p>Cucumber 에서는 @ScenarioScope 를 이용하여 하나의 시나리오가 끝나면
TestContext 를 초기화를 해준다. 별도의 clear 해주는 작업없이 이용가능한다.
도메인 별로 공유하는 값들은 이너 클래스를 이용하여 내부적으로 공유하도록 해주었다.</p>
<p>도메인 별로 inner 클래스를 이용하여 값을 저장하도록 구현하였지만, 도메인 별로 저장해야할 값이 많아질 경우에는 별로의 클래스로 분리하는것이 좋아보인다.</p>
<h3 id="최소한의-수정으로-인수테스트-통과하기">최소한의 수정으로 인수테스트 통과하기</h3>
<hr>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/f59c10de-5fa4-447a-bd50-2a72a4d30e2e/image.png" alt=""></p>
<p>프로젝트 지원 기능은 서비스의 핵심 기능이기 때문에, 비교적 다양한 시나리오를 작성했다.
다만 처음부터 모든 시나리오를 검증하기보다는,
Happy Case 2<del>3개, Error Case 2</del>3개, Edge Case 2~3개 정도로 시작하는 것이 적절하다고 판단했다.</p>
<pre><code class="language-java">
    @Counted(&quot;project.apply&quot;)
    @Override
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.PROJECT_NOT_FOUND));

        validateProjectNotCompleted(project);

        // 프로젝트 리더는 자신의 프로젝트에 지원 불가
        if (project.getCreator().getId().equals(memberId)) {
            throw new CustomException(ErrorCode.APPLICATION_SELF_PROJECT_FORBIDDEN);
        }

        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));

        if (applicationRepository.existsByProjectAndApplicant(project, member)) {
            throw new CustomException(ErrorCode.PROJECT_APPLICATION_ALREADY_EXISTS);
        }

        if (projectMemberRepository.existsByProjectIdAndMemberId(projectId, memberId)) {
            throw new CustomException(ErrorCode.PROJECT_MEMBER_ALREADY_EXISTS);
        }

        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -&gt; new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 해당 프로젝트에서 해당 포지션으로 모집 중인지 확인
        recruitmentStateRepository.findAvailableByProjectIdAndJobPosition(projectId, jobPosition)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.RECRUITMENT_POSITION_NOT_AVAILABLE));

        ProjectApplication application = ProjectApplication.create(
                project,
                member,
                jobPosition,
                request.motivation()
        );

        ProjectApplication savedApplication = applicationRepository.save(application);

        Long receiverId = project.getCreator().getId();
        Long actorId = member.getId();

        // 1. 팀장에게 지원 알림 저장
        Notification applyNotification = createNotification(
                project.getCreator(), project, actorId, NotificationType.PROJECT_APPLY, savedApplication.getId()
        );
        notificationRepository.save(applyNotification);

        // 2. 지원자에게 지원 완료 알림 저장
        Notification myApplyNotification = createNotification(
                member, project, actorId, NotificationType.PROJECT_MY_APPLY, savedApplication.getId()
        );
        notificationRepository.save(myApplyNotification);

        // 프로젝트 리더 알림 발행 (SSE)
        eventPublisher.publishEvent(new NotificationEvent(
                receiverId,
                project.getId(),
                actorId,
                NotificationType.PROJECT_APPLY,
                savedApplication.getId()
        ));

        // 지원자 알림 발행 (SSE)
        eventPublisher.publishEvent(new NotificationEvent(
                actorId,
                project.getId(),
                actorId,
                NotificationType.PROJECT_MY_APPLY
        ));

        log.info(&quot;프로젝트 지원 완료 - projectId: {}, applicantId: {}, jobPositionCode: {}&quot;,
                projectId, memberId, request.jobPositionCode());

        return ApplicationResponse.from(savedApplication);
    }
</code></pre>
<p>프로젝트 지원 메서드를 보면 하나의 메서드에서 너무 많은 역할을 수행하고 있다.
프로젝트 지원 처리뿐만 아니라, 검증 로직과 알림 처리까지 함께 포함되어 있어 책임이 과도하게 집중된 상태였다.</p>
<p>이러한 구조를 안정망 없이 리팩토링한다고 생각하면 상당히 부담스러운 작업이다.
하지만 인수 테스트라는 안정망을 구축해두었기 때문에,
기능이 깨지더라도 빠르게 버그를 탐지하고 수정할 수 있는 환경을 만들 수 있었다.
AI 에이전트에게 리팩토링을 맡기기 전에,
먼저 <strong>내가 원하는 방향을 정리한 컨벤션 문서를 작성</strong>하고 이를 Claude.md에 포함시켰다.
Claude Code에서는 @Convention.md와 같은 방식으로
컨텍스트에 포함된 문서를 참조할 수 있도록 구성했다.
처음에는 하나의 endpoint를 직접 리팩토링한 뒤,그 결과를 문서화하고 이를 기반으로 전체 리팩토링을 진행했다.
컨벤션 문서를 작성할 때는 단순한 설명보다 원하는 구조를 담은 예시 코드를 함께 제공하는 것이 가장 중요하다고 생각한다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/d9c3bcb9-6417-4c09-9d69-e767e9aac351/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/eac250af-dfeb-42b6-be6d-ad60388c545f/image.png" alt=""></p>
<p>이후 해당 컨벤션을 기반으로 리팩토링을 진행했다.
아래는 프로젝트 지원 메서드를 리팩토링한 결과이다.</p>
<pre><code class="language-java">


    @Counted(&quot;project.apply&quot;)
    @Override
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        // 1단계: 엔티티 조회
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.PROJECT_NOT_FOUND));
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));
        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -&gt; new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 2단계: 권한 검증
        validateNotSelfApplication(project, memberId);

        // 3단계: 비즈니스 규칙 검증
        validateApplyPrecondition(project, member, projectId, memberId);
        validateRecruitmentPosition(projectId, jobPosition);

        // 4단계: 지원서 생성 및 저장
        ProjectApplication savedApplication = createAndSaveApplication(project, member, jobPosition, request.motivation());

        // 5단계: 알림 발행 및 응답
        publishApplyNotifications(project, member, savedApplication);

        log.info(&quot;프로젝트 지원 완료 - projectId: {}, applicantId: {}, jobPositionCode: {}&quot;,
                projectId, memberId, request.jobPositionCode());

        return ApplicationResponse.from(savedApplication);
    }
</code></pre>
<p>역할을 분리하고 각 책임을 위임하면서,기존보다 훨씬 가독성이 좋아지고 구조가 명확해졌다.다만 이 과정에서 private 메서드가 많이 생성되었는데,
이는 하나의 클래스가 너무 많은 책임을 가지고 있다는 신호일 수도 있다.</p>
<p><strong>따라서 메서드가 더 늘어난다면,관련된 책임을 별도의 클래스로 분리하는 방향도 고려해야 한다.</strong>
리펙토링을 완료했으니, 프로젝트 지원과 관련된 인수테스트를 돌려보자.
<img src="https://velog.velcdn.com/images/heeun_98/post/4c5f431c-a445-4c42-bea8-b3c8b9cd3451/image.png" alt=""></p>
<p>모든 테스트가 성공적으로 통과하는 것을 확인할 수 있었다.만약 인수 테스트가 없었다면,Swagger를 통해 다음과 같은 과정을 모두 수동으로 검증해야 했을 것이다.</p>
<ul>
<li>지원 요청</li>
<li>지원 결과 확인</li>
<li>프로젝트 리더 알림 확인</li>
<li>지원자 알림 확인</li>
<li>프로젝트 인원 변화 확인</li>
</ul>
<p>이 과정을 매번 반복하는 것은 상당한 시간과 비용이 드는 작업이며,생각만 해도 부담스러운 과정이다.이전까지는 배포 전 수동 테스트에 많은 시간을 투자했지만,인수 테스트를 도입하면서 검증 시간을 크게 줄일 수 있었다.이번 작업의 목적은 프로덕션 코드 리팩토링이었지만,테스트 코드 또한 하나의 코드이기 때문에 유지보수가 가능한 형태로 작성하는 것이 매우 중요하다.그렇지 않으면 테스트 코드 역시 결국 방치되고 버려질 수 있기 때문에,테스트 코드 작성에도 많은 시간을 투자했다.테스트 코드 작성 과정은 다음 글에서 정리해보려고 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[test] - Mockito로 stubbing 하기]]></title>
            <link>https://velog.io/@heeun_98/test-Mockito%EB%A1%9C-stubbing-%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/test-Mockito%EB%A1%9C-stubbing-%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 19 Dec 2025 13:20:53 GMT</pubDate>
            <description><![CDATA[<h3 id="mockitobean">@MockitoBean</h3>
<p>MockitoBean 은 Application Context 
이미 존재하는 빈이면 <strong>Mock 빈</strong> 으로 대체한다.
존재하지 않으면 <strong>Mock 빈</strong> 으로 새로 등록한다.</p>
<p>가짜 객체를 빈으로 등록하고,
그 빈이 어떤 상태를 반환할지 정하면 된다.</p>
<p>위와 같이 Mock 빈과 같은 Mock 객체는 언제 사용하는것일까?</p>
<pre><code class="language-java">
@Transactional
@SpringBootTest
@ActiveProfiles(value = &quot;test&quot;)
class OrderStatisticsServiceTest {


    @Autowired
    private OrderStatisticsService orderStatisticsService;

    @Autowired
    private OrderProductRepository orderProductRepository;

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private MailSendHistoryRepository mailSendHistoryRepository;

    @MockitoBean
    private MailSendClient mailSendClient;


    @AfterEach
    void tearDown() {
        orderProductRepository.deleteAllInBatch();
        orderRepository.deleteAllInBatch();
        productRepository.deleteAllInBatch();
        mailSendHistoryRepository.deleteAllInBatch();
    }


    @DisplayName(&quot;결제완료 주문들을 조회하여 매출 통계 메일을 전송한다.&quot;)
    @Test
    void sendOrderStatisticsMail() {
        //given
        LocalDateTime now = LocalDateTime.of(2023, 3, 5, 0, 0);
        Product product1 = createProduct(HANDMADE, &quot;001&quot;, 1000);
        Product product2 = createProduct(HANDMADE, &quot;002&quot;, 2000);
        Product product3 = createProduct(HANDMADE, &quot;003&quot;, 3000);
        List&lt;Product&gt; products = List.of(product1, product2, product3);
        productRepository.saveAll(products);


        Order order1 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 4, 23, 59, 59), products);
        Order order2 = createPaymentCompletedOrder(now, products);
        Order order4 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 5, 23, 59, 59), products);
        Order order3 = createPaymentCompletedOrder(LocalDateTime.of(2023, 3, 6, 0, 0), products);

        // stubbing : 목 객체에 원하는 행위를 정의하는 것
        when(mailSendClient.sendEmail(any(String.class), any(String.class), any(String.class), any(String.class)))
                .thenReturn(true);

        // when
        boolean result = orderStatisticsService.sendOrderStatisticsMail(LocalDate.of(2023, 3, 5), &quot;test@test.com&quot;);

        //then
        assertThat(result).isTrue();

        List&lt;MailSendHistory&gt; histories = mailSendHistoryRepository.findAll();
        assertThat(histories).hasSize(1)
                .extracting(&quot;content&quot;)
                .contains(&quot;총 매출 합계는 12000원입니다.&quot;);
    }

    private Order createPaymentCompletedOrder(LocalDateTime now, List&lt;Product&gt; products) {
        Order order = Order.builder()
                .products(products)
                .orderStatus(PAYMENT_COMPLETED)
                .registeredDateTime(now)
                .build();
        orderRepository.save(order);

        return order;
    }


    private Product createProduct(ProductType type, String productNumber, int price) {
        return Product.builder()
                .type(type)
                .productNumber(productNumber)
                .price(price)
                .sellingStatus(SELLING)
                .name(&quot;메뉴 이름&quot;)
                .build();
    }

}


</code></pre>
<p>테스트 클래스의 필드를 보면 @Autowired 를 통해서 의존관계 주입을 해주었다.</p>
<p>하지만 하나의 클래스는 @Autowired 를 사용하지 않은것을 확인할 수 있다.</p>
<pre><code class="language-java">    @MockitoBean
    private MailSendClient mailSendClient;</code></pre>
<p>이건 왜 @Autowired 를 통해서 DI 를 하는것이 아닌 @MockitoBean 으로 가짜 빈을 Application Context 에 등록하는것일까</p>
<pre><code class="language-java">public boolean sendOrderStatisticsMail(LocalDate orderDate, String email) {

        // 해당 일자에 결제완료된 주문들을 가져와서
        List&lt;Order&gt; orders = orderRepository.findOrdersBy(
                orderDate.atStartOfDay(),
                orderDate.plusDays(1).atStartOfDay(),
                PAYMENT_COMPLETED
        );

        // 총 매출 합계를 계산하고

        int totalPrice = orders.stream()
                .mapToInt(order -&gt; order.getTotalPrice())
                .sum();

        // 메일 전송
        boolean result = mailService.sendMail(
                &quot;no-reply@cafekiosk.com&quot;,
                email,
                String.format(&quot;[매출통계] %s&quot;, orderDate),
                String.format(&quot;총 매출 합계는 %s원입니다.&quot;, totalPrice)
        );

        if (!result) {
            throw new IllegalArgumentException(&quot;매출 통계 메일 전송에 실패했습니다.&quot;);
        }

        return true;
    }
</code></pre>
<p>sendEmailService 내부에서 메일 전송을 한다.</p>
<pre><code class="language-java">
package sample.cafekiosk.spring.api.service.mail;


import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import sample.cafekiosk.spring.client.mail.MailSendClient;
import sample.cafekiosk.spring.domain.history.MailSendHistory;
import sample.cafekiosk.spring.domain.history.MailSendHistoryRepository;

@RequiredArgsConstructor
@Service
public class MailService {

    private final MailSendClient mailSendClient;
    private final MailSendHistoryRepository mailSendHistoryRepository;


    public boolean sendMail(String fromMail, String toEmail, String subject, String content) {

        boolean result = mailSendClient.sendEmail(fromMail, toEmail, subject, content);

        if (result) {
            mailSendHistoryRepository.save(MailSendHistory.builder()
                    .fromEmail(fromMail)
                    .toEmail(toEmail)
                    .subject(subject)
                    .content(content)
                    .build()
            );
            return true;
        }

        return false;
    }
}
</code></pre>
<p>여기서 실제로 메일을 전송하는 객체는 MailSendClient 가 담당한다.</p>
<p>그럼 MailSendClient 에서 외부 네트워크를 사용해서 매번 테스트를 진행하는건 매우 비효율적일것이다. 테스트를 할때마다 실제로 메일을 전송하는건 바람직하지 않다.</p>
<p>이런 경우에 Mock 객체를 사용해서 메일 전송 로직을 작동한다고 가정하고 테스트를 진행할 수 있다.</p>
<p>테스트 코드에서는 실제 객체가 아닌 가짜 객체를 등록해 사용하기 위해서 </p>
<p><code>@MockitoBean
    private MailSendClient mailSendClient;</code></p>
<p>   으로 Mock 빈으로 등록하는것이다.</p>
<p>Mock 빈을 이용해서 반환하도록 하는것을 Stubbing 이라고 한다.</p>
<pre><code class="language-java">
// stubbing : 목 객체에 원하는 행위를 정의하는 것
 when(mailSendClient.sendEmail(any(String.class), any(String.class), any(String.class), any(String.class)))
                .thenReturn(true);
</code></pre>
<p>원하는 행위를 정의하고 사용하면 된다.</p>
<p>참고로 위와 같은 과정은 Given 에서 진행하는것이 맞다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[미팀 - 지원은 실패, 알림은 성공? 트랜잭션 이벤트 정합성 해결]]></title>
            <link>https://velog.io/@heeun_98/%EB%AF%B8%ED%8C%80-%EC%95%8C%EB%A6%BC-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC</link>
            <guid>https://velog.io/@heeun_98/%EB%AF%B8%ED%8C%80-%EC%95%8C%EB%A6%BC-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98-%EB%B6%84%EB%A6%AC</guid>
            <pubDate>Fri, 05 Dec 2025 15:12:59 GMT</pubDate>
            <description><![CDATA[<p>미팀 프로젝트에서는 프로젝트 지원 후 지원자에게 SSE를 통한 알림을 전송해야 한다.
처음에 단순히, 아래와 같이 기능을 개발을 위해서 자연스럽게 다음과 같은 형태의 코드를 떠올렸다.</p>
<pre><code class="language-java">
@Service
@RequiredArgsConstructor
public class ProjectApplicationService {

    private final ProjectRepository projectRepository;
    private final MemberRepository memberRepository;
    private final JobPositionRepository jobPositionRepository;
    private final ApplicationRepository applicationRepository;
    // 이벤트를 발행하는 대신, 알림 서비스를 직접 의존합니다.
    private final NotificationService notificationService; 

    @Transactional
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        // 1단계: 엔티티 조회
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.PROJECT_NOT_FOUND));
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));
        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -&gt; new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 2단계: 권한 및 비즈니스 검증
        validateNotSelfApplication(project, memberId);
        validateApplyPrecondition(project, member, projectId, memberId);
        validateRecruitmentPosition(projectId, jobPosition);

        // 3단계: 지원서 생성 및 저장
        ProjectApplication application = ProjectApplication.builder()
                .project(project)
                .member(member)
                .jobPosition(jobPosition)
                .motivation(request.motivation())
                .build();
        ProjectApplication savedApplication = applicationRepository.save(application);

        // 4단계: 알림 서비스 직접 호출 (동기 방식의 핵심)
        // 알림 전송이 완료될 때까지 아래 로그와 리턴문은 실행되지 않고 대기합니다.
        notificationService.sendApplyNotification(project.getOwner(), member, savedApplication);

        log.info(&quot;프로젝트 지원 완료 - projectId: {}, applicantId: {}, jobPositionCode: {}&quot;,
                projectId, memberId, request.jobPositionCode());

        return ApplicationResponse.from(savedApplication);
    }
}

</code></pre>
<p>이 코드는 전형적인 동기 방식을 따른다. 동기 방식은 프로그램의 흐름을 직관적으로 이해할 수 있다.
하지만 동기 방식에서, 알림 전송과 같은 부가적인 기능을 만나면 고려할 부분이 있다.
먼저, 위의 요구사항에서 사용자가 지원을 완료하고 <strong>알림 전송</strong>을 받을때, 알림 전송을 하는 과정에서 Exception 이 발생하면 프로젝트 지원절차 역시 실패한다.
이게 맞는걸까? 생각을 해보면, 알림 전송을 실패했다고 지원까지 실패하는건 사용자가 원하지 않은 결과일것이다. 또한, 알림 전송을 완료하고 사용자에게 프로젝트 지원에 대한 응답을 보낼경우 응답시간 또한 지연될 것이다.
위와 같은 이유로 반드시 알림이 전송되고 지원이 완료되어야 하는게 아니라면, 동기 방식 대신 비동기 방식으로 연동하는 것을 고민해 볼 필요가 있다. 현재 구조에서 비동기 방식으로 구현할 경우, 알림전송에 대한 로직이 끝날때까지 기다리지 않고, 바로 프로젝트 지원 성공을 사용자에게 응답할 수 있다.</p>
<h3 id="알림-발송-로직을-비동기로-호출">알림 발송 로직을 비동기로 호출</h3>
<pre><code class="language-java">
@Service
@RequiredArgsConstructor
public class ProjectApplicationService {

    private final ProjectRepository projectRepository;
    private final MemberRepository memberRepository;
    private final JobPositionRepository jobPositionRepository;
    private final ApplicationRepository applicationRepository;

    // 알림 서비스를 직접 의존 (결합 발생)
    private final NotificationService notificationService; 

    @Transactional
    public ApplicationResponse apply(Long projectId, Long memberId, ApplicationRequest request) {
        // 1단계: 엔티티 조회
        Project project = projectRepository.findActiveById(projectId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.PROJECT_NOT_FOUND));
        Member member = memberRepository.findById(memberId)
                .orElseThrow(() -&gt; new CustomException(ErrorCode.MEMBER_NOT_FOUND));
        JobPosition jobPosition = jobPositionRepository.findByCode(request.jobPositionCode())
                .orElseThrow(() -&gt; new CustomException(ErrorCode.JOB_POSITION_NOT_FOUND));

        // 2단계: 권한 및 비즈니스 검증
        validateNotSelfApplication(project, memberId);
        validateApplyPrecondition(project, member, projectId, memberId);
        validateRecruitmentPosition(projectId, jobPosition);

        // 3단계: 지원서 생성 및 저장
        ProjectApplication application = ProjectApplication.builder()
                .project(project)
                .member(member)
                .jobPosition(jobPosition)
                .motivation(request.motivation())
                .build();
        ProjectApplication savedApplication = applicationRepository.save(application);

        // 4단계: 단순 비동기 호출 (Direct Async Call)
        // @Async가 붙은 메서드를 직접 호출합니다. 
        // 이 시점에 별도의 스레드에서 알림 발송 로직이 즉시 시작됩니다.
        notificationService.sendApplyNotification(project.getOwner(), member, savedApplication);

        // 5단계: 의도적인 예외 발생 (블로그 예시용)
        // 만약 여기서 알 수 없는 에러가 발생하여 트랜잭션이 롤백된다면?
        if (someErrorCondition) {
            throw new RuntimeException(&quot;DB 커밋 직전 에러 발생!&quot;);
        }

        log.info(&quot;프로젝트 지원 완료 - projectId: {}, applicantId: {}&quot;, projectId, memberId);
        return ApplicationResponse.from(savedApplication);
    }
}

</code></pre>
<p>@Async 를 이용하여 non-blocking 비동기로 알림 발송 로직을 실행했다.
하지만 이 과정에서는 전의 상황보다 더 심각한 상황이 발생할것이다.
비동기 처리를 하기 위해 스레드풀에서 스레드를 할당받고 알림을 보낸다.
하지만, 핵심 로직인 프로젝트 지원 스레드가 예외가 터지면, 롤백이 발생할것이고 결국 지원은 실패한다.
결과적으로 사용자는 <strong>&quot;지원이 실패했는데 지원 완료 알림을 받는&quot;</strong> 당황스러운 경험을 하게 된다.
이 문제를 어떻게 해야할까? 쉽게 생각하면 프로젝트 지원이 완료되고 알림 발송 로직이 실행되면 된다.
프로젝트 지원후 commit이 완료 되고 나서, 알림전송을 하기 위해서 다음과 같은 방식으로 구현을 하였다.</p>
<h3 id="facade-클래스로-commit-이후에-비동기-호출하기">Facade 클래스로 commit 이후에 비동기 호출하기</h3>
<pre><code class="language-java">// 상위 Facade 클래스
public void applyAndNotify(...) {
    applicationService.apply(...); // 여기서 트랜잭션 커밋 완료
    notificationService.sendAsync(...); // 커밋 완료 후 비동기 호출
}
</code></pre>
<p>Fascade 상위 클래스에서는 <code>apply</code> 이후에 commit 이 완료되고 나서 비동기로 비동기 호출을 하면 된다. 위에서 발생할수 있는 문제를 해결할수 있다.
하지만 나는 미팀에서는 Facade 클래스를 이용하지 않고, Spring Event 를 이용하여 비동기를 호출하였다. 현재 요구사항에서는 지원이 완료시에 알림전송만 하면된다. 하지만 추후에 지원후 외부 시스템을 이용하여 알림톡 전송을 하는등, 추가적인 요구사항이 생길경우 Facade 클래스가 의존하는 클래스가 많아질 것이다.
또한, 다른 개발자가 Facade 를 거치지 않고 applicationService.apply()를 직접 호출하면? 알림이 영영 가지 않을수 있다.
현재 상황에서는 위의 방식이 전혀 문제는 없다. 하지만 Spring Event에서의 TransactionEventListener는 다양한 케이스들에서 동작할수 있도록 제어가 가능하다.
After commit만 있는게 아니라 beforeCommit, beforeCompletion, AflterCompletion도 가능하고, 나는 commit 이후에 알림 발송을 하면 되었기에 아래와 같이 @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)와 @Async 를 이용했다.</p>
<pre><code class="language-java">
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(NotificationEvent e) {
      // 엔티티 조회를 SSENotificationService로 위임
      notificationService.notify(e);
}
</code></pre>
<p>여기서 <code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code>
를 사용할때 조심해야한다.
AFTER_COMMIT 을 보고 COMMIT 이 완료되었으니까, 새로운 @Transaction을 열면 된다고 생각하는 순간 commit 이 되지 않는다.
위의 코드는 현재 비동기로 처리되기 때문에 기존 트랜잭션과 다른 스레드를 사용하기 때문에 문제가 발생하지 않지만, 만약 동기적으로 실행할 경우에는 하나의 스레드를 계속해서 사용하기 때문에 commit이 안된다.
commit 이 왜 발생하지 않을까 ?
AFTER_COMMIT 이면 COMMIT 이후에 실행되기 때문에, 추가적으로 물리 트랜잭션을 새로 연다고 해도 COMMIT은 되어야 한다고 생각했다.
하지만 여기서 AOP를 통해서 <code>connection.commit()</code> 을 호출하여 물리적인 DB commit 을 하더라도
아직 DataSource 는 살아있고, 아직 connection을 release() 한 상태는 아니다.
즉, 자원정리는 아직 하지 않은 상태이고, DB에 commit을 보낸상태로 <code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code> 를 호출하는것이다. 이 어노테이션 이후에 상황은 다음과 같다</p>
<ul>
<li>기존 커넥션은 살아있음</li>
<li>DB에 commit 호출</li>
</ul>
<p>그럼 알림서비스에서  @Transaction을 열고 write 연산을 진행했다고 가정하자. 그럼 이 상황에서 기존 커넥션을 이용할것이다. 메서드 종료 시점에 commit 을 하겠지만, 여기서 장애가 발생하고 commit을 하지 못한다.
그 이유는 이미 기존에 commit을 호출하였기 때문에 write 연산을 해도 의미가 없기 때문이다.
반면 기존 커넥션을 살아있으므로 read 연산을 해도 에러가 발생하지 않는다.
<strong>즉, <code>@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)</code> 는 동기적으로 실행될 경우, DB에 commit 호출 이후, 그리고 커넥션을 커넥션풀에 반납하기 전에 실행된다</strong></p>
<p>물론 미팀에서 @Async 를 통해 비동기로 실행되기 때문에 별도의 스레드에서 실행되므로 문제가 발생하지 않지만, 해당 어노테이션이 일어나는 시점을 명확하게 인지할 필요가 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[자바] - 날짜와 시간]]></title>
            <link>https://velog.io/@heeun_98/%EC%9E%90%EB%B0%94-%EB%82%A0%EC%A7%9C%EC%99%80-%EC%8B%9C%EA%B0%84</link>
            <guid>https://velog.io/@heeun_98/%EC%9E%90%EB%B0%94-%EB%82%A0%EC%A7%9C%EC%99%80-%EC%8B%9C%EA%B0%84</guid>
            <pubDate>Sat, 29 Nov 2025 09:38:17 GMT</pubDate>
            <description><![CDATA[<h3 id="localdatetime">LocalDateTime</h3>
<ul>
<li><p>LocalDate: 날짜만 표현할 때 사용한다 
ex) <code>2013-11-21</code></p>
</li>
<li><p>LocalTime: 시간만을 표현할 때 사용한다. 시, 분, 초를 다룬다. 
ex) <code>08:20:30.213</code></p>
</li>
<li><p>LocalDateTime: LocalDate 와 LocalTime 을 합한 개념 
ex) <code>2013-11-21T08:20:30.213</code></p>
</li>
</ul>
<pre><code class="language-java">
public class LocalDateMain {
public static void main(String[] args) {

    LocalDate nowDate = LocalDate.now();
    LocalDate ofDate = LocalDate.of(2013, 11, 21);     
    System.out.println(&quot;오늘 날짜 = &quot; + nowDate);     
    System.out.println(&quot;지정 날짜 = &quot; + ofDate);
    //계산(불변)
    LocalDate plusDays = ofDate.plusDays(10); 
    System.out.println(&quot;지정 날짜+10d = &quot; + plusDays);
    } 
}
</code></pre>
<p><strong>실행결과</strong></p>
<pre><code>오늘 날짜 = 2024-02-09
지정 날짜 = 2013-11-21
지정 날짜+10d = 2013-12-01
</code></pre><p><strong>생성</strong></p>
<ul>
<li>now() : 현재 시간을 기준으로 생성한다.</li>
<li>of(. . .) : 특정 날짜를 기준으로 생성한다. 년, 월, 일을 입력할 수 있다.</li>
</ul>
<p><strong>계산</strong></p>
<ul>
<li>plusDays(): 특정 일을 더한다. 다양한 plusXxx() 메서드가 존재하다.</li>
</ul>
<p><strong>모든 날짜는 불변이므로, 변경이 발생하면 새로운 객체를 생성해서 반환한다.</strong></p>
<h3 id="localtime">LocalTime</h3>
<pre><code class="language-java">public class LocalTimeMain {
      public static void main(String[] args) {

        LocalTime nowTime = LocalTime.now();
        LocalTime ofTime = LocalTime.of(9, 10, 30);
        System.out.println(&quot;현재 시간 = &quot; + nowTime); 
        System.out.println(&quot;지정 시간 = &quot; + ofTime);

         LocalTime ofTimePlus = ofTime.plusSeconds(30);
        System.out.println(&quot;지정 시간+30s = &quot; + ofTimePlus);

    } 
}
</code></pre>
<p><strong>실행결과</strong></p>
<pre><code>현재 시간 = 11:52:51.219602 
지정 시간 = 09:10:30
지정 시간+30s = 09:11:00
</code></pre><p><img src="https://velog.velcdn.com/images/heeun_98/post/2cec057f-c588-4631-a9d6-47b3ceb54a10/image.png" alt=""></p>
<p><strong>LocalTime</strong>은 <strong>초</strong>가 항상 나와야 하는건 아니다</p>
<h3 id="localdatetime-1">LocalDateTime</h3>
<p><strong>LocalDateTime 클래스</strong></p>
<pre><code class="language-java">
public class LocalDateTime {
    private final LocalDate date;
    private final LocalTime time;
    ...
}</code></pre>
<pre><code class="language-java">package time;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
public class LocalDateTimeMain {
public static void main(String[] args) {

    LocalDateTime nowDt = LocalDateTime.now();

    LocalDateTime ofDt = LocalDateTime.of(2016, 8, 16, 8, 10, 1); 
    System.out.println(&quot;현재 날짜시간 = &quot; + nowDt); 
    System.out.println(&quot;지정 날짜시간 = &quot; + ofDt);
    //날짜와 시간 분리
    LocalDate localDate = ofDt.toLocalDate(); 
    LocalTime localTime = ofDt.toLocalTime(); 
    System.out.println(&quot;localDate = &quot; + localDate); 
    System.out.println(&quot;localTime = &quot; + localTime);

    //날짜와 시간 합체
    LocalDateTime localDateTime = LocalDateTime.of(localDate, localTime);
    System.out.println(&quot;localDateTime = &quot; + localDateTime);
    //계산(불변)
    LocalDateTime ofDtPlus = ofDt.plusDays(1000); 
    System.out.println(&quot;지정 날짜시간+1000d = &quot; + ofDtPlus);         
    LocalDateTime ofDtPlus1Year = ofDt.plusYears(1);             
    System.out.println(&quot;지정 날짜시간+1년 = &quot; + ofDtPlus1Year);
    //비교
    System.out.println(&quot;현재 날짜시간이 지정 날짜시간보다 이전인가? &quot; + nowDt.isBefore(ofDt));
    System.out.println(&quot;현재 날짜시간이 지정 날짜시간보다 이후인가? &quot; +
    nowDt.isAfter(ofDt));

    System.out.println(&quot;현재 날짜시간과 지정 날짜시간이 같은가? &quot; + nowDt.isEqual(ofDt));


}

</code></pre>
<p><strong>실행 결과</strong></p>
<pre><code>현재 날짜시간 = 2024-02-09T11:54:54.389163 
지정 날짜시간 = 2016-08-16T08:10:01 localDate = 2016-08-16
localTime = 08:10:01
localDateTime = 2016-08-16T08:10:01
지정 날짜시간+1000d = 2019-05-13T08:10:01 
지정 날짜시간+1년 = 2017-08-16T08:10:01 
현재 날짜시간이 지정 날짜시간보다 이전인가? false 
현재 날짜시간이 지정 날짜시간보다 이후인가? true 
현재 날짜시간과 지정 날짜시간이 같은가? false</code></pre><h2 id="기간-시간의-간격---duration-period">기간, 시간의 간격 - Duration, Period</h2>
<p>시간의 개념은 크게 2가지</p>
<ul>
<li><p>특정 시간의 시간(시각)</p>
<ul>
<li>이 프로젝트는 2013년 8월 16일 까지 완료해야해</li>
<li>다음 회의는 11시 30분에 진행한다.</li>
</ul>
</li>
<li><p>시간의 간격(기간)</p>
<ul>
<li>앞으로 4년은 더 공부해야 해</li>
<li>이 프로젝트는 3개월 남았어</li>
<li>라면은 3분 동안 끓어야 해</li>
</ul>
</li>
</ul>
<p>Period, Duration 은 시간의 <strong>간격</strong> 을 표현하는데 사용된다.
시간의 간격은 영어로 amount of time(시간의 양)으로 불린다.</p>
<p><strong>Period</strong>
두 날짜 사이의 간격을 년, 월, 일 단위로 나타낸다.</p>
<ul>
<li>이 프로젝트는 3개월 정도 걸릴 것 같아</li>
<li>기념일이 183일 남았어</li>
</ul>
<p><strong>Duration</strong>
두 시간 사이의 간격을 시, 분, 초(나노초) 단위로 나타낸다.</p>
<ul>
<li>라면을 끓이는 시간은 3분이야</li>
<li>영화 상영 시간은 2시간 30분이야</li>
<li>서울에서 부산까지는 4시간이 걸려</li>
</ul>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/627524e1-15a7-4b89-8ef0-f8de9ce19851/image.png" alt=""></p>
<p><strong>Period</strong></p>
<p>두 날짜 사이의 간격을 년,월,일 단위로 나타낸다.</p>
<pre><code class="language-java">
 public class Period {
      private final int years;
      private final int months;
      private final int days;
}
</code></pre>
<pre><code class="language-java"> package time;
  import java.time.LocalDate;
  import java.time.Period;
  public class PeriodMain {
public static void main(String[] args) { 

    //시간, 분, 초, 나노초 toHours(), toMinutes(), getSeconds(), getNano()
    //생성
    Period period = Period.ofDays(10);
    System.out.println(&quot;period = &quot; + period);
    //계산에 사용
    LocalDate currentDate = LocalDate.of(2030, 1, 1);
    LocalDate plusDate = currentDate.plus(period);
    System.out.println(&quot;현재 날짜: &quot; + currentDate);
    System.out.println(&quot;더한 날짜: &quot; + plusDate);
    //기간 차이
    LocalDate startDate = LocalDate.of(2023, 1, 1);
    LocalDate endDate = LocalDate.of(2023, 4, 2);
    Period between = Period.between(startDate, endDate);
    System.out.println(&quot;기간: &quot; + between.getMonths() + &quot;개월 &quot; + between.getDays() +     &quot;일&quot;); 
    }
}
</code></pre>
<p><strong>Period</strong> 생성 방법</p>
<ul>
<li><code>of()</code>: 특정 기간을 지정해서 <code>Period</code> 를 생성한다.<ul>
<li><code>of(년, 월, 일)</code></li>
<li><code>ofDays()</code></li>
<li><code>ofMonths()</code></li>
<li><code>ofYears()</code></li>
</ul>
</li>
</ul>
<p><strong>계산에 사용</strong></p>
<ul>
<li>2030 1월 1일에 10일을 더하면 2030년 1월 11일이 된다.라고 표현할 때 특정 날짜에 10일 이라는 기간을 더할수 있다.</li>
</ul>
<p><strong>기간 차이</strong></p>
<ul>
<li>2032년 1월 1일과 2023년 4월 2일간의 차이는 3개월 1일이다. 라고 표현할 때 특정 날짜의 차이를 구하면 기간이 된다.</li>
<li><code>Period.between(startDate, endDate)</code> 와 같이 특정 날짜의 차이를 구하면 Period 가 반환된다.</li>
</ul>
<h3 id="duration">Duration</h3>
<p>두 시간 사이의 간격을 시, 분, 초 단위로 나타낸다.</p>
<pre><code class="language-java">
public class Duration {
      private final long seconds;
      private final int nanos;
 }
</code></pre>
<p>내부에서 초를 기반으로 시, 분, 초를 계산해서 사용한다.</p>
<ul>
<li>1분 = 60초</li>
<li>1시간 = 3600초</li>
</ul>
<pre><code class="language-java">
package time;
  import java.time.Duration;
  import java.time.LocalTime;
  public class DurationMain {
    public static void main(String[] args) { 
    //생성
    Duration duration = Duration.ofMinutes(30);
    System.out.println(&quot;duration = &quot; + duration);
    LocalTime lt = LocalTime.of(1, 0);
    System.out.println(&quot;기준 시간 = &quot; + lt);
    //계산에 사용
    LocalTime plusTime = lt.plus(duration); 
    System.out.println(&quot;더한 시간 = &quot; + plusTime);
    //시간 차이
    LocalTime start = LocalTime.of(9, 0);
    LocalTime end = LocalTime.of(10, 0);
    Duration between = Duration.between(start, end);
    System.out.println(&quot;차이: &quot; + between.getSeconds() + &quot;초&quot;);
    System.out.println(&quot;근무 시간: &quot; + between.toHours() + &quot;시간 &quot; +
    between.toMinutesPart() + &quot;분&quot;); 
    }
}
</code></pre>
<p><strong>실행 결과</strong></p>
<pre><code>duration = PT30M
기준 시간 = 01:00
더한 시간 = 01:30
차이: 3600초
근무 시간: 1시간 0분</code></pre><p><strong>Duration 생성 방법</strong></p>
<ul>
<li>of(): 특정 시간을 지정해서 Duration을 생성한다.<ul>
<li>of(지정)</li>
<li>ofSeconds()</li>
<li>ofMinutes()</li>
<li>ofHours()</li>
</ul>
</li>
</ul>
<p><strong>계산에 사용</strong></p>
<ul>
<li>1:00에 30분을 더하면 1:30이 된다.라고 표현할 때 특정 시간에 30분이라는 시간(시간의 간격)을 더할 수 있다.</li>
</ul>
<p><strong>시간 차이</strong></p>
<ul>
<li>9시와 10시의 차이는 1시간이라고 표현할 때 시간의 차이를 구할 수 있다.</li>
<li>Duration.between(start, end)와 같이 특정 시간의 차이를 구하면 Duration이 반환된다.</li>
</ul>
<hr>
<h3 id="chronounit-vs-chrnonofield">ChronoUnit VS ChrnonoField</h3>
<ul>
<li><p>ChronoUnit → 시간의 간격(기간, 단위) 을 의미.</p>
<ul>
<li><p>예: 3일 차이 계산, 2시간 더하기</p>
</li>
<li><p>즉, “기간 단위”</p>
</li>
</ul>
</li>
<li><p>ChronoField → 시간 필드의 값 을 읽고 쓸 때 사용.</p>
<ul>
<li><p>예: 연도 읽기, 월 읽기, 분 읽기</p>
</li>
<li><p>즉, “연도/월/일/시/분 같은 필드”</p>
</li>
</ul>
</li>
</ul>
<p><strong>ChronoUnit은 단순히 ‘시간의 단위’를 의미하는 것이고,
날짜·시간의 ‘구체적인 필드 값’을 조회하려면 ChronoField를 써야 한다.</strong></p>
<h4 id="1-chronounit--단위기간을-나타낸다">1. ChronoUnit — 단위(기간)을 나타낸다</h4>
<pre><code class="language-java">long days = ChronoUnit.DAYS.between(date1, date2);
long hours = ChronoUnit.HOURS.between(time1, time2);

//차이 구하기
LocalTime lt1 = LocalTime.of(1, 10, 0);
LocalTime lt2 = LocalTime.of(1, 20, 0);


//중요
long secondsBetween = ChronoUnit.SECONDS.between(lt1, lt2);
System.out.println(&quot;secondsBetween = &quot; + secondsBetween);
long minutesBetween = ChronoUnit.MINUTES.between(lt1, lt2);
System.out.println(&quot;minutesBetween = &quot; + minutesBetween);</code></pre>
<p>여기서 DAYS, HOURS 는 필드 값이 아니라 시간 간격 단위일 뿐이다.</p>
<ul>
<li><code>long minutesBetween = ChronoUnit.MINUTES.between(lt1, lt2);</code> <ul>
<li>이 방식은 거의 암기를 할 정도로 잘 알고 있자</li>
<li>몇 분 차이가 나는지 확인하는 API</li>
</ul>
</li>
</ul>
<ul>
<li><p>“며칠 차이인지”</p>
</li>
<li><p>“몇 시간 차이인지”</p>
</li>
<li><p>“몇 분 차이인지”</p>
</li>
<li><p>“plusDays(3)”이나 “plusHours(2)” 같은 연산</p>
</li>
</ul>
<p>에 쓰인다.</p>
<p>✔️ 즉, <strong>ChronoUnit은 날짜·시간의 &quot;양(수량)&quot;</strong>을 다루는 것</p>
<h4 id="2-chronofield--날짜·시간-필드를-읽고-쓸-때-사용">2. ChronoField — 날짜·시간 &quot;필드&quot;를 읽고 쓸 때 사용</h4>
<pre><code class="language-java">LocalDateTime now = LocalDateTime.now();

int year = now.get(ChronoField.YEAR);
int month = now.get(ChronoField.MONTH_OF_YEAR);
int minute = now.get(ChronoField.MINUTE_OF_HOUR);
</code></pre>
<ul>
<li><p>올해가 몇 년인지</p>
</li>
<li><p>이번 달이 몇 월인지</p>
</li>
<li><p>현재 분(minute)이 몇 분인지</p>
</li>
<li><p>요일이 몇 번째 요일인지</p>
</li>
</ul>
<p>이런 “필드 값”을 읽을 때 쓰는 게 ChronoField야.</p>
<p>✔️ 즉, ChronoField는 날짜·시간을 구성하는 <strong>각 조각(필드)</strong>을 의미하는 것.</p>
<hr>
<p>날짜와 시간 문자열 파싱과 포맷팅</p>
<ul>
<li><p>포맷팅 : 날짜와 시간 데이터를 원하는 포맷의 문자열로 변경 (Date -&gt; String)</p>
</li>
<li><p>파싱: 문자열을 날짜와 시간 데이터로 변경하는 것, (String -&gt; Date)</p>
</li>
</ul>
<pre><code class="language-java">
package time.ex;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class FormattingMain2 {

    public static void main(String[] args) {

        //포맷팅 : 날짜와 시간을 문자로
        LocalDateTime now = LocalDateTime.of(2024, 12, 31, 13, 30, 59);

        DateTimeFormatter formatter = DateTimeFormatter.ofPattern(&quot;yyyy년MM월dd일 HH시mm분ss초&quot;);

        String format = now.format(formatter);

        System.out.println(format);



        //파싱
        String date = &quot;2025년11월29일 09시49분32초&quot;;

        DateTimeFormatter formatter1 = DateTimeFormatter.ofPattern(&quot;yyyy년MM월dd일 HH시mm분ss초&quot;);

        LocalDateTime dateTime = LocalDateTime.parse(date, formatter1);

        LocalTime time = dateTime.toLocalTime();

        System.out.println(time);

    }

}




</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Spring AOP] 하나의 프록시, 여러 Advisor]]></title>
            <link>https://velog.io/@heeun_98/Spring-AOP-%ED%95%98%EB%82%98%EC%9D%98-%ED%94%84%EB%A1%9D%EC%8B%9C-%EC%97%AC%EB%9F%AC-Advisor</link>
            <guid>https://velog.io/@heeun_98/Spring-AOP-%ED%95%98%EB%82%98%EC%9D%98-%ED%94%84%EB%A1%9D%EC%8B%9C-%EC%97%AC%EB%9F%AC-Advisor</guid>
            <pubDate>Mon, 20 Oct 2025 16:59:37 GMT</pubDate>
            <description><![CDATA[<p><strong>어떤 스프링 빈이 advisor1, advisor2 가 제공하는 포인트컷의 조건을 모두 만족하면 프록시 자동 생성기는 프록시를 몇개 생성할까?</strong></p>
<ul>
<li>프록시 자동 생성기는 프록시를 하나만 생성한다.
프록시 팩터리가 생성하는 프록시는 내부에 여러 Advisor등을 포함 할 수 있기 때문이다.</li>
</ul>
<p><strong>&lt;여러 프록시를 사용했을때&gt;</strong></p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/20196790-3d70-4c6b-9b98-b2c3360b601f/image.png" alt=""></p>
<p>만약에, 적용해야할 advisor가 10개라면 프록시를 10개 생성해야한다.
이 방법은 매우 비효율적이다.</p>
<p>Spring은 이를 해결하기위해 &#39;<strong>프록시 팩토리</strong>&#39; 객체를 지원한다.</p>
<p>아래의 코드를 보자</p>
<pre><code class="language-java">@Test
@DisplayName(&quot;하나의 프록시, 여러 어드바이저&quot;) void multiAdvisorTest2() {
     //proxy -&gt; advisor2 -&gt; advisor1 -&gt; target
     DefaultPointcutAdvisor advisor2 
     = new DefaultPointcutAdvisor(Pointcut.TRUE,
 new Advice2());
     DefaultPointcutAdvisor advisor1 
     = new DefaultPointcutAdvisor(Pointcut.TRUE,
 new Advice1());
     ServiceInterface target = new ServiceImpl();

     ProxyFactory proxyFactory1 = new ProxyFactory(target);
     proxyFactory1.addAdvisor(advisor2);
     proxyFactory1.addAdvisor(advisor1);

     ServiceInterface proxy = (ServiceInterface) proxyFactory1.getProxy();
//실행
     proxy.save();
}
</code></pre>
<p>코드를 보면 <code>proxyFactory.addAdvisor(adviosr1);</code>   , <code>proxyFactory.addAdvisor(adviosr2);</code> 를 통해 두개의 advisor 를 추가하는 코드를 확인할 수 있다. </p>
<p>자동 프록시 생성기는 모든 Advisor 빈들을 조회하고 pointcut의 프록시 적용 대상을 체크한다.</p>
<ul>
<li><code>advisor1</code> 의 포인트컷만 만족 프록시1개 생성, 프록시에 <code>advisor1</code> 만 포함</li>
<li><code>advisor1</code> , <code>advisor2</code> 의 포인트컷을 모두 만족 프록시1개 생성, 프록시에 <code>advisor1</code>,<code>advisor2</code> 모두 포함</li>
<li><code>advisor1</code> , <code>advisor2</code> 의 포인트컷을 모두 만족하지 않음 프록시가 생성되지 않음</li>
</ul>
<p><strong>자동 프록시 생성기</strong>
<img src="https://velog.velcdn.com/images/heeun_98/post/97c49bd7-f2b8-40dd-821d-6a065e56f668/image.png" alt=""></p>
<p>여기서 가장 중요한것은 &#39;빈&#39;으로 등록되는것은 real객체가 아니라 &#39;<strong>프록시 객체</strong>&#39; 라는점이다.</p>
<p>두개의 advisor의 포인트컷을 만족 시킬 경우 다음 그림과 같다
<img src="https://velog.velcdn.com/images/heeun_98/post/cb2d0ca2-1846-4cf4-ac1b-824934cfea4d/image.png" alt=""></p>
<p>프록시를 생성하는 &#39;주체&#39;는 프록시 팩토리이고
자동 프록시 생성기는 <strong>프록시 팩토리를</strong> 이용해서 프록시를 생성한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JVM21에 등장한 Virtual Thread (Context Switching Test)]]></title>
            <link>https://velog.io/@heeun_98/JVM21%EC%97%90-%EB%93%B1%EC%9E%A5%ED%95%9C-Virtual-Thread-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@heeun_98/JVM21%EC%97%90-%EB%93%B1%EC%9E%A5%ED%95%9C-Virtual-Thread-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Tue, 07 Oct 2025 06:55:13 GMT</pubDate>
            <description><![CDATA[<p>Virtual Thread 를 알아보기전에 기존의 Native Thread 의 context switching 이 발생하는 원리에 대해 알아보자</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/96766976-fb7f-4b8e-90e8-a1d54658aa1d/image.png" alt=""></p>
<p>기존의 JVM 에 존재하는 톰캣 스레드는 커널스레드와 1대1로 매핑되어 실행이 되는 구조이다.
지금까지 CPU 코어가 톰캣 스레드를 context switching 하여 스케줄링을 하는 구조라고 알고있었지만 , 그것은 커널스레드와 Native Thread가 1대1 대응이 되기때문에 중간과정이 생력된 것이였다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/12048cd0-ce69-4b86-b6f7-79f6347e923d/image.png" alt=""></p>
<p>운영체제 시간에 배웠듯이 context switching 이란
스레드가 sleep, I/O 처리 등을 진행할 동안 다른 OS 커널 스레드는 다른 Thread를 점유하여 작업하는 것을 말한다.</p>
<p>스레드는 프로세스의 공통영역을 제외하고 만들어지기 때문에, 프로세스에 비해 크기가 작아서 생성 비용이 적고, 컨텍스트 스위칭 비용이 저렴했기 때문에 주목받아 왔다.</p>
<p>그러나, 요청량이 급격하게 증가하는 서버 환경에서는 갈수록 더 많은 스레드 수를 요구하게 되었다. 스레드의 사이즈가 프로세스에 비해 작다고 해도, 스레드 1개당 1MB 사이즈라고 가정하면, 4GB 메모리 환경에서도 많아야 4,000개의 스레드를 가질 수 있다. 이처럼 메모리가 제한된 환경에서는 생성할 수 있는 스레드 수에 한계가 있었고, 스레드가 많아지면서 컨텍스트 스위칭 비용도 기하급수적으로 늘어나게 되었다.</p>
<p>Context Switching 과정에서 발생하는 과정은 생력하겠지만,간단하게 말하면</p>
<p>1.커널이 개입해서 현재 스레드의 상태를 <strong>PCB(Process Control Block) 에 저장</strong></p>
<p>2.새 스레드의 PCB를 불러와서 <strong>레지스터, 스택 포인터, 프로그램 카운터(PC)</strong> 복원</p>
<p>3.<strong>TLB flush, CPU 캐시 무효화, 유저↔커널 모드 전환</strong></p>
<p>이런 전체 과정이 전부 커널 모드에서 수행되기 때문에
CPU 캐시, TLB, 커널 진입 비용이 발생합니다.
결과적으로 스위칭 하나당 수 µs 이상 걸립니다.</p>
<p>서버는 더 많은 요청 처리량과 컨텍스트 스위칭 비용을 줄여야 했는데, 이를 위해 나타난 스레드 모델이 <strong>경량 스레드 모델인 Virtual Thread</strong>입니다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/32cfe4d1-3cc7-49dd-8b0a-1b05f8907464/image.png" alt=""></p>
<p>Virtual Thread는 기존 Java의 스레드 모델과 달리, 플랫폼 스레드와 가상 스레드로 나뉜다. 플랫폼 스레드 위에서 여러 Virtual Thread가 번갈아 가며 실행되는 형태로 동작합니다. 마치 커널 스레드와 유저 스레드가 매핑되는 형태랑 비슷하다.
가장 <strong>큰 특징은 Virtual Thread는 컨텍스트 스위칭 비용이 저렴</strong>하다는 것이다.</p>
<p>Thread는 기본적으로 최대 2MB의 스택 메모리 사이즈를 가지기 때문에, 컨텍스트 스위칭 시 메모리 이동량이 큽니다. 또한 생성을 위해선 커널과 통신하여 스케줄링해야 하므로, 시스템 콜을 이용하기 때문에 생성 비용도 적지 않다</p>
<p>하지만 Virtual Thread는 JVM에 의해 생성되기 때문에 시스템 콜과 같은 커널 영역의 호출이 적고, 메모리 크기가 일반 스레드의 1%에 불과합니다. 따라서 Thread에 비해 컨텍스트 스위칭 비용이 적다</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/7a6c5607-9cca-4c3c-b281-4b7c95c54308/image.png" alt=""></p>
<ol>
<li>실행될 virtual thread의 작업인 runContinuation을 carrier thread의 workQueue에 push 합니다.</li>
<li>Work queue에 있는 runContinuation들은 forkJoinPool에 의해 work stealing 방식으로 carrier thread에 의해 처리됩니다.</li>
<li>처리되던 runContinuation들은 I/O, Sleep으로 인한 interrupt나 작업 완료 시, work queue에서 pop되어 park과정에 의해 다시 힙 메모리로 되돌아갑니다.</li>
</ol>
<p>*<em>용어 정리 *</em>:</p>
<p><strong>Platfrom Thread</strong> : 톰캣 스레드, new Thread()로 만든 스레드, <strong>즉  OS 커널스레드와 1대1 대응하는 스레드는 모두 Platfrom Thread이다.</strong>
<strong>Carrier Thread</strong>: Platfrom Thread 의 한종류, Virtual Thread를 실행시키는 스레드이다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/f02bb0ce-8d5a-42c5-ab6b-2d058250ef39/image.png" alt="">
JDK21 에서의 Park() 하는 과정이다.
기존의 Thread가 Virtual 인지 일반 Thread 인지 확인을 한다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/6e27f242-ff51-4c28-a48f-f893dd14b7b3/image.png" alt=""></p>
<p>unpark()를 하는 과정을 보면
<code>vthread.switchToCarrierThread()</code> 함수를 볼수있다.
이 과정에서 UNPARKED 된 Virtual Thread 를 unmound()하고 다른 기존의 Carrier 스레드에게 mount()하는 과정이 발생한다.</p>
<hr>
<h3 id="platform-thread-vs-virtual-thread-성능-비교">Platform Thread VS Virtual Thread 성능 비교</h3>
<p>context switching 비용에서 발생하는 성능테스트를 위해서
docker compose 를 비용해 <strong>CPU 코어수 1개</strong>, 스레드당 <strong>sleep(30ms)</strong> 을 가정하고 테스트를 진행하였다.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/cdb9f9cc-0df3-4a12-88f9-03b7d4a870c6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/11396cba-80e2-4b01-a205-83e3bf1b469c/image.png" alt=""></p>
<p><strong>&lt;기존 PlatFrom Thread인 경우&gt;</strong>
<img src="https://velog.velcdn.com/images/heeun_98/post/c63c5fc0-8d56-40c9-8720-58a389ef7db6/image.png" alt=""></p>
<p><strong>&lt;기존 Virtaul Thread인 경우&gt;</strong>
<img src="https://velog.velcdn.com/images/heeun_98/post/689c7569-2999-4b73-bb26-ae14428b09a6/image.png" alt=""></p>
<p>가상의 유저를 500으로 하였을경우 위와 같은 결과가 발생하였지만
더 많은 트래픽이 발생할수록(Context Switching이 많이 발생) 할수록 성능차이가 많이 날것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SSE 를 이용한 이벤트 전송]]></title>
            <link>https://velog.io/@heeun_98/SSE-%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%A0%84%EC%86%A1</link>
            <guid>https://velog.io/@heeun_98/SSE-%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%9D%B4%EB%B2%A4%ED%8A%B8-%EC%A0%84%EC%86%A1</guid>
            <pubDate>Mon, 29 Sep 2025 08:48:57 GMT</pubDate>
            <description><![CDATA[<p>SSE 는 그냥 단순하게 단향방 통신에서 사용된다 이정도만 알고 사용하였다.
그저 HTTP에서 지원해는 기능? 정도 구나라고 생각했지만
<code>HTTP가 지원하는 기능</code>이라기보다는
<code>HTTP 위에 정의된 이벤트 전송 규약(HTML5 표준)</code>이라고 한다.
<strong>SSE는 HTTP 위에서 동작하는 표준화된 이벤트 스트리밍 방식이다.</strong></p>
<p>그래서 별도의 라이브러리 없이 사용할 수 있나보다....</p>
<h3 id="클라이언트-→-서버-구독-요청">클라이언트 → 서버 구독 요청</h3>
<pre><code class="language-javascript">const eventSource = new EventSource(&quot;/subscribe&quot;);
</code></pre>
<pre><code>
GET /subscribe HTTP/1.1
Host: api.example.com
Accept: text/event-stream

</code></pre><p>브라우저는 /subscribe에 HTTP GET 요청을 보낸다.
client 가 보내는 단순 GET 요청에 Accept: <code>text/event-stream</code> 헤더 포함만 하는것이다. <strong>(단순 요청)</strong></p>
<h3 id="서버-→-클라이언트-응답-sseemitter-반환">서버 → 클라이언트 응답 (SseEmitter 반환)</h3>
<pre><code class="language-java">    @GetMapping(value = &quot;/api/subscribe&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    @ResponseStatus(HttpStatus.OK)
    public SseEmitter subscribe(@AuthenticationPrincipal UserDetails userDetails,
                                // Last-Event-ID 헤더는 마지막으로 받은 이벤트부터 이벤트 스트리밍을 재개하는 데 사용됩니다.
                                @RequestHeader(value = &quot;Last-Event-ID&quot;, required = false, defaultValue = &quot;&quot;)
                                String lastEventId) {

        return sseNotificationService.subscribe(userDetails.getUsername(), lastEventId);

    }
</code></pre>
<pre><code>
HTTP/1.1 200 OK
Content-Type: text/event-stream;charset=UTF-8
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked
</code></pre><p>이때 컨트롤러가 <strong>SseEmitter</strong>를 반환하면
Spring MVC가 HTTP 응답을 끊지 않고 열린 스트림으로 바꿔줍니다.</p>
<ul>
<li>SseEmitter 는 간단하게 말하면 <strong>Event-stream 의 파이프역할(통로)</strong> 을 한다고 생각하면 쉽다.</li>
<li>Client 는 최초에 받은 <strong>SseEmitter</strong>(파이프) 를 통해서 이벤트를 통해서 받으면 된다.</li>
</ul>
<p>여기서 궁금한게 컨트롤러에서 단순하게 SSeEmitter 를 반환했을 뿐인데
헤더에 <code>Content-Type: text/event-stream</code>을 반환 하는것일까 ?</p>
<h4 id="spring-mvc-가-요청응답의-헤더를-추가해주는-과정을-찾아보았다">Spring MVC 가 요청응답의 헤더를 추가해주는 과정을 찾아보았다.</h4>
<p><strong>1. DispatcherServlet</strong></p>
<pre><code class="language-java">
// DispatcherServlet 내부
returnValueHandler.handleReturnValue(returnValue, returnType, mavContainer, webRequest);
</code></pre>
<p><strong>2. Handler 선택</strong></p>
<ul>
<li><code>SseEmitter</code>는 <code>ResponseBodyEmitter</code>를 상속</li>
<li>그래서 <code>ResponseBodyEmitterReturnValueHandler</code>가 선택됨</li>
</ul>
<pre><code class="language-java">public class ResponseBodyEmitterReturnValueHandler implements HandlerMethodReturnValueHandler {
    @Override
    public boolean supportsReturnType(MethodParameter returnType) {
        return ResponseBodyEmitter.class.isAssignableFrom(returnType.getParameterType());
    }
}</code></pre>
<p><strong>3. ResponseBodyEmitterReturnValueHandler 실행</strong></p>
<pre><code class="language-java">
@Override
public void handleReturnValue(Object returnValue, MethodParameter returnType,
        ModelAndViewContainer mavContainer, NativeWebRequest webRequest) throws Exception {

    mavContainer.setRequestHandled(true); // DispatcherServlet이 view 렌더링 안 함

    HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
    response.setHeader(&quot;Cache-Control&quot;, &quot;no-store&quot;);
    response.setContentType(&quot;text/event-stream;charset=UTF-8&quot;); // 🔹 여기서 헤더 세팅

    ResponseBodyEmitter emitter = (ResponseBodyEmitter) returnValue;
    emitter.initialize(response); // Emitter와 응답 OutputStream 연결
}

</code></pre>
<p><strong>4. Emitter 동작</strong></p>
<ul>
<li><p>이제 컨트롤러에서 반환한 <code>SseEmitter</code> 객체는 응답 <code>OutputStream</code>에 바인딩됨</p>
</li>
<li><p>이후 다른 스레드에서 <code>emitter.send(...)</code> 하면 → <code>response stream</code>에 바로 쓰여 나갑니다.</p>
</li>
<li><p>HTTP 응답은 <code>200 OK</code> 상태로 열려 있고, 끊어지지 않음</p>
</li>
</ul>
<p><code>SseEmitter.send()</code>가 내부적으로 <code>ServletResponse.getOutputStream().write(...)</code> 까지 이어지는 실제 I/O 플로우는 나중에 자바 입출력을 공부하고 복습해보자.</p>
<p>이제 이벤트가 발생했을때 (emitter.send를 했을때) Client 에 어떤 형태로 응답이 보내질까?
SSE 는 서버 -&gt; Client 니까 매번 새로운 HTTP 응답이 발생하는것인줄 알았다.</p>
<p>하지만 그게 아니라 최초의 응답의 헤더를 그대로 사용하는것이였다.</p>
<pre><code>HTTP/1.1 200 OK
Content-Type: text/event-stream;charset=UTF-8
Cache-Control: no-cache
Connection: keep-alive
Transfer-Encoding: chunked
</code></pre><p>응답 body (계속 append 됨)</p>
<pre><code>data: hello world
id: 1

event: notification
data: {&quot;msg&quot;:&quot;새 알림&quot;}
id: 2

data: ping</code></pre><p>정리하면</p>
<ul>
<li><p>헤더는 최초 한 번 내려가고 그 상태에서 <strong>연결 유지됨</strong>.</p>
</li>
<li><p>이벤트가 생길 때마다 body에 줄 단위로 <strong>append</strong>.</p>
</li>
<li><p>클라이언트(EventSource)는 이 body 스트림을 읽어서 이벤트로 파싱.</p>
</li>
</ul>
<hr>
<h3 id="sseemittersend--를-통한-event-전달-과정">SseEmitter.send()  를 통한 Event 전달 과정</h3>
<ol>
<li><strong>Emitter와 HTTP 응답 OutputStream 연결</strong></li>
</ol>
<ul>
<li>컨트롤러에서 return new SseEmitter() 하면
Spring MVC(ResponseBodyEmitterReturnValueHandler)가
이 emitter를 현재 HTTP 응답 스트림(ServletResponse.getOutputStream())과 연결해둡니다.
다시 말하면, <strong>HttpServletResponse.getOutputStream()을 얻어와서 emitter와 연결해둡니다.</strong></li>
<li><blockquote>
<p>즉, emitter 객체가 내부적으로 response outputStream을 가지고 있고, 그걸 통해 write 가능한 상태가 됩니다.</p>
</blockquote>
</li>
</ul>
<p>즉, emitter 객체가 내부적으로 response outputStream을 가지고 있고, 그걸 통해 write 가능한 상태가 됩니다.</p>
<ol start="2">
<li>**emitter.send() 호출 순간</li>
</ol>
<p>**</p>
<ul>
<li>내부적으로는 이렇게 처리돼요:<pre><code class="language-java">outputStream.write(&quot;data: ...\n\n&quot;.getBytes());
outputStream.flush();</code></pre>
즉, 호출 즉시 응답 body에 write + flush가 일어납니다.
메서드 종료와 무관
일반적인 @ResponseBody 응답은 메서드가 return 할 때 한 번에 body를 만들어 내려주죠.
SSE는 다릅니다. 
SseEmitter가 응답을 열어둔 상태라, 메서드가 끝났든 말든 send() 하는 순간 body에 즉시 append됩니다.</li>
</ul>
<ol start="3">
<li><strong>클라이언트 수신 시점</strong></li>
</ol>
<ul>
<li>서버에서 flush()까지 호출되면 TCP 버퍼를 통해 바로 클라이언트로 흘러갑니다.
클라이언트는 이걸 이벤트 블록 단위로 파싱해서 onmessage 또는 addEventListener로 전달받죠.</li>
</ul>
<h3 id="정리">정리</h3>
<p><strong>1.SseEmitter.send() 호출</strong></p>
<ul>
<li>서버 쪽에서 이벤트 데이터를 준비 (data: ..., id: ..., event: ... 같은 문자열)
이걸 Spring 내부가 비동기 TaskExecutor를 통해 HTTP 응답 body에 write + flush</li>
</ul>
<p><strong>2.응답 body는 이미 열려 있는 HTTP 스트림 (Content-Type: text/event-stream)</strong></p>
<ul>
<li>즉, send()가 호출될 때마다 body에 append 됨
헤더는 최초 한 번만 내려갔기 때문에 이후엔 body만 계속 이어짐</li>
</ul>
<p><strong>3.네트워크 전송</strong></p>
<ul>
<li>flush 된 데이터는 OS의 TCP 버퍼로 흘러가고
연결이 살아있는 클라이언트(EventSource)는 즉시 그 데이터를 수신</li>
</ul>
<p><strong>4.클라이언트 쪽 이벤트 발생</strong></p>
<ul>
<li>브라우저의 EventSource는 body에 새 블록이 append 될 때마다 파싱 → 이벤트 발생</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA - 페이징과 한계 돌파]]></title>
            <link>https://velog.io/@heeun_98/JPA-%ED%8E%98%EC%9D%B4%EC%A7%95%EA%B3%BC-%ED%95%9C%EA%B3%84-%EB%8F%8C%ED%8C%8C</link>
            <guid>https://velog.io/@heeun_98/JPA-%ED%8E%98%EC%9D%B4%EC%A7%95%EA%B3%BC-%ED%95%9C%EA%B3%84-%EB%8F%8C%ED%8C%8C</guid>
            <pubDate>Wed, 30 Jul 2025 10:03:05 GMT</pubDate>
            <description><![CDATA[<p>페이징 + 컬렉션 엔티티를 조회하려면 어떻게 해야할까</p>
<p>데이터가 뻥튀기 되지 않게 할수 없을까???</p>
<pre><code class="language-json">[
    {
        &quot;orderId&quot;: 1,
        &quot;name&quot;: &quot;userA&quot;,
        &quot;orderDate&quot;: &quot;2025-07-30T18:23:28.899788&quot;,
        &quot;orderStatus&quot;: &quot;ORDER&quot;,
        &quot;address&quot;: {
            &quot;city&quot;: &quot;서울&quot;,
            &quot;street&quot;: &quot;1&quot;,
            &quot;zipcode&quot;: &quot;1111&quot;
        },
        &quot;orderItems&quot;: [
            {
                &quot;itemName&quot;: &quot;JPA1 BOOK&quot;,
                &quot;orderPrice&quot;: 10000,
                &quot;count&quot;: 1
            },
            {
                &quot;itemName&quot;: &quot;JPA2 BOOK&quot;,
                &quot;orderPrice&quot;: 20000,
                &quot;count&quot;: 2
            }
        ]
    },
    {
        &quot;orderId&quot;: 2,
        &quot;name&quot;: &quot;userB&quot;,
        &quot;orderDate&quot;: &quot;2025-07-30T18:23:28.945129&quot;,
        &quot;orderStatus&quot;: &quot;ORDER&quot;,
        &quot;address&quot;: {
            &quot;city&quot;: &quot;진주&quot;,
            &quot;street&quot;: &quot;2&quot;,
            &quot;zipcode&quot;: &quot;2222&quot;
        },
        &quot;orderItems&quot;: [
            {
                &quot;itemName&quot;: &quot;SPRING1 BOOK&quot;,
                &quot;orderPrice&quot;: 20000,
                &quot;count&quot;: 3
            },
            {
                &quot;itemName&quot;: &quot;SPRING2 BOOK&quot;,
                &quot;orderPrice&quot;: 40000,
                &quot;count&quot;: 4
            }
        ]
    }
]
</code></pre>
<p>이렇게 order 를 기준으로 페이징을 할 경우는 어떻게 해야할까
쉽게 말해서 Order 엔티티는 OrderItem 과 일대다 관계이기 때문에
fetch join 을하고 페이징이 불가능하다.</p>
<ul>
<li>먼저 <strong>ToOne</strong>(OneToOne, ManyToOne) 관계를 모두 페치조인 한다. ToOne 관계는 row수를 증가시키지 않 으므로 페이징 쿼리에 영향을 주지 않는다.</li>
<li>컬렉션은 지연 로딩으로 조회한다</li>
<li>지연 로딩 성능 최적화를 위해 <code>hibernate.default_batch_fetch_size</code> , <code>@BatchSize</code> 를      적용한다.
  <strong>-hibernate.default_batch_fetch_size: 글로벌 설정</strong><ul>
<li>@BatchSize: 개별 최적화</li>
<li>이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다</li>
</ul>
</li>
</ul>
<pre><code class="language-java">spring:
   jpa:
     properties:
       hibernate:
         default_batch_fetch_size: 1000
</code></pre>
<p> *<em>장점 *</em></p>
<ul>
<li><p>쿼리 호출 수가 <code>1 + N</code> <code>1 + 1</code> 로 최적화 된다</p>
</li>
<li><p>조인보다 DB 데이터 전송량이 최적화 된다. (Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다. 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.)</p>
<ul>
<li>페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.</li>
<li>컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.</li>
</ul>
</li>
<li><p><em>결론*</em></p>
</li>
<li><p>ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치조인으로 쿼리 수 를 줄이고 해결하고, 나머지는 <code>hibernate.default_batch_fetch_size</code> 로 최적화 하자.
객체 그래프 탐색을 하다보면 알아서 batch fetch size 에 따라서 쿼리가 in 쿼리로 한꺼번에 가져올 것이다.</p>
</li>
</ul>
<p> XXToOne -&gt; DTO로 바로 조회, Fetch Join
 XXToMany -&gt; 페이징 필요없고 하나만 하는거면 컬렉션 페치조인 (최소한개만), 페이징할꺼면 다 XXXToOne Fetch 로 다가져오고, 나머지는 그냥 객체 그래프 탐색으로 지연로딩 + batch fetch size</p>
<p>참고로 컬렉션 페치 조인일때는 DTO 로 해결하지말자.
그리고 DTO 로 조회역시 SQL 을 짜는것과 유사하기 때문에 select 절에 컬렉션이 오는것 자체가 불가능해서 다른 방법을 해야한다.</p>
<p>대부분의 문제는 위에서 말한 엔티티를 직접 조회해서 DTO 로 변환하자.
그리고 컬렉션 fetch join 은 페이징을 안할꺼면 상관이 없지만 , 1개만 사용하자.
그리고 2개 이상사용해야하거나, 페이징을 해야되는 상황이면
ToOne 만 fetch join 하고 ToMany 는 default_batch_fetch_size 로 해결하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[JPA - 컬렉션 fetch join]]></title>
            <link>https://velog.io/@heeun_98/JPA-%EC%BB%AC%EB%A0%89%EC%85%98-fetch-join</link>
            <guid>https://velog.io/@heeun_98/JPA-%EC%BB%AC%EB%A0%89%EC%85%98-fetch-join</guid>
            <pubDate>Wed, 30 Jul 2025 08:40:55 GMT</pubDate>
            <description><![CDATA[<h3 id="fetch-join-을-하지-않았을때-발생하는-문제점">Fetch join 을 하지 않았을때 발생하는 문제점</h3>
<p>먼저 Fetch join 을 하지 않을때 어떤 문제점이 발생하는지 알아야 Fetch join 을 잘 사용할 수 있다</p>
<pre><code class="language-java">
@GetMapping(&quot;/api/v2/orders&quot;)
    public List&lt;OrderDto&gt; ordersV2() {
        List&lt;Order&gt; orders = orderRepository.findAllByString(new OrderSearch());
        List&lt;OrderDto&gt; result = orders.stream()
                .map(o -&gt; new OrderDto(o))
                .collect(Collectors.toList());

        return result;
    }
</code></pre>
<pre><code class="language-java">
@Data
    static class OrderDto {

        private Long orderId;
        private String name;
        private LocalDateTime orderDate;
        private OrderStatus orderStatus;
        private Address address;
        //속에 있는것도 DTO로 감싸자..!
        private List&lt;OrderItemDto&gt; orderItems;//DTO 안에도 엔티티 관련된 리스트가 있으면 안되고 모든걸 DTO
        //private List&lt;OrderItem&gt; orderItems -&gt; 이렇게 하지 말아라 , OrderItem이 엔티티라서 이렇게 하면 안됨

        public OrderDto(Order order) { // order 의 개수만큼 호출된다.
            orderId = order.getId();
            name = order.getMember().getName();//lazy 로딩 초기화 -&gt; Order 객체의 Member 자체가 실제 엔티티를 가진다.
            orderDate = order.getOrderDate();
            orderStatus = order.getStatus();
            address = order.getDelivery().getAddress();
            //여기서 루프를 돌면서 orderItems 의 select 문이 나간다
            System.out.println(&quot;===== OrderItems 루프 시작 ======&quot;);
            orderItems = order.getOrderItems().stream()
                    .map(orderItem -&gt; new OrderItemDto(orderItem))
                    .collect(Collectors.toList());
        }
</code></pre>
<pre><code class="language-java">
@Data
    static class OrderItemDto {
        //프론트(client)가 요구하는 api 스펙
        private String itemName;//상품명
        private int orderPrice;//주문 가격
        private int count;//주문 수량

        //private Item item -&gt; 그냥 OrderItem 인 경우 이런 엔티티까지 있었다. 필요한것만 가져와서 사용

        public OrderItemDto(OrderItem orderItem) {
            System.out.println(&quot;=======Item ====== &quot;);
            itemName = orderItem.getItem().getName();// depth 가 줄어든다.
            System.out.println(&quot;=====Item 끝 =====&quot;);
            orderPrice = orderItem.getOrderPrice();
            count = orderItem.getCount();
        }
    }

</code></pre>
<p>여기서 먼저 Lazy 로딩이 뭔지 알아보자.</p>
<p>엔티티를 접근해서 특정 필드에 접근하면 그때 연관관계에 있는 엔티티가 실제로 주입이 된다.
즉 order.getMember().getName() 다른 필드도 상관없음, 필드에 접근 getName() 을 한 경우 Order 의 Member 가 실제 엔티티가 주입인된다. 이렇게 주입이 어떻게 되는것이가?</p>
<p>기존에 Order 의 Member 은 프록시로 존재한다. 하지만 위와 같이 접근시 Member 엔티티를 select 하는 조회 쿼리가 추가로 발생한다.</p>
<p>쉽게 말하면, 객체 그래프 탐색을 하면 프록시로 객체가 존재하기 때문에 필드에 접근시 추가 쿼리가 생긴다는 것이다.
이건 매우 비효율적이다.</p>
<p>그럼 어떻게 하면 좋을까??
복잡한거 없다.  그냥 쿼리 조회할때 내가 필요한 엔티티 즉, client 에게 반환할 정보... 등 요구 사항에 맞춰서 필요한 데이터를 전부 쿼리를 통해서 한번에 가져오면 된다.
이렇게 가져오게 되면 Order 입장에서 Member 는 진짜 엔티티 Member 입장에서 Item 은 진짜 엔티티 이다.
이러면 추가 쿼리가 안생긴다.</p>
<pre><code class="language-java">
public List&lt;Order&gt; findAllWithItem() {
        return em.createQuery(
                &quot;select o from Order o&quot; +
                        &quot; join fetch o.member m&quot; + // member : xxToOne
                        &quot; join fetch o.delivery d&quot; + // delivery : xxToOne
                        &quot; join fetch o.orderItems oi&quot; +// -&gt; 데이터 뻥튀기 : xxToMany
                        &quot; join fetch oi.item i&quot;, Order.class)
                //.setFirstResult(1)
                //.setMaxResults(100) -&gt; 페이징 쿼리가 sql 이 발생하지 않고 메모리에서 처리하게 된다.
                .getResultList();//
    }</code></pre>
<p>다 가져오기 위해 fetch join 으로 다 가져왔다.
매우 간단하다.
이렇게 가져온 엔티티를 객체 그래프 탐색을 그냥 하면된다. </p>
<p>여기서 중요한것이 있다.
컬렉션 fetch join 을 할 경우 데이터가 row 에 중복이 생긴다는것이다.
즉 . Order 기준으로 OrderItem 은 List 로 존재한다. 그럼 둘이 join 을 해서 내가 Order 를 가져오면 어떻게 될까??
데이터가 뻥튀기 된다. 직접 확인해 보자.</p>
<p><img src="https://velog.velcdn.com/images/heeun_98/post/173c02e0-db02-4be5-81db-b7c36e0d15e1/image.png" alt=""></p>
<p>위의 findAllWithItem 을 컬렉션 fetch join을 통해서 한 결과이다ㅏ.
OrderId 를 보면 2개가 아니라 4개이다. 역시 뻥튀기 되었다.</p>
<p>그럼 어플리케이션 단에서 <strong>List</strong>&lt;<strong>Order</strong>&gt; <strong>order</strong> 으로 받을때도 중복 Order 가 생기는지 직접 확인해 보았다</p>
<pre><code class="language-java">
@GetMapping(&quot;/api/v3/orders&quot;)
    public List&lt;OrderDto&gt; ordersV3() {
        List&lt;Order&gt; orders = orderRepository.findAllWithItem();
        for (Order order : orders) {
            System.out.println(&quot;order ref = &quot; + order + &quot; id = &quot; + order.getId() );
        }
        List&lt;OrderDto&gt; result = orders.stream()
                .map(o -&gt; new OrderDto(o))
                .collect(Collectors.toList());

        return result;

    }
</code></pre>
<p>결과는 이상하게 2개만 나왔따.</p>
<pre><code>order ref = Order{id=1, member=jpabook.jpashop.domain.Member@765d8449, orderItems=[jpabook.jpashop.domain.OrderItem@610ff2f9, jpabook.jpashop.domain.OrderItem@7ca043fb], delivery=jpabook.jpashop.domain.Delivery@c07844, orderDate=2025-07-30T17:30:30.501906, status=ORDER} id = 1
order ref = Order{id=2, member=jpabook.jpashop.domain.Member@49dbc5cb, orderItems=[jpabook.jpashop.domain.OrderItem@3569594c, jpabook.jpashop.domain.OrderItem@2688a90d], delivery=jpabook.jpashop.domain.Delivery@682a17f8, orderDate=2025-07-30T17:30:30.576967, status=ORDER} id = 2</code></pre><p>이게 어떻게 된것인가.
하이버네이버 6이전에는 이렇게 중복된 Order 와 같이 데이터가 뻥튀기 되었을때 중복을 제거하기 위해서 
<code>distinct</code> 를 select 절 뒤에 추가하였다. 즉</p>
<ol>
<li>DB SQL -&gt; distinct 쿼리 발생</li>
<li>어플리케이션 단에서 중복 루트 엔티티 제거
두가지 기능을 지원하였다.</li>
</ol>
<p>하지만 지금은 distinct 없이도 컬렉션 fetch join 시에 알아서 distinct 를 추가해서 중복을 제거해준다고 한다. 매우 편해졌다.</p>
<hr>
<h3 id="컬렉션-fetch-join-의-문제점">컬렉션 Fetch join 의 문제점</h3>
<p>컬렉션 페치 조인을 하면 1 대 다 관계로 인해 데이터가 뻥튀기 된다( row 수 증가)
위에서 본것처럼 row 가 여러개 발생하기 때문에 페이징 쿼리가 발생하지 않는다
<strong>내가 Order 를 OrderItem 과 fetch join 을 하면 OrderItem 의 개수만큼 row 가 생긴다.</strong>
아래 코드를 보자</p>
<pre><code class="language-java">

 public List&lt;Order&gt; findAllWithItem() {
        return em.createQuery(
                &quot;select o from Order o&quot; +
                        &quot; join fetch o.member m&quot; + // member : xxToOne
                        &quot; join fetch o.delivery d&quot; + // delivery : xxToOne
                        &quot; join fetch o.orderItems oi&quot; +// -&gt; 데이터 뻥튀기 : xxToMany
                        &quot; join fetch oi.item i&quot;, Order.class)
                .setFirstResult(1)
                .setMaxResults(100) //-&gt; 페이징 쿼리가 sql 이 발생하지 않고 메모리에서 처리하게 된다.
                .getResultList();//
    }
</code></pre>
<p>아래가 발생한 실제 sql</p>
<pre><code class="language-mysql"> select
        o1_0.order_id,
        d1_0.delivery_id,
        d1_0.city,
        d1_0.street,
        d1_0.zipcode,
        d1_0.status,
        m1_0.member_id,
        m1_0.city,
        m1_0.street,
        m1_0.zipcode,
        m1_0.name,
        o1_0.order_date,
        oi1_0.order_id,
        oi1_0.order_item_id,
        oi1_0.count,
        i1_0.item_id,
        i1_0.dtype,
        i1_0.name,
        i1_0.price,
        i1_0.stock_quantity,
        i1_0.artist,
        i1_0.etc,
        i1_0.author,
        i1_0.isbn,
        i1_0.actor,
        i1_0.director,
        oi1_0.order_price,
        o1_0.status 
    from
        orders o1_0 
    join
        member m1_0 
            on m1_0.member_id=o1_0.member_id 
    join
        delivery d1_0 
            on d1_0.delivery_id=o1_0.delivery_id 
    join
        order_item oi1_0 
            on o1_0.order_id=oi1_0.order_id 
    join
        item i1_0 
            on i1_0.item_id=oi1_0.item_i
</code></pre>
<p><strong>나는 페이징 쿼리를 날렸는데 페이징 쿼리는 전혀 발생하지 않았다.</strong></p>
<p>즉 jpa 는 데이터 뻥튀기가 발생한것을 알고 페이징 쿼리 자체를 db 로 보내지 않는다. 
만약 보낸다고 해도 큰일이 발생한다.페이징이 안될것이다. 데이터 뻥튀기로인해서</p>
<p>여기서 어플리케이션 단에서 메모리위에서 페이징을 하지만 데이터가 엄청 많으면 메모리가 터질수도 있기때문에</p>
<p><strong>절대 , 일대다 fetch join 에서는 페이징을 하지말자.
페이징이 없으면 그냥 사용해도 된다 어차피 중복을 제거해주니까</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[멀티 스레드 - Lock , CAS 비교]]></title>
            <link>https://velog.io/@heeun_98/%EB%A9%80%ED%8B%B0-%EC%8A%A4%EB%A0%88%EB%93%9C-Lock-CAS-%EB%B9%84%EA%B5%90</link>
            <guid>https://velog.io/@heeun_98/%EB%A9%80%ED%8B%B0-%EC%8A%A4%EB%A0%88%EB%93%9C-Lock-CAS-%EB%B9%84%EA%B5%90</guid>
            <pubDate>Fri, 25 Jul 2025 07:35:58 GMT</pubDate>
            <description><![CDATA[<p>CAS 연산을 사용하면 Lock 을 사용했을 때보다 성능상 좋았다.</p>
<pre><code>
  BasicInteger: ms=39
  VolatileInteger: ms=455
  SyncInteger: ms=625
  MyAtomicInteger: ms=367
</code></pre><p>이런 결과가 왜 이러난 것일까?</p>
<p>예를 들어 스레드 100개를 동시에 실행했을때 한 쓰레드가 Lock 을 소유하게 되면
다른 스레드 99개는 <strong>전부 CPU 자원을 사용하지 않는 상태</strong>가 된다.(BLOCKING , WAITING)</p>
<p>하지만 <code>AtomicInteger</code> 와 같은  클래스는 내부적으로 CAS( Compare and swap) 을 사용한다.
다음 코드를 보자. 실제 AtomicInteger 클래스는 아니고 같은 원리로 구현해 보았다.</p>
<pre><code class="language-java">package thread.cas;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class CasMainV3 {

    private static final int THREAD_COUNT = 2;

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

        AtomicInteger atomicInteger = new AtomicInteger(0);

        System.out.println(&quot;start value = &quot; + atomicInteger.get());


        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                incrementAndGet(atomicInteger);
            }
        };

        List&lt;Thread&gt; threads = new ArrayList&lt;&gt;();

        for (int i = 0;i &lt; THREAD_COUNT; i++) {
            Thread thread = new Thread(runnable);
            threads.add(thread);
            thread.start();
        }

        for (Thread thread : threads) {
            thread.join();
        }

        int result = atomicInteger.get();
        System.out.println(atomicInteger.getClass().getName() + &quot; result = &quot; + result);


    }


    private static int incrementAndGet(AtomicInteger atomicInteger) {

        int getValue;
        boolean result;

        do {
            getValue = atomicInteger.get();
            log(&quot;getValue&quot; + getValue);
            sleep(100);
            //  (CAS 연산의 핵심은 값이 바뀌었어(값이 읽은거랑 다르네)? -&gt; 그럼 난 값을 바꾸지 않을꺼야 )
            //  ( 다르면 다시 시도)
            // 결국 내가 읽은 값과 같으면 값을 바꿔라 ㅎㅎ
            result = atomicInteger.compareAndSet(getValue, getValue + 1);
            log(&quot;result: &quot; + result);

        } while (!result);

        return getValue + 1; // atomicInteger.get() 을 하면 다른 쓰레드가 값을 덮어 씌어버릴수도있어서 그러지마.
    }
}


</code></pre>
<p>sleep(100) 아래 주석을 달았지만, CAS 연산의 핵심은
<code>result = atomicInteger.compareAndSet(getValue, getValue + 1);</code>  이 부분이다.</p>
<p><strong>읽은 값이랑 같으면 값을 바꾸고, 다르면 do-while 문을 반복한다.(CPU자원소모)</strong></p>
<p><code>CompareAndSet</code>은 참고로 비교하고 값을 연산하고 저장까지 하지만
CPU 하드웨어적으로 원자적 연산으로 취급한다.
여기서 동시성을 제어하기 위해서 CPU 자원을 소모하면 안좋은거 아닌가? 라는 생각이 들지만, 동시문 문제( 스레드 충돌) 은 간단한 원자적 연산 ( value ++ ) 일때는 CPU 의 연산속도가 매우매우 빠르게 때문에 충돌은 드물게 발생한다.</p>
<p>즉, 충돌이 드물게 발생을 하는데 LOCK 을 걸어서 싱글 스레드만 접근이 가능하게 하면 성능상 CAS 연산보다 안좋을 수 밖에 없다. 또한 LOCK 을 걸면 스레드의 상태가 변하기 때문에 거기서 발생하는 오버헤드 또한 크기 때문에 아무래도 성능상 좋지 않다.</p>
<p>하지만 위에서 말한것 처럼 CAS 연산은 , 스레드의 상태 변화가 없다.
100개의 스레드가 동시에 실행한다고 가정하면, 100개의 스레드가 모두 멀티스레드로 CPU 를 사용해 연산을 한다.</p>
<p>정리해보면
<strong>CAS(Compare-And-Swap)와 락(Lock) 방식의 비교</strong></p>
<p><strong>락(Lock) 방식</strong></p>
<ul>
<li>비관적(pessimistic) 접근법</li>
<li>데이터에 접근하기 전에 항상 락을 획득 다른 스레드의 접근을 막음</li>
<li>&quot;다른 스레드가 방해할 것이다&quot;라고 가정</li>
</ul>
<p><strong>CAS(Compare-And-Swap) 방식</strong> </p>
<ul>
<li>낙관적(optimistic) 접근법</li>
<li>락을 사용하지 않고 데이터에 바로 접근 충돌이 발생하면 그때 재시도</li>
<li>&quot;대부분의 경우 충돌이 없을 것이다&quot;라고 가정</li>
</ul>
<p>이를 학습한 경험으로 스레드간 충돌이 적으면(ex: value++) CAS 방식 즉 낙관적 락을 사용하는것이 좋을것이고, 충돌이 많이 발생하게 된다면, 락 방식을 고민해 볼 필요가 있다.</p>
<p><strong>아래는 Thread 1000개 를 1를 증가하는 연산을 동시에 실행했을때 결과</strong></p>
<pre><code class="language-java">
BasicIntegerresult: 973 //충돌횟수 여기서는 37회정도발생
VolatileIntegerresult: 984 // 충돌 발생
SyncIntegerresult: 1000 //충돌 x
MyAtomicIntegerresult: 1000 // 충돌 x</code></pre>
<p>앞서 스레드를 1000개를 동시에 실행했을때 중간에 sleep(100)을 줬음에도 불구하고 충돌이 1000번중 대략50번 밖에 발생하지 않았다.</p>
<p><strong>정리하자면 , 간단한 cpu 연산에서는 lock 보다는 CAS 연산을 사용하는것이 효과적이다.</strong></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java - 멀티스레드 CAS , LOCK, VOLATILE 성능 비교]]></title>
            <link>https://velog.io/@heeun_98/%EB%A9%80%ED%8B%B0%EC%93%B0%EB%A0%88%EB%93%9C-CAS-LOCK-VOLATILE-%EC%84%B1%EB%8A%A5%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@heeun_98/%EB%A9%80%ED%8B%B0%EC%93%B0%EB%A0%88%EB%93%9C-CAS-LOCK-VOLATILE-%EC%84%B1%EB%8A%A5%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Thu, 24 Jul 2025 17:38:27 GMT</pubDate>
            <description><![CDATA[<h2 id="cascompare-and-swap-vs-lock-성능-차이의-근본-원인">CAS(Compare-And-Swap) vs. Lock: 성능 차이의 근본 원인</h2>
<h3 id="1-비차단nonblocking-알고리즘">1. 비차단(Non‑Blocking) 알고리즘</h3>
<p>CAS는 <strong>비차단</strong> 알고리즘의 대표격입니다.  </p>
<ul>
<li>스레드는 공유 변수의 예상 값(<code>expected</code>)과 실제 메모리 값을 비교하고, 같을 때만 새로운 값으로 교체합니다.  </li>
<li>다른 스레드가 변수 값을 바꾸고 있더라도 해당 스레드는 <strong>대기(block)</strong>하지 않고 즉시 재시도(retry)할 뿐입니다.  </li>
<li>반면 <code>synchronized</code>나 <code>ReentrantLock</code>은 <strong>차단(blocking)</strong> 방식으로, 락을 얻지 못하면 스레드를 대기 큐에 넣고 문맥 전환(context switch)이 발생합니다.  </li>
</ul>
<h3 id="2-커널-모드-전환-및-스케줄링-오버헤드-제거">2. 커널 모드 전환 및 스케줄링 오버헤드 제거</h3>
<p>락(<code>synchronized</code>/<code>Lock</code>)을 획득·해제할 때는 JVM 내부에서  </p>
<ol>
<li>스핀 혹은 OS <strong>futex</strong>(fast userspace mutex) 호출  </li>
<li>대기 스레드를 OS 스케줄러에 등록  </li>
<li>컨텍스트 전환 및 스케줄링<br>과 같은 일이 일어납니다.<br>이 과정에서 발생하는 <strong>수백~수천 사이클</strong>의 오버헤드는,<br>CAS의 <strong>한두 개의 기계어 명령</strong>다.</li>
</ol>
<h3 id="3-캐시-일관성-프로토콜-최적화">3. 캐시 일관성 프로토콜 최적화</h3>
<p>모던 멀티코어 시스템은 캐시 일관성 프로토콜(MESI, MOESI 등)을 통해 캐시라인 단위로 동기화합니다.  </p>
<ul>
<li>CAS는 <strong>한 캐시라인</strong>에서 해당 필드만 교체하며, 다른 라인에는 영향을 주지 않습니다.  </li>
<li>락 해제/획득 시 JVM과 OS는 관련 캐시라인을 플러시(flush) 또는 무효화(invalidate)해야 하는데,<br>이는 불필요한 메모리 대역폭 및 추가 지연을 초래합니다.</li>
</ul>
<h3 id="4-스케일-아웃scale-out-시-컨텐션contention-감소">4. 스케일 아웃(Scale-Out) 시 컨텐션(Contention) 감소</h3>
<ul>
<li><strong>낮은 컨텐션</strong>: 스레드가 짧은 주기로 재시도만 하기 때문에, 다른 스레드와 경쟁이 발생해도 전체 처리량이 급격히 떨어지지 않습니다.  </li>
<li><strong>높은 컨텐션</strong>: 락의 경우 한 번 락 보유자가 길게 작업 중이면, 다른 모든 스레드는 대기상태로 빠지며 CPU 자원을 활용하지 못합니다.  </li>
</ul>
<p>예를 들어 100개의 스레드가 동시에 <code>increment()</code>를 호출하는 상황에서,  </p>
<ul>
<li>CAS는 각 스레드가 독립적으로 <code>compareAndSet()</code>을 재시도하며 진행  </li>
<li>락은 1개의 스레드만 진입 후 나머지는 전부 블록 → CPU 유휴 증가  </li>
</ul>
<h3 id="5-jvm과-하드웨어의-협업">5. JVM과 하드웨어의 협업</h3>
<ul>
<li><code>java.util.concurrent.atomic</code> 패키지의 <code>AtomicInteger</code>나 <code>AtomicReference</code>는 <strong>Unsafe</strong> API를 통해 네이티브 CAS 명령어와 직접 연동합니다.  </li>
<li>JVM은 메모리 배리어(memory barrier)를 적절히 삽입해, <strong>메모리 가시성</strong>을 확보하면서도 <strong>불필요한 배리어 비용</strong>은 최소화합니다.</li>
</ul>
<h3 id="결론-및-실제-벤치마크-결과">결론 및 실제 벤치마크 결과</h3>
<p>본 벤치마크(<code>COUNT = 100_000_000</code>) 환경에서 측정된 실행 시간:  </p>
<table>
<thead>
<tr>
<th>구현체</th>
<th>걸린 시간(ms)</th>
</tr>
</thead>
<tbody><tr>
<td>BasicInteger</td>
<td>8</td>
</tr>
<tr>
<td>VolatileInteger</td>
<td>327</td>
</tr>
<tr>
<td>SyncInteger</td>
<td>765</td>
</tr>
<tr>
<td>MyAtomicInteger</td>
<td>342</td>
</tr>
</tbody></table>
<ul>
<li><strong>BasicInteger</strong>(락/동기화 없이 순수 연산): 최상의 성능  </li>
<li><strong>MyAtomicInteger</strong>(CAS): 락 기반 방식보다 약 2배 빠른 처리  </li>
<li><strong>SyncInteger/ReentrantLock</strong>(락): 커널 전환과 스케줄링 오버헤드로 크게 느림  </li>
<li><strong>VolatileInteger</strong>(<code>volatile</code>만): 메모리 배리어 비용으로 CAS보다 빠르지만 쓰기 비용 증가  </li>
</ul>
<p>CAS 연산이 빠른 이유는 “작고 단일한 하드웨어 원자 명령” + “락을 얻기 위한 시스템 콜 불필요” + “캐시 일관성만 최소한으로 유지”하기 때문입니다.<br>락 기반 동기화가 필요한 복잡한 임계영역이 아니라, <strong>단일 변수 증감</strong>처럼 간단한 동시성 제어에는 CAS가 훨씬 더 적합합니다. </p>
<hr>
<p>아래 코드는 실제 테스트해본 코드입니다.</p>
<pre><code class="language-java">package thread.cas.increment;


public class IncrementPerformanceMain {

    public static final long COUNT = 100_000_000;

    public static void main(String[] args) {

        test(new BasicInteger());
        test(new VolatileInteger());
        test(new SyncInteger());
        test(new MyAtomicInteger());

    }

    private static void test(IncrementInteger incrementInteger) {
        long startMs = System.currentTimeMillis();

        for (int i = 0; i &lt; COUNT; i++) {
            incrementInteger.increment();
        }

        long endMs = System.currentTimeMillis();

        System.out.println(incrementInteger.getClass().getSimpleName() + &quot;: ms=&quot; + (endMs - startMs));

    }
}


</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Redis - 사용, 실습]]></title>
            <link>https://velog.io/@heeun_98/Redis-%EC%82%AC%EC%9A%A9-%EC%8B%A4%EC%8A%B5</link>
            <guid>https://velog.io/@heeun_98/Redis-%EC%82%AC%EC%9A%A9-%EC%8B%A4%EC%8A%B5</guid>
            <pubDate>Thu, 24 Jul 2025 17:32:25 GMT</pubDate>
            <description><![CDATA[<p>Redis <strong>단일 쓰레드</strong>에서 실행이 되기 때문에 동시성 문제에 대해 고민할 필요 조차 없다.</p>
<ul>
<li>레디스 주요 특징<ul>
<li>key-value로 구성된 단순화된 데이터 구조로 sql 쿼리 사용 불필요</li>
<li>빠른 성능<ul>
<li>인메모리 NoSQL 데이터베이스로서 빠른 성능<ul>
<li>rdb는 기본적으로 disk에 저장이고 필요시에 메모리에 캐싱하는 것이므로, rdb보다 훨씬 빠른 성능</li>
<li>redis의 메모리상의 데이터는 주기적으로 스냅샷 disk에 저장</li>
</ul>
</li>
<li>key-value는 구조적으로 해시 테이블을 사용함으로서 매우 빠른 속도로 데이터 검색 가능</li>
</ul>
</li>
<li>Single Thread 구조로 동시성 이슈 발생X</li>
<li>윈도우 서버에서는 지원하지 않고, linux서버 및 macOS등에서 사용 가능</li>
</ul>
</li>
</ul>
<p>레디스 명령어를 직접 이용할 일은 없지만 한번 쯤 사용해 보자</p>
<pre><code class="language-bash"># redis설치(linux)
sudo apt-get install redis-server
# redis접속
redis-cli

# redis도커설치(윈도우, mac)
docker run --name redis-container -d -p 6379:6379 redis
# docker 컨네이너 조회
docker ps
# redis도커 접속
docker exec -it &lt;containerID&gt; redis-cli

# redis는 0~15번까지의 database로 구성(default는 0번 db)
# 데이터베이스 선택
select db번호

# 데이터베이스내 모든 키 조회
keys *

# 일반적인 String 자료구조

# set을 통해 key:value 세팅.
set user:email:1 hong1@naver.com
set user:email:2 &quot;hong2@naver.com&quot;
# nx : 이미존재하면 pass, 없으면 set 
set user:email:1 hong1@naver.com nx
# ex : 만료시간(초단위) - ttl(time to live)
set user:email:1 hong1@naver.com ex 10
# redis활용 : refresh토큰등 사용자 인증정보 저장
set user:1:refresh_token eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ex 10000
# get을 통해 value값 얻기
get user:1:refresh_token

# 특정 key삭제
del user:email:1
# 현재 DB내 모든 key삭제
flushdb

# redis활용 : 좋아요기능 구현
set likes:posting:1 0
incr likes:posting:1 #특정 key값의 value를 1만큼 증가
decr likes:posting:1 #특정 key값의 value를 1만큼 감소
get likes:posting:1
# redis활용 : 재고관리(동시성이슈 해결)
set stocks:product:1 100
decr stocks:product:1
get stocks:product:1

# redis활용 : 캐싱 기능 구현
# 1번 member 회원 정보 조회
# select name, email, age from member where id=1;
# 위 데이터의 결과값을 redis로 캐싱 -&gt; json형식으로 저장 {&quot;name&quot;:&quot;hong&quot;, &quot;email&quot;:&quot;hong@daum.net&quot;, &quot;age&quot;:30}
set member:info:1 &quot;{\&quot;name\&quot;:\&quot;hong\&quot;, \&quot;email\&quot;:\&quot;hong@daum.net\&quot;, \&quot;age\&quot;:30}&quot; ex 20

# list자료구조
# redis의 list는 deque와 같은 자료구조, 즉 doueble-ended queue구조

# lpush : 데이터를 왼쪽에 삽입
# rpush : 데이터를 오른쪽에 삽입
# lpop : 데이터를 왼쪽에서 꺼내기
# rpop : 데이터를 오른쪽에서 꺼내기
lpush hongildongs hong1
lpush hongildongs hong2
rpush hongildongs hong3
rpop hongildongs
lpop hongildongs

# list조회
# -1은 리스트의 끝자리를 의미. -2는 끝에서 2번째를 의미.
lrange hongildongs 0 0 #첫번째값
lrange hongildongs -1 -1 #마지막값
lrange hongildongs 0 -1 #처음부터마지막
lrange hongildongs -3 -1 #마지막3번째부터 마지막까지
lrange hongildongs 0 2

# 데이터 개수 조회
llen hongildongs
# ttl 적용
expire hongildongs 20
# ttl 조회
ttl hongildongs
# pop과 push를 동시에
# A리스트에서 POP하여 B리스트로 PUSH
rpoplpush A리스트 B리스트

# redis활용 : 최근 방문한 페이지
# 5개정도 데이터 push
# 최근방문한 페이지 3개만 보여주는
rpush mypages www.naver.com
rpush mypages www.google.com
rpush mypages www.daum.net
rpush mypages www.chatgpt.com
rpush mypages www.daum.net
lrange mypages -3 -1

# set자료구조 : 중복없음. 순서없음.
sadd memberlist member1
sadd memberlist member2
sadd memberlist member1

# set 조회
smembers memberlist
# set멤버 개수 조회
scard memberlist 
# set에서 멤버 삭제
srem memberlist member2
# 특정 멤버가 set안에 있는지 존재여부 확인
sismember memberlist member1

# redis활용 : 좋아요 구현
sadd likes:posting:1 member1
sadd likes:posting:1 member2
sadd likes:posting:1 member1
scard likes:posting:1
sismember likes:posting:1 member1

# zset : sorted set
# 사이에 숫자는 score라고 불리고, score를 기준으로 정렬
zadd memberlist 3 member1
zadd memberlist 4 member2
zadd memberlist 1 member3
zadd memberlist 2 member4

# 조회방법
# score기준 오름차순 정렬
zrange memberlist 0 -1
# score기준 내림차순 정렬
zrevrange memberlist 0 -1

# zset삭제
zrem memberlist member4

# zrank : 특정 멤버가 몇번째(index 기준) 순서인지 출력
zrank memberlist member4

# redis 활용 : 최근 본 상품목록
# zset을 활용해서 최근시간순으로 정렬
# zset도 set이므로 같은 상품을 add할 경우에 시간만 업데이트되고 중복이 제거
# 같은 상품을 더할경우 시간만 마지막에 넣은 값으로 업데이트(중복제거)
zadd recent:products 151930 pineapple
zadd recent:products 152030 banana
zadd recent:products 152130 orange
zadd recent:products 152230 apple
zadd recent:products 152330 apple
# 최근본 상품목록 3개 조회
zrevrange recent:products 0 2
zrevrange recent:products 0 2 withscores

# redis활용사례 : 주식시세저장
# 종목명: 삼성전자, 시세: 72000원, 시간: 1672527600 (유닉스 타임스탬프) -&gt; 년월일시간을 초단위로 변환한것.(밀리초 단위도 가능능)
zadd stock:prices:samsung 1672527600 &quot;53000&quot;
# 종목명: LG전자, 시세: 95000원, 시간: 1672527660
zadd stock:prices:lg 1672527660 &quot;95000&quot;
# 종목명: 삼성전자, 시세: 72500원, 시간: 1672527720
zadd stock:prices:samsung 1672527720 &quot;72500&quot;
# 종목명: LG전자, 시세: 94500원, 시간: 1672527780
zadd stock:prices:lg 1672527780 &quot;94500&quot;
# 삼성전자의 최신 시세 조회 (최대 1개)
zrevrange stock:prices:samsung 0 0 withscores

# hashes : map형태의 자료구조(key:value key:value ... 형태의 자료구조)
hset author:info:1 name hong email hong@naver.com age 30
# 특정값 조회
hget author:info:1 name
# 모든 객체값 조회
hgetall author:info:1
# 특정 요소값 수정
hset author:info:1 name kim

# 

</code></pre>
<p>Redis 를 공부하고 나서 실제로 Redis 를 구현했을때 얼마나 더 빠른지 직접 확인해보자.</p>
<pre><code class="language-java">
package com.example.redisTest.service;

import com.example.redisTest.ArticleCacheDto;
import com.example.redisTest.RequestDto;
import com.example.redisTest.entity.Article;
import com.example.redisTest.repository.ArticleRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@CacheConfig(cacheNames = &quot;article&quot;)
public class ArticleService {

    public final ArticleRepository repo;


    @Transactional(readOnly = true)
    @Cacheable(key = &quot;&#39;all&#39;&quot;)
    public List&lt;ArticleCacheDto&gt; findAll() {
        return repo.findAll()
                .stream()
                .map(a -&gt; new ArticleCacheDto(a.getId(), a.getTitle(), a.getBody()))
                .collect(Collectors.toList());
    }

    /** 조회: @Cacheable → 캐시에 있으면 캐시에서 가져오고 없으면 DB에서 가져옴과 동시에 캐시에 올려둔다.(만료시간) */
    @Transactional
    @Cacheable(key = &quot;#id&quot;)
    public ArticleCacheDto getById(Long id) {

        long start = System.currentTimeMillis();
        Article article = repo.findById(id)
                .orElseThrow(() -&gt; new RuntimeException());

        return new ArticleCacheDto(article.getId(), article.getTitle(), article.getBody());

    }

    @Transactional
    public ArticleCacheDto getById2(Long id) {

        long start = System.currentTimeMillis();
        Article article = repo.findById(id)
                .orElseThrow(() -&gt; new RuntimeException());

        return new ArticleCacheDto(article.getId(), article.getTitle(), article.getBody());

    }

    /** DB 반영: 메서드 로직(repo.save())으로 처리
     캐시 반영: 메서드 반환값을 무조건 캐시에 저장 */
    @Transactional
    @CachePut(key = &quot;#result.id&quot;)
    @CacheEvict(key = &quot;&#39;all&#39;&quot;) // -&gt; redis-cli : DEL article::all
    public ArticleCacheDto create(RequestDto articleDto) {

        Article article = new Article(articleDto.getTitle(), articleDto.getBody());
        repo.save(article);

        return new ArticleCacheDto(article.getId(), article.getTitle(), article.getBody());
    }


    @Transactional
    @CachePut(key = &quot;#articleDto.id&quot;)
    @CacheEvict(key = &quot;&#39;all&#39;&quot;)
    public ArticleCacheDto update(RequestDto articleDto) {
        Article article = repo.findById(articleDto.getId())
                .orElseThrow(() -&gt; new EntityNotFoundException(&quot;not found&quot;));
        article.setTitle(articleDto.getTitle());
        article.setBody(articleDto.getBody());
        // 변경 감지 → save 호출 안 해도 됨
        return new ArticleCacheDto(article.getId(), article.getTitle(), article.getBody());
    }

    /** 삭제: @CacheEvict → DB 삭제 후 캐시에서 해당 키 제거 */
    @Transactional
    @Caching(evict = {
            @CacheEvict(key = &quot;#id&quot;),       // article::{id} 삭제
            @CacheEvict(key = &quot;&#39;all&#39;&quot;)      // article::all 삭제
    })
    public void delete(Long id) {
        repo.deleteById(id);
    }


}

</code></pre>
<p><strong>여기서 중요한게 Spring AOP 이다.</strong>
저 서비스 빈 클래스 내부에서는 해당 @Cacheable 과 같은 내부 메서드를 호출해도 캐싱이 안된다.
이것 마치 @Transactional 과 같이 Spring AOP 때문이다.</p>
<p>결국 캐시관련 서비스 클래스나 따로 빈을 만들어서
도메인 서비스 빈 -&gt; 캐시관련 빈 클래스를 호출해서 사용하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[SpringBoot -redis with cache]]></title>
            <link>https://velog.io/@heeun_98/SpringBoot-redis</link>
            <guid>https://velog.io/@heeun_98/SpringBoot-redis</guid>
            <pubDate>Sat, 19 Jul 2025 15:44:57 GMT</pubDate>
            <description><![CDATA[<h3 id="2-1-redistemplatek-v">2-1. RedisTemplate&lt;K, V&gt;</h3>
<p><strong>정의</strong>: 스프링이 제공하는 <em>범용</em> Redis 접근 객체.
<strong>특징</strong>:</p>
<ul>
<li><code>opsForValue(), opsForHash(), opsForList(), opsForSet(), opsForZSet(), opsForStream()</code> 등 → Redis 자료구조 전부 다룸</li>
<li>직렬화 전략을 <strong>네가 Bean 등록 시 지정</strong> (<code>String</code>, <code>JSON</code>, <code>Kryo</code> 등)</li>
<li>멱등/파이프라인/트랜잭션, Pub/Sub 모두 가능</li>
</ul>
<p><strong>언제 주로 사용?</strong></p>
<ul>
<li>객체를 JSON 으로 저장</li>
<li>List/Set/ZSet/Stream 같은 구조 직접 활용</li>
<li>캐시라기보다 “실시간 상태 저장소” “이벤트 큐” 로 쓸 때</li>
</ul>
<p><strong>간단 커스터마이징 예</strong> (JSON 값):</p>
<pre><code class="language-java">@Bean
public RedisTemplate&lt;String, Object&gt; redisTemplate(RedisConnectionFactory cf) {
    RedisTemplate&lt;String, Object&gt; t = new RedisTemplate&lt;&gt;();
    t.setConnectionFactory(cf);
    t.setKeySerializer(new StringRedisSerializer());
    t.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    t.setHashKeySerializer(new StringRedisSerializer());
    t.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
    t.afterPropertiesSet();
    return t;
}</code></pre>
<h3 id="2-2-stringredistemplate">2-2. StringRedisTemplate</h3>
<p><strong>정의</strong>: <code>RedisTemplate&lt;String, String&gt;</code> 을 편하게 쓸 수 있도록 Spring Boot 가 <strong>자동으로 만들어 주는</strong> 특수 버전.</p>
<p><strong>특징</strong>:</p>
<ul>
<li>Key / Value / Hash Field / Hash Value / Set Member 전부 UTF-8 String 직렬화</li>
<li>별도 Bean 선언 필요 없음 (의존성 + Redis 설정만 있으면 자동)</li>
<li>CLI 로 값 보면 그대로 읽힘 → <strong>디버깅 최고</strong></li>
<li>객체를 넣고 싶으면 <em>직접</em> JSON 변환 (ObjectMapper)</li>
</ul>
<pre><code class="language-java">stringRedisTemplate.opsForValue().set(&quot;greeting&quot;, &quot;hello&quot;);
stringRedisTemplate.opsForSet().add(&quot;likes:post:1&quot;, &quot;user123&quot;);
Long size = stringRedisTemplate.opsForSet().size(&quot;likes:post:1&quot;);</code></pre>
<p><strong>언제?</strong></p>
<ul>
<li>값이 정말 싹 다 문자열 / 숫자(문자열로 변환 가능)</li>
<li>빠르게 프로토타입</li>
<li>카운터, 토큰, 플래그, Set 기반 중복체크 등</li>
</ul>
<h3 id="2-3-rediscachemanager">2-3. RedisCacheManager</h3>
<p><strong>정의</strong>: Spring Cache 추상화를 Redis 로 “백엔드” 매핑해 주는 <strong>관리자</strong>.
→ 네가 <code>@Cacheable</code>, <code>@CachePut</code>, <code>@CacheEvict</code> 를 붙이면 <strong>AOP</strong> 가 메서드 호출 전후로 자동으로 Redis 접근.</p>
<p><strong>흐름 ( @Cacheable )</strong>:</p>
<ol>
<li>메서드 호출 직전 키 생성 (cacheName + 파라미터)</li>
<li>Redis 에 “이미 값 있으면” → 메서드 본문 <strong>안 타고</strong> 바로 반환</li>
<li>없으면 메서드 실행 → 결과 직렬화해서 Redis 저장 → 반환</li>
</ol>
<p><strong>장점</strong>:</p>
<ul>
<li>코드에 캐시 로직 if/else 안 섞임</li>
<li>TTL, 직렬화 정책 중앙 관리</li>
<li>로컬/Redis 다른 구현으로 쉽게 교체</li>
</ul>
<p><strong>기본 설정 예</strong>:</p>
<pre><code class="language-java">@EnableCaching
@Configuration
public class CacheConfig {

    @Bean
    public RedisCacheManager redisCacheManager(RedisConnectionFactory cf) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        return RedisCacheManager.builder(cf)
                .cacheDefaults(config)
                .withInitialCacheConfigurations(Map.of(
                   &quot;article&quot;, config.entryTtl(Duration.ofMinutes(5)),
                   &quot;userProfile&quot;, config.entryTtl(Duration.ofHours(1))
                ))
                .build();
    }
}</code></pre>
<p><strong>사용 예</strong>:</p>
<pre><code class="language-java">@Cacheable(value = &quot;article&quot;, key = &quot;#id&quot;)
public ArticleDto getArticle(Long id) { ... }

@CacheEvict(value = &quot;article&quot;, key = &quot;#id&quot;)
public void deleteArticle(Long id) { ... }

@CachePut(value = &quot;article&quot;, key = &quot;#result.id&quot;)
public ArticleDto updateArticle(ArticleDto dto) { ... }</code></pre>
<hr>
<h2 id="3">3</h2>
<table>
<thead>
<tr>
<th>한 줄 요약</th>
</tr>
</thead>
<tbody><tr>
<td><strong>RedisTemplate</strong>: “Redis 명령을 <em>직접</em> 다루는 범용 공구 상자.”</td>
</tr>
<tr>
<td><strong>StringRedisTemplate</strong>: “그 공구 상자 중 ‘문자열만’ 빠르게 쓰는 미니 세트.”</td>
</tr>
<tr>
<td><strong>RedisCacheManager</strong>: “특정 메서드 결과를 Redis 로 <em>자동 캐시</em> 해 주는 관리자(AOP).”</td>
</tr>
</tbody></table>
<hr>
<h2 id="4">4</h2>
<pre><code>Controller
  ↓
Service
  ├─ @Cacheable getArticle(id)  ← RedisCacheManager (자동)
  ├─ likePost(postId, userId)   ← StringRedisTemplate (Set: 중복방지)
  ├─ feedRecent()               ← RedisTemplate (ZSet or Stream 소비)</code></pre><ul>
<li><strong>읽기 많은 엔드포인트</strong> → <code>@Cacheable</code></li>
<li><strong>실시간 상태(좋아요 중복, 카운트)</strong> → <code>StringRedisTemplate</code></li>
<li><strong>복잡 구조(ZSet 정렬, Stream 이벤트)</strong> → <code>RedisTemplate</code> (JSON 직렬화)</li>
</ul>
<hr>
<h2 id="5">5</h2>
<table>
<thead>
<tr>
<th>질문</th>
<th>예/답</th>
<th>선택</th>
</tr>
</thead>
<tbody><tr>
<td>“그냥 문자열+숫자만 저장?”</td>
<td>yes</td>
<td><code>StringRedisTemplate</code></td>
</tr>
<tr>
<td>“객체(JSON) 직렬화해서 다양한 구조 사용?”</td>
<td>yes</td>
<td>커스텀 <code>RedisTemplate</code></td>
</tr>
<tr>
<td>“메서드 결과 캐시하고 싶고 코드에 if문 넣기 싫어?”</td>
<td>yes</td>
<td><code>RedisCacheManager + @Cacheable</code></td>
</tr>
<tr>
<td>“TTL/직렬화 정책을 캐시별로 쉽게 다루고 싶어?”</td>
<td>yes</td>
<td><code>RedisCacheManager</code></td>
</tr>
<tr>
<td>“Pub/Sub, Stream, ZSet 직접?”</td>
<td>yes</td>
<td><code>RedisTemplate</code></td>
</tr>
<tr>
<td>“이후 Kafka 가거나 다른 백엔드로 교체 가능하게?”</td>
<td>캐시만 추상화</td>
<td><code>RedisCacheManager</code> 유지</td>
</tr>
</tbody></table>
<hr>
<h2 id="6">6</h2>
<table>
<thead>
<tr>
<th>실수</th>
<th>왜 문제</th>
<th>해결</th>
</tr>
</thead>
<tbody><tr>
<td>JDK 기본 직렬화 그대로 사용</td>
<td>값이 이상한 바이트 → 디버깅 어려움, 클래스 변경 시 깨짐</td>
<td>JSON 직렬화로 교체</td>
</tr>
<tr>
<td><code>@Cacheable</code> + 내부 메서드 self 호출</td>
<td>AOP 프록시 안 타서 캐시 미동작</td>
<td>외부 호출 경로, 또는 self-injection</td>
</tr>
<tr>
<td>캐시 키 폭발: <code>@Cacheable</code> 에 파라미터 과다</td>
<td>메모리 급증</td>
<td>key 전략 (필요 필드만), TTL</td>
</tr>
<tr>
<td>Set / Hash 혼용 시 직렬화 다름</td>
<td>조회 못함</td>
<td>동일 Serializer 정책 강제</td>
</tr>
<tr>
<td>캐시 무효화 잊음 (<code>@CacheEvict</code>)</td>
<td>stale 데이터</td>
<td>create/update/delete 마다 Evict 또는 Put</td>
</tr>
</tbody></table>
<hr>
<h2 id="7">7</h2>
<p><strong>시나리오</strong>: 게시글 상세 + 좋아요 기능</p>
<table>
<thead>
<tr>
<th>기능</th>
<th>사용</th>
</tr>
</thead>
<tbody><tr>
<td>게시글 상세 조회 (읽기 많음)</td>
<td><code>@Cacheable(&quot;article&quot;)</code> (RedisCacheManager)</td>
</tr>
<tr>
<td>게시글 수정</td>
<td><code>@CachePut</code> or <code>@CacheEvict</code></td>
</tr>
<tr>
<td>좋아요 클릭 중복 체크 &amp; 카운트</td>
<td><code>StringRedisTemplate</code> (<code>SADD</code>, <code>SCARD</code>)</td>
</tr>
<tr>
<td>실시간 인기글 (점수=좋아요수+시간감쇠)</td>
<td><code>RedisTemplate.opsForZSet()</code> (ZSet)</td>
</tr>
<tr>
<td>좋아요 이벤트 비동기 처리</td>
<td><code>RedisTemplate.opsForStream()</code> (Stream)</td>
</tr>
</tbody></table>
<hr>
<h2 id="8-한눈에-흐름-그림">8. 한눈에 흐름 그림</h2>
<pre><code>[Client] ──HTTP──&gt; [Service]

[Service.getArticle(id)]
   └─ AOP: CacheInterceptor
        ├─ Redis GET (cache key: article::id)
        ├─ 없으면 -&gt; DB 조회 -&gt; Redis SET (TTL)
        └─ 반환

[Service.like(postId,userId)]
   ├─ StringRedisTemplate SADD likes:post:1 user123
   ├─ (added==1) =&gt; INCR likes:post:1:count
   └─ RedisTemplate XADD stream:like {...}</code></pre><hr>
<h2 id="9">9</h2>
<ol>
<li><strong>StringRedisTemplate</strong>: 기본 Redis 명령 감각 익히기</li>
<li><strong>RedisTemplate (JSON 커스터마이징)</strong>: ZSet, Hash, Stream 활용</li>
<li><strong>RedisCacheManager + @Cacheable</strong>: 서비스 레이어 캐싱 구조화</li>
<li>(추가) TTL 전략, 캐시 스탬피드 방지, 분산락, 비동기 이벤트</li>
</ol>
<hr>
<h2 id="10">10</h2>
<ol>
<li><code>@Cacheable</code> 썼는데 Redis 에 값이 안 생김. 가능한 원인 2가지?</li>
<li>Set 중복 방지를 위해 어떤 템플릿과 명령?</li>
<li>객체를 JSON 으로 넣고 싶다. 어느 Bean 에 어떤 Serializer?</li>
<li>Stream 으로 이벤트 소비 시 ACK 안 하면 어디에 남나?</li>
</ol>
<p>(필요하면 답도 알려줄게.)</p>
<hr>
<ul>
<li><strong>RedisTemplate</strong>: <em>“원하는 방식으로 직렬화 설정해서 Redis 자료구조를 직접 건드리는 범용 도구”</em></li>
<li><strong>StringRedisTemplate</strong>: <em>“전부 문자열로 단순·가볍게 Redis 명령 쓰는 빠른 스타터”</em></li>
<li><strong>RedisCacheManager</strong>: <em>“<code>@Cacheable</code> 같은 선언형 애노테이션을 Redis 저장/조회로 연결해 주는 캐시 관리자”</em></li>
</ul>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[Java - 멀티스레드 CPU 코어에 맞는 쓰레드 풀 개수]]></title>
            <link>https://velog.io/@heeun_98/%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EC%8A%A4%EB%A0%88%EB%93%9C</link>
            <guid>https://velog.io/@heeun_98/%ED%94%84%EB%A1%9C%EC%84%B8%EC%8A%A4%EC%99%80-%EC%8A%A4%EB%A0%88%EB%93%9C</guid>
            <pubDate>Fri, 18 Jul 2025 06:15:19 GMT</pubDate>
            <description><![CDATA[<h2 id="cpu-바운드-작업-vs-i--o-바운드-작업">CPU 바운드 작업 VS I / O 바운드 작업</h2>
<p>각각의 스레드가 하는 작업은 크게 2가지로 구분할 수 있다.</p>
<ul>
<li><strong>CPU-바운드 작업</strong><ul>
<li>cpu의 연산 능력을 많이 요구하는 작업을 의미한다.</li>
<li>이러한 작업은 주로 계산, 데이터 처리, 알고리즘 실행 등 cpu의 처리속도가 작업 완료 시간을 결정하는 경우다.</li>
<li>예시 : 복잡한 수학 연산, 데이터 분석, 비디오 인코딩, 과학적 시뮬레이션 등</li>
</ul>
</li>
</ul>
<ul>
<li><strong>I / O 바운드 작업</strong><ul>
<li>디스크 , 네트워크, 파일 시스템 등과 같은 입출력 작업을 많이 요구하는 작업을 의미한다.</li>
<li>이러한 작업은 I/O 작업이 완료될 때가지 대기 시간이 많이 발생하며 , CPU 는 상대적으로 대기 상태에 있는 경우가 많다. 쉽게 이야기 해서 스레드가 CPU 를 사용하지 않고 I /O 작업이 완료될 때 까지 대기한다.</li>
<li>예시 : 데이터베이스 쿼리 처리 , 파일 읽기 / 쓰기, 네트워크 통신, 사용자 입력 처리 등.</li>
</ul>
</li>
</ul>
<p>  현대 웹 어플리케이션 서버는 주로 CPU 바운드 작업 보다는 I / O 바운드 작업이 많다.
  예를들어 백앤드 개발자의 경우 주로 웹 애플리케이션 서버를 개발하는데, 스레드가 1- 10000까지 더하는 CPU 연산이 필요한 작업보다는, 대부분 사용자의 입력을 기다리거나, 데이터베이스를 호출하고 그 결과를 기다리는 등, 기다리는 일이 많다. 쉽게 이야기 해서 스레드가 CPU 를 사용하지 않는 I / O 바운드 작업이 많다는 뜻이다.</p>
<p>  일반적인 자바 웹 애플리케이션 서버의 경우 사용자가 요청 하나를 처리하는데 1개의 스레드가 필요하다.
  사용자 4명이 동시에 요청하면 4개의 스레드가 작동하는 것이다. 그래야 4명의 사용자의 요청을 동시에 처리 할 수 있다.</p>
<p>  사용자의 요청 하나를 처리하는데, 스레드는 CPU 를 1% 정도 사용하고 있고 , 대부분 데이터베이스 서버에 어떤 결과를 조회하면서 기다린다고 가정하자. 이대 스레드는 CPU 코어 즉 CPU 를 거의 사용하지 않고 대기한다. 바로 이것이 I / O 바운드 작업이 많다는 것이다.
  여기서 1% 라는 가정을 한 이유는 스레드는 I  / O 작업이 많기 때문이다.</p>
<hr>
<p><strong>정리하면 스레드의 숫자는 CPU-바운드 작업이 많은가, 아니면 I/O-바운드 작업이 많은가에 따라 다르게 설정해야 한다.</strong></p>
<p><strong>CPU-바운드 작업</strong>: CPU 코어 수 + 1개
CPU를 거의 100% 사용하는 작업이므로 스레드를 CPU 숫자에 최적화</p>
<p><strong>I/O-바운드 작업</strong>: CPU 코어 수 보다 많은 스레드를 생성, CPU를 최대한 사용할 수 있는 숫자까지 스레드 생성 CPU를 많이 사용하지 않으므로 성능 테스트를 통해 CPU를 최대한 활용하는 숫자까지 스레드 생성
단 너무 많은 스레드를 생성하면 컨텍스트 스위칭 비용도 함께 증가 - 적절한 성능 테스트 필요</p>
]]></description>
        </item>
    </channel>
</rss>