<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>염소의 블로그</title>
        <link>https://velog.io/</link>
        <description>기록하고, 공유합시다</description>
        <lastBuildDate>Sun, 03 May 2026 05:55:57 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>염소의 블로그</title>
            <url>https://velog.velcdn.com/images/goat_hoon/profile/de18b72c-628b-41e0-ab97-0d527cb69b6e/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 염소의 블로그. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/goat_hoon" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[9차 스터디 백프레셔부터 CDC까지: 시스템이 감당 가능한 만큼만 처리하게 만드는 방법들]]></title>
            <link>https://velog.io/@goat_hoon/9%EC%B0%A8-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%B0%B1%ED%94%84%EB%A0%88%EC%85%94%EB%B6%80%ED%84%B0-CDC%EA%B9%8C%EC%A7%80-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%B4-%EA%B0%90%EB%8B%B9-%EA%B0%80%EB%8A%A5%ED%95%9C-%EB%A7%8C%ED%81%BC%EB%A7%8C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B2%8C-%EB%A7%8C%EB%93%9C%EB%8A%94-%EB%B0%A9%EB%B2%95%EB%93%A4</link>
            <guid>https://velog.io/@goat_hoon/9%EC%B0%A8-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%B0%B1%ED%94%84%EB%A0%88%EC%85%94%EB%B6%80%ED%84%B0-CDC%EA%B9%8C%EC%A7%80-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%B4-%EA%B0%90%EB%8B%B9-%EA%B0%80%EB%8A%A5%ED%95%9C-%EB%A7%8C%ED%81%BC%EB%A7%8C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B2%8C-%EB%A7%8C%EB%93%9C%EB%8A%94-%EB%B0%A9%EB%B2%95%EB%93%A4</guid>
            <pubDate>Sun, 03 May 2026 05:55:57 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이번 내용의 중심 주제는 시스템이 과부하나 장애 상황에서 어떻게 흐름을 제어하고, 어디까지 처리하고, 어디서 끊어야 하는가이다.</p>
