<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>supernovaMK</title>
    <link>https://supernovamk.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Tue, 8 Sep 2026 17:36:47 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>supernovaMK</managingEditor>
    <image>
      <title>supernovaMK</title>
      <url>https://tistory1.daumcdn.net/tistory/6877795/attach/002e9120678940f1ba5ca6e6573357f2</url>
      <link>https://supernovamk.tistory.com</link>
    </image>
    <item>
      <title>비동기 예매 파이프라인 구축기</title>
      <link>https://supernovamk.tistory.com/82</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;비동기를 도입하게 된 배경&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GiveMeTicket 선착순 예매 서비스를 구축 중이다. 오픈 직후 몰리는 트래픽의 대부분인 '매진 요청'이 DB 커넥션을 낭비하는 현상을 막기 위해서&amp;nbsp;조회 병목을 캐시로 완화하고 재고 차감 로직을 Redis Lua 스크립트를 사용해 MySQL 부하 최소화를 적용하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;하지만 apply(티켓팅 신청) 코드를 다시 리뷰하는 과정에서 문제점을 발견했다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public ApplicationResponse apply(Long campaignId, Long userId) {
    CampaignState state = campaignStateRepository.find(campaignId)
            .orElseThrow(CampaignApplicationException::notOpen);

    decreaseStock(campaignId);                                  // Redis

    return ApplicationResponse.from(
            applicationPersister.confirm(campaignId, userId));  // MySQL INSERT - 문제 지점
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인메모리 기반 재고 차감은 마이크로초 단위로 끝나지만, 직후에 실행되는 DB INSERT 작업이 동기 작업이었다. 평상시에는 밀리초 단위라 티가 안 나지만, 트래픽이 몰려 DB 성능이 떨어질 때 치명적인 병목으로 작용한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;스레드 풀 고갈과 재시도 폭풍&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB 커넥션이 지연되면 어떤 연쇄적인 문제가 발생하는지 분석해 봤다.&lt;br /&gt;INSERT 작업을 대기하며 톰캣 스레드가 계속 묶인다. 계속되는 요청으로 인해 스레드 풀이 빠르게 고갈된다. 결과적으로 예매와 무관한 기능(행사 조회, 로그인 등)의 요청조차 스레드를 할당받지 못해 시스템 전체 장애로 이어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 INSERT 실패에 대한 재시도 처리도 문제였다. 실패의 대부분은 데드락이나 일시적 네트워크 지연인데 이 상황에서 재시도는 복구 중인 DB에 똑같은 부하를 다시 가하게 된다. 흔히 말하는 '재시도 폭풍'을 유발해 큐가 적체되고 DB는 회복하지 못하게 될 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;메시지 큐 도입&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞선 문제를 해결하기 위해서 DB 데이터 쓰기 작업을 비동기로 처리하도록 하였다. 비동기 작업을 위해 메시지 큐를 도입해 비동기 처리를 진행하고자 하였다.&lt;br /&gt;메시지 큐 도구에 여러 종류가 있어 고민했지만 결론은 &lt;b&gt;이미 사용 중인 Redis를 최대한 활용하자&lt;/b&gt;는 것이었다. Redis Stream과 Consumer Group, 그리고 ZSET을 결합하면 무거운 브로커를 띄우지 않고도 메인 큐, 지연 재시도 큐, DLQ를 모두 구현할 수 있다는 장점이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;그럼 어떻게 설계할까&lt;/b&gt;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;apply ──[Redis Lua: 중복 확인 + 재고 차감]──&amp;gt; 번호 채번 ──&amp;gt; 메인 큐(Stream) ──&amp;gt; 201 응답
                                                              │
                                                            워커
                                     ┌────────────────────┼────────────────────┐
                                   성공                   실패               해석 불가
                                    │                     │                    │
                                 저장 후 ack      지연 큐(ZSET)로 우회           │
                                                          │                    │
                                                1초 폴러가 되돌림 ──┐          │
                                                          │         │          │
                                                  재시도 5회 소진 ───┴──────────┤
                                                                               ▼
                                                                     DLQ + 슬랙 알림 + ack
                                                                               │
                                                                       어드민 재인입 ──┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 중요한 건 메인 큐를 기준으로 나뉜 동기(사용자가 기다리는 구간)와 비동기(기다리지 않는 구간)의 분리다. 백엔드 처리 지연이나 DB 장애가 클라이언트 응답 시간에 영향을 주지 않도록 파이프라인을 설계했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;구현하면서 주의했던 부분들&lt;/b&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. 기존 API 응답 스펙 유지&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 해결할 과제는 기존 API 응답 계약을 지키는 것이었다. apply는 예매 번호(id)를 즉시 반환해야 한다.&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;201 Created
Location: applications/42

