<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>FireDrago</title>
    <link>https://flowerdragon95.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Fri, 26 Jun 2026 23:35:37 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>화이용</managingEditor>
    <item>
      <title>[치즈픽] Redis vs 애플리케이션 비즈니스 로직은 어디에 둬야 할까?</title>
      <link>https://flowerdragon95.tistory.com/292</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하며 방송 제목 변경, 카테고리 변경과 같은 스트림 메타데이터 변경을 추적하는 기능을 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 Redis Lua 스크립트 내부에서 상태 비교와 변경 감지까지 모두 처리하도록 구성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 기능이 점점 추가되면서 &lt;b&gt;비즈니스 로직이 계속 쌓이기 시작&lt;/b&gt;했다.&lt;br /&gt;그 과정에서 자연스럽게 이런 고민이 들었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비즈니스 로직은 Redis가 담당하는 게 맞을까?&lt;br /&gt;Redis와 애플리케이션 중 어디까지 책임을 가져가야 할까?&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;이번 글에서는 &lt;b&gt;Redis Lua 스크립트에 비대해진 비즈니스 로직&lt;/b&gt;을 어떻게 바라봤는지,&lt;br /&gt;그리고 &lt;b&gt;Redis와 애플리케이션 사이에서 트레이드 오프&lt;/b&gt;를 고민하며 리팩토링했는지 정리해보려 한다.&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;문제 : 비대해진 Lua 스크립트&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;785&quot; data-origin-height=&quot;660&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/blS1yb/dJMcacpJ4yx/0rrcyqo9R6RKbZ7dCyafmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/blS1yb/dJMcacpJ4yx/0rrcyqo9R6RKbZ7dCyafmk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/blS1yb/dJMcacpJ4yx/0rrcyqo9R6RKbZ7dCyafmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FblS1yb%2FdJMcacpJ4yx%2F0rrcyqo9R6RKbZ7dCyafmk%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;606&quot; height=&quot;510&quot; data-origin-width=&quot;785&quot; data-origin-height=&quot;660&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- [AS-IS Lua 스크립트 중 일부] JSON 디코딩 및 상태 비교를 Redis에서 직접 수행
for i = count + 2, #ARGV, 2 do
    local stream_id = ARGV[i]
    local new_json_str = ARGV[i + 1]
    local old_json_str = redis.call('HGET', info_key, stream_id)

    if old_json_str then
        local old_data = cjson.decode(old_json_str) -- 여기서 JSON 파싱!
        local new_data = cjson.decode(new_json_str)

        -- 비즈니스 로직 (제목, 카테고리 변경 감지)
        if old_data.liveTitle ~= new_data.liveTitle or old_data.categoryName ~= new_data.categoryName then
            -- 변경분 저장 로직...
        end
    end
end&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lua 스크립트의 일부코드이다.&amp;nbsp;&lt;/p&gt;
&lt;p 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;b&gt;JSON 데이터 파싱&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;방송 시작/종료 감지&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;JSON 상태 비교를 통한 방송제목, 카테고리 변경 감지&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;변경된 방송 정보 업데이트&lt;/b&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;크게 4가지 작업을 한 스크립트 내에서 처리하고 있었다.&lt;br /&gt;방송 수집단에서 비지니스 로직이 추가될 경우 Lua 스크립트가 점점 비대해지는 구조였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;Redis(Lua) vs 애플리케이션 트레이드 오프&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1499&quot; data-origin-height=&quot;826&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cyu88Z/dJMcaiwHfbP/s4b0UdfwqL6kGlVcwRjnTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cyu88Z/dJMcaiwHfbP/s4b0UdfwqL6kGlVcwRjnTk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cyu88Z/dJMcaiwHfbP/s4b0UdfwqL6kGlVcwRjnTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcyu88Z%2FdJMcaiwHfbP%2Fs4b0UdfwqL6kGlVcwRjnTk%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;1499&quot; height=&quot;826&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1499&quot; data-origin-height=&quot;826&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트는 데이터가 단순하고(JSON 100개 + 채널 ID)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수집단 특성상 고동시성 환경도 아니라, Lua의 네트워크/원자성 이점이 크지 않다.&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;반면, 재고 차감, 포인트 적립처럼 원자성이 핵심이고 실행 시간이 짧은 도메인에서는 Lua가 여전히 유효할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;리팩토링&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;791&quot; data-origin-height=&quot;642&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OYCjZ/dJMcafmtB9v/NkPFZAFXKK0ZGgW4f3k1Z0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OYCjZ/dJMcafmtB9v/NkPFZAFXKK0ZGgW4f3k1Z0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OYCjZ/dJMcafmtB9v/NkPFZAFXKK0ZGgW4f3k1Z0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOYCjZ%2FdJMcafmtB9v%2FNkPFZAFXKK0ZGgW4f3k1Z0%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;632&quot; height=&quot;513&quot; data-origin-width=&quot;791&quot; data-origin-height=&quot;642&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1779862925486&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Component
public class StreamUpdateAnalyzer {
    
    public StreamUpdateResults analyze(List&amp;lt;StreamTarget&amp;gt; current, Set&amp;lt;String&amp;gt; activeIds, List&amp;lt;StreamTarget&amp;gt; old) {
        Set&amp;lt;StreamTarget&amp;gt; newStreams = filterNewStreams(current, activeIds);
        Set&amp;lt;String&amp;gt; closedStreamIds = filterClosedStreams(current, activeIds);
        Set&amp;lt;ChangedStream&amp;gt; changedStreams = detectChangedStreams(current, old);

        return new StreamUpdateResults(newStreams, closedStreamIds, changedStreams);
    }