</blockquote>
<p>유닉스 파이프의 백프레셔에서 시작해 Reactor, Tomcat, Rate Limit, Circuit Breaker로 확장되고, 이후 Kafka와 RabbitMQ의 선택 기준, CDC와 DB 로그의 연관관계까지 이어진다.</p>
<h2 id="1-백프레셔란-무엇인가">1. 백프레셔란 무엇인가</h2>
<p>백프레셔는 단순히 데이터를 버리는 것이 아니라, 소비자가 생산자에게 “지금 너무 빠르니 속도를 줄여달라”고 신호를 보내는 메커니즘에 가깝다.</p>
<p>예를 들어 유닉스 파이프에서 다음과 같은 명령을 생각할 수 있다.</p>
<p>cat large.log | grep ERROR</p>
<p>cat은 데이터를 빠르게 쓰고, grep은 그 데이터를 읽어서 처리한다.
그런데 grep이 처리하는 속도보다 cat이 쓰는 속도가 더 빠르면 파이프 버퍼가 가득 찬다.</p>
<p>이때 데이터가 무조건 드롭되는 것이 아니라, cat의 write()가 블로킹될 수 있다.
즉, 소비자인 grep의 처리 속도가 생산자인 cat의 쓰기 속도를 간접적으로 제한한다.</p>
<p>이것이 유닉스 파이프에서 볼 수 있는 가장 원시적인 백프레셔라고 볼 수 있다.</p>
<h2 id="2-reactor의-백프레셔">2. Reactor의 백프레셔</h2>
<p>Reactive Streams나 Reactor에서도 백프레셔는 중요한 개념이다.</p>
<p>Reactor의 Flux 같은 스트림에서는 소비자가 처리 가능한 만큼만 데이터를 요청할 수 있다.
즉, 생산자가 무한히 데이터를 밀어 넣는 것이 아니라, 소비자가 감당 가능한 수요를 표현한다. (<code>request(n)</code>)</p>
<p>이 관점에서 백프레셔는 단순한 성능 최적화가 아니라 생산자와 소비자 사이의 계약이다.</p>
<p>생산자는 “얼마나 보낼 수 있는지”를 알고 보내고, 소비자는 “얼마나 받을 수 있는지”를 명시한다.
이 구조 덕분에 중간 버퍼가 무한히 커지거나, 메모리가 폭발하는 상황을 어느 정도 방지할 수 있다.</p>
<h2 id="3-tomcat--spring-mvc에도-백프레셔가-있을까">3. Tomcat + Spring MVC에도 백프레셔가 있을까?</h2>
<p>일반적인 Tomcat + Spring MVC 구조에서도 유사한 흐름 제어는 존재한다.</p>
<p>예를 들어 Tomcat에는 다음과 같은 제한이 있다.</p>
<p>maxThreads
acceptCount
maxConnections</p>
<p>요청이 너무 많이 들어오면 커넥션 큐나 워커 스레드 풀이 포화될 수 있다.
이때 더 이상 요청을 받지 않거나, 연결을 거부하거나, 503 같은 응답이 발생할 수 있다.</p>
<p>다만 이것을 엄밀한 의미의 백프레셔라고 부를지는 애매하다.</p>
<p>백프레셔는 보통 지속적인 데이터 스트림에서 소비자가 생산자의 속도를 제어하는 구조에 더 가깝다.
반면 HTTP 요청은 대체로 단발성 요청이기 때문에, Tomcat의 제한은 백프레셔라기보다는 리밋, 큐잉, 거부 정책에 더 가깝다고 볼 수 있다.</p>
<h2 id="4-rate-limit과-circuit-breaker의-차이">4. Rate Limit과 Circuit Breaker의 차이</h2>
<p>레이트 리밋과 서킷 브레이커는 둘 다 요청을 막을 수 있지만 목적이 다르다.</p>
<p>레이트 리밋은 요청량 자체를 제한하는 장치다.
예를 들어 특정 사용자가 초당 100번 이상 요청하면 429 Too Many Requests를 반환할 수 있다.</p>
<p>서킷 브레이커는 장애 전파를 막기 위한 장치다.
예를 들어 A 서비스가 B 서비스를 호출하는데, B 서비스가 장애 상태라면 A의 스레드가 계속 대기하게 된다.
이 상황이 계속되면 A 서비스도 같이 장애가 날 수 있다.
서킷 브레이커는 이런 상황에서 B로 가는 호출을 빠르게 차단하고, fallback 응답을 반환한다.
중요한 점은 서킷 브레이커는 보통 호출하는 쪽에 둔다는 것이다. (굳이 왜 요청하는쪽에 두어야할까? 생각했음)
B 서비스 앞단에 두는 것이 아니라, A가 B를 호출하는 코드에 둔다.
왜냐하면 A 입장에서 장애는 B 자체의 장애뿐 아니라, A와 B 사이의 네트워크 장애, 타임아웃, 인프라 문제까지 포함되기 때문이다.</p>
<h2 id="5-서킷-브레이커는-언제-도입할까">5. 서킷 브레이커는 언제 도입할까?</h2>
<p>서킷 브레이커는 모든 호출에 무조건 넣는 것이 아니다.</p>
<p>도입 기준은 다음 질문으로 판단할 수 있다.</p>
<p>이 외부 의존성이 장애가 났을 때, 우리 서비스의 다른 기능까지 같이 죽는가?</p>
<p>예를 들어 추천 API가 장애가 났다고 하자.
추천 데이터를 못 받아도 인기 콘텐츠나 운영자가 지정한 콘텐츠로 대체할 수 있다면 서킷 브레이커와 fallback이 매우 유용하다.</p>
<p>반대로 프로필 서비스가 없으면 대부분의 API가 정상 동작할 수 없는 구조라면, 서킷 브레이커를 넣어도 의미가 제한적이다.
어차피 핵심 기능 자체가 성립하지 않기 때문이다.</p>
<p>즉, 서킷 브레이커는 다음 조건에서 특히 유효하다.</p>
<ol>
<li>외부 의존성이 있다.</li>
<li>그 의존성이 장애날 가능성이 있다.</li>
<li>장애 시 다른 기능까지 전파될 수 있다.</li>
<li>차단했을 때 대체 응답이나 graceful degradation이 가능하다.</li>
</ol>
<h2 id="6-kafka와-rabbitmq-선택-기준">6. Kafka와 RabbitMQ 선택 기준</h2>
<p>둘 다 메시지 브로커로 사용할 수 있지만, 설계 철학이 다르다.</p>
<h3 id="kafka가-적합한-경우">Kafka가 적합한 경우</h3>
<p>Kafka는 로그 기반 메시지 브로커다.
메시지를 파티션 로그에 저장하고, 컨슈머가 오프셋을 기준으로 읽는다.</p>
<p>Kafka가 적합한 경우는 다음과 같다.</p>
<ul>
<li>이벤트 순서가 중요하다.</li>
<li>메시지를 오래 보관해야 한다.</li>
<li>재처리 가능성이 중요하다.</li>
<li>대용량 처리량이 필요하다.</li>
<li>이벤트 소싱 또는 CDC 파이프라인이 필요하다.</li>
</ul>
<p>예를 들어 도메인 이벤트, 주문 상태 변경, CDC 기반 데이터 동기화는 Kafka와 잘 맞는다.</p>
<h3 id="rabbitmq가-적합한-경우">RabbitMQ가 적합한 경우</h3>
<p>RabbitMQ는 AMQP 기반 메시지 브로커로, 큐 중심의 메시지 전달에 강하다.
Kafka처럼 로그를 길게 보관하고 재처리하는 구조라기보다는, 메시지를 빠르게 소비자에게 전달하는 데 초점이 있다.</p>
<p>RabbitMQ가 적합한 경우는 다음과 같다.</p>
<ul>
<li>순서가 크게 중요하지 않다.</li>
<li>빠른 전달이 중요하다.</li>
<li>작업 큐 형태로 분산 처리하고 싶다.</li>
<li>메시지가 오래 쌓일 필요가 없다.</li>
<li>실패 시 드롭해도 되는 비즈니스다.</li>
</ul>
<p>예를 들어 푸시 알림은 Kafka보다 RabbitMQ가 더 자연스러울 수 있다.</p>
<p>푸시 알림은 보통 순서 보장이 중요하지 않다.
심지어 Apple/Google 푸시 시스템을 거치면 최종 단말에 도착하는 순서도 보장하기 어렵다.</p>
<p>이런 경우 Kafka로 순서를 보장하려고 하면 오히려 head-of-line blocking이 생길 수 있다.
앞 메시지가 실패하면 뒤 메시지들이 불필요하게 지연될 수 있기 때문이다.</p>
<p>따라서 푸시 알림처럼 “최신성, 낮은 지연, 빠른 처리”가 중요한 경우에는 RabbitMQ 같은 큐 기반 브로커가 더 적합할 수 있다.</p>
<h2 id="7-cdc는-언제-유용한가">7. CDC는 언제 유용한가</h2>
<p>CDC는 Change Data Capture의 약자로, DB의 변경 사항을 로그 기반으로 읽어 다른 시스템에 전달하는 방식이다.</p>
<p>CDC가 유용한 경우는 다음과 같다.</p>
<ul>
<li>배치로 처리하기에는 실시간성이 부족하다.</li>
<li>애플리케이션에서 이벤트를 직접 발행하면 부하나 정합성 문제가 생긴다.</li>
<li>DB 변경 사항을 기반으로 검색엔진, 캐시, 분석 시스템 등을 동기화해야 한다.</li>
<li>원천 데이터는 DB이고, 그 변경 흐름을 안정적으로 외부로 전달해야 한다.</li>
</ul>
<p>예를 들어 PostgreSQL 변경 사항을 Kafka로 흘리고, 이를 Elasticsearch에 반영하는 구조가 가능하다.</p>
<p>CDC의 장점은 애플리케이션이 직접 이벤트를 발행하지 않아도 된다는 점이다.
DB에 반영된 변경 사항을 로그에서 읽어 파생 시스템에 전달할 수 있다.</p>
<p>하지만 단점도 있다.</p>
<p>CDC를 소비하는 쪽에서 비즈니스 로직이 필요해지면, 기존 애플리케이션 로직과 CDC 처리 로직이 분산될 수 있다.
즉, 같은 계산이나 검증 로직이 여러 곳에 흩어질 위험이 있다.</p>
<h2 id="8-db-로그-mongodb-mysql-postgresql의-차이">8. DB 로그: MongoDB, MySQL, PostgreSQL의 차이</h2>
<p>대화 마지막에서는 CDC가 어떤 로그를 읽는지에 대한 이야기도 나온다.</p>
<p>DB마다 로그 구조가 다르다.</p>
<p>MongoDB</p>
<p>MongoDB에는 journal과 oplog가 있다.</p>
<p>journal : 장애 복구를 위한 로그
oplog   : replica set 복제를 위한 operation log</p>
<p>CDC 관점에서는 보통 oplog를 본다.
왜냐하면 oplog는 실제 primary에 반영된 연산을 secondary에 복제하기 위한 로그이기 때문이다.</p>
<p>MySQL</p>
<p>MySQL에는 redo log, undo log, binlog가 있다.</p>
<p>redo log : 스토리지 엔진의 crash recovery
undo log : rollback, MVCC
binlog   : 복제, CDC, point-in-time recovery</p>
<p>CDC 도구는 일반적으로 binlog를 읽는다.
Debezium 같은 도구도 MySQL binlog를 읽어 Kafka로 변경 이벤트를 전달할 수 있다.</p>
<p>PostgreSQL</p>
<p>PostgreSQL은 WAL을 기반으로 복제와 복구, logical decoding을 처리한다.</p>
<p>WAL : crash recovery, replication, logical decoding의 기반</p>
<p>PostgreSQL에서는 WAL이 CDC의 기반이 될 수 있다.
MySQL처럼 redo log와 binlog가 분리된 구조와 달리, PostgreSQL은 WAL 중심으로 더 통합된 구조라고 볼 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[jvm 꾸준히 - 스택오버플로/메모리오버플로/metaspace]]></title>
            <link>https://velog.io/@goat_hoon/jvm-%EA%BE%B8%EC%A4%80%ED%9E%88-%EC%8A%A4%ED%83%9D%EC%98%A4%EB%B2%84%ED%94%8C%EB%A1%9C%EB%A9%94%EB%AA%A8%EB%A6%AC%EC%98%A4%EB%B2%84%ED%94%8C%EB%A1%9Cmetaspace</link>
            <guid>https://velog.io/@goat_hoon/jvm-%EA%BE%B8%EC%A4%80%ED%9E%88-%EC%8A%A4%ED%83%9D%EC%98%A4%EB%B2%84%ED%94%8C%EB%A1%9C%EB%A9%94%EB%AA%A8%EB%A6%AC%EC%98%A4%EB%B2%84%ED%94%8C%EB%A1%9Cmetaspace</guid>
            <pubDate>Tue, 14 Apr 2026 13:22:49 GMT</pubDate>
            <description><![CDATA[<h3 id="stack-이란">stack 이란</h3>
<p>스레드가 생겨날때, stack도 같이 생긴다.
이 때, 그 스택 안에는 해당 스레드에서 호출된 메서드로부터 만들어진 스택프레임이 채워진다.</p>
<h3 id="stackoverflow">StackOverFlow</h3>
<p>왜 발생하는가?
재귀 호출이 너무 깊어지거나, 각 호출 프레임이 커서 현재 스레드의 stack 공간 안에 더 이상 새로운 frame을 넣을 수 없을 때 발생한다.</p>
<p>클래식 VM에서는 동적으로 스택의 크기를 확장할 수 있어서 OOM이 났었다. 하지만 핫스팟 가상머신으로 들어와서부터는 아님!</p>
<h3 id="스레드가-많이생겨서-oom이-나는것을-해결하는-방법">스레드가 많이생겨서 OOM이 나는것을 해결하는 방법</h3>
<p>Heap memory 줄이고, 스레드 스택사이즈 줄이고..!</p>
<h3 id="jdk-버전-차이에-따른">JDK 버전 차이에 따른</h3>
<h4 id="jdk-6-및-그-이전">JDK 6 및 그 이전</h4>
<p>String intern pool이 PermGen(메서드 영역 계열) 쪽에 있었음
그래서 intern() 동작이 상대적으로 부담스럽고
perm 영역 압박 문제가 있었음</p>
<h4 id="jdk-7-이후">JDK 7 이후</h4>
<p>String intern pool이 heap으로 이동
그래서 intern() 관련 동작 특성이 달라짐
더 이상 예전 PermGen 제약과 같은 식으로 보지 않음</p>
<h4 id="jdk-8-이후">JDK 8 이후</h4>
<p>PermGen 자체가 사라지고 Metaspace 사용
메타스페이스는 native memory 사용
하지만 String Pool은 heap 쪽으로 보면 됨</p>
<h3 id="동적-프록시-사용으로-인한-metaspace-공간-사용-증가">동적 프록시 사용으로 인한 MetaSpace 공간 사용 증가</h3>
<p>동적 프록시를 많이 쓰거나 동적으로 클래스를 생성하면 metaspace 사용량에 영향이 갈 수 있다.
왜냐하면 CGLIB이나 JDK 동적 프록시는 런타임에 프록시 클래스를 만들거나 로딩한다.
그럼 결국 JVM은 그 클래스 메타정보를 관리해야 하고, 그런 정보는 method area / metaspace 계열에 들어가게 된다.</p>
<h2 id="정리">정리</h2>
<p>JVM에서 스레드마다 하나의 stack이 있고, 메서드 호출마다 stack frame이 쌓인다.
StackOverflowError는 한 스레드의 stack 공간 안에서 새로운 frame을 더 할당할 수 없을 때 발생한다.
반면 OutOfMemoryError는 heap, metaspace, thread stack 총합 등 프로세스 전체 메모리가 부족할 때 발생한다.
또한 Runtime Constant Pool과 String Pool은 같은 것이 아니다. Runtime Constant Pool은 클래스의 상수 정보를 위한 영역이고, String Pool은 문자열 객체 공유를 위한 풀이다. 현대 JVM에서는 String Pool은 heap에, 클래스 메타정보는 Metaspace(native memory)에 저장된다. String.intern()은 같은 문자열이 풀에 있으면 그 참조를 반환하고, 없으면 풀에 등록한다.
또한 Spring의 @Transactional, @Async, @Cacheable 등은 프록시 기반으로 동작하며, CGLIB/JDK Dynamic Proxy처럼 런타임에 생성되는 프록시 클래스들은 metaspace 사용량에 영향을 줄 수 있다.(곁들이기)</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[8차스터디 - 일괄 배치 (이지만 OS이야기만 주구장창)]]></title>
            <link>https://velog.io/@goat_hoon/8%EC%B0%A8%EC%8A%A4%ED%84%B0%EB%94%94-%EC%9D%BC%EA%B4%84-%EB%B0%B0%EC%B9%98-%EC%9D%B4%EC%A7%80%EB%A7%8C-OS%EC%9D%B4%EC%95%BC%EA%B8%B0%EB%A7%8C-%EC%A3%BC%EA%B5%AC%EC%9E%A5%EC%B0%BD</link>
            <guid>https://velog.io/@goat_hoon/8%EC%B0%A8%EC%8A%A4%ED%84%B0%EB%94%94-%EC%9D%BC%EA%B4%84-%EB%B0%B0%EC%B9%98-%EC%9D%B4%EC%A7%80%EB%A7%8C-OS%EC%9D%B4%EC%95%BC%EA%B8%B0%EB%A7%8C-%EC%A3%BC%EA%B5%AC%EC%9E%A5%EC%B0%BD</guid>
            <pubDate>Fri, 10 Apr 2026 15:46:20 GMT</pubDate>
            <description><![CDATA[<p>하지만 즐거운...</p>
<h2 id="파일-디스크립터">파일 디스크립터</h2>
<p>파일 디스크립터는 “파일 번호”가 아니라, 커널이 관리하는 I/O 대상에 접근하기 위한 참조값이다</p>
<p>파일 디스크립터를 단순히 파일을 가리키는 숫자로 이해하면 범위가 너무 좁다.
실제로는 파일뿐 아니라 소켓, 파이프, 표준 입력/출력처럼 커널이 관리하는 다양한 I/O 대상을 가리키는 핸들에 가깝다.</p>
<p>즉, 프로세스는 파일 디스크립터를 통해 어떤 대상에 대해 read/write 같은 I/O 작업을 수행한다.
이 관점에서 보면 파일, 네트워크 소켓, 파이프가 서로 완전히 다른 것이 아니라, 사용자 입장에서는 공통된 I/O 모델로 다뤄지는 대상이라는 점이 중요하다.</p>
<h2 id="epoll-poll-select">epoll, poll, select</h2>
<h3 id="c10k-를-극복하기-위해-등장한-select">C10K 를 극복하기 위해 등장한 select</h3>
<ul>
<li>정해진 fd의 개수 (1024개)</li>
<li>1024개 전체 사용하지 않더라도 이를 넘겨야함</li>
<li>O(n)의 fd 모니터링</li>
</ul>
<h3 id="poll">poll</h3>
<ul>
<li>fd를 고정된 번호의 비트로 관리하는게 아니라, 구조체배열 형태로 관리하여 fd 번호의 유연성 증가</li>
<li>하지만 그래도 관리되고 있는 fd는 전체 순회 O(n)</li>
</ul>
<h3 id="epoll">epoll</h3>
<ul>
<li>커널이 ready list를 관리 (실제 이벤트가 발생한 fd만)</li>
<li>epoll_ctl로 감시 fd 등록</li>
<li>fd 가 가리키고 있는 버퍼에 데이터 도착시 내부적으로 ready list에 넣음</li>
<li>O(1)로 준비된 fd 확인 가능</li>
</ul>
<h2 id="3-os의-blocking과-io-대기">3. OS의 blocking과 I/O 대기</h2>
<p>이벤트 루프는 단순히 “계속 돌면서 처리하는 스레드”가 아니다.
실제로는 OS가 제공하는 I/O 대기 메커니즘 위에서 동작한다.</p>
<p>네트워크 서버는 여러 연결을 다뤄야 하므로, 각 연결마다 무작정 스레드를 막아 세우는 방식보다
어떤 파일 디스크립터에 읽을 데이터가 준비되었는지 OS에 물어보고,
준비된 것만 처리하는 방식이 필요하다.
이때 등장하는 것이 select, poll, epoll 같은 I/O multiplexing 방식이다.</p>
<p>그래서 이벤트 루프를 제대로 이해하려면 프레임워크 API보다 먼저,
커널이 I/O 준비 상태를 어떻게 알려주는지,
그리고 스레드가 무엇을 기다리며 block되는지를 이해해야 한다.</p>
<h2 id="os스레드-상태">OS스레드 상태</h2>
<p>OS는 스레드를
“무슨 일을 하고 있느냐”보다
지금 CPU에서 실행 가능한 상태인가, 아니면 무엇인가를 기다리고 있는가의 관점으로 본다.</p>
<p>즉, OS 입장에서 중요한 건 아래 두 가지다.</p>
<ol>
<li>이 스레드는 당장 CPU를 주면 실행할 수 있는가</li>
<li>아니면 어떤 자원이나 이벤트를 기다리느라 멈춰 있는가</li>
</ol>
<p>그래서 OS의 스레드 상태는 자바의 BLOCKED, WAITING 같은 분류와는 조금 다르다.
자바 상태가 애플리케이션/JVM 관점의 의미라면,
OS 상태는 스케줄링과 자원 대기 관점에 더 가깝다.</p>
<h4 id="running">Running</h4>
<p>스레드가 실제로 CPU 위에서 실행 중인 상태다.
이건 말 그대로 지금 일을 하고 있는 상태다.
CPU 코어 수가 4개라면, 동시에 running일 수 있는 스레드 수도 최대 4개다.</p>
<h4 id="runnable">Runnable</h4>
<p>스레드는 실행할 준비가 끝났지만, 아직 CPU를 받지 못한 상태다.
즉, </p>
<ul>
<li>코드 실행 가능</li>
<li>락이나 I/O 때문에 막힌 것도 아님</li>
<li>그냥 CPU 차례를 기다리는 중</li>
</ul>
<p>이 상태는 “쉬고 있다”가 아니라
당장 돌릴 수 있는데 CPU가 부족하거나 스케줄 순서를 기다리는 상태라고 보는 게 맞다.</p>
<h4 id="sleeping--waiting">Sleeping / Waiting</h4>
<p>스레드가 어떤 조건이 충족되기를 기다리며 멈춰 있는 상태다.
이건 다시 두 가지로 나눠서 보는 게 좋다.</p>
<ol>
<li><p>Interruptible sleep
이벤트나 신호를 받으면 깰 수 있는 대기 상태다.
예를 들면:
소켓 데이터 도착 대기, 타이머 대기, 어떤 이벤트 발생 대기
리눅스에서 보통 S 상태로 보게 된다.</p>
</li>
<li><p>Uninterruptible sleep
주로 커널 I/O 완료를 기다리는 상태다.
예를 들면
디스크 I/O 대기,일부 커널 자원 대기.
리눅스에서는 흔히 D 상태라고 부른다.
이 상태는 load average와도 연결되기 때문에 운영에서 특히 중요하다.</p>
</li>
</ol>
<h4 id="왜-runnable과-sleeping을-구분해야-하나">왜 Runnable과 Sleeping을 구분해야 하나</h4>
<p>둘 다 “지금 실행 중이 아님”이라는 점은 같지만, 의미는 완전히 다르다.</p>
<p>Runnable
CPU만 받으면 바로 실행 가능하다.
즉 병목이 CPU 쪽일 가능성이 크다.</p>
<p>Sleeping
CPU를 줘도 바로 실행할 수 없다.
무언가를 기다리고 있기 때문이다.</p>
<h4 id="그래서-loadaverage에는-왜-uninterruptible도-포함됨">그래서 LoadAverage에는 왜 uninterruptible도 포함됨?</h4>
<p>이 스레드는 CPU를 바로 쓰진 못하지만, 시스템 입장에선 여전히 끝나지 않은 작업.
CPU의 부하가 아닌, 시스템의 부하이다. </p>
<p>근데 왜 uninterruptible하게 설계했을까?</p>
<h4 id="어차피-디스크를-다-읽는-작업이-완료시-이벤트성으로-알려주게하면-안되었을까">어차피 디스크를 다 읽는 작업이 완료시 이벤트성으로 알려주게하면 안되었을까??</h4>
<p>그 대기를 중간에 깨워도 유의미한 작업인 경우가 없고, 오히려 커널 내부 상태와 장치 상태의 일관성을 안전하게 유지하기 더 어렵기 때문.
중간에 커널이 요청 큐에 넣고, 디스크 작업을 걸고, 버퍼/page cache/블록 계층 상태를 잡고, 완료 시점에 후처리 -&gt; 이거 어케보장?
또한, 디스크 응답이 아직 안 옴 / 필요한 페이지가 아직 안 올라옴 / 블록 장치 작업이 아직 안 끝남 -&gt; 어케보장?</p>
<h2 id="이벤트루프-스레드-동작방식">이벤트루프 스레드 동작방식</h2>
<p>blocking I/O
blocking 소켓에 대해 while loop에서 read()를 호출하면, 데이터가 없을 때 스레드는 busy waiting을 하는 것이 아니라 read() 안에서 block된다. 이 경우 스레드는 데이터가 도착할 때까지 sleep 상태로 들어갈 수 있다.</p>
<p>이벤트 루프
이벤트 루프는 보통 non-blocking 소켓을 사용하므로, read() 자체가 오래 block되는 구조가 아니다. 대신 epoll_wait()나 poll() 같은 I/O multiplexing 호출에서 이벤트가 올 때까지 block되고, 
준비된 소켓에 대해서만 read()를 수행한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[6차를 건너뛴 7차스터디 - 선형성,인과성,브로드캐스팅,합의]]></title>
            <link>https://velog.io/@goat_hoon/6%EC%B0%A8%EB%A5%BC-%EA%B1%B4%EB%84%88%EB%9B%B4-7%EC%B0%A8%EC%8A%A4%ED%84%B0%EB%94%94-%EC%84%A0%ED%98%95%EC%84%B1%EC%9D%B8%EA%B3%BC%EC%84%B1%EB%B8%8C%EB%A1%9C%EB%93%9C%EC%BA%90%EC%8A%A4%ED%8C%85%ED%95%A9%EC%9D%98</link>
            <guid>https://velog.io/@goat_hoon/6%EC%B0%A8%EB%A5%BC-%EA%B1%B4%EB%84%88%EB%9B%B4-7%EC%B0%A8%EC%8A%A4%ED%84%B0%EB%94%94-%EC%84%A0%ED%98%95%EC%84%B1%EC%9D%B8%EA%B3%BC%EC%84%B1%EB%B8%8C%EB%A1%9C%EB%93%9C%EC%BA%90%EC%8A%A4%ED%8C%85%ED%95%A9%EC%9D%98</guid>
            <pubDate>Sat, 28 Mar 2026 07:36:51 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>6차 스터디는 진행했지만.. 건너뛰어버렸음.. 여러모로 바쁜 나날이라.. 하지만 이 또한 핑계지..</p>
</blockquote>
<p>이번 주제는 <strong>일관성과 합의</strong> 의 관련된 주제였다.
여태까지 스터디했던 주제중에 가장 어렵고 난해했던 주제였다.</p>
<h2 id="선형성">선형성</h2>
<p>선형성이란? 무조건 최신성을 보장하는 것이다.
마치 단일 노드 데이터베이스처럼 동작하는 것이다.
클러스터 환경에서 모든 노드들이 단일 노드처럼 움직이게 하려면, 쓰기가 받아 들여졌다는 &quot;확신&quot;이 있어야 한다. 
어떤 쓰기가 성공했다고 관측된 이후의 모든 읽기는 그 결과를 반드시 반영해야 한다.
그렇기 때문에, 네트워크 지연에 매우매우 큰 영향을 받는다. 
그러니 당연히 성능이 저하될 수 밖에..</p>
<h2 id="인과성">인과성</h2>
<p>그래서 나온 것이 인과성의 개념이다.
원인과 결과의 흐름을 보장하자! 라는 컨셉이다.
즉, 모든 흐름에 대해 선형성을 보장할 필요없이, 일부에 해당하는 트랜잭션들만 인과성이 보장되게끔 하는것이다.
근데 자꾸 단일 리더 기반의 아키텍쳐라고 생각해서였는지 이해하기 정말 어려웠다.
전체 순서가 아닌 그 부분집합의 인과성을 어떻게 보장하는 것일까?
이와 관련되어서 여러 방법이 있다.</p>
<ol>
<li>일련번호</li>
<li>램포트 타임스탬프</li>
<li>vector clock (이게 제일 좋아보인다)</li>
</ol>
<p>(일련번호 방식)
각 노드의 로컬 시퀀스 번호만으로는 다른 노드의 이벤트와의 인과관계를 표현할 수 없다</p>
<p>(타임스탬프)
타임스탬프만으로 모든것을 해결할 수 있나?
무조건 A 이후에 B가 일어나면 T(A) &lt; T(B)임을 보장하지만, 역은 보장하지 않는다.
왜냐하면? 타임스탬프로 인해서 인과성이 없는 관계라도 인과성이 있는것 처럼 보이기 때문이다.</p>
<p>정리하면, 아래와 같은 단점들이 있다.</p>
<ul>
<li>동시성과 인과관계를 구분 못한다</li>
<li>실제로는 독립적인 이벤트도 강제로 순서화한다</li>
<li>타임스탬프 순서만 보고 인과관계를 역으로 알 수 없다</li>
<li>충돌 감지나 버전 병합에 부족하다</li>
<li>모든 노드가 같은 순서로 적용하게 만드는 보장은 없다 (노드 전파에 네트워크 이슈가생기면?)</li>
</ul>
<p>(vector clock)
각 연산을 벡터화한다.
벡터 클락은 “내가 무엇을 보고 왔는지”를 더 풍부하게 들고 다닌다.
클라이언트가 A를 읽고 B를 읽고 C에 쓰기를 할 때
“나는 A의 몇 번째 버전, B의 몇 번째 버전을 보고 왔다”는 메타데이터를 같이 보낸다.
그러면 C는 그 맥락을 알고 인과적인 순서를 판단할 수 있다.</p>
<h2 id="전체순서-브로드캐스트">전체순서 브로드캐스트</h2>
<p>앞서 살펴본 인과성은 문제가 있다.
&quot;동시에 발생한 충돌하는 쓰기&quot; 가 대표적인 문제 상황이다.</p>
<p>전체순서 브로드캐스트는 모든 노드가 같은 순서로 이벤트를 받게 만들어 충돌 상황에서도 동일한 결정을 하게 한다.
이 때 그 순서를 결정하는 주체가 있다.
보통의 단일 리더 아키텍쳐에서는 그 역할을 리더 노드가 하게 될 것이고, zookeper나 etcd가 해당 역할을 담당한다.
이 때, 해당 순서로 결정을 하기 위해, 다른 노드에게 브로드캐스팅으로 나의 결정에 동의하겠니? 라고 물어보고, 참여자들은 Ack를 날린다. (이때의 합의는 단순 결정에 대한 동의 측면이다.)
이 것이 <strong>합의</strong> 이다.</p>
<h2 id="2pc">2PC</h2>
<p>2PC는 합의가 필요해지는 이유이다.
2PC의 가장 큰 단점은 coordinator 의 장애, 커밋하려고 했는데, 참여자가 죽으면 무한정 대기 되어야하는 큰 병목이다.
이를 과반수와 같은 합의 정책으로 우회할 수 있다.</p>
<h3 id="곁들여">곁들여</h3>
<p>MongoDB에서 multi-document transaction에 대한 2PC 성격의 분산 커밋 조율은 특히 샤딩 환경에서 여러 샤드에 걸친 트랜잭션일 때 중요하다.
비샤딩 환경에서는 2PC는 필요 없지만, multi-document transaction 자체는 여전히 의미가 있으며 commit/abort를 관리하는 내부 메커니즘도 필요하다. 이러한 Transactino도 MongoDB자체에서 기본적으로 제공해주기도 하고.</p>
<h3 id="부록">부록</h3>
<p>인과성으로 해결이 안되는 문제 예시
여기서 전체 순서 브로드캐스트가 왜 필요한지 나온다.</p>
<p>대화에서 든 대표 예시는 이런 상황이야:</p>
<p>클라이언트1이 노드 A에 x = 10 기록
클라이언트2가 노드 B에 x = 20 기록
둘은 서로 모른 채 거의 동시에 발생함</p>
<p>이 둘은 인과관계가 없다.
즉 causal consistency는 “둘 사이에 순서가 없다”는 것까지만 말해줄 수 있어.</p>
<p>그런데 현실에서는 결국 x는 하나의 값이어야 하는 경우가 많아.
그러면 복제본 C는 무엇을 따라야 하지?
A의 10? B의 20? 둘 중 하나를 골라야 해.</p>
<p>즉,</p>
<p>인과성은 “원인-결과 관계가 있는 것들”의 순서는 알려주지만
동시 충돌에 대해 최종 하나의 결정을 내려주진 못해.</p>
<p>바로 이 지점 때문에
전체 순서 브로드캐스트 / 합의가 필요해진다는 흐름으로 대화가 이어졌어</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[5차 스터디 Transaction & 카카오 테크블로그 이야기 ft. outbox]]></title>
            <link>https://velog.io/@goat_hoon/5%EC%B0%A8-%EC%8A%A4%ED%84%B0%EB%94%94-Transaction-%EC%B9%B4%EC%B9%B4%EC%98%A4-%ED%85%8C%ED%81%AC%EB%B8%94%EB%A1%9C%EA%B7%B8-%EC%9D%B4%EC%95%BC%EA%B8%B0-ft.-outbox</link>
            <guid>https://velog.io/@goat_hoon/5%EC%B0%A8-%EC%8A%A4%ED%84%B0%EB%94%94-Transaction-%EC%B9%B4%EC%B9%B4%EC%98%A4-%ED%85%8C%ED%81%AC%EB%B8%94%EB%A1%9C%EA%B7%B8-%EC%9D%B4%EC%95%BC%EA%B8%B0-ft.-outbox</guid>
            <pubDate>Thu, 19 Feb 2026 07:27:12 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>이번에는 테크블로그에 대한 아키텍쳐 이야기 + 책의 Transaction 이야기가 나왔다.
<a href="https://tech.kakao.com/posts/810">https://tech.kakao.com/posts/810</a></p>
</blockquote>
<h2 id="카카오테크블로그-이야기-거리-outbox-패턴이-반드시-필요한-것인가">카카오테크블로그 이야기 거리 (Outbox 패턴이 반드시 필요한 것인가?)</h2>
<p><a href="https://tech.kakao.com/posts/810">https://tech.kakao.com/posts/810</a></p>
<p>스터디원이 비판적인 사고방식을 가지고 있던 덕에 훌륭한 이야깃거리와 교훈을 얻었다.
테크블로그에서 이야기 하는 것은 다음과 같다. (지피티야 도와줘)</p>
<blockquote>
<p>문제: 일부 SMS가 벤더 리포트는 도착했는데도 DB 상태가 SENT에 머무르는 “리포트 실종”이 발생했는데, 원인은 벤더 리포트가 너무 빨리 와서(DB 커밋 전, 평균 8ms) Report Server가 메시지를 조회하지 못하고 리포트를 드롭하는 레이스 컨디션이었다.</p>
</blockquote>
<p>1차 개선: 트랜잭션 안의 불필요한 후처리(과금 이벤트 발행 등)를 AFTER_COMMIT 비동기로 빼고, 어떤 경로는 @Transactional 자체를 제거해 커밋 지연/커넥션 점유를 줄여 누락을 크게 감소시켰다.</p>
<blockquote>
</blockquote>
<p>근본 해결: 리포트를 먼저 Outbox에 적재하고, 처리/상태전이/과금 이벤트 발행을 단 하나의 워커(Report Replayer)가 전담하는 Single Writer + Outbox 구조로 바꿔 경쟁을 없애서 누락(리포트/과금) 문제를 구조적으로 제거했다.</p>
<h3 id="근데-outbox패턴이-필수인가">근데 Outbox패턴이 필수인가?</h3>
<p>Outbox 패턴을 적용한 근본적인 배경은 Race Condition 현상이 발생할 수 있는 근본적인 문제를 아키텍쳐적으로 해결하기 위함이었다.
실제 API서버에서 Message 상태를 DB 에 commit하기 전에, 기지국(벤더)리포트가 도착하는 경우가 있을 수도 있기 때문에...</p>
<p>하지만, 기지국으로 전송하기 전에 DB에 Message를 전송 진행중 (Pending)이라는 정보를 Commit한후에 기지국에 전송요청을 보낸다면...? 그러면 Race Condition이 근본적으로 제거가 된다. 더불어 Outbax패턴이 불필요하다.</p>
<p>다만 단점 몇가지가 있겠지..</p>
<ol>
<li>메세지 상태가 Pending &amp; Reported 두가지로 나뉘어서, 진짜 Pending인지 Sent인지, 구별할 수 있는 방법이 없어진다. 이슈 트래킹이 좀 힘들어질듯 하다.</li>
<li>Report서버에서 message 갱신 + Billing kafka 호출 을 하게 되면 실패할 수 있는 구간이 많아짐. 상황에 따라 재시도도 까다로울 수도 있다.</li>
<li>인수인계가 제대로 될까?
Message의 상태가 Pending인데요 사실 Sent가 되었어도 Pending이기는해요~ </li>
</ol>
<p>비즈니스로직이 정확이 어떻게 되어있는진 알 순 없지만.. 단지 DB Read Write 시점을 반드시 보장해야되는 그런 상황에서는 Outbox패턴이 정답이 될 순 없을 것같다.</p>
<h1 id="책이야기">책이야기</h1>
<h2 id="트랜잭션에서의-acid">트랜잭션에서의 ACID...</h2>
<p>Consistency 와 Durability
Consistency는 DB차원에서 보장하기엔 어려운 부분이 많다. 
PK/FK, UNIQUE, CHECK, NOT NULL, 트리거 등의 무결성은 OK. 하지만, 비즈니스의 제약조건을 모두 DB에서 해주는건 아니기때문이다. 이는 어플리케이션에 포함되는 좀 더 넓은 범위이다.
Durability는 하드웨어적인 관점 (HDD 자성잃으면?, SSD 셀 망가지면?) + WAL/REDO Log의 소프트웨어 관점이 합쳐진 개념</p>
<h2 id="동시-쓰기-작업시-무엇을-읽을-수-있는가-read-committed-vs-snapshot">동시 쓰기 작업시, 무엇을 읽을 수 있는가? (Read Committed vs Snapshot)</h2>
<h3 id="dirty-write-방지">Dirty Write 방지</h3>
<p>ReadCommitted든, Snapshot 격리이든 Dirty Write을 방지하기 위해 Write 대상의 Record에 Lock을 건다.</p>
<h3 id="repeatable-read-vs-non-repeatable-read">Repeatable Read vs Non-repeatable Read</h3>
<p>스냅샷은 Transactino ID기준으로 그 위의 트랜잭션의 내용은 읽을 수 없으므로, Repeatable Read가 보장. ReadCommitted는 그렇지않음</p>
<blockquote>
<p>번외..
Read Timeout은 실제 응답 패킷이 늦게오는 경우임.
만약 DB Transaction에서 commit이 된 이후에 ReadTimeout이 발생한다면...? 롤백판단이 힘듬. 그래서 멱등성있게 API를 작성하는것이 베스트하다.</p>
</blockquote>
<h2 id="동시-쓰기-작업시-충돌">동시 쓰기 작업시 충돌</h2>
<h3 id="lost-update">lost update</h3>
<p>read-&gt;modify-&gt;write 할 때 자주 발생 이를 막기위해 무엇을?</p>
<ol>
<li>원자적 쓰기 (Update Set Where) &amp; Lock</li>
<li>원자적 쓰기가 불가능할때, 명시적 잠금 수행 (Select for Update)</li>
<li>자동감지 &amp; 재시도..?</li>
<li>Compare and set (update set where x = &quot;before&quot;)</li>
</ol>
<h3 id="write-skew">Write Skew</h3>
<p>두 트랜잭션이 각기 다른 객체를 갱신하면서 발생하는 경쟁 조건.
이를 막기 위해서는 ?</p>
<ol>
<li>인위적인 데이터베이스에 잠금 객체를 추가 (다른 객체를 갱신하면서 발생하는 경쟁조건을 해결하기 위한 방법.. 내가 예전에쓴 분산락의 개념과 비슷할것같다.. NoSQL에서는 이것밖에 답이없어보인다)</li>
<li>Serializable 격리 수준 사용
단일 스레드에서 사용하면 직렬성이보장됨 ㅇㅇ Redis같은애들.
하지만 실제로 RDB에서는 멀티스레드 + 잠금 코디네이션 오버헤드로 Serializable 구현함</li>
</ol>
<h2 id="serializable--다음시간에">Serializable ... 다음시간에</h2>
]]></description>
        </item>
        <item>
            <title><![CDATA[4차스터디 파티셔닝 및 샤딩]]></title>
            <link>https://velog.io/@goat_hoon/4%EC%B0%A8%EC%8A%A4%ED%84%B0%EB%94%94-%ED%8C%8C%ED%8B%B0%EC%85%94%EB%8B%9D-%EB%B0%8F-%EC%83%A4%EB%94%A9</link>
            <guid>https://velog.io/@goat_hoon/4%EC%B0%A8%EC%8A%A4%ED%84%B0%EB%94%94-%ED%8C%8C%ED%8B%B0%EC%85%94%EB%8B%9D-%EB%B0%8F-%EC%83%A4%EB%94%A9</guid>
            <pubDate>Mon, 09 Feb 2026 13:10:32 GMT</pubDate>
            <description><![CDATA[<h2 id="fail-over-관점에서의-mongodb-vs-mysql">fail over 관점에서의 MongoDB vs MySQL</h2>
<p>실제 사례에서
MongoDB는 클러스터 구조로 3중화.
MySQL은 Primary - Replica구조로 2중화할 수 있음. (물론 더할수도있지만)</p>
<p>만약 하나의 노드가 죽어버리면? 남은 노드(들)은 죽은 노드의 트래픽을 모두 감당할 수 있을 정도의 capacity가 존재해야함. 그러한 capacity를 측정해서 하나의 노드 장애가 전체노드에 영향을 가지 않게끔 하는것도 중요하다. (replica를 단순 복제용으로 쓴다면 큰 문제는 없겠지만)</p>
<h2 id="write-부하-관점에서의-mongodb-vs-mysql">write 부하 관점에서의 MongoDB vs MySQL</h2>
<p>mongo: </p>
<ul>
<li>모아서 한번에 write (appendish)</li>
<li>LSM + Btree 혼용</li>
</ul>
<p>mysql: </p>
<ul>
<li>in-place update </li>
<li>B+tree</li>
</ul>
<p>그래서 mongo가 좀더 좋음 ㅇㅇ 일단 append하고 나중에 압축하자란 마인드</p>
<h2 id="왜-몽고-db에는-트랜잭션-개념이-없다고들-하나">왜 몽고 DB에는 트랜잭션 개념이 없다고들 하나?</h2>
<p>MySQL은 데이터가 가지고있는 주체가 노드 하나임.
반면 MongoDB는? 분산클러스터를 위해 설계된 노드이다. (물론, 단일노드나 replica 복제용도로 사용하는 클러스터의경우 분산클러스터라고 말하기 조금 애매하지만..)</p>
<p>쨌든간에 그렇기 때문에, 분산되어 저장된 데이터들을 다룰때, 그러니까 multi-document를 다룰때에는, 비즈니스로직상 트랜잭션이 필요하다면  데이터 정합성 보장을 위해 2PC 프로토콜로 가능은함. 근데 오버헤드가 클뿐.
그래서 보통 이러한 multi-document를 다룰때, 하나의 one document로 합쳐서 저장하면 되겠지? 왜냐하면 단일 document의 트랜잭션 즉 atomicity를 보장하니까.</p>
<h2 id="잠깐-카산드라-얘기">잠깐 카산드라 얘기</h2>
<p>카산드라를 쓸때 클라이언트에서 처음부터 병렬로 multi node에게 write요청을 보내는 것은 아니고, 실제로는 coordinator node로 보내는데, 이 노드가 다수의 multi node에게 write요청을 건네주는 역할이다. </p>
<ul>
<li>client에서 쓰기 요청을 보낼때 coordinator 노드가 죽으면? client에서 retry를 함. </li>
<li>또, 개별 노드들에 write 요청 relay중에 죽어도 retry할 수 있게는 함 (coordinator노드가) (quorum 도 여기에 개입되나?)</li>
<li>복제노드가 죽은 경우에도.. hinted handoff통해서 다른 노드가 대신받고, 해당 노드가 살아났을때 data write함</li>
</ul>
<h2 id="잠깐-레디스-얘기">잠깐 레디스 얘기</h2>
<p>레디스는 cluster구조를 사용한다면, 전체 key-value 저장소를 16384개의 slot 내에서 관리함.
(단일은 하나의 node에 다 저장하는거겠지)</p>
<p>이렇게되면 수평으로 확장할때 hash-key값을 토대로 확장된 노드들에 slot이 할당되는 것
redis를 사용하는 입장에서는 이러한 전략에 맞게끔 key값을 조절하는게 맞다 ㅇㅇ</p>
<p>진짜진짜번외지만..
단일노드로 sentinel 사용하는 경우에, sentinel도 홀수개 이상으로 해야함 (과반수)
!!! 근데 mongodb는 노드들끼리 quorum에 의해 선출되므로 sentinel개념없다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[3차 스터디 단일리더 다중리더 리더없는 아키텍쳐]]></title>
            <link>https://velog.io/@goat_hoon/3%EC%B0%A8-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%8B%A8%EC%9D%BC%EB%A6%AC%EB%8D%94-%EB%8B%A4%EC%A4%91%EB%A6%AC%EB%8D%94-%EB%A6%AC%EB%8D%94%EC%97%86%EB%8A%94-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90</link>
            <guid>https://velog.io/@goat_hoon/3%EC%B0%A8-%EC%8A%A4%ED%84%B0%EB%94%94-%EB%8B%A8%EC%9D%BC%EB%A6%AC%EB%8D%94-%EB%8B%A4%EC%A4%91%EB%A6%AC%EB%8D%94-%EB%A6%AC%EB%8D%94%EC%97%86%EB%8A%94-%EC%95%84%ED%82%A4%ED%85%8D%EC%B3%90</guid>
            <pubDate>Mon, 02 Feb 2026 11:06:00 GMT</pubDate>
            <description><![CDATA[<h1 id="단일리더다중리더리더없음">단일리더/다중리더/리더없음</h1>
<h2 id="다중리더가-언제-쓰이는가">다중리더가 언제 쓰이는가</h2>
<p>나는 왜 다중 클러스터의 구조를 다중 리더의 구조라고 착각했는가?
이는 대충 알고 있기 때문일 것이다.</p>
<p>우리가 일반적으로 사용하는 레디스, 몽고에서의 다중 클러스터의 구조는 실제로 단일 리더로 작동하는 것은 맞다. 
다만 다중 클러스터 구조의 의의는 <strong>장애 전파 차단과 확장성</strong> 의 측면일 뿐이다. </p>
<p>다중 리더랑 분명히 구별지어서 생각해야하는데, 다중리더는 말 그래도 한가지 요청에 대해 N개 이상의 노드에다 write을 하는 것이다.
이렇게 되버리면 어떻게될지 생각해보자.
데이터 충돌이 분명히 발생한다. 
한국에 사는 내가 보낸 요청이 한국region의 노드에도 쓰이고, 미국 region의 노드에도 쓰이고 (by 복제), 
미국에 사는 A가 보낸 요청은 미국 region에 먼저쓰이고, 한국region에도 쓰일것이다.
그러면 각 region의 노드들은 데이터 충돌을 어떻게 감지해야할 것인가? 이것에 대한 전략자체는 DB에게 위임한다. 
그렇기에 더욱 쓰임이 애매한 DB 아키텍쳐일 것이다. 책에서 나온 예시로는 google docs 동시편집 기능을 예시로 들더라.
두가지 측면이다.</p>
<ol>
<li>동시 write가 발생하는 상황에서</li>
<li>문서 편집시 즉시 로컬 서버(노드)에 적용하고 나서 다른 사용자가 바라보고 있는 서버에 비동식으로 복제해서 UX 향상</li>
</ol>
<h2 id="다중-클러스터">다중 클러스터</h2>
<p>다중 클러스터는 DR을 위함이다.
만일 A, B클러스터 두가지를 운영하고 있는데,
A가 죽을경우, B가 대신 Active하게 전환되는 것. 그것이 Failover이다.
하지만, 데이터 유실을 어떻게할것인가? 
이를 위해서 데이터 유실을 최소화 하기위한 전략을 수립하고,
비동기복제를하든, 동기복제를하든, 로그기반 복제를하든 할것이다.</p>
<h2 id="리더-없음">리더 없음</h2>
<p>리더없는거 진짜 왜씀?
데이터 정합성도 꼬이고, 모든 노드에 다 write하게되는게 무슨 의미가있는가? 라고 생각했지만..
괜히 쓰이는게 아니더라.
fan-out-write가 급증하는 상황을 보자
예를들어 한명의 인플루언서가 하나의 게시글을 올리고 천만명의 팔로워가 해당 게시글을 읽어들여야할때.... 이상황을 어떻게할것인가?
사용자가 팔로우한 사람의 게시글을 읽어야하므로, 사용자가 보는 데이터에는 인플루언서가 올린 게시글이 포함되게끔 write이 필요할 것이다.
그럼 총 1000만개의 row나 document대상으로 write이 발생해야되는데.... 이걸 실제 DB가 감당할 수 있을까?</p>
<p>그렇기에 write노드를 여러개 동시에두고, N개의 노드중 W개의 노드에 쓰는 개념인거다.
어떤 노드들인지 간에 write에 참여하게 하고, 팔로워들을 특정 키값으로 데이터를 샤딩하게 된다면...? 
근데 여기서 또 생기는 궁금증은 그럼 이게 multi-leader랑 뭐가다른데? 
하지만 근본적으로 다르다. multi-leader는 데이터 여러개가 &#39;중복&#39;되어 각 leader에게 요청을 보낼 수 있는 것이겠지만, leaderless는 샤딩의 개념이 들어간다고 보면 된다. 즉 데이터 키값기반으로 데이터를 저장하고 write하는 노드 set은 키값기반으로 정해져있다</p>
<p>그러면 데이터정합성이 깨질 수 있는 상황이 발생할수도? (네트워크지연으로 key값 기반으로 a,b,c 노드 정해서 읽었는데, a노드가 장애상황이어서 outdated한 데이터값가지고 있으면 어떻게판별?)
그렇기에 데이터정합성이 중요하지 않은 비즈니스로직에서 leaderless 아키를 사용하기도 하고, 결국엔 (eventual)하게는 나중에는 outdated가 해결될 문제일 것으로 보인다. (그 전략을 vector로하든 LWW으로 하든..)</p>
<h2 id="만약에-fan-out-write이-큰-상황에서-leaderless-아키없이-설계한다고하면-어떻게-설계할것인가">만약에 fan-out write이 큰 상황에서, leaderless 아키없이 설계한다고하면 어떻게 설계할것인가?</h2>
<p>-&gt; 데이터를 chunk단위로 잘라서 db write을 하는 프로세스를 배치로 돌리든 개별 프로세스로 띄우든... 큐잉을 하든... (근데 지연은 생기겠지)</p>
<h1 id="rdb는-secondary-preferred가-없네">RDB는 secondary preferred가 없네</h1>
<p>RDB에는 몽고디비처럼 특정 클러스터의 secondary 노드대상으로 읽는 기능이 없고, 직접 인스턴스를 지정해줘야함.. 신기하더라</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[2차 스터디]]></title>
            <link>https://velog.io/@goat_hoon/2%EC%B0%A8-%EC%8A%A4%ED%84%B0%EB%94%94</link>
            <guid>https://velog.io/@goat_hoon/2%EC%B0%A8-%EC%8A%A4%ED%84%B0%EB%94%94</guid>
            <pubDate>Fri, 16 Jan 2026 15:16:13 GMT</pubDate>
            <description><![CDATA[<h1 id="mysql-vs-mongodb-쓰는-이유">MySQL vs MongoDB 쓰는 이유</h1>
<ol>
<li>스키마의 정합성 -&gt; 데이터마이그할때 통일성 존재</li>
<li>다양한 요청과 다양한 엣지케이스의 호환</li>
<li>Scale out vs Scale up
파티션과 샤딩
논리적 vs 물리적 
MySQL도 잘만하면 물리적 분리도 가능</li>
<li>Join 데이터 합침 (정규화 vs 비정규화의 차이)</li>
<li>Write 쓰기 성능의 차이 실제 MongoDB는 append only방식으로 삭제를 한다. 이후에 compaction. 반면 MySQL은 포인터를 따라가고 따라가서 실제 삭제를 진행</li>
<li>mongos의 역할 (mongo sharding)
프록시 얘기 등장 하지만 mongosh자체는 라우팅역할을 하기위한 필수 관문</li>
<li>shard rebalancing의 위험도? -&gt; kafka rebalancing이랑 비슷하지 않을까?  </li>
</ol>
<p>그러다가 mongos 의 역할이 궁금해짐. 라우팅인가?
실제 그럼 secondarypreferred로 read 요청 보내는 역할은 누가함? mongo driver가함 ㅇㅇ</p>
<p>그러다가 gateway얘기도 나옴. 스티키세션얘기도 나오고... alb얘기도 나옴</p>
<h1 id="l4-l7-lb">L4 L7 LB</h1>
<p>L4 L7 얘기를 엄청 깊게함
4계층은 tcp 계층까지만 7계층은 Http계층 까지
라우팅의 주체가 달라짐. tcp는 ip+port까지겠고, 7계층은 path + <strong>도메인!!</strong> 까지 보겠지
근데 L7이 꼭필요한 경우가 있을까? 있을 수도 있지 ㅇㅇ 급하게 라우팅을 틀어야하는 상황이 온다고하거나 정말 성능적으로 중요하다면?</p>
<p>그러다가 host 파일얘기가 나옴
(7계층에서 header-host보고 라우팅틀기도 함)
host + os cache에따라 dns 쿼리 질의를 안할 수 도있다.</p>
<p>극단적으로는, JVM프로세스의 dns resolver 캐싱 라이브러리 쓰다가 dns 정보 캐싱되어서 신규 dns로 라우팅되어야하는데 안되는 경우가 존재하기도 했음.
그래서 보통 k8s에서 dns cache 0정책을 도입하기도 함.</p>
<h1 id="zgc-vs-g1gc">ZGC vs g1gc</h1>
<p>또 나온이야기..참 어렵다
zgc는 함부로 쓰는게 아니다 대용량 heap 다룰때, 객체를 옮길 때 애플리케이션 스레드를 멈추지 않고,
old region과 new region을 동시에 유지하며 stw없이 gc하는거임
훨씬더 cpu더 많이쓸수밖에없고 메모리사용량도 더올라감 굳이 zgc를 꼭해야해음?
RSS commit메모리 정말 어려운 이야기..</p>
<p>근데 스레드 -Xss512 로 하고, 1000개 스레드 실행시켰는데, 이는 native메모리로 잡히거든? 
스택메모리는 무조건 고정인걸로 알고 있는데 왜 OOM안남? native메모리가 절대 그정도 용량 못담게끔 설정했는데.... why??</p>
<h1 id="운영환경에서-힙덤프-어떻게-분석하는게-정확할까">운영환경에서 힙덤프... 어떻게 분석하는게 정확할까?</h1>
]]></description>
        </item>
        <item>
            <title><![CDATA[데이터 마이그레이션 & 클렌징 회고 (MongoDB 온몸 비틀기)]]></title>
            <link>https://velog.io/@goat_hoon/mongodb-hard-migration</link>
            <guid>https://velog.io/@goat_hoon/mongodb-hard-migration</guid>
            <pubDate>Tue, 13 Jan 2026 11:08:25 GMT</pubDate>
            <description><![CDATA[<h1 id="서론">서론</h1>
<p>2025년 11월부터 2026년 1월까지 약 3개월간 데이터 마이그레이션에 대부분의 힘을 쏟아부었다.</p>
<p>사용자의 데이터 (1000만개) 를 구별하기 위한 키 값을 전환하는 것이 작업의 궁극적인 목표였다.
그 와중에 데이터 증식(?)이 일어나는 버그도 발견해서 소스 코드 긴급 수정 및 데이터 클렌징 작업도 하고... 참 다사다난 했다.</p>
<p>그래서 그 경험을 정리해보려고 한다!</p>
<h1 id="뚜렷하지-않은-요구사항-끝-없는-커뮤니케이션-하지만-그런대로-좋다">뚜렷하지 않은 요구사항, 끝 없는 커뮤니케이션, 하지만 그런대로 좋다.</h1>
<p>참 서러운 부제이다. 
SI회사의 특성상 위에서 내려오는 방침대로 우리 조직은 움직일 수 밖에 없다. 하지만 요구사항이 너무나도 추상적이었다. 키 값을 바꾸라는 것은 알겠는데, 무슨 키값을 사용할 것인지 정해져 있지도 않고, 키 값을 관리하고 있는 서비스의 API는 개발도 안되어있고... 모든 것을 내가 직접 수소문해가며 알아가야만 하고, 요청해야만 했다. </p>
<p>처음에는 스트레스였다. 
한가지 사항의 논의가 마무리되면 다른 곳에서 엣지케이스가 또 튀어나오고... 고난의 연속이었다. </p>
<p>근데 또 다른 사람들과 소통하며 사양을 하나하나 정해가는 재미도 있긴 했다. 원래라면 알지 못했던 도메인의 전반적인 그림을 그릴 수 있게 되었고, 도메인 지식도 많이 늘었다. 해당 도메인의 전문가가 된 기분이었다.</p>
<h1 id="보수적인-제약에서-온몸-비틀기">보수적인 제약에서 온몸 비틀기</h1>
<p>아무쪼록, 데이터 마이그를 위한 고려사항이 얼추 정리가 되면서 데이터 마이그 전략을 수립했다. 고려사항과 계획은 다음과 같다.</p>
<blockquote>
<p>&lt;고려 사항&gt;
<strong>키 값 전환을 위해 호출할 API 서버의 영향도를 최소한</strong>으로 해야하며, 
<strong>1월 이내</strong>의 모든 데이터를 마이그레이션해야 한다.</p>
</blockquote>
<p>&lt;계획&gt;</p>
<ol>
<li>어플리케이션 소스 코드에서 키 값 전환 로직 선 반영</li>
<li>배치를 통해 전환되지 않은 사용자 데이터 전체 전환</li>
</ol>
<p>처음에는 API서버의 응답값으로 키 값을 전환하는 것이 참 비효율적이라고 생각했다. 그래서 키 값 DB를 덤프떠서 공유해줄 수 있지 않을까 부탁드렸는데 거절당했다...ㅠ</p>
<p>만약 1000만건의 데이터를 10TPS의 HTTP요청으로 전환하려면 대략 12일동안 풀타임으로 배치를 돌려야 한다. 새벽시간에만 작업한다고 하면 36일이 걸린다. 
이는 불가능하다.</p>
<p>그렇다면 최선은 미리 어플리케이션에서 전환하는 로직을 추가하는 것이다. 
일반적으로 사용자의 데이터 업데이트는 조회 → 수정 흐름으로 이루어진다.
이를 활용해 다음과 같은 로직을 추가했다.
(물론 Index는 미리 걸고 작업했다!)</p>
<p>조회 시 특정 key 값이 없으면 → API 호출 후 key 값 채워넣기</p>
<p>이러한 로직을 반영해놓고 보니 엣지 케이스가 추가로 더 생겼다.
치명적이지 않은 데이터이긴 하지만, 기존 키값을 기반으로 신규 키값이 없는 경우가 존재했다. 차주가 바뀐 경우가 그러했는데, 이 부분은 비즈니스 중요성/민감도가 아주 낮은 데이터에서만 발생하는 현상이라 Known Issue로 아직 해결방안을 논의중이다.</p>
<h1 id="배치를-돌려볼까-근데-데이터가-왜이리-많아요">배치를 돌려볼까? 근데 데이터가 왜이리 많아요?</h1>
<p>12월에 어플리케이션에서 키값 전환 로직을 배포하고, 1월에 진행할 데이터 배치 작업을 계획했다.</p>
<blockquote>
<p>신규 key 값이 없는 데이터를 Fetching 해오고, API Call 을 통해 Upsert</p>
</blockquote>
<p>그런데 데이터 양을 산정하다가, 특정 데이터가 비이상적으로 많다는 사실을 알게 되었다. 
처음에는 히스토리를 잘몰라서, 그냥 뭐 데이터가 많이 쌓였나 보지? 라고 생각했었는데 알고보니 가입자 수만큼 데이터가 존재해야 하는 도메인이었다. 
우리나라 국민이 5000만명인데 2억 5천만명이 가입할 일은 없는 노릇이었다..</p>
<p>데이터 증식의 원인을 찾아보니 범인은 소스코드였다. 
서버로 인입되는 요청중에 사용자 키값이 없는 경우가 존재해서 이를 채워넣기 위해 타 MSA 서비스 응답값을 활용하는데 응답값에서 필드값을 가져오는 것이 아니라, 또다시 Request Body의 키값으로 upsert하는 로직으로 인해 끊임없이 가비지 데이터가 양산되는 구조였다.</p>
<p>바로 핫픽스..나갔다.. 이걸 이제야 알았다니!</p>
<h1 id="배치-계획과-데이터-클렌징을-빙자한-이관">배치 계획과 데이터 클렌징을 빙자한 이관</h1>
<p>처음에는 2억여 건의 데이터를 신규 key값으로 전환하기 위한 배치 계획을 세웠었다.
2억여건중 500만개의 데이터만 실제 데이터였고 (거의 2%),로컬 DB에서 실행계획/성능검증도 진행해본 결과 인덱스 효과를 톡톡히 볼 수 있었다. 
upsert하는 다큐먼트 수도 2%의 데이터라서 upsert로 인한 인덱스 쓰기 비용이 성능을 크게 뒤흔들 것 같진 않았다.</p>
<p>하지만, 찜찜하기도 하고 기술 부채를 남기고 싶지도 않았다. 아무리 새벽에 작업을 한다고 하지만 혹여나 서비스의 영향도가 있지 않을까 하는 두려움, 그리고 내가 안하면 언젠가 누군가 해야하는 일이었다. (그리고 그게 내가 될것 같기도 했다ㅋ) 그래서 그냥 내가 하기로 했다!</p>
<h3 id="delete인가-insert인가">delete인가 Insert인가</h3>
<p>데이터 클렌징 방법에는 두가지가 있다. (DB는 mongoDB다)</p>
<ol>
<li>Primary에서 delete</li>
<li>신규 컬렉션 생성 이후 기존 컬렉션에서 유효한 데이터만 이관 (aggregation)</li>
</ol>
<p>아무래도 99%의 데이터가 삭제대상이라서 1번을 하기 많이 부담이 되었다.</p>
<p>그렇다면 2번은 빠를까? 
직접 해보면 되지!
해봤더니 1억건중 3백만건을 신규 컬렉션으로 이관하는게 2분30초안에 끝났다.
데이터 삭제를 위한 Prmiary 쓰기 비용도 없고 Read에는 문제가 없는 이 방법으로 진행하기로 했다!</p>
<p>근데 한가지 고려해야할 부분은 있었다. 전환 과정에서 기존 컬렉션 다큐먼트에 Update 요청이 인입되면 그 데이터는 신규 컬렉션에 존재하지 않을 수 있으니 유실될 수도 있다.</p>
<h3 id="데이터-유실-감안해-말아">데이터 유실 감안해? 말아?</h3>
<blockquote>
<p>&lt;데이터가 유실되면 안되는 경우&gt;</p>
</blockquote>
<ul>
<li>어플리케이션 소스 코드 반영 
(1) 둘다 쓰기 
신규 컬렉션 생성 이후 update 요청들에 대해 기존 컬렉션과 신규 컬렉션의 둘다 upsert &amp; write
(2) 데이터 이관
이후 기존 컬렉션에서 신규 컬렉션으로 aggregation 이관<blockquote>
</blockquote>
&lt;데이터가 유실되도 괜찮은 경우&gt;
그냥 이관. 
다만 Collection renaming을 할것인지 말지 선택이 필요</li>
<li>Renaming 한다
Renaming 도중 컬렉션에 전체 Lock 발생.
만일 쿼리는 날아갔는데, 도중에 컬렉션 Lock으로 인해 요청이 대기한다면 어플리케이션 커넥션풀 고갈로 장애 발생 가능성 증가 </li>
<li>Renaming 안한다
이관 이후 어플리케이션 소스코드에서 신규 컬렉션을 바라보도록 배포 딸깍</li>
</ul>
<p>클렌징 대상 데이터의 성격상, 유실이 있다고 하더라도 사용자들이 불편함을 모른다 (그만큼 중요치 않고, VOC가 여태껏 없었다). 또한 클렌징 작업은 새벽에 작업하기도 하고, update 요청도 굉장히 드물다.</p>
<p>그래서 데이터 유실을 감안하고 작업하기로 했다.</p>
<p>다만, Read요청은 초당 10TPS정도로 꽤나 많은 요청이 인입된다. MongoDB 커넥션 풀의 개수가 Default 100개로 설정되어 있는데, 만일 Renaming에 10초이상 소요된다면 장애가 발생할 여지가 존재했다.
그래서 Renaming 없이 어플리케이션에서 바라보는 컬렉션을 변경하는 배포를 진행하기로 했다.</p>
<h1 id="클렌징-작업-당일-해프닝">클렌징 작업 당일 해프닝</h1>
<p>지금 글을 작성하는 오늘 새벽 이관을 진행했는데 많은일이 존재했다..</p>
<ol>
<li>분명 로컬에선 2분30초안에 끝났는데 1시간이 지나도 끝나지 않는 쿼리 ($match $merge)</li>
<li>쿼리 실행 한시간쯤 지나니까 connection fail로 실패함</li>
<li>이게 쿼리 실패인줄 알고, 처음부터 다시 이관해야하니까 신규로 쌓인 컬렉션 drop시키고 좀더 빠른 dump 이관 커맨드 실행 ($out)</li>
<li>connection fail은 클라이언트의 입장일 뿐 DB입장에서는 계속 실행되고 있는 쿼리 작업이었음을 뒤늦게 인지 ($out은 마지막에 컬렉션 생성할텐데 왜 지금 만들어져있지?? 하고 인지하게됨)</li>
<li>currentOp로 op id 확인후 kill, 그리고 무한 기다림</li>
<li>아무리 기다려도 $out 결과로 신규 컬렉션이 뿅하고 나타야하는데 안나타남. 그리고 쿼리 종료</li>
<li>2차 멘붕후 기존 $match $merge 방식으로 진행</li>
</ol>
<p>그래서 작업시간이 거의 5시간이 걸렸다..</p>
<p>이번 작업을 하면서 한가지 알아간 사실은 $out 방식으로 기존 컬렉션 dump로 이관하는 방식은 컬렉션을 생성하는 마지막 단계에서 오래걸릴 수도 있다. 기존 컬렉션 덤프 내용을 메모리든 디스크에 올려놓고 그대로 복사하는 작업이라 금방 끝날줄 알았는데 그게 아니었다. (다른 브랜드에서 정상적인 flow로 작업했을 때에는 한 15분정도 걸린것 같다 - 이때에는 약 250만개의 데이터? 약 8기가정도였던 것 같았다)</p>
<p>위 6번에서 문제가 되었던 부분이.. 아마 중간에 내가 컬렉션을 drop했기 때문이 아닐까 싶다. 정확히 원인은 알 수가 없지만..</p>
<h1 id="남은-것은-배치뿐-wpartial-index">남은 것은 배치뿐 w.Partial-Index</h1>
<p>이제 남은 것은 배치이다.
Chunk 단위로, TPS조절이 가능하며, 엣지케이스를 고려한 쿼리, 인덱스도 다 마련해 놓은 상태이다.
성능 검증도 어느정도 된 것 같다.</p>
<p>엣지 케이스를 고려할때 Partial Index를 도입했다.
엣지 케이스가 앞에서 말한 신규 key값이 존재하지 않는 경우인데, 이 경우에는 신규 key값을 upsert칠 수 없게된다.</p>
<p>만일 API call 대상의 다큐먼트를 찾기 위한 쿼리가 다음과 같다면
<code>find(newKey : ${exist:false})</code>
엣지케이스의 경우에는 이미 한번 요청이 되어서 실패했지만 newKey가 없으므로 다시 Http 요청 대상에 포함되어질 것이다.</p>
<p>이를 방지하기 위해서 한번 요청이 끝났지만 newKey가 없는 대상을 후보지에서 제외 하기 위해 flag key 값을 upsert할 것이다.</p>
<p>그 이후, find쿼리는 다음처럼 작성한다.
<code>find(newKey : ${exist:false}, flag : {$exist: false})</code>
partialIndex는 newKey가 존재하지 않는 대상으로 filter하게끔 생성하면 된다.</p>
<blockquote>
<p>(참고) 
<code>find(flag : {$exist: false})</code>
이렇게 해도 partial index를 타긴 하더라!</p>
</blockquote>
<h1 id="배치-작업을-준비하며">배치 작업을 준비하며</h1>
<p>pod를 local로 port-forwarding해서 local 에서 내부 API endpoint로 요청하여 받아온 응답값을 토대로 DB에 직접 접근하여 upsert하는 방식으로 진행할 예정이다.</p>
<p>하지만.. 카펜터 때문인지 pod가 node로 부터 evict되는 drain 현상이 있어서 pod와의 연결이 끊길 수 있었다.</p>
<p>찾아보니 service name으로 포트포워딩하면 알아서 pod랑 붙을 수 있다는 사실을 알게되었다.</p>
<p>그런데도 불구하고, 포트포워딩 대상의 pod는 항상 고정되므로.. 문제는 여전했다.</p>
<p>이는 .bat파일로 스크립트를 만들어서 port forwarding 연결이 끊기면 자동으로 다시 service name 기준으로 port forwarding 될 수 있도록 스크립트를 실행하여 해결할 수 있었다. fail over도 성공적이었다!</p>
<p>이젠 실행만 남았다.</p>
<h1 id="마무리하며">마무리하며</h1>
<p>사실 배치라는 마지막 관문이 남았긴 한데..
3달간의 긴 여정을 마무리하는 느낌이라 설레발치며 글을 작성해봤다.
뭐 잘 되겠지~!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[1/13 데이터중심어플리케이션설계 3장까지]]></title>
            <link>https://velog.io/@goat_hoon/113-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%A4%91%EC%8B%AC%EC%96%B4%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98%EC%84%A4%EA%B3%84-3%EC%9E%A5%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@goat_hoon/113-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%A4%91%EC%8B%AC%EC%96%B4%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98%EC%84%A4%EA%B3%84-3%EC%9E%A5%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Fri, 09 Jan 2026 15:44:25 GMT</pubDate>
            <description><![CDATA[<h2 id="각자-이야기">각자 이야기</h2>
<h3 id="아웃박스-패턴과-인박스-패턴">아웃박스 패턴과 인박스 패턴</h3>
<p>카프카 이벤트와 DB 트랜잭션을 하나의 통 트랜잭션처럼 묶는다 = 아웃박스 (밖으로 나가는 트랜잭션을 박싱한다)
정확히 말하면 “DB 트랜잭션만을 신뢰하고, 이벤트 발행은 DB에 기록한 뒤 비동기로 보장한다”</p>
<p>인박스는 들어오는것 (중복/재처리 막는것)
“들어온 이벤트를 DB에 먼저 기록해서 중복 처리·재처리를 안전하게 만드는 패턴”</p>
<h3 id="kafka-장애-포인트">Kafka 장애 포인트</h3>
<p>동기 Kafka pub로직으로 인해 서비스 전체장애가 났을때, 만약 동기 pub 로직을 유지해야만 했다면 어떻게 해야했을까?</p>
<ol>
<li>Kafka timeout을 매우 줄인다</li>
<li>Kafka 로직 / API 로직을 모듈별로 분리하여 배포한다</li>
</ol>
<p>비동기 스레드풀로 위임한다고 해도 문제가 있지 않을까?
비동기 워커스레드풀 전략에 따라 다르다. 스레드풀 다차면 거절할건지 대기할건지.. </p>
<h2 id="책이야기">책이야기</h2>
<p>인메모리 디비 vs 데이터베이스 언젠간 합쳐질것이다
인메모리 디비의 안티캐싱방식 -&gt; OS의 가상메모리와 스왑파일처럼 메모리보다 더 큰 용량을 인메모리디비가 다룰 수 있게된다 (인메모리디비의 메모리 - 인메모리디비의 디스크)</p>
<p>이와 관련해서 얘기가 나왔던게 보통의 캐싱방식이라면 메모리에도 쓰고 (redis) 하드디스크에도 쓰는데(DB) 인메모리디비 구조 내부의 메모리와 하드디스크에서 cache eviction &amp; flush로 인해서 또다른 overhead가 있지 않을까?... 결과는 당연하고. overhead가 없다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[임계 트래픽에서 메모리가 급증한 이유 (12/22해결완료) : 8GB -> 600MB 최적화]]></title>
            <link>https://velog.io/@goat_hoon/solve-memory-issue-of-latency</link>
            <guid>https://velog.io/@goat_hoon/solve-memory-issue-of-latency</guid>
            <pubDate>Sat, 13 Dec 2025 10:25:32 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>3개월간 분석했던 문제의 원인을 드디어 찾아내 개선했던 경험을 기록한다.
근본적인 원인은 부족한 CPU 코어 위에서 Netty 이벤트루프와 가상 스레드 및 GC가 급증하는 트래픽을 처리하기 위해 과도한 일을 떠안으면서 스케줄링 지연이 발생했고, 그 결과 메모리 과부하가 일어난 것으로 추측된다.
실제로, CPU를 1 → 2 로 증가시킨 후, 모든 문제가 해소되었다. 8GB의 사용량에서 600MB정도로 메모리 사용량을 최적화할 수 있었다.
이러한 문제를 해결하기까지 경험했던 여러 삽질들과 배웠던 점들을 기록한다.</p>
</blockquote>
<h1 id="수많은-부하테스트--heapthread-dump">수많은 부하테스트 &amp; heap/thread dump</h1>
<p>메모리 과부하 원인을 찾기 위해 heap dump와 thread dump를 수 없이 떠봤다.</p>
<p>실제 운영환경과 비슷한 환경에서 부하테스트를 하기 위해 docker로 CPU 및 메모리 제한을 주고 서버를 띄운 뒤 stress test를 진행했다.
그런데 아무리 부하를 많이 줘도 운영 환경처럼 메모리 사용률이 드라마틱하게 증가하지 않았다.
처음에는 부하테스트를 잘못했는지 의심부터 했다. 하지만 문제는 다른 곳에 있었는데, 그것은 바로 <strong>GC옵션</strong> 이다.</p>
<h2 id="zgc-vs-g1gc">ZGC VS G1GC</h2>
<p>운영환경에서의 GC는 ZGC를 사용하고 있었고, 부하테스트를 했을 때에는 JDK21의 기본 GC인 G1GC를 사용하고 있었다. 혹시나 해서 ZGC옵션으로 바꿔서 진행해보니 메모리가 운영환경과 비슷하게 피크를 치는 현상이 발생했다.
옳다구나, 그래서 바로 Heap Dump를 진행했다. 분명 의미가 있는 정보가 나오겠지 생각했다.</p>
<h3 id="heap-dump를-해도-thread-dump를-해도-차이가-없어요-native-memory-살펴보기">Heap Dump를 해도, Thread Dump를 해도 차이가 없어요.. native memory 살펴보기</h3>
<p>ZGC 와 G1GC 환경에서 메모리 피크가 일어나는 시점에 아무리 heap dump를 떠봐도, 두 환경에서의 메모리 차이는 정말 미약했다. 해봤자 100MB 안쪽이었던 것 같다. 분명 메모리 사용량은 8배가 차이가 났는데 (350 MB vs 2GB) 어째서 heap dump의 차이는 없는지 의아했다. (아마 Full GC를 한번 트리거하기 때문이 아닐까?)
그래서 heap dump로 보지 못하는 메모리 누수가 있는지 확인하기 위해 JVM옵션에 <code>-XX:NativeMemoryTracking=detail</code> 을 키고 차이를 확인해봤다. 
어라? mmap의 committed메모리 지표의 차이가 실제 사용률의 차이랑 비슷했다.</p>
<h3 id="zgc와-g1gc의-본질적인-차이">ZGC와 G1GC의 본질적인 차이</h3>
<p>ZGC와 G1GC는 전혀 다른 메카니즘이다.
공통적으로 JVM환경에서 Java Heap은 OS가 제공하는 가상 주소 공간을 사용한다.
하지만 ZGC는 STW (Stop-The-World) 시간을 최소화 하기 위해서 heap을 ZPage로 쪼개고, relocation을 STW없이 하기 위해 추가적인 공간을 더 사용한다. 그러기 위해 충분히 많은 committed 메모리를 할당한다고 한다.
반면, G1GC는 relocation을 위해 STW를 하고, GC가 일어난다.</p>
<h3 id="그래서">그래서?</h3>
<p>메모리 사용률의 드라마틱한 차이는 추가적인 가상 메모리 주소 공간을 할당하기 위한 충분한 committed 메모리로부터 기인한다.</p>
<p>이러한 사실을 알고난 후에, 운영환경에서의 프로메테우스 지표로 RSS메모리 비율, 컨테이너 캐시 메모리 비율을 보여주는 대시보드를 살펴봤는데 피크시간대에 RSS메모리 비율은 고작 5%의 안팎이었고, 컨테이너 캐시 메모리 비율이 80%에 육박했음을 알 수 있었다. 캐시 메모리는 ZGC에 의해 OS로부터 메모리를 받아와 commit 메모리일 것이다.</p>
<blockquote>
<p>추가적으로 찾아보니, RSS 메모리에는 committed 메모리가 포함되지 않는다고 한다. &#39;실제 사용&#39;하는 RAM의 크기일 뿐, committed되었다고 다 사용하는건 아니니까.. 당연한 말이다.
다만, 메모리 사용량 지표에는 committed 지표는 포함이된다.
하지만 여기서 왜 heap 사용량은 8기가 까지 쳤는데 컨테이너의 rss메모리사용량은 600메가인지 잘 이해가 되지는 않는다..</p>
</blockquote>
<p>여태까지 고작 heap 메모리사용량 비율로 문제를 판단했었는데, 좀 더 정확하게 분석하기 위해서는 RSS메모리, 캐시메모리 비율을 살펴봐야겠다. 특히 ZGC를 사용하는 상황에서는 말이다.</p>
<h2 id="그렇다면-메모리-사용률이-peak치는-것은-무엇때문일까">그렇다면 메모리 사용률이 peak치는 것은 무엇때문일까?</h2>
<p>그것은 바로 너무 부족한 CPU 코어 탓이다. 
<code>System Load Average</code> 지표를 봤는데, I/O 지연시간이 늘어날 때, 해당 지표가 Core수를 상회했다. 약 4배?
반면, CPU의 사용률은 안정적이라면 <code>스케줄링 병목</code> 이 발생한다는 뜻이다. 즉, CPU는 작업을 할당 받으면 어차피 I/O bound한 작업이니까 사용률은 높진 않지만, 할당 받으려고 기다리는 작업들이 너무 많다는 뜻으로 해석할 수 있다.</p>
<p>현재 나의 상황에서 적용해보자면 아래로 정리할 수 있다.</p>
<blockquote>
<ol>
<li>Netty event loop에서 비동기로 응답을 받으면,</li>
<li>event loop가 다시 가상 스레드를 깨워야 하고</li>
<li>그 가상스레드가 다시 carrier thread를 잡아 실행해야 한다.</li>
</ol>
</blockquote>
<p>I/O 지연 → 참조 객체 수명 증가 → heap 사용량 증가 → GC마저 스케줄링을 받지 못해 GC지연 (실제 메모리 peak때 GC횟수 0) → Heap PEAK! → GC수행시 Heap 복구</p>
<blockquote>
<p>컨테이너 캐싱 메모리는 Pod 지표에서 볼 수 있다. JVM에서 사용하는 메모리를 캐싱하는 주체는 컨테이너이니까 당연하다. 애꿎은 JVM지표에서 찾지 말자. </p>
</blockquote>
<h1 id="결론">결론</h1>
<p>CPU를 늘려 실제 가상스레드 및 GC스레드의 스케줄링 지연을 빠르게 받게 하도록 하자. 그게 근본 원인이다!</p>
<p>CPU overload가 core수의 약 4배이다.
실제 가상스레드가 수행되는 carrier thread의 개수 또한 CPU의 개수랑 동일하게 잡히니, CPU를 2배 높이면 carrier thread가 2배증가되고, CPU또한 병렬처리가 2배더 병렬처리가 되어지므로 4배의 효과를 볼 것이라고 예상해서 2배 증가시켰다.</p>
<p>그 결과는 메모리 사용량을 8GB에서 600MB안쪽으로 최적화 할 수 있었다!</p>
<p>근데.. 당장은.. CPU 늘려서 해결은 했는데.. ZGC를 쓰는게 맞을까...?
G1GC로 바꾸면 훨씬 안정적인 것 같았다.</p>
<blockquote>
<p>참고자료</p>
</blockquote>
<ul>
<li><a href="https://d2.naver.com/helloworld/0128759">https://d2.naver.com/helloworld/0128759</a></li>
<li><a href="https://www.blog-dreamus.com/post/zgc%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C">https://www.blog-dreamus.com/post/zgc%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C</a></li>
<li><a href="https://www.packtpub.com/en-us/learning/how-to-tutorials/getting-started-with-z-garbage-collectorzgc-in-java-11-tutorial/">https://www.packtpub.com/en-us/learning/how-to-tutorials/getting-started-with-z-garbage-collectorzgc-in-java-11-tutorial/</a>
위 두가지 블로그에서 ZGC의 자세한 원리가 잘 정리되어 있어 참고했다. 내용이 복잡하고 어렵지만 몇번씩 정독해보면 좋을 것 같다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Collscan이 아니라고요? (MongoDB)]]></title>
            <link>https://velog.io/@goat_hoon/why-this-is-collscan</link>
            <guid>https://velog.io/@goat_hoon/why-this-is-collscan</guid>
            <pubDate>Tue, 02 Dec 2025 16:38:59 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>N억개 이상의 Documents에 신규 필드값 (key-value) 값을 추가해야 하는 요구사항이 들어왔다. 실제 운영중인 서비스에 영향도를 최소화하기 위해, 어떠한 전략으로 작업해야할까? 
그 고민의 흔적속에서 얻었던 사소하지만 중요한 MongoDB의 특징에 대해 정리하려고 한다.</p>
</blockquote>
<h1 id="문제-상황-정의">문제 상황 정의</h1>
<p>사용자의 데이터를 분간하기 위한 신규 Key값이 추가되었다.
이에 따라, 기존에 사용했던 고유ID를 통해 외부 서비스를 호출하여 신규 Key값 (이하 K)으로 전환및 저장하기 위한 작업이 필요했다. </p>
<p>운영중인 서비스의 영향도를 최소화하기 위해 (최대한 많은 사용자 Data들을 실시간으로 업데이트 하기 위해) 서버로 인입된 요청에 대해 K필드값이 없을 경우 이를 추가하는 로직을 미리 반영해놓은 상태이다. 또한, K필드값에 대해 단일 인덱스도 추가해놓았다.
하지만 해당 API 반영 이후부터 인입이 없었던 사용자들은 전환이 되지 않았을 것이므로, 이러한 Data들 또한 데드라인 전까지 전환하기 위해 배치 작업을 계획했다. </p>
<h1 id="생각">생각</h1>
<h2 id="1-find-k-exist-false-limit">(1) find( &quot;K&quot;: {$exist: false} ).limit()</h2>
<p>가장 처음으로 대충(?) 생각해봤을때 생각했던 쿼리이다.
그 당시에 이 쿼리가 실제로 어떻게 동작할까 생각했던 내용은 다음과 같다.</p>
<p>(참고: mongo cursor 기반으로 대량 데이터를 처리하는 방식은 옳지 않다고 생각했다. 배치 실행시간이 외부 API의 처리속도와 반비례하기 때문에 장기간 mongo cursor를 유지하기 위한 DB 서버 부하를 생각하지 않을 수 없었다)</p>
<blockquote>
</blockquote>
<p>(1) &quot;K&quot;필드값에 대한 인덱스는 있지만, 엄밀히 말하면 &quot;K&quot; 필드값이 없는 Document를 대상으로 하므로 Collscan일 것이다.</p>
<blockquote>
</blockquote>
<p>(2) (1)을 보완하기 위해 limit을 걸어보자. 그러면 Collscan중간에 limit 개수만큼 Document가 fetching된다면 Collscan이 멈춰서 DB부하를 줄일 수 있지 않을까?</p>
<p>(1)은 확실하다고 생각해서, (2)번만 검증이 필요했다. 공식 문서나 geeksforgeeks.. DBA통해서 들은 결과로는 나의 생각이 맞았다.</p>
<p>근데 한가지 찝찝했던 부분이 있었다.
초기 쿼리 실행시, 아직 업데이트 되지 않은 다큐먼트들이 많아 limit개수만큼 금방 fetching될 것이지만, 쿼리를 실행하면 실행할수록, 조건에 부합하는 Document들의 개수가 줄 것이고, 그에 따라 Collscan의 영향을 받을 것이라고 생각했다.</p>
<p>확실치는 않으니 일단 선택지중 하나로 남겨놓고 다른 확실한 방법을 생각해봤다.</p>
<h2 id="2-objectid-_id-기반으로-fetching하기">(2) ObjectId (_id) 기반으로 Fetching하기</h2>
<p>아까 전의 방식은 Collscan + limit 방식이라 문제가 생겼다면, Collscan을 하지 않으면 되는 것이 아닌가?</p>
<p>현재 Document들에 저장되고 있는 key값중의 <code>createDate</code> 필드가 있었고, 이 필드에 인덱스를 추가 후, 날짜별로 range scan하는 방식을 생각해보았다.
그러면 날짜의 range만큼만 fetching되어 사실상 IdxScan + limit와 비슷한 효과를 낼 수 있다.
근데 N억개의 다큐먼트에 인덱스를 거는 것이라 솔직히 조금 부담이 되었다. 그리고 또 작업이 끝나면 불필요한 인덱스라 삭제도 해야했고.. 귀찮은 일이었다.</p>
<p>그래서 생각한것이 ObjectId. 즉 기본 몽고 고유 ID 필드값이다.
이전에 MongoDB 서적을 읽으며 ObjectId의 앞 몇자리가 시간과 관련된 정보라고 얼핏 봤던 기억이 났다.
그러면, 굳이 createDate 필드에 인덱스를 추가하지 않고도, 기존에 존재하는 Id만으로도 같은 효과를 낼 수 있는것이 아닌가?!</p>
<p>이 또한 검증을 해보기로 했었다.</p>
<h1 id="검증">검증</h1>
<p>먼저 <code>find( &quot;K&quot;: {$exist: false} ).limit()</code>
쿼리가 나의 생각대로 K필드값이 없는 다큐먼트가 극소수라면 Collscan의 영향을 받게될지 검증해봤다.</p>
<p>데이터 셋을 두가지로 가져갔다. (환경: mongo6.0 standalone container 환경)
두가지 경우 모두 K필드에 인덱스를 걸었다.</p>
<ol>
<li>데이터 1억개 (K필드값 없는 다큐먼트 0)</li>
<li>데이터 1억개 (K필드값 있는 다큐먼트 9999만개, K필드값 없는 다큐먼트 1만개)</li>
</ol>
<p>그후 두가지 데이터셋에서 쿼리를 날려봤다. 실행시간 속도도 측정해봤다.</p>
<p>?? 엥 ?? 차이가 없다
다시 해봤다. ?? 엥 차이가없다.</p>
<p>실행계획을 분석해봤다. 
Fetch를 했고, inputStage에 IXSCAN이 있다.
인덱스 스캔 이후에 Fetching을 했다는 소리인데... 다큐먼트에는 인덱스 필드가 없는데 어떻게 된 일이지?</p>
<h1 id="이유">이유</h1>
<p><a href="https://www.mongodb.com/docs/manual/reference/operator/query/exists/#use-a-sparse-index-to-improve--exists-performance">https://www.mongodb.com/docs/manual/reference/operator/query/exists/#use-a-sparse-index-to-improve--exists-performance</a>
위 공식문서를 참고하자
이럴때는 항상 공식문서를 보아야한다. GPT를 믿어서도 안된다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/063e925e-0d9b-4205-931d-f540ebae177d/image.png" alt="">
뭔가 non-sparse index가 걸린 것 같았다. non-sparse index일 경우에 Fetch를 사용한다는데.. 이게 뭔지 찾아봤다.</p>
<blockquote>
<p>공식문서 내용
non-sparse indexes contain all documents in a collection, <strong>storing null values for those documents that do not contain the indexed field.</strong></p>
</blockquote>
<p>이 뜻이 index대상의 필드가 없어도 null처리 되어서 indexing이 된다는 뜻이었다.
그리고 index를 걸때 어떠한 옵션도 주지 않으면 default로 non-sparse index라고 한다.
그래서 인덱스를 거는데 오래걸렸나? 하고 K필드 index 사이즈 확인해보니 452메가쯤 되어있었다. 
음.. 뭔가 있긴 했다.</p>
<p>그래서 index를 삭제하고 다시 쿼리를 수행해봤더니, 겁~~나게 느린걸 확인할 수 있다.
실제 실행계획도 Collscan이었다.</p>
<p>그래서 sparse-index로 옵션을 주고 다시 인덱스를 생성해보니 인덱스 사이즈가 0에 가까웠다. 
(쿼리 실행은 안해봤는데 쿼리실행도 해볼걸 그랬다.)</p>
<h1 id="정리">정리</h1>
<p>K필드값이 non-sparse index라면 <code>find( &quot;K&quot;: {$exist: false} ).limit()</code> 요렇게 작업해도 무방하다는 결과를 얻게 되었다.
간단한 방법이 있으니, 굳이 ObjectId로 fetching하는 방식은 사용하지 않아도 될것 같다.
어쨌거나, 두가지를 얻어갈 수 있었다.</p>
<ol>
<li>non-sparse index의 특징 : 필드값이 없어도 index 대상이 된다</li>
<li>ObjectId로 만들어진 시각을 역추적할 수 있다.</li>
</ol>
<p>물론 옵티마이저가 최적화를 해주긴 하겠지만,
초기에는 Collscan으로 limit개수만큼 fetching을 하고, 그 이후에 index가 효용가치가 있어지면 그때부터 sparse-index를 통한 IxScan + fetching이 이뤄지지 않을까? 추측중이다..</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[임계 트래픽에서 메모리가 급증한 이유 (2): Opentelemetry & gRPC / OkHttp ]]></title>
            <link>https://velog.io/@goat_hoon/otel-with-grpc-makes-duration</link>
            <guid>https://velog.io/@goat_hoon/otel-with-grpc-makes-duration</guid>
            <pubDate>Fri, 21 Nov 2025 08:49:29 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p><a href="https://velog.io/@goat_hoon/%EC%9E%84%EA%B3%84-%ED%8A%B8%EB%9E%98%ED%94%BD%EC%97%90%EC%84%9C-%EB%A9%94%EB%AA%A8%EB%A6%AC%EA%B0%80-%EA%B8%89%EC%A6%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0-Virtual-Thread-Netty-1">지난 시리즈</a> 
지난 번에는 가상스레드에서 Netty 네트워크 라이브러리를 사용하는 문제 때문에 메모리 급증 현상이 발생했다는 것에 집중했다.</p>
</blockquote>
<p>이번에는 동일한 아키텍쳐 위에 Log 및 Metric 수집을 위한 Opentelemetry 라이브러리를 적용한 후에 발생한 또다른 메모리 증폭 현상에 대해 집중하려고 한다. 이 현상은 트래픽이 적어 메모리 급증 현상이 없었던 서비스 또한 해당 현상이 재현되었다... 아키텍쳐는 지난 포스팅을 참고하자.</p>
<h1 id="문제-포착-및-가설">문제 포착 및 가설</h1>
<p><strong>Opentelemetry 라이브러리를 추가</strong>하여, Metric/Log 수집부에 gRPC/Http로 전송하게 하였더니, 가상스레드 내부에서 Redis 조회하는 메서드와 MQTT로 Pub하는 메서드의 <strong>Duration Time이 또 다시 증가</strong>하였다. 그 결과 때문인지, 이전과 동일하게 <strong>메모리가 폭증</strong>하는 현상이 일어났다. 오히려 이전과 동일한 메모리 자원을 사용했다면 OOM이 발생했을만큼 &#39;더&#39; 늘어났다. 
<a href="https://velog.io/@goat_hoon/%EC%9E%84%EA%B3%84-%ED%8A%B8%EB%9E%98%ED%94%BD%EC%97%90%EC%84%9C-%EB%A9%94%EB%AA%A8%EB%A6%AC%EA%B0%80-%EA%B8%89%EC%A6%9D%ED%95%9C-%EC%9D%B4%EC%9C%A0-Virtual-Thread-Netty-1">이전 포스팅</a>을 보면 알 수 있겠지만, 이 현상이 가상스레드의 OverHead라고 생각했다. 그렇다면 왜 Opentelemetry 로그 전송을 추가했을 뿐인데 이전과 동일한 현상이 발생했을까? 그 원인에 대해 분석한 내용에 대해 정리하려고 한다.</p>
<h1 id="로그-수집-flow">로그 수집 Flow</h1>
<p>Metric과 Log 수집 관련된 Flow는 잘 알지 못해, 관련 부서에 문의를 드렸다. 
문의 드렸던 내용과 결과는 다음과 같다.</p>
<ol>
<li>Log Event가 발생할때마다 수집부로 I/O가 발생하는지?
→ I/O Flush는 N초 주기로 일어나거나 Log Queue Size가 가득 찼을때 발생</li>
<li>I/O 구현체가 무엇인가? Netty를 사용하는가?
→ 정확히는 알 수 없지만, Opentelemetry에서 사용하는 네트워크 구현체를 따라간다.</li>
</ol>
<h1 id="deep-dive">Deep Dive</h1>
<p>일단 서비스로 인입되는 QPS와 이에 따른 로그 사이즈로는 절대 Flush가 N초 주기로 일어날 리가 없다. 굉장히 짧은 시간안에 Log Queue Size가 가득 찼을 것이고, 이로 인해 수많은 I/O가 발생했을 것이라고 확신했다.</p>
<p><code>gradle dependencies</code>로 라이브러리 의존성을 확인해보니 OkHttp가 추가되어있었다. 네트워크 I/O를 보낼때 OkHttp를 사용하나보다. 
<img src="https://velog.velcdn.com/images/goat_hoon/post/01dbc9dd-f2a1-43d1-bfe3-52568cd138b7/image.png" alt="">그러면 OkHTTP가 어떤 방식으로 I/O를 처리하는지 알아볼 필요가 있다. (근데 코틀린으로 되어있더라. 신기했다!)
내부 구조를 살펴보니 <code>OkHttpGrpcSender</code> 클래스에서 로그를 send하는 메서드를 호출할 때, <code>OkHttpClient</code> 클래스에서 이를 호출하고 있다. 정확히는 OkHttpClient 내부의 Dispatcher를 호출한다. 
<img src="https://velog.velcdn.com/images/goat_hoon/post/a241b155-592e-44ea-859d-e74171ea8a0c/image.png" alt="">Dispatcher 내부에는 <code>ExecutorService</code> 를 직접 생성해서 비동기 I/O 작업을 함을 알게 되었다.
그런데 이상한부분은, 스레드풀의 스레드 개수가 사실상 무한대였다.. 아래를 보면 믿게 될 것이다.</p>
<p>실제 enqueue 하는 구현부를 사진으로 보자면
<img src="https://velog.velcdn.com/images/goat_hoon/post/e168032a-e6a4-417b-b9f8-6874814363c7/image.png" alt="">
해당 메서드에서 마지막에 <code>promoteAndExecute()</code>를 호출하는데, 요 메서드 마지막부분에 <img src="https://velog.velcdn.com/images/goat_hoon/post/8d73f506-e9e8-41da-a5c7-f010c0d60f34/image.png" alt=""> <code>executorService()</code> 메서드를 통해 싱글톤 스레드 풀을 사용하고 있었다. 만일, 싱글톤객체로 JVM에 올라와 있지 않으면 스레드의 개수가 무한대까지 생성 가능한 스레드풀을 생성한다..<img src="https://velog.velcdn.com/images/goat_hoon/post/6994c293-b267-4177-ba32-66d87618afab/image.png" alt=""></p>
<h2 id="okhttpclient-io-스레드-풀-전략-메모리-폭탄의-범인인가">OkHttpClient I/O 스레드 풀 전략.. 메모리 폭탄의 범인인가?</h2>
<pre><code class="language-java">this.executorServiceOrNull = (ExecutorService)(new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, (BlockingQueue)(new SynchronousQueue()), Util.threadFactory(Util.okHttpName + &quot; Dispatcher&quot;, false)));</code></pre>
<p>커스텀 스레드풀을 사용하고 있다. <code>SynchronousQueue</code> 와 함께 무한대까지 스레드를 생성할 수 있다. 이 Queue 전략은 스레드 풀 내에 유휴스레드가 없으면 &#39;무조건&#39; 스레드를 생성하는 방법이다. 그래서 조심히 사용해야하는 Queue이다. 나는 이 전략에서 문제가 있어서 스레드를 계속해서 생성하지 않았을까 추측했다.
하지만 유명한 오픈소스는 이러한 부분이 역시 고려가 되어있었다. 바로 아래 부분이다.</p>
<pre><code class="language-java">private final boolean promoteAndExecute() {
   Object $this$assertThreadDoesntHoldLock$iv = this;
   int isRunning = false;
   if (Util.assertionsEnabled &amp;&amp; Thread.holdsLock($this$assertThreadDoesntHoldLock$iv)) {
      throw new AssertionError(&quot;Thread &quot; + Thread.currentThread().getName() + &quot; MUST NOT hold lock on &quot; + $this$assertThreadDoesntHoldLock$iv);
   } else {
      List executableCalls = (List)(new ArrayList());
      isRunning = false;
      synchronized(this) {
         int var4 = false;
         Iterator var10000 = this.readyAsyncCalls.iterator();
         Intrinsics.checkNotNullExpressionValue(var10000, &quot;readyAsyncCalls.iterator()&quot;);
         Iterator i = var10000;

         while(true) {
            if (i.hasNext()) {
               RealCall.AsyncCall asyncCall = (RealCall.AsyncCall)i.next();
               if (this.runningAsyncCalls.size() &lt; this.maxRequests) {
                  if (asyncCall.getCallsPerHost().get() &lt; this.maxRequestsPerHost) {
                     i.remove();
                     asyncCall.getCallsPerHost().incrementAndGet();
                     Intrinsics.checkNotNullExpressionValue(asyncCall, &quot;asyncCall&quot;);
                     executableCalls.add(asyncCall);
                     this.runningAsyncCalls.add(asyncCall);
                  }
                  continue;
               }
            }

            isRunning = this.runningCallsCount() &gt; 0;
            Unit var9 = Unit.INSTANCE;
            break;
         }
      }

      int i = 0;

      for(int var10 = executableCalls.size(); i &lt; var10; ++i) {
         RealCall.AsyncCall asyncCall = (RealCall.AsyncCall)executableCalls.get(i);
         asyncCall.executeOn(this.executorService());
      }

      return isRunning;
   }
}
</code></pre>
<p>먼저 I/O 작업을 해야할 작업리스트 <code>executableCalls</code> 를 생성하고,
<code>while</code> 반복문 안에서 현재 등록된 비동기 작업들인 <code>readyAsyncCalls</code>에서 하나씩 순회하며
<code>runningAsyncCalls.size() &lt; maxRequests</code> 인 동안, 즉 실행하고 있는 작업들의 크기가 최대 수행 작업의 크기보다 작을 경우에만 실행 대상 후보에 포함하고,
<code>callsPerHost &lt; maxRequestsPerHost</code> 인 것만 실행대상에 추가한다.
마지막에 실제로 스레드 풀에 넘기는 건 실행대상에 추가된 작업들만 진행한다. </p>
<p>따라서 Dispatcher가 maxRequests, maxRequestsPerHost로 동시 실행 가능한 I/O 스레드 개수를 제한하고 있기 때문에,
단일 Dispatcher 기준으로는 스레드가 무한히 증가하는 상황은 거의 발생하지 않는다.</p>
<h1 id="back-to-problem">Back to Problem</h1>
<p>너무 메모리로만 치우쳐져서 생각하고 있는 것 같다.
다시 돌아와서, 내가 분석해야할 가설은 &#39;Duration 증가&#39;로 인한 &#39;Memory 증가&#39;현상이다. </p>
<p>&#39;Duration 증가?&#39;</p>
<h1 id="분석-결과--duration증가">분석 결과 : Duration증가???</h1>
<p>&#39;지연&#39;에 초점을 맞춰 보면, 현재 발생한 문제는 
로그가 쌓이는 속도 &gt; 전송/처리 속도 때문인 것으로 보인다.
처음에는 OpentelemetryAppender 내부에 BlockingQueue가 존재하고, 로그를 한 번에 모아 보내는 방식이라고 추정해 BatchLogRecordProcessor를 사용하고 있을 것이라 생각했다.</p>
<p>그러나 실제 호출 스택과 SDK 구현을 따라가보면, 배치 방식이 아닌SimpleLogRecordProcessor 를 사용하고 있는 것으로 보인다..
즉, 로그가 발생할 때마다 별도의 큐에 저장해 비동기적으로 모아 보내는 것이 아니라,
log.info()를 호출한 스레드가 즉시 Exporter까지 동기로 전송 요청을 건넨다.
이때 OkHttp Dispatcher 클래스에서 동시 요청 제한 갯수로 인해 병목이 생겨
추가 요청은 큐에 대기하거나 block 상태에 머무르게 되고,
그만큼 로그 이벤트가 발생한 가상 스레드가 직접적으로 지연의 영향을 받게 되는 것이다.
즉 아래와 같은 상황이다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/59974778-2720-433b-8db4-901db81b9ef8/image.png" alt=""></p>
<h1 id="해결-방안">해결 방안</h1>
<p>실제 로그 수집 방법, 혹은 설정 파라미터를 개선하거나, CPU성능을 더 좋게하는 방식으로 scale up 하는게 좋아보인다.
당장은 CPU의 성능을 높이는 것이 blocking 스레드의 병목을 줄일 수 있는 방법일 것이다.</p>
<p>근데 이거.. 디버깅포인트로 실제 어떤 stack trace로 함수호출이 이뤄지는지 구현해내는게 쉽지가 않다..허..</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[임계 트래픽에서 메모리가 급증한 이유 (1): Virtual Thread & Netty ]]></title>
            <link>https://velog.io/@goat_hoon/%EC%9E%84%EA%B3%84-%ED%8A%B8%EB%9E%98%ED%94%BD%EC%97%90%EC%84%9C-%EB%A9%94%EB%AA%A8%EB%A6%AC%EA%B0%80-%EA%B8%89%EC%A6%9D%ED%95%9C-%EC%9D%B4%EC%9C%A01-Virtual-Thread-Netty</link>
            <guid>https://velog.io/@goat_hoon/%EC%9E%84%EA%B3%84-%ED%8A%B8%EB%9E%98%ED%94%BD%EC%97%90%EC%84%9C-%EB%A9%94%EB%AA%A8%EB%A6%AC%EA%B0%80-%EA%B8%89%EC%A6%9D%ED%95%9C-%EC%9D%B4%EC%9C%A01-Virtual-Thread-Netty</guid>
            <pubDate>Wed, 19 Nov 2025 11:48:05 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>평상시에는 메모리 사용량이 300MB 안쪽으로 사용했던 서비스가, 트래픽이 몰리는 피크시간 때마다 메모리사용량이 메모리 임계치만큼(6G) 치솟는 현상이 발생했다.