{&quot;id&quot;: 42, &quot;campaignId&quot;: 1, &quot;userId&quot;: 7, &quot;status&quot;: &quot;CONFIRMED&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 DB의 AUTO_INCREMENT를 통해 ID를 발급받았으나, 저장을 비동기로 미루면 ID 반환이 불가능하다. UUID로 변경하거나 폴링(Polling) 방식을 도입하는 것은 프론트엔드 작업량이 커지므로 배제했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대안으로, 번호를 DB가 아닌 Redis에서 ID를 미리 받아 사용하는&amp;nbsp;&lt;b&gt;사전 채번&lt;/b&gt;&amp;nbsp;방식을 선택했다.&lt;/p&gt;
&lt;pre class=&quot;cs&quot;&gt;&lt;code&gt;public interface ApplicationIdIssuer {
    long issue();   // Redis INCR
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis INCR 명령어로 ID를 응답에 싣고, 워커는 해당 ID를 PK로 명시해 INSERT한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식을 적용하려면 엔티티가 ID를 외부에서 직접 주입받아야 했다. @GeneratedValue 제약을 피하기 위해 식별자를 아래로 내리고 시간 필드를 위로 올리는 식으로 JPA 상속 구조를 재조정했다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;BaseTimeEntity  (createdAt, updatedAt)
├── BaseEntity  (+ @Id @GeneratedValue)   &amp;larr; Campaign, User 는 그대로
└── Application (+ 자기 @Id, 채번값 대입)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 JpaRepository.save() 대신 EntityManager.persist()를 호출하는 create() 메서드를 사용하였다. 이유는 만약 Redis에서 받은 ID를 사용해 Jpa를 사용하게 된다면 이미 존재하는 데이터 업데이트인 것으로 Jpa가 착각하여 select문이 호출되기 때문에 직접 EntityManager를 호출하여 불필요한 쿼리문 하나를 삭제하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 저장 전 조회 보장&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ID 채번으로 응답 스펙은 지켰지만 그 ID로 조회했을 때의 문제가 남았다. 사용자는 201을 받은 직후 &quot;내 티켓&quot;을 확인할 수 있어야 하는데, 워커가 아직 행을 만들지 않았다면 DB에는 아무것도 없다. 그대로 두면 방금 신청에 성공한 사용자가 조회에서 404를 받는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;응답에 &quot;접수됐지만 저장 중&quot;이라는 상태를 새로 만드는 방법도 있었으나 배제했다. 저장이 어디까지 갔는지는 사용자가 알아야 할 정보가 아니기도 하고 상태가 하나 추가되면 프론트엔드가 다뤄야 할 분기도 함께 늘어나기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 큐에 넣을 때 Redis에 조회용 임시 레코드를 함께 남기는 방식을 선택했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public record PendingReservation(Long applicationId, Long campaignId, Long userId) { }

public interface PendingReservationStore {
    void put(PendingReservation reservation);
    Optional&amp;lt;PendingReservation&amp;gt; find(Long applicationId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;userId를 포함시킨 이유는 소유자 확인 때문이다. DB에 행이 없는 구간에서도 남의 예매 번호로 조회하는 것은 막아야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;apply에서는 대기 레코드를 먼저 남기고 큐에 넣는다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;pendingReservationStore.put(new PendingReservation(applicationId, campaignId, userId));
reservationQueue.publish(
        ReservationEvent.first(applicationId, campaignId, userId, LocalDateTime.now()));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순서가 반대이면 워커가 메시지를 먼저 집어간 뒤 조회가 들어왔을 때 DB에도 Redis에도 레코드가 없는 찰나가 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론으로는 조회는 DB를 먼저 확인해 보되 워커가 아직 작업하지 못해 DB에서 확인할 수 있는 데이터가 없을 때만 대기 레코드(Redis)에서 확인하도록 하였다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;Application application = applicationRepository.findById(applicationId).orElse(null);
if (application != null) {
    if (!application.isOwnedBy(userId)) {
        throw ApplyApplicationException.forbidden();
    }
    return ApplicationResponse.from(application);
}

PendingReservation pending = pendingReservationStore.find(applicationId)
        .orElseThrow(ApplyApplicationException::applicationNotFound);
if (!pending.userId().equals(userId)) {
    throw ApplyApplicationException.forbidden();
}
return ApplicationResponse.accepted(
        pending.applicationId(), pending.campaignId(), pending.userId());&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;임시 레코드에는 10분 TTL을 걸어 두고 워커가 별도로 삭제하지 않는다. 조회가 언제나 DB를 먼저 확인하므로, 저장이 끝난 뒤 남아 있는 레코드는 읽히지 않는다. 삭제 책임을 워커에 두면 그 호출이 실패했을 때의 처리가 또 필요해지므로 만료를 통해서 자동 삭제 되도록 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;3. 중복 검증 로직 분리 및 최적화&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 DB의 유니크 제약조건에 의존해 중복 예매를 막고있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 INSERT가 비동기로 처리되면서 메시지 큐로 담기게 되면서, 사용자가 201 응답을 이미 받은 뒤에야 중복을 발견할 수 있다. 흔히 말하는 따닥 처럼 사용자가 동시에 2번 api를 호출할 경우에 기존 신청 내역이 있는지 검사할때는 두 api 모두 중복 검사에서 문제가 없게 되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 중복 판정을 Redis를 사용하기로 하였다. 캠페인마다 신청자 집합(Set)을 두고, 재고 차감 로직과 단일 Lua 스크립트로 묶었다. 기존 재고 차감 로직의 Lua 스크립트 덕분에 해결하기 쉬웠다.&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;if redis.call('EXISTS', KEYS[1]) == 0 then return -1 end          -- 초기화 안 됨
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -3 end -- 이미 신청함
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock &amp;lt;= 0 then return -2 end                                  -- 매진

redis.call('SADD', KEYS[2], ARGV[1])
return redis.call('DECR', KEYS[1])
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;4. XACK 의미의 재정의와 타잔의 법칙&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;워커 설계 시 가장 고민했던 부분이다. 처음엔 단순히 &quot;성공하면 XACK&quot;이라고 생각했다.&lt;br /&gt;하지만 지연 큐를 도입하고 나니 로직이 꼬였다. DB 저장이 실패해서 지연 큐로 옮겨 놓은 메시지는 성공인가 실패인가? XACK을 해야 하나? 고민이 되었다. 결론은 해야한다고 한다. 안 하면 처리 중 목록(PEL)과 지연 큐 양쪽에 같은 메시지가 남는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 XACK의 의미를 '성공'에서 &lt;b&gt;메인 큐에서 관리 대상이 아님&lt;/b&gt; 이라는 뜻으로 정했다.&lt;br /&gt;단순 boolean 반환 대신 명시적인 Enum 구조를 채택했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public enum Disposition {
    ACKNOWLEDGE,     
    LEAVE_PENDING   
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 데이터 이동에서 AI에게 피드백을 받았는데 타잔의 법칙이라는 규칙이다. &lt;b&gt;언제나 도착지에 먼저 넣고, 기존 큐에서 뺀다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;reservationRetryQueue.schedule(next, delay);   // 1. 지연 큐에 안전하게 넣고
return Disposition.ACKNOWLEDGE;                 // 2. 원래 큐에서 뺀다(XACK)
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유하자면 타잔이 다음 덩굴을 확실히 잡기 전까지는 절대 원래 덩굴을 놓지 않는 것이라고 한다. 반대로 원래 덩굴부터 놓고 새 덩굴을 잡으려다 죽으면 데이터는 유실 된 위험이 생긴다. 순서를 지키면 중간에 서버가 다운되더라도 최악의 결과가 중복 배달인데, 앞서 저장 로직을 멱등하게 만들어 두었으므로 문제가 되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;5. 지수 백오프와 Jitter 전략&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고정된 지연 시간(예: 무조건 1초 뒤 재시도)은 같은 순간 실패한 메시지들이 정확히 같은 시각에 돌아오게 하므로 오히려 재시도로 인해서 부하가 더욱 발생하게 될 수 있다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public Duration nextDelay(int attempt) {
    long base = baseDelay.toMillis() &amp;lt;&amp;lt; Math.min(attempt, 30);
    long capped = Math.min(base, maxDelay.toMillis());
    long spread = jitter.isZero() ? 0 : ThreadLocalRandom.current().nextLong(jitter.toMillis());
    return Duration.ofMillis(capped + spread);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 1초 &amp;times; 2^attempt + 난수 공식을 통해 DB에 회복할 시간을 주고 재시도 정책을 만들었다. (상한에서 난수를 빼지 않고 더하는 디테일을 추가해, 최대 대기시간에 걸린 메시지들이 한 번에 몰리는 현상도 방지했다.)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;6. DLQ 자료구조 선택 과정&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5번 실패한 메시지는 최종적으로 DLQ(Dead Letter Queue)에 격리된다. 이 DLQ의 자료구조 역시 메인 큐와 똑같은 Redis Stream으로 설계했다. JSON 문자열로 바꿔 일반 List에 넣지 않은 데에는 이유가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메인 큐와 규격이 같으면 &lt;b&gt;나중에 관리자가 원인을 고치고 재처리(Requeue)할 때 데이터 포맷 변환이 필요 없다.&lt;/b&gt; DLQ에 적재할 때 원본 데이터에 붙였던 에러 사유(dlqReason) 필드만 떼어내어 그대로 메인 큐로 다시 쏘면(XADD) 끝이다. 장애 복구 스크립트가 단순해지고 파싱 에러 위험도 사라진다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;7. DLQ 격리 시 재고는 어떻게 할 것인가?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DLQ로 메시지를 격리할 때 재고(티켓)를 롤백여부를 잠시 고민하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DLQ로 간 예매는 사용자가 &lt;b&gt;이미 201 응답을 받아 자기가 티켓을 샀다고 알고있다&lt;/b&gt;. 시스템 장애 때문에 이 티켓을 롤백시키면 정상적으로 티켓팅 한 사람이 티켓이 나중에 취소되는 상황은 서비스의 신뢰를 잃게 되는 상황이라고 생각했다.&lt;/p&gt;
&lt;pre class=&quot;ceylon&quot;&gt;&lt;code&gt;public void isolate(ReservationEvent event, String reason) {
    deadLetterQueue.isolate(event, reason);

    operatorNotifier.notifyFailure(
            &quot;예매 저장 실패 &amp;mdash; DLQ 격리 (재고 점유 중)&quot;,
            &quot;&quot;&quot;
            이 예매의 자리는 그대로 잡아 두었습니다. 사용자는 이미 신청 성공 응답을
            받았기 때문입니다. 원인을 고친 뒤 재인입하면 그 자리에 그대로 확정됩니다.
            처리하지 않으면 이 좌석은 계속 잠겨 있습니다.&quot;&quot;&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장이 늦어진 건 시스템 문제이지 유저의 잘못이 아니다. DLQ에 쌓인 메시지는 개발자 slack 알림으로 전송되도록 하였다. 관리자 페이지에서 api호출을 통해서 DB 회복 후 작업하는 방식으로 데이터 원복력을 갖추도록 하였다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;그 외 삽질...&lt;/b&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. XACK은 스트림을 비우지 않는다...&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 XACK이 스트림에서 메시지를 안전하게 완전히 제거한다고 착각했다.&lt;br /&gt;실제로는 처리 중 목록(PEL)에서만 뺄 뿐 스트림 데이터 자체엔 그대로 남았다. 트리밍을 안 하면 처리 성공한 메시지까지 메모리에 영원히 쌓여 OOM을 유발한다.&lt;/p&gt;
&lt;pre class=&quot;autoit&quot;&gt;&lt;code&gt;return redis.call('XADD', KEYS[1], 'MAXLEN', '~', ARGV[1], '*', unpack(ARGV, 2))
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메시지 적재와 트리밍이 원자적으로 일어나도록 XADD 시 MAXLEN ~ 옵션을 달아 메모리 상한 방어벽을 세웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 테스트는 다 통과하는데 워커가 작동을 안한다..&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단위 테스트는 다 통과했는데 실제 구동 시 응답은 201이면서 DB엔 데이터가 하나도 안 들어가는 문제가 있었다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;16:15:34.776  worker  NOGROUP No such key 'reservation:stream' or consumer group
16:15:35.195  main    reservation consumer group created     &amp;larr; 0.4초 늦음
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 리스너 컨테이너 빈의 초기화 시점(폴링 시작)이 ApplicationRunner가 소비 그룹을 생성하는 시점보다 빠르기 때문에 발생한 의존성 역전 버그였다. 그룹 생성 책임을 리스너 컨테이너 설정 내부로 완전히 옮겨 해결했다. 기동 순서에 의존하는 버그는 단위 테스트로 잡을 수 없으며, 반드시 통합 환경에 띄워서 봐야 한다는 걸 다시금 배웠다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;</description>
      <category>프로그래밍/프로젝트</category>
      <author>supernovaMK</author>
      <guid isPermaLink="true">https://supernovamk.tistory.com/82</guid>
      <comments>https://supernovamk.tistory.com/82#entry82comment</comments>
      <pubDate>Sat, 29 Aug 2026 00:08:01 +0900</pubDate>
    </item>
    <item>
      <title>그리디 동아리에서의 회고</title>
      <link>https://supernovamk.tistory.com/81</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&amp;nbsp;소프트웨어 개발 동아리인 그리디 동아리에서 운영과 활동을 하며 회고를 담은 글입니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.greedy-homepage.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;그리디 소개 홈페이지&lt;/a&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;우테코는 끝났는데 학교로 가서 뭐하지?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우아한테크코스에서의 10개월 간의 몰입이 끝난 후 학교로 돌아오게 되었다. 당시에 주위에서 함께 공부하던 크루들은 취업을 바로 준비하게 되었고, 나는 자격 요건등이 맞지 않아 졸업을 먼저하고 돌아와야겠다는 생각으로 가득 차 있었다. 취업에 바로 성공하는 크루들을 보면서 내심 부럽기도하고 나도 할 수 있을까? 라는 걱정만 많았던 겨울이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나의 길을 찾기 위해서 학교로 돌아가 어떤 것들을 할 수 있을지들을 생각해보았다. 학교로 돌아가서 경험할 수 있는 것들 중에 가장 큰 것은 사람들을 만나는 것이었다. 새로운 사람들을 만나 새로운 환경에 노출되는 것이 내가 지향하는 생활이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 브라운 코치가 설명해주신 초록스터디라는 활동에 설명을 들었다. 초록 스터디의 취지는 현재 내가 배운 개발 경험들을 다른 배우고 싶어하는 사람들에게 다시 나누어주는 것이었다. 처음에 들었을 때는 &quot;내가 할 수 있을까?&quot; 라는 생각이 들었던 것 같았다. 특히 내 학교에서는 초록스터디가 매우 활성화 되어 동아리급으로 커져있다는 소식을 들어서 더욱 부담되었던 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 누군가에게 알려줄 만큼 내가 가지고 있는지에 대한 자신감도 없었을 뿐더러 부담이 되는 활동이겠다는 생각도 들었다. 또 취업 준비를 해야하는데 누군가에게 내 시간을 투자하면서 다른 사람들의 성장을 만드는데 집중하는게 맞을까?라는 생각들도 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 생각이 들면서도 내가 누군가에게 알려주는 경험이 곧 나의 성장을 만들어내었던 경험들이 떠올랐다. 누군가의 성장을 만들어가기 전에 나 스스로도 성장할 수 있는 경험이기도 하지않을까?라는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학교에서 먼저 초록스터디를 운영하던 코코닥(선배)과 만나서 커피챗을 진행하면서도 코코닥의 좋은 경험이야기들을 듣고 나서는 앞선 두려움들은 사라지고 해보고 싶다는 생각이 들었다. 대학교에서만 경험할 수 있는 것들을 경험할 수 있었다는 말이 나를 이 그리디에 이끌게 되었던 것 같았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;일단 해보자. 그리디&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;856&quot; data-origin-height=&quot;888&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/04w8j/dJMcahFz5nH/jYoAuoZ8WkPYoAtQIBOrf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/04w8j/dJMcahFz5nH/jYoAuoZ8WkPYoAtQIBOrf0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/04w8j/dJMcahFz5nH/jYoAuoZ8WkPYoAtQIBOrf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F04w8j%2FdJMcahFz5nH%2FjYoAuoZ8WkPYoAtQIBOrf0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;263&quot; height=&quot;273&quot; data-origin-width=&quot;856&quot; data-origin-height=&quot;888&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 &lt;b&gt;그리디활동이란 미션 기반의 스터디로 백엔드,프론트엔드 파트로 나뉘어있고 매주 미션을 진행하며 선배 개발자의 리뷰를 받으면서 학습을 진행하는 스터디&lt;/b&gt;이다. 이때 받은 리뷰들을 가지고 매주 모여 백엔드 스터디를 만들어 선배가 후배에게 알려주고 함께 공부하는 활동들이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학기가 시작하고 그리디(초록스터디)의 운영진을 맡으며 본격적으로 시작하게 되었다. 이때 졸업프로젝트 신청과 여러 졸업 여건들을 맞추기 위한 수업들을 신청한 탓에 초기에는 정신이 없었다. 또 그리디에서 기존에 부원이셨던 분들이 모두 나가게 되고 처음 하거나, 스터디원으로만 있었던 분들과 함께 운영진을 맡게 되어 동아리를 운영하는 테스크가 생겼었다. 어떻게 어떤 부원들을 뽑을 것인지, 백엔드 스터디 방식은 어떤 식으로 진행할 것인지 등 여러모로 고민할 지점들이 많이 있었다. 정할게 많다는 것은 회의 또한 많다는 것이다. 다행히 회의를 주도적으로 해주는 다른 운영진 부원들 덕분에 나의 테스크는 아주 크지는 않았다. 돌이켜보면 정말 고마웠던 것 같다. 다만 취업 준비를 해야한다는 생각에 사로잡힌 나로서는 부담이 될때도 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 동아리 운영에 관한 회의&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 백엔드 스터디 진행 및 스터디 리드 회의&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 코드 리뷰&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리해보면 위의 3가지를 그리디 활동을 하면서 하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;백엔드 스터디 리드를 진행하면서&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 백엔드 스터디를 진행하게 되었다. 방식은 주에 1번 스터디 부원들이 미션을 진행해오면 해당 미션 내용을 바탕으로 스터디를 리드들이 준비해와서 스터디를 진행하는 것이다. 주차별로 JAVA와 Spring에 관해서 스터디를 준비해갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;700&quot; data-origin-height=&quot;1016&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NiRLH/dJMcacLbcsf/RpfDA7uBellc0Th9rTV58K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NiRLH/dJMcacLbcsf/RpfDA7uBellc0Th9rTV58K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NiRLH/dJMcacLbcsf/RpfDA7uBellc0Th9rTV58K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNiRLH%2FdJMcacLbcsf%2FRpfDA7uBellc0Th9rTV58K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;405&quot; height=&quot;588&quot; data-origin-width=&quot;700&quot; data-origin-height=&quot;1016&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WvinZ/dJMcab6uv53/lSNC1ZYQUQEKDCWd4YJAwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WvinZ/dJMcab6uv53/lSNC1ZYQUQEKDCWd4YJAwK/img.png&quot; data-origin-width=&quot;1134&quot; data-origin-height=&quot;1332&quot; data-is-animation=&quot;false&quot; style=&quot;width: 54.2762%; margin-right: 10px;&quot; data-widthpercent=&quot;54.91&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WvinZ/dJMcab6uv53/lSNC1ZYQUQEKDCWd4YJAwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWvinZ%2FdJMcab6uv53%2FlSNC1ZYQUQEKDCWd4YJAwK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1134&quot; height=&quot;1332&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbrAsJ/dJMcabFmbWd/fEj2voNnJgy2Fa5wT61FAk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbrAsJ/dJMcabFmbWd/fEj2voNnJgy2Fa5wT61FAk/img.png&quot; data-origin-width=&quot;808&quot; data-origin-height=&quot;1156&quot; data-is-animation=&quot;false&quot; style=&quot;width: 44.561%;&quot; data-widthpercent=&quot;45.09&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbrAsJ/dJMcabFmbWd/fEj2voNnJgy2Fa5wT61FAk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbrAsJ%2FdJMcabFmbWd%2FfEj2voNnJgy2Fa5wT61FAk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;808&quot; height=&quot;1156&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dAN2rr/dJMcabyATBS/zvxkVVB68k1891dGt2KOu1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dAN2rr/dJMcabyATBS/zvxkVVB68k1891dGt2KOu1/img.png&quot; data-origin-width=&quot;772&quot; data-origin-height=&quot;1088&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;41.04&quot; style=&quot;width: 40.5617%; margin-right: 10px; margin-top: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dAN2rr/dJMcabyATBS/zvxkVVB68k1891dGt2KOu1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdAN2rr%2FdJMcabyATBS%2FzvxkVVB68k1891dGt2KOu1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;772&quot; height=&quot;1088&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vSXAd/dJMcaidy8x5/YGgKvNlorPibkKObll9eyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vSXAd/dJMcaidy8x5/YGgKvNlorPibkKObll9eyk/img.png&quot; data-origin-width=&quot;1574&quot; data-origin-height=&quot;1544&quot; data-is-animation=&quot;false&quot; style=&quot;width: 58.2755%; margin-top: 10px;&quot; data-widthpercent=&quot;58.96&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vSXAd/dJMcaidy8x5/YGgKvNlorPibkKObll9eyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvSXAd%2FdJMcaidy8x5%2FYGgKvNlorPibkKObll9eyk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1574&quot; height=&quot;1544&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스터디에서는 스터디원들의 키워드 발표, 미션 피드백 공통 피드백, 골든벨 키워드, 스터디 리드들의 키워드 발표 등의 순서를 토대로 스터디를 진행하였다. 스터디원들은 총 6명 스터디리드는 3명으로 하나의 스터디를 진행하였다. 스터디원들은 보통 Java와 Spring을 처음 배우는 상태이기에 어려웠을텐데 스터디를 잘 따라와주었다. &lt;b&gt;스터디를 진행하다보면서 가장 좋았던 부분은 내가 어떤 것을 중요하게 여기는지 알아갈 수 있었다는 것&lt;/b&gt;이었다. 스터디리드들이 함께 스터디를 준비해오면서 서로가 중요하다고 생각하는 부분이 조금씩 달랐다. 어떤 리드는 처음 보는 개념을 쉽게 접근할 수 있도록 쉬운 예시를 들어서 설명해주기도 하고, 어떤 리드는 직접 코드를 치면서 실습형태로 참여형 스터디 방식을 진행하였다. 다른 사람들의 스터디리드 진행방식을 보면서 어떤 방식이 스터디원들에게 가장 도움이 될지 고민도 해보았다. 다른 리드들이 너무 잘 준비해준 덕분에 나도 내가 중요하게 생각하는 부분들을 편하게 말할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;내가 전달하고 싶었던 메시지&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;내가 가장 중요하게 생각했던 부분은 스터디 이후에도 혼자서 공부할 수 있는 방법을 알려주고 싶었다.&lt;/b&gt; 어떤 개념에 대해서 내가 설명을 해주어도, 새로운 개념을 만나게 되었을때는 당황하게 된다. 이때 당황하지 않고 능동적으로 학습하는 것이 중요하다고 생각하여 스터디를 진행하면서 이런 점들을 강조하였다. 스터디 자료에는 공식문서 사이트를 넣어가 부원들과 함께 사이트를 구경하는 방식 처럼 모르는 문제에 대해서 어떻게 찾고 해결해가는지에 집중하였다. 다행히도 부원들이 내 스터디 방식을 싫어하지 않고? 잘 따라와주었다. 공식 문서를 보면서&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 공부를 해보았다는 부원들의 말을 들었을때는 뿌듯함을 느낄 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;1535&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OKM0N/dJMcadpDS5j/6EhSkuuEwv0AirufcR1MWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OKM0N/dJMcadpDS5j/6EhSkuuEwv0AirufcR1MWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OKM0N/dJMcadpDS5j/6EhSkuuEwv0AirufcR1MWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOKM0N%2FdJMcadpDS5j%2F6EhSkuuEwv0AirufcR1MWK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2048&quot; height=&quot;1535&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;1535&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;AI 시대에서 개발을 공부한다는 것&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 해당 스터디를 진행하면서 나 또한 부족했던 내용들을 다시 학습할 수 있어서 좋았다. 스터디를 진행하면서 내가 배웠던 내용들을 스터디원들에게 알려주는 방식으로 주로 진행하다 문득 의문이 들었던 지점이 있다. &lt;b&gt;바로 AI시대에 현재의 방식으로 개발을 공부하는 것이 맞을지에 대한 의문&lt;/b&gt;이 들었다. 물론 나는 AI 시대가 오기 전에 다들 학습하던 방식으로 개발을 공부했고, 취업 전에 AI 가 매우 활발해지면서 함께 공부한다는 것이 어색하지 않았다. 하지만 만약 내가 지금 처음 개발을 공부할때 바텀업 방식으로 공부를 하는 것이 맞을까?에 대한 생각이 들었다. 특히 Java나 Spring을 바텀업으로 공부를 하는 것이 요즘 추구하는 가치일까? 웹을 개발하기 위한 탑 다운으로 개발을 공부하는 것이 더 나은 학습 방식일지를 고민하게 되었다. 이런 고민에 대한 답이 내려지지 않은 상태에서 스터디 부원들에게 바텀업 방식으로 개발을 알려줄때는 혼란스러운 순간들도 있었다. spring의 어노테이션 하나를 더 알고 공부하는 것 보다 원하는 것을 AI에게 부탁하는 방법을 아는 것이 더 효율적이지 않나? 라는 생각이 들었다. 그럴수록 앞선 내가 전달하고 싶었던 공부를 하는 방법 자체를 더 고민하는게 중요해질 것이라는 생각이 강해졌다. 다만 이번 스터디의 취지인 Java와 Spring을 잘 쓰는 것을 목표로 공부하였기에 기존 방식대로 부원들과 함께 공부를 이어가 마무리하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;스터디 리드 회의&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스터디를 준비하기 위해서 스터디 리드들과의 회의도 매주 진행하였다. 처음에는 매주 2회를 진행했는데 주에 1번으로 줄이게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회의에서는 스터디 준비를 각자 해온 것을 합치고 피드백을 받는 시간을 가졌다. &lt;b&gt;특히 인상적이었던 부분은 칼리가 제안한 KPT 회고 방식이다. Keep, Problem, Try 로 지난 스터디에서 좋았던 가져갈만한 부분, 스터디를 진행하면서 문제였던 부분, 시도해볼만한 부분 이렇게 3가지로 회고를 진행&lt;/b&gt;하였다. 이 KPT 덕분에 지난 스터디에서 부족한 점은 없었는지를 점검하면서 더 개선해나갈 수 있었다. 또한 잘했던 점은 무엇이고 문제였던 점은 무엇인지 더 솔직하게 이야기하면서 질 좋은 스터디를 만들어갈 수 있었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 스터디 리드 회의 + 스터디 준비 + 스터디 이렇게 3개로 나뉜 과정을 매주 진행하다 보니 리소스도 많이 들고 힘들었던 순간들도 있었다. 하지만 함께한 다른 백엔드 리드들의 꾸준하고 성실한 모습속에서 나도 열심히 하려고 노력하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dONhoe/dJMcabeetQq/KaDYKcnI3ThhFJC9nkEJkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dONhoe/dJMcabeetQq/KaDYKcnI3ThhFJC9nkEJkk/img.png&quot; data-origin-width=&quot;1250&quot; data-origin-height=&quot;1622&quot; data-is-animation=&quot;false&quot; style=&quot;width: 42.7911%; margin-right: 10px;&quot; data-widthpercent=&quot;43.29&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dONhoe/dJMcabeetQq/KaDYKcnI3ThhFJC9nkEJkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdONhoe%2FdJMcabeetQq%2FKaDYKcnI3ThhFJC9nkEJkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1250&quot; height=&quot;1622&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nIkZh/dJMcaa0M1TJ/ygvP3Zx9UtaMMzbRHOcyK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nIkZh/dJMcaa0M1TJ/ygvP3Zx9UtaMMzbRHOcyK0/img.png&quot; data-origin-width=&quot;1508&quot; data-origin-height=&quot;1494&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;56.71&quot; style=&quot;width: 56.0461%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nIkZh/dJMcaa0M1TJ/ygvP3Zx9UtaMMzbRHOcyK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnIkZh%2FdJMcaa0M1TJ%2FygvP3Zx9UtaMMzbRHOcyK0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1508&quot; height=&quot;1494&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌이켜보면 회의를 정말 많이 했던 것 같다. 지치지 않고 끝까지 스터디리드를 이끌어준 리드들에게 감사하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;스터디가 끝나고 나는 무엇을 얻었나?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스터디가 끝나고 나서 나는 어떤 변화가 있을까를 고민해보았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 내가 어떤 것을 중요하게 여기는지 알게 됨.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 어떤 방식으로 앞으로 학습을 해야할지에 대한 의문을 갖게 됨.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 부원들이 개발을 잘하게 되는 과정을 보면서 뿌듯함.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 끝까지 스터디를 진행한 것에 대한 성취감.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 스터디를 진행하면서 헷갈리던 개념들을 더욱 단단하게 복습.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6. 교육자의 삶을 간접체험해봄.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7. 다른 사람들 앞에서 개발 관련해서 발표를 하는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;꾀나 많은 것을 얻었던 것 같다. 스터디를 진행하면서 일정에 쫓겨 정신없이 살앗던 학교 생활들 속에서 뒤돌아보았을때는 생각보다 많은 것을 얻게 된 것 같다. 결국 내가 에너지를 사용하면서 만들어낸 가치들은 언젠가 돌아오지 않을까 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;코드 리뷰&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스터디 리드 또한 진행하였지만 코드 리뷰 역시 진행하였다. 리뷰어 활동으로서 부원들의 미션에 코드 리뷰를 달아두는 형식이다. 사진으로 다 담기는 어렵겠지만 약 9개~10개의 PR들에 리뷰를 달면서 많은 토론을 하였다. 리뷰 활동은 우테코에서 이미 익숙해져있어서 어렵지는 않았다. &lt;b&gt;다만 리뷰의 목적 자체가 나의 프로젝트 개발이 아닌 다른 사람의 코드를 순수하게 리뷰하는 경험은 처음이었다. 말 그대로 처음 보는 맥락과 코드를 이해하며 상대가 놓친 부분들을 찾아주고, 해당 의도를 질문하는 과정은 새로운 경험&lt;/b&gt;이었다. 코드리뷰를 통해서 또 내가 중요하게 여기는 부분들을 다시 확인할 수 있었고, 부원들의 질문에 잘 대답하기 위해서 공부도 열심히 하는 등 좋은 경험들을 많이할 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ViIpd/dJMcabrStkB/Tes2k3P2WtmjWNK1WVFV9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ViIpd/dJMcabrStkB/Tes2k3P2WtmjWNK1WVFV9k/img.png&quot; data-origin-width=&quot;1810&quot; data-origin-height=&quot;1372&quot; data-is-animation=&quot;false&quot; style=&quot;width: 45.8392%; margin-right: 10px;&quot; data-widthpercent=&quot;46.38&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ViIpd/dJMcabrStkB/Tes2k3P2WtmjWNK1WVFV9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FViIpd%2FdJMcabrStkB%2FTes2k3P2WtmjWNK1WVFV9k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1810&quot; height=&quot;1372&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uN7lb/dJMcaixOLzJ/RXz3KVk5Pizr2cw1QZlYvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uN7lb/dJMcaixOLzJ/RXz3KVk5Pizr2cw1QZlYvK/img.png&quot; data-origin-width=&quot;1992&quot; data-origin-height=&quot;1306&quot; data-is-animation=&quot;false&quot; width=&quot;544&quot; height=&quot;357&quot; data-widthpercent=&quot;53.62&quot; style=&quot;width: 52.998%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uN7lb/dJMcaixOLzJ/RXz3KVk5Pizr2cw1QZlYvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuN7lb%2FdJMcaixOLzJ%2FRXz3KVk5Pizr2cw1QZlYvK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1992&quot; height=&quot;1306&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;동아리 활동&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초록 스터디라는 스터디에서 시작된 활동이 어느덧 동아리 규모로 커져있었던 그리디였기에 여러 동아리 활동에도 참여를 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학교 축제에도 참여하였다. 그리디에서는 노트북으로 할 수 있는 게임들을 직접 개발하여 축제에 참여하였다. 게임에 대한 아이디어들을 운영진들이 제공하고 투표를 받아서 부원들이 개발하는 방식으로 진행하였다. 대학교 4학년이라는 죄?와 같은 학년을 하면서 축제를 마지막으로 참여해보는 경험을 귀했던 것 같다. 책상도 나르며 부스를 만들어보고 돈도 벌어보는 경험을 하게 해준 동아리 사람들에게 감사하다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cO8ox1/dJMcahsazs3/STcoAEnkQbhFzNj3KhUMd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cO8ox1/dJMcahsazs3/STcoAEnkQbhFzNj3KhUMd0/img.png&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;1535&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cO8ox1/dJMcahsazs3/STcoAEnkQbhFzNj3KhUMd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcO8ox1%2FdJMcahsazs3%2FSTcoAEnkQbhFzNj3KhUMd0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2048&quot; height=&quot;1535&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1mHHV/dJMcacLbeIb/WgJW3X7CvttKw7vDmxWIB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1mHHV/dJMcacLbeIb/WgJW3X7CvttKw7vDmxWIB1/img.png&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;1535&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;50&quot; style=&quot;width: 49.4186%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1mHHV/dJMcacLbeIb/WgJW3X7CvttKw7vDmxWIB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1mHHV%2FdJMcacLbeIb%2FWgJW3X7CvttKw7vDmxWIB1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2048&quot; height=&quot;1535&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 그리디콘(연사자 초청 행사)들을 준비해주고 있는 운영진들에게 또 감사하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여담으로 &lt;a href=&quot;https://www.greedy-homepage.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;동아리의 홈페이지&lt;/a&gt;를 만들어주고 있는 부원들도 있다는 소식또한 듣게 되었다. 이 동아리가 더욱 오래 좋은 동아리로 남으면 좋겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;그래서 정리를 하자면&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리디 활동을 처음 할때는 걱정 반 설렘 반으로 시작했던 시간들을 지나 벌써 그리디 활동을 마무리하게 되었다. 정신 없는 1학기를 보내어 나름 성취감도 있는 것 같다. 스터디 리드를 진행하면서 좀 더 잘 알려줄 수는 없었는지, 동아리 운영 회의를 진행하면서 참여하지 못한 회의들, 조금 더 친해질 수 있었던 동아리 사람들.. 처럼 아쉬움도 남지만 결국 내가 작지만 가치있는 어떤 활동들을 했다는 것이 뿌듯하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 좋은 동아리 부원이었는지, 좋은 선배였는지 등 궁금한 것도 많은 것을 보면 이 활동을 하면서 정이 많이 들었던 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;후배들이 개발을 공부하는데 도움을 준다는 것이 결국 나와 내 주변의 환경을 성장시키는 것이라고 믿고 있다. 함께 성장하는 것이 큰 힘을 갖는다고 항상 들어왔기에 내가 가진 능력을 조금이라도 나누어 성장해보고자 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;활동을 하면서 내가 중요하게 생각하는 개발 방식을 알게 되었고, 어떤 개발자로 성장하고 싶은지도 알게 된 좋은 경험이었다. 더 나은 소프트웨어 생태계를 위해서 노력했던 지난 날들이 아주 작은 도움이 되었기를 바라며 글을 마무리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리디 사람들에게 모두 감사를.&lt;/p&gt;</description>
      <author>supernovaMK</author>
      <guid isPermaLink="true">https://supernovamk.tistory.com/81</guid>
      <comments>https://supernovamk.tistory.com/81#entry81comment</comments>
      <pubDate>Wed, 26 Aug 2026 20:47:13 +0900</pubDate>
    </item>
    <item>
      <title>AI Agent를 잘 쓰는 것이란 어떤 것일까?</title>
      <link>https://supernovamk.tistory.com/80</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;Gemini_Generated_Image_4k11554k11554k11.png&quot; data-origin-width=&quot;2816&quot; data-origin-height=&quot;1536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgT0kX/dJMcaaT0wkY/Hm5tTGTyq0GoV6UnccxMX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgT0kX/dJMcaaT0wkY/Hm5tTGTyq0GoV6UnccxMX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgT0kX/dJMcaaT0wkY/Hm5tTGTyq0GoV6UnccxMX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgT0kX%2FdJMcaaT0wkY%2FHm5tTGTyq0GoV6UnccxMX0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2816&quot; height=&quot;1536&quot; data-filename=&quot;Gemini_Generated_Image_4k11554k11554k11.png&quot; data-origin-width=&quot;2816&quot; data-origin-height=&quot;1536&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h1&gt;&lt;b&gt;내가 진짜 겪은 AI Agent의 병목지점은 무엇일까..?&lt;/b&gt;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 에이전트를 잘 사용하기 위해 최근 여러 방식이 나오고 있는 것 같다. 프롬프트를 잘게 쪼개거나 에이전트를 역할별로 나누는 등 다양한 기법이 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;1&quot; data-ke-size=&quot;size16&quot;&gt;나는 에이전트 활용에서 가장 중요한 것은 그저 '내가 원하는 결과를 잘 만들어냈는가?'라고 생각한다. 에이전트를 어떤 방식으로 사용하든, 프롬프트를 얼마나 잘 다듬었든, 결국 본질적인 평가는 원하는 결과물의 완성도로 결정될 것 같다. 가령 프롬프트를 길게 적어 AI에게 현재 상황과 맥락을 구구절절 이해시키는 것보다, 그냥 &quot;~이렇게 해줘&quot;라고 던졌을 때 토큰도 덜 쓰고 원하는 결과를 더 빨리 얻게 되는 상황도 오지 않을까 생각한다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;1&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;지금까지 AI 에이전트를 사용해보면서, 에이전트를 역할별로 나누는 식의 고도화된 방식들을 시도해 보기도 했지만 이전과 큰 차이를 느끼진 못했다. 프로젝트 규모가 그리 크지 않은 취준생인 나로서는 더더욱 체감하기 어려웠다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;그렇다면 내가 AI를 직접 사용하며 겪은 실질적인 병목 지점은 결국 '토큰 수'였다. 에이전트가 내 이전 컨텍스트를 파악하는 데 걸리는 대기 시간, 그리고 잘못 이해한 내용을 바로잡고 다시 알려주는 데 소모되는 토큰 낭비가 진짜 병목이었던 것이다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;이때 무의미하게 타버리는 토큰들을 내가 통제하고 줄일 수만 있다면 내 현재 상황에서는 그것이 AI 에이전트를 가장 잘 쓰는 방법이라고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 매 세션마다 기억을 잃는 에이전트&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 에이전트와 일하면서 토큰이 제일 많이 사용되는 곳은 코딩만은 아니었다. 매번 새 창을 열 때마다 &quot;지금 우리 프로젝트가 어떤 상태인지&quot; 처음부터 다시 설명하는 데 비용이 소모되었다. 특히 이전 작업에 대한 기록들이 해당 세션에 남겨져있다면 문제가 없었지만 나와 내 팀원만 알고있는 맥락을 이해시키기 위해서는 토큰 소모량이 더 많아지는 것을 확인할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 머릿속에 있는 맥락들을 에이전트에게 이해시키려면, 매번 코드를 다시 읽고 기획서를 주면서 git log를 추적해야 한다는 것이다. 세션마다 이 과정이 반복되다 보니 굳이 안 써도 될 비용이 반복되고 있다고 생각하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 내가 생각하는 &quot;잘 쓴다&quot;는 건 프롬프트가 아니었다.&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI를 잘 쓰려면 agent에게 역할을 나누고 agent에게 명령을 잘 내리는 것처럼 다양한 기법들이 존재한다. 하지만 앞서 말했듯 내가 직접 겪은 진짜 병목은 프롬프트나 역할 문제가 아닌 맥락 파악에 있었다. 맥락 파악에서 발생하는 토큰은 내가 원하는 것을 만들기 위한 큰 자산이다. 맥락을 이해시키기 위해서 내가 채팅하는 것 역시 결국 토큰으로 비용이 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 팀 차원으로 보았을 때에도 ai agent를 공유하지 않는 이상 맥락 파악에 사용되는 토큰은 병렬적으로 발생한다. 팀원A가 작업한 내용 뒤에 작업을 하기 전에는 팀원 A가 작성한 코드들을 모두 읽어보아야할 수도 있다. 반복되는 토큰이 발생하면서 맥락 공유가 어렵다는 점도 문제인 지점이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 파편화된 기존 기획서나 커밋 메시지로는 &quot;무엇이 현재 유효한 결정인지&quot; 한번에 알 수 없었다. 따라서 프로젝트의 &quot;왜&quot;를 한 번에 복구할 수 있는 장치가 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 코드 옆에 지식 저장소 뇌(Brain) 만들어주기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맥락을 더 빨리 이해할 수 있도록 기록들을 저장할 공간이 먼저 필요했다. 따라서 코드 레포 옆에 지식 레포를 만들었다. 코드는 코드 레포에, 결정과 맥락은 지식 레포에 두는 식이다. 저장소에서는 크게 3가지를 사용하여 기록들을 보관하도록 하였다. 뒤의 글에서부터 이 저장소는 &lt;b&gt;Brain&lt;/b&gt;이라고 부르도록 하겠다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Frontmatter 스키마:&lt;/b&gt; 모든 노트에 YAML 블록을 달아서 검색 시 본문 전체가 아닌 메타데이터만 훑도록 했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;클러스터 노트:&lt;/b&gt; 주제 하나당 파일 하나를 뒀다. 읽기의 진입점을 하나로 모아서, 관련 이슈와 핵심 문서를 한눈에 보게 만들었다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Supersede 체인:&lt;/b&gt; 결정이 바뀌면 지우는 게 아니라 status: superseded로 남겨뒀다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기록들을 정리하기 위해서 더 나은 방식을 찾기 위해서는 계속 사용하면서 업데이트를 해볼 예정이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 그럼 기록 보관소를 사용해서 정말 토큰 수를 아꼈을까?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI에게 &quot;결제 타임아웃 정책이 뭐야?&quot;라고 질문했을 때, 잘 정리된 경우와 없는 경우 토큰 소모량이 얼마나 차이 나는지 직접 비교해 보았다.&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;5&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;질문 한 번 할 때 드는 비용 비교&lt;/b&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-path-to-node=&quot;6&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;비교 항목&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;방식 설명&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;소모 토큰&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;6,1,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,1,0,0&quot;&gt;시나리오 A&lt;/b&gt; (기록 없음)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;6,1,1,0&quot;&gt;AI가 흩어진 소스 코드 9개, 기획서, 최근 깃 로그(Git log) 14개를 전부 뒤져서 파악함&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;6,1,2,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,1,2,0&quot;&gt;약 24,027&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;6,2,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,2,0,0&quot;&gt;시나리오 B&lt;/b&gt; (기록 있음)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;6,2,1,0&quot;&gt;AI가 잘 정리된 '결제 클러스터 노트' 1개만 읽고 바로 답을 찾음&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;6,2,2,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,2,2,0&quot;&gt;622 (A의 2.6%)&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;6,3,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,3,0,0&quot;&gt;시나리오 B&lt;/b&gt; (최대치)&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;6,3,1,0&quot;&gt;필요시 클러스터 노트에 연결된 상세 링크 3개를 더 열어봄&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;6,3,2,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,3,2,0&quot;&gt;3,273 (A의 13.6%)&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;blockquote data-path-to-node=&quot;7&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;7,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;7,0&quot;&gt;결과:&lt;/b&gt; 문서를 미리 잘 정리해 두면, 24,000토큰을 써야 알 수 있던 답을 단 600토큰 만에 얻을 수 있었다. 비용이 &lt;b data-index-in-node=&quot;73&quot; data-path-to-node=&quot;7,0&quot;&gt;2.6% 수준&lt;/b&gt;으로 줄어든 셈이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 효과가 있었다. 가정했던 맥락 파악에서 더 적은 토큰을 토대로 작업 준비를 할 수 있었다. 다만 여기서 주의해야할 점이 있다. 현재 맥락 파악을 Brain에 있는 기록들을 보고 파악해서 작업하도록 한다는 것은 실제 ai가 다른 기록들을 전부 읽어보지 않는 다는 것이다. 그 뜻은 Brain에 있는 기록물들에 대해서는 정확하게 기록하고 파악해야한다는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 에이전트가 헷갈리지 않도록 도와주는 것&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비용 절감도 컸지만, 진짜 중요한 건 에이전트가 오래된 맥락을 바탕으로 틀린 답을 내놓는 일이 사라졌다는 점이라고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에 소셜 로그인을 2단계로 구성했다가 1단계로 바꾼 적이 있는데, git log에는 두 결정이 다 남아있었다. brain이 없을 때는 에이전트가 커밋 순서를 헷갈려서 예전 방식을 현재 정책인 것처럼 설명하는 일이 발생한 적도 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 brain에서는 구버전 결정이 명확하게 superseded 처리되어 '이전 결정' 으로 빠져버린다. 에이전트가 낡은 결정을 정답으로 착각할 여지가 구조적으로 차단된 것이다. 맥락의 순서들을 헷갈리지 않도록 하는 것이 중요하다고 생각했다. 물론 이 부분들에 대해서 brain과 code의 정합성을 맞춰가야한다는 것이 숙제로 남았지만 정확도를 높이기 위해 집중하는 곳을 한 곳으로 만든데에 의미가 있다고 생각한다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 현재 운영하는 워크플로우&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽기만 하는 시스템은 반드시 낡아버린다. 그래서 쓰는 행위가 일상적인 개발 루틴 안에 완전히 녹아들어야 한다고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시점 하는 일&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;기능 착수 전&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;/build &amp;lt;무엇을&amp;gt; &amp;mdash; 활성 결정&amp;middot;과거 이슈를 모은 브리프를 먼저 받고 구현&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;궁금할 때&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;/recall &amp;lt;주제&amp;gt; &amp;mdash; 읽기 전용 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;버그 잡기 전&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;/find-similar-issue &amp;lt;증상&amp;gt; &amp;mdash; 재발인지 먼저 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;결정&amp;middot;교훈이 나오면&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;/capture &quot;...&quot;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;버그 해결 후&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;/ingest-issue &amp;mdash; symptoms가 다음 재발 탐지의 검색 키가 된다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;주 1회&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;/maintain &amp;mdash; 무결성 검사 + 클러스터 재구성&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. 해보고 알게 된 것들&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 시스템을 굴려보며 몇 가지 느낀 점들이 있다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;평소 커밋 메시지가 전부다:&lt;/b&gt; &quot;왜 이렇게 했고, 무엇을 버렸는지&quot;가 커밋에 적혀 있었기 때문에 결정 노트를 수월하게 채울 수 있었다고 생각한다. 커밋이 빈약했다면 볼트를 만들 재료조차 없었을 것이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;초기 비용이 크다:&lt;/b&gt; 구축 비용이 초반에 몰려 있어서 수명이 짧은 프로젝트에는 손해일 수 있다. 길게 가는 프로젝트에서 이득이 극대화된다고 생각한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;볼트도 결국 낡는다:&lt;/b&gt; 원본 코드는 변하는데 볼트의 스냅샷은 그대로일 때가 있다. 클러스터가 너무 커지면 쪼개야 하는 유지보수 작업도 분명 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;8. 결론&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 AI 에이전트를 잘 쓴다는 건 &lt;b&gt;에이전트가 매번 밑바닥부터 다시 알아내야 하는 정보의 양을 줄여주는 것&lt;/b&gt;이라고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 구조를 잡아두니 토큰 비용은 실제로 줄었다. 정확도를 판단하기 위해서는 아직 더 실험해보아야겠지만, 원래의 정확도를 측정하기 불가능했던 상황을 brain의 정확도가 어느정도인지를 파악하는 것으로 측정할 수 있도록 만들었다는데 의미가 있을 것 같다. 앞으로 복잡한 장기 프로젝트를 진행할 때 이런 형태의 기록 레포 구축을 통해서 더 팀에 맞는 brain을 만들어가는데 공부해고 싶다.&lt;/p&gt;</description>
      <author>supernovaMK</author>
      <guid isPermaLink="true">https://supernovamk.tistory.com/80</guid>
      <comments>https://supernovamk.tistory.com/80#entry80comment</comments>
      <pubDate>Tue, 25 Aug 2026 18:03:59 +0900</pubDate>
    </item>
    <item>
      <title>로컬 캐시로 전환하면 정확히 어떤 점이 좋을까?</title>
      <link>https://supernovamk.tistory.com/79</link>
      <description>&lt;h1&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;선착순 서비스에서의 조회&lt;/b&gt;&lt;/span&gt;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;현재 givemeticket 선착순 서비스를 구현하고 있다. 선착순 서비스를 보통 생각해보면 예매 직전 사용자들이 엄청나게 몰리게 된다. 실제 수강신청을 했던 내 모습을 보더라도 새로고침을 반복해서 요청하는 과정에서 조회 요청을 여러번 누르고 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그럼 만약 DB 조회 요청이 반복되면? 1인당 &lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;3번 x 100명 x 10개의 행사 일때 순간적인 DB 조회 요청이 3000번 요청&lt;/b&gt;&lt;/span&gt;된다. 주 기능인 선착순 신청이 시작하기도 전에 이미 DB 커넥션이 엄청나게 소요 되는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2844&quot; data-origin-height=&quot;1246&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1oUHS/dJMcag7IvTC/RyoZiplbbM6NnPowZgKCQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1oUHS/dJMcag7IvTC/RyoZiplbbM6NnPowZgKCQk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1oUHS/dJMcag7IvTC/RyoZiplbbM6NnPowZgKCQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1oUHS%2FdJMcag7IvTC%2FRyoZiplbbM6NnPowZgKCQk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2844&quot; height=&quot;1246&quot; data-origin-width=&quot;2844&quot; data-origin-height=&quot;1246&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2850&quot; data-origin-height=&quot;1452&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UILp6/dJMcaiExxNA/xgMyqRjDRhvhJbPWy07eG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UILp6/dJMcaiExxNA/xgMyqRjDRhvhJbPWy07eG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UILp6/dJMcaiExxNA/xgMyqRjDRhvhJbPWy07eG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FUILp6%2FdJMcaiExxNA%2FxgMyqRjDRhvhJbPWy07eG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2850&quot; height=&quot;1452&quot; data-origin-width=&quot;2850&quot; data-origin-height=&quot;1452&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;현재 보이는 것으로는 HikariCP 커넥션에서 대기중인 커넥션으로 인해서 병목이 발생하고 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;DB 인덱싱을 통해서 DB 조회 시간을 단축시켰다고 해서 커넥션의 기본적인 요청량은 줄여지지 않는다. 그럼 해결책은 캐싱으로 이어지게 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h1&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;캐시 전략 고민&lt;/b&gt;&lt;/span&gt;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;캐싱은 주로 변하지 않고 자주 조회되는 데이터를 대상으로 한다. 행사 정보처럼 여러 사용자가 함께 보면서 자주 바뀌지 않는 데이터가 대표적이다. 캐시를 도입할 때 크게 두 가지 선택지가 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;인메모리 캐싱&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Redis 캐싱&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;순수 캐싱 목적만 본다면 중앙에서 캐시 메모리를 관리할 수 있는 Redis가 매력적이었다. 인메모리 캐싱은 배포할 때마다 캐시가 비워지고, 생명주기가 애플리케이션 서버에 강하게 의존한다는 단점이 있다. 이런 이유로 Redis를 캐시 스토리지로 먼저 채택했다.&lt;/span&gt;&lt;/p&gt;
&lt;h1&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Redis 캐싱 적용 시 고려한 점&lt;/b&gt;&lt;/span&gt;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Redis를 도입하면서 두 가지 원칙을 세웠다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,0,0&quot;&gt;종료된 행사는 캐싱하지 않음:&lt;/b&gt; 이미 끝난 행사는 다시 조회될 일이 거의 없으므로 메모리만 낭비한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15,1,0&quot;&gt;데이터 압축 저장:&lt;/b&gt; 네트워크 왕복 비용을 줄이기 위해 데이터를 최소한으로 압축해서 저장하기로 했다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;즉 메모리는 크지 않고 데이터 왕복에서 비용이 발생하기 때문에 데이터를 최소한으로 캐싱하는 것이 중요하다고 생각하여 위에 2가지를 적용하였다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;캐시에 담을 값 정하기&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;먼저 캐시에 담을 구조부터 정의해야 했다. 엔티티를 그대로 직렬화할 수도 있었지만 두 가지 문제가 걸렸다. &lt;b&gt;첫째는 캐시에서 꺼낸 객체가 영속 상태로 오해받을 수 있다는 점, 둘째는 JPA 매핑 변경에 캐시 포맷이 강제로 끌려다니게 된다는 점이다.&lt;/b&gt; 그래서 별도의 스냅샷을 만들었다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;public record CampaignSnapshot(
        Long id,
        Long ownerId,
        String shortCode,
        String title,
        CampaignType type,
        int totalStock,
        LocalDateTime openAt,
        boolean requiresPayment,
        CampaignStatus status,
        Detail detail
) {
    public static CampaignSnapshot from(Campaign campaign) { ... }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;행사 전 데이터들만 캐싱&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;끝난 행사는 다시 몰려서 조회될 일이 거의 없기에 캐시 메모리만 차지한다. 그래서 캐싱 대상에서 제외하기로 했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;public boolean isCacheable(LocalDateTime now) {
    if (status == CampaignStatus.DELETED || status == CampaignStatus.CLOSED) {
        return false;
    }
    return detail == null
            || detail.eventEndAt() == null
            || !detail.eventEndAt().isBefore(now);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;실제로 캐시를 채우는 지점에서 이 조건을 확인하도록 구현하였다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h2 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;데이터 압축시 GZIP 사용&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;압축 라이브러리를 몇 가지 두고 비교해 보았다.&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;gzip (JDK 내장)&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;의존성 0, 압축률 좋음&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;압축 속도가 상대적으로 느림&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Snappy / LZ4&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;압축&amp;middot;해제가 매우 빠름&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;압축률이 낮음, 별도 의존성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Zstd&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;압축률&amp;middot;속도 둘 다 좋음&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;네이티브 라이브러리 의존&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;결론은 &lt;b data-index-in-node=&quot;4&quot; data-path-to-node=&quot;24&quot;&gt;JDK에 내장된 GZIP&lt;/b&gt;이었다. 이유는 두 가지다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;첫째, &lt;b data-index-in-node=&quot;4&quot; data-path-to-node=&quot;25&quot;&gt;의존성이 늘지 않는다.&lt;/b&gt; Snappy나 Zstd는 성능이 뛰어나지만 별도 라이브러리를 추가해야 하고, Zstd는 네이티브 바이너리까지 관리해야 한다. 지금 단계에서 그만한 이득이 있는지 확신할 수 없었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;25&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;26&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;둘째, &lt;b data-index-in-node=&quot;4&quot; data-path-to-node=&quot;26&quot;&gt;행사 안내문은 반복되는 텍스트가 많다.&lt;/b&gt; &quot;공연 30분 전부터 입장 가능합니다&quot; 같은 문장이 반복되는 텍스트에서 GZIP은 압축률이 상당히 잘 나온다. 반대로 Snappy 계열은 속도를 위해 압축률을 희생하는 구조라 네트워크 바이트를 줄이려는 목적에는 덜 맞았다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;27&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;실제로 테스트해 보니 3,000자 안내문이 &lt;b data-index-in-node=&quot;24&quot; data-path-to-node=&quot;27&quot;&gt;7,497바이트에서 561바이트&lt;/b&gt;로 줄었다. 다만 압축을 사용할 경우 요청마다 CPU를 소모하고 힙에 임시 버퍼를 만든다. 데이터 크기가 너무 작으면 GZIP 헤더 비용과 CPU 연산만 하고 얻는 게 없을 수도 있다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Override
public byte[] serialize(T value) throws SerializationException {
    if (value == null) {
        return EMPTY;
    }
    try {
        byte[] json = objectMapper.writeValueAsBytes(value);
        byte[] compressed = gzip(json);

        rawSize.record(json.length);          // campaign_cache_value_size_bytes{state=&quot;raw&quot;}
        compressedSize.record(compressed.length);  // ...{state=&quot;compressed&quot;}

        return compressed;
    } catch (IOException e) {
        throw new SerializationException(&quot;캐시 값을 압축하지 못했다&quot;, e);
    }
}

@Override
public T deserialize(byte[] bytes) throws SerializationException {
    if (bytes == null || bytes.length == 0) {
        return null;
    }
    try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(bytes))) {
        return objectMapper.readValue(in.readAllBytes(), type);
    } catch (IOException e) {
        // 포맷이 바뀌었거나 값이 깨진 경우다. 캐시 미스로 떨어뜨려 DB 에서 다시 읽게 한다.
        throw new SerializationException(&quot;캐시 값을 풀지 못했다&quot;, e);
    }
}

private byte[] gzip(byte[] raw) throws IOException {
    ByteArrayOutputStream buffer = new ByteArrayOutputStream(raw.length / 2);
    try (GZIPOutputStream gzip = new GZIPOutputStream(buffer)) {
        gzip.write(raw);
    }
    return buffer.toByteArray();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;28&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;직렬화 과정에서의 삽질&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;구현 후 테스트를 돌리는데 역직렬화에서 뜬금없는 에러가 났다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;UnrecognizedPropertyException: Unrecognized field &quot;deleted&quot;(class CampaignSnapshot), not marked as ignorable
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;원인은 CampaignSnapshot에 넣어둔 isDeleted() 메서드였다. Jackson이 이걸 boolean getter로 인식해서 deleted라는 필드를 JSON에 제멋대로 끼워 넣고 있었던 것이다. 읽을 때는 대응하는 레코드 컴포넌트가 없으니 터질 수밖에 없었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;32&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;그래서 캐시 전용 ObjectMapper를 따로 구성하고 is-getter 탐지를 꺼버렸다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;public static ObjectMapper defaultObjectMapper() {
    return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            // isDeleted() 같은 파생 메서드가 필드로 새어 나가면 바이트가 늘고 역직렬화가 깨진다
            .setVisibility(PropertyAccessor.IS_GETTER, JsonAutoDetect.Visibility.NONE)
            // 스냅샷에 필드를 추가한 배포 직후, Redis 에 남아 있는 옛 값 때문에 전부 터지는 것보다
            // 조용히 무시하고 TTL 로 갈리는 편이 낫다
            .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;애플리케이션 공용 ObjectMapper를 그대로 쓰지 않고 따로 만든 것도 의도한 바다. 응답 포맷을 바꿨다고 Redis에 이미 쌓여 있는 캐시가 조용히 깨지는 상황을 막고 싶었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이 직렬화기를 캠페인 캐시 전용&amp;nbsp;RedisTemplate에 등록했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@Bean
public RedisTemplate&amp;lt;String, CampaignSnapshot&amp;gt; campaignCacheRedisTemplate(
        RedisConnectionFactory connectionFactory,
        MeterRegistry meterRegistry
) {
    RedisTemplate&amp;lt;String, CampaignSnapshot&amp;gt; template = new RedisTemplate&amp;lt;&amp;gt;();
    template.setConnectionFactory(connectionFactory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(new GzipRedisSerializer&amp;lt;&amp;gt;(
            CampaignSnapshot.class,
            GzipRedisSerializer.defaultObjectMapper(),
            meterRegistry,
            &quot;campaign&quot;));
    return template;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-path-to-node=&quot;36&quot; data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-path-to-node=&quot;36&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Redis 캐시 연동과 방어적 설계&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;앞선 결과 Redis를 사용해서 캐싱을 진행해보았다. 먼저 기존 데이터를 압축하여 redis에 저장하고 압축을 풀어서 꺼내는 부분이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Redis를 쓸 때 가장 경계했던 것은 &quot;Redis 장애가 곧 전체 서비스 장애로 이어지는 상황&quot;이었다. Redis가 죽거나 값이 깨져도 조회 자체는 실패하면 안 된다. 그래서 모든 예외는 로그와 지표로만 남기고 캐시 미스처럼 동작하게 처리했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;private static final String KEY_PREFIX = &quot;campaign:detail:&quot;;

@Override
public Optional&amp;lt;CampaignSnapshot&amp;gt; find(String shortCode) {
    try {
        CampaignSnapshot cached = getTimer.record(() -&amp;gt; redisTemplate.opsForValue().get(key(shortCode)));

        if (cached == null) {
            misses.increment();
            return Optional.empty();
        }
        hits.increment();
        return Optional.of(cached);
    } catch (RuntimeException e) {
        errors.increment();
        log.warn(&quot;campaign cache read failed: shortCode={}, reason={}&quot;, shortCode, e.toString());
        evict(shortCode);
        return Optional.empty();
    }
}

@Override
public void save(CampaignSnapshot snapshot) {
    try {
        redisTemplate.opsForValue().set(key(snapshot.shortCode()), snapshot, ttl);
    } catch (RuntimeException e) {
        errors.increment();
        log.warn(&quot;campaign cache write failed: shortCode={}, reason={}&quot;, snapshot.shortCode(), e.toString());
    }
}

@Override
public void evict(String shortCode) {
    try {
        redisTemplate.delete(key(shortCode));
    } catch (RuntimeException e) {
        // 지우지 못한 캐시는 TTL 이 만료될 때까지 낡은 값을 준다. 조용히 넘기면 안 된다.
        errors.increment();
        log.error(&quot;campaign cache evict failed: shortCode={}&quot;, shortCode, e);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-path-to-node=&quot;39&quot; data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;39&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;캐시 무효화(Eviction) 타이밍의 함정&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;캐시를 '넣는 것'보다 '지우는 것'이 훨씬 까다로웠다. 무효화 시점은 크게 세 곳이었다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;41&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;행사 수정 (정원 변경, 안내문 수정 등)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;행사 삭제&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;오픈 스케줄러&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;42&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;특히 3번을 놓치기 쉽다. 이 서비스는 status가 DB 컬럼으로 존재하고, 스케줄러가 1초마다 SCHEDULED 상태를 OPEN으로 바꾼다. 이때 캐시가 여전히 SCHEDULED로 남아있으면 열린 행사도 닫혀 있다고 잘못 보여주게 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;캐시를 지울 때는 &lt;b data-index-in-node=&quot;10&quot; data-path-to-node=&quot;44&quot;&gt;트랜잭션 커밋 타이밍&lt;/b&gt;을 고려해 두 번 지우도록 처리했다. 한 번만 지우면, 지우는 로직의 커밋이 끝나기 전에 들어온 조회 요청이 변경되기 전의 DB 값을 읽어와서 예전 값으로 캐시를 다시 채워버리기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;public void evict(String shortCode) {
    campaignCacheRepository.evict(shortCode);

    if (!TransactionSynchronizationManager.isSynchronizationActive()) {
        return;
    }
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCompletion(int status) {
            campaignCacheRepository.evict(shortCode);
        }
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-path-to-node=&quot;46&quot; data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-path-to-node=&quot;46&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Redis 캐시의 한계와 Local Cache 도입&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Redis를 도입해 DB 커넥션 병목은 해결했지만, 새로운 한계가 보였다. Redis도 결국 외부 서버이므로 &lt;b data-index-in-node=&quot;61&quot; data-path-to-node=&quot;47&quot;&gt;네트워크 왕복 + GZIP 압축 해제 + JSON 파싱 비용&lt;/b&gt;이 조회마다 발생한다. 1,600 RPS 부하를 주어보니 DB 직접 조회보다는 낫지만 여전히 CPU와 할당 메모리 측면에서 아쉬움이 남았다.&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;DB 직접 조회&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1,600&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;927&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;58.0%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Redis 캐시&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1,600&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1,176&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;73.5%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;95%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Redis가 DB보다 1.27배 더 버티긴 했지만&amp;nbsp;조회당 570KB를 할당하니 1,600 rps면 초당 890MB를 할당해야 하는데 그 부분에서 병목이 생겼다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;즉 Redis 캐싱은 DB 커넥션 병목은 확실히 없애주지만,&amp;nbsp;조회당 비용을 0으로 만들어주지는 않는다.&amp;nbsp;그 비용을 더 줄이려면 네트워크 왕복 비용을 최소화 하고 역직렬화를 없애보려고 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이 비용을 더 극단적으로 줄이기 위해 &lt;b data-index-in-node=&quot;21&quot; data-path-to-node=&quot;48&quot;&gt;Caffeine 기반의 로컬 캐시&lt;/b&gt;를 도입하기로 했다. 로컬 캐시를 고르는 후보는 크게 4가지 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;ConcurrentHashMap&amp;nbsp;직접 구현&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;의존성은 없지만 TTL, 최대 크기, 축출 정책을 전부 직접 만들어야 한다. 캐시가 무한정 커져서 힙을 먹는 사고가 나기 쉽다&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Guava Cache&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;검증됐지만 사실상 유지보수가 멈췄고, 개발자 본인이 Caffeine을 후계로 만들었다&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Ehcache&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;디스크 저장, 분산 캐시까지 되는 대신 무겁다. 프로세스 안 캐시만 필요한 지금은 과하다&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Caffeine&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Guava Cache의 후계. W-TinyLFU 축출 정책으로 히트율이 좋고, Spring Boot가 기본 지원한다&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;ConcurrentHashMap 을 사용하지 않은 이유는 로컬 캐시는 힙에 그대로 쌓이기 때문이다.&amp;nbsp;행사 데이터가 늘어나면 old 영역에 상주하는 양이 늘어 GC에 쌓인다고 한다. 생명주기를 관리하기 위해서는 외부 라이브러리를 사용하는 것이 좋다고 생각했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로컬 캐시를 넣는다고 Redis를 사용하지 않는 것은 아니다. 로컬을 앞(L1), Redis를 뒤(L2)에 두는 2단 구현을 해보았다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;@Override
public Optional&amp;lt;CampaignSnapshot&amp;gt; find(String shortCode) {
    Optional&amp;lt;CampaignSnapshot&amp;gt; local = localCache.find(shortCode);
    if (local.isPresent()) {
        return local;
    }

    Optional&amp;lt;CampaignSnapshot&amp;gt; remote = remoteCache.find(shortCode);
    remote.ifPresent(localCache::put);   // Redis 에서 찾으면 로컬에도 올린다
    return remote;
}

@Override
public void evict(String shortCode) {
    localCache.evict(shortCode);    // 가까운 곳부터
    remoteCache.evict(shortCode);
    invalidation.publish(shortCode);  // 다른 인스턴스에게 알린다
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-path-to-node=&quot;52&quot; data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-path-to-node=&quot;52&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;다중 서버 환경의 로컬 캐시 동기화: Redis Pub/Sub&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;로컬 캐시를 쓰면 피할 수 없는 문제가 있다. 바로 &lt;b data-index-in-node=&quot;29&quot; data-path-to-node=&quot;53&quot;&gt;인스턴스 간 데이터 불일치&lt;/b&gt;다. 서버 A에서 행사를 수정해도 서버 B의 로컬 캐시에는 옛날 값이 남아있게 된다. 이 문제를 해결하기 위해 Redis Pub/Sub을 사용해보았다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;5029&quot; data-origin-height=&quot;2728&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cJFU5R/dJMcags9UGT/ksNyWogl6HJpVqeQX0Gsz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cJFU5R/dJMcags9UGT/ksNyWogl6HJpVqeQX0Gsz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cJFU5R/dJMcags9UGT/ksNyWogl6HJpVqeQX0Gsz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcJFU5R%2FdJMcags9UGT%2FksNyWogl6HJpVqeQX0Gsz1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;5029&quot; height=&quot;2728&quot; data-origin-width=&quot;5029&quot; data-origin-height=&quot;2728&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public static final String CHANNEL = &quot;campaign:cache:invalidate&quot;;
private final String instanceId = UUID.randomUUID().toString();

public void publish(String shortCode) {
    try {
        stringRedisTemplate.convertAndSend(CHANNEL, instanceId + SEPARATOR + shortCode);
        published.increment();
    } catch (RuntimeException e) {
        log.warn(&quot;cache invalidation publish failed: shortCode={}, reason={}&quot;, shortCode, e.toString());
    }
}

@Override
public void onMessage(Message message, byte[] pattern) {
    String body = new String(message.getBody(), StandardCharsets.UTF_8);
    int separator = body.indexOf(SEPARATOR);
    if (separator &amp;lt; 0) {
        log.warn(&quot;cache invalidation message malformed: {}&quot;, body);
        return;
    }

    String sender = body.substring(0, separator);
    String shortCode = body.substring(separator + 1);

    if (instanceId.equals(sender)) {
        return;
    }

    localCache.evict(shortCode);
    received.increment();
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;메시지에 instanceId를 넣은 이유는 자기가 발행한 메시지를 자기가 다시 받아 불필요하게 캐시를 지우고 지표를 오염시키는 상황을 막기 위해서다. 다만 Redis Pub/Sub은 at-most-once 특성상 구독이 끊긴 사이에 날아간 메시지는 유실될 수 있다. 따라서 로컬 캐시에 짧은 TTL(기본 30초)을 반드시 함께 걸어두어 안전장치를 마련했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;58&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;성능 비교 및 결론&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;데이터를 조회하는 과정에서 앞선 3가지 방식을 비교해보았다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;실험 조건&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;안내문 50,000자 (UTF-8 약 150KB)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;캠페인 50개를 무작위로 조회&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;조회 시나리오&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;백엔드 2코어, 힙 718MB, G1GC&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;도착률을 100 &amp;rarr; 200 &amp;rarr; 400 &amp;rarr; 800 &amp;rarr; 1600 rps로 올리며 각 60초 유지&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;성능 비교&lt;/b&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;모드 목표 rps 실제 rps 달성률 할당/req GC 정지 p99 CPU&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;none&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;765KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;29회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;0.18s&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;74ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;20%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;none&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;400&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;400&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;770KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;115회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;0.19s&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;4ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;15%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;none&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;800&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;800&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;781KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;130회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;0.45s&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;15ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;30%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;none&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1600&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;927&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;58%&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;777KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;114회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1.87s&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;998ms&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;redis&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;597KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;23회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;0.09s&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;6ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;16%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;redis&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;400&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;400&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;569KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;84회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;0.14s&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;3ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;17%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;redis&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;800&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;800&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;570KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;178회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;0.26s&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;5ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;25%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;redis&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1600&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1176&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;74%&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;575KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;125회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1.99s&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;800ms&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;95%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;local&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;166KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;6회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;0.09s&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;5ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;14%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;local&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;400&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;400&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;169KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;27회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;0.12s&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;5ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;13%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;local&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;800&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;800&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;100%&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;168KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;57회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;0.13s&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;2ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;15%&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;local&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1600&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;1600&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;100%&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;168KB&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;114회&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;0.52s&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;68ms&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;47%&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;테스트 결과, &lt;b data-index-in-node=&quot;8&quot; data-path-to-node=&quot;6&quot;&gt;800 RPS 이하의 구간에서는 세 방식 모두 성능 차이가 거의 나지 않았다.&lt;/b&gt; 트래픽이 적을 때는 DB를 직접 찌르든 캐시를 쓰든 서버가 버텨내는 데 무리가 없었던 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;큰 차이가 나는 부분은 한계 부하인 1,600 RPS 구간이었다. DB 조회와 Redis 캐시는 커넥션이나 네트워크 한계에 부딪혀 처리율이 뚝 떨어졌지만, &lt;b data-index-in-node=&quot;85&quot; data-path-to-node=&quot;7&quot;&gt;로컬 캐시는 CPU 사용률 47%의 여유가 있었다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1760&quot; data-origin-height=&quot;1230&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SnjGv/dJMcabrRpZQ/sJDxvn6mCPKbyoMekpwnQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SnjGv/dJMcabrRpZQ/sJDxvn6mCPKbyoMekpwnQk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SnjGv/dJMcabrRpZQ/sJDxvn6mCPKbyoMekpwnQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSnjGv%2FdJMcabrRpZQ%2FsJDxvn6mCPKbyoMekpwnQk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1760&quot; height=&quot;1230&quot; data-origin-width=&quot;1760&quot; data-origin-height=&quot;1230&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;왜 GC 정지 시간에 차이가 날까?&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;모니터링 과정에서 흥미로운 지점을 발견했다. 각 방식별로 요청당 메모리 할당량이 달랐던 것이다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;11&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,0,0&quot;&gt;DB 직접 조회:&lt;/b&gt; 요청당 765KB 할당&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,1,0&quot;&gt;Redis 캐시:&lt;/b&gt; 요청당 570KB 할당&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;11,2,0&quot;&gt;로컬 캐시:&lt;/b&gt; 요청당 168KB 할당&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;GC 주기는 결국 Eden 영역이 얼마나 빠르게 차오르느냐에 따라 결정된다. 요청당 만드는 객체의 크기가 달랐기 때문에 GC 빈도와 정지 시간(STW)도 갈릴 수밖에 없었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;12&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;MySQL 직접 조회할 때&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;JDBC 드라이버가 압축되지 않은 150KB 데이터를 그대로 읽어온다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;ResultSet이 컬럼을 String으로 만들고, Hibernate가 엔티티 객체를 생성해 영속성 컨텍스트에 등록한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이 과정에서 페이로드 크기와 무관한 프레임워크 고정 오버헤드까지 더해져 메모리 소모가 가장 컸다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;Redis에서 조회할 때&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Lettuce가 압축된 상태의 바이트를 네트워크로 읽어온다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;GZIP 압축을 풀면서 150KB짜리 바이트 배열이 새로 생기고, 이를 다시 읽어 150KB 사본을 만든다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;Jackson이 이 데이터를 파싱해 String 객체들을 조립한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;네트워크 전송량은 아꼈지만, 압축 해제와 역직렬화 과정에서 여전히 상당한 객체가 힙에 만들어졌다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;로컬 캐시에서 꺼낼 때&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이미 힙 메모리에 만들어 둔 객체의 참조(Reference)를 그대로 가져다 쓴다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;네트워크 I/O도, 압축 해제도, 무거운 객체 생성 과정도 아예 존재하지 않는다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;1,600 RPS 부하 상황에서 총 GC 정지 시간은 각각 &lt;b data-index-in-node=&quot;33&quot; data-path-to-node=&quot;14&quot;&gt;1.87초 / 1.99초 / 0.52초&lt;/b&gt;였다. 전체 구간 비율로 보면 미미해 보이지만, GC가 몰리는 순간 발생하는 p99 지연 시간(998ms vs 800ms vs 68ms)에서 체감 성능은 크게 달랐다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;16&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;&lt;b&gt;GC 예측과의 차이&lt;/b&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;처음에는 &quot;Redis 캐시를 쓰면 객체 직렬화/역직렬화 때문에 GC가 오히려 더 많이 터지지 않을까?&quot; 하고 예상했었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;18&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;과거에 읽었던 글이나 다른 사례를 보면, Redis에서 수많은 데이터(예: List&amp;lt;Event&amp;gt;)를 한꺼번에 가져와 역직렬화할 때 객체 크기가 커져 Old Generation으로 곧바로 밀려나는 현상이 발생하곤 했다. 그래서 단일 조회를 할 때도 DB보다 오버헤드가 클 줄 알았다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;하지만 이번에 직접 단건 조회 위주로 테스트를 돌려보니, 우려했던 것만큼 DB 조회와 Redis 캐시 간의 극단적인 GC 차이는 나타나지 않았다. 결국 캐시를 도입하더라도 &quot;네트워크를 타고 온 데이터를 애플리케이션 메모리에서 얼마나 가볍게 다루느냐(역직렬화 및 객체 생성 비용)&quot;가 한계 성능을 결정짓는 핵심 열쇠라는 것을 이번 실험을 통해 확실히 깨달았다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로그래밍/프로젝트</category>
      <author>supernovaMK</author>
      <guid isPermaLink="true">https://supernovamk.tistory.com/79</guid>
      <comments>https://supernovamk.tistory.com/79#entry79comment</comments>
      <pubDate>Mon, 24 Aug 2026 11:36:48 +0900</pubDate>
    </item>
    <item>
      <title>[프로젝트/픽잇] 외부 API 순차 호출을 가상 스레드로 병렬화하고 분산 RateLimiter 도입하기</title>
      <link>https://supernovamk.tistory.com/78</link>
      <description>&lt;h2&gt;서론&lt;/h2&gt;
&lt;p&gt;픽잇에는 현재 위치를 기준으로 주변 식당을 가져오는 기능이 있다. 방을 만들 때 한 번 호출되어, 카카오 로컬 API로 주변 식당을 검색하고 그 결과를 저장한다.&lt;/p&gt;
&lt;p&gt;이 기능은 한식, 양식, 중식, 일식, 패스트푸드, 아시안푸드, 도시락, 분식의 여덟 개 카테고리를 각각 검색한다. 카테고리별로 골고루 담겨야 픽잇의 선택지가 한쪽으로 쏠리지 않기 때문이다. 문제는 이 여덟 번의 호출이 순차로 일어나고 있었다는 점이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;List&amp;lt;RestaurantRequest&amp;gt; requests = new ArrayList&amp;lt;&amp;gt;();
requests.addAll(restaurantSearchClient.getRestaurants(
        new RestaurantSearchRequest(RestaurantCategory.KOREAN, x, y, radius, RESTAURANT_SEARCH_SIZE)));
requests.addAll(restaurantSearchClient.getRestaurants(
        new RestaurantSearchRequest(RestaurantCategory.WESTERN, x, y, radius, RESTAURANT_SEARCH_SIZE)));
// ... 여덟 줄이 반복된다&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;여덟 개의 호출은 서로 의존하지 않는다. 한식 결과가 있어야 양식을 검색할 수 있는 것이 아니다. 그런데도 순서대로 기다리고 있으니, 전체 응답 시간은 개별 호출 시간의 합이 된다. 게다가 그 시간 내내 톰캣 워커 스레드 하나가 아무 일도 하지 않으면서 묶여 있다.&lt;/p&gt;
&lt;p&gt;이 글은 이 호출을 Java 21의 가상 스레드로 병렬화하고, 그 과정에서 새로 생긴 문제를 분산 RateLimiter로 막은 기록이다. 그리고 측정 환경을 만들어 실제로 재보면서 드러난, 예상하지 못한 문제들에 대한 기록이기도 하다.&lt;/p&gt;
&lt;h2&gt;순차 호출이 만든 문제&lt;/h2&gt;
&lt;p&gt;문제는 두 가지 층위에 있었다.&lt;/p&gt;
&lt;p&gt;첫째는 응답 시간이다. 카카오 API 응답이 100ms라고 하면 여덟 번을 더해 800ms가 되고, 여기에 저장 시간이 붙는다. 방을 만드는 사용자는 1초 가까이 기다리게 된다.&lt;/p&gt;
&lt;p&gt;둘째는 처리량이다. 우리 서비스의 톰캣 워커는 32개로 설정되어 있다. 요청 하나가 워커 하나를 0.9초 동안 붙잡고 있으면, 이론적인 처리량 상한은 32 ÷ 0.9 ≈ 35 req/s에서 막힌다. 그 워커들은 CPU를 쓰고 있는 것이 아니라 그냥 네트워크 응답을 기다리고 있을 뿐인데도 그렇다.&lt;/p&gt;
&lt;p&gt;가상 스레드가 풀려는 문제가 정확히 이것이라고 생각하였다.&lt;/p&gt;
&lt;h2&gt;가상 스레드로 병렬화하기&lt;/h2&gt;
&lt;h3&gt;카테고리별로 팬아웃하기&lt;/h3&gt;
&lt;p&gt;여덟 줄의 복붙을 &lt;code&gt;RestaurantCategory.values()&lt;/code&gt; 순회로 바꾸고, 각 호출을 가상 스레드에 태웠다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private List&amp;lt;RestaurantRequest&amp;gt; searchAllCategories(LocationRestaurantRequest request) {
    List&amp;lt;Callable&amp;lt;List&amp;lt;RestaurantRequest&amp;gt;&amp;gt;&amp;gt; tasks = Arrays.stream(RestaurantCategory.values())
            .map(category -&amp;gt; toSearchTask(category, request))
            .toList();

    try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
        return collectInOrder(executor.invokeAll(tasks));
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, &amp;quot;식당 검색이 중단되었습니다.&amp;quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;StructuredTaskScope&lt;/code&gt;를 쓰고 싶었지만 Java 21에서는 프리뷰 API라 &lt;code&gt;--enable-preview&lt;/code&gt;가 필요하다. 운영 빌드에 프리뷰 플래그를 다는 것은 부담이 커서, 정식 API인 &lt;code&gt;newVirtualThreadPerTaskExecutor&lt;/code&gt;를 선택하였다.&lt;/p&gt;
&lt;p&gt;가상 스레드 executor를 요청마다 새로 만드는 것이 낭비처럼 보일 수 있지만, 가상 스레드는 풀링하지 않는 것이 권장되는 사용법이다. 생성 비용이 거의 없기 때문에 매번 만들고 버리는 편이 오히려 자연스럽다.&lt;/p&gt;
&lt;p&gt;여기서 신경 쓴 것은 &lt;strong&gt;바꾸기 전과 동작이 같아야 한다&lt;/strong&gt;는 점이다. 두 가지를 지켰다.&lt;/p&gt;
&lt;p&gt;하나는 결과 순서다. &lt;code&gt;invokeAll&lt;/code&gt;은 제출한 순서대로 &lt;code&gt;Future&lt;/code&gt;를 돌려주므로, 결과를 순서대로 모으면 카테고리 선언 순서를 그대로 따른다. 순차 호출일 때와 같다.&lt;/p&gt;
&lt;p&gt;다른 하나는 실패의 의미다. 예전에는 여덟 번 중 하나라도 실패하면 요청 전체가 실패했다. 병렬로 바꾸면서 &amp;quot;일부만 성공해도 결과를 준다&amp;quot;로 바꿀 수도 있었지만, 그러면 사용자는 왜 어떤 카테고리 식당이 안 나오는지 알 수 없게 된다. 그래서 기존 의미를 유지하고, 원래 예외를 그대로 전파하도록 하였다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private RuntimeException toRuntimeException(ExecutionException e) {
    Throwable cause = e.getCause();
    if (cause instanceof RuntimeException runtimeException) {
        return runtimeException;
    }
    return new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, cause.getMessage());
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ExecutionException&lt;/code&gt;을 그대로 던지면 상위의 서킷브레이커나 폴백 로직이 예외 타입을 보고 판단할 수 없게 된다. 감싸인 원래 예외를 꺼내서 던져야 기존 장애 처리가 그대로 동작한다.&lt;/p&gt;
&lt;h3&gt;가상 스레드가 pin 되는 문제&lt;/h3&gt;
&lt;p&gt;병렬화를 하고 나서 확인해야 할 것이 있었다. 우리 카카오 클라이언트는 이렇게 만들어져 있었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(properties.getConnectTimeout());
factory.setReadTimeout(properties.getReadTimeout());&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;SimpleClientHttpRequestFactory&lt;/code&gt;는 내부적으로 &lt;code&gt;HttpURLConnection&lt;/code&gt;을 쓴다. 그리고 &lt;code&gt;HttpURLConnection&lt;/code&gt;은 응답 대기를 &lt;code&gt;synchronized&lt;/code&gt; 블록 안에서 한다.&lt;/p&gt;
&lt;p&gt;JDK 21의 가상 스레드는 &lt;code&gt;synchronized&lt;/code&gt; 안에서 블로킹되면 캐리어 스레드에 &lt;strong&gt;pin&lt;/strong&gt; 된다. 가상 스레드의 장점은 블로킹될 때 캐리어 스레드를 반납하고 park 하는 것인데, pin 되면 그 반납이 일어나지 않는다. 즉 여덟 개를 병렬로 띄워도 실제 동시 실행 수가 CPU 코어 수로 묶인다. 2코어 서버라면 여덟 개 중 두 개씩 진행되는 셈이라, 병렬화의 의미가 크게 줄어든다.&lt;/p&gt;
&lt;p&gt;그래서 &lt;code&gt;java.net.http.HttpClient&lt;/code&gt; 기반의 &lt;code&gt;JdkClientHttpRequestFactory&lt;/code&gt;로 교체하였다. 이쪽은 NIO 기반이라 대기 중에 스레드를 park 하므로 pin 되지 않는다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private ClientHttpRequestFactory requestFactory(KakaoMapApiProperties properties) {
    HttpClient httpClient = HttpClient.newBuilder()
            // HTTP/1.1을 명시한다. java.net.http.HttpClient의 기본값은 HTTP/2인데,
            // HTTP/2는 한 커넥션에 모든 요청을 스트림으로 몰아넣어 서버의 max-concurrent-streams에 걸린다.
            .version(HttpClient.Version.HTTP_1_1)
            .connectTimeout(Duration.ofMillis(properties.getConnectTimeout()))
            .build();

    JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
    factory.setReadTimeout(Duration.ofMillis(properties.getReadTimeout()));
    return factory;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;주석에 붙은 HTTP/1.1 이야기는 처음부터 알고 쓴 것이 아니다. 뒤에서 다시 이야기하겠지만, 부하 테스트를 돌리다가 얻어맞고 나서 추가한 줄이다.&lt;/p&gt;
&lt;h3&gt;MDC가 전파되지 않는 문제&lt;/h3&gt;
&lt;p&gt;우리는 logstash 마커를 이용한 구조화 로깅을 쓰고 있다. 장애가 나면 request_id 같은 필드로 요청을 추적한다.&lt;/p&gt;
&lt;p&gt;그런데 가상 스레드는 부모 스레드의 MDC를 물려받지 않는다. 그대로 두면 외부 API 호출 구간, 즉 정작 장애가 가장 많이 나는 구간의 로그에서 추적 필드가 통째로 비어 버린다. 병렬화 때문에 관측성을 잃는 것은 손해가 크다고 생각하였다.&lt;/p&gt;
&lt;p&gt;그래서 태스크를 만들 때 호출자의 MDC를 복사해서 넘기도록 하였다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private Callable&amp;lt;List&amp;lt;RestaurantRequest&amp;gt;&amp;gt; toSearchTask(RestaurantCategory category,
                                                       LocationRestaurantRequest request) {
    // 가상 스레드는 호출자의 MDC를 물려받지 않는다.
    // 그대로 두면 외부 API 호출 구간의 구조화 로그에서 추적 필드가 통째로 비어 장애 분석이 불가능해진다.
    Map&amp;lt;String, String&amp;gt; callerContext = MDC.getCopyOfContextMap();

    return () -&amp;gt; {
        if (callerContext != null) {
            MDC.setContextMap(callerContext);
        }
        try {
            return restaurantSearchClient.getRestaurants(new RestaurantSearchRequest(
                    category, request.x(), request.y(), request.radius(), RESTAURANT_SEARCH_SIZE));
        } finally {
            MDC.clear();
        }
    };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;finally&lt;/code&gt;에서 &lt;code&gt;MDC.clear()&lt;/code&gt;를 하는 이유는, 가상 스레드가 끝나도 그 컨텍스트가 남아 다른 로그에 섞이는 것을 막기 위해서다.&lt;/p&gt;
&lt;h2&gt;병렬화가 만든 새로운 문제&lt;/h2&gt;
&lt;p&gt;여기까지 하고 나서 뒤늦게 깨달은 것이 있다. &lt;strong&gt;순차 호출은 그 자체로 하나의 RateLimiter였다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;요청 하나가 카카오에 동시에 한 번씩만 요청을 보내고 있었다. 병렬로 바꾸는 순간 이 성질이 사라진다. 순간 호출량이 여덟 배가 되는 것이다. 즉 분산 RateLimiter는 독립적인 개선이 아니라, 병렬화가 만들어낸 문제를 되메우는 비용에 가깝다.&lt;/p&gt;
&lt;p&gt;그렇다면 얼마로 제한해야 하는가. 카카오 공식 문서를 확인해 보니 상황이 조금 애매했다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;키워드로 장소 검색 일일 쿼터&lt;/td&gt;
&lt;td&gt;100,000건 / 일 (앱 키 단위)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;카테고리로 장소 검색 일일 쿼터&lt;/td&gt;
&lt;td&gt;100,000건 / 일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;초당 / 분당 호출 제한&lt;/td&gt;
&lt;td&gt;공식 문서에 명시 없음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;공식 쿼터 문서에 rate limit으로 적혀 있는 것은 카카오 로그인의 토큰 발급 제한뿐이고, 로컬 API의 순간 호출량 제한은 공개되어 있지 않다. 데브톡에서도 담당자가 &amp;quot;쿼터 항목을 참고하라&amp;quot;고만 답하고 구체적인 수치는 주지 않는다.&lt;/p&gt;
&lt;p&gt;따라서 성격이 다른 두 제약을 각각 모델링하기로 하였다. 하나는 문서화된 하드 제약인 일일 쿼터, 다른 하나는 문서화되지 않은 순간 폭주에 대한 우리 스스로의 안전 상한이다.&lt;/p&gt;
&lt;h2&gt;Bucket4j로 분산 RateLimiter 만들기&lt;/h2&gt;
&lt;h3&gt;왜 분산이어야 했나&lt;/h3&gt;
&lt;p&gt;카카오 쿼터는 &lt;strong&gt;앱 키 단위&lt;/strong&gt;로 매겨진다. 서버 인스턴스가 여러 대여도 쿼터는 하나다. 각 인스턴스가 자기 메모리에서 따로 세면 총 호출량을 지킬 수 없다. 따라서 상태를 공유해야 하고, 우리는 이미 SSE 알림 때문에 Redis를 쓰고 있었으므로 Redis를 저장소로 선택하였다.&lt;/p&gt;
&lt;p&gt;라이브러리는 Bucket4j를 골랐다. 토큰 버킷 하나에 여러 대역폭을 걸 수 있어서, 위에서 나눈 두 제약을 그대로 표현할 수 있기 때문이다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-groovy&quot;&gt;implementation &amp;#39;com.bucket4j:bucket4j_jdk17-core:8.15.0&amp;#39;
implementation &amp;#39;com.bucket4j:bucket4j_jdk17-lettuce:8.15.0&amp;#39;&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;두 개의 대역폭&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private BucketConfiguration bucketConfiguration(KakaoSearchRateLimitProperties properties) {
    return BucketConfiguration.builder()
            // 카카오가 공식 문서에 밝힌 유일한 제약(키워드로 장소 검색 100,000건/일).
            // 하루 한 번 전량이 채워지는 쿼터이므로 greedy가 아닌 intervally로 채운다.
            .addLimit(limit -&amp;gt; limit.capacity(properties.dailyQuota())
                    .refillIntervally(properties.dailyQuota(), Duration.ofDays(1))
                    .id(DAILY_QUOTA_LIMIT_ID))
            // 초당 제한은 카카오가 공개하지 않으므로 우리가 정한 안전 상한이다.
            // 병렬 팬아웃 한 번이 카테고리 수만큼 토큰을 한꺼번에 쓰므로,
            // 용량이 카테고리 수보다 작으면 요청 한 건이 스스로를 스로틀하게 된다.
            .addLimit(limit -&amp;gt; limit.capacity(properties.burstCapacity())
                    .refillGreedy(properties.burstCapacity(), Duration.ofSeconds(1))
                    .id(BURST_GUARD_LIMIT_ID))
            .build();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;두 대역폭은 AND로 동작한다. 둘 중 어느 쪽이든 걸리면 토큰을 내주지 않는다.&lt;/p&gt;
&lt;p&gt;일일 쿼터에 &lt;code&gt;refillIntervally&lt;/code&gt;를 쓴 것은 의도적이다. &lt;code&gt;refillGreedy&lt;/code&gt;는 시간에 비례해 조금씩 채우는데, 일일 쿼터는 하루에 한 번 전량이 리셋되는 성격이므로 &lt;code&gt;intervally&lt;/code&gt;가 실제 제약에 더 가깝다.&lt;/p&gt;
&lt;p&gt;버스트 용량의 기본값은 40으로 두었다. 이 숫자에는 근거가 있다. 팬아웃 한 번이 여덟 개의 토큰을 거의 동시에 소모하므로, &lt;strong&gt;용량이 8보다 작으면 요청 한 건이 자기 자신을 스로틀한다.&lt;/strong&gt; 40이면 동시 다섯 건까지는 대기 없이 통과한다.&lt;/p&gt;
&lt;p&gt;참고로 일일 쿼터를 역산해 보면 100,000 ÷ 8 = 하루 12,500번의 방 생성이 상한이고, 평균으로 환산하면 초당 1.16회다. 점심과 저녁에 트래픽이 몰리는 것을 감안해도 40/s는 충분히 여유 있는 값이라고 생각하였다.&lt;/p&gt;
&lt;h3&gt;거절하는 대신 기다리게 하기&lt;/h3&gt;
&lt;p&gt;여기가 가상 스레드와 RateLimiter가 만나는 지점이다.&lt;/p&gt;
&lt;p&gt;토큰이 없을 때 즉시 거절하면, 그 요청은 유료인 구글 API로 폴백된다. 그런데 순간 폭주는 대개 아주 짧다. 몇백 밀리초만 기다리면 토큰이 다시 찬다. 그래서 즉시 거절하지 않고 최대 300ms까지 기다리도록 하였다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Override
public boolean tryAcquire() {
    try {
        return bucket.asBlocking().tryConsume(1, maxWait, BlockingStrategy.PARKING);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        return false;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;플랫폼 스레드였다면 하기 어려운 선택이다. 요청 하나가 여덟 개의 스레드를 300ms씩 재우는 셈이고, 톰캣 워커가 32개인 환경에서 이것은 그대로 처리량 붕괴로 이어진다. 가상 스레드는 park 비용이 사실상 없기 때문에 이 대기가 성립한다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;가상 스레드를 도입했기 때문에 선택할 수 있게 된 설계&lt;/strong&gt;라고 생각한다.&lt;/p&gt;
&lt;h3&gt;저장소가 죽었을 때 어떻게 할 것인가&lt;/h3&gt;
&lt;p&gt;Redis가 죽으면 어떻게 해야 하는가. 호출을 막아야 하는가, 통과시켜야 하는가.&lt;/p&gt;
&lt;p&gt;막으면 Redis 장애가 곧 식당 검색 전면 중단이 된다. 통과시키면 쿼터를 초과할 위험이 있지만, 카카오가 직접 돌려주는 429와 그 뒤의 폴백이 마지막 방어선으로 남아 있다. 그래서 통과시키는 쪽(fail-open)을 선택하였다.&lt;/p&gt;
&lt;p&gt;다만 이 판단을 어디에 둘지를 한 번 고쳤다. 처음에는 RateLimiter 안에서 예외를 삼켰는데, 다시 보니 &amp;quot;토큰을 줄 수 없다&amp;quot;와 &amp;quot;줄 수 있는지 판단할 수 없다&amp;quot;는 서로 다른 상태였다. 후자를 어떻게 다룰지는 호출을 실제로 내보내는 쪽의 정책이다. 그래서 RateLimiter는 예외를 그대로 던지고, 데코레이터가 정책을 판단하도록 옮겼다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private boolean tryAcquire() {
    try {
        return rateLimiter.tryAcquire();
    } catch (RuntimeException e) {
        unavailableCounter.increment();
        log.warn(&amp;quot;호출량 제한을 적용할 수 없어 호출을 그대로 내보냅니다. platform={}&amp;quot;, PLATFORM_NAME, e);
        return true;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;지표도 두 개로 나누었다. 제한에 걸려 거절된 것(&lt;code&gt;rejected_total&lt;/code&gt;)과 제한 자체를 적용하지 못한 것(&lt;code&gt;unavailable_total&lt;/code&gt;)은 대응이 다르기 때문이다. 앞은 한도를 조정할 신호이고, 뒤는 Redis를 봐야 한다는 신호다.&lt;/p&gt;
&lt;p&gt;같은 맥락에서, RateLimiter 관련 빈은 모두 &lt;code&gt;@Lazy&lt;/code&gt;로 두었다. 여기서 한 가지 실수를 했는데, 처음에는 주입 지점에만 &lt;code&gt;@Lazy&lt;/code&gt;를 붙였다. 그런데 스프링은 주입 지점이 지연이어도 싱글턴 빈 자체는 기동 시점에 미리 만든다. 결국 Redis 커넥션이 부팅 중에 열려서, &lt;strong&gt;Redis가 죽으면 애플리케이션이 뜨지도 못하는&lt;/strong&gt; 상태였다. 런타임 정책이 fail-open인데 부팅만 더 엄격할 이유가 없다. 빈 정의에도 &lt;code&gt;@Lazy&lt;/code&gt;를 붙여 해결했고, 테스트로 고정해 두었다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@SpringBootTest(
        classes = {KakaoMapClientConfig.class, KakaoSearchRateLimiterConfig.class, ...},
        properties = {
                // 아무도 듣고 있지 않은 포트. 커넥션을 열려고 하면 즉시 실패한다.
                &amp;quot;spring.data.redis.host=127.0.0.1&amp;quot;,
                &amp;quot;spring.data.redis.port=1&amp;quot;
        }
)
class KakaoMapClientConfigTest {

    @Test
    void Redis에_연결할_수_없어도_검색_클라이언트가_생성된다() {
        assertThat(kakaoRestaurantSearchClient).isInstanceOf(RateLimitedRestaurantSearchClient.class);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;서킷브레이커가 자기 스로틀링에 열리는 문제&lt;/h3&gt;
&lt;p&gt;우리 프로젝트에는 이미 Resilience4j 서킷브레이커가 붙어 있었다. 실패율이 10%를 넘으면 회로가 열리고, 10분 동안 모든 트래픽이 구글로 넘어간다.&lt;/p&gt;
&lt;p&gt;여기서 문제가 생긴다. RateLimiter가 던지는 예외를 실패로 집계하면, &lt;strong&gt;우리 스스로 건 제동이 실패율을 밀어 올려 회로를 열어 버린다.&lt;/strong&gt; 카카오는 멀쩡한데 10분 동안 유료 API를 쓰게 되는 것이다.&lt;/p&gt;
&lt;p&gt;자체 호출량 제한은 카카오의 장애가 아니다. 그래서 집계에서 제외하였다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;@Bean
public CircuitBreakerConfigCustomizer kakaoSearchCircuitBreakerCustomizer() {
    return CircuitBreakerConfigCustomizer.of(&amp;quot;kakaoSearch&amp;quot;, builder -&amp;gt; builder
            .ignoreExceptions(ExternalApiRateLimitException.class)
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ignoreExceptions&lt;/code&gt;는 실패 집계에서만 빼고 예외는 그대로 전파하므로, 폴백 메서드는 정상적으로 동작한다. 즉 제한에 걸린 요청은 구글로 폴백되어 사용자에게는 성공으로 보이고, 다만 회로는 열리지 않는다.&lt;/p&gt;
&lt;h2&gt;측정 환경 만들기&lt;/h2&gt;
&lt;p&gt;여기까지 만들고 나서, 이것이 실제로 효과가 있는지 재기로 하였다. 사실 재보기 전까지는 아무것도 주장할 수 없다고 생각한다.&lt;/p&gt;
&lt;p&gt;측정 환경은 다음과 같이 구성하였다.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;구성 요소&lt;/th&gt;
&lt;th&gt;역할&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;WireMock&lt;/td&gt;
&lt;td&gt;카카오·구글 지도 API 목 서버, 응답 지연 100ms 고정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;RateLimiter의 분산 토큰 버킷 저장소 (실제 컨테이너)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL&lt;/td&gt;
&lt;td&gt;실제 저장까지 포함해 측정 (실제 컨테이너)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;k6&lt;/td&gt;
&lt;td&gt;부하 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;한 가지 고민한 것은 카카오 목을 어떻게 만들 것인가였다. 우리 프로젝트에는 이미 인메모리 목 클라이언트가 있었다. 하지만 그것을 쓰면 &lt;strong&gt;HTTP 클라이언트가 측정에서 통째로 빠진다.&lt;/strong&gt; 위에서 고생한 요청 팩토리 교체나 프로토콜 선택 같은 문제가 아예 드러나지 않는다. 그래서 목 서버를 띄워 HTTP 왕복이 실제로 일어나게 하였다. 결과적으로 이 결정이 가장 잘한 선택이었다.&lt;/p&gt;
&lt;p&gt;k6 시나리오는 두 개를 두었다. 하나는 대기 없이 요청 하나가 얼마나 걸리는지 보는 지연 시나리오, 다른 하나는 부하를 올리며 처리량 한계를 보는 시나리오다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-javascript&quot;&gt;const SCENARIOS = {
  // 대기 없이 한 요청이 얼마나 걸리는지 본다. 병렬화의 직접 효과가 여기서 드러난다.
  latency: {
    executor: &amp;#39;constant-vus&amp;#39;,
    vus: 5,
    duration: &amp;#39;30s&amp;#39;,
  },
  // 부하를 올리며 처리량 한계를 본다. 워커 스레드가 외부 I/O에 묶여 있는지가 여기서 드러난다.
  load: {
    executor: &amp;#39;ramping-arrival-rate&amp;#39;,
    startRate: 5,
    timeUnit: &amp;#39;1s&amp;#39;,
    stages: [
      { target: 10, duration: &amp;#39;20s&amp;#39; },
      { target: 20, duration: &amp;#39;20s&amp;#39; },
      { target: 20, duration: &amp;#39;30s&amp;#39; },
    ],
  },
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;개선 전 버전과 비교하기 위해, 작업 직전 커밋을 git worktree로 꺼내 별도의 jar를 만들었다. 이때 설정 파일이 서브모듈에 있어서 워크트리에 따라오지 않는 문제가 있었는데, 복사해서 해결하였다.&lt;/p&gt;
&lt;h2&gt;측정 결과&lt;/h2&gt;
&lt;h3&gt;지연 시간 (5 VUs, 30초)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;순차 호출&lt;/th&gt;
&lt;th&gt;가상 스레드 병렬&lt;/th&gt;
&lt;th&gt;변화&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;avg&lt;/td&gt;
&lt;td&gt;921.70ms&lt;/td&gt;
&lt;td&gt;134.73ms&lt;/td&gt;
&lt;td&gt;−85.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;median&lt;/td&gt;
&lt;td&gt;907.42ms&lt;/td&gt;
&lt;td&gt;128.33ms&lt;/td&gt;
&lt;td&gt;−85.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;989.73ms&lt;/td&gt;
&lt;td&gt;167.73ms&lt;/td&gt;
&lt;td&gt;−83.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;1.12s&lt;/td&gt;
&lt;td&gt;250.76ms&lt;/td&gt;
&lt;td&gt;−77.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;처리량&lt;/td&gt;
&lt;td&gt;5.42 req/s&lt;/td&gt;
&lt;td&gt;37.05 req/s&lt;/td&gt;
&lt;td&gt;6.8배&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패율&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;순차 호출의 921ms는 여덟 번 × 100ms에 저장 시간을 더한 값과 정확히 맞아떨어진다. 병렬 호출의 134ms는 가장 느린 한 번의 호출에 수렴한다. 예상한 그대로의 모양이 나왔다.&lt;/p&gt;
&lt;h3&gt;처리량 한계 (20 req/s까지 ramp)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;순차 호출&lt;/th&gt;
&lt;th&gt;가상 스레드 병렬&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;처리량&lt;/td&gt;
&lt;td&gt;8.09 req/s&lt;/td&gt;
&lt;td&gt;14.97 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;avg&lt;/td&gt;
&lt;td&gt;15.11s&lt;/td&gt;
&lt;td&gt;159.82ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;31.34s&lt;/td&gt;
&lt;td&gt;403.40ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;소화하지 못한 요청&lt;/td&gt;
&lt;td&gt;205건&lt;/td&gt;
&lt;td&gt;0건&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;순차 호출은 도착하는 부하를 소화하지 못하고 큐에 쌓다가 15초짜리 응답을 만들어낸다. 병렬 호출은 같은 워커 수로 도착 부하를 전부 소화한다.&lt;/p&gt;
&lt;p&gt;이 실험은 톰캣 워커를 8개로 낮춰서 했다. 이유는 뒤의 한계 부분에 적었다.&lt;/p&gt;
&lt;h3&gt;RateLimiter 동작 (운영 설정 40/s)&lt;/h3&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;항목&lt;/th&gt;
&lt;th&gt;값&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td&gt;토큰 확보 시도&lt;/td&gt;
&lt;td&gt;3,088회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;제한으로 거절&lt;/td&gt;
&lt;td&gt;1,702회 (55.1%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;구글로 폴백&lt;/td&gt;
&lt;td&gt;1,702회&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HTTP 실패율&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;avg 응답 시간&lt;/td&gt;
&lt;td&gt;411.92ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;토큰 확보 최대 대기&lt;/td&gt;
&lt;td&gt;357ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;의도한 대로 동작하는 것을 확인하였다. 부하가 한도를 크게 넘어도 사용자 입장에서는 모든 요청이 성공한다. 초과분은 폴백으로 흘러가고, 응답 시간이 최대 대기(300ms)만큼 늘어날 뿐이다.&lt;/p&gt;
&lt;p&gt;제한에 걸리지 않을 때 RateLimiter가 더하는 비용은 호출당 평균 6.5ms였다.&lt;/p&gt;
&lt;h2&gt;측정하면서 드러난 문제들&lt;/h2&gt;
&lt;p&gt;솔직히 말하면 여기서부터가 이번 작업에서 가장 많이 배운 부분이다. 측정 환경을 만들지 않았다면 전부 운영에서 터졌을 것들이다.&lt;/p&gt;
&lt;h3&gt;구글 폴백은 처음부터 동작하지 않았다&lt;/h3&gt;
&lt;p&gt;RateLimiter가 요청을 거절하기 시작하자 폴백이 동작했고, 그 요청들이 전부 500으로 끝났다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Cannot invoke &amp;quot;FoodCategory.name()&amp;quot; because the return value of
&amp;quot;Restaurant.getFoodCategory()&amp;quot; is null&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;구글 클라이언트가 음식 분류를 &lt;code&gt;null&lt;/code&gt;로 채우고 있었다. 저장 단계에서 &lt;code&gt;getFoodCategory().name()&lt;/code&gt;을 호출하므로 그대로 NPE가 난다. 고치고 나니 이번에는 주소가 &lt;code&gt;null&lt;/code&gt;이라 &lt;code&gt;road_address_name&lt;/code&gt; NOT NULL 제약에 걸렸다.&lt;/p&gt;
&lt;p&gt;즉 &lt;strong&gt;폴백이 동작한 모든 요청은 원래부터 100% 실패하고 있었다.&lt;/strong&gt; 카카오가 429를 주거나 서킷이 열려서 폴백으로 넘어가는 순간, 폴백은 서비스를 구제하기는커녕 전부 실패시켰을 것이다. 장애 대비로 만들어 둔 장치가 정작 장애 때 상황을 악화시키는 구조였다.&lt;/p&gt;
&lt;p&gt;이게 여태 드러나지 않은 이유는 단순하다. &lt;strong&gt;폴백 경로를 실제로 태워 본 적이 없었기 때문이다.&lt;/strong&gt; 유닛 테스트는 있었지만, 폴백 결과가 저장까지 성공하는지를 확인하는 테스트는 없었다.&lt;/p&gt;
&lt;h3&gt;RateLimiter가 스스로 병목이 되었다&lt;/h3&gt;
&lt;p&gt;부하 시나리오에서 병렬 버전이 순차 버전보다 &lt;strong&gt;느린&lt;/strong&gt; 결과가 나왔다. 처음에는 DB가 병목이라고 생각했는데, 지표를 보니 아니었다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;restaurant_search_location_duration_seconds{quantile=&amp;quot;0.5&amp;quot;}   62.0
restaurant_search_rate_limit_acquire_duration_seconds_sum     70668.1
restaurant_search_rate_limit_acquire_duration_seconds_count   6179
restaurant_search_rate_limit_rejected_total                   0.0&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;토큰 확보에 걸린 시간이 6,179회에 70,668초, 평균 11.4초였다. 최대는 108초였다. 그런데 거절은 한 건도 없었다. &lt;strong&gt;토큰이 남아도는데 그것을 받아오는 데만 평균 11.4초가 걸린 것이다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;원인은 단일 Redis 키에 대한 CAS 경합이었다. Bucket4j의 분산 버킷은 compare-and-swap으로 동작하는데, 같은 키에 동시 요청이 몰리면 CAS가 실패하고 재시도한다. 팬아웃 때문에 요청 하나가 여덟 개의 토큰을 동시에 요구하니 경합이 급격히 심해졌다. 게다가 나는 호출마다 버킷 프록시를 새로 만들고 있었다.&lt;/p&gt;
&lt;p&gt;Bucket4j에는 이 문제를 위한 배칭 최적화가 있다. 같은 JVM 안의 동시 요청을 하나의 Redis 왕복으로 합쳐 준다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;// 버킷은 한 번만 만들어 재사용한다. 호출마다 새로 만들면 아래 배칭 최적화가 아무 효과도 내지 못한다.
Bucket bucket = proxyManager.builder()
        .withOptimization(Optimizations.batching())
        .build(bucketKey(properties), () -&amp;gt; bucketConfiguration(properties));&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;적용 후 평균 11.4초가 &lt;strong&gt;6.5ms&lt;/strong&gt;로 떨어졌다. 최대도 108초에서 0.78초가 되었다. 인스턴스 간 총량 제한은 여전히 Redis가 보장하고, 합쳐지는 것은 같은 JVM 안의 동시 요청뿐이다.&lt;/p&gt;
&lt;h3&gt;요청 팩토리를 바꾸자 프로토콜이 HTTP/2로 바뀌었다&lt;/h3&gt;
&lt;p&gt;배칭을 적용하자 이번에는 다른 에러가 무더기로 나왔다.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;I/O error on GET request: too many concurrent streams&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;java.net.http.HttpClient&lt;/code&gt;의 기본 프로토콜은 HTTP/2다. HTTP/2는 커넥션 하나에 모든 요청을 스트림으로 다중화하는데, 서버가 허용하는 동시 스트림 수를 넘으면 이렇게 실패한다.&lt;/p&gt;
&lt;p&gt;pin을 피하려고 요청 팩토리를 바꿨는데, 그 부수효과로 프로토콜까지 바뀌어 다른 한계에 걸린 것이다. 팬아웃처럼 동시 호출이 많은 작업에는 커넥션 풀을 쓰는 HTTP/1.1이 오히려 맞는다고 판단해서, 명시적으로 고정하였다.&lt;/p&gt;
&lt;h3&gt;한도를 바꿔도 반영되지 않았다&lt;/h3&gt;
&lt;p&gt;측정 중에 버스트 한도를 40에서 100만으로 올렸는데 아무 변화가 없었다. Redis를 열어 보니 이유를 알 수 있었다.&lt;/p&gt;
&lt;p&gt;Bucket4j는 키에 해당하는 버킷이 &lt;strong&gt;이미 존재하면 저장된 설정을 그대로 쓰고 새 설정을 무시한다.&lt;/strong&gt; 우리 버킷의 TTL은 2일이었으므로, 한도를 바꿔 배포해도 이틀 동안은 예전 한도로 동작한다는 뜻이다. 운영에서 한도를 급히 조정해야 하는 상황을 생각하면 꽤 위험한 성질이다.&lt;/p&gt;
&lt;p&gt;버킷 키에 한도를 포함시켜 해결하였다. 한도를 바꾸면 다른 키를 쓰게 되므로 즉시 반영된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-java&quot;&gt;private String bucketKey(KakaoSearchRateLimitProperties properties) {
    return &amp;quot;%s:q%d:b%d&amp;quot;.formatted(BUCKET_KEY_PREFIX, properties.dailyQuota(), properties.burstCapacity());
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;이 측정의 한계&lt;/h2&gt;
&lt;p&gt;숫자를 그대로 운영에 옮기기 전에 알아야 할 것들을 함께 적어 둔다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;목 서버 지연 100ms는 가정이다.&lt;/strong&gt; 실제 카카오 응답 시간이 다르면 절대값은 달라진다. 다만 &amp;quot;순차는 호출 수에 비례하고, 병렬은 가장 느린 호출에 수렴한다&amp;quot;는 관계 자체는 그대로 유지된다.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;처리량 실험은 톰캣 워커를 8개로 낮춰서 했다.&lt;/strong&gt; 운영 설정은 32개다. 목 서버가 약 200 calls/s에서 한계에 도달하는데, 요청 하나가 여덟 번 호출하므로 애플리케이션 기준 약 25 req/s가 상한이다. 워커 32개로는 순차 버전의 포화 지점이 이 상한 밖에 있어서, 목 서버가 먼저 무너져 애플리케이션을 측정할 수 없었다. 그래서 워커를 낮춰 포화 지점을 측정 가능한 범위 안으로 끌어왔다. &lt;strong&gt;절대 처리량이 아니라 두 버전의 상대 비교로 읽어야 하는 숫자다.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;단일 인스턴스 측정이다.&lt;/strong&gt; 분산 RateLimiter의 핵심인 인스턴스 간 총량 제한은 검증하지 못했다. 배칭 최적화는 JVM 안의 동시 요청만 합치고 인스턴스 간 제한은 여전히 Redis가 보장하지만, 여러 인스턴스를 띄운 상태의 실측은 숙제로 남아 있다.&lt;/p&gt;
&lt;h2&gt;회고&lt;/h2&gt;
&lt;h3&gt;순차 호출은 공짜 RateLimiter였다&lt;/h3&gt;
&lt;p&gt;이번 작업에서 가장 크게 배운 것은, 없애려던 비효율이 사실 어떤 성질을 지켜 주고 있었다는 점이다. 순차 호출은 느렸지만 동시에 외부 API에 대한 자연스러운 제동 장치이기도 했다.&lt;/p&gt;
&lt;p&gt;그래서 &amp;quot;병렬화로 지연을 줄였다&amp;quot;와 &amp;quot;RateLimiter를 도입했다&amp;quot;는 나란히 놓인 두 개의 개선이 아니다. 앞의 것이 뒤의 것을 필요하게 만든 것이다. 개선을 이야기할 때 얻은 것만 세는 것이 아니라 무엇을 대가로 치렀는지도 같이 세어야 정직하다고 생각하게 되었다.&lt;/p&gt;
&lt;h3&gt;만들어 둔 장애 대비는 태워 보기 전까지 동작한다고 말할 수 없다&lt;/h3&gt;
&lt;p&gt;구글 폴백은 코드가 있었고, 테스트도 있었고, 서킷브레이커와 연결까지 되어 있었다. 그런데 실제로는 동작한 적이 한 번도 없었고, 동작하는 순간 전부 실패하는 상태였다.&lt;/p&gt;
&lt;p&gt;장애 대비 코드는 평소에 실행되지 않기 때문에 썩어도 아무도 모른다. 그리고 하필 가장 필요한 순간에 처음 실행된다. 이번에 RateLimiter를 넣으면서 우연히 폴백 경로를 실제로 태우게 되었고, 그 덕에 발견할 수 있었다. 앞으로는 폴백 같은 경로도 부하 테스트 시나리오에 명시적으로 포함시켜야겠다고 생각하였다.&lt;/p&gt;
&lt;h3&gt;성능 개선은 병목을 옮기는 일에 가깝다&lt;/h3&gt;
&lt;p&gt;이번 작업에서 병목은 계속 이동했다. 외부 API 순차 호출에서 시작해서, RateLimiter의 Redis 경합으로 갔다가, HTTP/2 스트림 한계로 갔고, 마지막에는 목 서버 자체가 병목이 되었다.&lt;/p&gt;
&lt;p&gt;측정 없이 코드만 보고 판단했다면 첫 번째 단계에서 멈췄을 것이고, 나머지 세 개는 운영에서 만났을 것이다. 특히 RateLimiter가 스스로 병목이 되어 병렬화를 무의미하게 만든 구간은, 코드를 아무리 들여다봐도 알 수 없는 종류의 문제였다.&lt;/p&gt;
&lt;p&gt;숫자를 만들기 위해 측정 환경을 만들었는데, 정작 얻은 것은 숫자가 아니라 몰랐던 문제 네 개였다.&lt;/p&gt;</description>
      <category>프로그래밍/프로젝트</category>
      <author>supernovaMK</author>
      <guid isPermaLink="true">https://supernovamk.tistory.com/78</guid>
      <comments>https://supernovamk.tistory.com/78#entry78comment</comments>
      <pubDate>Sat, 15 Aug 2026 17:50:03 +0900</pubDate>
    </item>
    <item>
      <title>[프로젝트/픽잇] 부하테스트 튜닝 과정</title>
      <link>https://supernovamk.tistory.com/77</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;서론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 실제로 부하를 걸어보며 인스턴스 한 대 기준으로 안정적으로 처리 가능한 목표 TPS를 측정하고, Tomcat Thread 수와 HikariCP Connection Pool 크기를 조정해 성능을 튜닝한 과정을 정리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목표는 단순했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;인스턴스 1대당 안정적으로 처리 가능한 최대 TPS를 측정한다.&lt;/li&gt;
&lt;li&gt;그 근거가 되는 서버 설정값(스레드 수, 커넥션 풀 크기 등)을 도출한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 대상 기능은 픽잇의 식당 추천 및 조회 전체 플로우로 잡았고, 측정 지표는 Peak RPS와 p50&amp;middot;p95&amp;middot;p99 응답 시간으로 정했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;어떤 부하를 걸 것인가&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단순 조회가 아니라 실제 시나리오로&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순히 조회 API 하나에 부하를 거는 방법도 고민했다. 하지만 그렇게 하면 실제 서비스에서 발생하는 DB 동시성 경합, 트랜잭션 락, 복잡한 쿼리 패턴을 전혀 재현할 수 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 단일 API가 아니라 &lt;b&gt;전체 비즈니스 로직을 포함한 End-to-End 시나리오&lt;/b&gt;로 테스트를 설계하기로 했다. 프로덕션에서 실제로 벌어질 법한 상황을 최대한 흉내내는 것이 목표였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 가상 사용자(VU)는 다음 순서로 하나의 픽잇 세션을 처음부터 끝까지 진행하도록 구성했다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;픽잇 방 생성&lt;/b&gt; &amp;mdash; 식당 추천 세션 시작&lt;/li&gt;
&lt;li&gt;&lt;b&gt;주변 식당 목록 생성&lt;/b&gt; &amp;mdash; 위치 기반(반경 500m) 식당 데이터 약 20~30개 생성&lt;/li&gt;
&lt;li&gt;&lt;b&gt;참가자 10명 동시 생성&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;참가자마다 개별 토큰 발급&lt;/li&gt;
&lt;li&gt;동일 픽잇에 대한 동시 INSERT로 락 경합 유발&lt;/li&gt;
&lt;li&gt;참가자 생성 간 50~200ms 랜덤 지연을 줘서 과도한 락 충돌은 완화&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;참가자별 동시 행동&lt;/b&gt; (10명이 병렬 수행)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;식당 목록 조회 (3회 폴링, 1~2초 간격)&lt;/li&gt;
&lt;li&gt;1~4개 식당 소거 (UPDATE)&lt;/li&gt;
&lt;li&gt;남은 식당 조회 (3회 폴링, 1~2초 간격)&lt;/li&gt;
&lt;li&gt;랜덤 5개 식당에 좋아요 등록 (INSERT)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결과 집계 및 조회&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최종 결과 생성 (집계 쿼리)&lt;/li&gt;
&lt;li&gt;픽잇 방 비활성화 (UPDATE)&lt;/li&gt;
&lt;li&gt;결과 데이터 조회 (SELECT)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 &lt;b&gt;1회 시나리오당 약 40~50개의 DB 쿼리&lt;/b&gt;가 발생한다. 참가자 10명이 각자 30~40회씩 쿼리를 날리므로, 세션 하나가 곧 300~400회 수준의 DB 작업을 만들어내는 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시나리오가 재현하는 실전 부하 특성은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;높은 DB 트랜잭션 부하&lt;/b&gt; &amp;mdash; 세션당 300~400회의 DB 작업&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동시성 경합&lt;/b&gt; &amp;mdash; 참가자 생성&amp;middot;좋아요 등록 시 락 획득 경쟁&lt;/li&gt;
&lt;li&gt;&lt;b&gt;I/O 집약적 워크로드&lt;/b&gt; &amp;mdash; 폴링 방식의 반복 조회로 스레드 대기 시간 증가&lt;/li&gt;
&lt;li&gt;&lt;b&gt;혼합 쿼리 패턴&lt;/b&gt; &amp;mdash; SELECT / INSERT / UPDATE가 뒤섞인 실제 트랜잭션 패턴&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 복잡한 시나리오로 테스트해야 단순 처리량이 아니라 실제 서비스 가능한 수준의 TPS를 측정할 수 있다고 판단했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;부하 프로필 (Ramping Arrival Rate)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하는 한 번에 몰아치는 게 아니라 점진적으로 올렸다가 내리는 방식으로 설계했다. k6의 ramping-arrival-rate를 사용했다.&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;Stage 1: 0 &amp;rarr; 50 RPS   (1분간 점진 증가)
Stage 2: 50 &amp;rarr; 100 RPS  (1분간 점진 증가)
Stage 3: 100 &amp;rarr; 200 RPS (1분간 점진 증가)
Stage 4: 200 &amp;rarr; 250 RPS (2분간 유지)
Stage 5: 250 &amp;rarr; 0 RPS   (2분간 점진 감소)
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최대 동시 사용자 수(VU): 500명&lt;/li&gt;
&lt;li&gt;총 테스트 시간: 7분&lt;/li&gt;
&lt;li&gt;목표 실패율: 5% 이하&lt;/li&gt;
&lt;li&gt;목표 p95 응답 시간: 1.5초 이하&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;매 테스트마다 데이터 초기화&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일관성 있는 결과를 얻으려면 매 테스트가 같은 출발선에서 시작해야 했다. 그래서 테스트 전마다 시나리오로 쌓인 데이터를 초기화했다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;DELETE FROM restaurant_like  WHERE restaurant_id &amp;gt; 1000000;
DELETE FROM pickeat_result   WHERE restaurant_id &amp;gt; 1000000;
DELETE FROM restaurant       WHERE id &amp;gt; 1000000;
DELETE FROM participant      WHERE id &amp;gt; 500000;
DELETE FROM pickeat          WHERE id &amp;gt; 100000;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 시드 데이터를 건드리지 않도록 ID 범위를 기준으로 테스트 중 생성된 데이터만 골라 삭제했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;시작 전 튜닝할 수 있는 것들을 정리해보았다.&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;7271&quot; data-origin-height=&quot;3377&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/W2hvd/dJMcahrvVkB/zcr4r5MOEaEBwXHkSvyyF1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/W2hvd/dJMcahrvVkB/zcr4r5MOEaEBwXHkSvyyF1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/W2hvd/dJMcahrvVkB/zcr4r5MOEaEBwXHkSvyyF1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FW2hvd%2FdJMcahrvVkB%2Fzcr4r5MOEaEBwXHkSvyyF1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;7271&quot; height=&quot;3377&quot; data-origin-width=&quot;7271&quot; data-origin-height=&quot;3377&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;부하 테스트 아키텍쳐는 어떻게 구성할 것인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하를 어떻게 걸지 설계하기 전에, 먼저 부하테스트를 위한 환경 자체를 어떻게 구성할지 정해야 했다. 크게 세 가지를 고민했다. 부하를 발생시키는 쪽(k6), 스크립트 관리 방식, 그리고 부하를 받아낼 대상 서버였다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;k6는 전용 EC2에서 실행한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 k6는 부하를 받는 대상 서버와 완전히 분리된 전용 EC2에서 실행하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 k6를 대상 서버와 같은 인스턴스에서 돌린다면, 부하를 발생시키는 k6 자체가 CPU와 메모리를 잡아먹으면서 정작 측정하려는 서버의 성능에 영향을 준다. 그러면 측정값이 오염되어 &quot;서버가 몇 건까지 버티는가&quot;라는 질문에 정확히 답할 수 없게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;부하를 만드는 주체와 부하를 받는 주체를 물리적으로 분리해야&lt;/b&gt; 측정 결과를 신뢰할 수 있다. 그래서 k6는 별도의 전용 EC2에 올려두고, 여기서 대상 서버를 향해 요청을 쏘는 구조로 잡았다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트 스크립트는 Git으로 관리한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;k6 스크립트는 JavaScript 기반이라 Git으로 관리하기에 자연스러웠다. (앞선 글에서 JMeter 대신 k6를 선택한 이유 중 하나이기도 했다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시나리오를 코드로 관리하니 다음과 같은 이점이 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;어떤 부하 프로필로 어떤 시나리오를 돌렸는지 &lt;b&gt;버전으로 추적&lt;/b&gt;할 수 있다.&lt;/li&gt;
&lt;li&gt;설정값을 바꿔가며 실험할 때 &lt;b&gt;변경 이력이 그대로 남는다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;팀원과 &lt;b&gt;리뷰&amp;middot;공유가 가능&lt;/b&gt;하고, 필요하면 CI/CD 파이프라인에도 녹일 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하테스트는 한 번 하고 끝나는 게 아니라 설정을 바꿔가며 여러 번 반복하는 작업이다. 그래서 &quot;어떤 조건으로 테스트했는가&quot;를 코드로 남겨두는 것이 재현성과 협업 측면에서 중요했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;대상 서버는 Docker 이미지로 복제해 필요할 때만 띄운다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 고민이 됐던 건 부하를 받아낼 대상 서버였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하테스트는 프로덕션 서버에 직접 걸 수 없다. 실제 사용자에게 영향을 줄 수 있기 때문이다. 그렇다고 프로덕션과 동일한 스펙의 서버를 상시로 하나 더 띄워두자니 서버 비용이 아까웠다. 부하테스트는 하루 종일 돌리는 게 아니라 필요할 때 잠깐씩 진행하는 작업이기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 대상 서버를 &lt;b&gt;Docker 기반 이미지로 미리 복제해두고, 부하테스트를 진행할 때만 실행하는&lt;/b&gt; 방식을 택했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프로덕션과 동일한 환경을 Docker 이미지로 만들어두면, 언제든 &lt;b&gt;동일한 조건으로 빠르게 서버를 복제&lt;/b&gt;할 수 있다.&lt;/li&gt;
&lt;li&gt;테스트가 필요한 순간에만 컨테이너를 띄우고, 끝나면 내려서 &lt;b&gt;불필요한 상시 비용을 없앤다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;설정(스레드 수, 커넥션 풀 크기 등)을 바꿔 재테스트할 때도, 이미지를 기반으로 빠르게 환경을 재구성할 수 있어 &lt;b&gt;실험 사이클이 짧아진다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 측정 정확도를 위해 &lt;b&gt;k6는 전용 EC2로 분리&lt;/b&gt;하고, 재현성과 협업을 위해 &lt;b&gt;스크립트는 Git으로 관리&lt;/b&gt;하며, 비용 효율을 위해 &lt;b&gt;대상 서버는 Docker 이미지로 복제해 필요할 때만 실행&lt;/b&gt;하는 구조로 부하테스트 환경을 구성했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1단계: Tomcat Thread 수 튜닝&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;튜닝 가설&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 CPU-Bound 작업은 코어 수 + 1 수준의 스레드가 권장된다. 하지만 우리 시나리오는 성격이 조금 달랐다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;I/O 대기 발생&lt;/b&gt; &amp;mdash; 참가자별 폴링 조회 시 스레드가 DB 응답을 기다린다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;트랜잭션 락 대기&lt;/b&gt; &amp;mdash; 동시 참가자 생성 시 락을 얻을 때까지 스레드가 블로킹된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;네트워크 지연&lt;/b&gt; &amp;mdash; 향후 외부 API 호출 가능성도 고려해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 I/O 대기 특성 때문에 초기에는 스레드를 넉넉하게 200개로 잡고 시작했다. 그런데 Grafana로 관측해보니 &lt;b&gt;유휴 스레드가 지나치게 많고 Context Switching 비용이 과도&lt;/b&gt;했다. 그래서 &lt;b&gt;200 &amp;rarr; 64 &amp;rarr; 32 &amp;rarr; 3&lt;/b&gt;으로 단계적으로 줄여가며 최적점을 찾아보기로 했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 1: Thread 200개 (기본 설정)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Tomcat Threads: 200&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;총 요청 수: 51,775&lt;/li&gt;
&lt;li&gt;Peak RPS: 311 req/s&lt;/li&gt;
&lt;li&gt;p50: 27.8ms / p95: 587ms / p99: 1.16s&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;a href=&quot;https://snapshots.raintank.io/dashboard/snapshot/0lVNOqs3rA4f5ykeVEhl7ElLfCcElowx&quot;&gt;https://snapshots.raintank.io/dashboard/snapshot/0lVNOqs3rA4f5ykeVEhl7ElLfCcElowx&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3808&quot; data-origin-height=&quot;1530&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cY9byf/dJMcac4JLZ0/CvPjyQ28b4k6KDkJwaEFCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cY9byf/dJMcac4JLZ0/CvPjyQ28b4k6KDkJwaEFCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cY9byf/dJMcac4JLZ0/CvPjyQ28b4k6KDkJwaEFCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcY9byf%2FdJMcac4JLZ0%2FCvPjyQ28b4k6KDkJwaEFCk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3808&quot; height=&quot;1530&quot; data-origin-width=&quot;3808&quot; data-origin-height=&quot;1530&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 자체는 안정적으로 처리됐지만 p95, p99 지연이 눈에 띄게 높았다. Grafana CPU 메트릭을 확인해보니 과도한 스레드 수로 인한 Context Switching 오버헤드가 원인으로 보였다. 실제 활성 스레드는 30~50개 수준이었고, 나머지 150개 이상은 유휴 상태로 남아 불필요한 스케줄링 비용만 만들어내고 있었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 2: Thread 64개&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Tomcat Threads: 64&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;총 요청 수: 53,862&lt;/li&gt;
&lt;li&gt;Peak RPS: 320 req/s&lt;/li&gt;
&lt;li&gt;p50: 27.1ms / p95: 553ms / p99: 1.06s&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;a href=&quot;https://snapshots.raintank.io/dashboard/snapshot/ogMMmmFnPopl2pqDqtDmaJDvtMmQMbdR&quot;&gt;https://snapshots.raintank.io/dashboard/snapshot/ogMMmmFnPopl2pqDqtDmaJDvtMmQMbdR&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3786&quot; data-origin-height=&quot;1534&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/srgO2/dJMcafAqCwr/yI3Iz9kEEcKWmTvjlzMIb0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/srgO2/dJMcafAqCwr/yI3Iz9kEEcKWmTvjlzMIb0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/srgO2/dJMcafAqCwr/yI3Iz9kEEcKWmTvjlzMIb0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsrgO2%2FdJMcafAqCwr%2FyI3Iz9kEEcKWmTvjlzMIb0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3786&quot; height=&quot;1534&quot; data-origin-width=&quot;3786&quot; data-origin-height=&quot;1534&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;200 &amp;rarr; 64로 줄였는데도 성능 차이는 미미했다. TPS와 응답 지연 모두 비슷한 수준을 유지했다. 이는 곧 &lt;b&gt;스레드 200개가 애초에 과도했다&lt;/b&gt;는 걸 반대로 확인해준 결과였다. 여전히 p95, p99가 높아 더 줄여보기로 했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 3: Thread 32개&amp;nbsp; (1차 최적값)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Tomcat Threads: 32&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;총 요청 수: 54,962&lt;/li&gt;
&lt;li&gt;Peak RPS: 347 req/s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;p50: 22ms / p95: 261ms / p99: 434ms&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;a href=&quot;https://snapshots.raintank.io/dashboard/snapshot/EqE2S9BXljKwWr3Q1Lg0xJqvng38KnNb?orgId=0&amp;amp;from=2025-10-18T05:13:15.000Z&amp;amp;to=2025-10-18T05:20:45.000Z&amp;amp;timezone=browser&amp;amp;var-env=test&amp;amp;var-application=pickeats-api&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://snapshots.raintank.io/dashboard/snapshot/EqE2S9BXljKwWr3Q1Lg0xJqvng38KnNb?orgId=0&amp;amp;from=2025-10-18T05:13:15.000Z&amp;amp;to=2025-10-18T05:20:45.000Z&amp;amp;timezone=browser&amp;amp;var-env=test&amp;amp;var-application=pickeats-api&lt;/a&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3806&quot; data-origin-height=&quot;1554&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zAuI0/dJMcafAqCyC/52UofftS4KqZKdxCITasG1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zAuI0/dJMcafAqCyC/52UofftS4KqZKdxCITasG1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zAuI0/dJMcafAqCyC/52UofftS4KqZKdxCITasG1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzAuI0%2FdJMcafAqCyC%2F52UofftS4KqZKdxCITasG1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3806&quot; height=&quot;1554&quot; data-origin-width=&quot;3806&quot; data-origin-height=&quot;1554&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 응답 지연이 이전 실험 대비 극적으로 개선됐다. Grafana에서 다음을 확인할 수 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;CPU 사용률이 안정화되고 Context Switching 횟수가 감소&lt;/li&gt;
&lt;li&gt;스레드 대부분이 활성 상태로 전환되어 유휴 스레드 최소화&lt;/li&gt;
&lt;li&gt;DB 커넥션 풀 대기 시간이 줄어 전체 처리 효율 개선&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지표 Thread 200 Thread 64 Thread 32&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Peak RPS&lt;/td&gt;
&lt;td&gt;311&lt;/td&gt;
&lt;td&gt;320&lt;/td&gt;
&lt;td&gt;&lt;b&gt;347&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;27.8ms&lt;/td&gt;
&lt;td&gt;27.1ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;22ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;587ms&lt;/td&gt;
&lt;td&gt;553ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;261ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;1.16s&lt;/td&gt;
&lt;td&gt;1.06s&lt;/td&gt;
&lt;td&gt;&lt;b&gt;434ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 4: Thread 3개&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 궁금해졌다. 그럼 아예 극단적으로 줄이면 어떻게 될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Tomcat Threads: 3&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Peak RPS: 144 req/s&lt;/li&gt;
&lt;li&gt;p50: 20ms / p95: 93ms / p99: 186ms&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://snapshots.raintank.io/dashboard/snapshot/WhEqrbUE6Vz21EWPh9yhUYBS9WndQzlo&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://snapshots.raintank.io/dashboard/snapshot/WhEqrbUE6Vz21EWPh9yhUYBS9WndQzlo&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3796&quot; data-origin-height=&quot;1528&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Wks5a/dJMcacXZtgN/QES50iDuC3UWb08Kjizc8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Wks5a/dJMcacXZtgN/QES50iDuC3UWb08Kjizc8k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Wks5a/dJMcacXZtgN/QES50iDuC3UWb08Kjizc8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWks5a%2FdJMcacXZtgN%2FQES50iDuC3UWb08Kjizc8k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3796&quot; height=&quot;1528&quot; data-origin-width=&quot;3796&quot; data-origin-height=&quot;1528&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;p99는 186ms로 모든 실험 중 가장 낮았다. 하지만 &lt;b&gt;RPS가 144로 절반 이하로 떨어졌다.&lt;/b&gt; 스레드 3개로는 참가자 10명의 동시 요청을 감당하기 부족해서 요청이 큐에서 대기하는 시간이 길어졌기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 시나리오는 1회 실행당 40~50개의 DB 쿼리가 발생하고, 향후 외부 API 호출 같은 I/O 대기가 추가될 여지도 있다. 이 점을 고려하면 처리량이 너무 낮아 실전에는 부적합하다고 판단했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Thread 튜닝 결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최종 선택: Tomcat Threads = 32&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;성능 개선&lt;/b&gt; &amp;mdash; 200 &amp;rarr; 32로 줄이며 p95는 587ms &amp;rarr; 261ms(약 55% 개선), p99는 1.16s &amp;rarr; 434ms(약 63% 개선)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;처리량 증가&lt;/b&gt; &amp;mdash; Peak RPS 311 &amp;rarr; 347 (약 12% 향상)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Context Switching 최소화&lt;/b&gt; &amp;mdash; Grafana로 CPU 스케줄링 효율 개선 확인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실전 대응력&lt;/b&gt; &amp;mdash; 외부 API I/O가 추가돼도 견딜 동시성 확보 (3은 부족, 64는 과도)&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 Keep-Alive 유지 시간은 기본값을 그대로 뒀다. 줄여서 테스트해보니 실패하는 요청이 발생했기 때문이다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2단계: HikariCP Connection Pool 튜닝&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Thread 32로 최적화한 환경에서, 이번에는 DB 커넥션 풀 크기를 조정해 추가 개선을 노렸다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;튜닝 전략&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HikariCP 공식 문서의 권장 공식을 참고했다.&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;connections = (core_count * 2) + effective_spindle_count
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 시나리오의 특징은 이랬다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;높은 DB 쿼리 빈도&lt;/b&gt; &amp;mdash; 세션당 300~400회 쿼리&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동시 트랜잭션&lt;/b&gt; &amp;mdash; 참가자 10명이 병렬로 DB 작업&lt;/li&gt;
&lt;li&gt;&lt;b&gt;락 경합&lt;/b&gt; &amp;mdash; 동일 레코드에 대한 UPDATE/INSERT 경쟁&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 두 가지를 실험했다. 하나는 Pool Size 자체를 바꿔보는 것, 다른 하나는 MySQL PreparedStatement Cache를 켜서 쿼리 파싱 오버헤드를 제거하는 것이었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 1: Pool Size 15&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HikariCP Maximum Pool Size: 15&lt;/li&gt;
&lt;li&gt;Tomcat Threads: 32&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;총 요청 수: 56,359&lt;/li&gt;
&lt;li&gt;Peak RPS: 337 req/s&lt;/li&gt;
&lt;li&gt;p50: 33.9ms / p95: 248ms / p99: 430ms&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;/span&gt;&lt;a href=&quot;https://snapshots.raintank.io/dashboard/snapshot/OwYs5BoepVNjuqZFnZoONRuRjfWK9e87&quot;&gt;https://snapshots.raintank.io/dashboard/snapshot/OwYs5BoepVNjuqZFnZoONRuRjfWK9e87&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3804&quot; data-origin-height=&quot;1688&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bCXX9D/dJMb991iXCz/h2lxbJlxE3npdYg4DLEju0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bCXX9D/dJMb991iXCz/h2lxbJlxE3npdYg4DLEju0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bCXX9D/dJMb991iXCz/h2lxbJlxE3npdYg4DLEju0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbCXX9D%2FdJMb991iXCz%2Fh2lxbJlxE3npdYg4DLEju0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3804&quot; height=&quot;1688&quot; data-origin-width=&quot;3804&quot; data-origin-height=&quot;1688&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pool Size를 늘렸는데 오히려 &lt;b&gt;p50이 22ms &amp;rarr; 33.9ms로 나빠졌다.&lt;/b&gt; Grafana에서 커넥션 대기(Pending) 메트릭을 봤더니 거의 발생하지 않고 있었다. 즉 &lt;b&gt;병목은 커넥션 개수가 아니었다.&lt;/b&gt; 오히려 Pool Size를 키우면서 DB 측 커넥션 관리 오버헤드만 늘어난 것으로 보였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;병목이 다른 곳에 있다는 신호를 받은 셈이라, 방향을 커넥션 개수가 아니라 쿼리 처리 자체로 틀었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 2: Pool Size 10 + PreparedStatement Cache&amp;nbsp; (최종 채택)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;HikariCP Maximum Pool Size: 10&lt;/li&gt;
&lt;li&gt;HikariCP Minimum Idle: 10&lt;/li&gt;
&lt;li&gt;MySQL PreparedStatement Cache 활성화&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;spring.datasource.write.maximum-pool-size=10
spring.datasource.write.minimum-idle=10
spring.datasource.write.data-source-properties.cachePrepStmts=true
spring.datasource.write.data-source-properties.prepStmtCacheSize=250
spring.datasource.write.data-source-properties.prepStmtCacheSqlLimit=2048
spring.datasource.write.data-source-properties.useServerPrepStmts=true
spring.datasource.write.data-source-properties.useLocalSessionState=true
spring.datasource.write.data-source-properties.rewriteBatchedStatements=true
spring.datasource.write.data-source-properties.cacheResultSetMetadata=true
spring.datasource.write.data-source-properties.cacheServerConfiguration=true
spring.datasource.write.data-source-properties.elideSetAutoCommits=true
spring.datasource.write.data-source-properties.maintainTimeStats=false
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;총 요청 수: 56,610&lt;/li&gt;
&lt;li&gt;Peak RPS: 378 req/s&lt;/li&gt;
&lt;li&gt;&lt;b&gt;p50: 21ms / p95: 125ms / p99: 236ms&lt;br /&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span&gt;&lt;/span&gt;&lt;a href=&quot;https://snapshots.raintank.io/dashboard/snapshot/QuN1t81BK1CjBlEOx21IofH9uF87xNAV&quot;&gt;https://snapshots.raintank.io/dashboard/snapshot/QuN1t81BK1CjBlEOx21IofH9uF87xNAV&lt;/a&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3790&quot; data-origin-height=&quot;1544&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pHI23/dJMcadvJw5B/bt0NebktdRgwM7TSjyLkWk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pHI23/dJMcadvJw5B/bt0NebktdRgwM7TSjyLkWk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pHI23/dJMcadvJw5B/bt0NebktdRgwM7TSjyLkWk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpHI23%2FdJMcadvJw5B%2Fbt0NebktdRgwM7TSjyLkWk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3790&quot; height=&quot;1544&quot; data-origin-width=&quot;3790&quot; data-origin-height=&quot;1544&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PreparedStatement 캐시를 켜자 응답 지연이 극적으로 줄었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;p50: 22ms &amp;rarr; 21ms&lt;/li&gt;
&lt;li&gt;&lt;b&gt;p95: 261ms &amp;rarr; 125ms (약 52% 감소)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;p99: 434ms &amp;rarr; 236ms (약 46% 감소)&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Peak RPS: 347 &amp;rarr; 378 (약 9% 증가)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지표 Thread 32만 Pool 15 Pool 10 + PS Cache&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Peak RPS&lt;/td&gt;
&lt;td&gt;347&lt;/td&gt;
&lt;td&gt;337&lt;/td&gt;
&lt;td&gt;&lt;b&gt;378&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;22ms&lt;/td&gt;
&lt;td&gt;33.9ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;21ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;261ms&lt;/td&gt;
&lt;td&gt;248ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;125ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;434ms&lt;/td&gt;
&lt;td&gt;430ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;236ms&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;PreparedStatement Cache가 왜 효과적이었나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 시나리오는 세션당 300~400회의 쿼리가 발생하는데, 그 대부분이 동일한 SQL 패턴의 반복이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;참가자 조회: SELECT * FROM participant WHERE pickeat_id = ?&lt;/li&gt;
&lt;li&gt;식당 조회: SELECT * FROM restaurant WHERE pickeat_id = ? AND is_excluded = ?&lt;/li&gt;
&lt;li&gt;좋아요 등록: INSERT INTO restaurant_like (participant_id, restaurant_id) VALUES (?, ?)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 SQL이 계속 반복되니, 매번 파싱&amp;middot;컴파일하는 오버헤드가 누적되고 있었던 것이다. 주요 옵션의 역할은 이렇다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;cachePrepStmts=true + useServerPrepStmts=true &amp;mdash; 서버&amp;middot;클라이언트 양측에서 캐싱해 파싱을 1회만 수행&lt;/li&gt;
&lt;li&gt;rewriteBatchedStatements=true &amp;mdash; 배치 INSERT를 최적화해 네트워크 왕복 감소&lt;/li&gt;
&lt;li&gt;prepStmtCacheSize=250 &amp;mdash; 대부분의 SQL 패턴을 커버할 만큼 충분한 캐시 크기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반복되는 쿼리들이 캐싱되면서 p95가 절반 수준으로 떨어졌다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Connection Pool 튜닝 결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;최종 선택: Maximum Pool Size = 10, Minimum Idle = 10&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;스레드 32 환경에서 커넥션 대기가 거의 없어 10개로 충분&lt;/li&gt;
&lt;li&gt;과도한 커넥션 생성을 막아 DB 부하 최소화&lt;/li&gt;
&lt;li&gt;PreparedStatement Cache와의 시너지로 p95 52%, p99 46% 개선&lt;/li&gt;
&lt;li&gt;스케일 아웃 여지 확보 &amp;mdash; 3대 확장 시 30개, 10대 확장 시 100개로 MySQL 기본 max_connections(151) 이내&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3단계: 추가 검증 (Thread 64 재테스트)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 DB 설정(Pool 10 + PS Cache)을 적용한 뒤, 트래픽이 늘고 외부 의존성이 추가될 상황을 대비해 Thread를 다시 64로 올려 검증해봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설정&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Tomcat Threads: 64&lt;/li&gt;
&lt;li&gt;HikariCP Pool Size: 10&lt;/li&gt;
&lt;li&gt;PreparedStatement Cache: 활성화&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;총 요청 수: 48,543&lt;/li&gt;
&lt;li&gt;Peak RPS: 366 req/s&lt;/li&gt;
&lt;li&gt;p50: 30.5ms / p95: 145ms / p99: 339ms&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://snapshots.raintank.io/dashboard/snapshot/y8O98RR7FA94h9AJyvIfIcpVxdzH42h7&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://snapshots.raintank.io/dashboard/snapshot/y8O98RR7FA94h9AJyvIfIcpVxdzH42h7&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2814&quot; data-origin-height=&quot;1206&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dhMij3/dJMb99NFAuy/f9eTPYIAUYSsp0Y5SO3TrK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dhMij3/dJMb99NFAuy/f9eTPYIAUYSsp0Y5SO3TrK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dhMij3/dJMb99NFAuy/f9eTPYIAUYSsp0Y5SO3TrK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdhMij3%2FdJMb99NFAuy%2Ff9eTPYIAUYSsp0Y5SO3TrK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2814&quot; height=&quot;1206&quot; data-origin-width=&quot;2814&quot; data-origin-height=&quot;1206&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지표 Thread 32 Thread 64 차이&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Peak RPS&lt;/td&gt;
&lt;td&gt;378&lt;/td&gt;
&lt;td&gt;366&lt;/td&gt;
&lt;td&gt;-12 (-3.2%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;21ms&lt;/td&gt;
&lt;td&gt;30.5ms&lt;/td&gt;
&lt;td&gt;+9.5ms (+45%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;125ms&lt;/td&gt;
&lt;td&gt;145ms&lt;/td&gt;
&lt;td&gt;+20ms (+16%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;236ms&lt;/td&gt;
&lt;td&gt;339ms&lt;/td&gt;
&lt;td&gt;+103ms (+44%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Thread 64에서는 오히려 성능이 떨어졌다. Grafana CPU 메트릭을 보니 Context Switching이 늘며 CPU 오버헤드가 생겼고, 커넥션 풀 10개를 64개 스레드가 경쟁하면서 대기 시간이 증가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로 현재 시나리오에서는 Thread 32가 최적이었다. 다만 향후 외부 API I/O가 크게 늘어난다면 한 번에 64로 올리기보다 40~50 수준으로 점진적으로 확장하는 편이 낫다고 판단했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최종 설정&lt;/h2&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;# Tomcat
server.tomcat.threads.max=32

# HikariCP
spring.datasource.write.maximum-pool-size=10
spring.datasource.write.minimum-idle=10
spring.datasource.write.data-source-properties.cachePrepStmts=true
spring.datasource.write.data-source-properties.prepStmtCacheSize=250
spring.datasource.write.data-source-properties.prepStmtCacheSqlLimit=2048
spring.datasource.write.data-source-properties.useServerPrepStmts=true
spring.datasource.write.data-source-properties.useLocalSessionState=true
spring.datasource.write.data-source-properties.rewriteBatchedStatements=true
spring.datasource.write.data-source-properties.cacheResultSetMetadata=true
spring.datasource.write.data-source-properties.cacheServerConfiguration=true
spring.datasource.write.data-source-properties.elideSetAutoCommits=true
spring.datasource.write.data-source-properties.maintainTimeStats=false
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;성능 개선 요약&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;복잡한 비즈니스 로직 시나리오(참가자 10명, 300~400 쿼리/세션) 기준이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항목 초기 (Thread 200) 최종 (Thread 32 + Pool 10 + Cache) 개선율&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Peak RPS&lt;/td&gt;
&lt;td&gt;311 req/s&lt;/td&gt;
&lt;td&gt;&lt;b&gt;378 req/s&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;+21.5%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;27.8ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;21ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;-24.5%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;587ms&lt;/td&gt;
&lt;td&gt;&lt;b&gt;125ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;-78.7%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;1.16s&lt;/td&gt;
&lt;td&gt;&lt;b&gt;236ms&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;-79.7%&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시나리오를 기준으로, &lt;b&gt;인스턴스 1대당 안정적으로 처리 가능한 목표 TPS를 350~380 RPS로 확정&lt;/b&gt;했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;회고&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;스레드는 많다고 좋은 게 아니었다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 크게 배운 건 스레드 수에 대한 직관이 틀렸다는 점이다. I/O 대기가 많으니 스레드를 넉넉히 줘야 한다고 막연히 생각했는데, 실제로는 200개 중 150개 이상이 놀고 있었고 오히려 Context Switching 비용만 늘리고 있었다. 200 &amp;rarr; 32로 줄였을 뿐인데 p99가 1.16s에서 434ms까지 떨어졌다. 숫자를 키우는 것보다 실제 활성 스레드 수를 관측하고 거기에 맞추는 게 정답이었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;병목은 눈으로 확인해야 한다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pool Size를 15로 키웠는데 성능이 나빠졌을 때, 만약 Grafana로 커넥션 대기 메트릭을 보지 않았다면 계속 커넥션 개수만 만지작거렸을 것이다. 대기가 거의 없다는 걸 눈으로 확인하고 나서야 &quot;병목은 커넥션이 아니다&quot;라는 판단을 내리고 PreparedStatement Cache 쪽으로 방향을 틀 수 있었다. 추측이 아니라 관측된 지표에 근거해 다음 실험을 설계하는 흐름이 중요했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;단순 조회로 측정하지 않길 잘했다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 단순 조회 API 하나로 테스트했다면 훨씬 높은 숫자가 나왔겠지만, 그건 실제 서비스와는 동떨어진 값이었을 것이다. 참가자 10명의 동시 행동과 락 경합, 폴링 조회를 모두 포함한 시나리오로 측정했기 때문에, 여기서 나온 350~380 RPS는 실제로 신뢰할 수 있는 목표치가 됐다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;남은 과제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 인스턴스 1대 기준의 목표 TPS를 확정한 단계다. 앞으로는 이 값을 기준으로 스케일 아웃 시 실제 처리량이 선형에 가깝게 늘어나는지, 외부 API I/O가 추가됐을 때 스레드 수를 어떻게 재조정해야 하는지를 이어서 검증해나갈 예정이다.&lt;/p&gt;</description>
      <category>프로그래밍/프로젝트</category>
      <author>supernovaMK</author>
      <guid isPermaLink="true">https://supernovamk.tistory.com/77</guid>
      <comments>https://supernovamk.tistory.com/77#entry77comment</comments>
      <pubDate>Thu, 2 Jul 2026 22:23:10 +0900</pubDate>
    </item>
    <item>
      <title>[프로젝트/픽잇] 부하테스트 준비 과정</title>
      <link>https://supernovamk.tistory.com/76</link>
      <description>&lt;h1&gt;서론&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 새로운 요구사항을 받았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인스턴스 한 대 기준으로 핵심 기능의 목표 TPS를 정하고, 서비스가 안정적으로 운영될 수 있도록 관련 값을 설정한 뒤 그 근거를 공유해야 한다는 것이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목표 TPS를 정하려면 결국 현재 우리 서버가 얼마나 버틸 수 있는지를 알아야 했고, 그러려면 부하 테스트가 필요했다. 이 글에서는 부하 테스트를 준비하기까지의 과정을 정리해본다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;TPS란 무엇인가&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TPS(Transaction Per Second)는 말 그대로 초당 처리할 수 있는 트랜잭션 수를 뜻한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버의 성능 지표이자 확장성 지표로, TPS가 높을수록 더 많은 사용자의 요청을 동시에 처리할 수 있다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 TPS를 알아야 할까&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 TPS를 모르고 운영한다면 여러 문제가 생긴다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;갑자기 트래픽이 몰렸을 때 서버가 버틸 수 있는 한계를 알 수 없다.&lt;/li&gt;
&lt;li&gt;API마다 얼마나 처리할 수 있어야 하는지 기준이 없다.&lt;/li&gt;
&lt;li&gt;성능 최적화를 하더라도 목표치가 없으니 개선 성과를 확인하기 어렵다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, TPS는 단순한 숫자가 아니라 우리 서비스가 얼마나 안정적으로 운영될 수 있는지를 보여주는 기준선이라고 생각했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TPS로 얻을 수 있는 인사이트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하 테스트를 진행하다 보면 TPS가 일정 수준 이상 올라가지 않고 응답 시간이 늘어나는 구간이 반드시 존재한다. 이는 곧 병목 지점(DB 연결, CPU 한계, 네트워크 지연 등)이 있다는 피드백을 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 로그인 API가 초당 200건까지는 안정적으로 동작하지만 그 이상부터 응답 시간이 두 배로 늘어난다고 하자. 이 경우 DB 커넥션 풀 크기나 인증 로직에서 병목이 발생했음을 의심할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, TPS를 구한다는 건 &quot;우리 서버가 초당 몇 건의 요청을 처리할 수 있느냐?&quot;를 넘어서 서비스 안정성, 사용자 경험, 나아가 어디를 개선해야 하는지에 대한 피드백까지 연결되는 문제였다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;TPS를 늘리는 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TPS를 높인다는 건 사실상 서버가 더 많은 요청을 처리하게 만든다는 뜻이다. 그 방법은 크게 두 가지로 나눌 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;총 요청 시간을 줄인다&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;DB 연결, 외부 API 연동처럼 시간이 오래 걸리는 구간을 줄인다.&lt;/li&gt;
&lt;li&gt;캐싱, 쿼리 최적화, 커넥션 풀 튜닝을 통해 병목을 없앤다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동시에 처리할 수 있는 요청 수를 늘린다&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;서버 스레드풀, 커넥션 풀을 늘려 더 많은 요청을 받아들인다.&lt;/li&gt;
&lt;li&gt;서버/DB 스케일 아웃, 로드밸런싱을 통해 처리량을 확장한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;현재 TPS는 어떻게 알 수 있을까&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;측정 방법은 여러 가지가 있었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;모니터링 시스템&lt;/b&gt;: Prometheus, Loki, Grafana, Alloy&lt;/li&gt;
&lt;li&gt;&lt;b&gt;APM 도구&lt;/b&gt;: Scouter, Pinpoint&lt;/li&gt;
&lt;li&gt;&lt;b&gt;웹서버 접근 로그&lt;/b&gt;: ELK(Elasticsearch, Logstash, Kibana)로 집계&lt;/li&gt;
&lt;li&gt;&lt;b&gt;직접 로그 분석&lt;/b&gt;: 접근 로그를 파싱해서 계산&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;픽잇은 이미 Grafana 기반의 모니터링 시스템이 구축된 상태였으므로 현재 TPS를 확인하는 건 어렵지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 API별 RPS(Request Per Second, 엄밀히 말하면 TPS와는 다르지만 거의 같은 의미로 쓰이는 지표)를 Spring Actuator + Prometheus 기반으로 수집하고 있었기에 Grafana 대시보드에서 바로 확인할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 이것으로 충분할까? 아쉽게도 아니었다. 현재 TPS를 보는 것만으로는 우리 서버의 한계가 어디에 있는지를 알 수 없었다. 즉, &quot;어느 순간까지는 잘 버티다가, 그 이상이 되면 성능이 급격히 떨어진다&quot;라는 경계선을 찾아야 했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;부하 테스트란&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경계선을 알기 위해서는 결국 부하 테스트(Load Test)가 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하 테스트는 말 그대로 시스템에 의도적으로 부하를 걸어서 어느 수준까지 정상적으로 동작하는지 확인하는 과정이다. 사용자가 많아졌을 때 서버가 얼마나 많은 요청을 동시에 처리할 수 있는지, 그리고 어디서부터 성능 저하가 시작되는지를 알아내는 실험이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 통해 다음과 같은 것을 얻을 수 있다고 생각했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;최대 처리 용량 파악&lt;/b&gt; &amp;mdash; &quot;우리 서버는 초당 몇 건까지 견딜 수 있는가?&quot;라는 질문에 답할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;병목 지점 확인&lt;/b&gt; &amp;mdash; CPU, 메모리, 네트워크, DB 커넥션 풀 등 어느 자원이 먼저 한계에 도달하는지 알 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;안정성 확보&lt;/b&gt; &amp;mdash; 실제 트래픽이 몰리는 상황(신기능 오픈, 점심시간 트래픽 폭주 등)에서 서비스가 멈추지 않게 대비할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비즈니스 의사결정 지원&lt;/b&gt; &amp;mdash; TPS&amp;middot;응답시간&amp;middot;에러율 같은 수치가 곧 매출/사용자 경험과 연결되므로, 인프라 증설이나 최적화의 근거가 된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;부하 테스트의 방식&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;점진적 부하 (Ramp-up Test)&lt;/b&gt; &amp;mdash; 요청 수를 조금씩 늘려가며 서버가 언제부터 버거워하는지 확인한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최대 부하 (Stress Test)&lt;/b&gt; &amp;mdash; 서버가 완전히 한계에 도달할 때까지 트래픽을 걸어본다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;지속 부하 (Soak Test)&lt;/b&gt; &amp;mdash; 특정 부하 수준을 오랫동안 유지시키며 메모리 누수&amp;middot;성능 저하 같은 장기적 문제를 확인한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자세한 부하 테스트 전략은 팀원들과 함께 논의하며 정할 예정이었으므로, 일단은 부하 테스트가 가능한 환경을 세팅하는 것에 집중하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;949&quot; data-origin-height=&quot;289&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c8wpoN/dJMb991iXkT/oQsixRlez2wKfGEftuUHj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c8wpoN/dJMb991iXkT/oQsixRlez2wKfGEftuUHj0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c8wpoN/dJMb991iXkT/oQsixRlez2wKfGEftuUHj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc8wpoN%2FdJMb991iXkT%2FoQsixRlez2wKfGEftuUHj0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;949&quot; height=&quot;289&quot; data-origin-width=&quot;949&quot; data-origin-height=&quot;289&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;부하 테스트 도구 선택&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부하 테스트를 하려면 도구가 필요하다. 대표적으로 JMeter, Locust, k6가 널리 쓰인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그중에서도 픽잇팀은 JMeter와 k6를 비교해보고 어떤 것이 현재 환경에 맞는지 판단할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면, 픽잇은 이미 Prometheus + Grafana 모니터링 시스템이 구축되어 있고 팀 협업도 Git 기반으로 진행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 k6는 우리의 기존 관측&amp;middot;협업 환경에 자연스럽게 녹아드는 선택지였다. 추가 플러그인이나 별도 변환 과정 없이 바로 지표를 통합할 수 있고 코드 기반 관리도 가능하므로, 단기적인 도입 효율성은 물론 장기적인 유지보수와 확장성 면에서도 가장 현실적인 대안이라고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;관측 스택 통합&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Prometheus 연동은 플러그인 필요&lt;/td&gt;
&lt;td&gt;Prometheus remote_write 공식 지원&lt;/td&gt;
&lt;td&gt;이미 Prometheus + Grafana 운영 중이므로 k6가 자연스럽게 맞음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;자원 효율성&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Java 기반 &amp;rarr; 스레드 많아지면 JVM 부담&lt;/td&gt;
&lt;td&gt;Go 기반 + goroutine으로 경량&lt;/td&gt;
&lt;td&gt;같은 인스턴스에서 더 많은 VU를 돌릴 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;스크립트 관리&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;XML(JMX) 기반 &amp;rarr; 버전관리 불편&lt;/td&gt;
&lt;td&gt;JavaScript 기반 &amp;rarr; Git 관리, 리뷰, CI/CD 친화적&lt;/td&gt;
&lt;td&gt;코드 기반 협업에 적합&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;자동화/CI&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;Jenkins 연동 등 가능하나 수동 설정이 요구됨&lt;/td&gt;
&lt;td&gt;threshold, exit code 활용해 자동화 친화&lt;/td&gt;
&lt;td&gt;배포 파이프라인에 쉽게 녹일 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;생태계&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;오랫동안 지속된 도구인만큼 다양한 레퍼런스, 커뮤니티 존재&lt;/td&gt;
&lt;td&gt;비교적 최근에 나왔지만 Grafana Labs가 인수하고Prometheus/Grafana와 통합 강화되며 현대화된 공식문서와 레퍼런스 존재&lt;/td&gt;
&lt;td&gt;k6는 Grafana Labs 생태계와 긴밀히 통합되고 있어, 현재 우리가 쓰는 관측 체계와의 시너지가 앞으로 더 강화될 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;k6 환경 세팅&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의사결정 이후에 k6 자체를 기존 환경에 통합시키는 것은 어렵지 않았다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. k6 세팅&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;k6를 띄우는 데에는 docker-compose를 활용하였다.&lt;/p&gt;
&lt;pre class=&quot;dsconfig&quot;&gt;&lt;code&gt;# docker-compose.yml
version: &quot;3.8&quot;

services:
  k6:
    image: grafana/k6:latest
    volumes:
      - ./k6-scripts:/scripts
    command: run -o experimental-prometheus-rw /scripts/get-api-v1-rooms.js
    environment:
      - K6_PROMETHEUS_RW_TAGS=&quot;testid=get-api-v1-rooms&quot;
      - K6_PROMETHEUS_RW_SERVER_URL=https://{prometheus 주소}/api/v1/write&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;간단히 설명을 덧붙이자면, &lt;code&gt;./k6-scripts&lt;/code&gt; 폴더를 컨테이너 내부에 마운트하고, 작성한 테스트 스크립트를 실행하면서 그 결과를 Prometheus의 remote write 엔드포인트로 밀어 넣는 구성이다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;참고: 왜 experimental-prometheus-rw를 선택했는가?&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;k6는 본질적으로 단발성 실행 도구라서, 실험적 기능을 써도 운영 환경에 영향을 주지 않는다.&lt;/li&gt;
&lt;li&gt;만약 이 방식에 문제가 생기더라도, 그때는 Prometheus가 k6를 pull 하는 방식(exporter 모드)으로 바꿀 수 있다.&lt;/li&gt;
&lt;li&gt;사실 pull 방식도 어렵지 않지만, 이미 push 방식으로 구성을 맞췄고 구현이 간단해서 지금은 그대로 진행하기로 했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. Prometheus command 추가&lt;/h2&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;  prometheus:
    image: prom/prometheus:v3.5.0
    container_name: prometheus
    ports:
      - '9090:9090'
    volumes:
      - './monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro'
      - './monitoring/prometheus/alert_rules.yml:/etc/prometheus/alert_rules.yml:ro'
      - 'prometheus-data:/prometheus'
    command:
        ...
      - &quot;--web.enable-remote-write-receiver&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prometheus의 write API 엔드포인트를 활성화하기 위해서는 &lt;code&gt;--web.enable-remote-write-receiver&lt;/code&gt; command를 추가해서 실행해주어야 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 테스트 스크립트 작성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;k6에서 테스트 스크립트는 js 기반으로 작성된다. 테스트용이므로 간단하게 구성했다. 주석으로 설명을 대체한다.&lt;/p&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;// get-api-v1-rooms.js
import http from 'k6/http'; // k6에서 제공하는 http 모듈, HTTP 요청을 보낼 수 있게 해준다.

export const options = {
  vus: 1,         // 동시에 몇 명의 가상 사용자가 테스트를 수행할지 지정 -&amp;gt; 여기서는 1명
  iterations: 10, // 각 Virtual User가 몇 번 반복 실행할지 지정 -&amp;gt; 여기서는 10회
};

// k6 실행 시 가장 먼저 호출되는 함수
export default function () {
  http.get('http://{테스트 서버 ip}/api/v1/rooms');
}&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 실행&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;k6를 실행시키니 정상적으로 지표가 로그에 나타났음을 확인할 수 있었다. 테스트 서버에 요청도 잘 들어갔고, k6 대시보드에도 지표가 정상적으로 시각화되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(사실 10번으로는 지표가 잘 안 잡혀서 1000번으로 수정한 뒤 테스트했다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대시보드 템플릿으로는 &lt;a href=&quot;https://grafana.com/grafana/dashboards/19665-k6-prometheus/&quot;&gt;k6 Prometheus (Grafana Labs, 19665)&lt;/a&gt; 템플릿을 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1826&quot; data-origin-height=&quot;1384&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDQMiV/dJMb9971dNj/ZYvfBztrXazhMFZTIKN8G0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDQMiV/dJMb9971dNj/ZYvfBztrXazhMFZTIKN8G0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDQMiV/dJMb9971dNj/ZYvfBztrXazhMFZTIKN8G0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDQMiV%2FdJMb9971dNj%2FZYvfBztrXazhMFZTIKN8G0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1826&quot; height=&quot;1384&quot; data-origin-width=&quot;1826&quot; data-origin-height=&quot;1384&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;687&quot; data-origin-height=&quot;322&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbRFW9/dJMcafHejMR/UMFRc5ZfZ2d0IMIRecnP2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbRFW9/dJMcafHejMR/UMFRc5ZfZ2d0IMIRecnP2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbRFW9/dJMcafHejMR/UMFRc5ZfZ2d0IMIRecnP2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbRFW9%2FdJMcafHejMR%2FUMFRc5ZfZ2d0IMIRecnP2K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;687&quot; height=&quot;322&quot; data-origin-width=&quot;687&quot; data-origin-height=&quot;322&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;858&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yimlH/dJMcadbrqBX/Y1wHFb44deGxXsk6LDZFM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yimlH/dJMcadbrqBX/Y1wHFb44deGxXsk6LDZFM0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yimlH/dJMcadbrqBX/Y1wHFb44deGxXsk6LDZFM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyimlH%2FdJMcadbrqBX%2FY1wHFb44deGxXsk6LDZFM0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1894&quot; height=&quot;858&quot; data-origin-width=&quot;1894&quot; data-origin-height=&quot;858&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h1&gt;회고&lt;/h1&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;현재 값을 보는 것과 한계를 아는 것은 다르다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 준비 과정에서 가장 크게 느낀 점은, 이미 Grafana 대시보드로 현재 TPS를 보고 있었음에도 그것만으로는 서버의 한계를 전혀 알 수 없었다는 것이다. 현재 값은 &quot;지금 이 정도 트래픽을 처리하고 있다&quot;는 사실만 알려줄 뿐, &quot;어디까지 버틸 수 있는가&quot;라는 질문에는 답하지 못했다. 결국 의도적으로 부하를 걸어 경계선을 직접 찾아야 한다는 점을 다시 확인했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;기존 관측 환경과의 정합성이 도구 선택의 핵심이었다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도구를 고를 때 기능의 우열보다 우리 환경에 얼마나 자연스럽게 녹아드는지가 더 중요했다. 이미 Prometheus + Grafana를 운영하고 Git 기반으로 협업하는 상황에서, remote_write를 공식 지원하고 스크립트를 코드로 관리할 수 있는 k6는 별도의 변환이나 플러그인 없이 바로 붙일 수 있었다. 도구는 단독으로 좋은 것보다 지금의 환경과 잘 맞는 것을 고르는 편이 낫다고 느꼈다.&lt;/p&gt;</description>
      <category>프로그래밍/프로젝트</category>
      <author>supernovaMK</author>
      <guid isPermaLink="true">https://supernovamk.tistory.com/76</guid>
      <comments>https://supernovamk.tistory.com/76#entry76comment</comments>
      <pubDate>Thu, 2 Jul 2026 21:48:23 +0900</pubDate>
    </item>
    <item>
      <title>[프로젝트/픽잇] Docker 기반 포트 스위칭으로 무중단 배포 구현하기</title>
      <link>https://supernovamk.tistory.com/75</link>
      <description>&lt;h2 data-sourcepos=&quot;3:1-3:5;44-48&quot; data-ke-size=&quot;size26&quot;&gt;서론&lt;/h2&gt;
&lt;p data-sourcepos=&quot;5:1-5:123;50-172&quot; data-ke-size=&quot;size16&quot;&gt;배포를 할 때마다 기존 애플리케이션을 종료하고 새 버전을 다시 띄우는 방식에는 명확한 문제가 있었다. 애플리케이션이 종료되고 새 버전이 뜨기까지의 시간 동안 서비스가 중단되고, 그 사이에 들어온 요청들은 실패하게 된다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;7:1-7:150;174-323&quot; data-ke-size=&quot;size16&quot;&gt;픽잇은 사용자가 실시간으로 픽잇(투표)을 생성하고 참여하는 서비스이기 때문에, 배포 시점에 요청이 실패하는 것은 사용자 경험에 직접적인 영향을 준다. 배포는 앞으로도 계속 반복될 작업인데, 그때마다 짧게라도 서비스가 끊긴다면 배포 자체가 부담스러운 작업이 되어버린다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;9:1-9:53;325-377&quot; data-ke-size=&quot;size16&quot;&gt;따라서 배포 중에도 사용자가 서비스 중단을 느끼지 않도록 무중단 배포를 적용하기로 결정하였다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-sourcepos=&quot;13:1-13:15;384-398&quot; data-ke-size=&quot;size26&quot;&gt;무중단 배포 전략 선택&lt;/h2&gt;
&lt;h3 data-sourcepos=&quot;15:1-15:16;400-415&quot; data-ke-size=&quot;size23&quot;&gt;현재 우리가 처한 상황&lt;/h3&gt;
&lt;p data-sourcepos=&quot;17:1-17:87;417-503&quot; data-ke-size=&quot;size16&quot;&gt;무중단 배포 전략을 결정하기 전에 먼저 우리가 처한 인프라 환경을 정리해보았다. 전략은 결국 우리의 제약 조건 안에서 선택되어야 한다고 생각했기 때문이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;19:1-20:61;505-617&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;19:1-19:52;505-556&quot;&gt;Nginx를 리버스 프록시로 사용하고 있었다. (ELB는 비용 문제로 사용하지 않았다.)&lt;/li&gt;
&lt;li data-sourcepos=&quot;20:1-20:61;557-617&quot;&gt;WAS 인스턴스가 1~2개인 소규모 프로젝트였다. (마찬가지로 비용 문제로 인스턴스를 추가하지 않았다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-sourcepos=&quot;22:1-22:61;619-679&quot; data-ke-size=&quot;size16&quot;&gt;즉, &lt;b&gt;단일 서버 환경에서 추가 비용 없이 무중단 배포를 구현해야 한다는 것&lt;/b&gt;이 우리의 핵심 제약이었다.&lt;/p&gt;
&lt;h3 data-sourcepos=&quot;24:1-24:23;681-703&quot; data-ke-size=&quot;size23&quot;&gt;고려할 수 있었던 무중단 배포 전략&lt;/h3&gt;
&lt;p data-sourcepos=&quot;26:1-26:33;705-737&quot; data-ke-size=&quot;size16&quot;&gt;이 제약 안에서 고려할 수 있는 전략은 크게 세 가지였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;28:1-30:32;739-822&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;28:1-28:25;739-763&quot;&gt;Blue-Green 배포 (포트 스위칭)&lt;/li&gt;
&lt;li data-sourcepos=&quot;29:1-29:27;764-790&quot;&gt;Blue-Green 배포 (인스턴스 스위칭)&lt;/li&gt;
&lt;li data-sourcepos=&quot;30:1-30:32;791-822&quot;&gt;Rolling Update 배포 (Docker 기반)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-sourcepos=&quot;32:1-32:19;824-842&quot; data-ke-size=&quot;size16&quot;&gt;각각의 전략을 하나씩 따져보았다.&lt;/p&gt;
&lt;h4 data-sourcepos=&quot;34:1-34:27;844-870&quot; data-ke-size=&quot;size20&quot;&gt;Blue-Green 배포 (포트 스위칭)&lt;/h4&gt;
&lt;p data-sourcepos=&quot;36:1-36:148;872-1019&quot; data-ke-size=&quot;size16&quot;&gt;단일 서버 내에서 서로 다른 두 개의 포트(예: 8080, 8081)를 활용하여 애플리케이션을 교대로 배포하는 방식이다. 하나의 포트에서 현재 버전이 운영 중일 때 다른 포트에 새 버전을 배포한 뒤, Nginx의 upstream 설정을 변경하여 트래픽을 전환한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;38:1-44:52;1021-1232&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;38:1-42:46;1021-1175&quot;&gt;장점
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;39:5-42:46;1030-1175&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;39:5-39:34;1030-1059&quot;&gt;추가 인프라 없이 단일 서버에서 구현이 가능하다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;40:5-40:26;1064-1085&quot;&gt;구현이 간단하고 학습 곡선이 낮다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;41:5-41:44;1090-1129&quot;&gt;문제가 발생하면 포트만 다시 되돌리면 되므로 즉시 롤백이 가능하다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;42:5-42:46;1134-1175&quot;&gt;새 버전을 충분히 테스트한 뒤 트래픽을 전환할 수 있어 안정성이 높다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-sourcepos=&quot;43:1-44:52;1176-1232&quot;&gt;단점
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;44:5-44:52;1185-1232&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;44:5-44:52;1185-1232&quot;&gt;배포 순간 두 애플리케이션이 동시에 실행되어 메모리가 일시적으로 두 배 필요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-sourcepos=&quot;46:1-46:29;1234-1262&quot; data-ke-size=&quot;size20&quot;&gt;Blue-Green 배포 (인스턴스 스위칭)&lt;/h4&gt;
&lt;p data-sourcepos=&quot;48:1-48:142;1264-1405&quot; data-ke-size=&quot;size16&quot;&gt;두 개의 독립된 서버 인스턴스를 Blue와 Green으로 나누어, 하나는 현재 운영 환경으로, 다른 하나는 새 버전 배포 대상으로 사용하는 방식이다. 새 버전이 준비되면 Nginx의 upstream 설정을 변경하여 전체 트래픽을 새 인스턴스로 전환한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;50:1-57:44;1407-1672&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;50:1-54:44;1407-1582&quot;&gt;장점
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;51:5-54:44;1416-1582&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;51:5-51:41;1416-1452&quot;&gt;각 인스턴스가 완전히 격리된 환경에서 실행되어 안정성이 높다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;52:5-52:45;1457-1497&quot;&gt;실제 운영 환경과 동일한 조건에서 새 버전을 충분히 검증할 수 있다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;53:5-53:41;1502-1538&quot;&gt;각 서버가 독립적으로 리소스를 사용하므로 메모리 압박이 없다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;54:5-54:44;1543-1582&quot;&gt;Kubernetes 마이그레이션, 로드밸런서 적용 등 확장이 쉽다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-sourcepos=&quot;55:1-57:44;1583-1672&quot;&gt;단점
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;56:5-57:44;1592-1672&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;56:5-56:41;1592-1628&quot;&gt;최소 2대의 서버가 필요하여 인프라 비용이 두 배로 증가한다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;57:5-57:44;1633-1672&quot;&gt;환경변수, 인증서, 설정 파일 등을 양쪽에 동일하게 유지해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-sourcepos=&quot;59:1-59:34;1674-1707&quot; data-ke-size=&quot;size20&quot;&gt;Rolling Update 배포 (Docker 기반)&lt;/h4&gt;
&lt;p data-sourcepos=&quot;61:1-61:105;1709-1813&quot; data-ke-size=&quot;size16&quot;&gt;기존 WAS 인스턴스를 하나씩 순차적으로 새 버전으로 교체하는 방식이다. 새 버전의 컨테이너를 하나씩 실행하면서 기존 버전을 점진적으로 제거하기 때문에 서비스 중단 없이 배포가 진행된다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;63:1-70:40;1815-2084&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;63:1-66:44;1815-1950&quot;&gt;장점
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;64:5-66:44;1824-1950&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;64:5-64:42;1824-1861&quot;&gt;유휴 인스턴스 없이 모든 인스턴스를 효율적으로 사용할 수 있다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;65:5-65:45;1866-1906&quot;&gt;점진적으로 업데이트하기 때문에 문제 발생 시 빠르게 롤백이 가능하다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;66:5-66:44;1911-1950&quot;&gt;Kubernetes 마이그레이션, 로드밸런서 적용 등 확장이 쉽다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-sourcepos=&quot;67:1-70:40;1951-2084&quot;&gt;단점
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;68:5-70:40;1960-2084&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;68:5-68:41;1960-1996&quot;&gt;컨테이너를 하나씩 교체하므로 전체 배포에 시간이 오래 걸린다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;69:5-69:48;2001-2044&quot;&gt;구버전과 신버전이 동시에 실행되는 구간에서 호환성 문제가 발생할 수 있다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;70:5-70:40;2049-2084&quot;&gt;다중 인스턴스 환경에서만 의미 있는 무중단 배포가 가능하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-sourcepos=&quot;72:1-76:61;2086-2319&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-sourcepos=&quot;72:3-72:36;2088-2121&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Graceful Shutdown 방식은 왜 제외했는가&lt;/b&gt;&lt;/p&gt;
&lt;p data-sourcepos=&quot;74:3-74:133;2126-2256&quot; data-ke-size=&quot;size16&quot;&gt;서버 종료 시 처리 중인 요청을 모두 완료한 뒤 안전하게 종료하고, 배포 중 들어오는 요청을 대기 큐에 임시 저장했다가 배포 완료 후 순차적으로 처리하는 방식도 고려해볼 수 있었다. 요청을 &quot;중단&quot;시키지 않고 &quot;지연&quot;시키는 방식이다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;76:3-76:61;2261-2319&quot; data-ke-size=&quot;size16&quot;&gt;다만 이는 우리가 목표로 한 완전한 무중단 배포에는 해당하지 않는다고 판단하여 고려 대상에서 제외하였다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-sourcepos=&quot;78:1-78:15;2321-2335&quot; data-ke-size=&quot;size23&quot;&gt;종합적으로 비교해보기&lt;/h3&gt;
&lt;p data-sourcepos=&quot;80:1-80:24;2337-2360&quot; data-ke-size=&quot;size16&quot;&gt;세 전략을 두 가지 기준으로 비교해보았다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;82:1-85:106;2362-2579&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;82:1-83:87;2362-2461&quot;&gt;&lt;b&gt;구현 난이도&lt;/b&gt; 포트 스위칭이 가장 쉬웠고, 인스턴스 스위칭이 중간 수준이었으며, Docker 기반 Rolling Update가 가장 높은 학습 곡선을 요구하였다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;84:1-85:106;2462-2579&quot;&gt;&lt;b&gt;비용 측면&lt;/b&gt; 포트 스위칭과 Rolling Update 방식은 단일 서버로 구현이 가능하여 추가 비용이 없었으나, 인스턴스 스위칭은 서버를 2대 운영해야 하므로 인프라 비용이 두 배로 발생하였다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-sourcepos=&quot;87:1-87:34;2581-2614&quot; data-ke-size=&quot;size23&quot;&gt;최종 선택 - Blue-Green 배포 (포트 스위칭)&lt;/h3&gt;
&lt;p data-sourcepos=&quot;89:1-89:54;2616-2669&quot; data-ke-size=&quot;size16&quot;&gt;최종적으로 &lt;b&gt;포트 스위칭 기반 Blue-Green 배포&lt;/b&gt;를 선택하였다. 이유는 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-sourcepos=&quot;91:1-98:123;2671-3235&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-sourcepos=&quot;91:1-92:115;2671-2832&quot;&gt;&lt;b&gt;단일 WAS 인스턴스 구조를 유지하면서 무중단 배포를 수행할 수 있다.&lt;/b&gt; 현재 규모에서 새로운 인스턴스를 추가하는 것은 오버엔지니어링이라고 판단했다. 인스턴스 스위칭의 안정성과 확장성은 매력적이었지만, 우리 규모에서 비용을 두 배로 지불할 만큼의 이점은 아니라고 보았다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;93:1-94:86;2833-2972&quot;&gt;&lt;b&gt;일시적인 메모리 두 배 문제는 스왑 메모리(Swap Memory)로 감당 가능하다.&lt;/b&gt; 배포 시 Blue/Green WAS가 잠시 함께 실행되어 메모리 사용량이 늘어나지만, 이는 스왑 메모리를 통해 충분히 감당할 수 있다고 판단했다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;95:1-96:42;2973-3054&quot;&gt;&lt;b&gt;구현이 가장 빠르고 간단하여 배포 시간을 단축할 수 있다.&lt;/b&gt; 단일 서버 기반의 무중단 배포라는 목적에 가장 부합하는 전략이었다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;97:1-98:123;3055-3235&quot;&gt;&lt;b&gt;향후 다중 인스턴스 환경으로 확장 시 Rolling Update를 추가로 적용할 수 있다.&lt;/b&gt; 지금은 포트 스위칭으로 시작하되, 이후 분산 환경으로 넘어가면 Rolling Update를 적용하여 Blue/Green + Rolling Update 두 가지 무중단 배포를 모두 경험할 수 있을 것이라 기대했다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-sourcepos=&quot;102:1-102:28;3242-3269&quot; data-ke-size=&quot;size26&quot;&gt;왜 Nginx를 통한 포트 스위칭이어야 했는가&lt;/h2&gt;
&lt;p data-sourcepos=&quot;104:1-104:112;3271-3382&quot; data-ke-size=&quot;size16&quot;&gt;전략을 정한 뒤 실제 구현을 고민하는 과정에서 몇 가지 벽에 부딪혔고, 그 과정을 거치며 &quot;왜 WAS EC2 내부에 Nginx를 두어 포트 스위칭을 해야 하는가&quot;에 대한 답을 스스로 정리하게 되었다.&lt;/p&gt;
&lt;h2 data-sourcepos=&quot;106:1-106:40;3384-3423&quot; data-ke-size=&quot;size26&quot;&gt;WAS EC2에서 Nginx EC2의 라우팅을 어떻게 바꿀 것인가&lt;/h2&gt;
&lt;p data-sourcepos=&quot;108:1-108:70;3425-3494&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 자연스럽게 &quot;Nginx EC2의 라우팅 설정을 바꾸면 되지 않을까&quot;라고 생각했다. 하지만 여기서 문제가 발생하였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;110:1-111:67;3496-3608&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;110:1-110:46;3496-3541&quot;&gt;운영 Nginx의 보안그룹(project-lb)은 22번 포트가 닫혀 있었다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;111:1-111:67;3542-3608&quot;&gt;운영 WAS의 보안그룹(project-app)도 22번 포트가 닫혀 있었고, public 보안그룹에만 열려 있었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-sourcepos=&quot;113:1-113:74;3610-3683&quot; data-ke-size=&quot;size16&quot;&gt;즉 (Nginx &amp;rarr; WAS), (WAS &amp;rarr; Nginx) 양방향 접속이 모두 불가능하여 두 서버 간 동기화 자체가 어려운 상황이었다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;115:1-115:26;3685-3710&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 풀기 위해 여러 방법을 검토하였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;117:1-124:71;3712-4254&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;117:1-118:86;3712-3827&quot;&gt;&lt;b&gt;Nginx 보안그룹을 public으로 변경&lt;/b&gt; Nginx &amp;rarr; WAS로 베스천 방식 접속이 가능해지지만, Nginx의 22번 포트가 항상 열려 있어 WAS의 PEM 키가 노출되는 위험이 있었다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;119:1-120:196;3828-4051&quot;&gt;&lt;b&gt;워크플로우를 활용해 포트 정보를 넘기기&lt;/b&gt; CD 1단계에서 WAS EC2의 러너가 블루/그린 스크립트를 실행하며 새 WAS가 열린 포트를 워크플로우 변수로 저장하고, CD 2단계에서 Nginx EC2의 러너가 그 변수를 가져와 라우팅을 수정하는 방식이다. 다만 롤백까지 고려하면 CD 워크플로우가 너무 복잡해지고, 수동 배포 시 WAS와 Nginx 양쪽에서 작업해야 해서 부담이 컸다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;121:1-122:78;4052-4158&quot;&gt;&lt;b&gt;Session Manager를 통한 접속&lt;/b&gt; WAS EC2에 awscli와 session-manager-plugin을 설치해 SSM으로 Nginx에 접속하는 방식도 고려하였다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;123:1-124:71;4159-4254&quot;&gt;&lt;b&gt;Nginx 자동 라우팅 변경 방식&lt;/b&gt; Nginx에 Blue/Green WAS 주소를 모두 등록해두고 살아있는 WAS로 자동으로 트래픽을 라우팅하는 방식이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-sourcepos=&quot;126:1-126:142;4256-4397&quot; data-ke-size=&quot;size16&quot;&gt;여러 방법을 검토하며 Nginx 자동 라우팅이나 Session Manager 방식으로 가려고 했지만, 결정적인 사실을 뒤늦게 깨달았다. &lt;b&gt;운영 WAS의 인바운드 포트가 80번만 열려 있어 8080/8081을 외부로 노출하는 것이 불가능했던 것이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-sourcepos=&quot;128:1-128:142;4399-4540&quot; data-ke-size=&quot;size16&quot;&gt;즉, Nginx EC2에서 WAS의 8080/8081로 직접 라우팅을 바꾸는 것 자체가 애초에 불가능했다. 결국 포트 스위칭은 WAS EC2 안에서 이루어져야 의미가 있었고, 그렇다면 Nginx EC2의 라우팅을 건드리는 접근은 전제부터 틀렸던 셈이다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;130:1-130:63;4542-4604&quot; data-ke-size=&quot;size16&quot;&gt;이 과정을 거치며 자연스럽게 &lt;b&gt;WAS EC2 내부에 별도의 Nginx를 배치하는 방향&lt;/b&gt;으로 결론이 모아졌다.&lt;/p&gt;
&lt;h2 data-sourcepos=&quot;132:1-132:35;4606-4640&quot; data-ke-size=&quot;size26&quot;&gt;WAS EC2 내부에서는 어떻게 포트 스위칭을 할 것인가&lt;/h2&gt;
&lt;p data-sourcepos=&quot;134:1-134:91;4642-4732&quot; data-ke-size=&quot;size16&quot;&gt;WAS EC2가 80번 포트만 열려 있으므로, WAS 내부에서 80번 포트에 대해 포트 스위칭을 해줄 무언가가 필요했다. 여기서 두 가지 방법을 두고 고민하였다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;136:1-141:169;4734-5188&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;136:1-138:78;4734-4916&quot;&gt;&lt;b&gt;WAS EC2 내부에 Nginx를 추가 배치&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;137:5-138:78;4769-4916&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;137:5-137:74;4769-4838&quot;&gt;장점: 팀 전체가 쉽게 이해하고 유지보수할 수 있다. nginx -t로 사전 검증이 가능하고, 실수해도 복구가 쉽다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;138:5-138:78;4843-4916&quot;&gt;단점: 약간의 성능 오버헤드(0.1~0.3ms 추가 지연)와 메모리 사용(약 50MB 추가), 관리할 프로세스가 하나 늘어난다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-sourcepos=&quot;139:1-141:169;4917-5188&quot;&gt;&lt;b&gt;iptables를 활용한 커널 레벨 포트 스위칭&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;140:5-141:169;4954-5188&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;140:5-140:70;4954-5019&quot;&gt;장점: 커널 레벨에서 동작하여 0.01ms 미만의 지연, CPU 오버헤드가 거의 없고 메모리 사용량도 거의 없다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;141:5-141:169;5024-5188&quot;&gt;단점: iptables 명령어와 NAT 개념에 대한 이해가 필요해 팀 온보딩이 어렵다. 로그가 거의 없어 어느 컨테이너에서 에러가 났는지 추적이 어렵고, 잘못된 규칙 하나로 서비스 전체가 중단될 수 있는데 롤백 메커니즘도 없다. 휴먼 에러 가능성이 높고 문서화와 교육에도 많은 노력이 든다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-sourcepos=&quot;143:1-143:57;5190-5246&quot; data-ke-size=&quot;size16&quot;&gt;결론적으로 &lt;b&gt;WAS EC2 내부에 Nginx를 추가하는 방향&lt;/b&gt;을 선택하였다. 이유는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-sourcepos=&quot;145:1-148:81;5248-5457&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-sourcepos=&quot;145:1-145:52;5248-5299&quot;&gt;iptables 방식이 성능상 가장 좋지만, 학습 곡선이 커서 구현과 팀 공유가 어렵다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;146:1-146:41;5300-5340&quot;&gt;내부 Nginx의 성능 오버헤드는 크지 않아 충분히 감수할 수 있다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;147:1-147:36;5341-5376&quot;&gt;내부 Nginx로 포트 스위칭을 하면 팀원과의 공유가 쉽다.&lt;/li&gt;
&lt;li data-sourcepos=&quot;148:1-148:81;5377-5457&quot;&gt;이후 분산 환경 개발 시 Rolling Update를 적용할 예정이라, 지금은 단순한 아키텍처로 빠르게 구현하는 것이 효율적이라고 판단했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-sourcepos=&quot;150:1-150:158;5459-5616&quot; data-ke-size=&quot;size16&quot;&gt;정리하자면, 우리가 Docker 컨테이너 기반의 내부 Nginx 포트 스위칭을 선택한 것은 단순히 그것이 멋져서가 아니라, &lt;b&gt;80번 포트만 열려 있는 제약 &amp;rarr; WAS 내부에서 스위칭 필요 &amp;rarr; 팀이 공유하고 유지보수하기 쉬운 방식 필요&lt;/b&gt;라는 흐름에서 자연스럽게 도출된 결론이었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-sourcepos=&quot;154:1-154:20;5623-5642&quot; data-ke-size=&quot;size26&quot;&gt;무중단 배포 구현 (개발 환경)&lt;/h2&gt;
&lt;h3 data-sourcepos=&quot;156:1-156:20;5644-5663&quot; data-ke-size=&quot;size23&quot;&gt;Blue-Green 배포 구조&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1787&quot; data-origin-height=&quot;526&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bG0F1q/dJMb9970eRa/SgMlqfH2oAmuELXtaFTh61/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bG0F1q/dJMb9970eRa/SgMlqfH2oAmuELXtaFTh61/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bG0F1q/dJMb9970eRa/SgMlqfH2oAmuELXtaFTh61/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbG0F1q%2FdJMb9970eRa%2FSgMlqfH2oAmuELXtaFTh61%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1787&quot; height=&quot;526&quot; data-origin-width=&quot;1787&quot; data-origin-height=&quot;526&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-sourcepos=&quot;158:1-158:23;5665-5687&quot; data-ke-size=&quot;size16&quot;&gt;개발 환경의 인프라 구조는 다음과 같다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;160:1-167:4;5689-5798&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;inform7&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;[사용자]
   &amp;darr;
[EC2]
   ├─ Nginx Container
   ├─ Blue Container (8080 포트)
   └─ Green Container (8081 포트)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-sourcepos=&quot;169:1-169:38;5800-5837&quot; data-ke-size=&quot;size23&quot;&gt;WAS 관련 작업 - Blue / Green 애플리케이션 구성&lt;/h3&gt;
&lt;p data-sourcepos=&quot;171:1-171:58;5839-5896&quot; data-ke-size=&quot;size16&quot;&gt;애플리케이션의 docker-compose.yml을 Blue/Green 두 컨테이너로 나누어 구성하였다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;173:1-241:4;5898-7384&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;version: '3.8'
services:
  # 블루 애플리케이션 컨테이너
  backend-blue:
    image: pickeat/pickeat:latest
    container_name: backend-blue
    restart: always
    ports:
      - &quot;8080:8080&quot; # 8080 포트 포워드
    volumes:
      - ./data/springboot/logs/blue:/var/logs # logs/blue에 로그 저장
    environment:
      SPRING_ACTIVE_PROFILE: dev
    networks:
      - app-tier

  # 그린 애플리케이션 컨테이너
  backend-green:
    image: pickeat/pickeat:latest
    container_name: backend-green
    restart: always
    ports:
      - &quot;8081:8080&quot;  # 8081 포트 포워드
    volumes:
      - ./data/springboot/logs/green:/var/logs # logs/green에 로그 저장
    environment:
      SPRING_ACTIVE_PROFILE: dev
    networks:
      - app-tier

  mysqldb:
    image: mysql:latest
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: pickeat_db
      MYSQL_USER: pickeat
      MYSQL_PASSWORD: pickeatcheeze33
      SPRING_ACTIVE_PROFILE: dev
    ports:
      - '3306:3306'
    restart: always
    volumes:
      - 'mysqldb-data:/var/lib/mysql'
    networks:
      - app-tier

  alloy:
    image: grafana/alloy:latest
    container_name: alloy
    ports:
      - &quot;12345:12345&quot;
    volumes:
      - ./data/springboot/logs/blue:/var/log/spring/blue
      - ./data/springboot/logs/green:/var/log/spring/green
      - ./data/alloy/config.alloy:/etc/alloy/config.alloy
    environment:
      - ALLOY_ENV=dev
    networks:
      - app-tier

volumes:
  mysqldb-data:
    driver: local

networks:
  app-tier:
    driver: bridge&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-sourcepos=&quot;243:1-243:125;7386-7510&quot; data-ke-size=&quot;size16&quot;&gt;Blue/Green 로그를 각각 분리해서 수집하기 위해 Alloy 설정도 두 경로를 모두 바라보도록 구성하였다. 로그 파일 경로로부터 deployment 라벨값을 추출하여 어느 쪽에서 나온 로그인지 구분할 수 있게 하였다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;245:1-355:4;7512-9695&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;nix&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;logging {
  level  = &quot;info&quot;
  format = &quot;logfmt&quot;
}

local.file_match &quot;spring_logs&quot; {
  path_targets = [
    { __path__ = &quot;/var/log/spring/blue/*.log&quot; },   // Blue Was에서 로그 받아옴
    { __path__ = &quot;/var/log/spring/green/*.log&quot; },  // Green Was에서 로그 받아옴
  ]
}

loki.source.file &quot;spring_source&quot; {
  targets = local.file_match.spring_logs.targets
  forward_to = [loki.process.spring_labels.receiver]
}

loki.process &quot;spring_labels&quot; {
  forward_to = [loki.write.grafana_loki.receiver]

  // 고정 라벨값 부여
  stage.static_labels {
    values = {
      service = &quot;pickeat&quot;,
      env     = sys.env(&quot;ALLOY_ENV&quot;),
    }
  }

  // 멀티라인 처리
  stage.multiline {
    firstline = &quot;^\\[\\d{4}-\\d{2}-\\d{2}&quot;
    max_wait_time = &quot;3s&quot;
  }

  // 로그파일 경로로부터 deployment 라벨값을 가져옴
  stage.regex {
    expression = &quot;^/var/log/spring/(?P&amp;lt;deployment&amp;gt;[^/]+)/&quot;
    source     = &quot;filename&quot;
  }

  // 로그에 deployment 라벨 추가
  stage.labels {
    values = {
      deployment = &quot;&quot;,
    }
  }
}

loki.source.file &quot;nginx_access_logs&quot; {
  targets = [
    {
      __path__ = &quot;/var/log/nginx/access.log&quot;,
      job      = &quot;nginx-access&quot;,
      service  = &quot;nginx&quot;,
      logtype  = &quot;access&quot;,
    },
  ]
  forward_to = [loki.process.nginx_access.receiver]
}

loki.process &quot;nginx_access&quot; {
  forward_to = [loki.write.grafana_loki.receiver]

  stage.static_labels {
    values = {
      job     = &quot;nginx-docker&quot;,
      service = &quot;nginx&quot;,
      logtype = &quot;access&quot;,
    }
  }
}

loki.source.file &quot;nginx_error_logs&quot; {
  targets = [
    {
      __path__ = &quot;/var/log/nginx/error.log&quot;,
      job      = &quot;nginx-error&quot;,
      service  = &quot;nginx&quot;,
      logtype  = &quot;error&quot;,
    },
  ]
  forward_to = [loki.process.nginx_error.receiver]
}

loki.process &quot;nginx_error&quot; {
  forward_to = [loki.write.grafana_loki.receiver]

  stage.multiline {
    firstline = &quot;^[0-9]{4}/[0-9]{2}/[0-9]{2}&quot;
    max_lines = 50
  }

  stage.static_labels {
    values = {
      job     = &quot;nginx-docker&quot;,
      service = &quot;nginx&quot;,
      logtype = &quot;error&quot;,
    }
  }
}

loki.write &quot;grafana_loki&quot; {
  endpoint {
    url = &quot;https://monitoring.pickeat.io.kr/loki/loki/api/v1/push&quot;
    tenant_id = &quot;pickeat-dev&quot;
    batch_wait = &quot;2s&quot;
    batch_size = &quot;512KB&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-sourcepos=&quot;357:1-357:30;9697-9726&quot; data-ke-size=&quot;size23&quot;&gt;Nginx 관련 작업 - 라우팅 포트 변경 세팅&lt;/h3&gt;
&lt;p data-sourcepos=&quot;359:1-359:135;9728-9862&quot; data-ke-size=&quot;size16&quot;&gt;포트 스위칭의 핵심은 라우팅 대상 URL을 별도 파일(service-url.inc)로 분리해두고, 배포 시 이 파일만 바꿔 리로드하는 것이다. 이렇게 하면 nginx.conf 전체를 건드리지 않고도 안전하게 트래픽 대상을 바꿀 수 있다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;361:1-361:39;9864-9902&quot; data-ke-size=&quot;size16&quot;&gt;Nginx 컨테이너에 service-url.inc를 마운트하였다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;363:1-413:4;9904-11176&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;dts&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;services:
  nginx:
    build:
      context: ./nginx
      dockerfile: Dockerfile
    container_name: nginx
    ports:
      - 80:80
      - 443:443
    expose:
      - &quot;9001&quot;
    restart: always
    environment:
      - TZ=Asia/Seoul
    networks:
      - app-tier
    volumes:
      - ./nginx/metrics.conf:/etc/nginx/conf.d/metrics.conf
      - ./nginx/logs:/var/log/nginx
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
      - ./nginx/service-url.inc:/etc/nginx/service-url.inc // service-url.inc 마운트
    command: &quot;/bin/sh -c 'while :; do sleep 6h &amp;amp; wait $${!}; nginx -s reload; done &amp;amp; nginx -g \&quot;daemon off;\&quot;'&quot;

  nginx-exporter:
    image: nginx/nginx-prometheus-exporter:latest
    command: [&quot;-nginx.scrape-uri&quot;,&quot;http://nginx:9001/stub_status&quot;]
    expose:
      - &quot;9113&quot;
    networks:
      - app-tier
    depends_on:
      - nginx

  certbot:
    image: certbot/certbot
    restart: unless-stopped
    container_name: certbot
    volumes:
      - ./certbot/conf:/etc/letsencrypt
      - ./certbot/www:/var/www/certbot
    depends_on:
      - nginx
    entrypoint: &quot;/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h &amp;amp; wait $${!}; done;'&quot;

networks:
  app-tier:
    external: true
    name: docker_app-tier&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-sourcepos=&quot;415:1-415:39;11178-11216&quot; data-ke-size=&quot;size16&quot;&gt;라우팅 대상을 담는 service-url.inc 파일을 만들었다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;417:1-421:4;11218-11292&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;nginx&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;map $host $service_url {
    default http://10.0.0.71:8080;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-sourcepos=&quot;423:1-423:62;11294-11355&quot; data-ke-size=&quot;size16&quot;&gt;nginx.conf에서는 이 파일을 import하여 $service_url 변수로 프록시하도록 구성하였다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;425:1-537:4;11357-15266&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;properties&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;user  nginx;
worker_processes auto;
pid /var/run/nginx.pid;

events { worker_connections 1024; }

http {
    include       mime.types;
    include       /etc/nginx/conf.d/*.conf;
    include       /etc/nginx/service-url.inc; // nginx/service-url.inc 임포트

    default_type  application/octet-stream;
    sendfile      on;
    keepalive_timeout 30;
    client_max_body_size 20M;

    log_format json_combined escape=json
    '{ &quot;time_local&quot;:&quot;$time_local&quot;,'
    '  &quot;remote_addr&quot;:&quot;$remote_addr&quot;,'
    '  &quot;request_method&quot;:&quot;$request_method&quot;,'
    '  &quot;request_uri&quot;:&quot;$request_uri&quot;,'
    '  &quot;status&quot;:$status,'
    '  &quot;body_bytes_sent&quot;:$body_bytes_sent,'
    '  &quot;request_time&quot;:$request_time,'
    '  &quot;upstream_response_time&quot;:&quot;$upstream_response_time&quot;,'
    '  &quot;http_referrer&quot;:&quot;$http_referer&quot;,'
    '  &quot;http_user_agent&quot;:&quot;$http_user_agent&quot;,'
    '  &quot;host&quot;:&quot;$host&quot; }';

    access_log /var/log/nginx/access.log json_combined;
    error_log  /var/log/nginx/error.log;

    server {
        listen 80;
        server_name dev.api.pickeat.io.kr 10.0.0.71;
        charset utf-8;

        location ^~ /.well-known/acme-challenge/ {
            allow all;
            root /var/www/certbot;
            default_type &quot;text/plain&quot;;
            try_files $uri =404;
        }
        location /metrics {
            allow 10.0.0.0/24;
            deny  all;

            proxy_pass http://nginx-exporter:9113/metrics;
            proxy_set_header Host              $host;
            proxy_set_header X-Real-IP         $remote_addr;
            proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
        }

        location / {
            return 301 https://$host$request_uri;
        }
    }

    server {
        listen 443 ssl;
        server_name dev.api.pickeat.io.kr 10.0.0.71;
        server_tokens off;

        ssl_certificate     /etc/letsencrypt/live/dev.api.pickeat.io.kr/fullchain.pem;
        ssl_certificate_key /etc/letsencrypt/live/dev.api.pickeat.io.kr/privkey.pem;

        include     /etc/letsencrypt/options-ssl-nginx.conf;
        ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

        location = /healthz {
            add_header Content-Type text/plain;
            return 200 &quot;ok\n&quot;;
        }

        location /api/ {
            proxy_pass $service_url; // service-url.inc에 정의된 경로로 라우팅
            proxy_set_header Host              $host;
            proxy_set_header X-Real-IP         $remote_addr;
            proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Host  $host;
            proxy_set_header X-Forwarded-Port  $server_port;

            proxy_connect_timeout 10s;
            proxy_send_timeout    60s;
            proxy_read_timeout    60s;
            proxy_buffering on;
        }

        location /actuator/ {
            proxy_pass $service_url;  // service-url.inc에 정의된 경로로 라우팅
            proxy_set_header Host              $host;
            proxy_set_header X-Real-IP         $remote_addr;
            proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Host  $host;
            proxy_set_header X-Forwarded-Port  $server_port;
        }

        location ~ ^/(swagger|redoc|swagger-resources|swagger-ui\.html|webjars|v3|csrf) {
            proxy_pass $service_url;  // service-url.inc에 정의된 경로로 라우팅
            proxy_set_header Host              $host;
            proxy_set_header X-Real-IP         $remote_addr;
            proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_set_header X-Forwarded-Host  $host;
            proxy_set_header X-Forwarded-Port  $server_port;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-sourcepos=&quot;539:1-539:16;15268-15283&quot; data-ke-size=&quot;size23&quot;&gt;배포 스크립트 작성하기&lt;/h3&gt;
&lt;p data-sourcepos=&quot;541:1-541:180;15285-15464&quot; data-ke-size=&quot;size16&quot;&gt;배포 과정을 사람이 매번 수동으로 하기에는 실수의 여지가 많다고 판단하여, 전체 배포 과정을 쉘 스크립트로 자동화하였다. 스크립트는 현재 활성 포트를 확인하고, 유휴 컨테이너에 새 버전을 띄운 뒤, Health Check가 통과하면 Nginx를 리로드하여 트래픽을 전환하고, 마지막으로 구버전을 정리하는 순서로 동작한다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;543:1-752:4;15466-22259&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;clean&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;#!/bin/bash

################################################################################
# 블루/그린 무중단 배포 스크립트
# WAS EC2에서 실행 &amp;rarr; Nginx EC2의 컨테이너 제어
################################################################################

# 설정 변수 - 실제 환경에 맞게 수정하세요!
NGINX_EC2_IP=&quot;10.0.0.71&quot;                           # Nginx EC2 Private IP (예: 10.0.1.100)
WAS_EC2_IP=&quot;10.0.0.71&quot;                             # WAS EC2 Private IP (예: 10.0.2.100)
NGINX_CONTAINER=&quot;nginx&quot;                            # Nginx 컨테이너 이름
SERVICE_URL_FILE=&quot;/home/ubuntu/project/proxy/nginx/service-url.inc&quot;   # Nginx EC2의 파일 경로
APPLICATION_DOCKER_COMPOSE_FILE=&quot;/home/ubuntu/project/application/docker-compose.yml&quot;   # 애플리케이션의 docker-compose.yml 파일 경로

# 색상 정의
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'

echo -e &quot;${BLUE}========================================${NC}&quot;
echo -e &quot;${BLUE}  블루/그린 무중단 배포 시작${NC}&quot;
echo -e &quot;${BLUE}  (Nginx 컨테이너 환경)${NC}&quot;
echo -e &quot;${BLUE}========================================${NC}&quot;
echo &quot;&quot;

################################################################################
# 1단계: 현재 활성 포트 확인
################################################################################
echo -e &quot;${YELLOW}[1/7] 현재 활성 포트 확인 중...${NC}&quot;

# 8080 포트 Health Check (WAS EC2 로컬)
BLUE_HEALTH=$(curl -s http://${WAS_EC2_IP}:8080/actuator/health 2&amp;gt;/dev/null | grep -o '&quot;status&quot;:&quot;UP&quot;')

if [ -n &quot;$BLUE_HEALTH&quot; ]; then
    CURRENT_PORT=8080
    CURRENT_PROFILE=&quot;blue&quot;
    IDLE_PORT=8081
    IDLE_PROFILE=&quot;green&quot;
    echo -e &quot;  ${GREEN}Blue(8080) 활성 확인${NC}&quot;
else
    CURRENT_PORT=8081
    CURRENT_PROFILE=&quot;green&quot;
    IDLE_PORT=8080
    IDLE_PROFILE=&quot;blue&quot;
    echo -e &quot;  ${GREEN}Green(8081) 활성 확인${NC}&quot;
fi

echo -e &quot;  현재 활성: ${BLUE}${CURRENT_PROFILE}(${CURRENT_PORT})${NC}&quot;
echo -e &quot;  배포 대상: ${GREEN}${IDLE_PROFILE}(${IDLE_PORT})${NC}&quot;
echo &quot;&quot;

################################################################################
# 2단계: 최신 Docker 이미지 Pull
################################################################################
echo -e &quot;${YELLOW}[2/7] 최신 Docker 이미지 다운로드...${NC}&quot;
docker pull pickeat/pickeat:latest

if [ $? -ne 0 ]; then
    echo -e &quot;${RED}Docker 이미지 다운로드 실패${NC}&quot;
    exit 1
fi
echo -e &quot;  ${GREEN}이미지 다운로드 완료${NC}&quot;
echo &quot;&quot;

################################################################################
# 3단계: 유휴(Idle) 컨테이너에 새 버전 배포
################################################################################
echo -e &quot;${YELLOW}[3/7] ${IDLE_PROFILE} 컨테이너 재시작...${NC}&quot;

# 기존 컨테이너 중지 및 제거
docker-compose -f ${APPLICATION_DOCKER_COMPOSE_FILE} stop backend-${IDLE_PROFILE}
docker-compose -f ${APPLICATION_DOCKER_COMPOSE_FILE} rm -f backend-${IDLE_PROFILE}

# 새 버전으로 컨테이너 시작
docker-compose -f ${APPLICATION_DOCKER_COMPOSE_FILE} up -d backend-${IDLE_PROFILE}

if [ $? -ne 0 ]; then
    echo -e &quot;${RED}컨테이너 시작 실패${NC}&quot;
    exit 1
fi
echo -e &quot;  ${GREEN}${IDLE_PROFILE} 컨테이너 시작 완료${NC}&quot;
echo &quot;&quot;

################################################################################
# 4단계: Health Check (새 버전 확인)
################################################################################
echo -e &quot;${YELLOW}[4/7] Health Check 진행 중...${NC}&quot;

MAX_RETRY=30
RETRY_INTERVAL=3

for i in $(seq 1 $MAX_RETRY); do
    echo -e &quot;  시도 ${i}/${MAX_RETRY}...&quot;

    HEALTH_STATUS=$(curl -s http://${WAS_EC2_IP}:${IDLE_PORT}/actuator/health 2&amp;gt;/dev/null | grep -o '&quot;status&quot;:&quot;UP&quot;')

    if [ -n &quot;$HEALTH_STATUS&quot; ]; then
        echo -e &quot;  ${GREEN}Health Check 성공! (${i}번째 시도)${NC}&quot;
        HEALTH_CHECK_SUCCESS=true
        break
    fi

    if [ $i -eq $MAX_RETRY ]; then
        echo -e &quot;${RED}Health Check 실패. 새 버전에 문제가 있습니다.${NC}&quot;
        echo -e &quot;${RED}  배포를 중단하고 롤백합니다.${NC}&quot;

        # 문제있는 컨테이너 중지
        docker-compose -f ${APPLICATION_DOCKER_COMPOSE_FILE} stop backend-${IDLE_PROFILE}
        exit 1
    fi

    sleep $RETRY_INTERVAL
done
echo &quot;&quot;

################################################################################
# 5단계: Nginx 포트 스위칭
################################################################################
echo -e &quot;${YELLOW}[5/7] Nginx 포트 스위칭 중...${NC}&quot;

# map 형식으로 파일 생성
cat &amp;gt; ${SERVICE_URL_FILE} &amp;lt;&amp;lt; EOF
map \$host \$service_url {
    default http://${WAS_EC2_IP}:${IDLE_PORT};
}
EOF

if [ $? -ne 0 ]; then
    echo -e &quot;${RED}service-url.inc 파일 수정 실패${NC}&quot;
    exit 1
fi

echo -e &quot; ${GREEN}service-url.inc 파일 수정 완료${NC}&quot;
echo -e &quot;   새 URL: http://${WAS_EC2_IP}:${IDLE_PORT}&quot;

# Nginx 컨테이너 설정 테스트
echo -e &quot; Nginx 설정 검증 중...&quot;
docker exec ${NGINX_CONTAINER} nginx -t

if [ $? -ne 0 ]; then
    echo -e &quot;${RED}Nginx 설정 테스트 실패${NC}&quot;
    # 원래 설정으로 복구
    cat &amp;gt; ${SERVICE_URL_FILE} &amp;lt;&amp;lt; EOF
map \$host \$service_url {
    default http://127.0.0.1:${CURRENT_PORT};
}
EOF
    exit 1
fi

echo -e &quot; ${GREEN}Nginx 설정 검증 완료${NC}&quot;

# Nginx 컨테이너 리로드
echo -e &quot; Nginx 리로드 중...&quot;
docker exec ${NGINX_CONTAINER} nginx -s reload

if [ $? -ne 0 ]; then
    echo -e &quot;${RED}Nginx 리로드 실패${NC}&quot;
    exit 1
fi

echo -e &quot; ${GREEN}Nginx 리로드 완료${NC}&quot;
echo -e &quot; ${GREEN}Nginx가 이제 ${IDLE_PORT} 포트로 트래픽 전달${NC}&quot;
echo &quot;&quot;

################################################################################
# 6단계: 구버전 컨테이너 정리
################################################################################
echo -e &quot;${YELLOW}[6/7] 구버전 컨테이너 정리...${NC}&quot;
echo -e &quot;  10초 대기 후 ${CURRENT_PROFILE} 컨테이너를 중지합니다...&quot;

sleep 10

# 이전 버전 컨테이너 중지 (삭제는 하지 않음 - 롤백 대비)
docker-compose -f ${APPLICATION_DOCKER_COMPOSE_FILE} stop backend-${CURRENT_PROFILE}

echo -e &quot;  ${GREEN}${CURRENT_PROFILE} 컨테이너 중지 완료${NC}&quot;
echo &quot;&quot;

################################################################################
# 7단계: 배포 완료 및 확인
################################################################################
echo -e &quot;${YELLOW}[7/7] 배포 상태 확인...${NC}&quot;

# WAS 로컬 Health Check
FINAL_CHECK=$(curl -s http://${WAS_EC2_IP}:${IDLE_PORT}/actuator/health 2&amp;gt;/dev/null)
echo -e &quot;  WAS Health Status: ${GREEN}${FINAL_CHECK}${NC}&quot;

# Nginx를 통한 접근 확인 (선택사항)
NGINX_CHECK=$(curl -s http://${NGINX_EC2_IP}/actuator/health 2&amp;gt;/dev/null)
if [ -n &quot;$NGINX_CHECK&quot; ]; then
    echo -e &quot;  Nginx 프록시 확인: ${GREEN}${NGINX_CHECK}${NC}&quot;
fi
echo &quot;&quot;

echo -e &quot;${BLUE}========================================${NC}&quot;
echo -e &quot;${GREEN}  배포 완료!${NC}&quot;
echo -e &quot;${BLUE}========================================${NC}&quot;
echo -e &quot;  이전 버전: ${CURRENT_PROFILE}(${CURRENT_PORT}) ${RED}[중지됨]${NC}&quot;
echo -e &quot;  현재 버전: ${IDLE_PROFILE}(${IDLE_PORT}) ${GREEN}[활성]${NC}&quot;
echo -e &quot;${BLUE}========================================${NC}&quot;

################################################################################
# 배포 로그 기록
################################################################################
echo &quot;[$(date '+%Y-%m-%d %H:%M:%S')] 배포 완료: ${CURRENT_PROFILE}(${CURRENT_PORT}) -&amp;gt; ${IDLE_PROFILE}(${IDLE_PORT})&quot; &amp;gt;&amp;gt; /home/ubuntu/project/deploy.log&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-sourcepos=&quot;754:1-754:18;22261-22278&quot; data-ke-size=&quot;size23&quot;&gt;개발 환경 워크플로우 수정&lt;/h3&gt;
&lt;p data-sourcepos=&quot;756:1-756:137;22280-22416&quot; data-ke-size=&quot;size16&quot;&gt;GitHub Actions의 CD 단계에서 위 배포 스크립트를 실행하도록 워크플로우를 수정하였다. CI 단계는 기존과 동일하게 JAR 빌드 후 Docker 이미지를 push하고, CD 단계에서 DB 백업 후 블루-그린 배포 스크립트를 실행한다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;758:1-853:4;22418-25161&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;http&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;name: Back-Dev Build and Deploy

on:
  push:
    branches: [ &quot;back/dev&quot; ]

jobs:
  ci:
    runs-on: ubuntu-24.04
    environment: dev
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.SUBMODULE_KEY }}
          submodules: recursive

      - name: Update Submodule
        run: git submodule update --remote

      - name: Generate short SHA
        id: meta
        run: echo &quot;short_sha=$(echo ${{ github.sha }} | cut -c1-7)&quot; &amp;gt;&amp;gt; $GITHUB_OUTPUT

      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: 21
          cache: gradle

      - name: Build JAR
        working-directory: ./backend
        run: ./gradlew clean build -x test --no-daemon --build-cache

      - name: Login Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and Push Docker Image
        uses: docker/build-push-action@v5
        with:
          context: ./backend
          platforms: linux/arm64
          push: true
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:latest
            ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:${{ steps.meta.outputs.short_sha }}
          build-args: |
            SPRING_ACTIVE_PROFILE=dev

  cd:
    needs: ci
    runs-on: [ self-hosted, dev ]
    environment: dev
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.SUBMODULE_KEY }}
          submodules: true

      - name: Copy Docker Compose
        run: |
          sudo cp ./backend/docker/docker-compose.dev.yml /home/ubuntu/project/application/docker-compose.yml

      # 개발 DB 데이터 백업
      - name: Backup DB Data
        run: |
          if [ -f &quot;/home/ubuntu/project/database_backup/backup_db.sh&quot; ]; then
            sudo chmod +x /home/ubuntu/project/database_backup/backup_db.sh
            echo &quot;Running backup script...&quot;
            sudo /home/ubuntu/project/database_backup/backup_db.sh
          else
            echo &quot;backup_db.sh not found, skipping backup&quot;
          fi
        continue-on-error: true

      # 블루-그린 배포 스크립트 실행
      - name: Run Blue-Green Deploy
        run: |
          if [ -f &quot;/home/ubuntu/project/deploy.sh&quot; ]; then
            echo &quot;Running deploy script...&quot;
            sudo chmod +x /home/ubuntu/project/deploy.sh
            sudo /home/ubuntu/project/deploy.sh
          else
            echo &quot;deploy.sh not found!&quot;
            exit 1
          fi&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-sourcepos=&quot;857:1-857:20;25168-25187&quot; data-ke-size=&quot;size26&quot;&gt;무중단 배포 구현 (운영 환경)&lt;/h2&gt;
&lt;h3 data-sourcepos=&quot;859:1-859:20;25189-25208&quot; data-ke-size=&quot;size23&quot;&gt;Blue-Green 배포 구조&lt;/h3&gt;
&lt;p data-sourcepos=&quot;861:1-861:125;25210-25334&quot; data-ke-size=&quot;size16&quot;&gt;운영 환경은 개발 환경과 구조가 조금 다르다. 앞서 설명했듯 운영 WAS는 80번 포트만 열려 있기 때문에, WAS EC2 내부에 Nginx를 두어 80번 포트로 들어온 트래픽을 8080/8081로 스위칭하도록 구성하였다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;863:1-872:4;25336-25507&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;[사용자]
   &amp;darr;
[Nginx EC2]
   &amp;darr;
[WAS EC2] (80번 포트만 열려 있음)
   └─ 내부 Nginx Container (80 포트)
          ├─ Blue Container (8080 포트)
          └─ Green Container (8081 포트)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-sourcepos=&quot;874:1-874:39;25509-25547&quot; data-ke-size=&quot;size23&quot;&gt;WAS EC2 작업 - Blue / Green 애플리케이션 구성&lt;/h3&gt;
&lt;p data-sourcepos=&quot;876:1-876:72;25549-25620&quot; data-ke-size=&quot;size16&quot;&gt;운영 환경의 애플리케이션 docker-compose.yml이다. 이미지 태그는 release, 프로파일은 prod를 사용하였다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;878:1-926:4;25622-26789&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;version: '3.8'
services:
  # Blue Was 컨테이너 정의
  backend-blue:
    image: pickeat/pickeat:release
    container_name: backend-blue
    restart: always
    ports:
      - &quot;8080:8080&quot; # 호스트의 8080으로 포트포워딩
    volumes:
      - ./data/springboot/logs/blue:/var/logs # /logs/blue 경로에 로그 저장
    environment:
      SPRING_ACTIVE_PROFILE: prod
    networks:
      - app-tier

  # Green Was 컨테이너 정의
  backend-green:
    image: pickeat/pickeat:release
    container_name: backend-green
    restart: always
    ports:
      - &quot;8081:8080&quot; # 호스트의 8081으로 포트포워딩
    volumes:
      - ./data/springboot/logs/green:/var/logs  # /logs/green 경로에 로그 저장
    environment:
      SPRING_ACTIVE_PROFILE: prod
    networks:
      - app-tier

  alloy:
    image: grafana/alloy:latest
    container_name: alloy
    ports:
      - &quot;12345:12345&quot;
    volumes:
      - ./data/springboot/logs/blue:/var/log/spring/blue   # blue was 로그파일 마운트
      - ./data/springboot/logs/green:/var/log/spring/green # green was 로그파일 마운트
      - ./data/alloy/config.alloy:/etc/alloy/config.alloy
    environment:
      - ALLOY_ENV=prod
    networks:
      - app-tier

networks:
  app-tier:
    driver: bridge&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-sourcepos=&quot;928:1-928:97;26791-26887&quot; data-ke-size=&quot;size16&quot;&gt;운영 환경의 Alloy 로그 설정이다. 개발 환경과 동일한 구조로 Blue/Green 로그를 deployment 라벨로 구분하되, tenant_id를 prod로 지정하였다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;930:1-983:4;26889-27906&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;river&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;nix&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;logging {
  level  = &quot;info&quot;
  format = &quot;logfmt&quot;
}

local.file_match &quot;spring_logs&quot; {
  path_targets = [
    { __path__ = &quot;/var/log/spring/blue/*.log&quot; },
    { __path__ = &quot;/var/log/spring/green/*.log&quot; },
  ]
}

loki.source.file &quot;spring_source&quot; {
  targets = local.file_match.spring_logs.targets
  forward_to = [loki.process.spring_labels.receiver]
}

loki.process &quot;spring_labels&quot; {
  forward_to = [loki.write.grafana_loki.receiver]

  stage.static_labels {
    values = {
      service = &quot;pickeat&quot;,
      env     = sys.env(&quot;ALLOY_ENV&quot;),
    }
  }

  stage.multiline {
    firstline = &quot;^\\[\\d{4}-\\d{2}-\\d{2}&quot;
    max_wait_time = &quot;3s&quot;
  }

  stage.regex {
    expression = &quot;^/var/log/spring/(?P&amp;lt;deployment&amp;gt;[^/]+)/&quot;
    source     = &quot;filename&quot;
  }

  stage.labels {
    values = {
      deployment = &quot;&quot;,
    }
  }
}

loki.write &quot;grafana_loki&quot; {
  endpoint {
    url = &quot;https://monitoring.pickeat.io.kr/loki/loki/api/v1/push&quot;
    tenant_id = &quot;pickeat-prod&quot;
    batch_wait = &quot;1s&quot;
    batch_size = &quot;1MB&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-sourcepos=&quot;985:1-985:39;27908-27946&quot; data-ke-size=&quot;size23&quot;&gt;WAS EC2 작업 - 포트 스위칭을 위한 내부 Nginx 구현&lt;/h3&gt;
&lt;p data-sourcepos=&quot;987:1-987:105;27948-28052&quot; data-ke-size=&quot;size16&quot;&gt;운영 EC2의 보안그룹(project-app)은 80, 443, 35 포트만 접근이 가능하다. 따라서 Blue WAS와 Green WAS 모두 80번 포트를 통해 접근할 수 있어야 했다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;989:1-989:179;28054-28232&quot; data-ke-size=&quot;size16&quot;&gt;앞선 트러블슈팅에서 정리했듯 iptables로 커널 레벨에서 처리하는 방법도 고려했지만, 학습 곡선이 높고 팀원 간 공유가 어렵다고 판단하였다. 결과적으로 WAS EC2 내부에 Nginx를 추가로 배치하여 포트 스위칭을 담당하게 하였다. 구현이 간단하고 리소스 소비가 적으며, 팀 단위 공유가 쉽다는 점이 결정적이었다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;991:1-991:22;28234-28255&quot; data-ke-size=&quot;size16&quot;&gt;내부 Nginx 컨테이너를 구성하였다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;993:1-1005:4;28257-28599&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;version: '3.8'
services:
  nginx:
    build: ./nginx
    container_name: nginx
    ports:
      - 80:80
    restart: always
    volumes:
      - ./nginx/service-url.inc:/etc/nginx/service-url.inc // service-url.inc 마운트
    command: &quot;/bin/sh -c 'while :; do sleep 6h &amp;amp; wait $${!}; nginx -s reload; done &amp;amp; nginx -g \&quot;daemon off;\&quot;'&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div data-sourcepos=&quot;1007:1-1011:4;28601-28684&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;dockerfile&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;dockerfile&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;FROM nginx:latest
COPY nginx.conf /etc/nginx/nginx.conf
EXPOSE 80&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-sourcepos=&quot;1013:1-1013:88;28686-28773&quot; data-ke-size=&quot;size16&quot;&gt;내부 Nginx의 nginx.conf이다. 개발 환경과 마찬가지로 service-url.inc를 import하여 $service_url로 프록시한다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;1015:1-1064:4;28775-30406&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;nginx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;properties&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;user nginx;
worker_processes auto;
pid       /var/run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    include       mime.types;
    include       /etc/nginx/service-url.inc; // nginx/service-url.inc 임포트
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  30;
    client_max_body_size 20M;

    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;

    server {
        listen 80;
        server_name 10.0.20.49;

        location /api/ {
                proxy_pass $service_url; // service-url.inc에 정의된 경로로 라우팅
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto $scheme;
        }

        location /actuator/prometheus {
                proxy_pass $service_url; // service-url.inc에 정의된 경로로 라우팅
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto $scheme;
        }

        location ~ ^/(swagger|redoc|swagger-resources|swagger-ui.html|webjars|v3|csrf) {
                proxy_pass $service_url; // service-url.inc에 정의된 경로로 라우팅
               proxy_set_header Host $host;
               proxy_set_header X-Real-IP $remote_addr;
               proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
               proxy_set_header X-Forwarded-Proto $scheme;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div data-sourcepos=&quot;1066:1-1070:4;30408-30483&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;nginx&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;nginx&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;map $host $service_url {
    default http://10.0.20.49:8080;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h2 data-sourcepos=&quot;1072:1-1072:37;30485-30521&quot; data-ke-size=&quot;size26&quot;&gt;WAS EC2 작업 - Blue / Green 배포 스크립트&lt;/h2&gt;
&lt;p data-sourcepos=&quot;1074:1-1074:93;30523-30615&quot; data-ke-size=&quot;size16&quot;&gt;운영 환경의 배포 스크립트이다. 전체 흐름은 개발 환경과 동일하며, 이미지 태그가 release라는 점과 IP&amp;middot;경로가 운영 환경에 맞게 설정되어 있다는 점이 다르다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;1076:1-1285:4;30617-37389&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;clean&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;#!/bin/bash

################################################################################
# 블루/그린 무중단 배포 스크립트
# WAS EC2에서 실행 &amp;rarr; Nginx EC2의 컨테이너 제어
################################################################################

# 설정 변수 - 실제 환경에 맞게 수정하세요!
NGINX_EC2_IP=&quot;10.0.20.49&quot;                           # Nginx EC2 Private IP (예: 10.0.1.100)
WAS_EC2_IP=&quot;10.0.20.49&quot;                             # WAS EC2 Private IP (예: 10.0.2.100)
NGINX_CONTAINER=&quot;nginx&quot;                            # Nginx 컨테이너 이름
SERVICE_URL_FILE=&quot;/home/ubuntu/proxy/nginx/service-url.inc&quot;   # Nginx EC2의 파일 경로
APPLICATION_DOCKER_COMPOSE_FILE=&quot;/home/ubuntu/application/docker-compose.yml&quot;   # 애플리케이션의 docker-compose.yml 파일 경로

# 색상 정의
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'

echo -e &quot;${BLUE}========================================${NC}&quot;
echo -e &quot;${BLUE}  블루/그린 무중단 배포 시작${NC}&quot;
echo -e &quot;${BLUE}  (Nginx 컨테이너 환경)${NC}&quot;
echo -e &quot;${BLUE}========================================${NC}&quot;
echo &quot;&quot;

################################################################################
# 1단계: 현재 활성 포트 확인
################################################################################
echo -e &quot;${YELLOW}[1/7] 현재 활성 포트 확인 중...${NC}&quot;

# 8080 포트 Health Check (WAS EC2 로컬)
BLUE_HEALTH=$(curl -s http://${WAS_EC2_IP}:8080/actuator/health 2&amp;gt;/dev/null | grep -o '&quot;status&quot;:&quot;UP&quot;')

if [ -n &quot;$BLUE_HEALTH&quot; ]; then
    CURRENT_PORT=8080
    CURRENT_PROFILE=&quot;blue&quot;
    IDLE_PORT=8081
    IDLE_PROFILE=&quot;green&quot;
    echo -e &quot;  ${GREEN}Blue(8080) 활성 확인${NC}&quot;
else
    CURRENT_PORT=8081
    CURRENT_PROFILE=&quot;green&quot;
    IDLE_PORT=8080
    IDLE_PROFILE=&quot;blue&quot;
    echo -e &quot;  ${GREEN}Green(8081) 활성 확인${NC}&quot;
fi

echo -e &quot;  현재 활성: ${BLUE}${CURRENT_PROFILE}(${CURRENT_PORT})${NC}&quot;
echo -e &quot;  배포 대상: ${GREEN}${IDLE_PROFILE}(${IDLE_PORT})${NC}&quot;
echo &quot;&quot;

################################################################################
# 2단계: 최신 Docker 이미지 Pull
################################################################################
echo -e &quot;${YELLOW}[2/7] 최신 Docker 이미지 다운로드...${NC}&quot;
docker pull pickeat/pickeat:release

if [ $? -ne 0 ]; then
    echo -e &quot;${RED}Docker 이미지 다운로드 실패${NC}&quot;
    exit 1
fi
echo -e &quot;  ${GREEN}이미지 다운로드 완료${NC}&quot;
echo &quot;&quot;

################################################################################
# 3단계: 유휴(Idle) 컨테이너에 새 버전 배포
################################################################################
echo -e &quot;${YELLOW}[3/7] ${IDLE_PROFILE} 컨테이너 재시작...${NC}&quot;

# 기존 컨테이너 중지 및 제거
docker-compose -f ${APPLICATION_DOCKER_COMPOSE_FILE} stop backend-${IDLE_PROFILE}
docker-compose -f ${APPLICATION_DOCKER_COMPOSE_FILE} rm -f backend-${IDLE_PROFILE}

# 새 버전으로 컨테이너 시작
docker-compose -f ${APPLICATION_DOCKER_COMPOSE_FILE} up -d backend-${IDLE_PROFILE}

if [ $? -ne 0 ]; then
    echo -e &quot;${RED}컨테이너 시작 실패${NC}&quot;
    exit 1
fi
echo -e &quot;  ${GREEN}${IDLE_PROFILE} 컨테이너 시작 완료${NC}&quot;
echo &quot;&quot;

################################################################################
# 4단계: Health Check (새 버전 확인)
################################################################################
echo -e &quot;${YELLOW}[4/7] Health Check 진행 중...${NC}&quot;

MAX_RETRY=30
RETRY_INTERVAL=3

for i in $(seq 1 $MAX_RETRY); do
    echo -e &quot;  시도 ${i}/${MAX_RETRY}...&quot;

    HEALTH_STATUS=$(curl -s http://${WAS_EC2_IP}:${IDLE_PORT}/actuator/health 2&amp;gt;/dev/null | grep -o '&quot;status&quot;:&quot;UP&quot;')

    if [ -n &quot;$HEALTH_STATUS&quot; ]; then
        echo -e &quot;  ${GREEN}Health Check 성공! (${i}번째 시도)${NC}&quot;
        HEALTH_CHECK_SUCCESS=true
        break
    fi

    if [ $i -eq $MAX_RETRY ]; then
        echo -e &quot;${RED}Health Check 실패. 새 버전에 문제가 있습니다.${NC}&quot;
        echo -e &quot;${RED}  배포를 중단하고 롤백합니다.${NC}&quot;

        # 문제있는 컨테이너 중지
        docker-compose -f ${APPLICATION_DOCKER_COMPOSE_FILE} stop backend-${IDLE_PROFILE}
        exit 1
    fi

    sleep $RETRY_INTERVAL
done
echo &quot;&quot;

################################################################################
# 5단계: Nginx 포트 스위칭
################################################################################
echo -e &quot;${YELLOW}[5/7] Nginx 포트 스위칭 중...${NC}&quot;

# map 형식으로 파일 생성
cat &amp;gt; ${SERVICE_URL_FILE} &amp;lt;&amp;lt; EOF
map \$host \$service_url {
    default http://${WAS_EC2_IP}:${IDLE_PORT};
}
EOF

if [ $? -ne 0 ]; then
    echo -e &quot;${RED}service-url.inc 파일 수정 실패${NC}&quot;
    exit 1
fi

echo -e &quot; ${GREEN}service-url.inc 파일 수정 완료${NC}&quot;
echo -e &quot;   새 URL: http://${WAS_EC2_IP}:${IDLE_PORT}&quot;

# Nginx 컨테이너 설정 테스트
echo -e &quot; Nginx 설정 검증 중...&quot;
docker exec ${NGINX_CONTAINER} nginx -t

if [ $? -ne 0 ]; then
    echo -e &quot;${RED}Nginx 설정 테스트 실패${NC}&quot;
    # 원래 설정으로 복구
    cat &amp;gt; ${SERVICE_URL_FILE} &amp;lt;&amp;lt; EOF
map \$host \$service_url {
    default http://127.0.0.1:${CURRENT_PORT};
}
EOF
    exit 1
fi

echo -e &quot; ${GREEN}Nginx 설정 검증 완료${NC}&quot;

# Nginx 컨테이너 리로드
echo -e &quot; Nginx 리로드 중...&quot;
docker exec ${NGINX_CONTAINER} nginx -s reload

if [ $? -ne 0 ]; then
    echo -e &quot;${RED}Nginx 리로드 실패${NC}&quot;
    exit 1
fi

echo -e &quot; ${GREEN}Nginx 리로드 완료${NC}&quot;
echo -e &quot; ${GREEN}Nginx가 이제 ${IDLE_PORT} 포트로 트래픽 전달${NC}&quot;
echo &quot;&quot;

################################################################################
# 6단계: 구버전 컨테이너 정리
################################################################################
echo -e &quot;${YELLOW}[6/7] 구버전 컨테이너 정리...${NC}&quot;
echo -e &quot;  10초 대기 후 ${CURRENT_PROFILE} 컨테이너를 중지합니다...&quot;

sleep 10

# 이전 버전 컨테이너 중지 (삭제는 하지 않음 - 롤백 대비)
docker-compose -f ${APPLICATION_DOCKER_COMPOSE_FILE} stop backend-${CURRENT_PROFILE}

echo -e &quot;  ${GREEN}${CURRENT_PROFILE} 컨테이너 중지 완료${NC}&quot;
echo &quot;&quot;

################################################################################
# 7단계: 배포 완료 및 확인
################################################################################
echo -e &quot;${YELLOW}[7/7] 배포 상태 확인...${NC}&quot;

# WAS 로컬 Health Check
FINAL_CHECK=$(curl -s http://${WAS_EC2_IP}:${IDLE_PORT}/actuator/health 2&amp;gt;/dev/null)
echo -e &quot;  WAS Health Status: ${GREEN}${FINAL_CHECK}${NC}&quot;

# Nginx를 통한 접근 확인 (선택사항)
NGINX_CHECK=$(curl -s http://${NGINX_EC2_IP}/actuator/health 2&amp;gt;/dev/null)
if [ -n &quot;$NGINX_CHECK&quot; ]; then
    echo -e &quot;  Nginx 프록시 확인: ${GREEN}${NGINX_CHECK}${NC}&quot;
fi
echo &quot;&quot;

echo -e &quot;${BLUE}========================================${NC}&quot;
echo -e &quot;${GREEN}  배포 완료!${NC}&quot;
echo -e &quot;${BLUE}========================================${NC}&quot;
echo -e &quot;  이전 버전: ${CURRENT_PROFILE}(${CURRENT_PORT}) ${RED}[중지됨]${NC}&quot;
echo -e &quot;  현재 버전: ${IDLE_PROFILE}(${IDLE_PORT}) ${GREEN}[활성]${NC}&quot;
echo -e &quot;${BLUE}========================================${NC}&quot;

################################################################################
# 배포 로그 기록
################################################################################
echo &quot;[$(date '+%Y-%m-%d %H:%M:%S')] 배포 완료: ${CURRENT_PROFILE}(${CURRENT_PORT}) -&amp;gt; ${IDLE_PROFILE}(${IDLE_PORT})&quot; &amp;gt;&amp;gt; /home/ubuntu/deploy.log&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-sourcepos=&quot;1287:1-1287:18;37391-37408&quot; data-ke-size=&quot;size23&quot;&gt;운영 환경 워크플로우 수정&lt;/h3&gt;
&lt;p data-sourcepos=&quot;1289:1-1289:96;37410-37505&quot; data-ke-size=&quot;size16&quot;&gt;운영 환경의 워크플로우이다. main 브랜치의 backend 경로 변경 시 트리거되며, release 태그로 이미지를 빌드하고 prod 러너에서 배포 스크립트를 실행한다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;1291:1-1370:4;37507-39527&quot;&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;http&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;name: Back-Prod Build and Deploy

on:
  push:
    branches: [ &quot;main&quot; ]
    paths:
      - 'backend/**'

jobs:
  ci:
    runs-on: ubuntu-24.04
    environment: dev
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.SUBMODULE_KEY }}
          submodules: recursive

      - name: Update Submodule
        run: git submodule update --remote

      - name: Set up JDK
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: 21
          cache: gradle

      - name: Build JAR
        working-directory: ./backend
        run: ./gradlew clean build -x test --no-daemon --build-cache

      - name: Login Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Setup Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build and Push Docker Image
        uses: docker/build-push-action@v5
        with:
          context: ./backend
          platforms: linux/arm64
          push: true
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/${{ secrets.DOCKER_REPOSITORY }}:release
          build-args: |
            SPRING_ACTIVE_PROFILE=prod

  cd:
    needs: ci
    runs-on: [ self-hosted, prod ]
    environment: dev
    steps:
      - name: Checkout
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.SUBMODULE_KEY }}
          submodules: true

      - name: Copy Docker Compose
        run: |
          sudo cp ./backend/docker/docker-compose.prod.yml /home/ubuntu/application/docker-compose.yml

      - name: Run Blue-Green Deploy
        run: |
          if [ -f &quot;/home/ubuntu/deploy.sh&quot; ]; then
            echo &quot;Running deploy script...&quot;
            sudo chmod +x /home/ubuntu/deploy.sh
            sudo /home/ubuntu/deploy.sh
          else
            echo &quot;deploy.sh not found!&quot;
            exit 1
          fi&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-sourcepos=&quot;1374:1-1374:12;39534-39545&quot; data-ke-size=&quot;size26&quot;&gt;무중단 배포 검증&lt;/h2&gt;
&lt;p data-sourcepos=&quot;1376:1-1376:108;39547-39654&quot; data-ke-size=&quot;size16&quot;&gt;무중단 배포를 구현했다면, 실제로 배포 중에 요청이 끊기지 않는지를 검증하는 것이 무엇보다 중요하다고 생각했다. 구현만 하고 검증하지 않으면 &quot;무중단&quot;이라는 이름을 붙일 근거가 없기 때문이다.&lt;/p&gt;
&lt;h3 data-sourcepos=&quot;1378:1-1378:16;39656-39671&quot; data-ke-size=&quot;size23&quot;&gt;요청 실패 구간 테스트&lt;/h3&gt;
&lt;p data-sourcepos=&quot;1380:1-1380:100;39673-39772&quot; data-ke-size=&quot;size16&quot;&gt;배포가 진행되는 동안 0.5초 간격으로 API 요청을 지속적으로 전송하였다. 그 결과 모든 요청이 성공적으로 처리되어, 요청이 실패하는 구간이 존재하지 않음을 확인할 수 있었다.&lt;/p&gt;
&lt;h3 data-sourcepos=&quot;1382:1-1382:28;39774-39801&quot; data-ke-size=&quot;size23&quot;&gt;트래픽 전환 검증 (Blue &amp;rarr; Green)&lt;/h3&gt;
&lt;p data-sourcepos=&quot;1384:1-1384:89;39803-39891&quot; data-ke-size=&quot;size16&quot;&gt;무중단 배포 중 20초 동안 발생한 요청 로그를 분석한 결과, 트래픽이 Blue WAS에서 Green WAS로 점진적으로 자연스럽게 전환되는 것을 확인하였다.&lt;/p&gt;
&lt;div data-sourcepos=&quot;1386:1-1390:42;39893-40052&quot;&gt;구간상태설명
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;초기&lt;/td&gt;
&lt;td&gt;Blue WAS 처리&lt;/td&gt;
&lt;td&gt;기존 트래픽 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;td&gt;Blue &amp;amp; Green 혼재&lt;/td&gt;
&lt;td&gt;Nginx 리로드 및 연결 전환 진행&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;완료&lt;/td&gt;
&lt;td&gt;Green WAS 처리&lt;/td&gt;
&lt;td&gt;신규 버전으로 트래픽 완전 전환&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-sourcepos=&quot;1392:1-1392:82;40054-40135&quot; data-ke-size=&quot;size16&quot;&gt;Nginx가 리로드되는 순간에도 기존 연결은 유지되고 새 연결부터 Green으로 향하기 때문에, 급격한 전환이 아니라 부드러운 전환이 이루어졌다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-sourcepos=&quot;1396:1-1396:5;40142-40146&quot; data-ke-size=&quot;size26&quot;&gt;회고&lt;/h2&gt;
&lt;h3 data-sourcepos=&quot;1398:1-1398:20;40148-40167&quot; data-ke-size=&quot;size23&quot;&gt;전략은 제약에서 출발해야 한다&lt;/h3&gt;
&lt;p data-sourcepos=&quot;1400:1-1400:115;40169-40283&quot; data-ke-size=&quot;size16&quot;&gt;무중단 배포 전략을 고를 때, 처음에는 인스턴스 스위칭이 안정성과 확장성 측면에서 가장 좋아 보였다. 하지만 우리의 제약(단일 서버, 추가 비용 불가)을 놓고 보면 포트 스위칭이 훨씬 합리적인 선택이었다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;1402:1-1402:183;40285-40467&quot; data-ke-size=&quot;size16&quot;&gt;&quot;가장 좋은 전략&quot;이 아니라 &quot;우리 상황에 가장 맞는 전략&quot;을 골라야 한다는 것을 이번 과정을 통해 다시 느꼈다. 특히 WAS의 80번 포트만 열려 있다는 인프라 제약을 뒤늦게 발견하면서, 우리가 검토했던 여러 방법의 전제가 무너지는 경험을 했다. 제약 조건을 처음부터 정확히 파악하는 것이 얼마나 중요한지 체감할 수 있었다.&lt;/p&gt;
&lt;h3 data-sourcepos=&quot;1404:1-1404:28;40469-40496&quot; data-ke-size=&quot;size23&quot;&gt;팀이 함께 유지보수할 수 있는 단순함의 가치&lt;/h3&gt;
&lt;p data-sourcepos=&quot;1406:1-1406:132;40498-40629&quot; data-ke-size=&quot;size16&quot;&gt;iptables 방식이 성능상 가장 뛰어났지만, 우리는 내부 Nginx를 선택했다. 혼자서 빠르게 구현하는 것보다, 팀 전체가 이해하고 함께 유지보수할 수 있는 구조를 택하는 것이 프로젝트 전체의 효율에는 더 낫다고 판단했기 때문이다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;1408:1-1408:137;40631-40767&quot; data-ke-size=&quot;size16&quot;&gt;nginx -t로 사전에 검증할 수 있고 실수해도 복구가 쉽다는 점, 그리고 팀원 누구나 설정을 읽고 이해할 수 있다는 점은 약간의 성능 오버헤드와 충분히 맞바꿀 만한 가치였다. 성능만이 좋은 아키텍처의 유일한 기준은 아니라는 것을 배웠다.&lt;/p&gt;
&lt;h3 data-sourcepos=&quot;1410:1-1410:26;40769-40794&quot; data-ke-size=&quot;size23&quot;&gt;지금의 선택이 다음 단계의 발판이 되도록&lt;/h3&gt;
&lt;p data-sourcepos=&quot;1412:1-1412:129;40796-40924&quot; data-ke-size=&quot;size16&quot;&gt;포트 스위칭을 선택하면서, 이후 분산 환경으로 확장할 때 Rolling Update를 추가로 적용할 수 있다는 점을 염두에 두었다. 당장의 요구를 해결하는 동시에, 다음 단계로 나아갈 수 있는 여지를 남겨두는 선택을 하려고 했다.&lt;/p&gt;
&lt;p data-sourcepos=&quot;1414:1-1414:129;40926-41054&quot; data-ke-size=&quot;size16&quot;&gt;단일 서버 환경에서도 Nginx 포트 스위칭을 통해 트래픽 손실 없는 완전한 무중단 배포를 구현할 수 있었고, 배포 자동화를 GitHub Actions와 Shell Script로 구성하여 재현성과 유지보수성까지 확보할 수 있었다.&lt;/p&gt;</description>
      <category>프로그래밍/프로젝트</category>
      <author>supernovaMK</author>
      <guid isPermaLink="true">https://supernovamk.tistory.com/75</guid>
      <comments>https://supernovamk.tistory.com/75#entry75comment</comments>
      <pubDate>Wed, 1 Jul 2026 17:29:58 +0900</pubDate>
    </item>
    <item>
      <title>AI 에이전트로 테스트 커버리지 높이기</title>
      <link>https://supernovamk.tistory.com/74</link>
      <description>&lt;h1&gt;들어가며&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하다 보면 테스트 코드의 우선순위가 점점 뒤로 밀린다. 기능을 빨리 붙여야 하는 시기가 오면 &quot;일단 동작하게 만들고 테스트는 나중에&quot;가 되고, 그 나중은 잘 오지 않는다. 우리 프로젝트도 다르지 않았다. 시간이 지날수록 테스트 커버리지가 낮게 나오는 영역이 늘어났고, 정작 장애가 나면 곤란한 코드일수록 검증이 비어 있는 경우가 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 AI 에이전트에게 테스트 코드를 맡겨보기로 하였다. 그런데 그냥 &quot;테스트 코드 작성해줘&quot;라고 던지면 문제가 생긴다. 에이전트가 프로젝트의 맥락을 파악하지 못한 채, 그냥 통과만 되는 테스트를 무작정 찍어낸다. 커버리지 숫자는 올라가지만 실제로 의미 있는 검증은 아닌, 껍데기 테스트가 쌓이는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 그 문제를 어떻게 단계로 나누어 풀었는지에 대한 기록이다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JaCoCo 도입&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 한 일은 커버리지를 측정할 수단을 만드는 것이었다. 측정이 안 되면 어디가 비어 있는지 알 수 없고, 알 수 없으면 우선순위도 정할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JaCoCo를 Gradle 빌드에 연동하고 HTML/XML 리포트를 생성하도록 하였다. 다만 모든 클래스를 측정 대상으로 두지는 않았다. DTO, Config, 예외 클래스처럼 로직이 없는 코드까지 커버리지에 포함하면, 숫자가 왜곡되어 정작 중요한 영역의 신호가 묻힌다. 그래서 의미 없는 대상은 측정에서 제외하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1782996113501&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;jacocoTestReport {
reports {
html.required = true // 시각적인 HTML 리포트 생성
xml.required = true // XML 리포트 생성 (CI 도구 연동용)
csv.required = false // CSV는 보통 필요 없음
}
// DTO, Config, 예외 클래스 등은 측정에서 제외
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;XML 리포트를 따로 켠 이유가 있다. 사람은 HTML 리포트를 눈으로 보면 되지만, 에이전트가 &quot;어느 메서드가 비어 있는지&quot;를 정밀하게 파악하려면 기계가 읽을 수 있는 형식이 필요하다. 실제로 이후 분석 단계에서 XML을 파싱해 메서드 단위로 미커버 지점을 찾아냈다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;왜 마크다운을 여러 개로 나누었나&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기가 이번 작업의 핵심이다. 에이전트가 맥락 없이 테스트를 찍어내는 문제를 막으려면, 작업의 흐름 자체를 강제해야 한다고 보았다. 그래서 하나의 거대한 지시문 대신, 단계별로 마크다운을 나누었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 이렇다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;```&lt;br /&gt;프로젝트루트/&lt;br /&gt;├── CLAUDE.md&lt;br /&gt;└── docs/&lt;br /&gt;└── testing/&lt;br /&gt;├── 00-TEST_PLAN.md&lt;br /&gt;├── 01-WRITING_TESTS.md&lt;br /&gt;├── 02-QUALITY_CHECK.md&lt;br /&gt;└── 03-COVERAGE_ANALYSIS.md&lt;br /&gt;```&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CLAUDE.md &amp;mdash; 진입점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에이전트가 가장 먼저 읽는 문서다. 프로젝트 구조와 빌드 명령어, 그리고 &quot;테스트 작업이라면 어떤 문서를 어떤 순서로 읽어야 하는지&quot;를 안내한다. 길게 설명하지 않고, 나머지 문서로 가는 길만 알려주는 역할이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;00-TEST_PLAN.md &amp;mdash; 작업 시작 전 체크리스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 작성하기 전에 무엇을 확인해야 하는지를 담았다. 어떤 계층은 반드시 테스트하고(도메인, 서비스), 어떤 것은 테스트하지 않는지(단순 getter, DTO)를 명시했다. 무작정 다 테스트하는 것이 아니라, 테스트할 가치가 있는 곳을 가리도록 하는 문서다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;03-COVERAGE_ANALYSIS.md &amp;mdash; 분석과 승인 게이트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로 가장 중요하게 생각한 문서다. 에이전트가 커버리지 리포트를 분석해서 &quot;어디가 비어 있고, 그중 무엇이 위험한지&quot;를 보고하도록 했다. 그리고 여기에 승인 게이트를 두었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 에이전트는 분석 결과를 보고하는 것까지만 하고, **내가 승인하기 전까지는 테스트 코드를 한 줄도 작성하지 않는다.** 분석하는 턴과 작성하는 턴을 강제로 분리한 것이다. 이렇게 하면 에이전트가 엉뚱한 곳에 시간을 쏟거나, 통과만 되는 테스트를 쏟아내는 것을 막을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분석은 미커버 지점을 리스크 기준으로 나누는 방식으로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- A: 실제 위험이 큰 영역 (외부 API 연동, 인증, 전역 예외처리)&lt;br /&gt;- B: piece/scenario 골격은 있지만 연결되지 않아 비어 있는 컨트롤러 엔드포인트&lt;br /&gt;- C: 의도적으로 낮은 영역 (부하 테스트 코드, 단순 배선)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;01-WRITING_TESTS.md &amp;mdash; 작성 규칙&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;승인이 떨어지면 그때 읽는 문서다. 네이밍 규칙, Given-When-Then 구조, 계층별 전략, Mock과 Fake를 언제 쓰는지, 인수 테스트의 piece/scenario 패턴 등 우리 팀이 이미 쓰던 방식을 그대로 문서화했다. 에이전트가 자기 스타일로 짜는 것이 아니라, 기존 코드와 같은 결로 짜게 만드는 것이 목적이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;02-QUALITY_CHECK.md &amp;mdash; 커밋 전 검증&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마지막으로, 작성한 테스트를 커밋하기 전에 무엇을 확인해야 하는지를 담았다. 전체 테스트를 돌리고, 커버리지 리포트를 다시 생성하고, 컨벤션을 재점검하고, 무엇을 의도적으로 건너뛰었는지까지 보고하도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문서를 이렇게 나눈 이유를 한 문장으로 정리하면, **&quot;맥락 &amp;rarr; 분석 &amp;rarr; 승인 &amp;rarr; 작성 &amp;rarr; 검증&quot;이라는 사람의 작업 흐름을 에이전트에게 그대로 강제하기 위해서**다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;커버리지는 어떻게 변했나&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1142&quot; data-origin-height=&quot;753&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TWkzt/dJMcaiqjVp7/RuXNgN2LZo8KKBnFfSnCo1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TWkzt/dJMcaiqjVp7/RuXNgN2LZo8KKBnFfSnCo1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TWkzt/dJMcaiqjVp7/RuXNgN2LZo8KKBnFfSnCo1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTWkzt%2FdJMcaiqjVp7%2FRuXNgN2LZo8KKBnFfSnCo1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1142&quot; height=&quot;753&quot; data-origin-width=&quot;1142&quot; data-origin-height=&quot;753&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;637&quot; data-origin-height=&quot;374&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKdoxu/dJMcaiqjVp6/bzkZfsLvMkPBeo59SVkXoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKdoxu/dJMcaiqjVp6/bzkZfsLvMkPBeo59SVkXoK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKdoxu/dJMcaiqjVp6/bzkZfsLvMkPBeo59SVkXoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKdoxu%2FdJMcaiqjVp6%2FbzkZfsLvMkPBeo59SVkXoK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;637&quot; height=&quot;374&quot; data-origin-width=&quot;637&quot; data-origin-height=&quot;374&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1145&quot; data-origin-height=&quot;745&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bobaUx/dJMcafNY4Lr/WoFEaUzj3frgl9EGMMXDYk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bobaUx/dJMcafNY4Lr/WoFEaUzj3frgl9EGMMXDYk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bobaUx/dJMcafNY4Lr/WoFEaUzj3frgl9EGMMXDYk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbobaUx%2FdJMcafNY4Lr%2FWoFEaUzj3frgl9EGMMXDYk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1145&quot; height=&quot;745&quot; data-origin-width=&quot;1145&quot; data-origin-height=&quot;745&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 시점으로 나누어 측정했다. JaCoCo 도입 직후, A 그룹 보강 후, B 그룹 보강 후다.&lt;/p&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;지표&lt;/th&gt;
&lt;th&gt;도입 직후&lt;/th&gt;
&lt;th&gt;A 그룹 후&lt;/th&gt;
&lt;th&gt;B 그룹 후&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LINE&lt;/td&gt;
&lt;td&gt;76.5%&lt;/td&gt;
&lt;td&gt;92.0%&lt;/td&gt;
&lt;td&gt;95.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INSTRUCTION&lt;/td&gt;
&lt;td&gt;73.9%&lt;/td&gt;
&lt;td&gt;91.3%&lt;/td&gt;
&lt;td&gt;94.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;BRANCH&lt;/td&gt;
&lt;td&gt;50.0%&lt;/td&gt;
&lt;td&gt;77.0%&lt;/td&gt;
&lt;td&gt;78.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A 그룹에서는 전역 예외처리, 카카오 로그인, 외부 API 클라이언트처럼 장애 시 영향이 큰 영역을 채웠다. 특히 전역 예외처리 패키지는 1.3%에서 93.7%로, 카카오 로그인 인프라는 87%대까지 올랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B 그룹에서는 piece/scenario 골격은 있지만 한 번도 호출되지 않던 컨트롤러 엔드포인트를 시나리오에 연결했다. 그 결과 대부분의 컨트롤러 패키지가 100%에 도달했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업 과정에서 예상치 못한 수확도 있었다. 한 번도 호출된 적이 없어 드러나지 않았던, 위시 사진 업로드 헬퍼의 MIME 타입 누락 버그를 발견해 함께 고쳤다. 테스트를 채우는 과정이 곧 죽은 코드와 숨은 버그를 찾는 과정이기도 했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;마치며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 작업을 하면서 든 생각을 정리해 본다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 의도는 단순했다. 프로젝트가 진행될수록 테스트의 중요도가 밀려 커버리지가 낮아지는 문제를, 에이전트의 힘을 빌려 해결하고 싶었다. 그런데 막상 맡겨보니, 에이전트에게 그냥 &quot;테스트 짜줘&quot;라고 하는 것은 초보자에게 알아서 잘 해줘~와 같은 것과 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맥락이 없으니 통과만 되는 테스트가 나왔고, 그건 커버리지 숫자만 채우는 가짜 안전감이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 핵심은 도구가 아니라 **흐름을 설계하는 것**이었다. 분석과 작성을 분리하고, 그 사이에 사람의 승인을 넣는 것이라고 생각한다. 에이전트가 똑똑한지 아닌지보다, 에이전트가 따라올 수 있는 절차를 사람이 먼저 정의해 두었는지가 결과를 갈랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 하나 느낀 점은, 이렇게 문서로 흐름을 박아두면 그 효과가 일회성으로 끝나지 않는다는 것이다. 다음에 또 커버리지를 올릴 일이 생겨도 같은 문서를 따라가면 되고, 나뿐 아니라 다른 팀원이 에이전트를 쓸 때도 같은 결과를 기대할 수 있다. 한 번 잘 만들어 둔 절차가 계속 일하는 셈이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트는 결국 미루기 쉬운 일이다. 그렇다면 미루지 않게 만드는 장치를, 사람이 매번 의지력으로 버티는 대신 절차와 도구로 만들어 두는 편이 낫다고 생각한다. 이번에 만든 문서들이 그 작은 장치가 되기를 바란다.&lt;/p&gt;</description>
      <category>프로그래밍/프로젝트</category>
      <author>supernovaMK</author>
      <guid isPermaLink="true">https://supernovamk.tistory.com/74</guid>
      <comments>https://supernovamk.tistory.com/74#entry74comment</comments>
      <pubDate>Tue, 30 Jun 2026 13:45:13 +0900</pubDate>
    </item>
    <item>
      <title>다중 스레드 환경에서 조심해하는 것 정리</title>
      <link>https://supernovamk.tistory.com/73</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-path-to-node=&quot;2&quot; data-ke-size=&quot;size26&quot;&gt;  다중 스레드 환경의 핵심 정리&lt;/h2&gt;
&lt;h3 data-path-to-node=&quot;3&quot; data-ke-size=&quot;size23&quot;&gt;1. 공유 자원과 발생 가능한 문제&lt;/h3&gt;
&lt;p data-path-to-node=&quot;4&quot; data-ke-size=&quot;size16&quot;&gt;다중 스레드 환경에서 여러 스레드가 &lt;b data-index-in-node=&quot;20&quot; data-path-to-node=&quot;4&quot;&gt;힙(Heap) 영역의 인스턴스 변수&lt;/b&gt;나 &lt;b data-index-in-node=&quot;41&quot; data-path-to-node=&quot;4&quot;&gt;정적(Static) 변수&lt;/b&gt;에 동시에 접근할 때 다음 세 가지 문제가 발생합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;5&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5,0,0&quot;&gt;원자성(Atomicity):&lt;/b&gt; 연산이 도중에 끊겨 데이터가 소실되는 문제 (예: count++)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5,1,0&quot;&gt;가시성(Visibility):&lt;/b&gt; 한 스레드가 바꾼 값이 다른 스레드의 CPU 캐시에는 반영되지 않는 문제&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5,2,0&quot;&gt;순서성(Ordering):&lt;/b&gt; 성능 최적화를 위해 실행 순서가 바뀌어 결과가 달라지는 문제&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;6&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size23&quot;&gt;2. 동기화 솔루션 비교 (Java 기준)&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-path-to-node=&quot;8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;volatile&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;Atomic 클래스&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;synchronized&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,1,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,1,0,0&quot;&gt;핵심 기법&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,1,1,0&quot;&gt;메인 메모리 직접 접근&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,1,2,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,1,2,0&quot;&gt;CAS (Compare-And-Swap)&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,1,3,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,1,3,0&quot;&gt;Monitor Lock&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,2,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,2,0,0&quot;&gt;차단 여부&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,2,1,0&quot;&gt;Non-blocking&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,2,2,0&quot;&gt;Non-blocking&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,2,3,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,2,3,0&quot;&gt;Blocking (대기 발생)&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,3,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,3,0,0&quot;&gt;보장 범위&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,3,1,0&quot;&gt;가시성&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,3,2,0&quot;&gt;가시성 + 원자성&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,3,3,0&quot;&gt;가시성 + 원자성 + 순서성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,4,0,0&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,4,0,0&quot;&gt;적합한 사례&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,4,1,0&quot;&gt;단순 플래그 확인&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,4,2,0&quot;&gt;숫자 카운팅, 값 교체&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span data-path-to-node=&quot;8,4,3,0&quot;&gt;복합 로직, 여러 변수 보호&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;hr data-path-to-node=&quot;9&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;10&quot; data-ke-size=&quot;size23&quot;&gt;3. 다중 스레드용 주요 자료구조&lt;/h3&gt;
&lt;p data-path-to-node=&quot;11&quot; data-ke-size=&quot;size16&quot;&gt;단순 변수가 아닌 데이터를 묶음으로 관리할 때는 성능 최적화가 된 컬렉션을 사용해야 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,0,0&quot;&gt;ConcurrentHashMap:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12,0,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;맵 전체가 아닌 &lt;b data-index-in-node=&quot;9&quot; data-path-to-node=&quot;12,0,1,0,0&quot;&gt;버킷(Bucket) 단위로 락&lt;/b&gt;을 겁니다.&lt;/li&gt;
&lt;li&gt;빈 버킷 삽입 시에는 CAS를 사용하고, 데이터가 있을 때만 해당 노드를 synchronized로 잠급니다.&lt;/li&gt;
&lt;li&gt;조회(get) 시에는 락을 전혀 사용하지 않아 매우 빠릅니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;12,1,0&quot;&gt;CopyOnWriteArrayList:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;12,1,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;수정 시 배열 전체를 &lt;b data-index-in-node=&quot;12&quot; data-path-to-node=&quot;12,1,1,0,0&quot;&gt;새로 복사&lt;/b&gt;하여 교체합니다.&lt;/li&gt;
&lt;li&gt;수정은 가끔 발생하고 조회가 압도적으로 많은 환경(설정값, 리스너 목록 등)에 최적화되어 있습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;13&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;14&quot; data-ke-size=&quot;size23&quot;&gt;4. 운영체제(OS) 수준의 동기화와 데드락&lt;/h3&gt;
&lt;h4 data-path-to-node=&quot;15&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;15&quot;&gt;데드락 (Deadlock) 발생 조건&lt;/b&gt;&lt;/h4&gt;
&lt;p data-path-to-node=&quot;16&quot; data-ke-size=&quot;size16&quot;&gt;다음 4가지가 모두 충족되면 시스템이 멈춥니다. 하나라도 깨뜨리는 것이 해결책입니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;17&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,0,0&quot;&gt;상호 배제:&lt;/b&gt; 자원은 한 번에 하나만.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,1,0&quot;&gt;점유 및 대기:&lt;/b&gt; 자원을 가진 채로 다른 걸 기다림.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,2,0&quot;&gt;비선점:&lt;/b&gt; 남의 것을 뺏을 수 없음.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;17,3,0&quot;&gt;순순환 대기:&lt;/b&gt; 서로 꼬리에 꼬리를 물고 대기함.&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-path-to-node=&quot;18&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18&quot;&gt;동기화 도구들&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;19&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,0,0&quot;&gt;뮤텍스(Mutex):&lt;/b&gt; 열쇠를 가진 단 한 명만 진입 가능 (Locking).&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,1,0&quot;&gt;세마포어(Semaphore):&lt;/b&gt; 정해진 개수만큼의 스레드 진입 가능 (Signaling).&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,2,0&quot;&gt;모니터(Monitor):&lt;/b&gt; 프로그래밍 언어 차원의 고수준 동기화 (자바의 synchronized 기반).&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,3,0&quot;&gt;스핀락(Spinlock):&lt;/b&gt; 락을 얻을 때까지 CPU를 쓰면서 무한 루프로 대기.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;19,4,0&quot;&gt;어토믹(Atomic):&lt;/b&gt; 하드웨어 명령어를 이용한 중단 없는 최소 단위 연산.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-path-to-node=&quot;20&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-path-to-node=&quot;21&quot; data-ke-size=&quot;size23&quot;&gt;5. 실무 적용 가이드라인 (Best Practice)&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;22&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,0,0&quot;&gt;불변성(Immutability) 활용:&lt;/b&gt; 가능하면 변수의 값을 바꾸지 않도록 설계하여 동기화 비용 자체를 없앱니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,1,0&quot;&gt;최소 범위 잠금:&lt;/b&gt; synchronized를 쓸 때는 메서드 전체보다는 필요한 코드 블록만 최소한으로 감쌉니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,2,0&quot;&gt;락 프리(Lock-free) 선호:&lt;/b&gt; 성능이 중요하다면 synchronized보다는 Atomic이나 Concurrent 컬렉션을 우선 고려합니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;22,3,0&quot;&gt;로컬 변수 활용:&lt;/b&gt; 메서드 안의 지역 변수는 스레드마다 독립적인 스택에 쌓이므로 동기화 걱정 없이 자유롭게 사용하세요.&lt;/li&gt;
&lt;/ol&gt;</description>
      <author>supernovaMK</author>
      <guid isPermaLink="true">https://supernovamk.tistory.com/73</guid>
      <comments>https://supernovamk.tistory.com/73#entry73comment</comments>
      <pubDate>Thu, 7 May 2026 22:59:46 +0900</pubDate>
    </item>
  </channel>
</rss>