    private Set&amp;lt;ChangedStream&amp;gt; detectChangedStreams(List&amp;lt;StreamTarget&amp;gt; current, List&amp;lt;StreamTarget&amp;gt; old) {
        // ... (중략) ... 
        // Java 객체 레벨에서 안전하게 상태 비교
        if (isMetadataChanged(oldTarget, newTarget)) {
            changedStreams.add(createChangedStream(oldTarget, newTarget));
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;lua 스크립트에서 실행하는 각종 비교 로직을 &lt;b&gt;도메인 서비스&lt;/b&gt;로 분리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`StreamUpdateAnalyzer` 컴포넌트가 기존 데이터와 새 데이터를 비교하여 신규, 종료, 변경된 방송을 분류한다.&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;pre id=&quot;code_1779863049558&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- [TO-BE Lua 스크립트] 비즈니스 로직 제거, 순수 상태 동기화만 수행

local closed_count = tonumber(ARGV[1])

-- 1. 종료된 방송 일괄 삭제
if closed_count &amp;gt; 0 then
  -- ... (closed_ids 파싱) ...
  redis.call('HDEL', KEYS[2], unpack(closed_ids))
  redis.call('SREM', KEYS[3], unpack(closed_ids))
end

-- 2. 활성 방송 상태 덮어쓰기
-- ... (active_ids, hash_data 파싱) ...
redis.call('DEL', KEYS[1])

if #active_ids &amp;gt; 0 then
    redis.call('SADD', KEYS[1], unpack(active_ids))
    redis.call('SADD', KEYS[3], unpack(active_ids))
end
if #hash_data &amp;gt; 0 then
    redis.call('HSET', KEYS[2], unpack(hash_data))
end

return {}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lua 스크립트는 단순히 넘어온 데이터를 삭제 (`HDEL`, `SREM`)하고 덮어쓰는 (`SADD`,`HSET`)역할만 수행하도록 줄였다.&lt;br /&gt;동시에 리포지토리 역시 단순 조회와 json 파싱으로 위 도메인 객체에 데이터를 넘겨주는 역할만 하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 애플리케이션에서 Redis의 기존 상태를 읽어오는 과정(multiGet)이 추가되어 &lt;b&gt;네트워크 I/O는 소폭 증가&lt;/b&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;&amp;nbsp;&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;추후 변경 사항이 생겨도 Java 코드만 수정하면 되니 &lt;b&gt;유지보수성은 비교할 수 없을 만큼 좋아졌다&lt;/b&gt;.&lt;/p&gt;</description>
      <category>프로젝트 &amp;amp; 트러블슈팅</category>
      <author>화이용</author>
      <guid isPermaLink="true">https://flowerdragon95.tistory.com/292</guid>
      <comments>https://flowerdragon95.tistory.com/292#entry292comment</comments>
      <pubDate>Wed, 27 May 2026 15:27:53 +0900</pubDate>
    </item>
    <item>
      <title>[기술면접] 2. 캐시 스탬피드(Cache Stampede)에 대해 설명해주세요. 해결방법은요?</title>
      <link>https://flowerdragon95.tistory.com/291</link>
      <description>&lt;div style=&quot;position: relative; background-color: #f9f9f9; padding: 10px 25px 10px 65px; margin: 0.5em 0; border: 1px solid #e3e3e3;&quot;&gt;
&lt;div style=&quot;position: absolute; top: -1px; left: 14px; width: 30px; height: 47px; background-color: #ef6c57;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;position: absolute; top: 17px; left: 14px; width: 0; height: 0; border: 15px solid; border-color: transparent transparent #f9f9f9 transparent;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;기술면접&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. &lt;a title=&quot;Young GC와 Full GC의 차이가 뭐죠?&quot; href=&quot;https://flowerdragon95.tistory.com/290&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Young GC와 Full GC의 차이가 뭐죠?&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. &lt;a title=&quot;캐시 스탬피드(Cache Stampede)에 대해 설명해주세요. 해결방법은요?&quot; href=&quot;https://flowerdragon95.tistory.com/291&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;캐시 스탬피드(Cache Stampede)에 대해 설명해주세요. 해결방법은요?&lt;/a&gt;&lt;/p&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;암기 보단 사고과정&lt;/h3&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;캐시 스탬피드에 대해서 설명해주세요&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; 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 style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;자 그럼 캐시 만료 시간을 1분으로 지정했다고 가정해보죠. &lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;캐시 만료 이후, 폭발적 요청이 와서 db 부하가 폭증하는 상황이 오면 어떻게 해결할 수 있을까요?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;처음 접하는 문제였지만, 생각해보니 일단 '캐시 만료와 동시에 몰리는 요청'이 핵심이라고 생각했다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;우선 모니터링을 통해 &lt;b&gt;적절한 캐시 만료 시간을 찾아 조정&lt;/b&gt;하는 방법이 떠올랐다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;그리고 여러 요청이 동시에 DB를 찌르는 것이 문제라면, &lt;b&gt;분산락&lt;/b&gt;을 도입해&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;하나의 요청만 DB에 접근해 캐시를 갱신하도록 통제할 수 있을 것이라 답변했다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;분산락을 도입하면 DB 부하는 줄일 수 있을것 같네요. 근데 저라면 이 상황에서 분산락 도입은 안할 것 같은데 어떻게 생각하세요?&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;분산락을 도입하면, 분명 DB 부하는 줄어들겠지만, 다른 요청이 DB요청을 대기할것이므로,&lt;br /&gt;캐시의 목적중 하나인 빠른 응답속도면에서 손해가 있을것 같다고 답변했다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;그리고&amp;nbsp;&lt;b&gt;2중 캐시 (Caffeine Cache + Redis)를 도입&lt;/b&gt;해서&lt;br /&gt;한쪽이 캐싱 만료 되어도, 다른 쪽이 캐싱해 줄 수 있을것 같다는 생각이 번쩍 떠올라서 답변했고, 이 질문은 마무리됐다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;집에 돌아와서 캐시스탬피드가 정확히 뭔지, 어떻게 해결할지 공부했다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;캐시 스탬피드 (Cache Stampede)&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;841&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rhPwI/dJMcagr0BwA/r3C9kDtY0RW1F3cZud5gFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rhPwI/dJMcagr0BwA/r3C9kDtY0RW1F3cZud5gFk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rhPwI/dJMcagr0BwA/r3C9kDtY0RW1F3cZud5gFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrhPwI%2FdJMcagr0BwA%2Fr3C9kDtY0RW1F3cZud5gFk%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;795&quot; height=&quot;435&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;841&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 이미지를 보면 쉽게 이해될것이다.&amp;nbsp;&lt;br /&gt;캐시 스탬피드 현상은 &lt;b&gt;캐시 만료로 인해, DB 과부하가 걸리는 현상&lt;/b&gt;을 말한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;[해결책 1] 분산락 (Distributed Lock)&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pC7xs/dJMb99TW2kh/VKdWuiKT5zF15SyJpE7GF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pC7xs/dJMb99TW2kh/VKdWuiKT5zF15SyJpE7GF0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pC7xs/dJMb99TW2kh/VKdWuiKT5zF15SyJpE7GF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpC7xs%2FdJMb99TW2kh%2FVKdWuiKT5zF15SyJpE7GF0%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;674&quot; height=&quot;449&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&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;여러 요청이 동시에 DB를 찌르는 것이 문제라면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 떠오르는 방법은 단 하나의 요청만 DB에 다녀오게 통제하자는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`Redis`의 `SETNX`, `Redison` 등을 활용해 분산락을 구현하면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락을 획득한 딱 하나의 스레드만 DB에서 데이터를 조회해 캐시를 갱신하도록 만들 수 있다.&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;size18&quot;&gt;&lt;b&gt;응답지연 및 스레드풀 고갈&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분명 DB 부하를 줄여 DB를 보호할 수는 있다. 하지만 반드시 고려해야 할 점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락을 얻지 못한 스레드들은 락이 풀릴 때까지 대기 상태(Blocking)에 빠지게 된다.&lt;/p&gt;
&lt;p 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;b&gt;응답 지연과 UX 저하&lt;/b&gt; : 캐시를 사용하는 가장 큰 목적은 빠른 응답 속도인데, 락 대기로 인해 사용자들의 로딩이 길어진다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스레드 풀 고갈&lt;/b&gt; : 대기하는 스레드들이 많아지면 애플리케이션의 스레드풀이 고갈 될 위험이 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 분산락은 DB 보호의 목적은 달성하지만,&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;[해결책 2] 지터 (무작위 지연시간)&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SuF1V/dJMcah5yCcv/KEslNzHJzUIkvwkcqXvt31/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SuF1V/dJMcah5yCcv/KEslNzHJzUIkvwkcqXvt31/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SuF1V/dJMcah5yCcv/KEslNzHJzUIkvwkcqXvt31/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSuF1V%2FdJMcah5yCcv%2FKEslNzHJzUIkvwkcqXvt31%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;703&quot; height=&quot;469&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;1024&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;b&gt;캐시가 동시에 만료되는 상황 자체를 막을 수는 없을까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 등장하는 개념이 &lt;b&gt;지터(Jitter)&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;지터는 &lt;b&gt;캐시 만료 시간에 무작위 지연시간을 더해주는 기법&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를들어 모든 데이터의 TTL을 일괄적으로 딱 1분으로 설정하는 것이 아니라,&lt;br /&gt;`60초 + (0~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;동일한 시점에 생성된 여러 데이터들의 만료 시간이 제각각으로 분산된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대규모 배치 작업이나 이벤트 등으로 인해&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수많은 캐시 키가 동시에 만료되면서 DB에 부하가 집중되는 현상을 적은 비용으로 효과적으로 막을 수 있다.&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;size18&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;하지만 &lt;b&gt;단 하나의 핫키 (예: 메인 화면의 타임 특가 상품)가 만료되는 상황에서는 무용지물&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왜냐하면 만료 시간을 어떻게 분산시키든, 결국 그 핫 키 하나가 만료되는 찰나의 순간은 반드시 있고,&lt;br /&gt;그 순간 수천개의 요청이 동시에 DB로 쏟아지는 캐시 스탬피드는 동일하게 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;[해결책 3] 2중 캐시&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;931&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjIvHc/dJMcaaL4SeY/OMbRwXK5zKOhBZFoHEYMs0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjIvHc/dJMcaaL4SeY/OMbRwXK5zKOhBZFoHEYMs0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjIvHc/dJMcaaL4SeY/OMbRwXK5zKOhBZFoHEYMs0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjIvHc%2FdJMcaaL4SeY%2FOMbRwXK5zKOhBZFoHEYMs0%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;782&quot; height=&quot;474&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;931&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단일 핫 키가 만료되어 DB를 조회해 오는 그 짧은 공백.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지터로도 막을 수 없는 이 공백을 메우기 위해 &lt;b&gt;2중 캐시(Look-aside Layered Cache)&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;2중 캐시는 각 아키텍처의 장점을 결합한 구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션의 로컬 메모리를 사용하는 &lt;b&gt;1차 캐시(예: Caffeine Cache)&lt;/b&gt;와&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분산 환경에서 공유되는 &lt;b&gt;2차 캐시(예: Redis)&lt;/b&gt;를 계층적으로 배치한다.&lt;/p&gt;
&lt;p 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;b&gt;1차 로컬 캐시&lt;/b&gt;를 찌른다. 메모리 내부 조회이므로 네트워크 오버헤드 없이 가장 빠르다.&lt;/li&gt;
&lt;li&gt;만약 로컬 캐시에 데이터가 없다면(Miss), 그때 &lt;b&gt;2차 글로벌 캐시(Redis)&lt;/b&gt;를 조회한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;만약 Redis 마저 비었다면, 그제서야 &lt;b&gt;DB&lt;/b&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;로컬캐시의 만료시간을 Redis의 만료시간 보다 약간 짧거나 길게 설정해 두면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;데이터 정합성&lt;/b&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;Redis는 모든 서버가 공유하는 하나의 저장소이기에 데이터 정합성을 유지하기 쉽다.&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 관리자가 DB에서 상품 가격을 수정했다면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis의 데이터는 바로 무효화(Invalidation)할 수 있지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 대의 분산 서버가 각자 들고 있는 로컬 캐시 속 데이터까지 동시에 싱크를 맞추는 것은 매우 까다롭다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 `MQ(Message Queue)`나 `Redis Pub/Sub`을 활용해 전송하는 구조를 추가할 수도 있지만,&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;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;[해결책 4]&amp;nbsp; 논리적 만료와 백그라운드 갱신 (PER)&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;955&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bIDVkJ/dJMcacb4TCp/TTJfL93X4yxQSH14yNh8m1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bIDVkJ/dJMcacb4TCp/TTJfL93X4yxQSH14yNh8m1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bIDVkJ/dJMcacb4TCp/TTJfL93X4yxQSH14yNh8m1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbIDVkJ%2FdJMcacb4TCp%2FTTJfL93X4yxQSH14yNh8m1%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;1536&quot; height=&quot;955&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1536&quot; data-origin-height=&quot;955&quot;/&gt;&lt;/span&gt;&lt;/figure&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 data-index-in-node=&quot;57&quot; data-path-to-node=&quot;4,2&quot;&gt;물리적 만료 시간(Redis TTL)과 논리적 만료 시간(Logical TTL)을 분리해서 부여&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;핵심은 데이터가 캐시에서 완전히 삭제(물리적 만료)되어 스탬피드가 발생하기 전에,&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;&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;4,4&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;기존 데이터 즉시 반환&lt;/b&gt; : 사용자가 데이터를 요청했을 때, 캐시는 살아있지만 논리적 만료 시간이 지났음을 확인한다. 이때 스레드들을 락에 대기시키는 것이 아니라, &lt;b data-index-in-node=&quot;109&quot; data-path-to-node=&quot;4,4,0,0&quot;&gt;일단 기존의 데이터를 사용자에게 즉시 반환&lt;/b&gt;한다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;비동기 백그라운드 갱신&lt;/b&gt; : 기존 데이터를 반환함과 동시에, 뒤에서는 별도의 백그라운드 스레드를 띄워 비동기적으로 DB를 조회하고 캐시를 최신화한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;백그라운드 스탬피드 방지&lt;/b&gt; : 수많은 요청이 동시에 들어와 무수히 많은 백그라운드 스레드가 생성되는 것을 막기 위해, 갱신 스레드를 띄우기 직전에만 짧게 분산락(또는 로컬락)을 건다. 중요한 점은, &lt;b&gt;락 획득에 실패한 스레드들은 대기(Blocking)하는 것이 아니라 과거 데이터를 반환&lt;/b&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;size18&quot;&gt;&lt;b&gt;데이터 최신성 vs 서비스 가용성&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;백그라운드에서 갱신이 완료되기 전 아주 짧은 찰나 동안, 사용자들은 &lt;b data-index-in-node=&quot;62&quot; data-path-to-node=&quot;4,6&quot;&gt;최신이 아닌 과거의 데이터를 보게 된다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 대규모 B2C 서비스(예: 메인 화면의 타임 특가 상품 목록)에서는 과거의 데이터를 보여주는 것보다,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;락 대기나 DB 부하로 인해 서버가 멈춰서 모든 사용자가 무한 로딩을 겪는 것을 훨씬 큰 장애로 여긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 약간의 데이터 정합성을 양보하는 대신 &lt;b data-index-in-node=&quot;160&quot; data-path-to-node=&quot;4,7&quot;&gt;성능과 시스템 가용성&lt;/b&gt;을 챙긴 전략이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;아키텍처에 은탄환은 없다.&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;&amp;nbsp;&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;'DB 부하 방어', '사용자 응답 속도', '데이터 정합성 유지', '시스템 복잡도 관리'라는 여러 가치 사이에서,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;136&quot; data-path-to-node=&quot;4,11&quot;&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;단순히 새로운 기술 용어를 지식으로 암기하는 것을 넘어,&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;&amp;nbsp;&lt;/p&gt;</description>
      <category>인프라</category>
      <author>화이용</author>
      <guid isPermaLink="true">https://flowerdragon95.tistory.com/291</guid>
      <comments>https://flowerdragon95.tistory.com/291#entry291comment</comments>
      <pubDate>Mon, 18 May 2026 13:36:19 +0900</pubDate>
    </item>
    <item>
      <title>[기술면접] 1. Young GC와 Full GC의 차이가 뭐죠?</title>
      <link>https://flowerdragon95.tistory.com/290</link>
      <description>&lt;div style=&quot;position: relative; background-color: #f9f9f9; padding: 10px 25px 10px 65px; margin: 0.5em 0; border: 1px solid #e3e3e3;&quot;&gt;
&lt;div style=&quot;position: absolute; top: -1px; left: 14px; width: 30px; height: 47px; background-color: #ef6c57;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div style=&quot;position: absolute; top: 17px; left: 14px; width: 0; height: 0; border: 15px solid; border-color: transparent transparent #f9f9f9 transparent;&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;기술면접&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;1.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://flowerdragon95.tistory.com/290&quot;&gt;Young GC와 Full GC의 차이가 뭐죠?&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://flowerdragon95.tistory.com/291&quot;&gt;캐시 스탬피드(Cache Stampede)에 대해 설명해주세요. 해결방법은요?&lt;/a&gt;&lt;/p&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;최근 기술면접을 다녀왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때까지 기술면접에서 받았던 질문들을 공부하고 정리하는 시리즈를 포스팅해보려고 한다.&lt;br /&gt;첫번째 질문은 &quot;&lt;b&gt;Young GC와 Full GC의 차이&lt;/b&gt;&quot;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;GC(Garbage Collection)가 뭔데?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'자바의 특징'을 검색하면 바로 &lt;b&gt;자동 메모리 관리&lt;/b&gt;가 뜬다&lt;b&gt;. &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바가 처음 세상에 나올 당시 기존의 언어(c,c++)들은 개발자가 수동으로 메모리를 관리해야 했다.&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;메모리 누수&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;그래서 자바는 GC(Garbage Collector)를 도입하여&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;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;Young / Old Generation&amp;nbsp;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;887&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IuEru/dJMcabxrcPq/Dq81KLjKZUmkCOx841zdT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IuEru/dJMcabxrcPq/Dq81KLjKZUmkCOx841zdT0/img.png&quot; data-alt=&quot;힙 메모리 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IuEru/dJMcabxrcPq/Dq81KLjKZUmkCOx841zdT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIuEru%2FdJMcabxrcPq%2FDq81KLjKZUmkCOx841zdT0%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;882&quot; height=&quot;441&quot; data-origin-width=&quot;1774&quot; data-origin-height=&quot;887&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;힙 메모리 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바 메모리 구조는 &lt;b&gt;Young Generation&lt;/b&gt; 과 &lt;b&gt;Old Generation&lt;/b&gt;으로 구분되어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션을 실행하면 &lt;b&gt;끝까지 살아남는 무거운 객체&lt;/b&gt;(스프링의 @Service, @Controller 빈 등)와&lt;br /&gt;0.1초 만에 쓰임을 다하고 버려지는 &lt;b&gt;가벼운 객체&lt;/b&gt;(API 응답용 DTO, 임시 문자열 등)가 생성된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;만약 이들이 하나의 공간에 마구 뒤섞이게 된다면 어떻게 될까?&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&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;전체 남은 용량은 충분한데 연속된 빈 공간이 없어서 새로운 객체를 할당하지 못하는 메모리 단편화 문제가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;단일 메모리 구조의 한계 : STW(Stop-The-World)&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;그런데 전체 메모리를 하나의 통으로 쓰면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;청소를 할 때마다 수 GB에 달하는 거대한 메모리 전체를 스캔하고 위치를 조정해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;청소하는 동안 서버가 멈추는 시간(STW)이 기하급수적으로 늘어난다.&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-path-to-node=&quot;10&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;약한 세대 가설 (Weak Generational Hypothesis)&lt;/b&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&quot; data-ke-size=&quot;size16&quot;&gt;단일 메모리 구조의 위와 같은 한계와 함께 JVM 연구자들은 두가지 가설을 바탕으로 메모리 구조를 설계했다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;10&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 data-path-to-node=&quot;10&quot;&gt;&amp;nbsp;&lt;b&gt;대부분의 객체는 생성된 후 금방 참조가 사라져 쓸모없는 객체가 된다.&lt;/b&gt;&lt;/li&gt;
&lt;li data-path-to-node=&quot;10&quot;&gt;&lt;b&gt;오래된 객체가 젊은 객체를 참조하는 일은 드물다.&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;어차피 생성되는 객체 대부분이 쓸모없어 진다면, 이 객체들만 따로 모아서 한번에 밀어버리자!&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것이 메모리를 Young / Old 각각 구분하는 이유다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;Young Generation&amp;nbsp; 구조&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1061&quot; data-origin-height=&quot;594&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqMPU4/dJMcacpCm4u/L5HDgdUgcsHr0CNusV5GJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqMPU4/dJMcacpCm4u/L5HDgdUgcsHr0CNusV5GJK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqMPU4/dJMcacpCm4u/L5HDgdUgcsHr0CNusV5GJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqMPU4%2FdJMcacpCm4u%2FL5HDgdUgcsHr0CNusV5GJK%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;1061&quot; height=&quot;594&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1061&quot; data-origin-height=&quot;594&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;금방 죽을 객체들이 모이는 Young Generation은 어떻게 청소 효율을 극대화했을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Young 영역은 단순히 하나의 공간이 아니라, 다시 &lt;b data-index-in-node=&quot;88&quot; data-path-to-node=&quot;17&quot;&gt;3개의 구역&lt;/b&gt;으로 쪼개진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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;Eden&lt;/b&gt; : 최초로 생성된 객체가 할당되는 영역이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Survivor0&lt;/b&gt; / &lt;b&gt;Survivor1&lt;/b&gt; : GC후 살아남은 객체들이 이동하는 영역이다.&amp;nbsp;&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;size18&quot;&gt;&lt;b&gt;Young GC의 동작 원리&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Eden 구역이 꽉 차게 되면 Young GC(Minor GC)가 발동한다.&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;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Eden 구역과 Survivor(예 Survivor0) 구역에서 살아남은 객체를 골라낸다.&lt;/li&gt;
&lt;li&gt;반대 Survivor(Survivor1) 구역에 빈공간이 없도록 잘 압축하여 이동시킨다.&lt;/li&gt;
&lt;li&gt;Eden 구역과 Survivor0의 공간을 한 번에 싹 정리한다. (효율적)&lt;/li&gt;
&lt;/ol&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;b data-index-in-node=&quot;82&quot; data-path-to-node=&quot;22&quot;&gt;Survivor 구역이 반드시 2개여야 하는 이유&lt;/b&gt;도 바로 이 무한 핑퐁(대피소 이동)을 위해서이며,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘 중 하나는 무조건 100% 텅 빈 상태를 유지해야 한다.&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;size18&quot;&gt;&lt;b&gt;주의점: 조기 승급 (Premature Promotion)문제&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;살아남은 객체들은 Young GC를 겪을 때마다 나이(Age)를 1살씩 먹고,&lt;br /&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;특정 나이에 도달한&lt;/span&gt;&amp;nbsp;객체는 Old Generation으로 승급하게 된다. (Promotion)&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 data-index-in-node=&quot;7&quot; data-path-to-node=&quot;25&quot;&gt;Survivor 공간이 너무 좁다면&lt;/b&gt; 어떻게 될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나이를 덜 먹은 젊은 객체들이 갈 곳이 없어 억지로 Old 영역으로 승급하게 된다.&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 객체들이 Old 영역으로 쫓겨나는 순간 끔찍한 &lt;b&gt;Full GC를 유발하는 시한폭탄&lt;/b&gt;이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 애플리케이션의 특성에 맞게 &lt;b&gt;Eden과 Survivor의 공간 비율을 튜닝&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;Old Generation과 STW 문제&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;771&quot; data-origin-height=&quot;580&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bIzWso/dJMcadvcLA4/F7Gr8x6FptwevIaY2MtZjK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bIzWso/dJMcadvcLA4/F7Gr8x6FptwevIaY2MtZjK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bIzWso/dJMcadvcLA4/F7Gr8x6FptwevIaY2MtZjK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbIzWso%2FdJMcadvcLA4%2FF7Gr8x6FptwevIaY2MtZjK%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;882&quot; height=&quot;441&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;771&quot; data-origin-height=&quot;580&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Young 영역에서 끈질기게 살아남은 객체들과,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Survivor이 좁아 억지로 밀려난 조기 승급 객체들이 차곡차곡 쌓이다 보면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 거대한 &lt;b data-index-in-node=&quot;84&quot; data-path-to-node=&quot;4&quot;&gt;Old 영역마저 꽉 차게 되는 순간&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;이때 JVM은 &lt;b&gt;힙(Heap) 메모리 전체를 뒤집어엎는 대청소, Full GC&lt;/b&gt; (또는 Major GC)를 발동시킨다.&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;size18&quot;&gt;&lt;b&gt;Stop-The-World (STW)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안전하게 메모리를 청소하고 흩어진 객체들의 빈 공간을 압축(Compaction)하려면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;청소하는 동안 새로운 객체가 생성되거나 참조 위치가 바뀌면 안된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 JVM은 &lt;b data-index-in-node=&quot;98&quot; data-path-to-node=&quot;7&quot;&gt;GC를 수행하는 스레드를 제외한 애플리케이션의 모든 스레드를 일시 정지 &lt;/b&gt;시킨다&lt;b data-index-in-node=&quot;98&quot; data-path-to-node=&quot;7&quot;&gt;.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 &lt;b&gt;Stop-The-World(STW)&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;Youg GC도 물론 STW를 발생시킨다. 하지만 빠르게 끝나기 때문에 거의 체감하지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 덩치가 큰 Old 영역을 뒤지는 &lt;b&gt;Full GC&lt;/b&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;&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;size18&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;AWS에 돈을 더 주고 서버 메모리(RAM)를 확 늘려버리면 해결되지 않을까?&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;Full GC가 터질 때까지 걸리는 시간이 엄청나게 길어진다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하루에 10번 터지던 Full GC를 하루 1번으로 줄일 수 있다.&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;&lt;b data-index-in-node=&quot;122&quot; data-path-to-node=&quot;13,1,0&quot;&gt;STW 시간이 5초, 10초로 무지막지하게 늘어난다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 입장에서 하루 10번 0.5초씩 살짝 버벅이는 서버는 견딜 수 있지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;44&quot; data-path-to-node=&quot;14&quot;&gt;하루 1번 10초 동안 완전히 뻗어버리는 서버는 버틸 수 없다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 메모리를 무작정 늘리는 것(Scale-up)은 과거의 GC 방식에서는 오히려 더 큰 재앙을 불러왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;GC 튜닝과 최신 GC&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 &quot;Young GC와 Full GC의 차이가 무엇인가?&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;애플리케이션의 멈춤(STW) 현상을 어떻게 통제할 것인가?를 묻는 시스템 아키텍처 관점의 질문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위한 실무적인 GC 튜닝과 최적화의 방향성은 크게 두 가지로 나뉜다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 공간 튜닝: &quot;죽을 객체는 Young에서 확실히 죽게 만들어라&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 기본적이고 중요한 튜닝은 힙 내부의 &lt;b data-index-in-node=&quot;23&quot; data-path-to-node=&quot;7&quot;&gt;비율(공간)을 조절&lt;/b&gt;하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 살펴보았듯, 가장 치명적인 재앙은 Survivor이 좁아&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;금방 죽을 젊은 객체들이 Old 영역으로 밀려나는 조기 승급이다.&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;애플리케이션의 특성&lt;/b&gt;에 맞춰 힙(Heap) 메모리의 전체 크기뿐만 아니라,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Young과 Old 영역의 비율, 그리고 Eden과 Survivor 영역의 비율을 세밀하게 조절해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간으로 쏟아지는 웹소켓 채팅 메시지나 API 응답 DTO처럼 짧게 쓰이고 버려지는 임시 객체가 많은 시스템일수록,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Young 영역(특히 Survivor 공간)을 넉넉하게 확보하여 Full GC 발생 자체를 최대한 억제하는 것이 핵심이다.&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;size18&quot;&gt;&lt;b&gt;2. 알고리즘의 발전: G1GC와 ZGC&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메모리(RAM)를 크게 늘리면 STW 시간이 길어지는 딜레마는 어떻게 해결했을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현대 자바는 패러다임을 바꾸는 &lt;b data-index-in-node=&quot;79&quot; data-path-to-node=&quot;10&quot;&gt;새로운 GC 알고리즘&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;최신 알고리즘들은 거대한 old 영역을 한 번에 다 청소하려다 시간을 지체하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 &lt;b data-index-in-node=&quot;59&quot; data-path-to-node=&quot;11&quot;&gt;전체 메모리를 바둑판처럼 수많은 작은 구역(Region)으로 쪼갠다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 전체를 멈추고 스캔하는 대신, &quot;쓰레기가 가장 많이 쌓인 구역(Garbage First)부터 조금씩,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션 실행과 동시에 틈틈이 치우는 방식&quot;을 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;덕분에 수십, 수백 GB의 거대한 메모리를 사용하면서도 STW 시간을 1ms ~ 10ms 이하의 찰나의 순간으로 통제할 수 있다.&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;</description>
      <category>백엔드/Java</category>
      <author>화이용</author>
      <guid isPermaLink="true">https://flowerdragon95.tistory.com/290</guid>
      <comments>https://flowerdragon95.tistory.com/290#entry290comment</comments>
      <pubDate>Sat, 16 May 2026 13:14:05 +0900</pubDate>
    </item>
    <item>
      <title>[치즈픽] 실시간 채팅 분석 파이프라인 구축기 (Kafka, Redis TS)</title>
      <link>https://flowerdragon95.tistory.com/289</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://cheesepick.me/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;치즈픽 - Cheese-Pick&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776144259848&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;치즈픽 - Cheese-Pick&quot; data-og-description=&quot;&quot; data-og-host=&quot;cheesepick.me&quot; data-og-source-url=&quot;https://cheesepick.me/&quot; data-og-url=&quot;https://cheesepick.me/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://cheesepick.me/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://cheesepick.me/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;치즈픽 - Cheese-Pick&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;cheesepick.me&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-path-to-node=&quot;4,2&quot;&gt;&lt;span&gt;치지직의 상위 N개의 방송을 동적으로 인식하고, 자동으로 하이라이트 타임스탬프를 찍는 '치즈픽' 서비스를 운영 중이다. &lt;/span&gt;&lt;/span&gt;&lt;span data-path-to-node=&quot;4,3&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;p id=&quot;p-rc_c8d39b20605dcf14-22&quot; data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span data-path-to-node=&quot;5,0&quot;&gt;이전 &lt;a href=&quot;https://flowerdragon95.tistory.com/288&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;부하 테스트&lt;/a&gt;를 통해 가상 스레드를 적용하여 웹소켓 수집단의 I/O 병목을 개선했다. &lt;/span&gt;&lt;span data-path-to-node=&quot;5,1&quot;&gt;&lt;/span&gt;&lt;span data-path-to-node=&quot;5,2&quot;&gt;&lt;span&gt;하지만 트래픽을 지연 없이 수신하는 것과 &lt;br /&gt;이를 '분석하고 저장'하는 것은 별개의 문제다. &lt;/span&gt;&lt;/span&gt;&lt;span data-path-to-node=&quot;5,3&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span data-path-to-node=&quot;5,4&quot;&gt;&lt;/span&gt;&lt;span data-path-to-node=&quot;5,5&quot;&gt;&lt;span&gt;단일 애플리케이션 내에서 수천 건의 채팅 데이터를 지연 없이 버퍼링하고, DB I/O 부하를 최적화하여 안정적인 분석 파이프라인을 구축한 과정을 정리했다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;1. 강결합으로 인한 장애 전파 위험&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 구조에서는 웹소켓으로 채팅이 유입되면, 메인스레드가 채팅 수 집계와 DB 저장까지 동기적으로 처리했다. 이렇게 수집과 분석 로직의 강결합은 단점이 존재했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;특정 대형 방송에서 하이라이트 상황이 발생해 일시적으로 트래픽 스파이크가 발생할 경우, 분석 및 DB I/O 로직에서 병목이 생기면 웹소켓 수신 스레드 자체가 블로킹 된다. 이는 플랫폼 서버와의 통신 타임아웃으로 이어져, 장애와 무관한 타 소규모 방송들의 세션까지 끊어지는 연쇄 장애를 유발 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;2. 도메인 분리를 통한 모듈러 모놀리스 설계&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;999&quot; data-origin-height=&quot;401&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Kmsxb/dJMcafsHhk9/dscN1c4CZLqSsgXhdqCQhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Kmsxb/dJMcafsHhk9/dscN1c4CZLqSsgXhdqCQhk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Kmsxb/dJMcafsHhk9/dscN1c4CZLqSsgXhdqCQhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKmsxb%2FdJMcafsHhk9%2FdscN1c4CZLqSsgXhdqCQhk%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;999&quot; height=&quot;401&quot; data-origin-width=&quot;999&quot; data-origin-height=&quot;401&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;는 개발 속도는 빠르지만, 도메인 간의 경계가 모호해져 앞서 언급한 '장애 전파'에 취약하다. 그렇다고 1인 개발 프로젝트에서 &lt;b&gt;MSA&lt;/b&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;6&quot; data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 단일 배포 환경 내에서도 도메인 간의 결합도를 낮춘 &lt;b data-index-in-node=&quot;40&quot; data-path-to-node=&quot;6&quot;&gt;모듈러 모놀리스(Modular Monolith)&lt;/b&gt; 아키텍처를 채택했다. &lt;br /&gt;시스템을 수집(Ingestion)과 분석(Analysis) 계층으로 명확히 분리하고, 그 경계에 &lt;b data-index-in-node=&quot;143&quot; data-path-to-node=&quot;6&quot;&gt;Apache Kafka&lt;/b&gt;를 배치하여 논리적&amp;middot;물리적으로 분리했다. 이 구조를 통해 각 모듈은 자신의 책임에만 집중하게 되었으며, 향후 트래픽 증가 시 특정 도메인만 독립적인 서비스(MSA)로 분리해낼 수 있는 확장 가능성까지 확보했다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; 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;b&gt;웹소켓 수집 모듈 (Ingestion)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776150843102&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package io.slice.stream.engine.chat.infrastructure.kafka; // chat 도메인

@RequiredArgsConstructor
public class ChzzkChatCollector implements ChatMessageListener {

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

    @Override
    public void onMessage(ChatMessage message) {
        // 1. 수집 도메인(chat)의 역할만 수행: 채팅 유입 즉시 Kafka 버퍼로 전달
        // 2. 데이터 분석/저장을 기다리지 않고 즉시 스레드를 반납하여 I/O 블로킹 원천 차단
        kafkaTemplate.send(&quot;chzzk-chat-topic&quot;, message.streamId(), message);
        // .get() 이나 콜백 없음 -&amp;gt; 메서드 종료 -&amp;gt; 스레드 반납
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;`Chat` 도메인 패키지에 속한 `ChzzkChatCollector`는 웹소켓 채팅을 수신하는 즉시 중앙 Kafka 버퍼로 메시지를 밀어 넣고 스레드를 반납한다. 데이터의 저장이나 분석에는 관여하지 않아 I/O 블로킹으로 발생할 수 있는 문제가 없어진다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; 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;b&gt;화력 분석 모듈 (Analysis)&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776151186526&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package io.slice.stream.engine.analyzer.application; // analyzer 도메인

@Component
@RequiredArgsConstructor
public class ChatAnalysisKafkaConsumer {

    private final ChatAggregationService chatAggregationService;

    // 수집단과 격리된 상태에서 시스템 여력(Backpressure)에 맞춰 비동기 소비
    @KafkaListener(topics = &quot;chzzk-chat-topic&quot;, groupId = &quot;chat-analysis-group&quot;)
    public void consume(ChatMessage message, Acknowledgment ack) {
        // 분석 도메인(analyzer)의 역할 수행: 집계 파이프라인 호출
        chatAggregationService.aggregate(message);
        
        // 정상 처리 후 수동 커밋 (안전한 메시지 처리 보장)
        ack.acknowledge(); 
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;`analyzer` 도메인 패키지에 속한 `ChatAnalysisKafkaConsumer`는 수집단과 격리된 상태에서, 시스템의 데이터 처리 상태에 맞춰 비동기적으로 메시지를 가져와 분석 로직을 수행한다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;3. 로컬 메모리 집계와 Redis TimeSeries&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수집된 채팅 데이터를 RDBMS에 건건이 저장한다면 빈번한 디스크 I/O와 인덱스 업데이트로 인해 성능 저하가 발생한다. 특히 치즈픽의 하이라이트 알고리즘은 최근 2분간의 1초당 채팅 수와 같은 &lt;b&gt;시계열 범위 조회(Range Query)&lt;/b&gt;가 핵심이다. 이를 일반 DB의 `GROUP BY`로 처리하기엔 오버헤드가 크기 때문에, 인메모리 기반으로 시간 구간 쿼리에 특화된 `&lt;b data-index-in-node=&quot;209&quot; data-path-to-node=&quot;6&quot;&gt;Redis TimeSeries`&lt;/b&gt;를 도입했다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;더불어 쓰기 연산 자체를 최소화하기 위해 `Caffeine Cache` 기반의 로컬 압축 ➔ `Redis TimeSeries` 배치 저장 흐름을 구현했다.&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;1. Caffeine Cache를 활용한 로컬 압축&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776151563183&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class ChatAggregationService {

    private final ChatRoomAggregationRepository aggregationRepository;
    
    // Caffeine Cache를 활용한 1차 로컬 메모리 버퍼 (TTL 1분)
    private final Cache&amp;lt;String, AtomicLong&amp;gt; localCache = Caffeine.newBuilder()
            .expireAfterWrite(1, TimeUnit.MINUTES)
            .build();

    // ① 1차 집계: Kafka에서 꺼낸 채팅을 멀티스레드 환경에서 안전하게 +1 누적 (DB I/O 없음)
    public void aggregate(ChatMessage message) {
        localCache.get(message.streamId(), k -&amp;gt; new AtomicLong(0))
                  .incrementAndGet();
    }

    // ② 2차 집계: 3초 주기(@Scheduled)로 캐시 데이터를 모아서 Redis로 일괄 전송
    @Scheduled(fixedRateString = &quot;${highlight.engine.scheduler-interval-ms:3000}&quot;)
    public void saveAggregations() {
        List&amp;lt;ChatRoomAggregation&amp;gt; snapshots = extractAndClearCache();
        if (!snapshots.isEmpty()) {
            aggregationRepository.saveAll(snapshots);
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유입되는 채팅 메시지를 즉시 외부 DB로 보내지 않고, `Caffeine Cache`를 활용해 스트림별 카운트를 로컬 메모리에서 1차 집계한다. 멀티 스레드(Virtual Thread) 환경에서의 동시성 문제를 방지하기 위해 `AtomicLong`을 사용하여 락(Lock) 없이 안전하게 빈도수만 누적했다.&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;2. 3초 주기 스케줄링 및 Lua Script 원자적 저장&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776151748015&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Repository
@RequiredArgsConstructor
public class RedisChatRoomAggregationRepository implements ChatRoomAggregationRepository {

    private final StringRedisTemplate redisTemplate;
    private final RedisScript&amp;lt;Void&amp;gt; tsAddScript; // TS.ADD를 수행할 커스텀 Lua Script

    @Override
    public void saveAll(List&amp;lt;ChatRoomAggregation&amp;gt; aggregations) {
        // 수백 개의 방송 화력을 업데이트할 때 발생하는 N+1 네트워크 지연과 
        // 동시성 락(Lock) 경합을 막기 위해 Lua Script로 단일 트랜잭션 처리
        redisTemplate.execute(
            tsAddScript, 
            extractKeys(aggregations), 
            extractArgs(aggregations)
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에서 압축된 카운트는 스프링 스케줄러(`@Scheduled`)를 통해 &lt;b data-index-in-node=&quot;39&quot; data-path-to-node=&quot;13&quot;&gt;3초마다 한 번씩&lt;/b&gt; Redis로 일괄 전송된다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;수백 개 채널의 시계열 데이터(`TS.ADD`)를 한 번에 기록할 때 발생하는 네트워크 왕복 비용을 줄이고 스레드 간 경합을 방지하기 위해 &lt;b data-index-in-node=&quot;76&quot; data-path-to-node=&quot;14&quot;&gt;Lua Script&lt;/b&gt;를 활용했다. Redis의 &lt;b&gt;Lua Script&lt;/b&gt;는 단일 스레드 기반으로 실행되어 다른 명령어의 개입을 막아주므로, 데이터의 정합성 문제 없이 원자적으로 배치 작업을 처리할 수 있다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;14&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;4. 부하 테스트 지표를 통한 파이프라인 검증&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;5. 가 - 3000tps.png&quot; data-origin-width=&quot;1719&quot; data-origin-height=&quot;730&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0zq7W/dJMcaju5hFC/QTiICSIWEF4wa8fFT5jTA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0zq7W/dJMcaju5hFC/QTiICSIWEF4wa8fFT5jTA1/img.png&quot; data-alt=&quot;3,000 TPS 환경의 부하테스트 지표&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0zq7W/dJMcaju5hFC/QTiICSIWEF4wa8fFT5jTA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0zq7W%2FdJMcaju5hFC%2FQTiICSIWEF4wa8fFT5jTA1%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;1719&quot; height=&quot;730&quot; data-filename=&quot;5. 가 - 3000tps.png&quot; data-origin-width=&quot;1719&quot; data-origin-height=&quot;730&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;3,000 TPS 환경의 부하테스트 지표&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 80px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;지표&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;수치&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;비고&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;&lt;b&gt;Kafka 처리 지연&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;0 유지&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;분석 로직이 지연없이 채팅 분석중&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;&lt;b&gt;Redis 평균 명령 지연시간&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;약 2.5ms&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;부하를 안정적으로 지연없이 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;&lt;b&gt;Redis 초당 명령 처리 수&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;약 200TPS&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; text-align: center; height: 20px;&quot;&gt;유입(2,840TPS) 대비 93% 감축&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;b&gt;1. Kafka 처리 지연: 0 유지&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수집 모듈이 초당 약 2,840건의 채팅 메시지를 발행하는 상황에서도 분석 모듈의 소비량이 정확히 일치하며 Kafka Lag이 0으로 유지됨을 확인했다. 비동기 분석로직이 실시간 트래픽을 지연없이 소화하고 있다.&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;2. Redis 쓰기 부하 감소: 2,840TPS -&amp;gt; 200 TPS&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유입 트래픽 대비 실제 Redis에 가해지는 명령 수가 약 200건 수준으로 안정화 되었다. 로컬 메모리 집계를 통해 DB 네트워크 I/O 부하를 약 93% 압축해 내는 효과를 거뒀다.&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;3. Redis 평균 지연 시간(2.5ms)과 활성 스레드 최적화&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lua Script를 통한 최적화로 300개 방의 집계를 3초마다 일괄 갱신함에도 Redis 응답 속도는 2.5ms 내외로 평온했다. 결과적으로 JVM 활성 스레드를 단 48개만 유지하면서도 3,000TPS 규모의 부하를 안정적으로 처리 했다.&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 data-path-to-node=&quot;33,1&quot;&gt;&lt;b data-index-in-node=&quot;23&quot; data-path-to-node=&quot;33,1&quot;&gt;&lt;span&gt;Kafka를 경계로 도메인의 책임을 분리하고 배압을 제어하는 구조&lt;/span&gt;&lt;/b&gt;&lt;span&gt;를 고민한 것이 이번 설계의 가장 크게 배운 지점이었다. &lt;/span&gt;&lt;/span&gt;&lt;span data-path-to-node=&quot;33,2&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span data-path-to-node=&quot;33,3&quot;&gt;&lt;/span&gt;&lt;span data-path-to-node=&quot;33,4&quot;&gt;&lt;span&gt;이러한 설계 덕분에 향후 '채팅 감정 분석' 등 새로운 도메인 기능이 추가되더라도 수집 모듈의 수정 없이 독립적인 확장이 가능한 시스템 기반을 다질 수 있었다.&lt;/span&gt;&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;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트 &amp;amp; 트러블슈팅</category>
      <author>화이용</author>
      <guid isPermaLink="true">https://flowerdragon95.tistory.com/289</guid>
      <comments>https://flowerdragon95.tistory.com/289#entry289comment</comments>
      <pubDate>Tue, 14 Apr 2026 16:46:15 +0900</pubDate>
    </item>
    <item>
      <title>[치즈픽] 가상 스레드 도입기 (부하 테스트와 스레드 최적화)</title>
      <link>https://flowerdragon95.tistory.com/288</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://cheesepick.me/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;치즈픽 - Cheese-Pick&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776060006360&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;치즈픽 - Cheese-Pick&quot; data-og-description=&quot;&quot; data-og-host=&quot;cheesepick.me&quot; data-og-source-url=&quot;https://cheesepick.me/&quot; data-og-url=&quot;https://cheesepick.me/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://cheesepick.me/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://cheesepick.me/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;치즈픽 - Cheese-Pick&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;cheesepick.me&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&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-filename=&quot;스크린샷 2026-04-13 오후 4.46.30.png&quot; data-origin-width=&quot;1551&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cUcOQj/dJMcabcJuHF/LxHxvpeCC37747QliaGfV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cUcOQj/dJMcabcJuHF/LxHxvpeCC37747QliaGfV1/img.png&quot; data-alt=&quot;Engine 모듈의 흐름&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cUcOQj/dJMcabcJuHF/LxHxvpeCC37747QliaGfV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcUcOQj%2FdJMcabcJuHF%2FLxHxvpeCC37747QliaGfV1%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;1551&quot; height=&quot;506&quot; data-filename=&quot;스크린샷 2026-04-13 오후 4.46.30.png&quot; data-origin-width=&quot;1551&quot; data-origin-height=&quot;506&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Engine 모듈의 흐름&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;치지직의 상위 N개의 방송을 동적으로 인식하고, 자동으로 하이라이트 타임스탬프를 찍는 '치즈픽' 서비스를 운영 중이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;치즈픽의 핵심 엔진은 실시간으로 수백 개의 방송&lt;span&gt;&amp;nbsp;&lt;b&gt;채팅&amp;nbsp;&lt;/b&gt;&lt;/span&gt;&lt;b&gt;웹소켓에 연결&lt;/b&gt;하여 `Kafka`를 통해 수집과 분석을 비동기적으로 분리하고,&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;`Redis TimeSeries`를 활용해 시간 기반 채팅 데이터를 집계하여 하이라이트 분석하고, api 모듈에 전송하는 역할을 하고 있다. 전형적인 I/O 집약적인 작업이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;2코어 2GB램 (t4g.medium 과 유사)의 제한된 로컬 리소스 환경에서 스프링 기본 스레드 (200개)를 사용했다. 하지만 부하테스트를 진행하며, 이 구조의 한계를 발견하고 가상스레드를 적용한 과정을 정리했다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;1. 플랫폼 스레드의 한계와 병목&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;3. 플- 2000tps(200), 200스레드.png&quot; data-origin-width=&quot;1719&quot; data-origin-height=&quot;729&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/x4tNP/dJMcahKPsTb/2s368ht3O16XIvJb6VkARK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/x4tNP/dJMcahKPsTb/2s368ht3O16XIvJb6VkARK/img.png&quot; data-alt=&quot;200개 웹소켓 2000TPS (웹소켓 하나당 초당 10개의 채팅)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/x4tNP/dJMcahKPsTb/2s368ht3O16XIvJb6VkARK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fx4tNP%2FdJMcahKPsTb%2F2s368ht3O16XIvJb6VkARK%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;1719&quot; height=&quot;729&quot; data-filename=&quot;3. 플- 2000tps(200), 200스레드.png&quot; data-origin-width=&quot;1719&quot; data-origin-height=&quot;729&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;200개 웹소켓 2000TPS (웹소켓 하나당 초당 10개의 채팅)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 100px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 20px; text-align: center;&quot;&gt;측정 항목&lt;/td&gt;
&lt;td style=&quot;width: 37.6356%; height: 20px; text-align: center;&quot;&gt;플랫폼 스레드 (2000TPS 유입 시)&lt;/td&gt;
&lt;td style=&quot;width: 34.1473%; height: 20px; text-align: center;&quot;&gt;비고&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;CPU 사용률&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.6356%; height: 20px; text-align: center;&quot;&gt;13.7%&lt;/td&gt;
&lt;td style=&quot;width: 34.1473%; height: 20px; text-align: center;&quot;&gt;CPU 자원은 여유 있음&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;활성 스레드 수&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.6356%; height: 20px; text-align: center;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;265&lt;/span&gt;개 (244개&amp;nbsp;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;대기&lt;/b&gt;&lt;/span&gt;)&lt;/td&gt;
&lt;td style=&quot;width: 34.1473%; height: 20px; text-align: center;&quot;&gt;스레드풀 고갈&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;채팅 큐잉 P95 지연 시간&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.6356%; height: 20px; text-align: center;&quot;&gt;1.2초 (1,200ms)&lt;/td&gt;
&lt;td style=&quot;width: 34.1473%; height: 20px; text-align: center;&quot;&gt;실시간 처리 지연 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.217%; text-align: center;&quot;&gt;&lt;b&gt;힙 메모리 사용량&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.6356%; text-align: center;&quot;&gt;최대 1.20 GB&lt;/td&gt;
&lt;td style=&quot;width: 34.1473%; text-align: center;&quot;&gt;최대 60% 수준 무난&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 28.217%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;모니터링 상태&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.6356%; height: 20px; text-align: center;&quot;&gt;블랙아웃 (400ms)&lt;/td&gt;
&lt;td style=&quot;width: 34.1473%; height: 20px; text-align: center;&quot;&gt;Prometheus 스크랩 요청 처리 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스레드 200개가 모두 웹소켓 수신과 kafka 전송 등 I/O 대기에 빠지면서 스레드 기아(Thread Starvation) 상태가 되었다. CPU 사용량은 높지 않은데도, 정작 일을 할 수 있는 스레드가 없어 prometheus 요청이 넘어오지 않아, 대시보드에 지표가 표시되지 않는 장애를 겪기도 했다.&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 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;2.&amp;nbsp; 왜 가상 스레드인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;I/O 병목을 해결하기 위한 가장 전통적인 방법은 비동기 논블로킹(Asynchronous Non-blocking) 방식을 사용하는 것이다. Spring 생태계에서는 Spring WebFlux가 대표적이다. 하지만 이번 프로젝트에서는 WebFlux 대신 Java25의 가상스레드를 선택했다. 그 이유는 두가지 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1. 러닝 커브와 패러다임 전환&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WebFlux를 도입하려면 `Mono`와 `Flux`라는 리액티브 프로그래밍 패러다임을 익혀야 한다. 또한, 로직 전반을 메서드 체이닝 형태로 작성해야 하므로 &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;size18&quot;&gt;&lt;b&gt;2. 기존 동기식 코드의 장점 유지&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면 가상 스레드는 기존의 동기식/블로킹 코드를 그대로 작성해도 된다. JVM이 알아서 I/O 대기(Blocking)가 발생할때 실제 OS 스레드를 다른 가상스레드에게 Unmount 하기 때문이다. 즉 &lt;b&gt;WebFlux 수준의 높은 처리량을 확보하면서도, 기존 MVC 방식의 쉬운 코드를 유지 할 수 있다&lt;/b&gt;는 것이 가상 스레드를 선택한 가장 큰 이유였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;3. 스레드 전환을 위한 리팩토링&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트의 신뢰성을 높이기 위해서는 &lt;b&gt;애플리케이션 코드를 수정하지 않고, 환경 설정만으로 플랫폼 스레드와 가상스레드를 전환&lt;/b&gt; 할 수 있어야 했다. 이를 위해 `application.yml` 설정값에 따라 스레드 풀(Executor)을 동적으로 주입 (DI)하도록 리팩토링을 진행했다.&lt;/p&gt;
&lt;pre id=&quot;code_1776073428318&quot; class=&quot;yaml&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// application-local.yml
spring:
  threads:
    virtual:
      enabled: true  # 플랫폼 스레드/ 가상 스레드 전환

app:
  thread:
    mode: virtual  # platform 또는 virtual 로 동적 전환&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;`spring:threads:virtual:enabled: true` : 스프링의 표준 인프라 설정이 가상스레드로 전환된다. Tomcat, @Async, @Scheduled 등이 가상스레드 기반으로 작동하도록 설정한다.&lt;br /&gt;`app:thread:mode: virtual` : 비지니스 로직 제어용 커스텀 설정이다. 수동으로 주입해서 사용하는 비지니스 로직 전용 설정이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776073741247&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Configuration
public class ThreadConfig {

    @Value(&quot;${app.thread.mode:platform}&quot;)
    private String threadMode;

    @Bean(name = &quot;customExecutor&quot;)
    public Executor customExecutor() {
        if (&quot;virtual&quot;.equalsIgnoreCase(threadMode)) {
            // 가상 스레드 풀 반환
            return Executors.newVirtualThreadPerTaskExecutor();
        } else {
            // 기존 플랫폼 스레드 풀 (Tomcat 기본 수준인 200개로 설정)
            ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
            executor.setCorePoolSize(200);
            executor.setMaxPoolSize(200);
            executor.setThreadNamePrefix(&quot;platform-exec-&quot;);
            executor.initialize();
            return executor;
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 분석 로직이나 웹소켓 Handler 등 I/O가 발생하는 로직에서 `@Qualifier(&quot;customExecutor&quot;)`를 주입받아 사용하게 함으로써, yaml 설정 한 줄만 바꾸면 즉각적인 가상 &amp;lt;-&amp;gt; 플랫폼 스레드의 전환이 가능한 구조가 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;4. 가상 스레드 도입 결과 : 3000TPS 처리량 달성&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;5. 가 - 3000tps.png&quot; data-origin-width=&quot;1719&quot; data-origin-height=&quot;730&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/m8UzE/dJMcaakCtPF/4pKLxHDjpu0V9sdfceyfY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m8UzE/dJMcaakCtPF/4pKLxHDjpu0V9sdfceyfY1/img.png&quot; data-alt=&quot;300개 웹소켓 3000TPS (웹소켓 하나당 초당 10개의 채팅)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m8UzE/dJMcaakCtPF/4pKLxHDjpu0V9sdfceyfY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm8UzE%2FdJMcaakCtPF%2F4pKLxHDjpu0V9sdfceyfY1%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;1719&quot; height=&quot;730&quot; data-filename=&quot;5. 가 - 3000tps.png&quot; data-origin-width=&quot;1719&quot; data-origin-height=&quot;730&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;300개 웹소켓 3000TPS (웹소켓 하나당 초당 10개의 채팅)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 140px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;측정 항목&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;플랫폼 스레드 (2,000 TPS)&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;가상 스레드 (3,000TPS)&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;비교 결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;처리량 (TPS)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;1,928TPS&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;2,840TPS&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;부하 1.5배 안정적 수용&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;활성 스레드 수&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;265개 (스레드 풀 고갈)&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;48개 (안정적 유지)&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;스레드 점유 80% 감소&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;채팅 큐잉 P95 지연 시간&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;1.2초 (1,200ms)&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;32ms&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;약 97% 지연 시간 단축&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;CPU 사용률&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;13.7%&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;17.7%&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;유사한 자원 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;힙 메모리 사용량&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;최대 1.2GB&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;최대 1.25GB&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;유사한 자원 사용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;&lt;b&gt;모니터링&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;블랙아웃 (스레드 풀 고갈)&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;문제없이 모니터링&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 20px; text-align: center;&quot;&gt;원활한 모니터링&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과는 만족스러웠다. 유입되는 트래픽은 1.5배가 늘어났음에도 불구하고, 채팅 수입부터 kafka에 큐잉 되기 까지의 P95 지연 시간은 &lt;b data-index-in-node=&quot;51&quot; data-path-to-node=&quot;24&quot;&gt;1.2초에서 32ms로 약 97% 감소&lt;/b&gt;했다. 플랫폼 스레드 환경에서는 200개가 넘는 스레드가 I/O 블로킹에 빠졌지만, 가상스레드 환경에서는 활성 스레드가 단 &lt;b&gt;48개&lt;/b&gt;만을 유지했다. I/O 블로킹이 발생할 때마다 가상 스레드가 캐리어 스레드를 효율적으로 반납하며 300개의 웹소켓 연결을 가볍게 처리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;5. 아키텍쳐로 부하 한계 극복하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트래픽이 몰려 장애가 발생하면 가장 먼저 서버 스케일업(Scale-up)을 고민하게 된다. 나 역시 초기에는 오라클 무료 티어(ARM 4코어 24GB)나 값비싼 AWS 인스턴스로 이사 가야 하나?라는 고민을 했다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;29&quot; data-ke-size=&quot;size16&quot;&gt;하지만 원인은 하드웨어가 아니라 소프트웨어의 I/O 블로킹에 있었다. 하드웨어 스펙은 단 1도 올리지 않고, 단지 Java 25의 가상 스레드로 스레드 모델만 변경했을 뿐인데 시스템의 한계를 2배 이상 끌어올릴 수 있었다.&lt;/p&gt;</description>
      <category>프로젝트 &amp;amp; 트러블슈팅</category>
      <author>화이용</author>
      <guid isPermaLink="true">https://flowerdragon95.tistory.com/288</guid>
      <comments>https://flowerdragon95.tistory.com/288#entry288comment</comments>
      <pubDate>Mon, 13 Apr 2026 18:58:47 +0900</pubDate>
    </item>
    <item>
      <title>[치즈픽] 하이브리드 아키텍쳐 도입기 (OCI + Home Server)</title>
      <link>https://flowerdragon95.tistory.com/287</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://cheesepick.me/&quot;&gt;https://cheesepick.me/&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;1162&quot; data-origin-height=&quot;626&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baJl6K/dJMcaaLoh24/yensEMg3GTF20N2wz9PfBk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baJl6K/dJMcaaLoh24/yensEMg3GTF20N2wz9PfBk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baJl6K/dJMcaaLoh24/yensEMg3GTF20N2wz9PfBk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaJl6K%2FdJMcaaLoh24%2FyensEMg3GTF20N2wz9PfBk%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;1162&quot; height=&quot;626&quot; data-origin-width=&quot;1162&quot; data-origin-height=&quot;626&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;치지직의 상위 N개의 방송을 동적으로 인식하고, 자동으로 하이라이트 타임스탬프를 찍는 '치즈픽' 서비스를 배포했다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;실시간 방송 채팅 데이터를 수집하고 분석하는 엔진 특성상 Kafka와 Redis를 구동하고 연산을 처리하기 위해 꽤 큰 메모리와 CPU 성능이 필요했다. AWS 에서 구동하기 위해서는 꽤 큰 비용이 예상됐다. 그래서 역할별로 모듈을 구분하고, 홈서버와 오라클 클라우드에 나누어 배치했다. 그 과정을 알아보자&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #555555; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;1. 인프라 역할 분리 (Cloud vs On Premise)&lt;/h3&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;ul style=&quot;list-style-type: disc;&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;10,0,0&quot;&gt;Engine (Home Server):&lt;/b&gt; 웹소켓 채팅 수집, Kafka 메시지 큐잉, 실시간 화력 분석 등 &lt;b data-index-in-node=&quot;59&quot; data-path-to-node=&quot;10,0,0&quot;&gt;메모리와 연산이 많이 필요한 무거운 작업&lt;/b&gt;은 로컬 홈 서버(On-Premise)에 배치하여 인프라 비용을 줄였다. 30초 마다 치지직 API를 호출하여 최신 방송 정보를 호출하고, 이를 기반으로 웹소켓을 연결하여 3초마다 채팅을 카프카에 큐잉한다. 이벤트 기반 구조로 분리된 분석단은 레디스를 이용하여, 채팅의 화력을 분석하고 하이라이트를 감지하여 3초마다 하트비트의 형식으로 API-Server에 Push 한다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;API-Server (Oracle Cloud):&lt;/b&gt; 분석이 끝난 데이터를 서빙하고, 유저 트래픽을 직접 받아내는 API 서버는 24시간 안정적으로 돌아가야 한다. 따라서 높은 가용성을 보장하는 OCI(Oracle Cloud Infrastructure) 무료 티어에 배치했다. Engine으로 부터 전송받은 실시간 방송 정보, 채팅 화력 수치, 하이라이트 여부를 DB에 저장하고 클라이언트에게 전달하는 역할을 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 모듈을 분리하여 &lt;b&gt;인프라 유지비용을 0&lt;/b&gt;으로 줄이면서도, &lt;b&gt;높은 트래픽과 리소스 성능&lt;/b&gt;을 확보할 수 있었다.&amp;nbsp;&lt;br /&gt;물론 집 노트북 전기세가 있긴하지만 저전력 12코어 CPU를 사용하고, 리눅스 설치, 배터리 최적화를 통해 클라우드에 비해 높은 리소스 성능을 활용할수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;2. 온 프레미스 환경의 보안 (Cloudflare Tunnel)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물리적으로 분리된망 (집 &amp;lt; -&amp;gt; 클라우드)에서 데이터를 안전하게 넘길지도 중요한 문제였다. ` 두가지 취약점이 있는데, &lt;b&gt;홈 서버의 네트워크 개방&lt;/b&gt;과 &lt;b&gt;데이터 전송 구간의 탈취&lt;/b&gt; 위험이다. 통신사 환경은 IP가 고정되지 않는 문제와 외부 노출이라는 보안 위험을 해결하기위해 인프라와 애플리케이션 구 가지 계층에서 보안을 구축했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;인바운드 통제 : Cloudflare 터널과 Zero Trust&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;1768&quot; data-origin-height=&quot;1102&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zuiTF/dJMcabjfHHa/f4J8V11BXJhr3ovT6KWk70/img.webp&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zuiTF/dJMcabjfHHa/f4J8V11BXJhr3ovT6KWk70/img.webp&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zuiTF/dJMcabjfHHa/f4J8V11BXJhr3ovT6KWk70/img.webp&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzuiTF%2FdJMcabjfHHa%2Ff4J8V11BXJhr3ovT6KWk70%2Fimg.webp&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;611&quot; height=&quot;381&quot; data-origin-width=&quot;1768&quot; data-origin-height=&quot;1102&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 외부에서 홈 서버에 접속하려면 공유기의 포트를 열고 기다리는 포트포워딩이 필수적이다. 하지만 전세계에 우리집 네트워크 문을 열어놓는 것과 같다. 이를 해결하기 위해 `Cloudflare Tunnel`을 도입했다.&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;이 기술의 핵심은 외부의 '인바운드'를 기다리는 대신, 홈 서버 내부에 설치된 데몬이 Cloudflare의 글로벌 엣지 네트워크를 향해 먼저 '아웃바운드' 터널을 뚫어 연결을 유지하는 것이다. 결과적으로 공유기의 포트를 하나도 열지 않아도 된다. 모든 외부 접근은 홈서버가 아닌 cloudflare 엣지 서버로 향하며, 유동 IP 문제와 물리적 IP 노출을 완벽하게 제거할 수 있다. 심지어 무료티어로 이 기능을 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;3. 망 분리 환경의&amp;nbsp; 데이터 전송 보안 (Engine -&amp;gt; Api-Server)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;아웃바운드 및 데이터 전송 방어: HTTPS와 커스텀 시크릿 헤더&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;홈 서버를 보호했다면, 다음은 `Engine`이 3초마다 분석 결과를 OCI의 `API-Server`로 전송하는 구간의 보안이다. 이 과정에서 보안 처리가 없으면, 허가받지 않은 제3의 사용자가 API 서버의 엔드포인트를 알아내어 가짜 분석 데이터를 전송할 위험이 있다. 이를 해결하기 위해 먼저 API 서버의 수신 엔드포인트 URL을 환경변수 (env)로 숨기고, 전송 구간 전체에 HTTPS를 적용했다. 중간에 누군가가 패킷을 가로채어 훔쳐 볼 수 없다록 했다.&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;다음으로 애플리케이션 계층에서 검증을 구축했다. `Engine`이 API 서버로 보낼 때, 사전에 서버 간에 약속된 '보안 헤더'를 패킷에 포함하도록 설계했다.&amp;nbsp; `API-Server`는 필터를 통해 이 헤더를 검사하고 고유한 헤더값이 없거나 일치하지 않는 외부의 요청은 비지니스 로직에 도달하기 전에 필터 단에서 차단하도록 했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1773991434281&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
    throws ServletException, IOException {
    String requestPath = request.getRequestURI();

    if (pathMatcher.match(signalPath + &quot;/**&quot;, requestPath) ||
        pathMatcher.match(syncPath + &quot;/**&quot;, requestPath)) {
        String requestToken = request.getHeader(headerName);

        if (requestToken == null || !requestToken.equals(expectedSecret)) {
            log.warn(&quot;유효하지 않은 토큰 접근 차단 : {}&quot;, requestPath);
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, &quot;권한없는 엔진 접근&quot;);
            return;
        }
    }
    filterChain.doFilter(request, response);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;4. 통합 모니터링 구축&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;일요일 100개.png&quot; data-origin-width=&quot;1522&quot; data-origin-height=&quot;1275&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ezOjNR/dJMcajhf3UV/Kr5kiAFv1KwbDvua4B2kTk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ezOjNR/dJMcajhf3UV/Kr5kiAFv1KwbDvua4B2kTk/img.png&quot; data-alt=&quot;Engine 모니터링&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ezOjNR/dJMcajhf3UV/Kr5kiAFv1KwbDvua4B2kTk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FezOjNR%2FdJMcajhf3UV%2FKr5kiAFv1KwbDvua4B2kTk%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;669&quot; height=&quot;560&quot; data-filename=&quot;일요일 100개.png&quot; data-origin-width=&quot;1522&quot; data-origin-height=&quot;1275&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Engine 모니터링&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;api-server 3:20 오전.png&quot; data-origin-width=&quot;1833&quot; data-origin-height=&quot;1288&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdaHVv/dJMcaiilhYl/CYzG6H3BvUKEhpzgm6gCMK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdaHVv/dJMcaiilhYl/CYzG6H3BvUKEhpzgm6gCMK/img.png&quot; data-alt=&quot;Api-Server 모니터링&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdaHVv/dJMcaiilhYl/CYzG6H3BvUKEhpzgm6gCMK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdaHVv%2FdJMcaiilhYl%2FCYzG6H3BvUKEhpzgm6gCMK%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;659&quot; height=&quot;463&quot; data-filename=&quot;api-server 3:20 오전.png&quot; data-origin-width=&quot;1833&quot; data-origin-height=&quot;1288&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Api-Server 모니터링&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인프라가 두 군데로 나뉘어 있다 보니 모니터링이 파편화되는 문제가 발생했다. 이를 해결하기 위해 OCI와 홈 서버 각각에 &lt;b data-index-in-node=&quot;70&quot; data-path-to-node=&quot;20&quot;&gt;Grafana Alloy&lt;/b&gt;를 수집기로 설치했다. 각 서버의 시스템 지표(CPU, RAM), JVM 지표(가상 스레드, 힙 메모리, HikariCP), 그리고 애플리케이션 에러 로그들을 모두 &lt;b data-index-in-node=&quot;79&quot; data-path-to-node=&quot;21&quot;&gt;Grafana Cloud&lt;/b&gt;로 던져 하나의 Grafana 환경에서 통합 관리 할 수 있도록 구축했다. 모니터링 DB를 직접 띄우지 않아 자원을 아낄 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;22&quot; data-ke-size=&quot;size23&quot;&gt;마무리하며&lt;/h3&gt;
&lt;p data-path-to-node=&quot;23&quot; 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;</description>
      <category>프로젝트 &amp;amp; 트러블슈팅</category>
      <author>화이용</author>
      <guid isPermaLink="true">https://flowerdragon95.tistory.com/287</guid>
      <comments>https://flowerdragon95.tistory.com/287#entry287comment</comments>
      <pubDate>Fri, 20 Mar 2026 16:30:17 +0900</pubDate>
    </item>
    <item>
      <title>[치즈픽] 치지직 채팅 화력분석 알고리즘 개선기</title>
      <link>https://flowerdragon95.tistory.com/286</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://cheesepick.me/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://cheesepick.me/&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1773908373493&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;치즈픽 - Cheese-Pick&quot; data-og-description=&quot;&quot; data-og-host=&quot;cheesepick.me&quot; data-og-source-url=&quot;https://cheesepick.me/&quot; data-og-url=&quot;https://cheesepick.me/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://cheesepick.me/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://cheesepick.me/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;치즈픽 - Cheese-Pick&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;cheesepick.me&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;치지직의 상위 N개의 방송을 동적으로 인식하고, 자동으로 하이라이트 타임스탬프를 찍는 '치즈픽' 서비스를 배포했다.&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;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;절대값으로 하이라이트 판정 (V1)&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 164px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 20%; height: 20px; text-align: center;&quot;&gt;스트리머&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 20px; text-align: center;&quot;&gt;측정시간 (UTC)&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 20px; text-align: center;&quot;&gt;오프셋&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 20px; text-align: center;&quot;&gt;초당 채팅&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 20px; text-align: center;&quot;&gt;판정결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;대형 스트리머 A&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;2026-02-15 16:40:21&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;24184334&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;17&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;대형 스트리머 A&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;2026-02-15 16:40:24&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;24187334&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;32&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;대형 스트리머 A&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;2026-02-15 16:40:27&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;24190334&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;16&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;대형 스트리머 A&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;2026-02-15 16:40:30&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;24193334&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;53&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;대형 스트리머 A&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;2026-02-15 16:40:33&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;24196334&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;45&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;대형 스트리머 A&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;2026-02-15 16:40:36&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;24199334&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;42&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;대형 스트리머 A&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;2026-02-15 16:40:39&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;24202334&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;37&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;대형 스트리머&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt; A&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;2026-02-15 16:40:42&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;24205334&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;44&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;스트리머&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;측정시간 (UTC)&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&amp;nbsp;평소 채팅&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;초당 채팅&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;판정결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;소형 스트리머 B&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;2026-02-15 16:39:39&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;6&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;NORMAL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;소형 스트리머 B&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;2026-02-15 18:42:42&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;NORMAL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;소형 스트리머 C&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;2026-02-15 23:49:00&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;NORMAL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: center;&quot;&gt;소형 스트리머 C&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;2026-02-15 23:50:00&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;11&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;NORMAL&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 알고리즘을 작성할때는 단순히&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;초당 15&lt;/b&gt;개가 넘으면 하이라이트로 인식하도록 단순하게 구현했다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;시험삼아 하루동안 분석엔진을 돌린결과,&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp; - 대형스트리머&lt;/b&gt;는 30초동안 8개가 넘는 하이라이트가 생성되어, &lt;b&gt;하이라이트가 중복 생성&lt;/b&gt;되고, &lt;b&gt;일상적인 소통 구간까지 오탐지&lt;/b&gt;되었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&amp;nbsp; - 소형스트리머&lt;/b&gt;는 평소 채팅이 1~2개인 방에서는&amp;nbsp;&lt;b&gt;11개의 채팅이 발생했음에도 하이라이트가 생성되지 않았다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오탐지&lt;/b&gt;와 &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 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;Z-Score의 도입 (V2)&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;p data-ke-size=&quot;size16&quot;&gt;실측 데이터를 바탕으로 해결방법을 찾았다. '표준편차'와 z-score를 도입하기로 결정했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Z-Score란&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Z-Score는 현재 값이 평균대비 어느정도 튀는 값인지를 나타내는 지표이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식은 &lt;span style=&quot;background-color: #dddddd;&quot;&gt;(현재 값 - 평균) / 표준편차&lt;/span&gt; 로 계산한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2분동안의 평균&lt;/b&gt;과 &lt;b&gt;표준편차&lt;/b&gt;를 계산하는 로직을 통해 &lt;b&gt;z-score 3.0 이상 (상위 0.13%)&lt;/b&gt;의 화력을 하이라이트로 탐지하도록 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;z-score의 기준값은 실제 통계값들을 분석하여 설정했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Z-Score의 맹점&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;Z-Score 3.0(상위 0.13%)이라는 통계적 기준을 도입하면 모든 문제가 해결될 줄 알았다. 하지만 실제 라이브 방송의 채팅 트래픽은 정규분포처럼 예쁘게 흘러가지 않았다. 하루 동안 V2 엔진을 모니터링한 결과, 전혀 예상치 못한 3가지 결함이 또 발견되었다.&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;size18&quot;&gt;&lt;b&gt;1. 기준선 붕괴&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span data-start-index=&quot;606&quot;&gt;Z-Score 공식은 &lt;/span&gt;&lt;span style=&quot;background-color: #dddddd;&quot;&gt;(현재 값 - 평균) / 표준편차&lt;/span&gt;&lt;span data-start-index=&quot;636&quot;&gt; 이다. 여기서 수학적 오류가 발생했다. 평소 채팅이 아예 없는 소규모 방송이나, 대형 스트리머의 시청자가 빠진 새벽 시간대에는 &lt;/span&gt;&lt;b data-start-index=&quot;713&quot;&gt;평균과 표준편차가 0에 한없이 수렴&lt;/b&gt;&lt;span data-start-index=&quot;732&quot;&gt;하게 된다. &lt;/span&gt;&lt;span data-start-index=&quot;738&quot;&gt;결과적으로 분모가 0에 가까워지니, 누군가 소소하게 인사만 해서 &lt;b&gt;채팅이 2~3개만 올라와도 Z-Score가 20.0, 30.0으로 폭발하는 현상이 발생&lt;/b&gt;했다.&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 73px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 15.6977%; height: 20px; text-align: center;&quot;&gt;스트리머&lt;/td&gt;
&lt;td style=&quot;width: 19.6511%; height: 20px; text-align: center;&quot;&gt;측정시간 (UTC)&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; height: 20px; text-align: center;&quot;&gt;2분 평균 채팅&lt;/td&gt;
&lt;td style=&quot;width: 10%; text-align: center;&quot;&gt;초당 채팅&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 20px; text-align: center;&quot;&gt;판정결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 15.6977%; height: 17px; text-align: center;&quot;&gt;소형 스트리머 D&lt;/td&gt;
&lt;td style=&quot;width: 19.6511%; height: 17px; text-align: center;&quot;&gt;2026-03-01 19:35:00&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; height: 17px; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 10%; text-align: center;&quot;&gt;2&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 17px; text-align: center;&quot;&gt;PEAK (Z-Score :20)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 18px;&quot;&gt;
&lt;td style=&quot;width: 15.6977%; height: 18px; text-align: center;&quot;&gt;소형 스트리머 E&lt;/td&gt;
&lt;td style=&quot;width: 19.6511%; height: 18px; text-align: center;&quot;&gt;2026-03-01 18:50:42&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; height: 18px; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;width: 10%; text-align: center;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 20%; height: 18px; text-align: center;&quot;&gt;PEAK (Z-Score: 30)&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;size18&quot;&gt;&lt;b&gt;2. 평균의 함정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인터넷 방송의 하이라이트는 갑자기 터지는 경우가 많다. 갑자기 게임에서 웃기게 죽는다거나, 예상치못한 웃긴 장면이 나온다. 그리고 채팅화력은 그때 급격하게 터지다가, 스트리머의 리액션에 의해 2차로 다시 터지는 경우가 많다. 문제는 앞서 발생한 하이라이트가 평균값을 극단적으로 높인다는 점이다. 평균이 이미 높아져 버리니, 진짜 클라이맥스 구간이나, 재밌는 리액션이 Z-Score 임계치 (3.0)을 넘지 못하고 누락되는 결과가 나왔다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;스트리머&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;측정시간&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; text-align: center;&quot;&gt;초당 채팅&lt;/td&gt;
&lt;td style=&quot;width: 25.3488%; text-align: center;&quot;&gt;방송 흐름&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;판정 결과&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;대형스트리머 F&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;2026-03-02 11:41:23&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; text-align: center;&quot;&gt;29&lt;/td&gt;
&lt;td style=&quot;width: 25.3488%; text-align: center;&quot;&gt;어이 없는 데스 발생&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;대형 스트리머 F&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;2026-03-02 11:41:35&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; text-align: center;&quot;&gt;37&lt;/td&gt;
&lt;td style=&quot;width: 25.3488%; text-align: center;&quot;&gt;스트리머 억울함 리액션 시작&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;대형 스트리머 F&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;2026-03-02 11:41:38&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; text-align: center;&quot;&gt;41&lt;/td&gt;
&lt;td style=&quot;width: 25.3488%; text-align: center;&quot;&gt;시청자들의 2차 반응&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;NORMAL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;대형 스트리머 F&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;2026-03-02 11:41:41&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; text-align: center;&quot;&gt;61&lt;/td&gt;
&lt;td style=&quot;width: 25.3488%; text-align: center;&quot;&gt;스트리머의 하이라이트 리액션&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;NORMAL&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;size18&quot;&gt;&lt;b&gt;3. 파편화 현상&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대기업 방송의 경우, 스트리머가 재밌는 리액션을 하면 시청자들의 웃음 여진이 30초에서 1분 가까이 이어진다. Z-Score만 적용하게 되면 이 1분간의 여진을 개별 하이라이트로 쪼개서 인식하게 된다. 이는 V1에서도 확인할 수 있는 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;통계의 맹점 해결하기 (V3)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;Z-Score 일괄 적용만으로는 라이브 방송을 모두 제어할 수 없음을 알게되었다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;통계적 방법외에 추가적인 방법으로 하이라이트를 보다 정확하게 포착하도록 개선했다.&lt;/span&gt;&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;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;1. 동적 체급 분류&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 60분 채팅 평균을 기준으로 방송의 체급을 실시간 분류하여 기준을 다르게 적용했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;18&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;18,0,0&quot;&gt;대기업:&lt;/b&gt; 변화가 빠르므로 1분 윈도우 적용 / Z-Score 임계치 3.5 (덜 민감하게)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,1,0&quot;&gt;중소규모:&lt;/b&gt; 모수가 적으므로 2분 윈도우 적용 / Z-Score 임계치 4.0 (기준선 붕괴 방어)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;996&quot;&gt;2. 절대 기준 설정및 스케줄러 설정&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&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-path-to-node=&quot;21&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;21,0,0&quot;&gt;최소 화력 하한선:&lt;/b&gt; 아무리 트래픽이 없어도 '최소 5개 이상'일 때만 탐지하도록 커트라인 설정.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;21,1,0&quot;&gt;배치(Batch) 스케줄러 정제:&lt;/b&gt; 새벽 시간대 노이즈 발생을 대비해 실시간 노출은 화력 순 Top 6로 제한하고, 24시간 후 스케줄러가 최고 화력 Top 10만 남기고 DB에서 일괄 Drop 처리하여 인프라 비용 절감.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;3. 10초 갭 윈도우 도입&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;평균과 표준편차를 계산할 때 &lt;b data-index-in-node=&quot;16&quot; data-path-to-node=&quot;23&quot;&gt;'직전 10초'의 데이터를 고의로 배제&lt;/b&gt;했다. 이를 통해 방금 터진 1차 리액션이 평균 기준선을 오염시켜 진짜 하이라이트를 누락시키는 현상을 해결했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;4. 파편화 방지 쿨타임 도입&lt;/b&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-path-to-node=&quot;26&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;26,0,0&quot;&gt;60초 쿨다운 세션 병합:&lt;/b&gt; 피크 감지 후 60초 내의 이벤트는 Caffeine Cache를 이용하여 하나의 '세션 ID'로 묶는다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;26,1,0&quot;&gt;-20초 / +5초 버퍼링:&lt;/b&gt; 실제 채팅 딜레이를 계산하여, 채팅 피크 기준 &lt;b data-index-in-node=&quot;40&quot; data-path-to-node=&quot;26,1,0&quot;&gt;-20초 전으로 시작점을 당겨 맥락을 알 수 있게&lt;/b&gt;하고, 피크 직후 &lt;b data-index-in-node=&quot;77&quot; data-path-to-node=&quot;26,1,0&quot;&gt;+5초 만에 영상을 끊어내어&lt;/b&gt; 타임라인을 완성했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;개선 결과 (V3)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;1. 중소형 방송의 작은 하이라이트 포착&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;스트리머&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; text-align: center;&quot;&gt;측정시간(UTC)&lt;/td&gt;
&lt;td style=&quot;width: 14.7674%; text-align: center;&quot;&gt;1분 평균 채팅&lt;/td&gt;
&lt;td style=&quot;width: 14.3023%; text-align: center;&quot;&gt;초당 최고 채팅&lt;/td&gt;
&lt;td style=&quot;width: 15.3489%; text-align: center;&quot;&gt;V1 판정&lt;/td&gt;
&lt;td style=&quot;width: 20.9302%; text-align: center;&quot;&gt;V3 판정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;소형 스트리머 G&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; text-align: center;&quot;&gt;23:48:00&lt;/td&gt;
&lt;td style=&quot;width: 14.7674%; text-align: center;&quot;&gt;2.0&lt;/td&gt;
&lt;td style=&quot;width: 14.3023%; text-align: center;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 15.3489%; text-align: center;&quot;&gt;NORMAL&lt;/td&gt;
&lt;td style=&quot;width: 20.9302%; text-align: center;&quot;&gt;NORMAL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;소형 스트리머 G&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; text-align: center;&quot;&gt;23:49:00&lt;/td&gt;
&lt;td style=&quot;width: 14.7674%; text-align: center;&quot;&gt;3.0&lt;/td&gt;
&lt;td style=&quot;width: 14.3023%; text-align: center;&quot;&gt;7&lt;/td&gt;
&lt;td style=&quot;width: 15.3489%; text-align: center;&quot;&gt;NORMAL&lt;/td&gt;
&lt;td style=&quot;width: 20.9302%; text-align: center;&quot;&gt;PEAK (하이라이트 생성)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;소형 스트리머&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; G&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 14.6512%; text-align: center;&quot;&gt;23:50:00&lt;/td&gt;
&lt;td style=&quot;width: 14.7674%; text-align: center;&quot;&gt;3.0&lt;/td&gt;
&lt;td style=&quot;width: 14.3023%; text-align: center;&quot;&gt;11&lt;/td&gt;
&lt;td style=&quot;width: 15.3489%; text-align: center;&quot;&gt;NORMAL&lt;/td&gt;
&lt;td style=&quot;width: 20.9302%; text-align: center;&quot;&gt;PEAK (하이라이트 생성)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;평소 평균 채팅이 2개에 불과한 소규모 방에서는 7개, 11개만 터져도, V3 엔진은 방의 체급에 맞춰 기준선을 스스로 낮춰 하이라이트를 감지했다.&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;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot;&gt;2. 대형 방송의 노이즈 억제 및 진짜 하이라이트 감지&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 80px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;스트리머&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;측정시간(UTC)&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;1분 평균 채팅&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;초당 채팅&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;V3 판정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;대형 스트리머 E&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;18:39:48&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;22&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;15&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;NORMAL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;대형 스트리머 E&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;20:15:03&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;33&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;26&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;NORMAL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;대형 스트리머&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; E&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;20:14:57&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;34&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;58&lt;/td&gt;
&lt;td style=&quot;width: 16.6667%; text-align: center; height: 20px;&quot;&gt;PEAK&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;1284&quot;&gt;15개, 초당 26개의 채팅 상황에도, V3 엔진은 대형 방송의 평범한 트래픽임을 인지하고 무시한다. 그러나 초당 채팅이 58개로 증가한 20:14:57 구간에서는 Z-Score 커트라인을 넘겨 피크를 잡아냈다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;1284&quot;&gt;3. 하이라이트 파편화 방지&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;스트리머&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;측정시간(UTC)&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;초당 채팅&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;판정&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;SessionID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;소형 스트리머 D&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;18:42:45&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;Session_511&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;소형 스트리머 D&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;18:42:48&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;Session_511 (연장)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;소형 스트리머&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; D&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;18:42:51&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;3&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;PEAK&lt;/td&gt;
&lt;td style=&quot;width: 20%; text-align: center;&quot;&gt;Session_511 (연장)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;1873&quot;&gt; 3초 주기로 연속 3번 터진 피크가 파편화되지 않고 &lt;/span&gt;511&lt;span style=&quot;background-color: #ffffff; color: #303030; text-align: start;&quot; data-start-index=&quot;1912&quot;&gt;이라는 &lt;b&gt;동일한 세션 ID&lt;/b&gt;에 할당되었다. 결과적으로 유저는 매끄럽게 하나로 이어진 하이라이트 타임스탬프를 볼 수 있게 되었다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>프로젝트 &amp;amp; 트러블슈팅</category>
      <author>화이용</author>
      <guid isPermaLink="true">https://flowerdragon95.tistory.com/286</guid>
      <comments>https://flowerdragon95.tistory.com/286#entry286comment</comments>
      <pubDate>Thu, 19 Mar 2026 18:55:27 +0900</pubDate>
    </item>
    <item>
      <title>[Kafka] Kafka를 사용할때 살펴봐야할 6가지 (2)</title>
      <link>https://flowerdragon95.tistory.com/285</link>
      <description>&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;4. EOS 설정&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터의 유실과 중복을 방지하는 &lt;b data-index-in-node=&quot;18&quot; data-path-to-node=&quot;1&quot;&gt;'정확히 한 번(Exactly-Once)'&lt;/b&gt; 처리를 위해서는 프로듀서(데이터를 보내는 앱)와 브로커(카프카 서버) 사이의 약속이 필요하다. 하지만 이 설정들을 활성화하면 단일 노드로 구성된 로컬 환경(Docker)에서는 에러가 발생한다.&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;size18&quot;&gt;&lt;b&gt;EOS를 구현하기 위해 프로듀서에서 설정하는 두 가지 핵심 옵션&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;4&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;4,0,0&quot;&gt;`acks=all` (유실 방지)&lt;/b&gt;: 프로듀서가 메시지를 보낸 후, 메인 서버(Leader)뿐만 아니라 &lt;b data-index-in-node=&quot;55&quot; data-path-to-node=&quot;4,0,0&quot;&gt;예비 서버(Follower)들까지 복제를 마쳤는지&lt;/b&gt; 확인하고 응답(ACK)을 받는 설정이다. 서버 한 대가 고장 나도 데이터가 사라지지 않게 보장한다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4,1,0&quot;&gt;`enable.idempotence=true` (중복 방지)&lt;/b&gt;: 프로듀서가 메시지마다 고유 번호(PID, Sequence Number)를 붙여서 보낸다. 브로커는 이 번호를 장부에 기록해두었다가, &lt;b data-index-in-node=&quot;107&quot; data-path-to-node=&quot;4,1,0&quot;&gt;이미 받은 번호가 중복으로 들어오면 저장하지 않고 버린다.&lt;/b&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;size18&quot;&gt;&lt;b&gt;왜 단일 노드에서 에러가 날까?&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카프카는 본래 여러 대의 서버에 데이터를 나눠 담아 안정성을 확보하도록 설계된 분산 시스템이다. 특히 오프셋이나 트랜잭션 상태를 기록하는 핵심 내부 토픽들은 장애 발생을 대비해 &lt;b&gt;최소 3대의 브로커에 복제본&lt;/b&gt;을 만들도록(Replication Factor=3) 기본값이 설정되어 있다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;하지만 로컬 개발을 위해 단일 브로커(1노드)만 띄운 환경에서는 이 '복제본 3개'라는 조건을 물리적으로 충족할 방법이 없다. 카프카 입장에서는 시스템의 안전을 보장하는 최소한의 복제 요구사항을 지킬 수 없다고 판단하고, 작업을 거부하며 `INVALID_REPLICATION_FACTOR` 에러를 띄우는 것이다. 이 과정이 무한히 반복되면서 브로커가 정상적으로 기동되지 않는 루프에 빠지게 된다.&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;2&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;size18&quot;&gt;&lt;b&gt;환경에 맞는 명시적 설정과 초기화&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772538115702&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# docker-compose.yml 환경 변수 설정
environment:
  # 내부 장부(오프셋, 트랜잭션)의 복사본 수를 1개로 제한
  KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
  KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
  KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 환경에서는 카프카에게 &quot;서버가 1대뿐이니 복사본을 1개만 만들어도 된다&quot;고 허락해줘야 한다. 설정을 고쳐도 에러가 반복된다면, 이미 잘못된 설정(복사본 3개 필요)으로 생성된 데이터가 로컬 볼륨에 남아있기 때문이다. 카프카는 첫 기동 시의 메타데이터를 디스크에 기록하므로, &lt;b data-index-in-node=&quot;117&quot; data-path-to-node=&quot;11&quot;&gt;docker compose down -v&lt;/b&gt; 명령어로 기존 데이터를 완전히 삭제한 뒤 재기동해야 새로운 설정이 적용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;5. Consumer Lag 과 모니터링&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실시간 분석 엔진에서 '지연(Lag)'은 시스템의 신뢰도를 결정하는 핵심 지표다. 카프카에서 지연이란 프로듀서가 보낸 메시지의 위치(Log End Offset)와 컨슈머가 읽은 메시지의 위치(Current Offset) 사이의 차이를 의미한다. 컨슈머가 메시지를 읽어가는 속도가 생산 속도를 못 따라가면, 엔진은 현재의 하이라이트가 아닌 '이미 지나간 과거의 채팅'을 분석하게 된다. 실시간성이 깨진 분석 결과는 대시보드에서 아무런 가치를 갖지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;모니터링 체계 구축 : Pr&lt;b&gt;ometheus &amp;amp; Grafana&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;그라파나.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;783&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nJIcr/dJMcagkjFhV/vkJQpAxeQNIN7ubtXq35j0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nJIcr/dJMcagkjFhV/vkJQpAxeQNIN7ubtXq35j0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nJIcr/dJMcagkjFhV/vkJQpAxeQNIN7ubtXq35j0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnJIcr%2FdJMcagkjFhV%2FvkJQpAxeQNIN7ubtXq35j0%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;668&quot; height=&quot;409&quot; data-filename=&quot;그라파나.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;783&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prometheus와 Grafana를 연동하여 컨슈머 그룹별 랙(Lag)을 실시간 대시보드로 시각화했다. `Message in per second` 지표를 통해 초당 유입량을 확인하고, `Lag by Consumer Group`을 통해 컨슈머가 얼마나 뒤처지고 있는지 실시간으로 감시한다.&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;이미지에서 보듯 Message in 수치가 갑자기 튀는 구간(Spike)이 발생하면 컨슈머 랙도 함께 증가할 가능성이 높다. 이때 무작정 서버를 늘리는 게 아니라 대시보드를 보고 원인을 파악해야 한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;12&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;12,0,0&quot;&gt;파티션 병목&lt;/b&gt;: 유입량은 많은데 특정 파티션만 랙이 심하다면 키(Key) 분산 전략을 다시 점검해야 한다.&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;컨슈머 처리 속도&lt;/b&gt;: 유입량 대비 Message consume 속도가 낮다면 분석 로직 내 외부 I/O(예: Redis 쓰기) 병목을 의심해 봐야 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;Dead Letter Queue (DLQ)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시스템이 처리할 수 없는 잘못된 형식의 데이터나 비즈니스 로직 오류를 유발하는 메시지를 독약 메시지(Poison Pill)라고 한다. 이러한 데이터가 유입되면 컨슈머는 에러를 내뱉으며 처리를 중단하거나, 설정에 따라 무한 재시도(Retry)를 반복하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;재시도와 장애 전이&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카프카 컨슈머는 메시지 처리에 실패할 경우, 성공할 때까지 동일한 오프셋을 계속 읽으려 시도한다. 하지만 데이터 자체가 손상되었거나 로직상 처리 불가능한 데이터라면 아무리 재시도해도 성공할 수 없다. 결과적으로 한 개의 메시지 때문에 해당 파티션에 쌓인 수만 개의 후속 메시지들이 줄지어 대기하게 되는 &lt;b&gt;블로킹&lt;/b&gt;&amp;nbsp;&lt;b&gt;현상&lt;/b&gt;이 발생하고, &lt;b&gt;전체 시스템의 지연(Lag)&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;size18&quot;&gt;&lt;b&gt;실패한 메시지를 격리 수거하기&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772538808597&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Service
public class ChatConsumer {

    // 1. 재시도 전략 설정
    @RetryableTopic(
        attempts = &quot;3&quot;, // 최대 3번 시도 (본 시도 1회 + 재시도 2회)
        backoff = @Backoff(delay = 1000, multiplier = 2.0), // 1초부터 시작해 재시도 간격을 2배씩 늘림
        topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE,
        dltStrategy = DltStrategy.FAIL_ON_ERROR, // 최종 실패 시 DLT로 전송
        include = {RuntimeException.class} // 재시도를 유발할 예외 종류
    )
    @KafkaListener(topics = &quot;chat-topic&quot;, groupId = &quot;analysis-group&quot;)
    public void consume(ChatMessage message) {
        // 비즈니스 로직 수행
        if (message.getContent() == null) {
            throw new RuntimeException(&quot;내용이 없는 독약 메시지 발생&quot;);
        }
        System.out.println(&quot;채팅 분석 중: &quot; + message.getContent());
    }

    // 2. 최종 실패 처리 (DLT 컨슈머)
    @DltHandler
    public void handleDlt(ChatMessage message, @Header(KafkaHeaders.RECEIVED_TOPIC) String topic) {
        // 최종 실패한 메시지를 로그로 남기거나 별도 DB에 저장하여 사후 분석
        System.err.printf(&quot;최종 실패 메시지 격리 - Topic: %s, Message: %s%n&quot;, topic, message);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제를 해결하기 위해 일정 횟수 이상 처리에 실패한 메시지는 별도의 &lt;b data-index-in-node=&quot;40&quot; data-path-to-node=&quot;5&quot;&gt;DLQ(Dead Letter Queue) 토픽&lt;/b&gt;으로 전송하도록 설계했다. 문제가 된 메시지만 별도 토픽으로 격리하기 때문에, 정상적인 다음 메시지들은 중단 없이 계속 처리될 수 있다. DLQ에 쌓인 메시지는 실시간 흐름을 방해하지 않는다. 나중에 개발자가 여유 있게 로그를 확인하여 왜 실패했는지 분석하고, 버그를 수정하거나 데이터를 보정하여 해당 메시지만 수동으로 재처리(Reprocess)할 수 있다.&lt;/p&gt;</description>
      <category>프로젝트 &amp;amp; 트러블슈팅</category>
      <author>화이용</author>
      <guid isPermaLink="true">https://flowerdragon95.tistory.com/285</guid>
      <comments>https://flowerdragon95.tistory.com/285#entry285comment</comments>
      <pubDate>Tue, 3 Mar 2026 20:53:46 +0900</pubDate>
    </item>
    <item>
      <title>[Kafka] Kafka를 사용할때 살펴봐야할 6가지 (1)</title>
      <link>https://flowerdragon95.tistory.com/284</link>
      <description>&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;왜 Kafka가 필요할까?&lt;/h3&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-path-to-node=&quot;2&quot; data-ke-size=&quot;size16&quot;&gt;하지만 파이프라인만 구축하면 모든 게 해결될 줄 알았던 생각은 오산이었다. 카프카는 메시지를 배달해줄 뿐, 중복 처리 방어(Idempotency)나 &lt;b&gt;데이터 순서 보장&lt;/b&gt;, &lt;b&gt;이벤트 시간(Event Time) 정렬&lt;/b&gt; 같은 데이터 무결성은 온전히 개발자의 설계 영역이었다. 분산 시스템의 문제들을 어떻게 해결했는지, 삽질을 통해 배운것을 6가지 원칙으로 정리한다.&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;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;멱등성 : 카프카가 메시지를 여러개 보낼때&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카프카는 기본적으로 'At-least-once(최소 한 번)' 전달을 보장한다. 네트워크 장애로 프로듀서(카프카)가 잘 받았다는 응답을 못 받으면 메시지를 재전송하고, 컨슈머는 같은 메시지를 두 번 읽게 된다. 카프카가 메시지를 잘 배달해주는 것과, 그 메시지를 받은 애플리케이션이 정확하게 계산하는 것은 별개의 문제다. 100개의 채팅이 발생했을 때 중복 처리로 인해 200개가 기록되는 참사를 막으려면, 데이터가 최종적으로 담기는 저장소에서 멱등성 설계가 필수적이다. 이번 프로젝트에서는 분석 엔진의 상태를 저장하는 `&lt;b&gt;Redis TimeSeries&lt;/b&gt;`와 &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;size18&quot;&gt;&lt;b&gt;저장소(Redis) 레벨의 방어&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772261389155&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- Redis TS.ADD 명령: 동일 타임스탬프 유입 시 마지막 값으로 덮어쓰기
redis.call('TS.ADD', key, timestamp, value, 'ON_DUPLICATE', 'LAST')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨슈머가 동일한 이벤트 시간의 데이터를 중복해서 밀어 넣더라도 데이터가 뻥튀기되지 않도록 `ON_DUPLICATE LAST` 옵션을 사용했다. 동일한 타임스탬프의 데이터가 유입되면 새로운 값으로 덮어쓰기를 수행하여, 결과적으로 데이터의 합계가 오염되는 것을 원천 차단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;애플리케이션(Java)레벨의 방어&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772261411111&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Java: AtomicReference를 이용한 원자적 시간 갱신
lastChatTime.updateAndGet(current -&amp;gt; 
    (current == null || eventTime.isAfter(current)) ? eventTime : current
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플리케이션 내부 메모리 상태도 보호해야 한다. 여러 스레드가 동시에 같은 채팅방의 데이터를 갱신할 때 발생하는 경쟁상태를 막기위해 `AtomicReference`를 도입했다. `updateAndGet` 연산을 통해 어떤 상황에서도 최신 시간의 데이터만 반영되도록 보장했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;이벤트 시간과 처리시간 : 서버에서 데이터 발생 시간을 찍지 말것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;서버 시계를 사용했더니&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순하게 `Instant.now()`를 기준으로 Redis에 데이터를 쌓았다. 하지만 서버가 재시작되어 밀린 메시지를 한꺼번에 읽어올 때 재앙이 시작되었다. 10분 전에 발생한 채팅들이 '지금' 발생한 것처럼 한 슬롯에 몰려 들어가면서 분석 차트가 비정상적으로 튀는 현상이 발생한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;데이터 안의 시각을 끝까지 추적하기&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772262266513&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ❌ Processing Time: 서버의 현재 시각 기준 (지연 시 데이터 왜곡 발생)
long toTs = Instant.now().toEpochMilli();

// ✅ Event Time: 메시지 발생 시각을 기준으로 하되, 역전 방지 로직 추가
lastChatTime.updateAndGet(current -&amp;gt; 
    (current == null || eventTime.isAfter(current)) ? eventTime : current
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 모든 분석 좌표를 치지직이 내려주는 채팅시간(&lt;b data-index-in-node=&quot;24&quot; data-path-to-node=&quot;7&quot;&gt;Event Time)&lt;/b&gt;으로 고정했다. 메시지 페이로드에 포함된 타임스탬프를 끝까지 들고 가서 Redis의 타임스탬프로 사용했다. 하지만 여기서 또 다른 문제에 직면했다. 분산 환경에서는 메시지의 도착 순서가 발생 순서와 미세하게 다를 수 있다는 점이었다. 이를 방어하기 위해 Java의 `AtomicReference`를 활용했다. 새로운 데이터가 들어올 때 현재 메모리에 기록된 '최종 처리 시간'보다 과거의 데이터라면 무시하거나 별도 처리하도록 로직을 보강했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&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 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;파티셔닝과 순서보장 : 키 설계의 중요성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화력 분석 엔진에서 메시지 순서가 꼬이는 것은 치명적이다. 누적 채팅수를 기반으로 델타(Delta) 값을 계산하는데, 최신 데이터가 이전 데이터보다 먼저 도착하면 계산 로직은 '음수 변화량'을 내뱉거나 분석 불능 상태(WAITING)에 빠지게 된다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;같은 채팅방은 같은 파티션에 할당하기&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1772262970605&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ❌ 기존: 키 없이 전송 (라운드 로빈으로 파티션 분산)
kafkaTemplate.send(&quot;chat-topic&quot;, chatMessage);

// ✅ 개선: roomId를 Key로 지정 (동일 방 데이터는 동일 파티션 고정)
kafkaTemplate.send(&quot;chat-topic&quot;, chatMessage.getRoomId(), chatMessage);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 전송 시 roomId를 메시지 키로 명시하여 전송하도록 수정했다. &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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;1편 요약&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&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 data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0,0&quot;&gt;멱등성:&lt;/b&gt; 중복 배달을 대비해 Redis와 Java 레벨에서 2중 방어막 구축.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;Event Time:&lt;/b&gt; 서버 시계를 버리고 데이터 안의 시간을 사용.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,2,0&quot;&gt;파티셔닝:&lt;/b&gt; Key 설정을 통해 동시성과 순서 보장하기.&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;&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트 &amp;amp; 트러블슈팅</category>
      <author>화이용</author>
      <guid isPermaLink="true">https://flowerdragon95.tistory.com/284</guid>
      <comments>https://flowerdragon95.tistory.com/284#entry284comment</comments>
      <pubDate>Sat, 28 Feb 2026 16:17:31 +0900</pubDate>
    </item>
    <item>
      <title>[트러블 슈팅] 비동기 코드가 동기적으로 처리되는 이유 (Java Stream, CompletableFuture)</title>
      <link>https://flowerdragon95.tistory.com/283</link>
      <description>&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;문제 상황&lt;/h3&gt;
&lt;pre id=&quot;code_1771070706589&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Scheduled(fixedRate = 3000)
public void monitorHighlights() {
    // 추적중인 방송id 목록을 조회
    List&amp;lt;String&amp;gt; activeStreamIds = streamProvider.getActiveStreamIds();

    // 스트림을 사용하여 방송분석 로직을 비동기로 호출하기 위한 코드
    // CompletableFuture을 통해 비동기 호출한뒤
    // join을 통해 결과를 조합
    List&amp;lt;AnalysisSignal&amp;gt; signals = activeStreamIds.stream()
        .map(id -&amp;gt; CompletableFuture.supplyAsync(() -&amp;gt; processStream(id), virtualThreadExecutor))
        .map(CompletableFuture::join)
        .filter(Optional::isPresent)
        .map(Optional::get)
        .toList();

    if (!signals.isEmpty()) {
        // 분석결과를 api-server로 https 메시지 발송
        signalClient.send(signals);
    }
}

private Optional&amp;lt;AnalysisSignal&amp;gt; processStream(String streamId) {
    // 채팅 빈도를 조회하여 NORMAL, PEAK 상태를 감지하는 분석로직 호출
    // RedisTimeSeries 호출하는 I/O 로직이 실행된다.
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;755&quot; data-origin-height=&quot;175&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ybGeu/dJMcahQTjDR/LJMYUOwDSaOqBbCiKMk350/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ybGeu/dJMcahQTjDR/LJMYUOwDSaOqBbCiKMk350/img.png&quot; data-alt=&quot;코드래빗의 리뷰사항&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ybGeu/dJMcahQTjDR/LJMYUOwDSaOqBbCiKMk350/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FybGeu%2FdJMcahQTjDR%2FLJMYUOwDSaOqBbCiKMk350%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;755&quot; height=&quot;175&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;755&quot; data-origin-height=&quot;175&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;코드래빗의 리뷰사항&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;치지직 채팅 데이터를 통해 하이라이트 상태를 감지하는 로직을 짰다. &lt;br /&gt;`CompletableFuture`와 `Virtual ThreadExecutor`를 썼으니 당연히 비동기 처리가 될것으로 생각했다.&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;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;원인 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제의 원인은 `CompletableFuture.join()`의 성격과 Stream의 동작 방식이 충돌하기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;14&quot;&gt;CompletableFuture. join()&lt;/b&gt;&amp;nbsp;메서드는 Future가 완료될 때까지 현재 스레드를 대기 상태(Blocking)로 만든다. &lt;br /&gt;즉, &quot;미래의 결과&quot;를 &quot;현재의 값&quot;으로 바꾸기 위해 흐름을 강제로 끊는다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Java의 Stream은 기본적으로 Loop(반복문)다. 앞의 요소가 파이프라인을 완전히 통과해야 다음 요소가 진입할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 둘이 만나면 아래와 같은 &lt;b data-index-in-node=&quot;44&quot; data-path-to-node=&quot;16&quot;&gt;직렬화(Serialization) 현상&lt;/b&gt;이 발생한다.&lt;/p&gt;
&lt;pre id=&quot;code_1771071924711&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;stream.map(id -&amp;gt; CompletableFuture.supplyAsync(...)) // 1. 비동기 작업 시작 (좋음)
      .map(CompletableFuture::join)                  // 2. 즉시 대기 (병목 발생)&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-path-to-node=&quot;18&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;18,0,0&quot;&gt;Task A 시작&lt;/b&gt; (비동기 처리) ➔ join()을 만나서 스레드 정지.&lt;/li&gt;
&lt;li&gt;Task A가 끝날 때까지 Task B는 시작조차 못 함. (스트림의 특성)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,2,0&quot;&gt;Task A 완료&lt;/b&gt; ➔ 그제야 Task B 시작 ➔ 다시 join() 만나서 정지. (결과적 동기적 작동)&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;결국 비동기 작업을 시키자마자 결과를 내놓으라고 멱살 잡고 기다리는 꼴이 되어, 멀티 스레드 환경에서도 단일 스레드처럼 &lt;b data-index-in-node=&quot;73&quot; data-path-to-node=&quot;19&quot;&gt;순차적으로 실행&lt;/b&gt;되는 결과를 낳게 된것이다.&lt;/p&gt;
&lt;p data-path-to-node=&quot;19&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;해결 방법 : 요청과 대기의 시점 분리&lt;/h3&gt;
&lt;pre id=&quot;code_1771072307874&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public void monitorHighlights() {
    List&amp;lt;String&amp;gt; activeStreamIds = streamProvider.getActiveStreamIds();

    // [Step 1] 비동기 작업 시작 (Start)
    // Stream을 순회하며 작업을 스레드 풀(Virtual Thread)에 전부 던진다.
    // join()을 호출하지 않고, Future만 받아 리스트로 먼저 모아둔다.
    List&amp;lt;CompletableFuture&amp;lt;Optional&amp;lt;AnalysisSignal&amp;gt;&amp;gt;&amp;gt; futures = activeStreamIds.stream()
        .map(id -&amp;gt; CompletableFuture.supplyAsync(() -&amp;gt; processStream(id), virtualThreadExecutor))
        .toList(); // 즉시 모든 작업이 병렬로 시작됨

    // [Step 2] 전체 대기 (Wait)
    // 모든 작업이 끝날 때까지 메인 스레드는 여기서 딱 한 번만 멈춘다.
    // 가장 오래 걸리는 작업 하나만큼의 시간만 소요된다.
    CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();

    // [Step 3] 결과 수집 (Collect)
    // 이미 모든 작업이 완료되었으므로, 여기서의 join()은 대기 시간 없이 즉시 값을 반환한다.
    List&amp;lt;AnalysisSignal&amp;gt; signals = futures.stream()
        .map(CompletableFuture::join)
        .filter(Optional::isPresent)
        .map(Optional::get)
        .toList();

    if (!signals.isEmpty()) {
        signalClient.send(signals);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 파악했으니 해결책은 &lt;b data-index-in-node=&quot;66&quot; data-path-to-node=&quot;4&quot;&gt;모든 작업을 먼저 던져놓은 뒤(Non-blocking) 마지막에 한 번만 기다리는(Blocking) 구조&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;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0,0&quot;&gt;1. toList()로 Future 수집: &lt;/b&gt;Stream은 지연 연산(Lazy Evaluation)을 수행하지만, toList()를 호출하는 순간 모든 요소에 대해 터미널 연산이 수행된다. 즉, 이 시점에 이미 모든 비동기 요청이 virtualThreadExecutor로 전달되어 병렬 실행이 시작된다.&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;2.CompletableFuture.allOf(...).join(): &lt;/b&gt;여러 개의 Future 중 하나라도 끝나지 않았다면 넘어가지 않도록 막아주는 '동기화 장벽(Barrier)' 역할을 한다. 개별적으로 기다리는 것이 아니라, &lt;b data-index-in-node=&quot;19&quot; data-path-to-node=&quot;10,1,1,1,0&quot;&gt;전체를 묶어서 한 번만 기다리기 때문에&lt;/b&gt; 전체 소요 시간은 개별 작업 중 가장 오래걸리는 시간만큼으로 단축된다.&lt;/p&gt;
&lt;pre id=&quot;code_1771072480754&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;====== [실험 1] 기존 코드 (순차 처리) ======
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1 분석 시작: 방송1
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1 분석 완료: 방송1
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-2 분석 시작: 방송2
VirtualThread[#25]/runnable@ForkJoinPool-1-worker-2 분석 완료: 방송2
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-3 분석 시작: 방송3
VirtualThread[#27]/runnable@ForkJoinPool-1-worker-2 분석 완료: 방송3
  총 소요 시간: 3027ms (기대값: 약 3000ms)

====== [실험 2] 개선된 코드 (병렬 처리) ======
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-2 분석 시작: 방송1
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-3 분석 시작: 방송2
VirtualThread[#30]/runnable@ForkJoinPool-1-worker-1 분석 시작: 방송3
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-2 분석 완료: 방송1
VirtualThread[#29]/runnable@ForkJoinPool-1-worker-1 분석 완료: 방송2
VirtualThread[#30]/runnable@ForkJoinPool-1-worker-3 분석 완료: 방송3
  총 소요 시간: 1005ms (기대값: 약 1000ms)&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;동기적 처리 (3000ms) 에서 비동기 처리 (1000ms) 만큼 단축된것을 확인했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;padding: 0.4em 1em 0.1em 0.2em; margin: 0.5em 0em; color: #000; border-bottom: 4px #000 solid; font-weight: bold;&quot; data-ke-size=&quot;size23&quot;&gt;결론 : 비동기 키워드에 속지 말자&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 스트림의 함정&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;java stream은 기본적으로 순차적 파이프라인이다. Stream 내부(`map`, `forEach`)에서 `join()` 이나 `get()` 같은 블로킹 메서드를 호출하는것은 병목의 원인이 될 수 있다. 비동기 작업을 시작했다면, 결과를 기다리는 시점은 반드시 스트림 루프 밖이어야 한다.&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;2. 병렬 처리의 정석 패턴&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;`CompleatbleFuture`&lt;/b&gt;를 리스트 처리할 때는 항상 3단계 패턴을 따라야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; &lt;b&gt;2.1 Request(비동기 처리 시작)&lt;/b&gt; : `Stream.map(async).toList()`로 Future 객체를 먼저 확보하여 모든 작업을 실행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; &lt;b&gt;2.2 Wait (전체 대기)&lt;/b&gt; : `CompletableFuture.allOf(...).join()`을 사용하여 병렬로 실행된 작업이 모두 끝날때 까지 한번만 대기한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; &lt;b&gt;2.3 Process (결과 처리)&lt;/b&gt; : 이미 완료된 Future들에서 `join()`으로 값을 즉시 꺼낸다.&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;3. VirtualThread의 역할&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;+) 현재 jdk25 버전에서는 `CompletableFuture`가 비동기 요청을 담당하는 클래스로 여전히 사용되고 있으나, 메서드 체이닝이 길어지고 내가 겪은 실수를 하는 경우가 많아 `StructuredTaskScope` 라는 새로운 객체를 도입중에 있다. 현재는 프리뷰 단계라 사용하지 않았다.&lt;br /&gt;자세한 내용은 아래의 openjdk 공식 발표를 참고하자&lt;br /&gt;&lt;a href=&quot;https://openjdk.org/jeps/525&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://openjdk.org/jeps/525&lt;/a&gt;&lt;/p&gt;</description>
      <category>프로젝트 &amp;amp; 트러블슈팅</category>
      <author>화이용</author>
      <guid isPermaLink="true">https://flowerdragon95.tistory.com/283</guid>
      <comments>https://flowerdragon95.tistory.com/283#entry283comment</comments>
      <pubDate>Sat, 14 Feb 2026 21:48:56 +0900</pubDate>
    </item>
  </channel>
</rss>