이후, 지속적인 GC로 6GB → 300MB → 6GB → 300MB 로 요동을 치다가 트래픽이 잠잠해지면 다시 300MB 안쪽으로 유지되었다.
또한, 트래픽이 2배정도 적은 곳에도 같은 서비스코드로 운영되고 있는데, 그 서비스는 트래픽이 많은 시간대에도 300MB안쪽으로 유지되고 있었다.</p>
</blockquote>
<p><strong>즉, 특정 임계치 이상의 트래픽을 받으면 메모리 과점유 현상이 발생하고 있는 문제가 발생중이다.</strong></p>
<blockquote>
</blockquote>
<p>이 현상을 한달 반째 틈날 때마다 분석중인데.. 아직 해결이 되지는 않았지만 나를 위해서라도 이를 정리해보려고 한다.</p>
<h1 id="대략적인-아키텍처">대략적인 아키텍처</h1>
<p><img src="https://velog.velcdn.com/images/goat_hoon/post/cb50f4e7-f6cc-4992-a918-551472205ff6/image.png" alt="">
대강의 아키텍쳐는 다음과 같다.</p>
<p>(1) 초당 &#39;N&#39;k씩 들어오는 메세지를 구독하여 
(2) 이를 Virtual Thread로 위임 하고, Virtual Thread에서
(3) redis 조회 후,
(4) MQTT로 PUB 한다.</p>
<p>이 때, Virtual Thread는 무한히 생성할 수 있으므로, 상한을 CPU개수에 비례하게 설정하였다. </p>
<h1 id="가설-세우기">가설 세우기</h1>
<h2 id="1-뻘짓한-가설---kafka-메시지-병목">(1) 뻘짓한 가설 - Kafka 메시지 병목</h2>
<blockquote>
<p>서버가 처리할 수 있는 요청보다 더 많은 메세지를 받아서 서버 메모리에 부하를 일으켰다.
즉, Virtual Thread 개수가 병목이다.</p>
</blockquote>
<p>~~로컬에서 실제 운영환경의 코드와 비슷하게 만들어놓고 부하테스트를 돌렸다. Virtual Thread의 개수를 1개만 두고, 메세지를 1000개, 2000개, 3000개를 발행해도 메모리는 동일했다.</p>
<p>도대체 왜지?</p>
<p>이는 Spring Kafka에서 메세지를 받았음을 전송하는 Ack가 별다른 설정이 없으면 <code>@KafkaListener</code> 어노테이션이 붙은 메서드가 완료될때, 비로소 Kafka에 ack를 보내기 때문이다.
지금의 상황은 Listener 어노테이션이 적용된 메서드가 동기방식이기 때문에, 실제 서버 내부 로직이 끝나기 전까지는 Ack를 보내지 않아 구독되는 메세지의 양은 일정할 수 밖에 없다.
그러니 메모리가 안튀지! 다소 바보같았던 가설이었다.~~</p>
<p>아니다. poll은 무조건 주기적으로 한다. 다만 컨슈머 스레드가 가상스레드 큐로 작업을 건네주려는데 한개의 태스크가 최대 사이즈라, 병목이 생겨서 polling이 느려진것 뿐이다.</p>
<h2 id="2-그럴듯한-가설---cpu가-부족하다">(2) 그럴듯한 가설 - CPU가 부족하다</h2>
<blockquote>
<p>CPU가 부족해서 메모리가 터졌다.</p>
</blockquote>
<p>다소 어이없어보이지만 이 가설에는 타당한 근거가 있다.
지금부터 이 가설을 뒷받침하는 글을 쓰려고 한다.</p>
<h1 id="근거-→-duration-time">근거 → Duration Time</h1>
<p>Virtual Thread에서 I/O는 두 번 발생한다.</p>
<ol>
<li>Redis</li>
<li>MQTT</li>
</ol>
<p>이 때, 트래픽이 많아지는 시점에 해당 I/O들에서 지연시간이 크게 늘어남을 확인할 수 있었다.
CPU랑 연관지어 생각한다면, 지연시간이 크게 늘어났고, 그 지연시간만큼 수많은 가상스레드들이 JVM을 점유하고 있을 것이다. max duration time이 400ms였으니.. 하나의 메세지를 처리하기 위한 객체(가상스레드 포함)들이 동시간대에 무지막지하게 생성되었을 것이다.</p>
<p>그렇다면 왜 지연이 생겼을까?</p>
<p>할 수 있는한 모든 경우의 수를 열어놓고 딥다이브했다.</p>
<ol>
<li><p>가상스레드에서 발생하는 Redis I/O가 Unmount되지 않고 Blocking이 되고있기 때문일까?
그 말은, Redis에 부하가 있다는 이야기인데, Redis 부하는 관측되지 않았다.</p>
</li>
<li><p>Redis I/O가 Unmount되면서 다른 가상스레드가 Carrier Thread에 의해 점유되며 뒤쳐지기 때문일까?
Redis 클라이언트가 Lettuce이고, Lettuce가 Netty기반으로 동작해서... mount될 수도 있고 안될 수도있고...</p>
</li>
</ol>
<p>2번까지 찾아보고 나니, Lettuce가 뭐고 Netty가 뭔지 궁금해졌다. (pinning은 없고, unmount가 되는 것같다. 이는 나중에 검증해봐야겠다)</p>
<h2 id="netty가-뭐에요">Netty가 뭐에요?</h2>
<p>spring boot에서 config로 직접 클라이언트 구현체를 지정하지 않는 이상, 기본으로 채택되는 redis client가 Lettuce이고 Lettuce는 netty기반으로 I/O가 동작한다. 
Netty는 비동기/논블록킹 I/O란다.</p>
<p><strong>여기서 중요한점은 Netty가 사용하는 비동기 스레드 풀의 개수는 실제 사용가능한 processor, 즉 CPU의 개수 라는 점이다.</strong></p>
<blockquote>
<p>공식문서 &amp; 소스코드 참고
<a href="https://redis.github.io/lettuce/advanced-usage/#default-thread-pool-size">https://redis.github.io/lettuce/advanced-usage/#default-thread-pool-size</a></p>
</blockquote>
<p>실제 io.lettuce.core.cluster 하위에 있는 <code>RedisClusterClient</code> 클래스는 <code>AbstractRedisClient</code> 를 상속받는데, 기본 생성자를 호출하면 하위 메서드를 호출하고 <code>DefaultClientResources.create()</code>를 호출하게 된다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/4a375fa2-57f0-433c-996b-bd7584aaa819/image.png" alt=""> 처음으로 <code>DefaultClientResources</code> 가 참조되는 순간 static block에서 ioThread의 초기화가 일어나는데<img src="https://velog.velcdn.com/images/goat_hoon/post/05330a30-9b37-44e3-979a-c8a163de367d/image.png" alt="">이 개수가 실제 CPU의 개수와 같다.
현재 운영환경에서 Pod의 CPU는 1vCPU이지만, Node는 한 16쯤 되므로, 거의 16개의 이벤 io thread가 존재한다는 이야기이다.</p>
<h2 id="확신">확신</h2>
<p>여기쯤에서 이런생각이 들었다. 
(1) 트래픽이 몰려서 수많은 가상스레드들이 생성됨 → 
(2) Redis와 비동기 스레드풀의 개수가 <del>16개(노드 vCPU)로 제한됨</del> 3개 (k8s CPU limit 1로 지정할 경우 pod vCPU로 제한됨. 하지만 netty 이벤트 루프 스레드 개수는 최소 3개로 지정됨 <code>max(vCPU,3)</code>) → 
(3) 실제 pod가 사용가능한 CPU는 1vCPU인데, 3개의 비동기 스레드풀이 스케줄링 되다보니 처리지연이 생김 </p>
<p>근데 더욱 확신을 가지게 된 부분이, MQTT 브로커 통신마저 Netty를 사용함을 알게되었다.
그러면 정말 pod의 vCPU가 낮아서가 문제가 아닐까?</p>
<blockquote>
<p>💡 참고사항</p>
</blockquote>
<p>실제로 Lettuce 구현체를 사용할때, <code>RedisTemplate.opsForValue().get()</code> 을 사용하게 되면 아래 메서드 로직을 탄다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/e340199f-e868-41e3-9dc3-6ba41f02033f/image.png" alt=""><img src="https://velog.velcdn.com/images/goat_hoon/post/527f8399-2e01-47f1-a95b-122eae08496f/image.png" alt="">statusCommand 가 false이면 pipeline에서 future.get()으로 비동기 작업이 종료되기를 대기함을 알 수 있다. 이게 실제로 unmount되는 작업인지 아닌지는 나중에 알아보도록 하자.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[컨테이너 재기동 원인 분석하다가 JVM까지 파헤친 썰 (부제: Kafka vs Feign 호출 JVM 스레드 상태 차이)]]></title>
            <link>https://velog.io/@goat_hoon/why-jvm-thread-is-runnable-when-io</link>
            <guid>https://velog.io/@goat_hoon/why-jvm-thread-is-runnable-when-io</guid>
            <pubDate>Fri, 19 Sep 2025 13:47:26 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>불과 한 달 전에는 Kafka Pub 로직이 워커 스레드 풀에서 동기 방식으로 동작하면서, Kafka 장애 발생 시 타임아웃이 날 때까지 스레드가 <code>timed waiting</code> 상태에 머무는 문제가 있었다. 그 결과 컨테이너가 무한 재기동되는 상황이 발생했다.</p>
<p>그런데 이번에는 <code>FeignClient</code>를 통한 외부 MSA 서비스 호출에서 응답 지연이 발생하면서 컨테이너가 재기동되었다. 하지만 이전과 달리 timed waiting 상태의 스레드가 늘어나지는 않았다. 그렇다면 이번에는 어떤 과정을 통해 Feign 호출 응답 지연이 컨테이너 재기동으로 이어졌는지를 정리하려 한다. 그 과정에서 JVM까지 파헤쳐봤다!</p>
</blockquote>
<h1 id="1-장애-원인-분석-배경">1. 장애 원인 분석 배경</h1>
<p>사용자를 인증하는 서비스에 장애가 발생했다.
내가 담당하는 서비스는 해당 인증서비스를 <code>FeignClient</code>로 호출하여 유효한 사용자가 맞는지 인증하는 로직이 구현되어 있다. 그래서 인증서비스를 호출할 때마다 Time Out 혹은 응답 지연이 발생하였고, Feign Client는 워커스레드 풀에서 실행되기 때문에 Time Out/지연이 발생할 때마다 해당 시간만큼 워커스레드가 점유가 되었을 것이다.
그래서 나는 당연히 이전에 카프카 장애 발생 상황(<a href="https://velog.io/@goat_hoon/sync-kafka-logic-fails-msa">해당 글</a>)과 동일하게 스레드들이 timed waiting 상태에 빠졌을 것이라고 생각했다. 근데 컨테이너 재기동시점에 timed waiting 상태의 스레드가 극소수였다. 하지만 Runnable 상태의 스레드가 급증했다. 이게 어떻게 된 일일까?</p>
<h1 id="2-장애-원인-재분석">2. 장애 원인 재분석</h1>
<p>나의 예상이 틀렸다는 것을 알게 되었을 때, 혹시 재기동이 다른 원인에 있지 않을지 생각해봤다. 하지만 쿠베 이벤트 로그상, <code>readiness probe failed</code> 가 발생했고, 해당 시간대의 metric 지표상에 특이점 또한 없었다. 즉, 이전 카프카 장애 상황과 동일했다.
다만, 다른 점이 있다면, 이번에는 카프카가 아닌 FeignClient 호출의 지연이었다는 점이다. 
그래서 나의 공부 친구 GPT랑 티키타카좀 했다. (처음에는 metric 수집 지표가 15초주기 간격이라 블랙박스때문에 timed waiting 스레드가 수집되지 않는 것이 아닐까 생각했다.)
<img src="https://velog.velcdn.com/images/goat_hoon/post/dcd6ba7e-9d2b-4dc1-9cf1-9de0a6bb340c/image.png" alt=""></p>
<p>통 답변은 다음과 같다. (한줄 요약은 맨 마지막에!)</p>
<blockquote>
<p>좋은 관찰이에요 👀
말씀하신 “Feign 호출이 10초 이상 블로킹인데 Grafana에서 Timed Waiting 스레드 수가 눈에 띄게 안 늘어난다” 현상은 여러 가지 이유가 있을 수 있습니다. 하나씩 짚어드릴게요.</p>
</blockquote>
<ol>
<li>JVM 스레드 상태와 모니터링 지표의 간극<blockquote>
</blockquote>
Feign은 기본적으로 블로킹 HTTP 호출을 하므로, 응답 대기 동안 호출 스레드는 WAITING 또는 TIMED_WAITING 상태에 들어갑니다.
(예: Object.wait(), LockSupport.parkNanos() 같은 메서드 내부에서 잡힘)<blockquote>
</blockquote>
그런데 메트릭 수집기는 “샘플링” 방식이라서, 15초 간격으로 스레드 덤프 비슷한 스냅샷을 떠서 집계합니다.<blockquote>
</blockquote>
만약 그 시점에 운 좋게(?) 블로킹 중인 스레드가 잡히지 않으면, 실제로는 수십 개가 대기 중이더라도 지표에 안 보일 수 있습니다.<blockquote>
</blockquote>
👉 즉, 수집 주기가 길면 &quot;스파이크&quot;성 상태는 놓치기 쉽다는 게 첫 번째 이유예요.<blockquote>
</blockquote>
</li>
<li>Worker 스레드 풀 크기와 관측치<blockquote>
</blockquote>
Undertow의 Worker 풀은 보통 coreThreads 이상일 때는 큐잉 → idle 스레드 사용하는 구조예요.<blockquote>
</blockquote>
Feign 대기가 길어도 풀 사이즈가 충분히 크면 단순히 “대기 중 스레드 수”가 크게 늘어나 보이지 않을 수 있습니다.<blockquote>
</blockquote>
즉, 늘어나더라도 전체 스레드 수 대비 비율이 작아 메트릭 대시보드에서 눈에 띄지 않는 것일 수도 있어요.<blockquote>
</blockquote>
</li>
<li>지표 수집 방식 차이<blockquote>
</blockquote>
jvm.threads.states 같은 Micrometer/Prometheus 지표는 스레드 상태별 갯수를 계수합니다.<blockquote>
</blockquote>
그런데 Feign 내부가 반드시 TIMED_WAITING만 쓰는 건 아니고, RUNNABLE + 소켓 read 블로킹 상태일 수 있어요.<blockquote>
</blockquote>
즉, OS 레벨에서 보면 소켓 read 대기지만, JVM은 그냥 RUNNABLE로 찍혀서 &quot;Waiting 스레드&quot;로는 안 잡힐 수도 있습니다.<blockquote>
</blockquote>
특히 HttpURLConnection 기반은 native socketRead0()에서 블록 → JVM 레벨에서는 RUNNABLE 상태로 분류되는 경우가 흔합니다.<blockquote>
</blockquote>
👉 그래서 “내가 예상한 TIMED_WAITING 스레드 수”와 JVM 지표 간에 괴리가 생겨요.<blockquote>
</blockquote>
</li>
<li>결론적으로<blockquote>
</blockquote>
수집 주기 문제: 15초 간격이라 스파이크성 대기 상태를 놓칠 수 있음.<blockquote>
</blockquote>
스레드 상태 분류 문제: Feign의 블로킹은 RUNNABLE로 보일 수 있어 TIMED_WAITING에 안 잡힘.<blockquote>
</blockquote>
풀 크기 문제: Worker 풀 여유가 크면 단순히 “대기 스레드가 많다”는 현상이 시각적으로 안 보일 수 있음.<blockquote>
</blockquote>
✅ 체크해보실 것<blockquote>
</blockquote>
스레드 덤프 직접 떠보기 (jstack / jcmd) → 정말로 어떤 상태로 블로킹되는지 확인 (RUNNABLE vs TIMED_WAITING)<blockquote>
</blockquote>
수집 주기 줄이기 (예: 5초) → 더 세밀하게 스파이크 잡힘<blockquote>
</blockquote>
대기 큐 길이/사용률 지표도 같이 모니터링 → 스레드 상태만 보는 것보다 원인 파악이 쉬움<blockquote>
</blockquote>
👉 그래서 질문 주신 대로 “15초 인터벌 수집 때문에 안 보이는 걸까?” → 네, 가능성이 높습니다.
다만 <strong>근본적으로는 Feign의 블로킹이 JVM 레벨에서 RUNNABLE로 찍히는 경우가 많아서, TIMED_WAITING 스레드 수가 안 늘어나는 건 정상적인 현상일 수도 있어요.</strong></li>
</ol>
<h3 id="근본적으로-feign의-blocking이-jvm-레벨에서-runnable로-찍힌다">근본적으로, Feign의 Blocking이 JVM 레벨에서 Runnable로 찍힌다...?</h3>
<p>이 답변을 보고 띄용했다. 운영체제 시간에 배운 바로는, 네트워크 I/O호출시 waiting상태에 빠진다고 들었는데 Feign Client 호출 응답 지연시 Runnable 상태라니.. 말이 안된다고 생각했다. Runnable은 엄밀히 CPU에 의해서 실행되고 있는 스레드를 말하는 것이 아닌가??</p>
<h1 id="3-thread-dump-떠보기">3. Thread Dump 떠보기</h1>
<p>보고도 믿기질 않아서, 정말로 Feign 호출 응답 지연 상황 발생시, JVM의 스레드 상태를 알아 보기로 했다. 마찬가지로 Kafka pub 호출 지연 상황 발생시에도 같은 과정을 반복해보았다. </p>
<blockquote>
<p>구현 코드 : <a href="https://github.com/goathoon/playground/commit/d1d37505e88136f998371ae95af0a3ecd826aee3">커밋 링크</a></p>
</blockquote>
<h2 id="1-feign">(1) Feign</h2>
<h3 id="준비-과정">준비 과정</h3>
<ol>
<li>Feign 호출 서버</li>
<li>Feign 응답 서버 (Thread sleep로직을 추가하여 호출 서버의 응답 지연 상황 재현)</li>
<li>지연 상황 발생중 호출 서버의 PID로 Thread dump</li>
</ol>
<h3 id="결과">결과</h3>
<blockquote>
<p>(참고) 스레드 명이 XNIO-1 task-N 으로 보이는 것은 undertow 웹 서버를 사용했기 때문이다. </p>
</blockquote>
<pre><code>&quot;XNIO-1 task-9&quot; #67 [65027] prio=5 os_prio=31 cpu=14.32ms elapsed=270.26s tid=0x0000000125862c00 nid=65027 runnable  [0x0000000174253000]
   java.lang.Thread.State: RUNNABLE
    at sun.nio.ch.Net.poll(java.base@21.0.5/Native Method)
    at sun.nio.ch.NioSocketImpl.park(java.base@21.0.5/NioSocketImpl.java:191)
    at sun.nio.ch.NioSocketImpl.timedRead(java.base@21.0.5/NioSocketImpl.java:280)
    at sun.nio.ch.NioSocketImpl.implRead(java.base@21.0.5/NioSocketImpl.java:304)
    at sun.nio.ch.NioSocketImpl.read(java.base@21.0.5/NioSocketImpl.java:346)
    at sun.nio.ch.NioSocketImpl$1.read(java.base@21.0.5/NioSocketImpl.java:796)
    at java.net.Socket$SocketInputStream.read(java.base@21.0.5/Socket.java:1099)
    at java.io.BufferedInputStream.fill(java.base@21.0.5/BufferedInputStream.java:291)
    at java.io.BufferedInputStream.read1(java.base@21.0.5/BufferedInputStream.java:347)
    at java.io.BufferedInputStream.implRead(java.base@21.0.5/BufferedInputStream.java:420)
    at java.io.BufferedInputStream.read(java.base@21.0.5/BufferedInputStream.java:399)
    at sun.net.www.http.HttpClient.parseHTTPHeader(java.base@21.0.5/HttpClient.java:827)
    at sun.net.www.http.HttpClient.parseHTTP(java.base@21.0.5/HttpClient.java:759)
    at sun.net.www.protocol.http.HttpURLConnection.getInputStream0(java.base@21.0.5/HttpURLConnection.java:1705)
    at sun.net.www.protocol.http.HttpURLConnection.getInputStream(java.base@21.0.5/HttpURLConnection.java:1614)
    at java.net.HttpURLConnection.getResponseCode(java.base@21.0.5/HttpURLConnection.java:531)
    at feign.Client$Default.convertResponse(Client.java:114)
    at feign.Client$Default.execute(Client.java:110)
    at feign.SynchronousMethodHandler.executeAndDecode(SynchronousMethodHandler.java:92)
    at feign.SynchronousMethodHandler.invoke(SynchronousMethodHandler.java:53)
    at feign.ReflectiveFeign$FeignInvocationHandler.invoke(ReflectiveFeign.java:104)
    at my.playground.playing.threadstateonjvm.$Proxy82.slow(Unknown Source)
    at my.playground.playing.threadstateonjvm.FeignController.test(FeignController.java:17)
    at java.lang.invoke.LambdaForm$DMH/0x00000070015ec400.invokeVirtual(java.base@21.0.5/LambdaForm$DMH)
    at java.lang.invoke.LambdaForm$MH/0x00000070015f2c00.invoke(java.base@21.0.5/LambdaForm$MH)
    at java.lang.invoke.Invokers$Holder.invokeExact_MT(java.base@21.0.5/Invokers$Holder)
    at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(java.base@21.0.5/DirectMethodHandleAccessor.java:154)
    at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(java.base@21.0.5/DirectMethodHandleAccessor.java:103)
    at java.lang.reflect.Method.invoke(java.base@21.0.5/Method.java:580)
    at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:258)
    at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:191)
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:986)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:891)
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)
    at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:527)
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)
    at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:614)
    at io.undertow.servlet.handlers.ServletHandler.handleRequest(ServletHandler.java:74)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:129)
    at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
    at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
    at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
    at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
    at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
    at io.undertow.servlet.handlers.FilterHandler.handleRequest(FilterHandler.java:84)
    at io.undertow.servlet.handlers.security.ServletSecurityRoleHandler.handleRequest(ServletSecurityRoleHandler.java:62)
    at io.undertow.servlet.handlers.ServletChain$1.handleRequest(ServletChain.java:68)
    at io.undertow.servlet.handlers.ServletDispatchingHandler.handleRequest(ServletDispatchingHandler.java:36)
    at io.undertow.servlet.handlers.RedirectDirHandler.handleRequest(RedirectDirHandler.java:68)
    at io.undertow.servlet.handlers.security.SSLInformationAssociationHandler.handleRequest(SSLInformationAssociationHandler.java:117)
    at io.undertow.servlet.handlers.security.ServletAuthenticationCallHandler.handleRequest(ServletAuthenticationCallHandler.java:57)
    at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
    at io.undertow.security.handlers.AbstractConfidentialityHandler.handleRequest(AbstractConfidentialityHandler.java:46)
    at io.undertow.servlet.handlers.security.ServletConfidentialityConstraintHandler.handleRequest(ServletConfidentialityConstraintHandler.java:64)
    at io.undertow.security.handlers.AuthenticationMechanismsHandler.handleRequest(AuthenticationMechanismsHandler.java:60)
    at io.undertow.servlet.handlers.security.CachedAuthenticatedSessionHandler.handleRequest(CachedAuthenticatedSessionHandler.java:77)
    at io.undertow.security.handlers.AbstractSecurityContextAssociationHandler.handleRequest(AbstractSecurityContextAssociationHandler.java:43)
    at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
    at io.undertow.servlet.handlers.SendErrorPageHandler.handleRequest(SendErrorPageHandler.java:52)
    at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
    at io.undertow.servlet.handlers.ServletInitialHandler.handleFirstRequest(ServletInitialHandler.java:276)
    at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:135)
    at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:132)
    at io.undertow.servlet.core.ServletRequestContextThreadSetupAction$1.call(ServletRequestContextThreadSetupAction.java:48)
    at io.undertow.servlet.core.ContextClassLoaderSetupAction$1.call(ContextClassLoaderSetupAction.java:43)
    at io.undertow.servlet.handlers.ServletInitialHandler.dispatchRequest(ServletInitialHandler.java:256)
    at io.undertow.servlet.handlers.ServletInitialHandler$1.handleRequest(ServletInitialHandler.java:101)
    at io.undertow.server.Connectors.executeRootHandler(Connectors.java:395)
    at io.undertow.server.HttpServerExchange$1.run(HttpServerExchange.java:861)
    at org.jboss.threads.ContextHandler$1.runWith(ContextHandler.java:18)
    at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2513)
    at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1538)
    at org.xnio.XnioWorker$WorkerThreadFactory$1$1.run(XnioWorker.java:1282)
    at java.lang.Thread.runWith(java.base@21.0.5/Thread.java:1596)
    at java.lang.Thread.run(java.base@21.0.5/Thread.java:1583)</code></pre><p>주목할 부분은 다음과 같다.</p>
<blockquote>
</blockquote>
<p>java.lang.Thread.State: <strong>RUNNABLE</strong>
at <strong>sun.nio.ch.Net.poll</strong>(java.base@21.0.5/Native Method)
at
...
my.playground.playing.threadstateonjvm.<strong>FeignController.test</strong>(FeignController.java:17)</p>
<p>실제로 Feign 호출의 응답 지연동안, JVM 스레드가 RUNNABLE 상태라는 사실을 볼 수 있다.</p>
<h2 id="2-kafka">(2) Kafka</h2>
<h3 id="준비-과정-1">준비 과정</h3>
<ol>
<li>Kafka pub 호출 서버 (워커스레드풀 실행) ++ Springframework.kafka.core 라이브러리 사용</li>
<li>Kafka 연결되지 않게 설정 (본인은 ACL로 pub시 AuthException 떨어지게 설정하였음)</li>
<li>지연 상황 발생중 호출 서버의 PID로 Thread dump</li>
</ol>
<h3 id="결과-1">결과</h3>
<pre><code>&quot;XNIO-1 task-43&quot; #103 [54019] prio=5 os_prio=31 cpu=0.54ms elapsed=32.83s tid=0x000000010f1a2000 nid=54019 in Object.wait()  [0x000000017b14b000]
   java.lang.Thread.State: TIMED_WAITING (on object monitor)
    at java.lang.Object.wait0(java.base@21.0.5/Native Method)
    - waiting on &lt;0x00000006824004e0&gt; (a org.apache.kafka.clients.producer.internals.ProducerMetadata)
    at java.lang.Object.wait(java.base@21.0.5/Object.java:366)
    at org.apache.kafka.common.utils.SystemTime.waitObject(SystemTime.java:55)
    - locked &lt;0x00000006824004e0&gt; (a org.apache.kafka.clients.producer.internals.ProducerMetadata)
    at org.apache.kafka.clients.producer.internals.ProducerMetadata.awaitUpdate(ProducerMetadata.java:120)
    - locked &lt;0x00000006824004e0&gt; (a org.apache.kafka.clients.producer.internals.ProducerMetadata)
    at org.apache.kafka.clients.producer.KafkaProducer.waitOnMetadata(KafkaProducer.java:1176)
    at org.apache.kafka.clients.producer.KafkaProducer.doSend(KafkaProducer.java:1025)
    at org.apache.kafka.clients.producer.KafkaProducer.send(KafkaProducer.java:991)
    at org.springframework.kafka.core.DefaultKafkaProducerFactory$CloseSafeProducer.send(DefaultKafkaProducerFactory.java:1103)
    at org.springframework.kafka.core.KafkaTemplate.doSend(KafkaTemplate.java:852)
    at org.springframework.kafka.core.KafkaTemplate.observeSend(KafkaTemplate.java:820)
    at org.springframework.kafka.core.KafkaTemplate.send(KafkaTemplate.java:597)
    at my.playground.playing.threadstateonjvm.KafkaController.pub(KafkaController.java:19)
    at java.lang.invoke.LambdaForm$DMH/0x00000070011b9c00.invokeVirtual(java.base@21.0.5/LambdaForm$DMH)
    at java.lang.invoke.LambdaForm$MH/0x0000007001109000.invoke(java.base@21.0.5/LambdaForm$MH)
    at java.lang.invoke.Invokers$Holder.invokeExact_MT(java.base@21.0.5/Invokers$Holder)
    at jdk.internal.reflect.DirectMethodHandleAccessor.invokeImpl(java.base@21.0.5/DirectMethodHandleAccessor.java:154)
    at jdk.internal.reflect.DirectMethodHandleAccessor.invoke(java.base@21.0.5/DirectMethodHandleAccessor.java:103)
    at java.lang.reflect.Method.invoke(java.base@21.0.5/Method.java:580)
    at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:258)
    at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:191)
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:986)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:891)
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)
    at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:914)
    at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:547)
    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)
    at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:614)
    at io.undertow.servlet.handlers.ServletHandler.handleRequest(ServletHandler.java:74)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:129)
    at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
    at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
    at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
    at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
    at io.undertow.servlet.core.ManagedFilter.doFilter(ManagedFilter.java:67)
    at io.undertow.servlet.handlers.FilterHandler$FilterChainImpl.doFilter(FilterHandler.java:131)
    at io.undertow.servlet.handlers.FilterHandler.handleRequest(FilterHandler.java:84)
    at io.undertow.servlet.handlers.security.ServletSecurityRoleHandler.handleRequest(ServletSecurityRoleHandler.java:62)
    at io.undertow.servlet.handlers.ServletChain$1.handleRequest(ServletChain.java:68)
    at io.undertow.servlet.handlers.ServletDispatchingHandler.handleRequest(ServletDispatchingHandler.java:36)
    at io.undertow.servlet.handlers.RedirectDirHandler.handleRequest(RedirectDirHandler.java:68)
    at io.undertow.servlet.handlers.security.SSLInformationAssociationHandler.handleRequest(SSLInformationAssociationHandler.java:117)
    at io.undertow.servlet.handlers.security.ServletAuthenticationCallHandler.handleRequest(ServletAuthenticationCallHandler.java:57)
    at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
    at io.undertow.security.handlers.AbstractConfidentialityHandler.handleRequest(AbstractConfidentialityHandler.java:46)
    at io.undertow.servlet.handlers.security.ServletConfidentialityConstraintHandler.handleRequest(ServletConfidentialityConstraintHandler.java:64)
    at io.undertow.security.handlers.AuthenticationMechanismsHandler.handleRequest(AuthenticationMechanismsHandler.java:60)
    at io.undertow.servlet.handlers.security.CachedAuthenticatedSessionHandler.handleRequest(CachedAuthenticatedSessionHandler.java:77)
    at io.undertow.security.handlers.AbstractSecurityContextAssociationHandler.handleRequest(AbstractSecurityContextAssociationHandler.java:43)
    at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
    at io.undertow.servlet.handlers.SendErrorPageHandler.handleRequest(SendErrorPageHandler.java:52)
    at io.undertow.server.handlers.PredicateHandler.handleRequest(PredicateHandler.java:43)
    at io.undertow.servlet.handlers.ServletInitialHandler.handleFirstRequest(ServletInitialHandler.java:276)
    at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:135)
    at io.undertow.servlet.handlers.ServletInitialHandler$2.call(ServletInitialHandler.java:132)
    at io.undertow.servlet.core.ServletRequestContextThreadSetupAction$1.call(ServletRequestContextThreadSetupAction.java:48)
    at io.undertow.servlet.core.ContextClassLoaderSetupAction$1.call(ContextClassLoaderSetupAction.java:43)
    at io.undertow.servlet.handlers.ServletInitialHandler.dispatchRequest(ServletInitialHandler.java:256)
    at io.undertow.servlet.handlers.ServletInitialHandler$1.handleRequest(ServletInitialHandler.java:101)
    at io.undertow.server.Connectors.executeRootHandler(Connectors.java:395)
    at io.undertow.server.HttpServerExchange$1.run(HttpServerExchange.java:861)
    at org.jboss.threads.ContextHandler$1.runWith(ContextHandler.java:18)
    at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2513)
    at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1512)
    at org.xnio.XnioWorker$WorkerThreadFactory$1$1.run(XnioWorker.java:1282)
    at java.lang.Thread.runWith(java.base@21.0.5/Thread.java:1596)
    at java.lang.Thread.run(java.base@21.0.5/Thread.java:1583)</code></pre><p>주목할 부분은 다음과 같다.</p>
<blockquote>
<p>java.lang.Thread.State: <strong>TIMED_WAITING (on object monitor)</strong>
    at java.lang.<strong>Object.wait0</strong>(java.base@21.0.5/Native Method)
    ...
    - <strong>locked &lt;0x00000006824004e0&gt; (a org.apache.kafka.clients.producer.internals.ProducerMetadata)</strong>
    ...
    at my.playground.playing.threadstateonjvm.<strong>KafkaController.pub</strong>(KafkaController.java:19)</p>
</blockquote>
<p>실제로 Kafka Pub 지연동안, TimedWating 상태임을 알 수 있다.</p>
<h1 id="jvm의-스레드-상태가-os와-일치하지-않는-이유">JVM의 스레드 상태가 OS와 일치하지 않는 이유</h1>
<p>왜 JVM은 Feign 호출 응답 지연을 Runnable 상태라고 인지하는 걸까? Kafka는 또 왜 Timed Waiting 상태일까?</p>
<p>공식 문서에 따르면 Runnable 상태여도 OS에서 다른 자원을 기다리고 있을 수도 있다고(waiting 상태) 한다.</p>
<p>Feign 지연시, 실제로 Thread가 멈춰 있는 곳은 <strong>sun.nio.ch.Net.poll</strong> 부분이다. 이는 JNI메서드이다.</p>
<p>반면 Kafka 지연시, 실제로 Thread가 멈춰 있는 곳은
<strong>java.lang.Object.wait0</strong> 부분이다. 이는 Java API이다.</p>
<h2 id="jni가-필요한-이유">JNI가 필요한 이유.</h2>
<p>JNI는 실제로 JVM에서 OS 커널로 명령어를 보내기 위한 메서드이다. OS syscall은 반드시 어셈블리로 실행되어야 할테고, 이를 C/C++ 메서드로 변환해주는 것이 JNI의 역할이다. (JNI → C → syscall)</p>
<h2 id="왜-네이티브-메서드를-사용하면-runnable-상태로-인지할까">왜 네이티브 메서드를 사용하면 Runnable 상태로 인지할까?</h2>
<p>JVM은 네이티브 메서드가 호출된 이후에 무엇을 하는지는 알 수 없다. 그 뒷 일은 운영체제의 일이기 때문이다. 
그래서 실제로 OS level에서의 스레드의 상태는 waiting일지 몰라도, JVM level의 상태는 RUNNABLE일 수 있을 것이라고 정리할 수 있다.</p>
<h2 id="java-api-kafka-내에서는">Java API (kafka) 내에서는..</h2>
<p>Kafka pub이 지연된 스레드의 마지막 호출 스택 트레이스는 Object.wait 메서드로, 이는 자바 API이다.
실제로 JVM위에서 런타임에 스레드가 wait하고 있는 상태이기 때문에 JVM은 이를 timed_waiting 상태라고 인지할 수 있는 것이다.</p>
<h1 id="교훈">교훈</h1>
<p>JVM과 OS에서의 스레드 상태는 다를 수도 있다.
JAVA API가 아닌, syscall 레벨에서 동작하는 스레드는 JVM위에서는 RUNNABLE상태이다. </p>
<blockquote>
<p>참고 
<a href="https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/Thread.State.html">https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/Thread.State.html</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[MSA 장애 원인 분석 회고 [MSK(kafka) 장애와 동기로직]]]></title>
            <link>https://velog.io/@goat_hoon/sync-kafka-logic-fails-msa</link>
            <guid>https://velog.io/@goat_hoon/sync-kafka-logic-fails-msa</guid>
            <pubDate>Fri, 08 Aug 2025 15:57:42 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>입사 1년차를 앞두고 있는 지금, 처음으로 실제 장애 상황에서 1선 대응 및 이후 원인 분석까지 진행하여 공유하였던 뜻 깊은 작업이었다.
나의 공부가 헛되지 않았구나! 기분이 매우 좋은 순간이었다.</p>
</blockquote>
<p>장애 대응 할 때에도 MSK 문제가 해소된 이후, MSK와 연관된 4개의 서비스 모듈을 재기동 할 때 Lag이 한꺼번에 해소 되면서 DB에 부하가 가지 않게 서비스 지표와 DB 지표를 동시에 확인 후 순차적으로 진행하며 문제 없이 장애 해소에 기여했다는 것도 뜻깊었다.
함께 고민을 같이하고 금요일 밤시간까지 함께 부하테스트를 진행한 사우님께 감사인사를 하고싶다.</p>
<h1 id="장애-원인-분석-배경">장애 원인 분석 배경</h1>
<p>어제, MSK 장애가 발생했다. 
원인은 타 팀에서 엉뚱한 MSK(그게 우리 서비스의 MSK.. 특히 그게 국내일줄은 몰랐지..)에 ACL작업을 하면서 벌어진 해프닝이었다.</p>
<p>바람직한 MSA서비스라면, 하나의 시스템에 장애가 발생하게 될 경우, 이와 연동된 API만 문제가 있고, 서비스 전체적으로는 문제가 없어야 정상일 것이다.
하지만, 이번 경우에 MSK장애 하나로 MSA 서비스의 1/3 정도가 컨테이너 다운이 되어버렸다.
그래서 장애 다음 날인 오늘 장애 원인을 바로 분석해보기 시작했다. 
<del>분석 중에, 갑자기 실 전체 서비스 운영인원들에게 각 서비스별 장애 원인 분석을 진행해달라고 요청이 왔었다.. 마침 잘됐다!</del></p>
<blockquote>
<p>서비스들 중에는 컨테이너가 다운되지는 않았지만 MSK장애 해소 이후, Consume(Subscribe)이 바로 되지 않고 재기동을 해야만 Consume이 성공한 서비스도 있다. 어쩌다보니 두 케이스 둘다 담당하고 있는 서비스에 포함되어서.. 해당 이유가 무엇인지 원인 확인 요청도 주셨다. 이 부분은 추후에 인프라팀과 원인을 분석해볼 예정이다.</p>
</blockquote>
<h1 id="장애-원인-추리기">장애 원인 추리기</h1>
<h2 id="1-pod-down-시간-확인-사실은-container-down">1. Pod Down 시간 확인 (사실은 Container Down)</h2>
<p>정확히 어느 시간에 Pod가 Down되었는지 확인해보았다. 그라파나와 연동된 Teams로 장애 알림을 수신하고 있는데, 담당 서비스 pod의 변경점 알림이 수신된 시간대를 파악하였다.</p>
<blockquote>
<p>나중에 알게된 것인데, 정확히 해당 알림은 Pod 재기동 문제가 아니고, Pod내의 서비스 컨테이너의 health check가 되지 않는 경우에도 Pod 변경점으로 보고 알림이 온다는 것을 알게 되었다. 실제로도 Pod 재기동이 아니고, 컨테이너 재기동 상황이었다.</p>
</blockquote>
<h2 id="2-metric--k8s-지표">2. Metric + k8s 지표</h2>
<p>처음 출근해서 바로 살펴봤던 지표는 MSK장애 발생 당시 Pod별 Metric 지표이다.
근데 웬걸? CPU, Memory, Thread 전반적으로 별다른 이상 징후가 없었다. HPA가 적용될 만큼의 지표조차 다다르지 않아 문제가 없어보였다. 대체 왜일까?</p>
<blockquote>
<p>쿠버네티스를 잘 모르긴 하지만, 알림을 수신한 서비스(Pod)들이 같은 Node라서 다같이 Down이 된게 아닐까? 싶었지만, 그것도 아니었다. MSK 통신 오류인데 Node랑은 큰 상관은 없어보이긴 한다...</p>
</blockquote>
<h2 id="3-log">3. Log</h2>
<p>이후에는 로그를 살펴봤다.
장애 상황에 어떤 로그가 찍혀있을까 살펴봤더니, 이게 또 웬걸, 서비스 요청을 잘 처리하다가 갑자기 Spring Boot server가 재시작 되는 로깅이 된다. 중간에 아무런 에러 로깅도 찍히지 않았다.</p>
<h2 id="4-설정-비교">4. 설정 비교</h2>
<p>3번까지 분석을 해도, 뇌 속에는 물음표만 남겨있었다. 다행인지는 몰라도, 담당 서비스들 중에 비교 대조 해볼만한 서비스가 있었다.</p>
<ol>
<li>Container down이 일어난 서비스</li>
<li>Container 및 Pod는 매우 정상이나, 장애 당시 Consume이 되지 않고, 장애 해소 이후 재기동으로 Conusme이 된 서비스</li>
</ol>
<h3 id="1-설정값-비교-kafkaconfig">(1) 설정값 비교 (KafkaConfig)</h3>
<p>1번 서비스, 2번 서비스 모두 kafka관련 설정이 모두 동일했다. 직렬/역직렬화 하는 부분에서 차이가 있기는 했는데, 기본적인 카프카 설정값들은 모두 동일해보여서 문제가 되지 않아보였다.</p>
<h3 id="2-msk-로직-비교">(2) MSK 로직 비교</h3>
<ol>
<li>Container Down이 일어난 서비스에서는 Kafka Pub 로직이 동기/비동기 로직이 둘 다 존재했다.</li>
<li>그렇지 않은 서비스에서는 Pub 로직이 모두 비동기였다.</li>
</ol>
<p>그렇다면 이 차이 때문이 아닐까? 고민하기 시작했다.</p>
<h2 id="5-동기비동기-차이에-따른-근거-마련">5. 동기/비동기 차이에 따른 근거 마련</h2>
<p>심증으로는 동기로직 때문에 문제가 생긴것 같은데, 근거가 없었다. 
이 때부터 나와 같이 분석을 하고 있는 다른 팀의 친한 3년차 사우님과 함께 분석을 진행하였다.
실제 이 사우님은 검증계에 테스트 해볼 수 있는 k8s namespace를 가지고 있었고 우리는 이를 적극 활용하여 문제의 원인을 분석해 보기로 했다.</p>
<h1 id="검증계-부하-테스트-시작">검증계 부하 테스트 시작</h1>
<h2 id="1-환경-구축">1. 환경 구축</h2>
<p>실제 kafka fail 상황을 만들기 위해서, 사우님이 실제 pod와 통신할 수 없는 잘못된 kafka 정보로 pod container를 기동하였다.
그 이후 kafka sync pub 로직이 적용된 API에 부하테스트를 진행하였다.</p>
<h2 id="2-부하-시작-그런데">2. 부하 시작... 그런데?</h2>
<p>Jmeter로 부하를 주기 시작했다. 
그런데 이상한게 Request Rate 지표가 아무리봐도 이상했다. 부하를 준만큼 Request Rate이 늘어나지 않고, 0.5~1 사이의 계단형 주기 함수 형태로 유지되었다.
무엇이 문제일까? 그라파나 대시보드가 잘못됐나? (대시보드 설정 값과 쿼리를 볼 수 있는 권한이 없어서 추측 밖에 할 수 없었다..)</p>
<h2 id="3-실제-비즈니스-로직-수행-여부-확인">3. 실제 비즈니스 로직 수행 여부 확인</h2>
<p>kafka sync pub 로직이 적용된 API에 앞단에 해당 로직이 수행되고 있는 시간대를 로깅할 수 있게끔 로직을 추가했다. 그렇다면 진짜로 부하를 서버가 받고 있는지 알 수 있겠지!
결과는, 정말 신기하게도 200개의 요청을 2초동안 나누어서 부하 테스트를 진행했음에도, 로그에는 1분 텀으로 16개의 요청만 인입되었다. 즉 100개의 요청 1초 텀으로 2초동안 동시에 보냈을때 초기에 요청 16개만 앞단에서 수용할 수 있다는 뜻이었다. 아마 thread pool timeout 시간이 1분으로 설정되어 있던 것 같다.</p>
<h2 id="4-번뜩-이거-웹서버-문제다">4. 번뜩! 이거 웹서버 문제다.</h2>
<p><a href="https://velog.io/@goat_hoon/tomcat-thread-vs-async-thread">https://velog.io/@goat_hoon/tomcat-thread-vs-async-thread</a>
불과 3달전 발생했던 장애 상황을 해결하며 회고했던 문제가 떠올랐다.
그 때 당시, tomcat 스레드의 수를 조절하면서 동시에 처리할 수 있는 요청의 수를 테스트 했는데, 이 설정 때문에 앞단에 웹서버가 받지 못하는 것이 아닌지 범위를 좁혀갔다.</p>
<p>그래서 옆 사우분께 물어봤다.
&quot;이거 tomcat 쓰는거죠?&quot;
&quot;아뇨, undertow인데요?&quot;</p>
<blockquote>
<p><strong>undertow는 또 뭐야..</strong>
undertow? 오늘 처음 알게된 내용인데, tomcat과 같이 앞단의 웹서버 역할을 하는 것인데, 논블록킹 I/O를 지원하며 대용량 트래픽 처리에 최적화된 웹서버라고 한다. 이는 다음에 또 공부를 해봐야겠다.</p>
</blockquote>
<p>&quot;그러면 default worker thread수는 몇개에요?&quot;
라고 질문했고 찾아보니, <strong>코어수 * 8</strong> 이라고 한다.
그 때, 뇌 속에 생각이 스쳤다.
<strong>&#39;우리 서비스 코어수가 8core로 helm chart에 지정되어 있었는데, 이거 Thread지표로 확인할 수 없었던게 8*8 = 64개의 thread만 사용하고 있어서 티가 안났나?&#39;</strong></p>
<p>아니나 다를까, 실제 JVM thread 지표에서 장애 시간대에 timed_waiting 상태의 thread가 max 64개까지 늘어나 거의 일직선으로 유지되고 있는 것을 확인할 수 있었다.</p>
<blockquote>
<p>지표상 waiting상태의 thread수가 200개 정도 있었는데, 이 지표는 무엇일까? 200개가 tomcat 스레드 수의 default값으로 알고 있어서 웹서버로 tomat을 이용하고 있을거라고 단언했었다.</p>
</blockquote>
<h2 id="5-undertow-worker-thread-수를-늘려보죠">5. undertow worker thread 수를 늘려보죠</h2>
<p>이후 worker thread수를 늘려보자고 제안했다. 
200개까지 늘린 후에 부하테스트를 진행해본 결과, 정상적으로 인입이 되기 시작했고, thread수도 마찬가지로 늘어나기 시작했다. 
운영환경처럼 꾸준한 부하를 준 것은 아니라서 timed_waiting 상태의 스레드가 늘어나고 이후에 다시 줄어드는 현상을 볼 수 있었다.</p>
<h2 id="6-pod-컨테이너-재기동-확인하기">6. pod 컨테이너 재기동 확인하기</h2>
<p>이후 max worker thread 수의 임계치까지 부하를 조절하면서 테스트 해봤을 때, 운영환경에서와 동일하게 container health check가 되지 않아서 pod가 깨지는 현상이 발생되었고, 재현에 성공했다.</p>
<h1 id="결과">결과</h1>
<p>그래서 장애 원인을 다음과 같이 분석할 수 있었다.
<strong>MSK 장애로 인해 Kafka 동기 Pub 로직이 worker thread pool을 전부 점유하였고, 그 결과 health check API가 응답하지 못해 컨테이너가 비정상 종료 및 재기동됨</strong></p>
<p>그리고 해결 방안으로, 동기 로직을 비동기 로직으로 전환하고, 비동기 스레드 풀 사용 시 OOM 방지를 위해 적절한 스레드풀 전략 및 TimeOut 설정을 제안한 상태이다. </p>
<p>끝!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[byte 파일을 stream을 통해 최적화 해보기 (메모리 Down, CPU Up)]]></title>
            <link>https://velog.io/@goat_hoon/byte-to-stream</link>
            <guid>https://velog.io/@goat_hoon/byte-to-stream</guid>
            <pubDate>Sun, 27 Jul 2025 04:41:32 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>실무에서 이미지 동영상의 binary 파일로 인해 문제가 되었던 CPU, Memory 사용량을 줄이기 위해 개인적으로 문제를 분석해보고 개선점을 찾아내기 위한 작업들의 기록입니다.
코드 내역 : <a href="https://github.com/goathoon/playground/commit/291dd72bc814530974eb4d2717f3b5d932262ced">https://github.com/goathoon/playground/commit/291dd72bc814530974eb4d2717f3b5d932262ced</a></p>
</blockquote>
<h1 id="들어가며">들어가며</h1>
<p>특정 이벤트가 발생시, 단말에서 이미지 메타데이터와 동영상을 서버쪽으로 전송하는 API가 있다. 
하지만 이러한 이벤트가 주변 환경으로 인해 갑작스럽게 많이 발생하는 경우가 있는데 이 때, CPU가 100%까지 요동을 치고, 이로 인해 Memory도 꽤나 많이 잡아먹는 현상(60%) 이 발생했다.
서버쪽 로직에서 문제가 되는 부분은 두가지라고 생각했다.</p>
<ol>
<li>동영상 파일(최대 2.5MB)을 한꺼번에 byte 배열에 할당한다. -&gt; <strong>메모리</strong> 이슈 발생 가능</li>
<li>byte 배열을 암호화한다. -&gt; <strong>CPU</strong> 이슈 발생가능</li>
</ol>
<p>운영환경에서 가장 크게 문제가 되었던 부분은 CPU가 100%까지 쳐서 container의 down up이 반복되었던 부분이어서 개인적으로 용량이 꽤나 큰 byte를 한꺼번에 암호화하는 작업때문에 문제가 생겼다고 확신했었다. </p>
<p>팀장님께서는 메모리 문제 위주로 문제점을 지적하셨다. 지적하신 부분은 9TPS 트래픽임에도 불구하고 committed memory가 max 6GB까지 할당된다는 사실이었다. 그래서 메모리를 효율화 하는 방식으로 개선하자고 말씀하셨다. 
JVM의 Heap size는 pod별로 할당된 메모리의 75%만 사용할 수 있게 설정해서 6GB가 max heap memory로 잡혀있었다. </p>
<blockquote>
<p>used는 최대 4GB정도로 사용되었었다. used가 4GB정도로 사용되는데, committed memory는 MAX값인 6GB정도까지 늘어날 수 있는게 정상 아닐까? 라고 생각했지만, 나도 잘 모르는 부분이기 때문에.. 다음에 기회가 된다면 여쭤봐야겠다. </p>
</blockquote>
<p>어쨌거나, 일단 메모리도 4GB정도로 사용하고 있는 부분은 추후에도 문제가 될 수 있기 때문에 CPU뿐만 아니라 메모리 측면에서도 효율화하는 작업을 개인 환경에서 먼저 테스트해보고자 한다.</p>
<h1 id="준비">준비</h1>
<p>처음에는 단순 POJO환경에서 동영상/이미지 파일의 binary 파일을 byte 배열로 바로 받아서 부하를 통해 효율화를 진행하는 방식으로 진행하려고 했다.
하지만 생각해보니, 이러한 방식을 사용하게 된다면 결국 단일스레드에서 테스트하는 것이고, 행여 멀티스레드환경에서 이를 구현한다고 하더라도 Spring 서버에서 테스트하는 것보다 정확도나 편의성 측면에서 많이 떨어진다고 생각해서 Spring서버로 테스트하기로 했다.</p>
<p>테스트를 위해 다음과 같은 툴을 사용하였다.</p>
<ul>
<li>2.6MB 무료 동영상 (출처 : <a href="https://pixabay.com/ko/videos/">https://pixabay.com/ko/videos/</a>)</li>
<li>Spring Boot 서버</li>
<li>파일 업로드 상황의 부하 테스트를 위한 Jmeter</li>
<li>서버의 Metric 정보를 간단하게 GUI로 관찰할 수 있는 VisualVM</li>
</ul>
<h2 id="비즈니스-로직-구현">비즈니스 로직 구현</h2>
<p>공통적으로 처리하는 로직은 다음과 같다.
단말에서 올리는 영상이 application/octet-stream type 이라는 것은 변하지 않는다.</p>
<ol>
<li>Binary(영상) 데이터를 받는다.</li>
<li>데이터를 암호화한다.</li>
</ol>
<p>이러한 로직을 처리하는 방식을 두가지 상황으로 나눈다.</p>
<ol>
<li>Binary 데이터를 byte배열에 할당해서 처리하는 방식 (기존)</li>
<li>Binary 데이터를 Stream으로 바꾸어 처리하는 방식 (개선)</li>
</ol>
<blockquote>
<p>실제 비즈니스로직에서는 영상 업로드시 기존에 존재하는 영상을 찾아서 삭제하는 로직이 존재하여 overhead가 무조건 생기겠지만, 이는 고려하지 않기로 한다.</p>
</blockquote>
<h2 id="부하-trigger">부하 Trigger</h2>
<p>이후, Jemter로 5000개의 동시 요청을 10초동안 나누어 부하를 준다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/28c116ab-3657-4ce7-bf3f-75ed1f54b57f/image.png" alt=""></p>
<ul>
<li><p>HTTP Request 정보 (mp4파일의 MIME Type 지정 = application/octet-stream)
<img src="https://velog.velcdn.com/images/goat_hoon/post/83788324-a410-4573-ab25-8fca809aa9ff/image.png" alt=""></p>
</li>
<li><p>HTTP Header Manager 설정 (Content-Type 지정 = application/octet-stream)
<img src="https://velog.velcdn.com/images/goat_hoon/post/486655e8-e401-4530-a3fe-2cd5e8ad7ad7/image.png" alt=""></p>
</li>
</ul>
<h1 id="전체-구현-로직">전체 구현 로직</h1>
<p>먼저 전체 코드를 살펴보자.</p>
<h2 id="실제-비즈니스-로직">실제 비즈니스 로직</h2>
<pre><code class="language-java">@RestController
@RequestMapping(&quot;/upload&quot;)
@Slf4j
public class BinaryUploadController {

    @PostMapping(value = &quot;/bytes-only&quot;, consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
    public ResponseEntity&lt;String&gt; bytesOnly(@RequestBody byte[] data) {
        File encryptedFile = new File(&quot;tmp/encrypted-byte.text&quot;);

        try {
            byte[] encrypted = AesEncryptor.encrypt(data);
            log.info(&quot;암호화된 바이트 크기: {}&quot;, encrypted.length);

            try (FileOutputStream fos = new FileOutputStream(encryptedFile)) {
                fos.write(encrypted);
            }

            log.info(&quot;저장된 파일 경로: {}&quot;, encryptedFile.getAbsolutePath());
            log.info(&quot;저장된 파일 크기: {} bytes&quot;, encryptedFile.length());

            return ResponseEntity.ok(&quot;Encrypted and saved: &quot; + encryptedFile.length() + &quot; bytes&quot;);

        } catch (Exception e) {
            log.error(&quot;암호화 또는 저장 실패&quot;, e);
            return ResponseEntity
                    .status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(&quot;암호화 실패: &quot; + e.getMessage());
        }
    }

    @PostMapping(value = &quot;/bytes-to-stream&quot;)
    public ResponseEntity&lt;String&gt; bytesToStream (HttpServletRequest request) throws IOException {
        File encryptedFile = new File(&quot;tmp/encrypted-stream.text&quot;);

        try (
                ServletInputStream inputStream = request.getInputStream();
                FileOutputStream fos = new FileOutputStream(encryptedFile);
                CipherOutputStream cos = new CipherOutputStream(fos, AesEncryptor.initEncryptCipher())
        ) {
            byte[] buffer = new byte[8192];
            int bytesRead;
            long totalRead = 0;

            while ((bytesRead = inputStream.read(buffer)) != -1) {
                // 암호화된 조각을 바로 출력 스트림에 씀
                cos.write(buffer, 0, bytesRead);
                totalRead += bytesRead;
            }

            cos.flush(); // 마지막 flush
            log.info(&quot;저장된 파일 경로: {}&quot;, encryptedFile.getAbsolutePath());
            log.info(&quot;저장된 파일 크기: {} bytes&quot;, encryptedFile.length());


            return ResponseEntity.ok(&quot;Encrypted and saved: &quot; + encryptedFile.length() + &quot; bytes&quot;);
        } catch (Exception e) {
            log.error(&quot;암호화 또는 저장 실패&quot;, e);
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(&quot;암호화 실패: &quot; + e.getMessage());
        }
    }
}
</code></pre>
<h2 id="암호화-유틸">암호화 유틸</h2>
<pre><code class="language-java">public class AesEncryptor {
    private static final String ALGORITHM = &quot;AES&quot;;
    private static final String KEY = &quot;MySecretKey12345&quot;; 

    public static byte[] encrypt(byte[] input) throws Exception {
        SecretKeySpec secretKey = new SecretKeySpec(KEY.getBytes(), &quot;AES&quot;);
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        cipher.init(Cipher.ENCRYPT_MODE, secretKey);
        return cipher.doFinal(input);
    }

    public static Cipher initEncryptCipher() throws Exception {
        SecretKeySpec keySpec = new SecretKeySpec(KEY.getBytes(), &quot;AES&quot;);
        Cipher cipher = Cipher.getInstance(ALGORITHM);
        cipher.init(Cipher.ENCRYPT_MODE, keySpec);
        return cipher;
    }
}</code></pre>
<h2 id="1-기존-로직">(1) 기존 로직</h2>
<p>기존의 로직은 Binary데이터를 그대로 Byte 배열에 할당하여 암호화하여 DB에 저장하는 방식이다.
여기서 DB저장은 어차피 I/O 작업이므로 이를 File I/O로 대체하기로 했다. (굳이 I/O상황을 만들지 않아도 되지만, 기존 로직과 개선 로직이 실제로 같은 암호화된 바이너리 값으로 저장되는지 확인하기 위해 추가하였다.)</p>
<pre><code class="language-java">@PostMapping(value = &quot;/bytes-only&quot;, consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity&lt;String&gt; bytesOnly(@RequestBody byte[] data) {
    File encryptedFile = new File(&quot;tmp/encrypted-byte.text&quot;);

    try {
        byte[] encrypted = AesEncryptor.encrypt(data);
        log.info(&quot;암호화된 바이트 크기: {}&quot;, encrypted.length);

        try (FileOutputStream fos = new FileOutputStream(encryptedFile)) {
            fos.write(encrypted);
        }

        log.info(&quot;저장된 파일 경로: {}&quot;, encryptedFile.getAbsolutePath());
        log.info(&quot;저장된 파일 크기: {} bytes&quot;, encryptedFile.length());

        return ResponseEntity.ok(&quot;Encrypted and saved: &quot; + encryptedFile.length() + &quot; bytes&quot;);

    } catch (Exception e) {
        log.error(&quot;암호화 또는 저장 실패&quot;, e);
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(&quot;암호화 실패: &quot; + e.getMessage());
    }
}</code></pre>
<p>** 로직 단계 **</p>
<ol>
<li>File을 생성한다.</li>
<li>octet-stream Binary로 넘어온 파일을 그대로 encrypt한다</li>
<li>encrypt한 byte데이터 전체를 File에 한꺼번에 write</li>
</ol>
<h2 id="2-개선-로직-중요한-부분">(2) 개선 로직 (중요한 부분)</h2>
<p>처음에 이를 개선하려고 했을때 두가지 생각을 했었다.</p>
<ol>
<li>binary 전체 데이터가 byte로 넘어오고, 이를 stream을 통해 블록 단위로 암호화 및 저장한다.</li>
<li>처음 넘어올 떄부터 전체 byte가 아니라 stream으로 받아서 메모리를 효율화 한다.</li>
</ol>
<h3 id="1번의-생각">1번의 생각</h3>
<p>1번의 생각은 유효할 수도 있겠지만, 데이터 전체가 byte배열로 한번 올라가고, 이후 암호화를 블록 단위로 하게 되므로, 어림 잡아 <code>데이터 전체 크기 * 1.1배</code> 정도가 메모리가 할당될 것이라고 생각했다. </p>
<blockquote>
<p>기존에는 <code>데이터 전체 크기 * 2배</code> (인입된 byte 전체 + 이를 암호화한 byte 배열) 의 메모리가 할당 되었기 때문에 50% 메모리 리소스 사용률을 줄일 수 있다.</p>
</blockquote>
<h3 id="2번의-생각">2번의 생각</h3>
<p>2번의 생각은 처음 넘어올 때부터 stream으로 받게되면 전체 데이터를 메모리에 올리지 않아도 되므로 어림 잡아 <code>데이터 전체 크기 * 0.31배</code> 정도로 메모리가 할당될 것이라고 생각했다. </p>
<blockquote>
<p>정확히는 buffer block의 크기에 따라서 메모리 효율성은 달라질 것이다. 8192 (8KB)를 buffer 크기로 잡으면 2.6MB를 0.008로 나누어서 메모리에 올리므로, (8192 / 2,600,000) * 100 ≈ 0.31% 0.31%의 메모리만 사용하므로 322배의 메모리 효율을 볼 수 있을 것이라고 예상했다.</p>
</blockquote>
<p>CPU또한 복잡한 암호화를 buffer (block) 단위로 나눠서 진행하므로 어느정도 개선이 있지 않을까? 짐작했다.</p>
<p>그렇다면 처음으로 든 생각은 <code>@RequestBody</code>로 Stream형태로 지원할까? 였다.</p>
<h4 id="httpmessageconverter에는-inputstream을-지원하지-않는다">HttpMessageConverter에는 InputStream을 지원하지 않는다.</h4>
<p><code>@RequestBody</code> 를 통해 받는 binary데이터를 stream으로 받고, 비즈니스로 직에서 buffer단위로 읽고 싶었지만, HttpMessageConverter에는 기본적으로 stream을 지원하지 않았다.</p>
<h4 id="httpservletrequest-이용하기">HttpServletRequest 이용하기</h4>
<p>그렇다면 <code>HttpServletReqeust</code> 를 이용해보자는 생각을 했다. 이전에 얼핏 개발했을때, <code>HttpServletReqeust.getInputStream(), ServletInputStream</code>으로 요청 정보를 받아왔던 기억이 있어서, 이를 활용했다. </p>
<h3 id="코드-구현">코드 구현</h3>
<pre><code class="language-java">@PostMapping(value = &quot;/bytes-to-stream&quot;)
public ResponseEntity&lt;String&gt; bytesToStream (HttpServletRequest request) throws IOException {
    File encryptedFile = new File(&quot;tmp/encrypted-stream.text&quot;);

    try (
            ServletInputStream inputStream = request.getInputStream();
            FileOutputStream fos = new FileOutputStream(encryptedFile);
            CipherOutputStream cos = new CipherOutputStream(fos, AesEncryptor.initEncryptCipher())
    ) {
        byte[] buffer = new byte[8192];
        int bytesRead;
        long totalRead = 0;

        while ((bytesRead = inputStream.read(buffer)) != -1) {
            // 암호화된 조각을 바로 출력 스트림에 씀
            cos.write(buffer, 0, bytesRead);
            totalRead += bytesRead;
        }

        cos.flush(); // 마지막 flush
        log.info(&quot;저장된 파일 경로: {}&quot;, encryptedFile.getAbsolutePath());
        log.info(&quot;저장된 파일 크기: {} bytes&quot;, encryptedFile.length());


        return ResponseEntity.ok(&quot;Encrypted and saved: &quot; + encryptedFile.length() + &quot; bytes&quot;);
    } catch (Exception e) {
        log.error(&quot;암호화 또는 저장 실패&quot;, e);
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(&quot;암호화 실패: &quot; + e.getMessage());
    }
}</code></pre>
<p><strong>로직 단계</strong></p>
<ol>
<li>request정보에서 InputStream을 받아온다.</li>
<li>CipherOutputStream을 사용하여 블록단위로 암호화 할 수 있게 설정한다.</li>
<li>CipherOutputStream은 보조 스트림이다. 이를 사용하기 위해 실재 OutputStream의 구현체인 FileOutputStream을 사용한다.</li>
<li>buffer를 일반적인 블록 크기인 8KB로 주고 buffer크기만큼 InputStream에서 읽어서 이를 암호화 Stream에 적용시킨다.</li>
<li>마지막에 flush를 하여 File에 쓴다.</li>
</ol>
<h1 id="결과">결과</h1>
<p><img src="https://velog.velcdn.com/images/goat_hoon/post/7653cf6b-1b75-4211-b351-bdf951e3a8e1/image.png" alt=""></p>
<h2 id="byte-배열-전체-사용">Byte 배열 전체 사용</h2>
<p>오전 1시41분에 trigger 했던 상황은 byte배열 전체를 암호화 했을 때의 상황이다.
CPU사용률은 최대 60%, 메모리는 Full Heap Size인 6GB까지 사용되어있음을 볼 수 있다.</p>
<h2 id="stream-사용">Stream 사용</h2>
<p>오전 1시43분, 1시 45분경 trigger했던 상황이 stream을 사용했던 상황이다.
CPU사용률은 오히려 80%정도까지 치솟았고, 메모리의 효율화는 확실했다. 해당 순간의 HeapDump까진 살펴보진 않았는데 대략 0.4GB정도 사용하지 않았을까 싶다. 
메모리 효율화의 측면에서 거의 15배의 효율화를 볼 수 있었다. (6GB -&gt; 0.4GB)
하지만 오히려 CPU는 늘어났다.</p>
<h2 id="cpu가-오히려-늘어난-이유">CPU가 오히려 늘어난 이유?</h2>
<p>이부분에 대해선 조금 더 살펴봐야겠지만, 다음과 같은 이유로 추측하고는 있다.</p>
<ul>
<li>AS-IS
byte전체 배열을 한꺼번에 암호화한다.</li>
<li>TO-BE<ol>
<li>블록 단위마다 암호화하는 작업 (AS-IS에서 전체 배열을 암호화 한 CPU-BOUND 작업이 쪼개진것 뿐)</li>
<li>이전에 암호화결과 값을 Chaining 해야하는 작업 </li>
</ol>
</li>
</ul>
<p>CPU-BOUND한 암호화 작업이 block단위로 쪼개졌을뿐 Asis나 Tobe나 같을 뿐이다. </p>
<h1 id="회고">회고</h1>
<p>Buffer(블록) 단위로 잘라서 암호화하면, 한꺼번에 암호화 하진 않아서 부하가 덜할 줄 알았다. 하지만 생각해보면 부하가 심한 상황에서 N개의 요청 각각을 블록단위로 암호화하나 전체 배열을 암호화하나 CPU부하는 비슷할 것임을 생각하지 못했던 점이 아쉽다.
하지만 Stream으로 메모리 효율화는 성공적으로 진행했으니, 실제 실무에는 적용하지 못하더라도, 뿌듯한 작업이었다!</p>
<blockquote>
<p>그렇다면 CPU의 효율화는 어떻게 진행해야할지.. 또 고민해야할 것이다!</p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[동시성 이슈를 테스트 코드로 잡아보기]]></title>
            <link>https://velog.io/@goat_hoon/catch-distributed-lock-error</link>
            <guid>https://velog.io/@goat_hoon/catch-distributed-lock-error</guid>
            <pubDate>Sun, 29 Jun 2025 14:49:18 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>실무에서 동시성 이슈를 MongoDB의 Document를 활용한 분산락을 사용해서 제어하는 개발건을 맡아 진행했다. 본디 Redis를 쓰는게 정배이긴 하지만, 상황이 여의치 않아서 MongoDB로 진행하였다. 
그런데 내가 개발했던 건에서 잘못된 <code>try-catch-finally</code> 사용으로 중복제어 에러를 반환하면서 바로 Lock을 해제해버리는 바람에 동시성 이슈가 제대로 해소되지 않는 문제가 발생했다.
소스코드는 아래 링크에 커밋했으니, 참고해주세요!
<a href="https://github.com/goathoon/playground/commit/55e6d682899ac5bb712633228df7a60620b51440">https://github.com/goathoon/playground/commit/55e6d682899ac5bb712633228df7a60620b51440</a></p>
</blockquote>
<h1 id="들어가며-mongodb-분산락-주의점">들어가며 (MongoDB 분산락 주의점)</h1>
<blockquote>
<p>먼저, 왜 Redis를 안쓰냐? -&gt; 회사 내부 이슈.. 저도 쓰고 싶습니다..</p>
</blockquote>
<p>MongoDB로 분산락을 구현하는 것은 생각보다 고려해야할 점이 많습니다.
저희는 샤딩이 없는 replica 모드로 MongoDB를 운영하고 있는점에 참고해주세요.</p>
<ul>
<li>readPreference
락 상태나 최신 데이터를 secondary에서 읽게되는 경우 primary에서 복제가 되지 않은 상태값을 가져와 중복제어가 완벽히 이뤄지지 않을 수 있습니다.</li>
<li>TTL
MongoDB의 TTL은 60초를 기준으로 백그라운드에서 돌아갑니다. 중복제어 상태의 TTL을 1초로 걸어도, worst case로 59초의 시간동안 중복제어로 판단할 가능성이 있습니다. 그래서 TTL은 정말 보험의 용도로 사용했습니다.</li>
</ul>
<h1 id="상황-세부-설명">상황 세부 설명</h1>
<h2 id="문제-1---중복제어로-인한-데이터-정합성-어긋남">문제 1 - 중복제어로 인한 데이터 정합성 어긋남</h2>
<p>같은 계정으로 다른 디바이스에서 동시에 같은 데이터에 변경을 하는 바람에 데이터 정합성이 깨지는 상황이 발생했었다.</p>
<p>그래서 이를 해결하기 위해 MongoDB에 Lock 상태를 관리하는 컬렉션을 만들고, 같은 계정에서 데이터를 중복으로 제어할 수 없게 로직을 작성했었다.
회사 코드를 가져올 수 없으니.. 예시 코드는 다음과 같다.
여기서 문제가 되는 코드가 무엇인지 살펴보자.</p>
<pre><code class="language-java">public String upload(String id) throws InterruptedException {
    try {
        lockService.tryLock(id);

        // 비즈니스 로직 수행
        delayUtil.randomDelay();

        ProtectedResource resource = resourceRepository.findById(id)
                .orElse(new ProtectedResource(id, 0));
        resourceRepository.save(new ProtectedResource(id, resource.getCount() + 1));
    } catch (DuplicateKeyException e) {
        return &quot;Fail (Lock)&quot;;
    } finally {
        lockService.unLock(id);
    }
    return &quot;Success&quot;;
}</code></pre>
<h2 id="문제-2---중복제어-발생-트랜잭션에서-lock-해제">문제 2 - 중복제어 발생 트랜잭션에서 Lock 해제</h2>
<p>무엇이 문제였을까?</p>
<p>바로 <code>finally</code> 로직이다.
중복 제어 상태임을 나타내는 에러인 <code>DuplicateKeyException</code> 의 상황에서 finally로 락을 삭제하는 것은 불필요한 로직이다.
이는 먼저 들어온 <code>트랜잭션1</code> 의 Lock을 다른 <code>트랜잭션2</code> 에서 해제해버리는 상황이기 때문이다.
이 때문에 <code>트랜잭션1</code>은 이미 없어진 Lock을 삭제하는 애꿎은 상황이 발생했다.</p>
<p><strong>Lock 해제의 주체는 Lock을 획득한 요청이어야만 한다.</strong></p>
<p>이러한 사실을 인지하지 못했고, 서비스가 운영되었었다.
물론... 실제 사용자의 입장에서는 대다수가 중복제어가 잘 되었겠지만.. QA에서 또다른 문제가 발생되었다.
그림으로 정리를 해봤다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/f60ed7f6-51fe-47f3-9d91-59918bdd92de/image.png" alt=""> <code>트랜잭션1</code> 에서 걸었던 Lock을 <code>트랜잭션2</code> 에서 해제를 해버리는 바람에, <code>트랜잭션3</code> 에서 <code>트랜잭션1</code>의 비즈니스 로직이 진행중임에도 중복으로 제어가 가능한 상황이 되어버렸다.</p>
<p>해당 문제를 캐치하기 까지 오랜시간이 걸렸다.
기존의 중복제어 문제를 해결하는 로직의 테스트코드를 작성했었지만, 이러한 부분까지 고려하지 못했던 부분이 아쉬웠다. 
(물론 알았다면.. 그 부분까지 고려한 테스트코드를 작성해보지 않았을까?)
그래서, 이번 블로그의 주제는 
<strong>중복제어 상황의 요청에서 타 트랜잭션에서 걸어놓은 Lock 해제로 인해 생긴 문제를 테스트 코드로 잡아내고 이를 해결하기 위한 방법을 고안해내는 것이다.</strong></p>
<h1 id="비즈니스-로직">비즈니스 로직</h1>
<h2 id="1-락-관리-로직">1) 락 관리 로직</h2>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
public class LockService {
    private final LockRepository lockRepository;

    public void tryLock(String id) throws DuplicateKeyException {
        lockRepository.insert(LockDocument.of(id));
    }

    public void unLock(String id) {
        lockRepository.deleteById(id);
    }
}</code></pre>
<h2 id="2-비즈니스-로직">2) 비즈니스 로직</h2>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {
    private final LockService lockService;
    private final ProtectedResourceRepository resourceRepository;
    private final DelayUtil delayUtil;

    public String upload(String id) throws InterruptedException {
        Boolean isSuccess = true;
        try{
            log.info(&quot;upload 수행&quot;);
            lockService.tryLock(id);

            // 비즈니스 로직 수행
            delayUtil.randomDelay();
            ProtectedResource resource = resourceRepository.findById(id)
                    .orElse(new ProtectedResource(id, 0));
            resourceRepository.save(new ProtectedResource(id, resource.getCount() + 1));

        } catch (DuplicateKeyException e) {
            log.info(&quot;DUP ERROR in Thread = {}&quot;, Thread.currentThread().getName());
            isSuccess = false;
            return &quot;Fail (Lock)&quot;;
        } finally {
            if(isSuccess){
                lockService.unLock(id);
                log.info(&quot;UNLOCK in Thread = {}&quot;, Thread.currentThread().getName());
            }
        }

        return &quot;Success&quot;;
    }

public String delete(String id) throws InterruptedException {
    Boolean isSuccess = true;
    try{
        log.info(&quot;delete 수행&quot;);
        lockService.tryLock(id);

        // 비즈니스 로직 수행
        delayUtil.randomDelay();
        resourceRepository.deleteById(id);

    } catch (DuplicateKeyException e) {
        log.info(&quot;DUP ERROR in Thread = {}&quot;, Thread.currentThread().getName());
        isSuccess = false;
        return &quot;Fail (Lock)&quot;;
    } finally {
        if(isSuccess){
            lockService.unLock(id);
            log.info(&quot;UNLOCK in Thread = {}&quot;, Thread.currentThread().getName());
        }
    }
        return &quot;Success&quot;;
    }
}
</code></pre>
<p>DelayUtil을 활용해서 random 시간 동안 (50~200ms) I/O와 같은 DB로직이 수행된다고 가정했다.</p>
<h1 id="그래서-테스트-코드를-어떻게-작성해볼까">그래서 테스트 코드를 어떻게 작성해볼까?</h1>
<p>테스트코드를 작성하기 위한 전제 조건은 두가지이다.</p>
<ol>
<li>동시성의 상황을 만들어야 한다.
ExecutorService와 CountDownLatch를 활용해 여러 스레드가 동시에 upload() 혹은 delete() 메서드를 호출하도록 설정한다.</li>
<li>Lock 주체가 아닌 트랜잭션이 락을 해제하는 상황을 테스팅 해야한다.
즉, DuplicateKeyException이 발생한 트랜잭션에서 finally 블록을 통해 다른 트랜잭션의 Lock까지 해제하는 현상을 포착하여 중복제어가 되지 않는 상황을 구현해야한다.</li>
</ol>
<h2 id="테스트-1">테스트 1</h2>
<blockquote>
<p>비즈니스로직에 0.5초가 걸린다고 가정하자.
그리고 0.02초 간격으로 세개의 요청이 들어오는데, 삭제/삭제/업로드 요청이 인입된다.
그러면 최초 삭제시 Lock을 획득하고, 
두번째 삭제시 중복제어 상황이므로 Lock이 해제되고,
세번째 업로드 요청에서 최초 삭제 로직이 진행중임에도, 두번째 요청에서 첫번째 요청에서 걸었던 Lock이 해제되어 업로드 요청이 수행될 수 있다.</p>
</blockquote>
<pre><code class="language-java">@Test
@DisplayName(&quot;0.02초 간격으로 삭제/삭제/업로드 요청시, 첫번째 요청의 비즈니스 로직만 수행된다&quot;)
void 순서대로_삭제_삭제_업로드_요청시_최초_요청만_수행된다() throws InterruptedException {
    int concurrency = 3;
    ExecutorService executor = Executors.newFixedThreadPool(concurrency);
    List&lt;String&gt; results = Collections.synchronizedList(new ArrayList&lt;&gt;());

    for (int i = 0; i &lt; concurrency; i++) {
        final int index = i;
        executor.submit(() -&gt; {
            try {
                String result = null;
                if (index == concurrency - 1) {
                    result = userService.upload(&quot;shared-id&quot;);
                } else {
                    result = userService.delete(&quot;shared-id&quot;);
                }
                results.add(result);
            } catch (DuplicateKeyException e) {
                results.add(&quot;Duplication Exception&quot;);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
        Thread.sleep(20);
    }

    // 스레드풀 전체 종료까지 main스레드 대기
    executor.shutdown();
    executor.awaitTermination(1, TimeUnit.SECONDS);

    long successCount = results.stream().filter(&quot;Success&quot;::equals).count();

    Optional&lt;ProtectedResource&gt; resource = resourceRepository.findById(&quot;shared-id&quot;);

    assertAll(
        () -&gt; assertThat(successCount)
                .as(&quot;하나의 스레드에서만 비즈니스 로직이 성공해야 한다&quot;)
                .isEqualTo(1),
        () -&gt; assertThat(resource)
                .as(&quot;shared-id 리소스는 존재하지 않아야 한다&quot;)
                .isNotPresent()

    );

}</code></pre>
<p>만약 제대로 중복제어가 되었을 것이라고 가정한다면, 세번째 인입된 업로드 요청은 시행되지 않았을 것이고, 결국 하나의 스레드에서만 비즈니스 로직이 성공 했어야 할 것이다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/0d860cc4-8e70-4e59-9f0d-f28b3627feb9/image.png" alt="">
 두번째로 인입되었던 요청 (pool-3-thread-2)에서 Lock이 해제되어, 세번째로 인입되었던 upload요청에서 중복제어 상황임에도 성공적으로 수행되었음을 알 수 있다.
따라서, 첫번째 요청이 아니라, 첫번째와 세번째의 요청이 성공했음을 알 수 있고, 세번째 요청인 업로드도 성공적으로 진행되면서 저장되지 말아야할 Document가 저장됨을 알 수 있다.</p>
<h2 id="테스트-2">테스트 2</h2>
<blockquote>
<p>비즈니스로직에 0.5초가 걸린다고 가정하고,
이번에는 세개의 요청이 0.02초 간격이 아니라, 정말 &#39;동시&#39; 상황을 가정해보자.
동시 상황을 가정하기 위해 Executor로 여러 스레드를 실행하기 전에, 메인 스레드 용 CountDownLatch로 스레드의 실행을 중지시키고, 모든 스레드를 submit시킨 후, 메인스레드에서 CouuntDownLatch를 0으로 만들어, 동시에 실행하게 했다.</p>
</blockquote>
<pre><code class="language-java">@Test
@DisplayName(&quot;동시에 삭제/삭제/업로드 요청시, 최초 인입 요청의 비즈니스 로직만 수행된다&quot;)
void 동시에_삭제_삭제_업로드_요청시_최초_요청만_수행된다() throws InterruptedException {
    int concurrency = 10;
    ExecutorService executor = Executors.newFixedThreadPool(concurrency);
    CountDownLatch allThreadsLatch = new CountDownLatch(1);
    CountDownLatch eachThreadLatch = new CountDownLatch(concurrency);
    List&lt;String&gt; results = Collections.synchronizedList(new ArrayList&lt;&gt;());

    for (int i = 0; i &lt; concurrency; i++) {
        final int index = i;
        executor.submit(() -&gt; {
            try {
                allThreadsLatch.await();
                String result = null;
                if (index == concurrency - 1) {
                    result = userService.upload(&quot;shared-id&quot;);
                } else {
                    result = userService.delete(&quot;shared-id&quot;);
                }
                results.add(result);
            } catch (DuplicateKeyException e) {
                results.add(&quot;Duplication Exception&quot;);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            } finally {
                eachThreadLatch.countDown();
            }
        });
    }

    allThreadsLatch.countDown();
    eachThreadLatch.await();

    // 스레드풀 전체 종료까지 main스레드 대기
    executor.shutdown();
    executor.awaitTermination(1, TimeUnit.SECONDS);

    long successCount = results.stream().filter(&quot;Success&quot;::equals).count();

    assertThat(successCount)
            .as(&quot;하나의 스레드에서만 비즈니스 로직이 성공해야 한다&quot;)
            .isEqualTo(1);


}</code></pre>
<p><img src="https://velog.velcdn.com/images/goat_hoon/post/6d03710d-0aa5-4d6b-84a1-2c593d37676d/image.png" alt="">
10개밖에 안되서 그런가.. 중복제어가 잘 이뤄졌다.
1000개정도로 늘려보자.
<img src="https://velog.velcdn.com/images/goat_hoon/post/9f8e4f9a-67c6-4dee-ad63-40b2aaf60483/image.png" alt="">
원하는 상황이 등장하기 시작했고 테스트는 실패한다.
(Duplicate error가 발생하면 unlock하면 안됨)
<img src="https://velog.velcdn.com/images/goat_hoon/post/32b2848e-4ca3-4335-916d-0ad942f9131c/image.png" alt=""></p>
<h1 id="테스트를-성공시켜-보자">테스트를 성공시켜 보자.</h1>
<p>그렇다면 이제 테스트를 성공시키기 위해 기존 로직을 수정하자.
중복제어 상황에서 Lock을 해제하지 못하게 해야한다.</p>
<pre><code class="language-java">@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {
    private final LockService lockService;
    private final ProtectedResourceRepository resourceRepository;
    private final DelayUtil delayUtil;

    public String upload(String id) throws InterruptedException {
        Boolean isSuccess = true;
        try{
            log.info(&quot;upload 수행&quot;);
            lockService.tryLock(id);

            // 비즈니스 로직 수행
            delayUtil.randomDelay();
            ProtectedResource resource = resourceRepository.findById(id)
                    .orElse(new ProtectedResource(id, 0));
            resourceRepository.save(new ProtectedResource(id, resource.getCount() + 1));

        } catch (DuplicateKeyException e) {
            log.info(&quot;DUP ERROR in Thread = {}&quot;, Thread.currentThread().getName());
            isSuccess = false;
            return &quot;Fail (Lock)&quot;;
        } finally {
            if(isSuccess){
                lockService.unLock(id);
                log.info(&quot;UNLOCK in Thread = {}&quot;, Thread.currentThread().getName());
            }
        }

        return &quot;Success&quot;;
    }

public String delete(String id) throws InterruptedException {
    Boolean isSuccess = true;
    try{
        log.info(&quot;delete 수행&quot;);
        lockService.tryLock(id);

        // 비즈니스 로직 수행
        delayUtil.randomDelay();
        resourceRepository.deleteById(id);

    } catch (DuplicateKeyException e) {
        log.info(&quot;DUP ERROR in Thread = {}&quot;, Thread.currentThread().getName());
        isSuccess = false;
        return &quot;Fail (Lock)&quot;;
    } finally {
        if(isSuccess){
            lockService.unLock(id);
            log.info(&quot;UNLOCK in Thread = {}&quot;, Thread.currentThread().getName());
        }
    }
        return &quot;Success&quot;;
    }
}</code></pre>
<p>성공 Flag를 통해서 성공일경우에만 Lock을 해제하자.</p>
<h1 id="테스트-결과">테스트 결과</h1>
<p><img src="https://velog.velcdn.com/images/goat_hoon/post/154b6417-5491-412f-a7c4-ef370772e84c/image.png" alt=""></p>
<p>완벽히 성공함을 볼 수 있었다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[북클럽 [구글 엔지니어는 이렇게 일한다] - 테스트의 중요성과 단위테스트]]></title>
            <link>https://velog.io/@goat_hoon/%EB%B6%81%ED%81%B4%EB%9F%BD-%EA%B5%AC%EA%B8%80-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4%EB%8A%94-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EC%9D%BC%ED%95%9C%EB%8B%A4-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%9D%98-%EC%A4%91%EC%9A%94%EC%84%B1</link>
            <guid>https://velog.io/@goat_hoon/%EB%B6%81%ED%81%B4%EB%9F%BD-%EA%B5%AC%EA%B8%80-%EC%97%94%EC%A7%80%EB%8B%88%EC%96%B4%EB%8A%94-%EC%9D%B4%EB%A0%87%EA%B2%8C-%EC%9D%BC%ED%95%9C%EB%8B%A4-%ED%85%8C%EC%8A%A4%ED%8A%B8%EC%9D%98-%EC%A4%91%EC%9A%94%EC%84%B1</guid>
            <pubDate>Fri, 20 Jun 2025 11:42:26 GMT</pubDate>
            <description><![CDATA[<p>첫번째 북클럽 모임의 책은 &#39;구글 엔지니어는 이렇게 일한다&#39; 이다.
양이 상당하기 때문에, 각각 두개의 장을 골라 읽어 오기로 했다.
나는 그 중, 테스트 파트가 가장 궁금했어서 11장, 12장, 13장을 읽고 기억에 남는 것들을 정리하려고 한다. (13장은 읽었지만.. 양이 방대할것같아서 생략!)</p>
<h1 id="11장-테스트-개요">11장 &quot;테스트 개요&quot;</h1>
<h2 id="테스트-도입--배경">테스트 도입  배경</h2>
<p>11장에서는 구글 웹서버 제품을 예를 들어 테스트에 대해 언급하기 시작한다.
구글도 초기에 테스트를 작성하지 않았다. 하지만, 2005년도에 급격하게 소프트웨어가 복잡해지면서 릴리스때마다 버그 투성이였고, 다음 버전을 출시하기 위한 기간이 매우 길어졌다고 한다. 
이를 해결하기 위해 테스트를 도입하였고 1년 후에는 긴급 배포건이 절반으로 줄어들었다고 한다. 현재 해당 제품은.. 거의 매일 릴리즈 되고 있다고 한다.</p>
<blockquote>
<p>매우 공감하는 바였다. 나 또한 2023년 소프트웨어 마에스트로에서 실제 프로덕트를 개발해보면서, 수많은 기능을 추가하면서 버그가 쏟아져 나왔었다. 이 후부터는 테스트 코드 없는 삶은 상상할 수 없게 되었다..</p>
</blockquote>
<h2 id="테스트-케이스의-두가지-독립-요소">테스트 케이스의 두가지 독립 요소</h2>
<p>크기와 범위이다.
크기는 테스트 케이스 하나를 실행하는데 필요한 자원 (메모리, 프로세스, 시간)을 의미한다.
범위는 검증하려는 특정 코드를 의미한다.</p>
<h3 id="크기">크기</h3>
<p>크기가 작은 테스트는 하나의 프로세스내에서 실행되는 테스트를 의미한다.
가장 엄격하지만, 가장 빠르고, 가장 결정적인 요소의 테스트이다. 구글에서는 항상 작은 테스트를 만들라고 권고한다.</p>
<p>중간 크기의 테스트는 하나의 기기에서 이뤄지는 테스트를 의미한다.
이 말이 조금은 애매하게 들릴지 모르겠는데, 여러 프로세스와 스레드 그리고 <strong>localhost 네트워트 호출</strong>과 같은 blocking 요소도 허용한다.
하지만 절대 외부와의 네트워크 호출은 허용하지 않는다. 외부는 &#39;하나의 기기&#39; 에서 벗어나는 영역이기 때문이다.</p>
<blockquote>
<p>그렇다면 작은 테스트보다 어떤점을 주의해야할까? 이를 북클럽 모임의 심심풀이 질문으로 던져본다! 그리고 비결정적인 테스트가 의미하는 게 무엇일까? </p>
</blockquote>
<p>큰 크기의 테스트는 테스트 대상과 여러 외부 시스템과의 호출을 허용한다.
E2E테스트와 동일하다고 생각하면 좋을 것 같다.</p>
<blockquote>
<p>현재 재직중인 회사에서는 MSA환경에서 E2E자동화 테스트 툴이 있다. 하지만 참.. 이 큰 크기의 테스트는 비결정적으로 만드는 요소가 너무너무 많다... 어쩔 수 없는 한계인걸까??</p>
</blockquote>
<h3 id="범위">범위</h3>
<p>단위 테스트 == 좁은 범위
통합 테스트 == 중간 범위
E2E 테스트 == 넓은 범위</p>
<p>이렇게 정의한다.
앞의 크기와 조금 혼동될 수 있는 부분이 있기는 하다.
혼동을 막기 위해 추가 설명을 하면, 메서드 하나를 검사하는 좁은 범위 테스트라도 중간크기가 될 수 있다고 책에서 말한다. 예를들어 웹 프레임워크는 HTML + Javascript 가 묶여서 배포가 되어, 하나의 좁은 범위의 테스트를 하더라도 웹브라우저를 구동시켜야만 하는 상황이 존재할 수 있다.
또 반대로, 중간 크기의 테스트를 진행했는데, DB나 네트워크를 모두다 Stubbing한다면..? 그럴 경우는 좁은 범위의 테스트라고 할 수 있을 것이다.</p>
<h2 id="테스트-문화의-활성화">테스트 문화의 활성화</h2>
<p>그 유명한 구글도 개발자들이 테스트를 당연히 생각할 수 있게 다방면으로 노력했다고 한다.
나는 나의 회사에서 그럴 수 있을까? 
E2E테스트의 복잡성 말고, 작고 좁은 범위의 테스트를 더욱 늘렸으면 좋겠다..</p>
<h1 id="12장-단위-테스트">12장 &quot;단위 테스트&quot;</h1>
<blockquote>
<p>솔직히 학생 ~ 주니어 레벨 단계에서는 단위 테스트가 가장 중요할 것 같다. 학생 때는 복잡한 시스템을 다루지도 않을 뿐더러, 단위 테스트가 모든 테스트의 근원이 되기 때문이다. 그렇다면 단위 테스트를 작성할 때 유의해야할 점은 무엇일까에 대해 궁금해서 더욱 열심히 읽었다.</p>
</blockquote>
<h2 id="깨지기-쉬운-테스트">깨지기 쉬운 테스트</h2>
<p>깨지기 쉬운 테스트는 치명적이다. 이 책에서 말하는 &#39;크기&#39;도 작고, &#39;범위&#39;도 좁은 단위 테스트에서 깨지기 쉬운 테스트를 작성하는 것은 정말 치명적일 수 있다.
1%의 단위테스트가 실패한다고 가정하면 10000개에서 100개의 테스트가 실패한다는 뜻이니, 엔지니어 입장에서는 100개의 포인트가 디버깅 포인트가 되어버린다.
그렇기 때문에 깨지지 않는 테스트를 작성해야하는 것이 필수이다</p>
<p>다음과 같은 작업을 실행할 때 절대로 테스트가 깨지지 말아야한다.</p>
<ul>
<li>순수 리팩토링</li>
<li>새로운 기능 추가 (기존 기능 회귀테스트시 무조건 성공해야 함)</li>
<li>버그 수정 (기존 기능 회귀테스트시 무조건 성공해야 함)</li>
</ul>
<p>다만, 한가지 &quot;행위/기능 변경&quot; 의 요소에서는 테스트가 깨질 수 밖에 없고, 우리가 의도적으로 변경하기 때문에 테스트를 수정해야할 것이다.</p>
<h2 id="그렇다면-어떻게-결정적인-테스트를-만들-것인가">그렇다면 어떻게 결정적인 테스트를 만들 것인가</h2>
<h3 id="1-공개-api의-사용">1. 공개 API의 사용</h3>
<p>Access Modifier를 지원하는 언어들을 사용하면 공개 API / 비공개 API를 따로 작성한다.
보통 클라이언트가 호출하는 코드들을 public으로, 내부적으로 메서드를 추출하거나 코드를 작성하는 개발자가 직접 호출할 수 없게끔 만들어 놓은 private으로 작성하는데 우리는 이러한 의도를 이해하고, <strong>테스트코드 작성시 실제로 호출되는 메서드와 클래스만 테스트 해야할 것이다.</strong>
즉, 테스트코드는 실제 서비스 사용자가 호출하는 시나리오를 모방해야 한다는 의미이다.</p>
<blockquote>
<p>물론 public으로 open 한다 하더라도, 이게 꼭 클라이언트가 호출하는 코드라는 것을 의미하는 것은 아니다. 이러한 부분은 어디까지가 테스트 해야할 범위인지 판단하는 &#39;경험&#39;으로 커버해야하는 영역인 것 같다.</p>
</blockquote>
<h3 id="2-not-interaction-but-state">2. Not Interaction, But State</h3>
<p>상호작용을 테스트하지 말고, 결과 값 (상태) 을 테스트 하자.
상호작용 테스트는 쉽게 말하면 &quot;어떤 상황에서 이 함수가 실행 됐니?&quot; 를 의미하는데, 이는 너무 비결정적이다. 내부적인 버그로 A함수가 실행됐지만, A함수 이후에 B함수를 잘못 넣어서 다른 결과값이 도출될 수 있기 때문이다.</p>
<h3 id="3-테스트의-의도를-명확하게-드러내자">3. 테스트의 의도를 명확하게 드러내자</h3>
<h4 id="1-완전하고-간결하게">(1) 완전하고 간결하게</h4>
<p>예륻들어 계산기를 테스트하기 위해 계산기 인스턴스를 생성하려고 하는데,</p>
<pre><code class="language-java">calculator = new Calculator(내림기법적용, 소수점몇쨰자리까지표출, &quot;taehoon&quot;);
int result = calculator.calculate(calculationTest());
assertThat(result).isEqualTo(5);</code></pre>
<p>이런 정보가 들어가는 것은 좋지 않다는 이야기이다. 테스트를 읽는 사람에게 혼동을 준다. 그냥 계산기를 만드는 것. 그 행위 자체에 의미를 두고, 계산 하는 대상, 계산 결과 값을 명확하게 드러내야한다.</p>
<blockquote>
<p>위의 테스트를 우리는 어떻게 바꿀 수 있을까? 다 같이 고민해보자!</p>
</blockquote>
<h4 id="2-몇가지-팁들">(2) 몇가지 팁들</h4>
<ul>
<li>Given When Then 
무엇이 주어졌을때, 사용자가 무엇을 하면, 어떤 결과값이 나온다</li>
<li><strong>한가지 행위</strong> 만 검사하자</li>
<li>메서드 이름에 테스트할 행위를 명시하자<blockquote>
<p>만일 테스트메서드의 이름을 지을때 And이 들어간다면 어떻게 해야할까?</p>
</blockquote>
</li>
<li>테스트에 논리를 넣지말자
예를 들어 연산자, 반복문, 조건문은 지양하자.
<code>String nextPage = nav.goNext();
assertThat(nav.getUrl()).isEqualTo(&quot;/taehoon + nextPage);</code>
직설적인 코드를 만들자.</li>
<li>DRY 대신 DAMP를 
중복코드를 방지하는 DRY 방법 대신, 조금 중복을 허용하더라도 DAMP하게 작성하자. DAMP는 (Descriptive And Meaningful Phase)를 의미한다.
반복을 줄이는 것이 아니라, 조금 더 서술적이고 의미있게 테스트를 작성하는 것이 목표이다. 우리가 작성하는 건 서비스 코드가 아닌, 서비스 코드를 검증하기 위한 설명서이다.</li>
</ul>
<h1 id="테스트와-관련된-짤막한-회고">테스트와 관련된 짤막한 회고</h1>
<p>10개월동안 MSA 서비스를 처음 경험하면서 테스트에 대한 참 많은 생각이 들었었다. </p>
<ol>
<li><p>테스트는 유지보수 대상이어야만 한다.
기존의 Unit 테스트코드는 돌아가지 않는 경우가 다반사였다. 안되는 테스트 코드는 주석처리하고 돌리며, 심지어 이제는 테스트코드를 더 이상 작성하지 않는 문화가 자리잡힌 서비스들이 많다. E2E테스트 툴이 있기 때문일지는 몰라도.. 너무 고착화 되어버린 경우가 많다.
하지만, 생각해보면 MSA서비스라는 성격 때문에 Mocking하는 요소가 매우 많다보니 효용이 없을것이라고 생각할 수도 있다.
하지만, 이는 설계의 문제이기도 하지 않을까? Mocking이 많다는 것은 결국 내부적으로 강하게 의존성이 있을 수도 있고, 관심사 분리가 명확이 이루어지지 않는다는 것을 의미할 것이다.
그렇기에, 우리는 꾸준히 리팩토링 하고 적절한 Mocking을 통해 MSA환경에서도 단위테스트를 작성해야 함이 장기적으론 마땅하다. 서비스 안정성의 측면이든, 개발자의 커뮤니케이션 측면이든 말이다.</p>
</li>
<li><p>Bypass 성격의 서비스는 어떻게 테스트 해야 할까?
최근 Bypass성격을 가지고 있는 서비스를 맡게 되며 테스트의 필요성에 고려해보았다.
나는 이 경우에 테스트코드를 단 1도 작성하지 않았다. Bypass한다는 이야기는 서비스 로직이 없고 외부 시스템과 내부 시스템을 이어주는 징검다리 역할이다. 이 경우에는 E2E테스트를 도입하여 검증하는 방법이 베스트가 아닐까? 다른 사람의 생각도 궁금하다.</p>
</li>
</ol>
<blockquote>
<p>❗️실제 Flow: 외부 시스템 -&gt; 내부 G/W -&gt; <strong>Bypass서비스</strong> -&gt; 다른 서비스
❗️E2E 테스트 방법: 내부 G/W로 외부 시스템의 요청값을 호출 -&gt; <strong>Bypass서비스</strong> -&gt; 다른 서비스</p>
</blockquote>
<ol start="3">
<li>&quot;중간 크기&quot;의 테스트를 잘 만드는 것은 정말 어려운 일이다.
드물겠지만, 동시성 이슈가 생기는 문제상황의 테스트는 작성하기 참 어렵다. 쉽게 테스트를 하기 위해 스레드를 억지로 블로킹시키고 하는 방법이 있겠지만은.. 이러한 테스트가 수백, 수천개가 되면 블로킹하는 시간이 수백, 수천초가 될것이기 때문에.. 항상 더 나은 테스트 방법은 없을지 고민해보는 시간이 필요할 것 같다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[async 추가됐으니, Tomcat 스레드 수를 늘려야해요]]></title>
            <link>https://velog.io/@goat_hoon/tomcat-thread-vs-async-thread</link>
            <guid>https://velog.io/@goat_hoon/tomcat-thread-vs-async-thread</guid>
            <pubDate>Wed, 28 May 2025 16:25:38 GMT</pubDate>
            <description><![CDATA[<blockquote>
<p>서버에서 사용하는 비동기 로직은 스레드가 블로킹되지 않게 하기 위해 사용된다는 것은 흔히들 알고 있는 사실이다. CPU를 사용하지 않는 I/O 작업 때문에 스레드를 묶어두는 것은 죄악이다!
그렇다면 만약 비동기로직을 SpringBoot기반 서버에 추가했다면, Tomcat의 스레드를 늘려야할까? 비동기로직을 처리하는 스레드가 추가된거니까 늘려야되는게 맞을까?</p>
</blockquote>
<h2 id="배경">&lt;배경&gt;</h2>
<p>때는 한가로운 5월 1일, 근로자의 날... <strong>운영 중이던 서비스에 장애</strong>가 발생했다.</p>
<p>동영상 파일을 Storage가 아닌 MongoDB의 GridFS 기능을 이용해 저장하고 있었는데, 디스크에 Write 작업이 몰리면서 쓰기 지연(write queue)이 발생했고, 결국 Timeout이 발생해 동영상이 정상적으로 저장되지 않는 문제가 나타났다. 
<del>(Write 작업이 몰린 근본적인 원인은 특정 단말모델의 잘못된 retry로직이긴 했다..)</del></p>
<p>GridFS는 하나의 파일을 여러 개의 chunk로 분할해 저장하고, 각 chunk는 별도의 메타데이터와 함께 저장된다. 특히 우리 시스템은 사용자 수가 급격히 증가하면서 총 10TB에 달하는 저장 용량으로 확장된 상태였고, 하나의 동영상이 여러 chunk로 나뉘고, 그만큼의 메타데이터가 따로 관리되는 구조였다. 
이로 인한 대용량 환경에서의 저장/조회 성능 문제는 인지하고 있었고, 이를 S3 Storage로 이관할 계획이었다.</p>
<p><strong>하지만 이관 전에, 장애가 발생해부렸다.</strong></p>
<p>그러면서 급하게 MongoDB에서 S3 Storage로 저장하는 로직을 추가하기로 했고, 일단 임시방편으로 백그라운드 스레드에서 비동기로 I/O 작업을 처리하게끔 하자는 의견이 나왔다. 
그러면서 다른 분이 비동기로직을 추가했으니 스프링부트의 Max Tomcat 스레드 수를 늘려야하지 않겠냐고 말씀하셨다.</p>
<blockquote>
<p><strong>Tomcat 스레드랑 비동기 스레드는 다른 스레드입니다..!</strong></p>
</blockquote>
<p>민망하실까봐 위처럼 말하지 않았지만.. 둘이 별개의 스레드 풀에서 동작하고 있다는 것을 알고 있었기 때문에, 정중하게 의미가 없을 것이라고 말씀드렸고 동일한 tomcat 스레드 수 설정값으로 임시 배포가 될 수 있었다. </p>
<p>근데 여기서! 나도 이러한 사실을 &#39;지식&#39;으로만 알고 있었던 사실이었기에 이를 실제로 검증해보고자 글을 쓰게 되었다. </p>
<h2 id="검증과정">&lt;검증과정&gt;</h2>
<ol>
<li>application.yaml에서 tomcat max threads 설정을 1로 설정</li>
<li>async가 적용된 비즈니스 로직 추가 (I/O 가정을 위한 Thread sleep 추가)</li>
<li>Load Test를 위한 모듈 추가 Jmeter나 부하테스트 툴로 해도 되는데 굳이..?</li>
</ol>
<p>하나하나 알아보자!</p>
<h3 id="1-application-yaml-설정">(1) application yaml 설정</h3>
<p>독립변인으로 Tomcat의 Max 스레드 수를 바꿔가며 실험해볼 예정이다.</p>
<pre><code>server:
  tomcat:
    threads:
      max: 1 </code></pre><p>위와 같이 설정하자. </p>
<h3 id="2-async적용하기">(2) async적용하기</h3>
<p>비동기 로직을 추가하는 방법이 보통 두가지가 있을 수 있다.</p>
<ol>
<li>순수 Java 사용하는 방법<pre><code class="language-java"> private final ExecutorService es = Executors.newFixedThreadPool(10);    
 Future&lt;String&gt; future = es.submit(() -&gt; slowThread(idx));</code></pre>
 ExecutorService를 활용해서 비동기 로직을 직접 구현하는 방식이다.<br> 대충 위 코드와 같은 방식이라고 이해하면 된다.</li>
</ol>
<ol start="2">
<li>@Async
스프링 부트를 사용해본 개발자라면 익숙할 만한 어노테이션이다.
@Async는 별도의 설정 없이도 간단하게 비동기 처리를 적용할 수 있으며, 비동기 처리를 위한 설정 클래스만 스프링 빈으로 등록하면, 스프링 AOP 기반으로 @Async가 붙은 메서드가 자동으로 비동기 실행된다.</li>
</ol>
<p>여기에서는 좀 더 통상적으로 사용되는 @Async 어노테이션을 적용한 로직을 소개하려한다.</p>
<h4 id="asyncconfig-클래스">AsyncConfig 클래스</h4>
<p>독립변인으로 최소 스레드 수를 바꿔가며 실험해볼 예정이다.</p>
<blockquote>
<p>여기서 maxPoolSize는 큐가 다 차있을때만 늘어날 수 있는 최대 스레드 수의 개수이므로 혼동하지 말자. 선후 관계는 &#39;큐가 찼을때&#39; 이다!!</p>
</blockquote>
<p>일단 최소 스레드를 1로 설정하자.</p>
<pre><code class="language-java">@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(1);
        executor.setMaxPoolSize(100);  
        executor.setQueueCapacity(100); 
        executor.setThreadNamePrefix(&quot;MyAsyncTask-&quot;);
        executor.initialize();
        return executor;
    }
}</code></pre>
<h4 id="asyncservice-클래스">AsyncService 클래스</h4>
<p>@Async 어노테이션을 바탕으로 비동기 로직을 처리할 로직이다.
컨트롤러에서 직접 @Async를 달아도 되지 않을까 생각했는데, 생각해보니 Async는 AOP니까 반드시 다른 클래스에서 불려야되는 것을 잊어버렸었다. 그래서 AsyncService클래스를 따로 생성하고 컨트롤러에서 비동기 로직이 포함된 클래스의 메서드를 호출하게 했다.</p>
<p>그리고, 해당 스레드가 비동기스레드인지 확인하기 위해서 Thread의 이름을 남긴다.
I/O는 2초에 시간이 걸린다고 가정하자.</p>
<p>이 때 함수의 반환값은 CompletableFuture인 것을 알 수 있는데 이는 비동기로직을 억지로 동기로 만들어 검증결과를 확실하게 확인하기 위함이다.</p>
<pre><code class="language-java">@Service
@Slf4j
public class AsyncService {

    @Async
    public CompletableFuture&lt;String&gt; asyncLogic() {
        log.info(&quot;[START] BACKGROUND THREAD : {}&quot;, Thread.currentThread().getName());
        try {
            // I/O 2초 걸린다고 가정
            Thread.sleep(2000L);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        log.info(&quot;[FINISH] BACKGROUND THREAD : {}&quot;, Thread.currentThread().getName());
        return CompletableFuture.completedFuture(&quot;finish&quot;);
    }
}</code></pre>
<h4 id="asynccontroller-클래스">AsyncController 클래스</h4>
<p>컨트롤러 클래스에서도 마찬가지로 Thread의 이름을 찍는다. 비동기 스레드랑 다른 스레드 풀을 사용하는지 확실히 구분하기 위해서이다.
그리고 비동기 로직을 호출하자.</p>
<p>하지만 비동기의 특성상 해당 응답을 기다리지 않고 바로 response를 해서 검증결과를 보기 쉽지 않은데, 이를 위해 응답을 기다리게 하기 위해 CompletebleFuture (Future)을 사용해서 타 스레드의 응답을 억지로 기다리게 하자.</p>
<pre><code class="language-java">@Slf4j
@RestController
@RequiredArgsConstructor
public class TomcatThreadAsyncThreadControllerWithAsyncAnnotation {

    private final AsyncService asyncService;

    @GetMapping(&quot;/async/block&quot;)
    public String block(int idx) throws InterruptedException, ExecutionException {
        log.info(&quot;TOMCAT REQUEST THREAD : {}&quot;, Thread.currentThread().getName());
        CompletableFuture&lt;String&gt; response = asyncService.asyncLogic();
        log.info(&quot;TOMCAT RESPONSE THREAD : {}&quot;, Thread.currentThread().getName());
        response.get();
        return &quot;TOMCAT RESPONSE THREAD [ &quot; + idx + &quot; ]&quot;;
    }
}
</code></pre>
<h3 id="3-loadtest-모듈-작성하기">(3) LoadTest 모듈 작성하기</h3>
<p>API 호출을 통한 검증을 위해 LoadTest 모듈을 만들어 진행했다.
아래처럼 작성하면 비동기로 i번의 API호출을 동시에 요청할 수 있으므로 웬만한 부하테스트 뺨친다!</p>
<blockquote>
<p>첨엔 curl로 날리려고 했는데.. 다른 블로거분이 작성한 글을 통해 이를 차용해왔다! <a href="https://ooeunz.tistory.com/149">https://ooeunz.tistory.com/149</a></p>
</blockquote>
<pre><code class="language-java">@Slf4j
public class LoadTest {
    private static final String BASE_URL = &quot;http://localhost:8080&quot;;
    private static final RestTemplate restTemplate = new RestTemplate();
    private static final ExecutorService es = Executors.newFixedThreadPool(100); // 여기 개수 변경하면서 실험해보면 어떻게 될까

    public void fetchApi() {
        for (int i = 1; i &lt;= 100; i++) {
            final int idx = i;
            // 비동기처리
            es.execute(() -&gt; {
                StopWatch stopWatch = new StopWatch();

                stopWatch.start();
                String response = restTemplate.getForObject(BASE_URL + &quot;/async/block?idx=&quot; + idx, String.class);
                stopWatch.stop();

                log.info(&quot;response=&quot; + response + &quot;, stopWatch=&quot; + stopWatch.getTotalTimeSeconds());
            });
        }
    }

    public static void main(String[] args) throws InterruptedException {
        LoadTest loadTest = new LoadTest();
        StopWatch stopWatch = new StopWatch();

        stopWatch.start();
        loadTest.fetchApi();
        stopWatch.stop();

        es.shutdown();
        es.awaitTermination(1000, TimeUnit.SECONDS);
        log.info(&quot;Total stop watch &quot; + stopWatch.getTotalTimeSeconds());
    }
}</code></pre>
<h2 id="결과">&lt;결과&gt;</h2>
<p>Tomcat 스레드가 1개임을 확인하는 방법!
<img src="https://velog.velcdn.com/images/goat_hoon/post/b283de01-822c-46f0-b6d7-65b21f95dc4b/image.png" alt=""></p>
<h3 id="1-tomcat-스레드-1개-비동기-스레드-1개">1. Tomcat 스레드 1개, 비동기 스레드 1개</h3>
<p>최대 스레드는 힘을 쓰지 못한다.
왜? 어차피 요청을 받는 스레드는 Tomcat 스레드인데, 1개로 제한되어 있으니까!
그러면 100번의 요청을 한번에 보내도 입구가 1개이므로 2초 * 100 = 200초가 걸릴 것으로 예상할 수 있다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/0823f2d0-4ab3-40f5-889f-8428c1693316/image.png" alt=""></p>
<h3 id="2-tomcat-스레드-1개-비동기-스레드-4개">2. Tomcat 스레드 1개, 비동기 스레드 4개</h3>
<p>최소 4개의 스레드가 비동기로 작업할 수 있게된다.
하지만 Tomcat 스레드가 1개이므로 어차피 입구가 한개이므로 어차피 200초일 것이다.
이전 결과와 같다!
<img src="https://velog.velcdn.com/images/goat_hoon/post/705c73b6-6215-4096-822a-c0acbdb4a27b/image.png" alt=""></p>
<h3 id="3-tomcat-스레드-2개-비동기-스레드-1개">3. Tomcat 스레드 2개, 비동기 스레드 1개</h3>
<p>Tomcat 스레드를 2개로 늘렸어도, 비동기 스레드 풀에 있는 스레드가 1개만 가용되어, 똑같이 200초가 걸린다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/15eda668-e4cb-48e5-b0b3-ea872bbb69c0/image.png" alt="">처음에 Tomcat 스레드 1,2 가 동시에 인입됐지만, AsyncTask-1만 가용되면서 다음과 같은 결과가 나왔다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/46fd2a40-6b6a-4cd6-9044-29b485ebce6a/image.png" alt=""></p>
<h3 id="4-tomcat-스레드-2개-비동기-스레드-4개">4. Tomcat 스레드 2개, 비동기 스레드 4개</h3>
<p>예상은? 2개의 스레드가 병렬로 동시에 비동기 스레드 풀에 넘기면서 2배 빨라질 것으로 예상할 수 있다. 즉 100초가 걸릴 것이다.
<img src="https://velog.velcdn.com/images/goat_hoon/post/db2aaa1a-67b4-4988-87f9-941f49484d28/image.png" alt=""><img src="https://velog.velcdn.com/images/goat_hoon/post/c49052a0-b41b-4ee4-9c02-febd6a31c43d/image.png" alt=""></p>
<p>동시에 실행되는 모습을 볼 수 있다!!</p>
<p><img src="https://velog.velcdn.com/images/goat_hoon/post/982bfd76-c2c8-4870-a7cd-a4d04261d434/image.png" alt=""></p>
]]></description>
        </item>
    </channel>
</rss>