<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>taetae-o 님의 블로그</title>
    <link>https://taetae-o.tistory.com/</link>
    <description>taetae-o 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Mon, 20 Jul 2026 00:56:07 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>taetae-o</managingEditor>
    <item>
      <title>Spring AI 기반 운동 피드백 기능 1차 구현 정리</title>
      <link>https://taetae-o.tistory.com/23</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 목표&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순 자유 질의형 챗봇이 아니라, 사용자의 운동 데이터를 기반으로 피드백을 생성하는 &lt;b&gt;AI 운동 코치 API&lt;/b&gt;를 구현하는 방향으로 진행했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 구현 범위는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단일 운동 데이터 입력 기반 AI 피드백 API&lt;/li&gt;
&lt;li&gt;운동 기록 RDB 저장&lt;/li&gt;
&lt;li&gt;운동 기록 텍스트 content 생성&lt;/li&gt;
&lt;li&gt;OpenAI embedding 생성&lt;/li&gt;
&lt;li&gt;PostgreSQL pgvector 기반 VectorDB 저장&lt;/li&gt;
&lt;li&gt;Spring AI 기반 LLM 스트리밍 호출&lt;/li&gt;
&lt;li&gt;SSE 기반 실시간 응답&lt;/li&gt;
&lt;li&gt;테스트 기반 검증&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지식 RAG 검색은 아직 피드백 호출 흐름에 직접 적용하지 않았다. 현재는 먼저 사용자의 운동 기록을 VectorDB에 쌓는 단계까지 구현했고, 이후 RAG 검색 기반 프롬프트 확장은 별도 기능으로 추가할 계획이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 프로젝트 MVP&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.1 MVP 기능&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 MVP는 &lt;b&gt;단일 운동 피드백 생성&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 러닝 또는 사이클링 운동 데이터를 입력한다.&lt;br /&gt;서버는 해당 운동 데이터를 저장하고, AI가 분석 가능한 형태로 변환한 뒤 LLM에 전달하여 피드백을 생성한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지원하는 운동 타입은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;RUNNING&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CYCLING&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 설정한 피드백 등급은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;AMATEUR&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;PRO&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;등급에 따라 프롬프트 톤과 분석 수준을 다르게 가져간다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2.2 단일 운동 피드백 흐름&lt;/h3&gt;
&lt;pre class=&quot;maxima&quot;&gt;&lt;code&gt;운동 데이터 입력
&amp;rarr; Controller에서 요청 수신
&amp;rarr; Workout 도메인으로 변환
&amp;rarr; 운동 기록 RDB 저장
&amp;rarr; 운동 기록을 임베딩용 content로 변환
&amp;rarr; OpenAI Embedding API 호출
&amp;rarr; PostgreSQL pgvector 테이블에 저장
&amp;rarr; 운동 타입별 Prompt 생성
&amp;rarr; Spring AI로 LLM 스트리밍 호출
&amp;rarr; SSE로 사용자에게 피드백 전달&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-19 오전 5.08.11.png&quot; data-origin-width=&quot;3054&quot; data-origin-height=&quot;1172&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqUSJz/dJMcajbQWw3/cgRM6JfyIPNLoqDDslgAhk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqUSJz/dJMcajbQWw3/cgRM6JfyIPNLoqDDslgAhk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqUSJz/dJMcajbQWw3/cgRM6JfyIPNLoqDDslgAhk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqUSJz%2FdJMcajbQWw3%2FcgRM6JfyIPNLoqDDslgAhk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3054&quot; height=&quot;1172&quot; data-filename=&quot;스크린샷 2026-07-19 오전 5.08.11.png&quot; data-origin-width=&quot;3054&quot; data-origin-height=&quot;1172&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 기술 선택과 구조&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3.1 Spring MVC + JPA + Virtual Thread&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 WebFlux/R2DBC 구조도 고려했지만, 현재 프로젝트는 다음 구조로 정리했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring MVC&lt;/li&gt;
&lt;li&gt;Spring Data JPA&lt;/li&gt;
&lt;li&gt;PostgreSQL&lt;/li&gt;
&lt;li&gt;pgvector&lt;/li&gt;
&lt;li&gt;Spring AI&lt;/li&gt;
&lt;li&gt;Java Virtual Thread&lt;/li&gt;
&lt;li&gt;SSE&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운동 기록은 JPA 연관관계 매핑을 사용하는 편이 자연스럽다.&lt;br /&gt;또한 AI API 호출은 외부 I/O가 중심이므로, MVC 환경에서도 Virtual Thread를 사용하면 동시 요청 처리 부담을 줄일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러에서는 SSE 응답 처리를 위해 &lt;code&gt;SseEmitter&lt;/code&gt;를 사용한다.&lt;/p&gt;
&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;@PostMapping(value = &quot;/single/stream&quot;, produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter generateSingleWorkoutFeedbackStream(@RequestBody FeedbackRequest request) {

    Member member = Member.of(1L, &quot;temporary-user&quot;);
    Workout workout = workoutMapper.toWorkout(request);
    SseEmitter emitter = new SseEmitter(0L);

    Thread.startVirtualThread(() -&amp;gt; {
        try {
            coachFeedback.getFeedbackStream(
                    member, workout, request.tier(), chunk -&amp;gt; send(emitter, chunk));
            emitter.complete();
        } catch (Exception e) {
            emitter.completeWithError(e);
        }
    });

    return emitter;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 &lt;code&gt;Member.of(1L, &quot;temporary-user&quot;)&lt;/code&gt;는 실제 인증 적용 전 수동 테스트를 위한 임시 처리다.&lt;br /&gt;운영 구조에서는 Spring Security의 인증 사용자 정보를 기반으로 &lt;code&gt;Member&lt;/code&gt;를 주입하는 방식으로 변경할 예정이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. Hexagonal Architecture 기반 패키지 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 패키지는 크게 다음과 같이 분리했다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;project
├── adapter
│   ├── in
│   │   └── mapper
│   └── out
│       └── persistence
├── application
│   ├── in
│   ├── out
│   ├── service
│   ├── workout
│   └── prompt
│       ├── embedding
│       ├── template
│       │   └── section
│       └── workout
└── domain&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.1 Domain&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인은 Spring 의존 없이 구성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 도메인:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;Member&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RunningWorkout&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CyclingWorkout&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WorkoutVector&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AthleteTier&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WorkOutType&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 &lt;code&gt;RunningWorkout&lt;/code&gt;은 생성 시점에 핵심 불변식을 검증한다.&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;@Override
public void validate() {
    if (startedAt == null) {
        throw new IllegalArgumentException(&quot;운동 시작 시간(startedAt)은 필수 값입니다.&quot;);
    }
    if (endedAt == null) {
        throw new IllegalArgumentException(&quot;운동 종료 시간(endedAt)은 필수 값입니다.&quot;);
    }
    if (endedAt.isBefore(startedAt)) {
        throw new IllegalArgumentException(&quot;운동 종료 시간(endedAt)은 시작 시간(startedAt)보다 빠를 수 없습니다.&quot;);
    }
    if (movingTime != null &amp;amp;&amp;amp; movingTime &amp;lt;= 0) {
        throw new IllegalArgumentException(&quot;운동 시간(movingTime)은 0보다 커야 합니다.&quot;);
    }
    if (distance != null &amp;amp;&amp;amp; distance &amp;lt; 0) {
        throw new IllegalArgumentException(&quot;운동 거리(distance)는 음수가 될 수 없습니다.&quot;);
    }
    if (steps != null &amp;amp;&amp;amp; steps &amp;lt; 0) {
        throw new IllegalArgumentException(&quot;걸음 수(steps)는 음수가 될 수 없습니다.&quot;);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.2 Application In Port&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부에서 들어오는 유스케이스는 application/in에 정의했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface CoachFeedback {

    void getFeedbackStream(
            Member member,
            Workout workout,
            AthleteTier tier,
            Consumer&amp;lt;String&amp;gt; chunkConsumer
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운동 벡터 저장 유스케이스도 인터페이스로 분리했다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface WorkoutVectorSaver {

    UUID save(Member member, Workout workout, AthleteTier tier);
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4.3 Application Out Port&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;외부 시스템으로 나가는 의존성은 application/out에 정의했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;AiSender&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;EmbeddingGenerator&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;MemberRepository&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RunningWorkoutRepository&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CyclingWorkoutRepository&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WorkoutVectorRepository&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 분리하면 application service는 JPA, JDBC, Spring AI 구현체를 직접 알지 않아도 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 핵심 구현 1: 단일 운동 피드백 유스케이스&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;CoachFeedbackService&lt;/code&gt;는 단일 운동 피드백의 상위 흐름을 담당한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class CoachFeedbackService implements CoachFeedback {

    private final AiSender aiSender;
    private final AiPromptBuilder aiPromptBuilder;
    private final WorkoutRecordSaver workoutRecordSaver;
    private final WorkoutVectorSaver workoutVectorSaver;

    @Override
    public void getFeedbackStream(
            Member member,
            Workout workout,
            AthleteTier tier,
            Consumer&amp;lt;String&amp;gt; chunkConsumer
    ) {
        Long workoutId = workoutRecordSaver.save(member, workout, tier);

        workoutVectorSaver.save(member, workoutId, workout, tier);

        String prompt = aiPromptBuilder.build(workout, tier);

        aiSender.sendStream(prompt, chunkConsumer);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 서비스는 다음 순서를 오케스트레이션한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;code&gt;WorkoutRecordSaver&lt;/code&gt;로 운동 기록을 RDB에 저장한다.&lt;/li&gt;
&lt;li&gt;저장된 &lt;code&gt;workoutId&lt;/code&gt;를 기반으로 &lt;code&gt;WorkoutVectorSaver&lt;/code&gt;가 VectorDB 저장을 수행한다.&lt;/li&gt;
&lt;li&gt;운동 데이터 기반 프롬프트를 생성한다.&lt;/li&gt;
&lt;li&gt;AI 스트리밍을 호출한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 RDB 운동 기록 저장과 VectorDB 저장 책임이 분리되었다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 핵심 구현 2: 운동 기록 저장과 VectorDB 저장 책임 분리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운동 기록 저장은 &lt;code&gt;WorkoutRecordSaver&lt;/code&gt; 유스케이스 포트가 담당한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface WorkoutRecordSaver {

    Long save(Member member, Workout workout, AthleteTier tier);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현체인 &lt;code&gt;WorkoutRecordSaveService&lt;/code&gt;는 운동 타입별 저장 전략 registry를 감싼다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class WorkoutRecordSaveService implements WorkoutRecordSaver {

    private final WorkoutSaveStrategyRegistry workoutSaveStrategyRegistry;

    @Override
    public Long save(Member member, Workout workout, AthleteTier tier) {
        return workoutSaveStrategyRegistry.save(member, workout, tier);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VectorDB 저장은 &lt;code&gt;WorkoutVectorSaver&lt;/code&gt;가 담당한다.&lt;br /&gt;이미 저장된 운동 기록 ID를 받아 content 생성, embedding 생성, pgvector 저장만 수행한다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class WorkoutVectorSaveService implements WorkoutVectorSaver {

    private final WorkoutEmbeddingContentBuilderRegistry contentBuilderRegistry;
    private final EmbeddingGenerator embeddingGenerator;
    private final WorkoutVectorRepository workoutVectorRepository;

    @Override
    public UUID save(Member member, Long workoutId, Workout workout, AthleteTier tier) {
        String content = contentBuilderRegistry.build(workout, tier);

        float[] embedding = embeddingGenerator.generate(content);

        WorkoutVector workoutVector = WorkoutVector.create(new CreateWorkoutVectorCommand(
                member.getId(),
                workoutId,
                workout.getWorkOutType(),
                content,
                createMetadata(workout, tier),
                embedding
        ));

        return workoutVectorRepository.save(workoutVector);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 저장 흐름은 다음을 보장한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;운동 기록은 RDB에 먼저 저장된다.&lt;/li&gt;
&lt;li&gt;저장된 운동 기록 ID를 &lt;code&gt;workout_id&lt;/code&gt;로 VectorDB에 함께 저장한다.&lt;/li&gt;
&lt;li&gt;VectorDB 저장 서비스는 RDB 저장을 직접 수행하지 않는다.&lt;/li&gt;
&lt;li&gt;운동 타입별로 임베딩 원문 content를 다르게 생성한다.&lt;/li&gt;
&lt;li&gt;OpenAI embedding 결과를 pgvector 컬럼에 저장한다.&lt;/li&gt;
&lt;li&gt;이후 &lt;code&gt;WorkoutVectorSaver&lt;/code&gt;는 비동기 색인 작업으로 분리하기 쉬운 구조가 되었다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 핵심 구현 3: 전략 패턴 기반 운동 타입 확장&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운동 타입이 늘어날 때 switch문을 계속 수정하지 않도록 전략 패턴을 적용했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7.1 요청 &amp;rarr; 도메인 변환 전략&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface WorkoutRequestMapper {

    WorkOutType supportType();

    Workout toWorkout(FeedbackRequest request);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;WorkoutMapper&lt;/code&gt;는 등록된 전략 중 운동 타입에 맞는 mapper를 선택한다.&lt;/p&gt;
&lt;pre class=&quot;php&quot;&gt;&lt;code&gt;@Component
public class WorkoutMapper {

    private final Map&amp;lt;WorkOutType, WorkoutRequestMapper&amp;gt; mappers;

    public WorkoutMapper(List&amp;lt;WorkoutRequestMapper&amp;gt; mappers) {
        this.mappers = mappers.stream()
                .collect(Collectors.toMap(
                        WorkoutRequestMapper::supportType,
                        Function.identity()
                ));
    }

    public Workout toWorkout(FeedbackRequest request) {
        if (request.workOutType() == null) {
            throw new IllegalArgumentException(&quot;운동 종목(workOutType)은 필수 값입니다.&quot;);
        }

        WorkoutRequestMapper mapper = mappers.get(request.workOutType());
        if (mapper == null) {
            throw new IllegalArgumentException(&quot;지원하지 않는 운동 타입입니다: &quot; + request.workOutType());
        }

        return mapper.toWorkout(request);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 운동 타입을 추가할 때는 해당 운동 전용 mapper만 추가하면 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7.2 운동 기록 저장 전략&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상위 유스케이스 포트는 &lt;code&gt;application/in&lt;/code&gt;의 &lt;code&gt;WorkoutRecordSaver&lt;/code&gt;이고, 운동 타입별 분기 로직은 &lt;code&gt;application/workout&lt;/code&gt; 패키지의 전략으로 분리했다.&lt;/p&gt;
&lt;pre class=&quot;applescript&quot;&gt;&lt;code&gt;application/in
├── WorkoutRecordSaver
└── WorkoutVectorSaver

application/workout
├── WorkoutSaveStrategy
├── RunningWorkoutSaveStrategy
├── CyclingWorkoutSaveStrategy
└── WorkoutSaveStrategyRegistry&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운동 타입별 저장 전략 인터페이스는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface WorkoutSaveStrategy {

    WorkOutType supportType();

    Long save(Member member, Workout workout, AthleteTier tier);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;러닝 저장 전략:&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class RunningWorkoutSaveStrategy implements WorkoutSaveStrategy {

    private final RunningWorkoutRepository runningWorkoutRepository;

    @Override
    public WorkOutType supportType() {
        return WorkOutType.RUNNING;
    }

    @Override
    public Long save(Member member, Workout workout, AthleteTier tier) {
        if (!(workout instanceof RunningWorkout runningWorkout)) {
            throw new IllegalArgumentException(&quot;러닝 운동 데이터가 아닙니다.&quot;);
        }

        return runningWorkoutRepository.save(member, runningWorkout, tier);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7.3 임베딩 content 생성 전략&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface WorkoutEmbeddingContentBuilder {

    WorkOutType supportType();

    String build(Workout workout, AthleteTier tier);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;러닝 content 예시:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;[러닝 운동 기록]
사용자 등급: AMATEUR
운동 타입: RUNNING
운동 시작 시간: 2026-07-18T07:00
운동 종료 시간: 2026-07-18T07:45
거리: 8.20 km
운동 시간: 45 분
...
평균 페이스: 5.48 min/km
최고 페이스: 4.92 min/km
걸음 수: 7600&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7.4 프롬프트 생성 전략&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트 생성은 &lt;code&gt;Template + Section&lt;/code&gt; 구조로 분리했다.&lt;br /&gt;&lt;code&gt;CoachFeedbackPromptBuilder&lt;/code&gt;는 application service가 사용하는 상위 진입점이고, 실제 단일 운동 프롬프트 조립은 &lt;code&gt;SingleWorkoutPromptTemplate&lt;/code&gt;이 담당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출 구조는 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;CoachFeedbackService
&amp;rarr; CoachFeedbackPromptBuilder
&amp;rarr; SingleWorkoutPromptTemplate
&amp;rarr; SingleWorkoutPromptSection&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;CoachFeedbackPromptBuilder&lt;/code&gt;는 직접 문자열을 조립하지 않고, &lt;code&gt;Workout&lt;/code&gt;과 &lt;code&gt;AthleteTier&lt;/code&gt;를 &lt;code&gt;SingleWorkoutPromptContext&lt;/code&gt;로 감싼 뒤 template에 위임한다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Component
public class CoachFeedbackPromptBuilder {

    private final PromptTemplate&amp;lt;SingleWorkoutPromptContext&amp;gt; singleWorkoutPromptTemplate;

    @Autowired
    public CoachFeedbackPromptBuilder(SingleWorkoutPromptTemplate singleWorkoutPromptTemplate) {
        this.singleWorkoutPromptTemplate = singleWorkoutPromptTemplate;
    }

    public String build(Workout workout, AthleteTier tier) {
        return singleWorkoutPromptTemplate.render(new SingleWorkoutPromptContext(workout, tier));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Template은 등록된 section들을 순서대로 실행하고, 결과 문자열을 합쳐 최종 prompt를 만든다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Component
public class SingleWorkoutPromptTemplate
        implements PromptTemplate&amp;lt;SingleWorkoutPromptContext&amp;gt; {

    private final List&amp;lt;SingleWorkoutPromptSection&amp;gt; sections;

    public SingleWorkoutPromptTemplate(List&amp;lt;SingleWorkoutPromptSection&amp;gt; sections) {
        this.sections = sections.stream()
                .sorted(Comparator.comparingInt(SingleWorkoutPromptSection::order))
                .toList();
    }

    @Override
    public String render(SingleWorkoutPromptContext context) {
        return sections.stream()
                .map(section -&amp;gt; section.render(context))
                .collect(Collectors.joining(&quot;\n&quot;));
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 단일 운동 프롬프트는 다음 section들로 구성된다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;SingleWorkoutPromptTemplate
├── SingleWorkoutSystemInstructionSection
├── SingleWorkoutCommonSection
├── SingleWorkoutSpecificSection
├── SingleWorkoutHallucinationGuardSection
└── SingleWorkoutOutputFormatSection&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 section의 책임은 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;SingleWorkoutSystemInstructionSection&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;AMATEUR&lt;/code&gt;, &lt;code&gt;PRO&lt;/code&gt; 등급별 코칭 톤과 분석 수준 지정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SingleWorkoutCommonSection&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;거리, 시간, 심박수, 케이던스 등 공통 운동 정보 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SingleWorkoutSpecificSection&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;운동 타입에 따라 러닝/사이클링 전용 section builder 선택&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SingleWorkoutHallucinationGuardSection&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;제공된 데이터 외 추측 금지 규칙 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SingleWorkoutOutputFormatSection&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;응답 형식 고정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운동 타입별 분석 지표는 기존 전략을 그대로 사용한다.&lt;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;public interface WorkoutPromptSectionBuilder {

    WorkOutType supportType();

    String build(Workout workout);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 프롬프트 생성의 전체 흐름은 template이 담당하고, 바뀌는 조각은 section과 운동 타입별 builder가 담당한다.&lt;br /&gt;나중에 1주일 피드백이나 RAG 지식 context가 추가되면 &lt;code&gt;WeeklyWorkoutPromptTemplate&lt;/code&gt;, &lt;code&gt;KnowledgeContextSection&lt;/code&gt; 같은 단위로 확장할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. 핵심 구현 4: RDB Entity와 Domain 분리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장용 Entity와 도메인 객체를 분리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 러닝 운동 기록 저장 Entity는 JPA 전용 객체다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Entity
@Table(name = &quot;running_workout&quot;)
public class RunningWorkoutEntity {

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

    @Enumerated(EnumType.STRING)
    private AthleteTier tier;

    @ManyToOne
    @JoinColumn(name = &quot;member_id&quot;)
    private MemberEntity member;

    private LocalDateTime startedAt;
    private LocalDateTime endedAt;
    private Double distance;
    ...

    public static RunningWorkoutEntity from(
            RunningWorkout workout,
            AthleteTier tier,
            Member member
    ) {
        return new RunningWorkoutEntity(
                null,
                tier,
                MemberEntity.reference(member),
                workout.getStartedAt(),
                workout.getEndedAt(),
                workout.getDistance(),
                ...
        );
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 객체는 JPA를 알지 않고, Entity는 persistence adapter 내부에만 존재한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. 핵심 구현 5: PostgreSQL pgvector 저장&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운동 벡터 저장 테이블은 &lt;code&gt;schema.sql&lt;/code&gt;로 관리한다.&lt;/p&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE IF NOT EXISTS workout_vector_store (
    id uuid PRIMARY KEY,
    member_id bigint,
    workout_id bigint NOT NULL,
    workout_type varchar(30) NOT NULL,
    content text NOT NULL,
    metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
    embedding vector(1536) NOT NULL,
    created_at timestamp NOT NULL
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 adapter는 JDBC를 사용한다.&lt;br /&gt;JPA가 pgvector 타입을 직접 다루기 어렵기 때문에, vector 저장소는 &lt;code&gt;JdbcTemplate&lt;/code&gt; 기반으로 구현했다.&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class WorkoutVectorRepositoryAdapter implements WorkoutVectorRepository {

    private final JdbcTemplate jdbcTemplate;
    private final ObjectMapper objectMapper;

    @Override
    public UUID save(WorkoutVector workoutVector) {
        WorkoutVectorEntity entity = WorkoutVectorEntity.from(workoutVector);

        jdbcTemplate.update(&quot;&quot;&quot;
                        INSERT INTO workout_vector_store (
                            id,
                            member_id,
                            workout_id,
                            workout_type,
                            content,
                            metadata,
                            embedding,
                            created_at
                        )
                        VALUES (
                            CAST(? AS uuid),
                            ?,
                            ?,
                            ?,
                            ?,
                            CAST(? AS jsonb),
                            CAST(? AS vector),
                            ?
                        )
                        &quot;&quot;&quot;,
                entity.getId().toString(),
                entity.getMemberId(),
                entity.getWorkoutId(),
                entity.getWorkoutType().name(),
                entity.getContent(),
                toMetadataJson(entity),
                toVectorLiteral(entity),
                entity.getCreatedAt()
        );

        return entity.getId();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 테스트 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 구현은 테스트를 계층별로 분리했다. 이렇게 분리 한 이유는 다음과 같다&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;SpringBootTest시 별도의 Spring 컨테이너에서 실행되는것을 방지 해 테스트 속도 증가&lt;/li&gt;
&lt;li&gt;테스트 환경 DB를&amp;nbsp; H2가 아닌&amp;nbsp; PostgreSQL Docker로 띄우기 때문에 동일한 컨테이너를 보도록 하기 위함&lt;/li&gt;
&lt;li&gt;중복되는 테스트 환경 설정을 통합 하기 위함 입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10.1 Test Support 분리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 환경은 목적별 Support 클래스로 분리했다.&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;ProjectIntegrationTestSupport
├── SpringBootIntegrationTestSupport
├── DataJpaTestSupport
└── JdbcTestSupport&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;ProjectIntegrationTestSupport&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PostgreSQL/pgvector Testcontainers 공통 설정&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;SpringBootIntegrationTestSupport&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@SpringBootTest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Application 통합 테스트&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DataJpaTestSupport&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@DataJpaTest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;JPA Repository slice 테스트&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;JdbcTestSupport&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;@JdbcTest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;pgvector JDBC Repository slice 테스트&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10.2 Domain 단위 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring 없이 도메인 불변식을 검증했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;MemberTest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RunningWorkoutTest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CyclingWorkoutTest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WorkoutVectorTest&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증 내용 예시:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;nickname 필수&lt;/li&gt;
&lt;li&gt;운동 시작/종료 시간 필수&lt;/li&gt;
&lt;li&gt;종료 시간이 시작 시간보다 빠르면 실패&lt;/li&gt;
&lt;li&gt;운동 시간은 0보다 커야 함&lt;/li&gt;
&lt;li&gt;운동 거리는 음수 불가&lt;/li&gt;
&lt;li&gt;embedding vector 필수&lt;/li&gt;
&lt;li&gt;embedding 방어적 복사&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10.3 전략 패턴 단위 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring 없이 전략 객체를 직접 생성해서 Registry에 주입하는 방식으로 테스트했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시:&lt;/p&gt;
&lt;pre class=&quot;haxe&quot;&gt;&lt;code&gt;WorkoutEmbeddingContentBuilderRegistry registry =
        new WorkoutEmbeddingContentBuilderRegistry(List.of(
                new RunningWorkoutEmbeddingContentBuilder(),
                new CyclingWorkoutEmbeddingContentBuilder()
        ));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 대상:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;WorkoutEmbeddingContentBuilderRegistryTest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WorkoutSaveStrategyRegistryTest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CoachFeedbackPromptBuilderTest&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증 내용:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;러닝이면 러닝 전략 선택&lt;/li&gt;
&lt;li&gt;사이클이면 사이클 전략 선택&lt;/li&gt;
&lt;li&gt;지원하지 않는 타입이면 실패&lt;/li&gt;
&lt;li&gt;프롬프트에 공통/전용 지표 포함&lt;/li&gt;
&lt;li&gt;응답 제한 규칙 포함&lt;/li&gt;
&lt;li&gt;응답 형식 포함&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10.4 Repository Slice 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA Repository는 &lt;code&gt;@DataJpaTest&lt;/code&gt;로 검증했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;MemberRepositoryTest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RunningWorkoutRepositoryTest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CyclingWorkoutRepositoryTest&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pgvector 저장소는 &lt;code&gt;@JdbcTest&lt;/code&gt;로 검증했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;WorkoutVectorRepositoryTest&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10.5 Application 통합 테스트&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;WorkoutVectorSaverTest&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이미 저장된 &lt;code&gt;workoutId&lt;/code&gt;를 받아 vector 저장&lt;/li&gt;
&lt;li&gt;content 생성&lt;/li&gt;
&lt;li&gt;embedding 생성&lt;/li&gt;
&lt;li&gt;vector 저장&lt;/li&gt;
&lt;li&gt;실패 시 예외 전파&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CoachFeedbackTest&lt;/code&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;운동 기록 저장 &amp;rarr; vector 저장 &amp;rarr; AI 호출 순서 검증&lt;/li&gt;
&lt;li&gt;AI chunk 전달&lt;/li&gt;
&lt;li&gt;운동 기록 저장 실패 시 vector 저장과 AI 호출 차단&lt;/li&gt;
&lt;li&gt;vector 저장 실패 시 AI 호출 차단&lt;/li&gt;
&lt;li&gt;AI 호출 실패 시 예외 전파&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10.6 Controller Slice 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@WebMvcTest&lt;/code&gt;로 Controller만 검증했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;CoachFeedbackControllerTest&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증 내용:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;러닝 JSON 요청 처리&lt;/li&gt;
&lt;li&gt;사이클 JSON 요청 처리&lt;/li&gt;
&lt;li&gt;도메인 객체 변환&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CoachFeedback&lt;/code&gt; 호출&lt;/li&gt;
&lt;li&gt;SSE 응답 반환&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;10.7 Architecture 테스트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Hexagonal Architecture의 의존성 방향이 깨지지 않도록 ArchUnit 기반 아키텍처 테스트를 추가했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;ArchitectureTest&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;검증 규칙은 다음과 같다.&lt;/p&gt;
&lt;pre class=&quot;jboss-cli&quot;&gt;&lt;code&gt;@ArchTest
static final ArchRule domain_should_not_depend_on_application_or_adapter =
        noClasses()
                .that().resideInAPackage(DOMAIN)
                .should().dependOnClassesThat().resideInAnyPackage(APPLICATION, ADAPTER);

@ArchTest
static final ArchRule application_should_not_depend_on_adapters =
        noClasses()
                .that().resideInAPackage(APPLICATION)
                .should().dependOnClassesThat().resideInAnyPackage(ADAPTER);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 다음 의존성 역류를 테스트로 막는다.&lt;/p&gt;
&lt;pre class=&quot;stylus&quot;&gt;&lt;code&gt;domain &amp;rarr; application / adapter 의존 금지
application &amp;rarr; adapter 의존 금지
adapter.in &amp;rarr; adapter.out 의존 금지
adapter.out &amp;rarr; adapter.in 의존 금지&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 layered architecture 규칙으로 전체 계층 접근 방향도 검증한다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;Domain
Application
InboundAdapter
OutboundAdapter&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 테스트 덕분에 &lt;code&gt;RunningWorkoutEntity&lt;/code&gt; 같은 persistence 전용 객체가 application 계층으로 침투하는 문제를 조기에 발견할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 테스트 결과:&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;./gradlew test
BUILD SUCCESSFUL&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 할루시네이션 대응 전략&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 구현에서는 자유 질의형 챗봇보다 &lt;b&gt;템플릿 기반 AI 기능&lt;/b&gt;을 우선한다. 이 방식 자체가 할루시네이션을 줄이는 전략이 될 수 있다고 생각 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;11.1 Template Routing&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 아무 질문이나 입력하는 구조가 아니라, 서비스가 제공하는 기능을 선택하도록 제한한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 기능:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;단일 운동 피드백&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가 예정 기능:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1주일 운동 기반 피드백&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 LLM이 답변해야 하는 범위가 좁아진다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;자유 질문
&amp;rarr; 모든 주제 가능
&amp;rarr; 도메인 밖 답변 가능성 증가

템플릿 기반 피드백
&amp;rarr; 운동 데이터 기반 답변으로 제한
&amp;rarr; 불필요한 질문 차단&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;11.2 Structured Prompt&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운동 데이터를 자유 텍스트로 전달하지 않고, 고정된 구조로 전달한다.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;[기본 운동 통계 정보]
- 운동 시작 시간
- 운동 종료 시간
- 운동 거리
- 운동 시간
- 평균 심박수 / 최대 심박수
- 평균 케이던스 / 최대 케이던스

[러닝 전용 분석 지표]
- 평균 페이스
- 최고 페이스
- 총 걸음 수&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조는 모델이 어떤 지표를 기준으로 피드백해야 하는지 명확히 알려준다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;11.3 Context Grounding Rule&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프롬프트에는 다음과 같은 규칙을 추가할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;제공된 운동 데이터에 근거해서만 피드백하세요.
제공된 데이터로 판단할 수 없는 내용은 추정하지 말고, 확인이 필요하다고 답하세요.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프롬프트는 &lt;code&gt;SingleWorkoutHallucinationGuardSection&lt;/code&gt;을 통해 이 규칙을 별도 section으로 포함한다.&lt;/p&gt;
&lt;pre class=&quot;prolog&quot;&gt;&lt;code&gt;[응답 제한 규칙]
- 반드시 제공된 운동 데이터에 근거해서만 피드백하세요.
- 제공되지 않은 신체 정보, 질병 정보, 과거 운동 이력은 추측하지 마세요.
- 판단할 수 없는 내용은 &quot;제공된 데이터만으로는 판단하기 어렵습니다&quot;라고 답하세요.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 RAG 적용 시에는 검색된 지식 context에 대해서도 같은 grounding rule을 적용할 예정이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;11.4 Query Scope Guard&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;향후 자유 질문 기능을 열 경우, 먼저 질문이 운동 기록 피드백 범위 안에 있는지 검사할 수 있다.&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;사용자 질문
&amp;rarr; 운동 기록/피드백 관련 질문인지 판별
&amp;rarr; 관련 없음: 거절 응답
&amp;rarr; 관련 있음: RAG/LLM 진행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시 응답:&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;이 서비스는 운동 기록 기반 피드백만 제공할 수 있습니다.
운동 기록, 페이스, 심박수, 주간 운동 추세와 관련된 질문을 선택해 주세요.&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;11.5 No Context, No Answer&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RAG 적용 시 가장 중요한 전략이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VectorDB에서 관련 지식이나 운동 기록을 찾지 못한 경우, LLM에게 억지로 답변을 생성하게 하지 않는다.&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;사용자 질문
&amp;rarr; embedding 생성
&amp;rarr; VectorDB 검색
&amp;rarr; similarity threshold 확인
&amp;rarr; 관련 context 없음
&amp;rarr; LLM 호출 차단 또는 근거 부족 응답&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시 응답:&lt;/p&gt;
&lt;pre class=&quot;erlang&quot;&gt;&lt;code&gt;관련 운동 기록이나 지식 컨텍스트를 찾지 못해 정확한 답변을 제공하기 어렵습니다.
운동 기록을 먼저 입력하거나, 지원하는 피드백 템플릿을 선택해 주세요.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 전략은 근거 없는 답변 생성을 줄이는 데 효과적이다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. 다음 구현 계획&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;12.1 1주일 운동 기반 피드백&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 단계는 단일 운동 피드백과 별도로 1주일 기반 피드백 API를 추가하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상 흐름:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;사용자 식별
&amp;rarr; 최근 1주일 러닝/사이클링 기록 조회
&amp;rarr; 운동 기록 목록을 주간 요약 context로 변환
&amp;rarr; 주간 피드백 프롬프트 생성
&amp;rarr; LLM 스트리밍 호출&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예상 엔드포인트:&lt;/p&gt;
&lt;pre class=&quot;awk&quot;&gt;&lt;code&gt;POST /api/v1/coach/feedback/weekly/stream&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;12.2 RAG 기반 지식 검색 확장&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후에는 현재 저장 중인 운동 벡터와 별도로, 운동 지식 문서를 VectorDB에 저장하고 검색하는 기능을 추가할 계획이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 목표 흐름:&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;사용자 운동 기록 context
+
VectorDB에서 검색한 운동 지식 context
+
질문 템플릿
&amp;rarr; 최종 프롬프트 생성
&amp;rarr; LLM 호출&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 &lt;code&gt;No Context, No Answer&lt;/code&gt;, &lt;code&gt;Similarity Threshold&lt;/code&gt;, &lt;code&gt;Scope Guard&lt;/code&gt; 전략을 함께 적용할 예정이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;12.3 인증 사용자 연동&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 수동 테스트를 위해 &lt;code&gt;member_id = 1&lt;/code&gt; 사용자를 seed하고 컨트롤러에서 임시로 사용한다.&lt;/p&gt;
&lt;pre class=&quot;ebnf&quot;&gt;&lt;code&gt;Member member = Member.of(1L, &quot;temporary-user&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추후 Spring Security 적용 후에는 인증된 사용자 정보를 기반으로 &lt;code&gt;Member&lt;/code&gt;를 조회하거나 주입할 예정이다.&lt;/p&gt;</description>
      <category>프로젝트/운동 AI Chat Bot</category>
      <author>taetae-o</author>
      <guid isPermaLink="true">https://taetae-o.tistory.com/23</guid>
      <comments>https://taetae-o.tistory.com/23#entry23comment</comments>
      <pubDate>Sat, 18 Jul 2026 15:46:24 +0900</pubDate>
    </item>
    <item>
      <title>지갑 잔액 차감 동시성 제어 및 락(Lock) 실험</title>
      <link>https://taetae-o.tistory.com/20</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;실험 조건 및 가설&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;테스트 대상 도메인&lt;/b&gt;: 토스 실시간 간편 결제 플랫폼 시나리오 (지갑 잔액 차감 및 결제 이력 적재)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실험 엔티티 구조&lt;/b&gt;: 하나의 트랜잭션 내에서 &lt;code&gt;Wallet&lt;/code&gt; &lt;code&gt;WalletOptimistic&lt;/code&gt; 잔액 차감(&lt;code&gt;UPDATE&lt;/code&gt;) 후, &lt;code&gt;PaymentHistory&lt;/code&gt; 내역 생성(&lt;code&gt;INSERT&lt;/code&gt;) 수행&lt;/li&gt;
&lt;li&gt;&lt;b&gt;최초 지갑 잔액&lt;/b&gt;: 10,000원&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동시 요청 수&lt;/b&gt;: 100개 (32개 고정 스레드 풀 &lt;code&gt;FixedThreadPool&lt;/code&gt; 활용)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;회당 차감 금액&lt;/b&gt;: 100원 (총 10,000원 차감 시도)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본 실험에서 검증하고자 하는 '데이터 정합성(Data Integrity)'은 단순히 &quot;모든 요청이 에러 없이 성공하여 잔액이 0원이 되었는가&quot;가 아닙니다.&lt;br /&gt;대규모 동시성 트래픽 속에서 락으로 인해 실패할지언정, &lt;b&gt;사용자가 확인한 성공 결과(결제 내역)와 실제 자산 금고(지갑 잔액)의 싱크가 단 1원의 오차도 없이 원자적으로 일치하는가&lt;/b&gt;를 본질적인 합격 기준으로 정의합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;데이터 정합성 검증 공식&lt;/b&gt;:&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;{실제 차감된 총 금액 (초기 잔액 - 최종 잔액)} = {적재된 결제 내역 수(historyCount)} * 100 {원}&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 1: No Lock (아무런 제어 장치가 없는 동시성 요청)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;락이 없는 경우 동시성 환경에서의 주문 내역과, 실제 차감된 돈의 정합성이 맞지 않는 문제가 발생한다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;No Lock 정합성 테스트 결과
생성된 총 주문 내역 수: 100건
최종 남은 DB 실제 잔액: 8900원
지갑에서 실제로 차감된 총 금액: 1100원&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;애플리케이션 계층에서는 100건의 주문이 모두 정상 처리(200 OK)되어 결제 내역이 생성되었으나, 실제 DB 자산은 고작 1,100원(11회 분량)만 차감되었습니다. 결과적으로 8,900원의 금액 오차(자산 손실)를 입으며 시스템의 무결성을 지키지 못함&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;원인 분석&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;레이스 컨디션 진입&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Thread A와 Thread B가 거의 동일한 밀리초(ms) 단위로 DB에서 동일 유저의 지갑 잔액(10,000원)을 읽어옵니다. 이 시점에 두 스레드가 자바 메모리상에 보유한 데이터는 둘 다 10,000원으로 동일합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;덮어쓰기 발생&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;두 스레드는 각자 독립적으로 10,000원 - 100원 = 9,900원이라는 차감 연산을 수행합니다. 이후 Thread A가 먼저 DB에 9,900원을 반영하고 커밋하지만, 곧바로 뒤이어 도착한 Thread B가 A가 업데이트한 사실을 모른 채 자신이 계산한 9,900원을 DB에 그대로 덮어써 버립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;결론&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비즈니스적으로는 분명히 2번의 차감 요청이 수행되어 잔액이 9,800원이 되어야 하지만, 앞선 트랜잭션의 연산 결과가 유실(Lost Update)되면서 돈이 100원만 깎이는 현상이 100번의 요청 동안 무한히 반복된 것입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 2 : 낙관적 락&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;낙관적 락을 사용하는 경우 동시성 환경에서의 주문 내역과 실제 차감된 돈의 정합성이 맞는다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;낙관적 락 정합성 테스트 결과
생성된 총 주문 내역 수: 15건
최종 남은 DB 실제 잔액: 8500원
지갑에서 실제로 차감된 총 금액: 1500원
낙관적 락 실패 횟수: 85번&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;100개의 동시 요청 중 85건의 요청은 버전 충돌로 인해 실패 처리되었습니다. 최종적으로 오직 15건의 주문만 정상 완료되었으며, 지갑 잔액 또한 정확히 1,500원만 차감되었습니다.&lt;br /&gt;[성공한 주문 수(15건) &amp;times; 100원 = 실제 차감액(1,500원)] 공식이 완벽히 성립하여 자산 정합성이 완벽하게 보장됨을 증명합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;원인 분석&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;버전 관리를 통한 동시성 제어&lt;/b&gt;: &lt;code&gt;WalletOptimistic&lt;/code&gt; 엔티티는 &lt;code&gt;@Version&lt;/code&gt; 필드를 통해 낙관적 락을 수행합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;레이스 컨디션에서의 충돌&lt;/b&gt;: 동시성 환경에서 여러 스레드가 거의 동시에 DB를 조회(&lt;code&gt;SELECT&lt;/code&gt;)하면, 스레드들이 읽어온 객체의 &lt;code&gt;version&lt;/code&gt; 초기값은 모두 동일(예: &lt;code&gt;version = 0&lt;/code&gt;)할 수 있게 됩니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;조건절 불일치로 인한 예외 발생&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;각 스레드가 연산을 마치고 &lt;code&gt;UPDATE&lt;/code&gt; 쿼리를 날릴 때, JPA는 내부적으로 &lt;code&gt;WHERE id = ? AND version = ?&lt;/code&gt; 조건과 함께 버전을 1 증가시키는 쿼리를 수행합니다.&lt;/li&gt;
&lt;li&gt;가장 먼저 도착한 1등 스레드가 커밋을 성공시키는 순간, DB 내부의 &lt;code&gt;version&lt;/code&gt; 컬럼은 &lt;code&gt;1&lt;/code&gt;로 업데이트됩니다.&lt;/li&gt;
&lt;li&gt;뒤이어 도착한 나머지 99개의 스레드들은 여전히 &lt;code&gt;WHERE version = 0&lt;/code&gt; 조건으로 수정을 시도하기 때문에, DB 엔진 내에서 조건이 일치하는 행을 찾지 못해 수정된 행의 수(Affected Rows)가 0건이 됩니다.&lt;/li&gt;
&lt;li&gt;JPA는 이를 데이터 충돌로 인지하여 &lt;code&gt;ObjectOptimisticLockingFailureException&lt;/code&gt; 예외를 던지고 트랜잭션을 롤백시킵니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;트랜잭션 원자성 사수&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;예외가 터지는 순간 스프링의 @Transactional 프록시 레이어는 해당 스레드의 트랜잭션을 통째로 롤백(Rollback) 시킵니다. 이 덕분에 지갑 잔액이 안 깎인 스레드는 아랫줄의 PaymentHistory 인서트 쿼리까지 함께 취소(유실)되어 주문 내역과 잔액의 싱크가 깨지지 않고 안전하게 보존되는 것입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 3 : 비관적 락&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비관적 락을 사용하는 경우 동시성 요청이 밀려올 때 애플리케이션이 아닌 DB 레벨에서 트래픽을 대기 시키기 때문에,&lt;br /&gt;단 한 건의 실패(예외)도 없이 100건의 요청을 모두 성공시킵니다.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;비관적 락 정합성 테스트 결과
생성된 총 주문 내역 수: 100건
최종 남은 DB 실제 잔액: 0원
지갑에서 실제로 차감된 총 금액: 10000원&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;낙관적 락과 달리 실패한 결제가 없습니다. 100개의 동시성 요청이 대기(Blocking)를 거쳐 모두 순차적으로 반영되었으며,&lt;br /&gt;그 결과 지갑 잔액은 정확히 0원이 되었고 주문 히스토리도 유실 없이 100건이 적재되었습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;원인 분석&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;FOR UPDATE를 통한 로우 레벨 락(Row-level Lock) 선점&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;WalletPessimisticRepository에서 호출한 findByUserIdWithLock 메서드는 MySQL 엔진에 SELECT ... FOR UPDATE 쿼리를 날립니다.&lt;br /&gt;이 쿼리는 조회하는 동시에 해당 유저의 지갑 레코드(Row)에 다른 트랜잭션이 접근하지 못하도록 배타적 쓰기 락(X-Lock)을 걸어버립니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스레드 블로킹(Blocking) 메커니즘&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;가장 먼저 도착한 1등 스레드가 락을 획득하고 트랜잭션을 시작하는 순간, 동시에 들이닥친 나머지 99개의 스레드는 예외가 발생하는 것이 아니라 1등 스레드가 연산을 마치고 커밋하여 락을 풀어줄 때까지 DB 문턱에서 대기(Blocking) 상태로 줄을 서게 됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;갱신 손실(Lost Update)의 원천 차단&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;2등 스레드는 1등 스레드가 10,000원 - 100원 = 9,900원으로 잔액을 완전히 업데이트하고 커밋한 이후의 상태를 바라보며 락을 이어받습니다.&lt;br /&gt;즉, 모든 스레드가 앞선 트랜잭션의 결과가 완전히 반영된 최신 잔액을 조회하여 차감 연산을 수행하므로, No Lock에서 발생했던 덮어쓰기(Lost Update) 현상이 원천적으로 불가능해집니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;안정적인 비즈니스 흐름 보장&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;트랜잭션이 실패하지 않고 순차적으로 통과하기 때문에, 지갑 차감 아랫줄에 있는 PaymentHistory 인서트문 또한 롤백 없이 100번 모두 안전하게 커밋됩니다.&lt;br /&gt;결과적으로 결제 내역과 지갑 자산이 완벽하게 1:1 매칭되는 무결한 데이터 상태가 완성됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;알게된 점 (Lessons Learned)&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;nbsp;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 문제는 비관적 락 테스트를 진행할 때 마주진 예외이다, 이 예외는 &lt;code&gt;@Lock(LockModeType.PESSIMISTIC_WRITE)&lt;/code&gt; 로 &lt;code&gt;Repository&lt;/code&gt; 메서드에 락을 걸게 되는데 &lt;br /&gt;이렇게 락이 걸린 메서드가 트렌젝션이 실행되지 않는 곳에서 호출했을 경우 나타나가 된다&lt;/li&gt;
&lt;li&gt;&lt;code&gt;@Lock(LockModeType.PESSIMISTIC_WRITE)&lt;/code&gt; 어노테이션이 부여된 리포지토리 메서드는 호출 시 MySQL 엔진에 &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; 구문을 전송하여 쓰기 락(X-Lock)을 획득하게 된다. &lt;br /&gt;데이터베이스 메커니즘상 락의 수명 주기는 트랜잭션의 수명 주기와 같기 때문에(트랜잭션이 종료되어야 락이 해제됨). 따라서 락을 걸고 유지하려면 반드시 활성화된 트랜잭션&lt;code&gt;@Transactional&lt;/code&gt; 범위 내부여야 한다. &lt;br /&gt;하지만 멀티스레드 비동기 연산이 끝난 후, 테스트 코드의 검증 레이어(메인 스레드)는 트랜잭션이 없는 맨바닥 상태였기 때문에 JPA 레이어에서 이를 부적절한 사용으로 판단하고 예외를 발생시킨 것이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li class=&quot;applescript&quot;&gt;&lt;code&gt;  org.springframework.dao.InvalidDataAccessApiUsageException : Query requires transaction be in progress, but no transaction is known to be in progress&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;어노테이션을 사용한 낙관적 락, 비관적 락 적용 방법&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;낙관적 락은 엔티티(Entity) 클래스의 특정 필드 위&lt;/li&gt;
&lt;li&gt;비관적 락은 리포지토리(Repository) 인터페이스의 메서드 위에 사용한다&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 코드의 &lt;code&gt;// when&lt;/code&gt; 부분에서 &lt;code&gt;List&amp;lt;CompletableFuture&amp;lt;Void&amp;gt;&amp;gt;&lt;/code&gt; 생성 부분&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테스트 코드를 작성하면서 이 &lt;code&gt;List&amp;lt;CompletableFuture&amp;lt;Void&amp;gt;&amp;gt;&lt;/code&gt; 부분을 보면 &lt;code&gt;IntStream&lt;/code&gt; 을 통해 100개의 &lt;code&gt;CompletableFuture&lt;/code&gt;를 생성해 실행 시키는것을 알 수 있다. &lt;br /&gt;왜 이렇게 생성하는지 보니 동시성 환경에서 최신 자바 동시성 테스트 코드에서는 이 형식을 표준처럼 아주 많이 사용한다고 한다.&lt;/li&gt;
&lt;li&gt;일반적인 &lt;code&gt;for&lt;/code&gt; 반복문으로 &lt;code&gt;CompletableFuture&lt;/code&gt;를 생성하고 반복문 외부에 선언된 &lt;code&gt;List&amp;lt;CompletableFuture&amp;lt;Void&amp;gt;&amp;gt;&lt;/code&gt; 에 &lt;code&gt;add&lt;/code&gt; 하는 코드와 동일한 동작을 하지만 &lt;br /&gt;선언된 리스트가 밖에 있다보니 원자성과 불변성에서 코드상의 문제가 발생할 여지를 최소화 한다고 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;표 정리&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;table data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;비교 항목&lt;/th&gt;
&lt;th&gt;낙관적 락 (@Version)&lt;/th&gt;
&lt;th&gt;비관적 락 (@Lock)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;기본 전제&lt;/td&gt;
&lt;td&gt;충돌이 거의 없을 것이다&lt;/td&gt;
&lt;td&gt;충돌이 무조건 있을 것이다.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;락의 주체&lt;/td&gt;
&lt;td&gt;애플리케이션 (JPA/Hibernate)&lt;/td&gt;
&lt;td&gt;데이터베이스 엔진 (MySQL FOR UPDATE)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;트래픽 밀릴 때&lt;/td&gt;
&lt;td&gt;롤백 및 재시도 비용 급증 (성능 저하)&lt;/td&gt;
&lt;td&gt;스레드 대기(Blocking)로 커넥션 풀 고갈 위험&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;데이터 무결성&lt;/td&gt;
&lt;td&gt;충돌 시 에러 발생 (버전 불일치 예외)&lt;/td&gt;
&lt;td&gt;원천 차단 (완벽한 무결성 보장)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;낙관적 락은 가벼운 데이터, 낮은 충돌 확률일때 사용 비관적 락은 무거운 자산 데이터, 높은 충돌 확률일때 사용&lt;/b&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 배달의민족, 쿠팡, 토스처럼 트래픽이 분당 수백만 건씩 몰리는 트래픽 환경이 되면 그때는 비관적 락도 쓰지 못합니다. &lt;br /&gt;&lt;br /&gt;DB 커넥션 풀이 다 말라붙어서 서버가 터지게 되기 때문이다 그래서 실무에서는 DB를 잠그는 대신 Redis를 활용한 분산 락(Distributed Lock)을 쓰거나, Kafka 같은 메시지 큐(Message Queue)를 앞에 두어 트래픽 자체를 순차적으로 통과시키는 방식으로 고도화합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/lock/scenario01&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/lock/scenario01&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1783626156072&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;db-playground/src/main/java/com/taehyun/db_playground/mysql/lock/scenario01 at main &amp;middot; oTAETAEo/db-playground&quot; data-og-description=&quot;DB 사용시 발생할 수 있는 시나리오를 정의하고 학습하는 공간 입니다. Contribute to oTAETAEo/db-playground development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/lock/scenario01&quot; data-og-url=&quot;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/lock/scenario01&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/om7Nt/dJMb9dHzXnj/CkYriNx4cwI7Z6IpMqEKuK/img.png?width=1200&amp;amp;height=600&amp;amp;face=967_153_1072_267,https://scrap.kakaocdn.net/dn/g00u7/dJMb9jgI7de/ST7y0x4UY2M5rkG49LVzu1/img.png?width=1200&amp;amp;height=600&amp;amp;face=967_153_1072_267&quot;&gt;&lt;a href=&quot;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/lock/scenario01&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/lock/scenario01&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/om7Nt/dJMb9dHzXnj/CkYriNx4cwI7Z6IpMqEKuK/img.png?width=1200&amp;amp;height=600&amp;amp;face=967_153_1072_267,https://scrap.kakaocdn.net/dn/g00u7/dJMb9jgI7de/ST7y0x4UY2M5rkG49LVzu1/img.png?width=1200&amp;amp;height=600&amp;amp;face=967_153_1072_267');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;db-playground/src/main/java/com/taehyun/db_playground/mysql/lock/scenario01 at main &amp;middot; oTAETAEo/db-playground&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;DB 사용시 발생할 수 있는 시나리오를 정의하고 학습하는 공간 입니다. Contribute to oTAETAEo/db-playground development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>DB</category>
      <author>taetae-o</author>
      <guid isPermaLink="true">https://taetae-o.tistory.com/20</guid>
      <comments>https://taetae-o.tistory.com/20#entry20comment</comments>
      <pubDate>Fri, 10 Jul 2026 04:22:47 +0900</pubDate>
    </item>
    <item>
      <title>MySQL 복합 인덱스로 빠른 조회 및 Using filesort 정렬 부하 해결하기</title>
      <link>https://taetae-o.tistory.com/19</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 실험 개요&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;목적&lt;/b&gt;: 결제 히스토리 테이블에서 특정 조건 조회 시, 인덱스 유무 및 인덱스 구성 방식에 따른 성능 변화를 측정하고 최적의 인덱스 전략을 확인합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;대상 테이블&lt;/b&gt;: &lt;code&gt;payments_v1&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;데이터 스케일&lt;/b&gt;: 총 &lt;b&gt;1,000,000 건&lt;/b&gt; (100만 건)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;실험 환경&lt;/b&gt;: Docker MySQL 8.x / Spring Boot 통합 테스트 환경&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 테스트 시나리오&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;비즈니스 요구사항&lt;/b&gt;&quot;특정 유저가 지정한 특정 기간 동안 결제에 &lt;b&gt;성공(SUCCESS)&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 한 내역 중, &lt;/span&gt;&lt;b&gt;카드(CARD)&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 결제 건만 &lt;/span&gt;&lt;b&gt;최신순(created_at DESC)&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 으로 화면에 노출합니다.&quot;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 성능 측정 결과 요약 (Benchmark)&lt;/h2&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 15px 0; font-size: 14px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;background-color: #f2f2f2; border-bottom: 2px solid #dee2e6;&quot;&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: left;&quot;&gt;실험 단계&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center;&quot;&gt;소요 시간 (ms)&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center;&quot;&gt;소요 시간 (초)&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center;&quot;&gt;성능 개선율&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: left;&quot;&gt;비고&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px;&quot;&gt;&lt;b&gt;Case 1: No Index&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center;&quot;&gt;294 ms&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center;&quot;&gt;0.294 초&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center; color: #6c757d;&quot;&gt;기준점 (100%)&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px;&quot;&gt;Full Table Scan + Filesort 부하 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;background-color: #f8f9fa;&quot;&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px;&quot;&gt;&lt;b&gt;Case 2: 단일 인덱스 (&lt;code&gt;user_id&lt;/code&gt;)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center;&quot;&gt;84 ms&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center;&quot;&gt;0.084 초&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center; color: #28a745; font-weight: bold;&quot;&gt;약 71.4% 개선&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px;&quot;&gt;Index Lookup 진입, &lt;code&gt;Using filesort&lt;/code&gt; 잔존&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; background-color: #e8f5e9;&quot;&gt;&lt;b&gt;Case 3: 복합 인덱스 (최적화)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center; font-weight: bold; background-color: #e8f5e9;&quot;&gt;79 ms&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center; background-color: #e8f5e9;&quot;&gt;0.079 초&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; text-align: center; color: #28a745; font-weight: bold; background-color: #e8f5e9;&quot;&gt;약 73.1% 개선&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 10px; background-color: #e8f5e9;&quot;&gt;&lt;code&gt;type: range&lt;/code&gt;, &lt;b&gt;정렬 연산 완전 제거&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 단계별 실행 계획(EXPLAIN) 및 상세 분석&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 1: 인덱스가 없는 상태 (No Index)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;측정 결과&lt;/b&gt;: &lt;code&gt;294 ms&lt;/code&gt; (조회 건수: &lt;code&gt;4 건&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MySQL 실행 계획 (&lt;code&gt;EXPLAIN&lt;/code&gt;)&lt;/b&gt;:&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 15px 0; font-size: 13px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;background-color: #f2f2f2; border-bottom: 2px solid #dee2e6;&quot;&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;id&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;select_type&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;table&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;type&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;rows&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;filtered&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: left;&quot;&gt;Extra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;SIMPLE&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;p1_0&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center; color: #dc3545; font-weight: bold;&quot;&gt;ALL&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;996,546&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;0.04&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px;&quot;&gt;Using where; &lt;b&gt;Using filesort&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;핵심 지표 분석&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;type: ALL&lt;/code&gt;: 100만 건 대용량 테이블을 처음부터 끝까지 전부 읽는 Full Table Scan이 발생하여 대량의 디스크 I/O 유발.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rows: 996,546&lt;/code&gt;: 단 4건의 실데이터를 찾기 위해 내부 통계 공식상 약 100만 개에 달하는 행을 검사하는 비효율 발생.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Extra: Using filesort&lt;/code&gt;: 인덱스를 통한 정렬 최적화가 불가능하여, 쿼리 실행 후 메모리(Sort Buffer)에서 무거운 파일 정렬 연산을 수행함.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 2: 단일 인덱스 적용 후 (&lt;code&gt;user_id&lt;/code&gt;)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;적용 인덱스&lt;/b&gt;: &lt;code&gt;CREATE INDEX idx_user_id ON payments_v1(user_id);&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;측정 결과&lt;/b&gt;: &lt;code&gt;84 ms&lt;/code&gt; (조회 건수: &lt;code&gt;4 건&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MySQL 실행 계획 (&lt;code&gt;EXPLAIN&lt;/code&gt;)&lt;/b&gt;:&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 15px 0; font-size: 13px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;background-color: #f2f2f2; border-bottom: 2px solid #dee2e6;&quot;&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;id&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;select_type&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;table&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;type&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;key&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;rows&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;filtered&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: left;&quot;&gt;Extra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;SIMPLE&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;p1_0&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center; color: #007bff; font-weight: bold;&quot;&gt;ref&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;idx_user_id&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center; font-weight: bold;&quot;&gt;15&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;0.37&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px;&quot;&gt;Using where; &lt;b&gt;Using filesort&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;핵심 지표 분석&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;type: ref&lt;/code&gt;: Full Table Scan에서 B-Tree 인덱스를 통한 고속 참조 조회(Index Lookup)로 변경.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rows: 15&lt;/code&gt;: 탐색해야 할 행의 수가 100만 건에서 단 15건으로 대폭 감소하여 CPU 및 데이터 스캔 부하가 감소.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Extra: Using filesort&lt;/code&gt;: &lt;code&gt;user_id&lt;/code&gt; 조건은 인덱스를 탔으나, &lt;code&gt;created_at DESC&lt;/code&gt; 정렬 조건을 인덱스가 지원하지 못해 메모리 정렬 연산이 여전히 존재.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Case 3: 복합 인덱스 적용 후 (최적화 상태)&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;적용 인덱스&lt;/b&gt;: &lt;code&gt;CREATE INDEX idx_user_payment_created ON payments_v1(user_id, payment_type, payment_status, created_at);&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;측정 결과&lt;/b&gt;: &lt;b&gt;79 ms&lt;/b&gt; (조회 건수: &lt;code&gt;4 건&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;MySQL 실행 계획 (&lt;code&gt;EXPLAIN&lt;/code&gt;)&lt;/b&gt;:&lt;/li&gt;
&lt;/ul&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; margin: 15px 0; font-size: 13px;&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;thead&gt;
&lt;tr style=&quot;background-color: #f2f2f2; border-bottom: 2px solid #dee2e6;&quot;&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;id&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;select_type&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;table&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;type&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;key&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;rows&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;filtered&lt;/th&gt;
&lt;th style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: left;&quot;&gt;Extra&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr style=&quot;background-color: #e8f5e9;&quot;&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;1&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;SIMPLE&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;p1_0&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center; color: #28a745; font-weight: bold;&quot;&gt;range&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;idx_user_payment_created&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center; font-weight: bold;&quot;&gt;4&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px; text-align: center;&quot;&gt;100.00&lt;/td&gt;
&lt;td style=&quot;border: 1px solid #dee2e6; padding: 8px;&quot;&gt;Using index condition; &lt;b&gt;Backward index scan&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;핵심 지표 분석&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;code&gt;type: range&lt;/code&gt;: 복합 인덱스 구성 후, &lt;code&gt;BETWEEN&lt;/code&gt; 조건으로 지정된 특정 3달간의 데이터 범위만 타깃하여 스캔했음을 의미.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rows: 4 / filtered: 100.00&lt;/code&gt;: 최종 결과인 4건을 추출하기 위해 정확히 4건의 행만 검사함. 쓸데없이 읽고 버려진 데이터가 없는 탐색 효율성 100% 상태 달성.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Extra: Backward index scan&lt;/code&gt;: &lt;code&gt;Using filesort&lt;/code&gt;가 완벽히 제거됨. 인덱스 마지막 열에 &lt;code&gt;created_at&lt;/code&gt;을 배치하여 이미 정렬된 인덱스 블록을 역방향으로 읽어 내림으로써 &lt;b&gt;정렬 연산 비용을 0&lt;/b&gt;으로 구성.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Extra: Using index condition&lt;/code&gt;: ICP(Index Condition Pushdown) 메커니즘이 작동하여 디스크에서 테이블 레코드를 읽기 전 스토리지 엔진 레벨에서 조건을 선 필터링, 불필요한 디스크 I/O를 원천 차단함.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;/h2&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;알게된 점 (Lessons Learned)&lt;/h2&gt;
&lt;ol style=&quot;line-height: 1.8;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;복합 인덱스 설계의 대원칙 규칙&lt;/b&gt; &lt;br /&gt;복합 인덱스 구성 시 딱 떨어지는 동등 조건(&lt;code&gt;=&lt;/code&gt;) 컬럼들을 전방에 배치하고, 범위 조건&lt;br /&gt;(&lt;code&gt;BETWEEN&lt;/code&gt;, &lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;)이나 정렬 조건(&lt;code&gt;ORDER BY&lt;/code&gt;) 컬럼을 후방에 배치해야 인덱스의 전 구간 스캔 효율이 극대화된다는 사실을 실측 데이터로 확인했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 가독성 및 유지보수성 향상&lt;/b&gt; &lt;br /&gt;테스트 코드 작성 시 하드코딩된 값 대신 고정 시점 필터를 매개변수(Parameter)로 추출하여 주입함으로, 가독성이 높고 시점이 고정되어 시간이 지나도 깨지지 않는 안정성 있는 테스트 코드를 작성하는 방법을 알게 되었습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;AssertJ 고급 검증 API의 활용&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre id=&quot;code_1783587799706&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public SELF isSortedAccordingTo(Comparator&amp;lt;? super ELEMENT&amp;gt; comparator)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Comparator를 통해 리스트의 요소들이 원하는 정렬 기준(예: 최신순 DESC)에 맞춰 정상적으로 정렬되어 넘어왔는지 최종 검증할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1783587777016&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public SELF allSatisfy(Consumer&amp;lt;? super ELEMENT&amp;gt; requirements)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리스트의 모든 요소가 람다식 내의 검증 조건을 통과하는지 반복문 없이 가독성 높게 전수 검증할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/index/scenario01_payment_history&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/index/scenario01_payment_history&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1783588359170&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;db-playground/src/main/java/com/taehyun/db_playground/mysql/index/scenario01_payment_history at main &amp;middot; oTAETAEo/db-playground&quot; data-og-description=&quot;DB 사용시 발생할 수 있는 시나리오를 정의하고 학습하는 공간 입니다. Contribute to oTAETAEo/db-playground development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/index/scenario01_payment_history&quot; data-og-url=&quot;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/index/scenario01_payment_history&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cjQCEO/dJMb8WeLBJW/JxjJCAtVk5kGQRlk9aXTUk/img.png?width=1200&amp;amp;height=600&amp;amp;face=967_153_1072_267,https://scrap.kakaocdn.net/dn/cGtBJb/dJMb86PdxRt/oNQ1YCut2ONrWo9JAjOKYk/img.png?width=1200&amp;amp;height=600&amp;amp;face=967_153_1072_267&quot;&gt;&lt;a href=&quot;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/index/scenario01_payment_history&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/oTAETAEo/db-playground/tree/main/src/main/java/com/taehyun/db_playground/mysql/index/scenario01_payment_history&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cjQCEO/dJMb8WeLBJW/JxjJCAtVk5kGQRlk9aXTUk/img.png?width=1200&amp;amp;height=600&amp;amp;face=967_153_1072_267,https://scrap.kakaocdn.net/dn/cGtBJb/dJMb86PdxRt/oNQ1YCut2ONrWo9JAjOKYk/img.png?width=1200&amp;amp;height=600&amp;amp;face=967_153_1072_267');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;db-playground/src/main/java/com/taehyun/db_playground/mysql/index/scenario01_payment_history at main &amp;middot; oTAETAEo/db-playground&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;DB 사용시 발생할 수 있는 시나리오를 정의하고 학습하는 공간 입니다. Contribute to oTAETAEo/db-playground development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>DB</category>
      <author>taetae-o</author>
      <guid isPermaLink="true">https://taetae-o.tistory.com/19</guid>
      <comments>https://taetae-o.tistory.com/19#entry19comment</comments>
      <pubDate>Thu, 9 Jul 2026 18:02:33 +0900</pubDate>
    </item>
    <item>
      <title>Webhook 수신 운동 데이터 처리시 메모리 폭주 트러블 슈팅(JMC 추적)</title>
      <link>https://taetae-o.tistory.com/18</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;간단 요약&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;대용량 Terra Activity 웹훅 payload를 처리할 때 발생하던 메모리 스파이크를 줄이기 위해 웹훅 수신부와 운동 데이터 처리 워커를 스트리밍 방식으로 변경했다.&lt;br /&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #222222; text-align: start;&quot;&gt;&lt;b&gt;테스트&lt;/b&gt; : 10명이 동시에 나눠서 총 100번 8.9M 크기의 운동데이터를 가진 json 데이터 전송&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동일한 k6 비교 테스트 기준으로 `byte[]` 총 할당량은 18.6 GiB &amp;rarr; 1.42 GiB로 줄었고, 약 92.4% 감소했다. Heap peak 역시 약 3 GiB &amp;rarr; 384 MiB 수준으로 낮아졌다.&lt;br /&gt;&lt;br /&gt;즉, 기존처럼 request body와 압축 해제 데이터를 JVM Heap에 통째로 올리던 구조에서 벗어나, 데이터를 스트림으로 흘려보내며 처리하도록 변경해 메모리 사용량을 크게 줄였다.&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 147px;&quot; border=&quot;1&quot; data-ke-style=&quot;style12&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;성능 측정 항목&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;변경 전 (A안: 메모리 적재)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;변경 후 (B안: 임시 파일 스풀링)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;개선 효과 및 결과&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;byte[] 총 할당량 (Alloc Total)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;18.6 GiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;1.42 GiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;약 92.4% 감소&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;byte[] 할당 지분율&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;81.50%&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;23.30%&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;58.2%p 감소&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;char[] 총 할당량 (Alloc Total)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;217 MiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;191 MiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;약 12% 감소&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;PropertyValueBuffer 총 할당량&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;102 MiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;55.6 MiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;약 45.5% 감소&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;힙 메모리 정점 (Heap Peak)&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;약 3.0 GiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;약 384 MiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;서버 다운 위험 제거 (대폭 감소)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 35px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;최상위 부하 지점 (Hot&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Spot)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Arrays.copyOfByteArrayOutputStream&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;java.lang.DoubleActivityGraphSampleExtractor&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;메모리 낭비 원천 차단&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;(순&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;수 비즈니스 연산으로 이동)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제 사항 분석 (JMC)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;K6 사용해 메모리 개선 효과를 확인하기 위한 테스트 10명이 동시에 나눠서 총 100번 8.9M 크기의 운동데이터를 가진 json 데이터 전송&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-07 오후 6.21.37.png&quot; data-origin-width=&quot;2622&quot; data-origin-height=&quot;1820&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TNr3W/dJMcab5UtfS/nbKlOJ31QPHH3ZyhCr5kf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TNr3W/dJMcab5UtfS/nbKlOJ31QPHH3ZyhCr5kf0/img.png&quot; data-alt=&quot;웹훅 수신 및 운동데이터 처리 워커의 byte[] 사용시 JVM 메모리 공간&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TNr3W/dJMcab5UtfS/nbKlOJ31QPHH3ZyhCr5kf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTNr3W%2FdJMcab5UtfS%2FnbKlOJ31QPHH3ZyhCr5kf0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2622&quot; height=&quot;1820&quot; data-filename=&quot;스크린샷 2026-07-07 오후 6.21.37.png&quot; data-origin-width=&quot;2622&quot; data-origin-height=&quot;1820&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;웹훅 수신 및 운동데이터 처리 워커의 byte[] 사용시 JVM 메모리 공간&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 중 &lt;b&gt;byte[]&lt;/b&gt; 에 총 할당된것이 18.6GiB, JFR 레코딩 기간의 Total Allocation는 81.5% 잡아먹는 문제 발생&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초반 webhook 수신부에서 사용한 &lt;b&gt;byte[]&lt;/b&gt;로 받는 문제와 받은 운동 데이터를 워커가 처리하는 두가지 동작이 복합적으로 일어나다 보니 엄청난 JVM 메모리 공간을 차지하는걸로 보인다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 Stack Trace를 보게되면 하단 Thread를 실행하고 우리 서비스 클래스가 실행된것을 확인 해 보니&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;ZstdTerraPayloadCompressor

public byte[] decompress(...)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;형태의 메서드를 사용하는것을 알 수 있었다, 우리 서비스 특성상 웹훅으로 들어온 큰 페이로드 데이터를 압축해 DB에 저장을 하는 방식을 사용하고 있고 이렇게 저장된 압축 데이터를 별도 구성한 워커로 처리하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터를 처리하는 워커 내부적으로 압축된 데이터를 메모리에서 풀기 때문에 압축 데이터 + 압축 해제 데이터가 같이 메모리에 공존하는 것 뿐만 아니라&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-07 오후 7.31.08.png&quot; data-origin-width=&quot;2840&quot; data-origin-height=&quot;982&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wptup/dJMcaazcasJ/Mz4wrZ3ie4DjnoXm5Ui7cK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wptup/dJMcaazcasJ/Mz4wrZ3ie4DjnoXm5Ui7cK/img.png&quot; data-alt=&quot;byte[] 의 Flame Graph&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wptup/dJMcaazcasJ/Mz4wrZ3ie4DjnoXm5Ui7cK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fwptup%2FdJMcaazcasJ%2FMz4wrZ3ie4DjnoXm5Ui7cK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2840&quot; height=&quot;982&quot; data-filename=&quot;스크린샷 2026-07-07 오후 7.31.08.png&quot; data-origin-width=&quot;2840&quot; data-origin-height=&quot;982&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;byte[] 의 Flame Graph&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것을 보게 되면 압축 해제 메서드인 &lt;b&gt;decompress(byte[])&lt;/b&gt; 메서드를 실행한 직후 가로로 긴 막대가 생긴것을 알 수 있다, 여기서 가로는 메모리를 많이 사용하면 할 수록 가로 크기가 커진다고 생각하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 다른 막대와 길이가 비슷하다고 생각할 수 있지만 여기서 핵심은 자식이 없다는 것이다 다른 막대는 긴 막대 아래 자식 막대가 있는데 지금 사진을 보게되면 하단 자식 없이 &lt;b&gt;Arrays.copy(byte[], int)&lt;/b&gt; 막대가 최 하단인 것을 알수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉 운동 데이터를 웹훅으로 수신 받을때도 byte[] 사용으로 JVM 메모리 압박과 이후 운동 데이터가 압축 저장된 DB에서 압축 운동 데이터를 JVM에 전부 올리고나서 압축 해제까지 하는 복합적인 문제가 발생했다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI도움을 받아 작성한 코드였지만 이걸 작성할때는 JVM의 메모리 부분을 생각하지도 못했던것 같다 ....&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 문제였던 메서드를 보자면&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Override
public byte[] decompress(byte[] payloadBytes) throws Exception {
    if (payloadBytes == null || payloadBytes.length == 0) {
        return new byte[0];
    }

    try (ZstdInputStream zstdInputStream = new ZstdInputStream(new ByteArrayInputStream(payloadBytes));
         ByteArrayOutputStream outputStream = new ByteArrayOutputStream(initialBufferSize(payloadBytes))) {
        zstdInputStream.transferTo(outputStream);
        return outputStream.toByteArray();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 형식이다, 여기서 사용되는 &lt;b&gt;transferTo(...) toByteArray(...)&lt;/b&gt; 가 문제였다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public long transferTo(OutputStream out) throws IOException {
    Objects.requireNonNull(out, &quot;out&quot;);
    long transferred = 0;
    byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
    int read;
    while ((read = this.read(buffer, 0, DEFAULT_BUFFER_SIZE)) &amp;gt;= 0) {
        out.write(buffer, 0, read);
        if (transferred &amp;lt; Long.MAX_VALUE) {
            try {
                transferred = Math.addExact(transferred, read);
            } catch (ArithmeticException ignore) {
                transferred = Long.MAX_VALUE;
            }
        }
    }
    return transferred;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public synchronized byte[] toByteArray() {
        return Arrays.copyOf(buf, count);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;transferTo(...) 메서드는 out.write(buffer, 0, read); 호출하고 내부적으로 ByteArrayOutputStream 의 write(...) 메서드를 호출&lt;br /&gt;내부에 있는 ensureCapacity(...) 메서드를 호출 해 마지막에는 Arrays.copy(byte[], int) 호출 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;toByteArray(...) 메서드도 Arrays.copy(byte[], int) 를 사용하고 있는걸 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여기서 알 수 있는것은 운동 압축 페이로드 하나를 풀기 위해서 총 3개의 큰 불필요한 byte[] 이 생성되었다는것을 알 수 있었다. 이런 이유 때문에 JVM의 메모리 부하가 일어난것을 알게 되었다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;특정 기술의 API를 사용할때 어떤식으로 동작하는지 JVM 메모리 관점에서도 생각해 봐야한다는것을 알게 되었다.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제 1. 웹훅 수신부에서 request body를 byte[]로 전부 읽고 있었다 (웹훅 수신부)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 확인된 가장 큰 문제는 웹훅 수신부였다. 기존 Terra 웹훅 컨트롤러는 요청 본문을 @RequestBody byte[]로 받고 있었다.&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;  @PostMapping
  public ResponseEntity&amp;lt;Void&amp;gt; receiveWebhook(
      @RequestBody byte[] payload,
      @RequestHeader(...) String signature
  ) {
      ...
  }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 HTTP request body가 컨트롤러 메서드에 들어오기 전에 이미 Spring MVC의 HttpMessageConverter에 의해 byte[]로 전부 읽힌다. 즉, 10MB payload가 들어오면 컨트롤러 로직이 시작되기 전에 이미 JVM Heap에는 10MB짜리 byte[]가 만들어진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP request body&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Spring ByteArrayHttpMessageConverter&lt;/li&gt;
&lt;li&gt;byte[] payload&lt;/li&gt;
&lt;li&gt;Controller 진입&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 여기서 끝나지 않는다. 컨트롤러 이후 로직에서는 이 byte[]를 기반으로 다음 작업들이 이어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;byte[] payload&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;HMAC 서명 검증&lt;/li&gt;
&lt;li&gt;payload 압축&lt;/li&gt;
&lt;li&gt;event_type 추출&lt;/li&gt;
&lt;li&gt;DB inbox 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;대용량 request body가 처음부터 JVM Heap에 통째로 올라오고, 이후 처리 과정에서도 추가적인 byte[]와 내부 배열 복사가 발생할 수 있는 구조였다.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제 2. DB에서 압축 payload를 byte[]로 전부 올리고 있었다 (워커)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 운동 데이터 처리 워커의 흐름은 대략 다음과 같았다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;DB payload_compressed 조회&lt;/li&gt;
&lt;li&gt;압축된 데이터를 byte[]로 전부 로딩&lt;/li&gt;
&lt;li&gt;압축 해제 결과도 byte[]로 전부 로딩&lt;/li&gt;
&lt;li&gt;ActivityPayload DTO 역직렬화&lt;/li&gt;
&lt;li&gt;센서 데이터 추출&lt;/li&gt;
&lt;li&gt;그래프 생성&lt;/li&gt;
&lt;li&gt;운동/Post 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, DB에는 압축된 payload를 저장하고 있었지만, 실제 처리 시점에는 압축된 데이터와 압축 해제된 JSON 데이터가 모두 JVM Heap에 올라오고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 Terra Activity payload는 운동 시간이 길어질수록 커진다. 2시간 운동이라도 센서 샘플이 1초 단위로 쌓이면 수천 개 이상의 데이터가 포함되고, 기기/제공자에 따라 payload는 훨씬 커질 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 구조에서는 payload가 커질수록 다음 메모리 비용이 한 번에 발생했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;압축된 byte[]&lt;/li&gt;
&lt;li&gt;압축 해제된 byte[]&lt;/li&gt;
&lt;li&gt;ActivityPayload DTO&lt;/li&gt;
&lt;li&gt;센서 sample 리스트&lt;/li&gt;
&lt;li&gt;그래프용 중간 객체&lt;/li&gt;
&lt;li&gt;그래프 JSON&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서는 worker가 한 개만 돌아도 큰 payload를 처리하는 순간 Heap이 튈 수밖에 없었다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제 3. 압축을 사용하고 있었지만 처리 경계는 스트리밍이 아니었다 (워커)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DB에는 압축된 payload를 저장하고 있었기 때문에 처음에는 메모리 사용이 어느 정도 제어되고 있다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 실제 병목은 저장 방식이 아니라 처리 경계에 있었다. 압축된 데이터를 DB에 저장하더라도, 처리할 때 다음처럼 사용하면 메모리 절감 효과가 줄어든다.&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;byte[] compressed = repository.findPayload(...);
byte[] json = compressor.decompress(compressed);
ActivityPayload payload = objectMapper.readValue(json, ActivityPayload.class);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 압축 저장의 이점은 있지만, 처리 순간에는 결국 전체 JSON이 JVM Heap에 올라온다. 따라서 목표는 명확해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;DB에서 payload를 읽는 순간부터 압축 해제, JSON 파싱까지 byte[] 전체 적재 없이 스트림으로 흘려보내자.&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제 4. ByteArrayOutputStream 내부 배열 복사 비용 (워커)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 문제사항 분석에도 적어 두었지만 기존 구조에서는 압축된 payload를 byte[]로 읽고, 압축 해제 결과도 다시 byte[]로 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정에서 ByteArrayOutputStream이 사용되면 내부 버퍼가 부족할 때마다 더 큰 배열을 새로 만들고 기존 데이터를 복사한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 대략 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;압축 해제 스트림&lt;/li&gt;
&lt;li&gt;ByteArrayOutputStream.write()&lt;/li&gt;
&lt;li&gt;내부 buffer 부족&lt;/li&gt;
&lt;li&gt;더 큰 byte[] 생성&lt;/li&gt;
&lt;li&gt;Arrays.copyOf()&lt;/li&gt;
&lt;li&gt;기존 데이터 복사&lt;/li&gt;
&lt;li&gt;다시 write()&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉 최종 JSON 크기가 10MB라고 해서 딱 10MB만 할당되는 것이 아니다. &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버퍼 확장 과정에서 다음과 같은 추가 비용이 발생한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;중간 크기의 byte[] 여러 번 생성&lt;/li&gt;
&lt;li&gt;기존 데이터 복사&lt;/li&gt;
&lt;li&gt;GC 대상 byte[] 증가&lt;/li&gt;
&lt;li&gt;CPU 복사 비용 증가&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JFR에서도 이와 관련된 흔적이 보였다.&lt;/p&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;java.util.Arrays
java.io.ByteArrayOutputStream
byte[]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 payload가 커질수록 ByteArrayOutputStream의 내부 배열 확장은 더 비싸진다. 압축 해제된 JSON이 수십 MB라면, 그 크기만큼의 최종 배열뿐 아니라 중간 확장 배열과 복사 비용까지 함께 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 문제는 단순히 큰 byte[] 하나가 생긴다 가 아니라, 큰 byte[]를 만들기 위해 내부적으로 여러 번 배열이 확장/복사된다 는 점이었다.이 때문에 CPU 사용량과 GC 부담이 함께 증가했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제 해결&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 알게된 문제점으로 인해 운동 데이터 처리 워커와 웹훅 수신부의 동작 방식을 변경하기로 결정했다&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;변경1. 웹훅 수신부를 스트리밍 방식으로 변경 (수신부)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 웹훅 요청을 @RequestBody byte[]로 받았다. 이 때문에 Spring MVC가 컨트롤러 진입 전에 request body 전체를 byte[]로 읽었고, 대용량 payload가 그대로 JVM Heap 에 올라갔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결하기 위해 @RequestBody byte[]를 제거하고, HttpServletRequest의 InputStream을 직접 읽도록 변경했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기존 구조&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;HTTP request body&lt;/li&gt;
&lt;li&gt;@RequestBody byte[]&lt;/li&gt;
&lt;li&gt;HMAC 검증&lt;/li&gt;
&lt;li&gt;압축&lt;/li&gt;
&lt;li&gt;DB 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;변경 구조&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[수신 + 검증 + 압축]&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP request InputStream&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;buffer&lt;/li&gt;
&lt;li&gt;HMAC update&lt;/li&gt;
&lt;li&gt;ZstdOutputStream&lt;/li&gt;
&lt;li&gt;TempFile OutputStream&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[이벤트 타입 추출]&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;TempFile InputStream&lt;/li&gt;
&lt;li&gt;ZstdInputStream&lt;/li&gt;
&lt;li&gt;Jackson JsonParser&lt;/li&gt;
&lt;li&gt;event_type 추출&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[DB 저장]&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;TempFile InputStream&lt;/li&gt;
&lt;li&gt;JDBC BinaryStream&lt;/li&gt;
&lt;li&gt;DB payload_compressed&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[정리]&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;TempFile 삭제&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;변경 구조의 핵심은 request body를 한 번에 메모리에 올리지 않고, 스트림으로 흘려보내며 처리한 것이다. 수신된 데이터는 작은 buffer 단위로 읽으면서 HMAC 검증과 Zstd 압축을 동시에 수행하고, 압축 결과는 임시 파일에 저장한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt; 이후 임시파일을 다시 스트림으로 열어 event_type을 추출하고, 같은 압축 파일을 JDBC BinaryStream으로 DB에 저장한다.&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;변경 2. DB BLOB을 BinaryStream으로 읽도록 변경 (워커)&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 DB에서 payload_compressed를 읽는 경계를 변경했다. 기존에는 압축 payload를 byte[]로 가져왔다면, 변경 후에는 JDBC의 getBinaryStream()을 사용해 BLOB 데이터를 스트림으로 읽도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 후 흐름은 다음과 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;JDBC getBinaryStream()&lt;/li&gt;
&lt;li&gt;압축 해제 InputStream&lt;/li&gt;
&lt;li&gt;Jackson Streaming Parser&lt;/li&gt;
&lt;li&gt;ActivityWebhookCommand 생성&lt;/li&gt;
&lt;li&gt;기존 activity strategy 처리&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 DB에서 읽은 압축 데이터 전체가 JVM Heap에 한 번에 올라오지 않는다. 즉, 기존 구조가: DB BLOB &amp;rarr; byte[] 였다면, 변경 후에는: DB BLOB &amp;rarr; InputStream 이 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 변경만으로도 압축 payload 자체로 인한 대형 byte[] 할당을 줄일 수 있었다.&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;변경 3. 압축 해제도 byte[]가 아니라 Stream으로 처리 &lt;b&gt;(워커)&lt;/b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로 압축 해제 경계도 변경했다. 기존에는 압축 데이터를 풀어서 byte[]로 만들었다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;compressed byte[]
decompressed byte[]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 변경 후에는 압축 해제 결과를 InputStream으로 바로 넘긴다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;compressed InputStream&lt;/li&gt;
&lt;li&gt;ZstdInputStream&lt;/li&gt;
&lt;li&gt;JsonParser&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉, 압축 해제된 JSON 전체를 메모리에 올리지 않고, Jackson이 필요한 만큼 읽으면서 파싱하도록 변경했다. 이 구조에서는 압축 해제된 JSON이 거대한 byte[]로 존재하지 않는다&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;변경 4. Activity payload 파싱 경계를 분리 &lt;b&gt;(워커)&lt;/b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;운동 데이터 처리에는 두 종류의 데이터가 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 일반 운동 메타데이터&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;운동 타입&lt;/li&gt;
&lt;li&gt;시작/종료 시간&lt;/li&gt;
&lt;li&gt;거리&lt;/li&gt;
&lt;li&gt;칼로리&lt;/li&gt;
&lt;li&gt;평균 심박&lt;/li&gt;
&lt;li&gt;평균 속도&lt;/li&gt;
&lt;li&gt;디바이스 정보 등&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 센서/그래프 데이터&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;speed_samples&lt;/li&gt;
&lt;li&gt;cadence_samples&lt;/li&gt;
&lt;li&gt;power_samples&lt;/li&gt;
&lt;li&gt;elevation_samples&lt;/li&gt;
&lt;li&gt;distance_samples&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 전체 JSON을 DTO로 역직렬화한 뒤, DTO 내부에서 센서 데이터를 다시 꺼냈다.변경 후에는 Jackson Streaming API를 사용해 JSON을 순회하면서 필요한 데이터를 추출하도록 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JsonParser&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;일반 운동 데이터 추출&lt;/li&gt;
&lt;li&gt;그래프 샘플 추출&lt;/li&gt;
&lt;li&gt;ActivityWebhookCommand 구성&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이번 1차 작업에서는 그래프 처리 자체를 완전히 스트리밍 방식으로 바꾸지는 않았다. 그래프 생성 로직은 여전히 기존 ActivityGraphBuilder, ActivityGraphSampleExtractor, LTTB 다운샘플링 흐름을 유지 했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;웹훅 수신부 + 워커 최종 흐름&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Webhook 수신부]&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;HTTP request InputStream&lt;/li&gt;
&lt;li&gt;buffer read&lt;/li&gt;
&lt;li&gt;HMAC update&lt;/li&gt;
&lt;li&gt;ZstdOutputStream&lt;/li&gt;
&lt;li&gt;TempFile 저장&lt;/li&gt;
&lt;li&gt;TempFile InputStream&lt;/li&gt;
&lt;li&gt;event_type 추출&lt;/li&gt;
&lt;li&gt;JDBC BinaryStream&lt;/li&gt;
&lt;li&gt;terra_webhook_inbox 저장&lt;/li&gt;
&lt;li&gt;TempFile 삭제&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[Inbox Processor / Worker]&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;terra_webhook_inbox.payload_compressed&lt;/li&gt;
&lt;li&gt;JDBC getBinaryStream()&lt;/li&gt;
&lt;li&gt;ZstdInputStream&lt;/li&gt;
&lt;li&gt;Jackson JsonParser&lt;/li&gt;
&lt;li&gt;ActivityWebhookCommand 생성&lt;/li&gt;
&lt;li&gt;Cycling/Running Strategy&lt;/li&gt;
&lt;li&gt;workout/post/graph 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웹훅 수신부에서는 request body를 byte[]로 만들지 않고 InputStream으로 읽으면서 HMAC 검증과 Zstd 압축을 동시에 수행한다. 압축 결과는 임시 파일에 저장한 뒤, 해당 파일을 다시 스트림으로 열어 event_type을 추출하고 DB에는 JDBC BinaryStream으로 저장한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 워커는 DB에 저장된 압축 payload를 getBinaryStream()으로 읽고, ZstdInputStream과 Jackson JsonParser를 연결해 전체 JSON을 메모리에 올리지 않고 운동 데이터를 처리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;핵심 차이는 byte[] 전체 적재를 제거하고 InputStream 기반 처리 로 메모리에 올려서 압축 헤제가 아닌 DB부터 Stream으로 읽으면서 압축 해제, json 파싱을 스트림으로 이어지게 바꾼 것이다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: center;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;변경 후 지표&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TNr3W/dJMcab5UtfS/nbKlOJ31QPHH3ZyhCr5kf0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TNr3W/dJMcab5UtfS/nbKlOJ31QPHH3ZyhCr5kf0/img.png&quot; data-is-animation=&quot;false&quot; data-filename=&quot;스크린샷 2026-07-07 오후 6.21.37.png&quot; data-origin-height=&quot;1820&quot; data-origin-width=&quot;2622&quot; style=&quot;width: 49.1967%; margin-right: 10px;&quot; data-widthpercent=&quot;49.78&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TNr3W/dJMcab5UtfS/nbKlOJ31QPHH3ZyhCr5kf0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTNr3W%2FdJMcab5UtfS%2FnbKlOJ31QPHH3ZyhCr5kf0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2622&quot; height=&quot;1820&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/naqLy/dJMcabrjsZw/WVVHsZpv9H8BBhB78Mank0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/naqLy/dJMcabrjsZw/WVVHsZpv9H8BBhB78Mank0/img.png&quot; data-origin-width=&quot;2666&quot; data-origin-height=&quot;1834&quot; data-filename=&quot;스크린샷 2026-07-07 오후 8.15.47.png&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.6405%;&quot; data-widthpercent=&quot;50.22&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/naqLy/dJMcabrjsZw/WVVHsZpv9H8BBhB78Mank0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnaqLy%2FdJMcabrjsZw%2FWVVHsZpv9H8BBhB78Mank0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2666&quot; height=&quot;1834&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;18.6 GiB ➔ 1.42 GiB 획기적인 감소&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1차 워커 리팩토링과 2차 웹훅 수신부 리팩토링 이후, 동일한 k6 비교용 부하 테스트에서 byte[] 총 할당량이 크게 줄었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동일한 조건의 메모리&amp;nbsp;개선&amp;nbsp;효과를&amp;nbsp;확인하기&amp;nbsp;위한 테스트 조건은 다음과 같다.&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;k6&amp;nbsp;VUS:&amp;nbsp;10명&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;총&amp;nbsp;요청&amp;nbsp;수:&amp;nbsp;100회&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;요청&amp;nbsp;1건당&amp;nbsp;payload&amp;nbsp;크기:&amp;nbsp;약&amp;nbsp;8.9MB&lt;br /&gt;&amp;nbsp;&amp;nbsp;-&amp;nbsp;총&amp;nbsp;전송&amp;nbsp;데이터:&amp;nbsp;약&amp;nbsp;890MB&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 147px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style12&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;성능 측정 항목&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;변경 전 (A안: 메모리 적재)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;변경 후 (B안: 임시 파일 스풀링)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;개선 효과 및 결과&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;byte[] 총 할당량 (Alloc Total)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;18.6 GiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;1.42 GiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;약 92.4% 감소&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;byte[] 할당 지분율&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;81.50%&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;23.30%&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;58.2%p 감소&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;char[] 총 할당량 (Alloc Total)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;217 MiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;191 MiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;약 12% 감소&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;PropertyValueBuffer 총 할당량&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;102 MiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;55.6 MiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;약 45.5% 감소&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;힙 메모리 정점 (Heap Peak)&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;약 3.0 GiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;약 384 MiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 19px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;서버 다운 위험 제거 (대폭 감소)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 35px;&quot;&gt;
&lt;td style=&quot;width: 28.2558%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;최상위 부하 지점 (Hot&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt; Spot)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.7442%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Arrays.copyOfByteArrayOutputStream&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;java.lang.DoubleActivityGraphSampleExtractor&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 35px;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;메모리 낭비 원천 차단&lt;br /&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;(순&lt;/span&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;수 비즈니스 연산으로 이동)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변경 전에는 웹훅 수신부와 운동 데이터 처리 워커가 동시에 동작하는 초반 구간에서 Heap 사용량이 급격히 치솟았다. 반면 변경 후에는 동일한 부하에서도 Heap 스파이크가 크게 완화되었다. 이후 운동 데이터가 지속적으로 처리되는 동안에는 Heap 사용량이 톱니 모양으로 오르내렸고, GC 이후 다시 안정적인 수준으로 회복되는 것을 확인할 수 있었다.&lt;/p&gt;</description>
      <category>프로젝트/Tooplay</category>
      <author>taetae-o</author>
      <guid isPermaLink="true">https://taetae-o.tistory.com/18</guid>
      <comments>https://taetae-o.tistory.com/18#entry18comment</comments>
      <pubDate>Wed, 8 Jul 2026 17:34:51 +0900</pubDate>
    </item>
    <item>
      <title>Platform Thread vs Virtual Thread의 AI I/O 블로킹 효율 비교</title>
      <link>https://taetae-o.tistory.com/16</link>
      <description>&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Platform&amp;nbsp;Thread&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;Platform Thread는 운영체제의 1:1 스레드 매핑 방식을 선택했습니다. 여기서 1:1 매핑이란 사용자 영역에 존재하는 JVM에서 생성된 스레드가 커널 영역의 스레드와 1대1 형태로 매핑된것을 말하며 스레드 스케줄링의 통제권을 운영체제 커널에 전적으로 위임 했다는 뜻 입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-03 오후 2.47.51.png&quot; data-origin-width=&quot;2300&quot; data-origin-height=&quot;1476&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MjRLv/dJMcacRbC0l/1FKTCUmh16wVIhkTggbOnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MjRLv/dJMcacRbC0l/1FKTCUmh16wVIhkTggbOnk/img.png&quot; data-alt=&quot;1:1 매핑&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MjRLv/dJMcacRbC0l/1FKTCUmh16wVIhkTggbOnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMjRLv%2FdJMcacRbC0l%2F1FKTCUmh16wVIhkTggbOnk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;678&quot; height=&quot;435&quot; data-filename=&quot;스크린샷 2026-07-03 오후 2.47.51.png&quot; data-origin-width=&quot;2300&quot; data-origin-height=&quot;1476&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;1:1 매핑&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이 방식을 선택 한 이유는 지금의 CPU는 멀티코어 환경으로 CPU의 성능을 최대로 사용하기 위함 입니다 OS 스케줄러가 TCB를 직접 &lt;br /&gt;관리 하면서 CPU의 멀티코어 자원에 자바 스레드를 골고루 분산 배치 해 물리적으로 동시에 연산이 가능하게 합니다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;초창기 자바는 &lt;b data-index-in-node=&quot;22&quot; data-path-to-node=&quot;8,0,0&quot;&gt;1:N 매핑&lt;/b&gt; 방식을 사용했습니다 이 방식의 한계는 1개의 OS 스레드가 JVM의 여러 스레드를 관리 하는 방식으로 만약 하나의 스레드에서 I/O 블로킹이 발생 한다면 다른 스레드도 같이 대기 해 버리는 문제가 발생 했습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이 문제를 해결 하기 위해 &lt;b&gt;1:1 매핑&lt;/b&gt; 방식을 선택 함으로, 하나의 스레드가 I/O 블로킹이 발생하게 되면 &lt;b&gt;OS 커널은 그 스레드를 재우고 커널 영역의 다음 스레드를 올려 작업을 진행하게 됩니다&lt;/b&gt; 이런 방식은 하나의 자바 스레드가 블로킹 되었을 때 프로세스 전체(혹은 다른 자바 스레드들까지) 같이 멈춰버리는 문제를 해결 합니다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이런 방식에도 문제가 있습니다 사용자 영역에서 생성된 스레드는 커널 영역의 스레드와 1:1 매핑이 되기 때문에 사용자 영역에서 생성된 스레드가 많게 되면 커널 영역에 생성되어야 하는 스레드도 같이 증가하게 됩니다, 이런 방식은 CPU의 컨텍스트 스위칭 문제로 시스템 전체의 처리량을 떨어뜨릴 뿐 아니라 스레드 당 고정적으로 할당되는 스택 메모리로 인해 메모리 고갈 현상을 나타낼 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;또한 JVM 내부에서 커널 영역에 스레드를 하나 생성 해 달라는 시스템 콜을 하기 때문에 생성 비용이 비싼 작업을 하게 됩니다 비록 스레드 풀을 통해 스레드를 재사용함으로써 초기의 시스템 콜을 최소화할 수 있으나, &lt;b data-index-in-node=&quot;146&quot; data-path-to-node=&quot;12,1&quot;&gt;대규모 트래픽이 몰려 스레드 풀의 최대 크기를 넘어서는 요청이 들어올 경우, 제한된 스레드가 모두 I/O 블로킹에 묶여 후속 요청들이 대기 큐에서 정체되거나 타임아웃으로 실패하는 근본적인 병목 현상&lt;/b&gt;을 막을 수 없습니다&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이런 문제를 해결 할 수 있는 기술중 Virtual Thread 가 있습니다&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Virtual&amp;nbsp;Thread&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Virtual&amp;nbsp;Thread&lt;/b&gt;는 &lt;b data-index-in-node=&quot;38&quot; data-path-to-node=&quot;5&quot;&gt;수많은 가상 스레드(M)를 소수의 OS 스레드(N) 위에서 실행&lt;/b&gt;시키는 N:M 스레드 매핑 방식을 선택 해 사용합니다&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-03 오후 3.27.53.png&quot; data-origin-width=&quot;2156&quot; data-origin-height=&quot;1632&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VcHuj/dJMcafArhOb/ZAPgI7RarVECTwpYofys4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VcHuj/dJMcafArhOb/ZAPgI7RarVECTwpYofys4k/img.png&quot; data-alt=&quot;가상 스레드 N:M&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VcHuj/dJMcafArhOb/ZAPgI7RarVECTwpYofys4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVcHuj%2FdJMcafArhOb%2FZAPgI7RarVECTwpYofys4k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;699&quot; height=&quot;529&quot; data-filename=&quot;스크린샷 2026-07-03 오후 3.27.53.png&quot; data-origin-width=&quot;2156&quot; data-origin-height=&quot;1632&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;가상 스레드 N:M&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 핵심은 &lt;b&gt;Platform Thread는 OS 영역에 스케줄링을 위임 했다면 Virtual Thread는 사용자 영역에서 스케줄링을 한다는 점과&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Carrier Platform Thread 가 커널 영역의 스레드와 1:1 매핑이 되어있다는 점 입니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커널에서 스케줄링, 사용자 영역에서 스케줄링의 큰 차이점은 스케줄링을 사용자가 제어할 수 있다는 점 입니다 여기서 핵심적인 동작을 하는것은 &lt;b&gt;Carrier Platform Thread &lt;/b&gt;입니다 이 스레드는 커널 영역의 스레드와 1:1 매핑이 되어있으면서 JVM의 사용자 영역에서의 스케줄링으로 작업 할 가상 스레드를 유동적으로 변경 할 수 있고 이렇게 되면 결과적으로 커널 영역의 OS 스케줄러는 오직 고정된 &lt;br /&gt;Carrier Thread 만을 바라보기 때문에, 유저 영역에서 가상 스레드가 교체되어도 이를 전혀 인지하지 못합니다. 즉, &lt;b data-index-in-node=&quot;102&quot; data-path-to-node=&quot;5,2&quot;&gt;스케줄링의 제어권을 애플리케이션(사용자)이 완벽히 쥐게 됨으로써&lt;/b&gt;, 커널 레벨의 컨텍스트 스위칭 부하를 원천적으로 차단하고 대규모 I/O 블로킹을 효율적으로 처리할 수 있게 됩니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 1:1 매핑 방식에서 발생한 메모리 문제도 이제 커널 영역에서 생성되는 무거운 스레드 생성 방식이 아닌 JVM의 힙 영역에 생성되기 때문에 더욱 작은 공간을 가지는 스레드를 생성 해 내부에서 동적으로 공간을 변경 할 수 있고 GC를 통해 메모리 회수 등등 메모리 제어권을 사용자가 가지고 있어 이에 대한 최적화도 진행 할 수 있게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: center;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;비교&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;테스트 환경&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;기본&amp;nbsp;플랫폼&amp;nbsp;스레드&amp;nbsp;풀&amp;nbsp;방식&lt;/li&gt;
&lt;li&gt;가상&amp;nbsp;스레드&amp;nbsp;방식&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Platform Thread를 사용해서 테스트를 진행 한 결과&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KOBvZ/dJMcahyghrd/Lbka3NUGMDMQBkSFxtbHmK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KOBvZ/dJMcahyghrd/Lbka3NUGMDMQBkSFxtbHmK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2676&quot; data-origin-height=&quot;1904&quot; data-filename=&quot;스크린샷 2026-07-03 오후 11.48.04.png&quot; width=&quot;715&quot; height=&quot;509&quot; style=&quot;width: 47.8663%; margin-right: 10px;&quot; data-widthpercent=&quot;48.43&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KOBvZ/dJMcahyghrd/Lbka3NUGMDMQBkSFxtbHmK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKOBvZ%2FdJMcahyghrd%2FLbka3NUGMDMQBkSFxtbHmK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2676&quot; height=&quot;1904&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpnNbX/dJMcage2ffb/9CRdOxzlskoJKlu9L2L1KK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpnNbX/dJMcage2ffb/9CRdOxzlskoJKlu9L2L1KK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2658&quot; data-origin-height=&quot;1776&quot; data-filename=&quot;스크린샷 2026-07-04 오전 2.28.51.png&quot; style=&quot;width: 50.9709%;&quot; data-widthpercent=&quot;51.57&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpnNbX/dJMcage2ffb/9CRdOxzlskoJKlu9L2L1KK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpnNbX%2FdJMcage2ffb%2F9CRdOxzlskoJKlu9L2L1KK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2658&quot; height=&quot;1776&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자의 요청이 들어올때 스레드는 OS 영역에 무겁게 쌓이고, 대신 스레드가 미처 처리하지 못한 '수천 개의 요청 대기 객체(큐)와 통신 버퍼 객체가 힙 영역에 쌓여서 상승하고 있는 모습 입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;b&gt;Platform Thread&lt;/b&gt;를 사용함으로 요청 1개 당 1개의 커널 스레드와 JVM의 스레드가 매핑이 되어 하단 Thread 부분에 그래프가 급격히 상승하는 모습을 볼수 있습니다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 스레드가 계속 &lt;b&gt;Wait&lt;/b&gt; 상태였다 &lt;b&gt;Park&lt;/b&gt; 상태로 들어가게 되는 모습을 볼수 있습니다, 이 문제는 요청 대기 큐는 동시성 큐이기 때문에 접근을 하려면 락을 얻어야 하는데 이 락을 얻기 위해서 경쟁이 심해지게 되면 Park 상태로 스레드 상태를 변경하게 됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드는 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;Platform Thread Pool을 만드는 코드로 내부적으로 LinkedBlockingQueue 를 가지고 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;ExecutorService executor = Executors.newFixedThreadPool(200);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue&amp;lt;Runnable&amp;gt;());
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;LinkedBlockingQueue는 내부에 ReentrantLock 라는 락 관련 필드를 가지고 있고 클래스 내부에는 락을 가지는 코드가 존재 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;actionscript&quot;&gt;&lt;code&gt;private final ReentrantLock putLock = new ReentrantLock();&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드 내부로 들어가 보면 아래 락을 얻는 진입점으로 들어가게 되고&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@ReservedStackAccess
final void lock() {
    if (!initialTryLock())
        acquire(1);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;AbstractQueuedSynchronizer 추상 클래스의 acquire 메서드를 호출하게 됩니다 이 메서드는 간단히 tryAcquire 메서드를 통해 락을 한번 더 확인 후 없으면 이 추상 클래스의 acquire 메서드를 호출하게 됩니다.&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;public final void acquire(int arg) {
    if (!tryAcquire(arg))
        acquire(null, arg, false, false, false, 0L);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;호출된 &lt;b&gt;acquire 메서드는 내부적으로 무한 루프를 돌면서 락을 얻고 락을 얻지 못한 스레드는 LockSupport.park(..) 메서드를 통해 스레드의 상태를 park 상태로 변경 합니다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1783101296185&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;final int acquire(Node node, int arg, boolean shared, ...) {
    for (;;) { 
        if (first || pred == null) {
            if (tryAcquire(arg)) { 
                return 1;          
            }
        }
        // ... 중간에 힙 영역에 줄 세우는 코드 (`new ExclusiveNode()`) 실행
        else {
            LockSupport.park(this); 
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이렇게 park 상태로 들어가게 되면 작업 큐에 쌓여 있는 요청을 소모하지 못하게 되는 상태가 되기 때문에 플랫폼 스레드 풀 모델이 대규모 트래픽에서 문제가 될 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Instruments 분석&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-04 오전 3.35.50.png&quot; data-origin-width=&quot;2920&quot; data-origin-height=&quot;1462&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mz50m/dJMcaccC21o/BFWviqXFE1O9V5kZEsLZfK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mz50m/dJMcaccC21o/BFWviqXFE1O9V5kZEsLZfK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mz50m/dJMcaccC21o/BFWviqXFE1O9V5kZEsLZfK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fmz50m%2FdJMcaccC21o%2FBFWviqXFE1O9V5kZEsLZfK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2920&quot; height=&quot;1462&quot; data-filename=&quot;스크린샷 2026-07-04 오전 3.35.50.png&quot; data-origin-width=&quot;2920&quot; data-origin-height=&quot;1462&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 지표는 K6 테스트를 하면서 발생한 지표 입니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;System Call Trace&lt;/b&gt; 부분을 보면 초반에 스레드 생성으로 인한 시스템 콜의 지표가 높은것을 알수 있습니다, 1:1 매핑은 OS 스레드와 사용자 스레드가 매핑이 되어야 하기 때문에 높은걸 알 수 있습니다, 또한 테스트가 끝날때 까지 지속적인 시스템 콜을 하고 있는데 이 부분은 스레스 생성 뿐 아니라, 스레드 상태 변경 등등 많은 시스템 콜을 하고 있는것을 알 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Thread State Trace &lt;/b&gt;부분을 보면 OS 커널 스케줄러는 큐의 키 경쟁에서 키를 잡지 못한 무거운 플랫폼 스레드들을 CPU 코어에서 끌어내리고(Block/Park), 깨우는 작업을 무한 반복 합니다. 이 과정에서 발생한&lt;b&gt; 컨텍스트 스위칭&lt;/b&gt; 오버헤드는 맥 OS 커널 레벨에 엄청난 연산 부하를 주었다는것을 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;java&amp;nbsp;&lt;/b&gt;부분을 보면 CPU 사용량과 컨텍스트 스위칭 수치가 일정하지 않고 중간중간 튀어 나와있는 것을 알 수 있습니다 이는 CPU 코어가 진짜 가상 사용자의 요청을 처리하는 작업을 한 것이 아니라, &lt;b data-index-in-node=&quot;125&quot; data-path-to-node=&quot;13&quot;&gt;스레드의 상태 변경과 TCB를 갈아끼우는 오버헤드(Context Switch)에 CPU 시간을 사용 했고 사용자의 요청을 올바르게 처리하지 못했다는 것을 알 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;K6 지표 분석&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-03 오후 11.50.10.png&quot; data-origin-width=&quot;1846&quot; data-origin-height=&quot;1836&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ont90/dJMcabrg9Lc/8pNfl9dHI07B90fFP37Khk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ont90/dJMcabrg9Lc/8pNfl9dHI07B90fFP37Khk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ont90/dJMcabrg9Lc/8pNfl9dHI07B90fFP37Khk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Font90%2FdJMcabrg9Lc%2F8pNfl9dHI07B90fFP37Khk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;850&quot; height=&quot;845&quot; data-filename=&quot;스크린샷 2026-07-03 오후 11.50.10.png&quot; data-origin-width=&quot;1846&quot; data-origin-height=&quot;1836&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 200개의 요청에 대한 각각의 스레드는 1.5초 대기 후 2초동안 작업을 하고 응답을 처리후 다음 작업을 작업 대기 큐에서 가져와 실행하게 됩니다, 그렇게 되면 응답 속도는 매우 느려지게 됩니다&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;k6의 HTTP 지표를 보게되면 평균 응답 속도는 25.59초 가장 늦게 들어온 사용자의 요청은 47.04초가 걸려 응답을 받은것을 볼 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 기간 중 순간 10000명의 동시 사용자가 한꺼번에 서버 접속을 시도 했지만 &lt;b&gt;http_reqs (123.71/s) 서버의 초당 처리량은 플랫폼 스레드 200개라는 문제 때문에 &lt;b data-index-in-node=&quot;95&quot; data-path-to-node=&quot;9,1,0&quot;&gt;초당 123개의 요청밖에 처리하지 못하는 큰 병목이 발생 한것을 알 수 있습니다.&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Virtual&amp;nbsp;Thread를 사용해서 테스트를 진행 한 결과&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/boekqt/dJMcabSigrL/VglUHuTDHnKnHvT1VXbQB0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/boekqt/dJMcabSigrL/VglUHuTDHnKnHvT1VXbQB0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2494&quot; data-origin-height=&quot;1894&quot; data-filename=&quot;스크린샷 2026-07-04 오전 3.53.21.png&quot; data-widthpercent=&quot;46.13&quot; style=&quot;width: 45.5894%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/boekqt/dJMcabSigrL/VglUHuTDHnKnHvT1VXbQB0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fboekqt%2FdJMcabSigrL%2FVglUHuTDHnKnHvT1VXbQB0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2494&quot; height=&quot;1894&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpodLl/dJMcacwW6ZZ/42OfeVe9DPiDsXUc1tgeG1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpodLl/dJMcacwW6ZZ/42OfeVe9DPiDsXUc1tgeG1/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2510&quot; data-origin-height=&quot;1632&quot; data-filename=&quot;스크린샷 2026-07-04 오전 3.56.20.png&quot; style=&quot;width: 53.2478%;&quot; data-widthpercent=&quot;53.87&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpodLl/dJMcacwW6ZZ/42OfeVe9DPiDsXUc1tgeG1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpodLl%2FdJMcacwW6ZZ%2F42OfeVe9DPiDsXUc1tgeG1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2510&quot; height=&quot;1632&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Virtual&amp;nbsp;Thread를&amp;nbsp;&lt;/b&gt;사용 했을 때 힙 공간이 늘어나는 이유는 K6 테스트를 통해 들어온 요청을 가상 스레드 객체로 힙 영역에 생성했기 때문에 증가 하는 모습을 보이고 있습니다. 가상 스레드(Virtual Thread)가 OS 커널에 종속되지 않고 JVM 힙 영역에 직접 적재되는 초경량 자바 객체이기 때문에. 수만 개의 요청이 들어오는 즉시 힙 공간에 가상 스레드 객체 형태로 실시간 할당되었다가, 비즈니스 로직 및 I/O 처리가 완료되는 대로 가비지 컬렉터(GC)에 의해 신속하게 소모, 회수되면서 대규모 부하 속에서도 메모리를 효율적으로 사용할 수 있는 모습입니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;여기서 Thread 부분을 보게 되면 &lt;b&gt;Platform Thread&amp;nbsp; &lt;/b&gt;와 다르게 커널 스레드와 매핑이 되는 스레드의 개수가 최대 31개인 모습을 볼 수 있습니다. 이유는 위에서 설명한 &lt;b&gt;Virtual Thread&lt;/b&gt;의 실행 방식 때문 입니다. 오른쪽 사진을 보게되면 커널 스레드와 JVM의 스레드 매핑은 고작 12개 정도밖에 되지 않습니다 여기서 &lt;b&gt;ForkJoinPool 접미사를 가진 스레드가 Carrier Platform Thread 입니다 위에서 설명 했듯이 &lt;/b&gt;1대1 매핑이 되는 스레드는 Carrier Platform Thread가 되고 이 스레드의 스케줄링을 JVM에서 진행 하면서 &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;커널 공간에서는 접근할 수 없던 부분인&amp;nbsp;&lt;/span&gt;스케줄링 부분을 사용자 영역으로 이동시켜 더욱 유연한 사용자 레벨의 컨텍스트 스위칭을 할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;여기서 왜 처음부터 ForkJoinPool 스레드가 Park 상태인지 의문이 들었는데&amp;nbsp;플랫폼 스레드는 1.5초의 대기 지연을 OS 커널 스레드가 진행 하기 때문에 초반 Wait 상태가 표시 되었지만, 가상 스레드는 1.5초의 대기 지연을 JVM 힙 영역 내부로 유턴시켜 격리(안 찍힘)하고, 물리 캐리어 스레드는 오직 나노초 단위의 순수 실행(Running) 순간에만 실행 되고 즉시 대기실(Park)로 복귀하기 때문에 지표상 주황색만 보이는것이라고 한다. 또한 Running를 보기 위해서 5억번 정도의 루프를 돌게 했지만 이번에는 Wait 막대만 나오는것을 보고 찾아봤더니 소켓 I/O 컨텍스트 내부에서 연산이 돌 때 JVM의 장부(ThreadMXBean)가 상태를 WAIT로 오독하는 기술적 착시가 있다고 한다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;이 가상 스레드는 ThreadFactory 를 통해 생성이 된다 실제 저장 위치는 VM Heap(힙) 영역이고 실행할 때만 잠깐 캐리어 스레드 메모리를 빌려 쓰고, &lt;b data-index-in-node=&quot;38&quot; data-path-to-node=&quot;17,0,2,0&quot;&gt;멈추거나 쉴 때는 다시 Heap 영역의 일반 객체 저장소로 돌아와 있는 구조이다.&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;b data-index-in-node=&quot;38&quot; data-path-to-node=&quot;17,0,2,0&quot;&gt;이렇기 때문에 I/O 바운디드 작업에서 가상 스레드의 강점이 나타나게 된다 &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Instruments 분석&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-04 오전 4.32.14.png&quot; data-origin-width=&quot;2916&quot; data-origin-height=&quot;1456&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pdUKn/dJMcaglMiFO/k2Gg8TkcttUfQsnwXhDnt0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pdUKn/dJMcaglMiFO/k2Gg8TkcttUfQsnwXhDnt0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pdUKn/dJMcaglMiFO/k2Gg8TkcttUfQsnwXhDnt0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpdUKn%2FdJMcaglMiFO%2Fk2Gg8TkcttUfQsnwXhDnt0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2916&quot; height=&quot;1456&quot; data-filename=&quot;스크린샷 2026-07-04 오전 4.32.14.png&quot; data-origin-width=&quot;2916&quot; data-origin-height=&quot;1456&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;System Call Trace&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;부분을 보면 테스트 초반부에 그래프가 소폭 상승하는 구간이 존재합니다. 가상 스레드 모델이라 하더라도 실제 연산을 수행할 물리 스레드인 '캐리어 플랫폼 스레드(ForkJoinPool)'가 필요하기 때문에 조금 상승하는 모습을 보이고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;지표를 보게되면 얼핏 &lt;/span&gt;&lt;b&gt;Platform Thread &lt;/b&gt;보다 안좋은거 아니야 라는 생각을 할 수 있는데 이는 지속적이고 건강한 시스템 콜 입니다 테스트가 끝날 때까지 완만한 능선 모양으로 지속적인 시스템 콜이 발생하는 것은, 플랫폼 스레드처럼 락 때문에 기절하는 비정상적인 호출이 아닙니다. 만 명의 가상 사용자의 요청으로 인한 네트워크 패킷을 처리하기 위해 &lt;b data-index-in-node=&quot;138&quot; data-path-to-node=&quot;5,1,0&quot;&gt;스레드의 동적 상태 제어 및 실제 소켓 Read/Write 등 필수적인 하드웨어 자원 동기화 시스템 콜&lt;/b&gt;을 멈춤 없이 안정적으로 수행하고 있음을 보여주고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Thread State Trace&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;부분을 보면 플랫폼 스레드 때처럼 100% 위로 튀지 않고, 부하 유입 모양 그대로 유연한 곡선을 그리는 이유가 여기에 있습니다. 큐의 자물쇠 경쟁에서 패배한 수많은 가상 스레드들을 CPU 코어에서 끌어내리고 깨우는 무거운 작업을 &lt;b data-index-in-node=&quot;173&quot; data-path-to-node=&quot;7,0,0&quot;&gt;OS 커널 스케줄러가 아니라 JVM 사용자 영역에서 전담&lt;/b&gt;했기 때문입니다 커널 레벨의 컨텍스트 스위칭 오버헤드가 방어되어 macOS 커널이 부하를 적절하게 받아내고 있음을 보여줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;java&amp;nbsp;&lt;/b&gt;부분을 보면 CPU 사용량과 컨텍스트 스위칭 수치가 크게 튀지 않고, 전체 부하 진입(Ramp-up) 곡선과 완벽하게 정렬되어 일정하게 움직입니다. 이는 CPU 코어가 '무거운 스레드 상태 변경이나 TCB 장부를 갈아끼우는 오버헤드'에 자원을 사용한 것이 아님을 뜻합니다. &lt;br /&gt;가상 스레드의 경량 유저 레벨 컨텍스트 스위칭 덕분에 CPU 시간이 오직 &lt;b data-index-in-node=&quot;243&quot; data-path-to-node=&quot;9,0,0&quot;&gt;가상 사용자의 비즈니스 요청을 올바르게 처리하고 소모하는 일에 집중&lt;/b&gt;되었다고 할 수 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;K6 지표 분석&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-07-04 오전 4.43.16.png&quot; data-origin-width=&quot;1818&quot; data-origin-height=&quot;1830&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b751oo/dJMcag69suy/rtTCB0it4kMg2jLDNrjZKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b751oo/dJMcag69suy/rtTCB0it4kMg2jLDNrjZKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b751oo/dJMcag69suy/rtTCB0it4kMg2jLDNrjZKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb751oo%2FdJMcag69suy%2FrtTCB0it4kMg2jLDNrjZKk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1818&quot; height=&quot;1830&quot; data-filename=&quot;스크린샷 2026-07-04 오전 4.43.16.png&quot; data-origin-width=&quot;1818&quot; data-origin-height=&quot;1830&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;k6의 HTTP 지표를 보게되면 avg=1.5s min=1.5s max=1.54s 등등 1.5초 근처(최대 1.54초)를 기록하고 있는 모습 입니다&lt;br /&gt;상단에서 &lt;span style=&quot;color: #666666; text-align: left;&quot;&gt;Park 상태인 막대만 보였지만 실질적으로 모든 요청이 성공적으로 응답이 왔다는것을 증명 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;checks_succeeded, http_req_failed 부분을 보게되면 가상 사용자가 1분 10초 동안 무려 &lt;b data-index-in-node=&quot;42&quot; data-path-to-node=&quot;5,1,0&quot;&gt;118,212번&lt;/b&gt;의 요청을 보냈지만 &lt;b data-index-in-node=&quot;127&quot; data-path-to-node=&quot;5,1,0&quot;&gt;100% 성공&lt;/b&gt;적으로 처리 한 모습 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;interrupted iterations 부분은 테스트가 강제로 종료되거나 부하 한계치를 넘어서 줄 서다가 타임아웃으로 잘려 나간 요청이 &lt;b data-index-in-node=&quot;59&quot; data-path-to-node=&quot;9,1,0&quot;&gt;정확히 0건으로 나타나고 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결론적으로 가상 스레드는 대기 지연이 큰 I/O 바운드 작업에서 &lt;b data-index-in-node=&quot;39&quot; data-path-to-node=&quot;12,3&quot;&gt;물리 캐리어 스레드를 기절시키지 않고 JVM 사용자 영역 스케줄링으로 자원을 극대화 해 &lt;/b&gt;소프트웨어 아키텍처의 전환만으로 많은 처리량 향상과 최적의 자원 효율성 나타내고 있다.&lt;/p&gt;</description>
      <category>java</category>
      <author>taetae-o</author>
      <guid isPermaLink="true">https://taetae-o.tistory.com/16</guid>
      <comments>https://taetae-o.tistory.com/16#entry16comment</comments>
      <pubDate>Sat, 4 Jul 2026 04:49:14 +0900</pubDate>
    </item>
    <item>
      <title>RAG(검색증강생성)를 위한 코사인 유사도(Cosine Similarity) 고속 연산</title>
      <link>https://taetae-o.tistory.com/15</link>
      <description>&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;요구사항&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유저의 질문 벡터와 가장 유사한 데이터 Top 5를 추출하는 고속 벡터 연산 알고리즘 구현&lt;/li&gt;
&lt;li&gt;반복문 언롤링 및 프리미티브 배열 최적화를 통해 연산 속도 극대화.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;구현 사항&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;OpenAi의 &lt;b&gt;text-embedding-3-small&amp;nbsp;&lt;/b&gt;을 사용해 사용자의 질문 및 기본 지식 세팅시 Vector 배열 추출&lt;/li&gt;
&lt;li&gt;기본 지식을 넣을 API 구축
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;카테고리 설정으로 모든 정보를 다 뒤지는게 아닌 특정 카테고리 데이터만 서치&lt;/li&gt;
&lt;li&gt;외부 API를 사용해서 얻은 지식 벡터 데이터는 postgresql 에 저장&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;사용자의 질문 API 구축&lt;/li&gt;
&lt;li&gt;사용자의 질문에 대해 유사도 Top5 추출 기능
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;pgvector 기반 DB내 Top5 추출&lt;/li&gt;
&lt;li&gt;반복문 언롤링 및 프리미티브 배열 최적화를 통한 고속 벡터 연산 알고리즘 사용해 Top5 추출&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;InMemoryVectorRow&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;text-embedding-3-small&amp;nbsp;를 사용하게 되면 1536 차원의 벡터 데이터를 받게 되는데 이것을 float[] 형태로 저장하고 적절한 분야에서 데이터를 찾기 위해 카테고리 필드를 추가.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;InMemoryCacheRegistry&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;@PostConstruct 를 사용해 미리 세팅 해 둔 지식 데이터를 메모리에 올리기 위한 준비 클래스&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;RagSearchService&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;사용자의 질문 Payload를 접수하여, 카테고리로 적절한 분야 기반으로 4칸 루프 언롤링으로 코사인 유사도를 측정&lt;b&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;DB, Memory 구분&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;RagSearchController&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;프론트엔드나 포스트맨의 고밀도 JSON 요청을 바디로 깔끔하게 접수하여 결과 보고서를 출고하는 엔드포인트.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;반복문 언롤링&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리가 흔히 짜는 &lt;b&gt;for (int i = 0; i &amp;lt; 1536; i++) &lt;/b&gt;이 반복문은 CPU가 내부적으로 명령을 수행 할 때 큰 비효율이 존재한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 루프는 다음과 같은 명령을 수행한다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;i &amp;lt; 1536 조건이 참인지 검사&lt;/li&gt;
&lt;li&gt;루프 내부 연산 수행&lt;/li&gt;
&lt;li&gt;i++ 실행 및 다시 루프 시작점으로 이동&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴퓨터 구조 관점에서 CPU는 파이프라인을 통해 여러 명령어를 겹쳐 처리하려고 합니다. 하지만 조건문이나 반복문의 분기 지점에서는 다음에 실행할 명령어의 흐름이 아직 확정되지 않았기 때문에 CPU는 분기 예측을 사용합니다. 만약 예측이 맞으면 파이프라인은 계속 효율적으로 동작하지만, 예측이 틀리면 이미 파이프라인에 들어온 잘못된 명령어들을 버리고 올바른 명령어 흐름으로 다시 채워야 합니다. 이 과정에서 파이프라인 &lt;b&gt;스톨&lt;/b&gt;이나 &lt;b&gt;플러시&lt;/b&gt;가 발생할 수 있고, 잠깐이지만 &lt;b&gt;ALU&lt;/b&gt;와 같은 실행 장치가 제대로 활용되지 못하는 &lt;b&gt;오버헤드&lt;/b&gt;가 생깁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 문제를 해결하기 효율적으로 해결하기 위해 &lt;b&gt;반복문 언롤링 이라는 것을 사용할 수 있습니다.&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;matlab&quot;&gt;&lt;code&gt;for (; i &amp;lt;= len - 4; i += 4) {
    dotProduct += vecA[i] * vecB[i]; normA += vecA[i] * vecA[i]; normB += vecB[i] * vecB[i];
    dotProduct += vecA[i+1] * vecB[i+1]; normA += vecA[i+1] * vecA[i+1]; normB += vecB[i+1] * vecB[i+1];
    dotProduct += vecA[i+2] * vecB[i+2]; normA += vecA[i+2] * vecA[i+2]; normB += vecB[i+2] * vecB[i+2];
    dotProduct += vecA[i+3] * vecB[i+3]; normA += vecA[i+3] * vecA[i+3]; normB += vecB[i+3] * vecB[i+3];
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드는 &lt;b&gt;4-Way 반복문 언롤링&lt;/b&gt;으로 한 번의 반복에서 4개의 연산을 처리하도록 만듭니다. 예를 들어 1,536번 반복하던 루프를 384번의 반복으로 줄이는 방식으로. 이 방식의 장점은 반복 횟수가 줄어들면서 루프 증가 연산, 조건 비교, 분기 명령 같은 제어 명령의 실행 횟수도 함께 줄어든다는 점입니다. 따라서 CPU 입장에서는 루프 분기 명령을 처리하는 횟수가 줄어들고, 잘못된 분기 예측이 발생할 수 있는 기회도 줄어듭니다. 또한 한 번의 반복 안에 여러 연산이 배치되기 때문에 CPU가 명령어를 더 연속적으로 처리하기 쉬워져 파이프라인 효율과 명령어 수준 병렬성이 좋아질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;프리미티브 배열 최적화&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;프리미티브 배열 최적화 부분은 위에서 &lt;b&gt;반복문 언롤링&lt;/b&gt;과 이어지게 될 수 있습니다 Java에는&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Primitive type(원시타입)&lt;/li&gt;
&lt;li&gt;Reference type(참조타입)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두가지 타입이 있습니다 간단히 말해 참조타입은 new 으로 생성되기 때문에 메모리 공간에 렌덤적으로 생성되게 됩니다 이런 상황으로 반복문 최적화를 하게 되면 루프를 도는 횟수는 줄어들 수 있지만 내부적으로 엄청난 비효율이 발생하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0번 방 값을 연산하고 나면, 1번 방 값을 읽기 위해 다시 주소값을 타고 메모리의 완전히 다른 구역으로&amp;nbsp; 이동 했다 오는 &lt;b&gt;포인터 추적 &lt;/b&gt;&lt;br /&gt;&lt;b&gt;오버헤드&lt;/b&gt; 가 발생합니다 데이터의 위치가 불 연속적이기 때문에 CPU 캐시를 효율적으로 사용하지 못하고 메모리 까지 내려갔다오는 &lt;br /&gt;비 효율이 발생하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 프리미티브 타입의 배열을 사용하게 되면 CPU가 메모리에서 데이터를 읽어올 때는 딱 숫자 1개만 가져오지 않고, 효율을 위해 그 주변 데이터까지 세트로 묶어서 &lt;b data-index-in-node=&quot;67&quot; data-path-to-node=&quot;17&quot;&gt;캐시 라인(Cache Line, 보통 64바이트 단위)&lt;/b&gt; 단위로 가져오게 됩니다. 그렇기 때문에 컴퓨터는 프리미티브 배열 vecA[0]의 숫자를 읽는 순간, CPU 내부의 하드웨어 장치는 내부적으로 vecA[1] vecA[2] ... 데이터도 순차적으로 읽는다는것을 감지 해 CPU 캐시에 올려두게 됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 되면 메모리까지 내려갔다 오는 횟수도 최적화가 되고 메인 메모리를 다녀오느라 발생하는 지연 시간(Latency)를 최적화 할 수 있습니다, 또한 참조 타입과 원시타입의 메모리 차지하는 크기도 원시타입이 더 적기 때문에 메모리 공강 효율의 이점을 얻을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring AI 의 Text embedding 모델을 사용해 보았는데 여기서도&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;float[] embedding = embeddingModel.embed(text);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리미티브 타입의 배열을 반환 하는것을 알 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>java</category>
      <author>taetae-o</author>
      <guid isPermaLink="true">https://taetae-o.tistory.com/15</guid>
      <comments>https://taetae-o.tistory.com/15#entry15comment</comments>
      <pubDate>Fri, 3 Jul 2026 02:39:17 +0900</pubDate>
    </item>
    <item>
      <title>Thread-Per-Connection 소켓 폭발 및 락 병목 스레드 덤프 분석</title>
      <link>https://taetae-o.tistory.com/14</link>
      <description>&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;요구 사항&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;멀티스레드 소켓 채팅 서버에 k6로 대량 동시 연결을 인입시켜 Thread-Per-Connection 구조의 한계를 확인한다.&lt;/li&gt;
&lt;li&gt;최초 목표는 3,000개 동시 연결이었고, 한계 지점을 확인하기 위해 최대 10,000개 연결까지 부하를 증가시켰다.&lt;/li&gt;
&lt;li&gt;그 과정에서 스레드 생성 폭발로 인한 OOM 또는 native thread 생성 실패 상황을 재현한다.&lt;/li&gt;
&lt;li&gt;AI 응답 대기 시간 동안 `synchronized` 락에 묶여 병목이 발생한 스레드 상태를 `jstack` 덤프로 추출해 원인을 격리한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;문제 사항&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,1,1,1,0,0&quot;&gt;메모리 고갈 (OOM)&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;고동시성 트래픽 유입 시 Thread-Per-Connection 구조에서는 연결 수에 비례해 플랫폼 스레드가 증가한다.&lt;/li&gt;
&lt;li&gt;플랫폼 스레드는 Java Heap 안에만 존재하는 것이 아니라 OS native thread와 매핑되며, 각 스레드는 별도의 stack memory를 필요로 한다.&lt;/li&gt;
&lt;li&gt;따라서 연결 수가 급격히 증가하면 Heap 사용량뿐 아니라 native memory와 OS thread 생성한계에도 도달할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-path-to-node=&quot;6,1,1,1,1,0&quot; data-index-in-node=&quot;0&quot;&gt;동기화 락 병목 (Monitor Locked)&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;긴 대기 시간 동안 임계구역을 보호하는 자바의 synchronized 키워드가 '스핀락' 임계치를 초과하여 '뮤텍스(Mutex) 기반 블로킹 중량 락'으로 강제 승격.&lt;/li&gt;
&lt;li&gt;이로 인해 수천 개의 스레드가 OS 대기실로 들어가 BLOCKED 상태 변경&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실험에서는 JVM Heap을 30MB로 제한한 상태에서 대량 연결을 인입했고, 약 8,200개 수준의 스레드가 생성된 뒤&lt;/b&gt;&lt;br /&gt;&lt;b&gt;&amp;nbsp;&amp;nbsp;더&amp;nbsp;이상&amp;nbsp;안정적으로&amp;nbsp;스레드를&amp;nbsp;생성하지&amp;nbsp;못하는&amp;nbsp;상황을&amp;nbsp;관찰했다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;Thread-Per-Connection&lt;/b&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;연결당 스레드 하나라는 뜻으로 클라이언트거 서버에 접속해서 소켓 커넥션을 맺으면 서버가 그 유저만을 위한 전담 스레드를 하나 새로 만들어거나 스레드 풀에서 꺼내서 사용자의 요청과 서버의 응답까지 1대1로 붙여주는 네트워크 구동 방식 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 방식의 위험성은 자바에서 스레드를 생성할 때 스레드가 하나 생성될 때 마다 OS로 부터 스레드 로컬 스택 메모리를 할당받게 되는데 만약 기본 메모리를 1MB로 가정하고 사용자가 3000명 들어왔을 때 단순 계산으로 3GB를 소모하게 됩니다. 즉 메모리의 자원은 한정적이고 사용자가 더 늘어나게 된다면 &lt;b&gt;OOM&lt;/b&gt;이 발생하게 되고 서버는 죽게됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 &lt;b&gt;컨텍스트 스위칭의 문제가 발생 합니다&lt;/b&gt;. 컨텍스트 스위칭의 비용은 무시할 수 없고, CPU코어는 한정 되어있기 때문에 운영체제 관점에서 생성된 사용자 3000명을 관리하기 위한 PCB 하위 스레드의 정보가 저장되어있는 TCB를 생성하고 이걸로 스케줄링을 하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴퓨터는 모든것이 동시에 실행 되는것 처럼 보이지만 내부에서는 CPU가 스케줄링을 통해 번갈아 수행을 하는것이다 이게 너무 빠르기 때문에 우리 눈에는 동시에 실행 되는것 처럼 보이게 됩니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 번갈아 가면서 수행을 하려면 이 스레드는 어디까지 수행을 했는지 저장과, 다시 실행하기 위해 데이터를 가져와야 하고, 가져온것을 실행 해야 하는 것 까지의 작업을 하게 되는데 CPU가 이 작업을 하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;여기서 문제가 발생 하게 되는데 한정되어있는 CPU 자원은 많이 생성된 스레드를 전부 기아 스레드 없이 동작 시키기 위해 우선순위 등등을 따져 스케줄링을 하게 되는데 코어는 한정되어있기 때문에 실행되는 스레드의 시간이 극단적으로 줄어들 수 밖에 없습니다, 이렇게 스레드의 정보를 올리는 동작이 스레드의 실행 시간보다 길어지게 되면 핵심은 코드를 실행 시키는것이 목적이였지만 이것이 스레드의 정보를 저장, 복원하는것에 시간을 더 사용하게 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 문제는 &lt;b&gt;CPU의 캐시 메모리인 L1, L2, L3의 캐시 미스 문제도 발생 시키게 됩니다&lt;/b&gt;. 스레드는 메모리 공간에 저장되어있고 이것을 더욱 빠르게 정보를 가져오고, 저장하기 위해 캐시를 사용하게 됩니다. 하지만 스레드가 많게 되면 케시 메모리가 계속 비워지고 다시 케시에 올리기 위해 메인 메모리까지 내려갔다 오는 작업의 간격이 짧아지게 됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리 하면 스레드 수가 과도하게 증가하면 CPU는 더 많은 스레드를 스케줄링해야 하고, 컨텍스트 스위칭과 캐시 효율 저하가 발생할 수 있다. 다만 본 실험처럼 대부분의 스레드가 monitor entry에서 `BLOCKED` 상태로 대기하는 경우, 핵심 병목은 CPU 연산 자체보다 스레드 자원 점유, 락 대기, native thread 생성 한계에 더 가깝다고 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style3&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: center;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 분석&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;테스트 환경&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Java: 21.0.9
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;nbsp;서버 구조: Thread-Per-Connection 소켓 서버&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;JVM 옵션
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;-Xms30m&lt;/li&gt;
&lt;li&gt;-Xmx30m&lt;/li&gt;
&lt;li&gt;-Xss1m 또는 기본값&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;부하 도구: k6
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;목표 연결 수
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1차: 3,000 connection&lt;/li&gt;
&lt;li&gt;&amp;nbsp;2차: 10,000 connections&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;관찰 도구:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;VisualVM&lt;/li&gt;
&lt;li&gt;jstack&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;관찰 항목:
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Heap 사용량&lt;/li&gt;
&lt;li&gt;Live Thread 수&lt;/li&gt;
&lt;li&gt;OOM 발생 시점&lt;/li&gt;
&lt;li&gt;BLOCKED (on object monitor) 스레드 수&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;테스트를 위한 서버를 구동 후 k6 test.js 실행&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Heap텝의 그래프의 초기 상태를 보게되면 사용량이 급격하게 증가되는 모습과 Threads 텝의 생성된 스레드의 그래프가 급격하게 증가하는 모습.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-25 오후 4.51.53.png&quot; data-origin-width=&quot;2872&quot; data-origin-height=&quot;1494&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6Zcb0/dJMcabY2BqW/PnTy20aR5molgIWvGsakp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6Zcb0/dJMcabY2BqW/PnTy20aR5molgIWvGsakp0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6Zcb0/dJMcabY2BqW/PnTy20aR5molgIWvGsakp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb6Zcb0%2FdJMcabY2BqW%2FPnTy20aR5molgIWvGsakp0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2872&quot; height=&quot;1494&quot; data-filename=&quot;스크린샷 2026-06-25 오후 4.51.53.png&quot; data-origin-width=&quot;2872&quot; data-origin-height=&quot;1494&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;10000개의 소켓을 연결 하다가 OOM 발생 총 8200개 정도의 스레드만 생성&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성된 스레드가 임계 구역을 들어가기 위해 대기 상태인 모습과, 실행되고 있는 스레드 확인 Total 섹션에는 이 스레드가 실행되고 나서의 라이프 타임, 임계구역에 들어가지 못해 대기하고있는 스레드는 계속해서 라이프타임이 길어지고, 메모리를 계속 잡고있는다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-25 오후 4.55.56.png&quot; data-origin-width=&quot;2868&quot; data-origin-height=&quot;1330&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cVIxNZ/dJMcaashDgX/lMLqIvFXki0RuvlTfhw6pk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cVIxNZ/dJMcaashDgX/lMLqIvFXki0RuvlTfhw6pk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cVIxNZ/dJMcaashDgX/lMLqIvFXki0RuvlTfhw6pk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcVIxNZ%2FdJMcaashDgX%2FlMLqIvFXki0RuvlTfhw6pk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2868&quot; height=&quot;1330&quot; data-filename=&quot;스크린샷 2026-06-25 오후 4.55.56.png&quot; data-origin-width=&quot;2868&quot; data-origin-height=&quot;1330&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 임계구역은 java의 &lt;b&gt;synchronized &lt;/b&gt;사용한다 락 종류에는 스핀락, 뮤텍스(Mutex)와 세마포어(Semaphore) 기반의 블로킹이 있다 &lt;b&gt;synchronized는&amp;nbsp;&lt;/b&gt;뮤텍스 기법과, 스핀락 기법을 같이 사용하는것을 채택하고 있다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;경쟁이 아주 잠깐 발생한다고 하면 스레드의 정보가 OS 대기실로 가고 깨어나는 오버헤드가 더 크기 때문에 임계구역 앞에서 아주 잠깐 스핀을 돌며 빠르게 키를 잡기 위함이고, 뮤텍스 기법 채택 이유는 스핀락은 키를 잡기까지 계속 루프를 돌기 때문에 불필요한 CPU 사용이 있다 이때 경쟁이 치열할 경우 스레드 내부 코드를 처리해야 하는 CPU가 스핀락의 루프까지 돌아야 하기 때문에 자바가 자원을 보호하려고 스레드를 OS 커널 뮤텍스 대기실로 보내 완전히 잠재워 버립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;아래 코드는 JAVA 의 synchronized 을 구현하고 있는 C++ 코드이다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1782407179669&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;bool ObjectSynchronizer::fast_lock_spin_enter(oop obj, LockStack&amp;amp; lock_stack, JavaThread* current, bool observed_deflation) {
  assert(UseObjectMonitorTable, &quot;must be&quot;);
  // Will spin with exponential backoff with an accumulative O(2^spin_limit) spins.
  const int log_spin_limit = os::is_MP() ? FastLockingSpins : 1;
  const int log_min_safepoint_check_interval = 10;

  markWord mark = obj-&amp;gt;mark();
  const auto should_spin = [&amp;amp;]() {
    if (!mark.has_monitor()) {
      // Spin while not inflated.
      return true;
    } else if (observed_deflation) {
      // Spin while monitor is being deflated.
      ObjectMonitor* monitor = ObjectSynchronizer::read_monitor(obj, mark);
      return monitor == nullptr || monitor-&amp;gt;is_being_async_deflated();
    }
    // Else stop spinning.
    return false;
  };
  // Always attempt to lock once even when safepoint synchronizing.
  bool should_process = false;
  for (int i = 0; should_spin() &amp;amp;&amp;amp; !should_process &amp;amp;&amp;amp; i &amp;lt; log_spin_limit; i++) {
    // Spin with exponential backoff.
    const int total_spin_count = 1 &amp;lt;&amp;lt; i;
    const int inner_spin_count = MIN2(1 &amp;lt;&amp;lt; log_min_safepoint_check_interval, total_spin_count);
    const int outer_spin_count = total_spin_count / inner_spin_count;
    for (int outer = 0; outer &amp;lt; outer_spin_count; outer++) {
      should_process = SafepointMechanism::should_process(current);
      if (should_process) {
        // Stop spinning for safepoint.
        break;
      }
      for (int inner = 1; inner &amp;lt; inner_spin_count; inner++) {
        SpinPause();
      }
    }

    if (fast_lock_try_enter(obj, lock_stack, current)) return true;
  }
  return false;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음으로&amp;nbsp;&lt;span style=&quot;color: #000000;&quot; data-token-index=&quot;0&quot;&gt;jstack &lt;/span&gt;덤프로 추출 한 결과이다 위 VisalVM으로 분석한것과 같이 &lt;b&gt;ava.lang.Thread.State: BLOCKED 처리 되어있는걸 알 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1782408119431&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 사용 명령어 
jstack &amp;lt;실행중인 서버 pid&amp;gt; &amp;gt; &amp;lt;저장 파일 명&amp;gt;.txt&lt;/code&gt;&lt;/pre&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;...
&quot;Thread-Per-Connection-Chat-3052&quot; #3052 [1741059] prio=5 os_prio=31 cpu=0.10ms elapsed=1124.67s tid=0x00000008bfc74000 nid=1741059 waiting for monitor entry  [0x00000004767ae000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at com.example.aisocket.week2.benchmark.ChatLockManager.processAiQuery(ChatLockManager.java:6)
    - waiting to lock &amp;lt;0x00000005a8b415c8&amp;gt; (a java.lang.Class for com.example.aisocket.week2.benchmark.ChatLockManager)
    at com.example.aisocket.week2.benchmark.ClientHandler.run(ClientHandler.java:19)
    at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596)
    at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583)

&quot;Thread-Per-Connection-Chat-3053&quot; #3053 [1740547] prio=5 os_prio=31 cpu=0.12ms elapsed=1124.67s tid=0x00000008bfc74700 nid=1740547 waiting for monitor entry  [0x00000004769ba000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at com.example.aisocket.week2.benchmark.ChatLockManager.processAiQuery(ChatLockManager.java:6)
    - waiting to lock &amp;lt;0x00000005a8b415c8&amp;gt; (a java.lang.Class for com.example.aisocket.week2.benchmark.ChatLockManager)
    at com.example.aisocket.week2.benchmark.ClientHandler.run(ClientHandler.java:19)
    at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596)
    at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583)

&quot;Thread-Per-Connection-Chat-3054&quot; #3054 [1577219] prio=5 os_prio=31 cpu=0.11ms elapsed=1124.67s tid=0x00000008bfc74e00 nid=1577219 waiting for monitor entry  [0x0000000476bc6000]
   java.lang.Thread.State: BLOCKED (on object monitor)
    at com.example.aisocket.week2.benchmark.ChatLockManager.processAiQuery(ChatLockManager.java:6)
    - waiting to lock &amp;lt;0x00000005a8b415c8&amp;gt; (a java.lang.Class for com.example.aisocket.week2.benchmark.ChatLockManager)
    at com.example.aisocket.week2.benchmark.ClientHandler.run(ClientHandler.java:19)
    at java.lang.Thread.runWith(java.base@21.0.9/Thread.java:1596)
    at java.lang.Thread.run(java.base@21.0.9/Thread.java:1583)
...&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이번 실험을 통해 Spring Boot/Tomcat을 사용할 때는 개발자가 직접 소켓 연결마다 스레드를 생성하지 않아도 되지만, 내부적으로도 요청 처리 스레드 풀, connection 수, blocking I/O, 락 대기 시간이 시스템 안정성에 큰 영향을준다는 점을 확인했다.&lt;br /&gt;&lt;br /&gt;즉, 프레임워크가 기본적인 네트워크 처리와 스레드 풀 관리를 제공하더라도, 애플리케이션 코드 내부에서 긴 I/O 작업을 &lt;b&gt;synchronized&lt;/b&gt;`임계구역 안에 넣으면 동일한 병목이 발생할 수 있다.&lt;/p&gt;</description>
      <category>java</category>
      <author>taetae-o</author>
      <guid isPermaLink="true">https://taetae-o.tistory.com/14</guid>
      <comments>https://taetae-o.tistory.com/14#entry14comment</comments>
      <pubDate>Fri, 26 Jun 2026 02:35:24 +0900</pubDate>
    </item>
    <item>
      <title>고동시성 환경에서의 AI 요청 비동기 스케줄링 큐</title>
      <link>https://taetae-o.tistory.com/13</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;요구사항&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유저의 AI 질의 요청을 순서와 가중치에 따라 데드락 없이 안전하게 큐잉&lt;/li&gt;
&lt;li&gt;소모시키는 지수 백오프(Exponential Backoff) 기반 스케줄러 알고리즘 작성&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제사항&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;멀티스레드 환경(유저 수천 명)에서 AI 질의 요청이 쏟아진다&lt;/li&gt;
&lt;li&gt;외부 AI API(예: OpenAI, Claude)는 분당 요청 제한(&lt;b data-index-in-node=&quot;46&quot; data-path-to-node=&quot;4,1,0&quot;&gt;Rate Limit&lt;/b&gt;)이 걸려 있다&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트와 서버의 &lt;b&gt;3-way-hanshake&lt;/b&gt;을 통해 세션을 맺으면 생성되는 소켓을 &lt;b&gt;Poller events&lt;/b&gt; 큐에 넣게 되면 요청에 대한 적절한 처리를 하게 되고 워커 스레드인 &lt;b&gt;http-nio-8080-exec-X&lt;/b&gt; 스레드를 통해 Spring Boot의 DispatcherServlet에 접근을 하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 적절한 컨트롤러를 찾아서 처리를 할텐데 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;b&gt;http-nio-8080-exec-X&lt;/b&gt;&lt;span&gt; 이 스레드가 AI 요청와 응답을 기다리는 작업을 하게되면 이 스레드는 &lt;b&gt;대기 상태(Waiting)&lt;/b&gt;이&amp;nbsp;된다, 이렇게 되면 스레드는 한정 되어있기 때문에 이런 사용자의 요청이 많이 들어오게 되면 요청을 처리할 수 없게 된다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;그렇기 때문에 &lt;b&gt;http-nio-8080-exec-X&lt;/b&gt; 이 스레드를 빨리 놔 주어야 한다, 이때 실행 작업을 이벤드 큐에 넣는 방식으로 사용하게 된다면 즉 비동기 식으로 &lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;b&gt;Acceptor의 스레드&lt;/b&gt;&lt;b&gt;(http-nio-8080-Acceptor)&lt;/b&gt;가 소켓을 감지한후 처리를 하고 &lt;b&gt;Poller&lt;/b&gt;의 &lt;b&gt;events&lt;/b&gt; &lt;b&gt;큐&lt;/b&gt;에 작업을 던지는 식으로 하게 되면 &lt;b&gt;Poller의 스레드&lt;/b&gt;&lt;b&gt;(http-nio-8080-Poller)&lt;/b&gt;가 이후 처리를 하듯이 사용자가 AI 질문 요청을 할때도 마찬가지로 이런 방식을 사용하게 되면 작업은 이벤트 큐에 쌓이게 되고 병렬로 처리가 가능하게 될것이다 &lt;b&gt;(생성된 AI 응답 스레드 수 만큼 병렬 처리 가능)&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 드는 의문은 &lt;b&gt;http-nio-8080-exec-X&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt; 이 스레드에서 사용자의 요청을 이벤트 큐에 넣고 마무리 된다면 사용자는 응답을 받을 수 없을것이다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;이것의 해결 방법은 컨트롤러 내부에서 유저에게 바로 응답을 주는 대신, &lt;b data-index-in-node=&quot;29&quot; data-path-to-node=&quot;9,0,0&quot;&gt;&quot;나중에 AI 답변이 오면 여기 가방에 담아서 응답해 줘&quot;&lt;/b&gt; 하고 약속하는 가상 객체(&lt;b data-index-in-node=&quot;80&quot; data-path-to-node=&quot;9,0,0&quot;&gt;DeferredResult&lt;/b&gt; 또는 &lt;b data-index-in-node=&quot;98&quot; data-path-to-node=&quot;9,0,0&quot;&gt;CompletableFuture&lt;/b&gt;)를 하나 생성한 후 이것을 이벤트 큐에 담는 방식이다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;이렇게 되면 &lt;b&gt;http-nio-8080-exec-X 이 스레드는 유저에게 답변을 주지 않은 상태로, 톰캣 엔진에게 &quot;나 이 요청 비동기(Async)로 전환했으니까 소켓 커넥션만 끊지 말고 끊어지지 않게 킵 해줘!&quot;라고 말한 뒤, 톰캣 스레드 풀로 즉시 복귀하게 된다.&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;이후 톰캣 풀과 완전히 격리된 우리만의 &lt;b data-index-in-node=&quot;19&quot; data-path-to-node=&quot;15,0,0&quot;&gt;AI-Scheduler-Consumer 스레드&lt;/b&gt;가 '질문 요청 큐'에서 일감을 하나씩 꺼내 AI의 응답을 받아오게 되면 &lt;b&gt;deferredResult.setResult(답변) 이 순간, 스프링이 가방에 데이터가 들어온 것을 감지하고 톰캣에게 컨텍스트를 다시 이관(Dispatch)하면, 톰캣 풀에서 마침 놀고 있던 스레드를 깨워 &lt;/b&gt;&lt;/span&gt;&lt;/span&gt;깨어난 워커 스레드가 아까 킵해두었던 유저의 소켓을 쥐고, 가방에 담긴 AI 답변을 실어서 브라우저로 &lt;b data-index-in-node=&quot;69&quot; data-path-to-node=&quot;17,3,0&quot;&gt;최종 HTTP 응답을 주고 완전히 상황을 마감한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[1단계: 인프라 아키텍처 관점] &lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(톰캣 커널 엔진) 어떻게 하드웨어 레벨의 날것의 패킷 트래픽을 서버 마비 없이 대량으로 접수하고 격리할 것인가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[2단계: 실전 서비스 설계 관점]&lt;/b&gt; (AI 스케줄링 파이프라인) 그렇게 인입된 요청 중에서, 특정 비즈니스 제약(AI Rate Limit)과 서비스 규칙(유저 가중치)을 녹여서 실제 가치(Value)로 어떻게 풀어낼 것인가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에 대한 문제라고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;데드락이란&amp;nbsp;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;멀티 스레드 환경에서 서로 상대방이 필요한 자원을 쥐고 다른 자원을 요구하는 것 (교착상태)&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;발생 원인 : 이 4가지 조건이 모두 성립 하면 발생&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,0,0&quot;&gt;상호 배제 (Mutual Exclusion)&lt;/b&gt;: 자원은 한 번에 한 스레만 가질 수 있다. (포크는 한 명만 쥘 수 있음)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,1,0&quot;&gt;점유와 대기 (Hold and Wait)&lt;/b&gt;: 자원을 하나 쥔 상태에서 다른 자원을 기다린다. (포크A를 쥔 채로 포크B를 요구함)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,2,0&quot;&gt;비선점 (No Preemption)&lt;/b&gt;: 다른 스레드가 쥔 자원을 강제로 뺏어올 수 없다. (상대방 손에 있는 포크를 강탈 못 함)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;18,3,0&quot;&gt;순환 대기 (Circular Wait)&lt;/b&gt;: 대기하는 주체들이 원형으로 꼬여있다. (스레드 1 ➡️ 스레드 2 ➡️ 스레드 1)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 해결 방법&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상위 발생 원인을 하나만 원천 차단 하면 해결된다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 데드락이 발생할 수 있는 상황은 사용자의 요청과 서버의 응답을 내려주는 스레드는 &lt;b&gt;http-nio-8080-exec-X &lt;/b&gt;스레드인데 사용자의 AI 요청을 이벤트 큐에 넣고 &lt;b&gt;AI-Scheduler-Consumer&lt;/b&gt; 스레드가 처리하는 AI의 답변까지 기다리는 상황이 생기면 &lt;b&gt;http-nio-8080-exec-X 이 스레드는 대기 상태에 걸리게 되고 AI 답변을 받아와서 톰캣에게 답변 받았으니 사용자한테 응답을 해 주어야 하는 상황에서 그걸 처리 할 http-nio-8080-exec-X&lt;span&gt; 스레드가 없어 스레드풀 데드락이 걸리는 상황이다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 style=&quot;text-align: center;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;u&gt;&lt;b&gt;구현 구조&lt;/b&gt;&lt;/u&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-24 오후 4.15.55.png&quot; data-origin-width=&quot;2160&quot; data-origin-height=&quot;2232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPB8hE/dJMcafmO3Tk/vuKlPL0knHvzna0RLumk11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPB8hE/dJMcafmO3Tk/vuKlPL0knHvzna0RLumk11/img.png&quot; data-alt=&quot;미션 1 : 구현 구조&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPB8hE/dJMcafmO3Tk/vuKlPL0knHvzna0RLumk11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPB8hE%2FdJMcafmO3Tk%2FvuKlPL0knHvzna0RLumk11%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2160&quot; height=&quot;2232&quot; data-filename=&quot;스크린샷 2026-06-24 오후 4.15.55.png&quot; data-origin-width=&quot;2160&quot; data-origin-height=&quot;2232&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;미션 1 : 구현 구조&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1차 구현&lt;/b&gt;&lt;/h4&gt;
&lt;h4 data-path-to-node=&quot;5&quot; data-ke-size=&quot;size20&quot;&gt;1. AIRequestTask.java (일감 데이터 가방)&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;6&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,0,0&quot;&gt;책임&lt;/b&gt;: 큐에 들어갈 일품 세트입니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,1,0&quot;&gt;핵심 필드&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;6,1,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;유저의 질문 내용 (prompt)&lt;/li&gt;
&lt;li&gt;유저의 가중치 등급 (UserGrade)&lt;/li&gt;
&lt;li&gt;요청 시간 (requestedTime - 가중치가 같을 때 선착순 정렬용)&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,1,1,3,0&quot;&gt;CompletableFuture&amp;lt;String&amp;gt; responseFuture&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,2,0&quot;&gt;특이사항&lt;/b&gt;: PriorityBlockingQueue가 스스로 순서를 정렬할 수 있도록 자바의 &lt;b data-index-in-node=&quot;51&quot; data-path-to-node=&quot;6,2,0&quot;&gt;Comparable&amp;lt;AIRequestTask&amp;gt;&lt;/b&gt; 인터페이스를 &lt;br /&gt;구현.&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-path-to-node=&quot;7&quot; data-ke-size=&quot;size20&quot;&gt;2. AIController.java (톰캣 스레드 해방 창구)&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;8&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,0,0&quot;&gt;책임&lt;/b&gt;: 유저의 HTTP 요청을 가장 먼저 받아서 큐에 넣는 엔드 포인트.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,1,0&quot;&gt;핵심 로직&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;8,1,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청이 들어오면 빈 가방(CompletableFuture)을 포함한 AIRequestTask를 생성합니다.&lt;/li&gt;
&lt;li&gt;스케줄러의 이벤트 큐에 이 태스크를 툭 집어넣습니다.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,1,1,2,0&quot;&gt;exec-X 스레드는 가방(CompletableFuture)만 톰캣 엔진에 리턴하면서 즉시 반환.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-path-to-node=&quot;9&quot; data-ke-size=&quot;size20&quot;&gt;3. AIScheduler.java (이벤트 큐 보유 및 지수 백오프 컨슈머 엔진)&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;10&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0,0&quot;&gt;책임&lt;/b&gt;: 이벤트 큐를 필드로 가지는 클래스이자, 백그라운드 스레드들을 관리.&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,1,0&quot;&gt;핵심 필드&lt;/b&gt;: PriorityBlockingQueue&amp;lt;AIRequestTask&amp;gt; eventQueue&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,2,0&quot;&gt;핵심 로직&lt;/b&gt;:
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;10,2,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;클래스가 초기화될 때 &lt;b data-index-in-node=&quot;12&quot; data-path-to-node=&quot;10,2,1,0,0&quot;&gt;AI-Scheduler-Consumer 백그라운드 스레드 3개&lt;/b&gt;를 생성하고 구동(while(true))&lt;/li&gt;
&lt;li&gt;이 컨슈머 스레드들이 큐에서 가중치 순으로 일감을 하나씩 꺼내 외부 API를 호출합니다.&lt;/li&gt;
&lt;li&gt;에러(Rate Limit) 발생 시 &lt;b data-index-in-node=&quot;20&quot; data-path-to-node=&quot;10,2,1,2,0&quot;&gt;지수 백오프 알고리즘&lt;/b&gt;을 돌리며 현재 스레드만 잠시 슬립(Thread.sleep)시킵니다.&lt;/li&gt;
&lt;li&gt;성공 시 task.getResponseFuture().complete(답변)을 호출하여 스프링 관제탑을 통해 &lt;b data-index-in-node=&quot;61&quot; data-path-to-node=&quot;10,2,1,3,0&quot;&gt;톰캣 유휴 스레드(exec-Y)를 깨워 응답을 마무리&lt;/b&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 style=&quot;text-align: center;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;디버깅&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;외부 AI 호출 기능은 임시로 구현 했기 때문에 Thread.sleep(10000); 를 사용해 외부 AI 요청 시간을 10초 준다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. Postman을 통해 AI 질문 API 호출&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-24 오후 5.56.55.png&quot; data-origin-width=&quot;1958&quot; data-origin-height=&quot;1294&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cnJ6xV/dJMcahEQ2If/GvHmtmnJxw4mk0U1VZsfQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cnJ6xV/dJMcahEQ2If/GvHmtmnJxw4mk0U1VZsfQk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cnJ6xV/dJMcahEQ2If/GvHmtmnJxw4mk0U1VZsfQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcnJ6xV%2FdJMcahEQ2If%2FGvHmtmnJxw4mk0U1VZsfQk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;736&quot; height=&quot;486&quot; data-filename=&quot;스크린샷 2026-06-24 오후 5.56.55.png&quot; data-origin-width=&quot;1958&quot; data-origin-height=&quot;1294&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. Tomcat의 &lt;b&gt;http-nio-8080-exec-9&lt;/b&gt; 스레드가 4번의 코드로 작업을 큐에 삽입 후 5번 코드를 실행 이때&lt;br /&gt;&lt;b&gt;AI-Scheduler-Consume&lt;/b&gt; 스레드가 큐에있는 작업을 잡아 실행 &lt;b&gt;(두 스레드 모두 RUNNING)&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-24 오후 6.00.02.png&quot; data-origin-width=&quot;3504&quot; data-origin-height=&quot;2040&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Hd4dJ/dJMcahSnv2f/qmRLVo5CD76g3GBNA5Ux01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Hd4dJ/dJMcahSnv2f/qmRLVo5CD76g3GBNA5Ux01/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Hd4dJ/dJMcahSnv2f/qmRLVo5CD76g3GBNA5Ux01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHd4dJ%2FdJMcahSnv2f%2FqmRLVo5CD76g3GBNA5Ux01%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;857&quot; height=&quot;499&quot; data-filename=&quot;스크린샷 2026-06-24 오후 6.00.02.png&quot; data-origin-width=&quot;3504&quot; data-origin-height=&quot;2040&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 5번 코드를 실행 후 &lt;b&gt;http-nio-8080-exec-9가 반환 돼 WAIT 상태로 들어간것을 확인&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-24 오후 6.05.27.png&quot; data-origin-width=&quot;1956&quot; data-origin-height=&quot;1464&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2s5bX/dJMcab5KKJ3/6C5hh3oLdP4XMDtlG7OTC1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2s5bX/dJMcab5KKJ3/6C5hh3oLdP4XMDtlG7OTC1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2s5bX/dJMcab5KKJ3/6C5hh3oLdP4XMDtlG7OTC1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2s5bX%2FdJMcab5KKJ3%2F6C5hh3oLdP4XMDtlG7OTC1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1956&quot; height=&quot;1464&quot; data-filename=&quot;스크린샷 2026-06-24 오후 6.05.27.png&quot; data-origin-width=&quot;1956&quot; data-origin-height=&quot;1464&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. AI의 응답이 10초 있다 전송, 사용자한테 응답을 해 주기위한 &lt;b&gt;http-nio-8080-exec-9 스레드 실행&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-24 오후 6.07.48.png&quot; data-origin-width=&quot;1964&quot; data-origin-height=&quot;1434&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZ1p0U/dJMcadvDuWT/8XVnsz1kWeahahzSyF8NFk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZ1p0U/dJMcadvDuWT/8XVnsz1kWeahahzSyF8NFk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZ1p0U/dJMcadvDuWT/8XVnsz1kWeahahzSyF8NFk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZ1p0U%2FdJMcadvDuWT%2F8XVnsz1kWeahahzSyF8NFk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1964&quot; height=&quot;1434&quot; data-filename=&quot;스크린샷 2026-06-24 오후 6.07.48.png&quot; data-origin-width=&quot;1964&quot; data-origin-height=&quot;1434&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 실행된 톰켓 워커 스레드를 통해 세션이 끊기지 않고 응답 전송&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-24 오후 6.09.05.png&quot; data-origin-width=&quot;1974&quot; data-origin-height=&quot;1354&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/v00ir/dJMcagzcH3r/0wJX8Tz9rytcncNlNPN5Ik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/v00ir/dJMcagzcH3r/0wJX8Tz9rytcncNlNPN5Ik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/v00ir/dJMcagzcH3r/0wJX8Tz9rytcncNlNPN5Ik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fv00ir%2FdJMcagzcH3r%2F0wJX8Tz9rytcncNlNPN5Ik%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1974&quot; height=&quot;1354&quot; data-filename=&quot;스크린샷 2026-06-24 오후 6.09.05.png&quot; data-origin-width=&quot;1974&quot; data-origin-height=&quot;1354&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;4&quot;&gt;이렇게 비동기 시, 스레드의 활성 상태를 보고&lt;/b&gt;&lt;span&gt; 톰캣 커널 엔진과 서블릿 컨테이너가 하부 하드웨어 레벨에서 어떻게 돌아가는지 확인 할 수 있었다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>ai</category>
      <category>HTTP</category>
      <category>java</category>
      <category>Spring Boot</category>
      <category>Tomcat</category>
      <author>taetae-o</author>
      <guid isPermaLink="true">https://taetae-o.tistory.com/13</guid>
      <comments>https://taetae-o.tistory.com/13#entry13comment</comments>
      <pubDate>Wed, 24 Jun 2026 18:13:26 +0900</pubDate>
    </item>
    <item>
      <title>3-Way Handshake 부터 Tomcat을 지나 스프링 부트 시작점 까지</title>
      <link>https://taetae-o.tistory.com/12</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 서버와 세션을 맺기 위해서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;3-Way Handshake&lt;/b&gt; 진행 후 세션을 맺는다 이때 Socket 생성을 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;톰켓은 생성된 소켓을 감지하는&lt;b&gt; Acceptor 클래스가 있다 Runnable 인터페이스를 상속받고 있어 스레드 생성이 가능하다&lt;/b&gt;. &lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Acceptor 스레드가 생성된 Socket의 주소를 serverSocketAccept() 매서드를 통해 내부 객체로 변환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때 2개의 구현체가 있는데 각각 반환 하는 값이 다르고 동작이 추가된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;NioEndpoint&lt;/b&gt;는 SocketChannel 반환후 setSocketOptions(..) 실행, 이 메서드 내부에서 NioSocketWrapper로 감싸지게 된 후 poller.register(...) 메서드를 통해 PollerEvent 객체가 Poller 스레드가 관리하는 SynchronizedQueue에 들어가게 된다 즉 Selector(멀티플렉서)라는 주문 벨 시스템에 등록 하는것 이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Nio2Endpoint&lt;/b&gt;는 AsynchronousSocketChannel 반환 자바 힙 영역에 ByteBuffer를 하나 만든 뒤, Nio2SocketWrapper로 감싸서&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;processSocket(..) 메서드를 호출 해&amp;nbsp; &lt;b data-index-in-node=&quot;180&quot; data-path-to-node=&quot;11,1,0&quot;&gt;소켓 주소, 빈 바구니, 작업 완료 후 실행할 콜백 함수(CompletionHandler)를 OS 커널에게 통째로 넘긴다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;이렇게 까지 Acceptor 스레드를 통해 세션 연결된 소켓을 내부적으로 등록하고 비동기 처리를 진행하기 전 까지의 작업이 완료된다.&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;180&quot; data-path-to-node=&quot;11,1,0&quot;&gt;여기서 차이점은 연결된 세션에서 패킷을 받을때 차이점이 있다. 먼저 내부적으로 톰켓은 연결된 세션 (소켓)을 어떤식으로 처리 하는지 자세히 알아보자&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;b&gt;&lt;b&gt;Acceptor 가&amp;nbsp;&lt;/b&gt;NioEndpoint를 사용하는 경우 (실제 Spring Boot 프로젝트로 &lt;b&gt;&lt;b&gt;Acceptor 클래스를 찾아 따라가 보면 좋다&lt;/b&gt;&lt;/b&gt;)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로그렘 준비 시점에 생성된&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;8,0,0&quot;&gt; http-nio-8080-Acceptor&lt;/b&gt; 스레드가 &lt;b&gt;endpoint.serverSocketAccept(...);&lt;/b&gt; 를 호출해 커널에 대기 중이던 소켓을 자바 영역으로 가져온다. 메서드를 타고 들어가면 &lt;b&gt;serverSock.accept(...);&lt;/b&gt; 를 호출 해 &lt;b&gt;SocketChannel &lt;/b&gt;객체를 반환을 한다 이 객체는 추상 클래스로 내부에는 &lt;b&gt;SocketChannelImpl &lt;/b&gt;객체가 들어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 &lt;b&gt;if(!endpoint.setSocketOptions(socket));&lt;/b&gt; 호출로 &lt;b&gt;NioSocketWrapper &lt;/b&gt;로 감싸 &lt;b&gt;poller.register(socketWrapper);&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;를 호출 해 마킹과, &lt;b&gt;PollerEvent &lt;/b&gt;객체를 생성 &lt;b&gt;addEvent()&lt;/b&gt; 메서드를 통해 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;events &lt;/b&gt;(Poller&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;객체의 필드이다) &lt;/span&gt;&lt;b&gt;SynchronizedQueue &lt;/b&gt;에 들어가게 된다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Poller&lt;/b&gt;도 &lt;b&gt;http-nio-8080-Poller&lt;/b&gt;&amp;nbsp;이름을 가진 스레드를 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;프로그렘 준비 시점에&lt;span&gt; 생성한다. 즉 &lt;b&gt;&lt;b&gt;Acceptor가&amp;nbsp;&amp;nbsp;&lt;b&gt;Poller의 &lt;b&gt;events 필드에 값을 넣어 두면 &lt;b&gt;Poller의 스레드가 알아서 이걸 작업 하는 형태로 간단히 생각 하면 된다.&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt; 조금 자세히 말하자면 &lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;Poller 스레드는 계속 깨어 있는것이 아니기 때문에 &lt;b&gt;&lt;b&gt;Acceptor가 이벤트를 추가 할때&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;private void addEvent(PollerEvent event) {
            events.offer(event);
            if (wakeupCounter.incrementAndGet() == 0) {
                selector.wakeup();
            }
        }&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;if문을 보면 어떤 숫자를 검사를 하게 된다 &lt;b&gt;wakeupCounter 필드는 AtomicLong 타입으로 공유 메모리에 위치해 있는 변수이기 때문에 사용한다 원자적인 값 증가, 감소가 가능하다.&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;selector.wakeup(); 이라는 매서드를 호출 하는데 이것은 내부적으로 시스템 콜을 하기 때문에 커널 영역까지 들어가는 작업을 하게 된다. 이런 무거운 작업을 하지 않도록 이미 스레드가 활성화 되어 있는 경우 (0이 아닌 경우) 는 깨우지 말고 이벤트 큐에만 삽입 하도록 하는 코드이다.&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;이렇게 &lt;b&gt;Poller&lt;/b&gt;의 &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;이벤트 큐에 들어가게 되면&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;http-nio-8080-Poller&lt;/b&gt;&lt;b&gt;&amp;nbsp;스레드가 작업을 시작 한다.&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;b&gt; run() 메서드를 보면 초반에 이벤트 큐를 관리하는 events()&lt;b&gt; 메서드를 호출 하는걸 알 수 있는데&lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;public boolean events() {
    boolean result = false;

    PollerEvent pe;
    for (int i = 0, size = events.size(); i &amp;lt; size &amp;amp;&amp;amp; (pe = events.poll()) != null; i++) {
        result = true;
        NioSocketWrapper socketWrapper = pe.getSocketWrapper();
        SocketChannel sc = socketWrapper.getSocket().getIOChannel();
        int interestOps = pe.getInterestOps();
        if (sc == null) {
            if (log.isDebugEnabled()) {
                log.debug(sm.getString(&quot;endpoint.nio.nullSocketChannel&quot;));
            }
            socketWrapper.close();
        } else if (interestOps == OP_REGISTER) {
            try {
                sc.register(getSelector(), SelectionKey.OP_READ, socketWrapper);
            } catch (Exception e) {
                log.error(sm.getString(&quot;endpoint.nio.registerFail&quot;), e);
            }
        } else {
            final SelectionKey key = sc.keyFor(getSelector());
            if (key == null) {
                // The key was cancelled (e.g. due to socket closure)
                // and removed from the selector while it was being
                // processed. Count down the connections at this point
                // since it won't have been counted down when the socket
                // closed.
                socketWrapper.close();
            } else {
                final NioSocketWrapper attachment = (NioSocketWrapper) key.attachment();
                if (attachment != null) {
                    // We are registering the key to start with, reset the fairness counter.
                    try {
                        int ops = key.interestOps() | interestOps;
                        attachment.interestOps(ops);
                        key.interestOps(ops);
                    } catch (CancelledKeyException ckx) {
                        socketWrapper.close();
                    }
                } else {
                    socketWrapper.close();
                }
            }
        }
        if (running &amp;amp;&amp;amp; eventCache != null) {
            pe.reset();
            eventCache.push(pe);
        }
    }

    return result;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;b&gt;이 메서드를 통해 events 필드를 순회 하면서 담긴 PollerEvent의 필드인 interestOps 상태가 OP_REGISTER 인 경우 sc.register(getSelector(), SelectionKey.OP_READ, socketWrapper); 메서드를 통해 SelectionKey 객체를&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;b&gt;Poller의 Selector 에 담는다.&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;b&gt;다시 Poller의 run() 메서드로 돌아와서 하단 코드를 보면 이런 코드가 있다.&amp;nbsp;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;armasm&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;Iterator&amp;lt;SelectionKey&amp;gt; iterator = keyCount &amp;gt; 0 ? selector.selectedKeys().iterator() : null;
                // Walk through the collection of ready keys and dispatch
                // any active event.
                while (iterator != null &amp;amp;&amp;amp; iterator.hasNext()) {
                    SelectionKey sk = iterator.next();
                    iterator.remove();
                    NioSocketWrapper socketWrapper = (NioSocketWrapper) sk.attachment();
                    // Attachment may be null if another thread has called
                    // cancelledKey()
                    if (socketWrapper != null) {
                        processKey(sk, socketWrapper);
                    }
                }
...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;b&gt;&lt;b&gt;&lt;b&gt;코드에서 selector의 키를 가져오고 있다&lt;/b&gt;&lt;/b&gt;&amp;nbsp;여기서 &lt;b&gt;Poller 클래스의 필드인&lt;span&gt;&amp;nbsp;&lt;/span&gt;selector 이건 뭐냐면 &lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&lt;b&gt;&lt;b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;Selector 추상클래스로&lt;span&gt;&amp;nbsp;&lt;/span&gt;자바 Non-Blocking I/O의 핵심이다, 소캣을 감시 해야 하는데 OS마다 엔진이 다르기 때문에 추상화 한것이고 OS에 맞춰 적절한 구현체가 끼워지게 된다. 간단히 &lt;b&gt;&lt;b&gt;Selector 하나로 생성된 모든 소켓을 관리 할수 있다.&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;요약 하면 Acceptor가&amp;nbsp;&amp;nbsp;Poller의 events 큐에 값을 넣어 두면 &lt;span&gt;Poller의 &lt;/span&gt;&lt;span&gt;http-nio-8080-Poller 스레드가 이미 일어나 있거나, 깨워지게 되고, events() 메서드를 실행 해 sc.register() 호출 함으로 Poller의 소켓 감시자인 Selector 필드 내부에&amp;nbsp; SelectionKey 를&amp;nbsp;추가 하게 된다.&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 후 다운 케스팅을 통해 구현체 객체로 변경 한 후 &lt;b&gt;processKey(...)&lt;/b&gt; 메서드를 호출 한다. 이때 내부에서 &lt;b&gt;SelectionKey, NioSocketWrapper 객체를 통해 &lt;/b&gt;이 소켓이 지금 데이터를 읽을 수 있는 상태(isReadable())인지, 아니면 데이터를 네트워크 랜카드로 내보낼 수 있는 상태(isWritable())인지 판정을 내려 적절한 분기를 타고&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;processSocket(socketWrapper, SocketEvent.OPEN_READ, true) 메서드를 실행 시킨다.&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public boolean processSocket(SocketWrapperBase&amp;lt;S&amp;gt; socketWrapper, SocketEvent event, boolean dispatch) {
    try {
        if (socketWrapper == null) {
            return false;
        }
        SocketProcessorBase&amp;lt;S&amp;gt; sc = null;
        if (processorCache != null) {
            sc = processorCache.pop();
        }
        if (sc == null) {
            sc = createSocketProcessor(socketWrapper, event);
        } else {
            sc.reset(socketWrapper, event);
        }
        Executor executor = getExecutor();
        if (dispatch &amp;amp;&amp;amp; executor != null) {
            executor.execute(sc);
        } else {
            sc.run();
        }
...&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 분기를 보면 Tomcat이 메모리 관리를 세세히 하고 있다는것을 알 수 있다. &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;SocketProcessorBase&amp;lt;S&amp;gt; sc = null; 여기에 들어가는 객체를 계속 &lt;b&gt;createSocketProcessor(socketWrapper, event)&lt;/b&gt; 이걸로 생성하는게 아닌 이미 생성 되어있는게 있으면 sc에 할당을 하고 &lt;b&gt;reset(...) &lt;/b&gt;메서드를 통해 이미 생성되어있는 객체에서 값만 바꾸는 동작을 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;sc에는 SocketProcessor 객체가 들어간다. &lt;b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;SocketProcessor 는 SocketProcessorBase&amp;lt;NioChannel&amp;gt; 를 상속 받고 이게&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Runnable&lt;/b&gt;를 구현하고 &lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/b&gt;&lt;b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;있어&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;b&gt;SocketProcessorBase&amp;lt;S&amp;gt; sc&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 변수에 들어갈수 있었고&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;.execute(Runnable r)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;이 메서드에 인자로 넣어줄 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;이후 하단에서 &lt;b&gt;executor.execute(sc); 메서드를 통해&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;java.util.concurrent.ThreadPoolExecutor&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;구현체를 호출 한다. 구현체의 &lt;b&gt;execute() 메서드를 확인해 보자&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public void execute(Runnable command) {
    if (command == null)
        throw new NullPointerException();
    int c = ctl.get();
    if (workerCountOf(c) &amp;lt; corePoolSize) {
        if (addWorker(command, true))
            return;
        c = ctl.get();
    }
    if (isRunning(c) &amp;amp;&amp;amp; workQueue.offer(command)) {
        int recheck = ctl.get();
        if (! isRunning(recheck) &amp;amp;&amp;amp; remove(command))
            reject(command);
        else if (workerCountOf(recheck) == 0)
            addWorker(null, false);
    }
    else if (!addWorker(command, false))
        reject(command);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tomcat의 스레드 풀 방식은 자바 표준의 스레드 풀 방식과는 다른점이 있다, &lt;b&gt;자바 표준 스레드 풀은 &lt;/b&gt;스레드 생성을 최대한 아끼고 대기 큐에 줄 부터 세우는 방식으로 큐가 전부 차면 그때 스레드를 추가하는 방식이고&lt;b&gt;, 톰캣 커스텀 스레드 풀은 &lt;/b&gt;네트워크 요청을 빠르게 처리하기 위해 대기큐에 두지 말고 최대 스레드 풀 개수만큼 추가해 요청을 빠르게 처리하는 방식이다&lt;b&gt;.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드의 분기를 간략이 설명 하자면&amp;nbsp;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;2번째 if 문 : 요청이 들어오면 기본 스레드 풀로 스레드를 생성해 처리한다&lt;/li&gt;
&lt;li&gt;3번째 if 문 : 기본 스레드가 전부 채워졌다면 workQueue.offer(command) 시점에 false를 반환&lt;/li&gt;
&lt;li&gt;else if 문 : addWorker(command, false) 으로 최대 풀 사이즈까지 워커 스레드를 생성한다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스레드를 생성하는 방식은 &lt;b&gt;addWorker(...) 메서드를 살펴보자 나중에 ...&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이렇게 addWorker(...) &lt;/b&gt;내부에서는 &lt;b&gt;Worker&lt;/b&gt; 객체로 감싸지게 된다 이 이유는&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;private boolean addWorker(Runnable firstTask, boolean core) {
    retry:
    for (int c = ctl.get();;) {
        // Check if queue empty only if necessary.
        if (runStateAtLeast(c, SHUTDOWN) &amp;amp;&amp;amp;
                (runStateAtLeast(c, STOP) || firstTask != null || workQueue.isEmpty())) {
            return false;
        }

        for (;;) {
            if (workerCountOf(c) &amp;gt;= ((core ? corePoolSize : maximumPoolSize) &amp;amp; COUNT_MASK)) {
                return false;
            }
            if (compareAndIncrementWorkerCount(c)) {
                break retry;
            }
            c = ctl.get(); // Re-read ctl
            if (runStateAtLeast(c, SHUTDOWN)) {
                continue retry;
                // else CAS failed due to workerCount change; retry inner loop
            }
        }
    }

    boolean workerStarted = false;
    boolean workerAdded = false;
    Worker w = null;
    try {
        w = new Worker(firstTask);
        final Thread t = w.thread;
        if (t != null) {
            final ReentrantLock mainLock = this.mainLock;
            mainLock.lock();
            try {
                // Recheck while holding lock.
                // Back out on ThreadFactory failure or if
                // shut down before lock acquired.
                int c = ctl.get();

                if (isRunning(c) || (runStateLessThan(c, STOP) &amp;amp;&amp;amp; firstTask == null)) {
                    if (t.getState() != Thread.State.NEW) {
                        throw new IllegalThreadStateException();
                    }
                    workers.add(w);
                    workerAdded = true;
                    int s = workers.size();
                    if (s &amp;gt; largestPoolSize) {
                        largestPoolSize = s;
                    }
                }
            } finally {
                mainLock.unlock();
            }
            if (workerAdded) {
                t.start();
                workerStarted = true;
            }
        }
    } finally {
        if (!workerStarted) {
            addWorkerFailed(w);
        }
    }
    return workerStarted;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 이걸 왜 감싸지 ? 라는 의문이 들었는데 이렇게 감싸는 이유는 &lt;b&gt;Thread&lt;/b&gt;는 재사용이 불가능 하기 때문에 풀에 미리 생성해 둔 &lt;b&gt;Thread 가 죽지 않게 하기 위함이다.&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;여기서도 의문 w.thread.start() 를 해도 되는거 같은데 왜 final로 Thread의 주소를 고정 시켰는지 궁금 했다 이 &lt;/b&gt;&lt;b&gt;이유는 멀티스레드 경합 상황에서 필드 변수의 주소값이 흔들리는 것을 원천 차단, 코드 내부에서 반복되는 w.thread. 문구를 제거하여 가독성 높이기 위함이라고 한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 &lt;b&gt;t.start(); 를 하게 되면 http-nio-8080-exec-1 이름 형식을 가진 스레드가 SocketProcessor를 실행 하게 되고 SocketProcessor&lt;b&gt;의 &lt;/b&gt;doRun&lt;b&gt;() 메서드를 호출 한다 &lt;/b&gt;doRun()&lt;b&gt;&lt;b&gt;&lt;b&gt;은&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public abstract class SocketProcessorBase&amp;lt;S&amp;gt; implements Runnable {

    protected SocketWrapperBase&amp;lt;S&amp;gt; socketWrapper;
    protected SocketEvent event;

    public SocketProcessorBase(SocketWrapperBase&amp;lt;S&amp;gt; socketWrapper, SocketEvent event) {
        reset(socketWrapper, event);
    }


    public void reset(SocketWrapperBase&amp;lt;S&amp;gt; socketWrapper, SocketEvent event) {
        Objects.requireNonNull(event);
        this.socketWrapper = socketWrapper;
        this.event = event;
    }


    @Override
    public final void run() {
        Lock lock = socketWrapper.getLock();
        lock.lock();
        try {
            // It is possible that processing may be triggered for read and
            // write at the same time. The lock above makes sure that processing
            // does not occur in parallel. The test below ensures that if the
            // first event to be processed results in the socket being closed,
            // the subsequent events are not processed.
            if (socketWrapper.isClosed()) {
                return;
            }
            doRun();
        } finally {
            lock.unlock();
        }
    }


    protected abstract void doRun();
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;템플릿 메서드 패턴 구조로 작성 되어있어, 내부적으로 &lt;b&gt;Runnable&lt;/b&gt;의 &lt;b&gt;run()&lt;/b&gt; 메서드를 호출하면서 정의한 &lt;b&gt;doRun()&lt;/b&gt; 을 실행 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;doRun()의 정의는 다음과 같다&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;@Override
    protected void doRun() {
        /*
         * Do not cache and re-use the value of socketWrapper.getSocket() in this method. If the socket closes the
         * value will be updated to CLOSED_NIO_CHANNEL and the previous value potentially re-used for a new
         * connection. That can result in a stale cached value which in turn can result in unintentionally closing
         * currently active connections.
         */
        Poller poller = NioEndpoint.this.poller;
        if (poller == null) {
            socketWrapper.close();
            return;
        }

        try {
            int handshake;
            try {
                if (socketWrapper.getSocket().isHandshakeComplete()) {
                    // No TLS handshaking required. Let the handler
                    // process this socket / event combination.
                    handshake = 0;
                } else if (event == SocketEvent.STOP || event == SocketEvent.DISCONNECT ||
                        event == SocketEvent.ERROR) {
                    // Unable to complete the TLS handshake. Treat it as
                    // if the handshake failed.
                    handshake = -1;
                } else {
                    handshake = socketWrapper.getSocket().handshake(event == SocketEvent.OPEN_READ,
                            event == SocketEvent.OPEN_WRITE);
                    // The handshake process reads/writes from/to the
                    // socket. status may therefore be OPEN_WRITE once
                    // the handshake completes. However, the handshake
                    // happens when the socket is opened so the status
                    // must always be OPEN_READ after it completes. It
                    // is OK to always set this as it is only used if
                    // the handshake completes.
                    event = SocketEvent.OPEN_READ;
                }
            } catch (IOException ioe) {
                handshake = -1;
                if (logHandshake.isDebugEnabled()) {
                    logHandshake.debug(sm.getString(&quot;endpoint.err.handshake&quot;, socketWrapper.getRemoteAddr(),
                            Integer.toString(socketWrapper.getRemotePort())), ioe);
                }
            } catch (CancelledKeyException ckx) {
                handshake = -1;
            }
            if (handshake == 0) {
                SocketState state;
                // Process the request from this socket
                state = getHandler().process(socketWrapper,
                        Objects.requireNonNullElse(event, SocketEvent.OPEN_READ));
                if (state == SocketState.CLOSED) {
                    socketWrapper.close();
                }
            } else if (handshake == -1) {
                getHandler().process(socketWrapper, SocketEvent.CONNECT_FAIL);
                socketWrapper.close();
            } else if (handshake == SelectionKey.OP_READ) {
                socketWrapper.registerReadInterest();
            } else if (handshake == SelectionKey.OP_WRITE) {
                socketWrapper.registerWriteInterest();
            }
        } catch (CancelledKeyException cx) {
            socketWrapper.close();
        } catch (VirtualMachineError vme) {
            ExceptionUtils.handleThrowable(vme);
        } catch (Throwable t) {
            log.error(sm.getString(&quot;endpoint.processing.fail&quot;), t);
            socketWrapper.close();
        } finally {
            socketWrapper = null;
            event = null;
            // return to cache
            if (running &amp;amp;&amp;amp; processorCache != null) {
                processorCache.push(this);
            }
        }
    }

}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 메서드를 설명하자면&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;가장 상단에서 톰캣은 혹시나 서버가 꺼지는 중이거나 비정상적인 소켓인지 먼저 걸러내기&amp;nbsp;&lt;/li&gt;
&lt;li&gt;try-catch 블록은 이 연결이 암호화 통신(HTTPS)을 위한 보안 악수(Handshake)가 끝난 안전한 연결인가? 를 판정&lt;/li&gt;
&lt;li&gt;&lt;b&gt;getHandler().process(...) 메서드를 통해 스프링 부트의 DispatcherServlet을 사용하기 위한 메서드 호출 부분&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;스프링 부트 컨트롤러가 JSON 데이터를 브라우저에 성공적으로 전송하고 모든 비즈니스 로직이 마무리되면, 최하단의 finally 실행&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;getHandler().process(...)&lt;span&gt; 이 메서드의 &lt;/span&gt;&lt;span&gt;getHandler() 이 부분은&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;ConnectionHandler&amp;lt;S&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의 클래스를 할당 받아 내부 &lt;b&gt;process(...) 메서드를 실행 시키고 do-while 문에 있는 이 메서드를 실행&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;state = processor.process(wrapper, status);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Processor의&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;process()&lt;span&gt; 를 실행 이때 구현체는 AbstractProcessorLight 이다. 내부 구현에는 여러 상태를 기반으로 분기가 나뉘지만 정상 동작을 했다고 가정 하에 아래 메서드 실행 구현체의 추상 메서드 이다.&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;pf&quot;&gt;&lt;code&gt;state = service(socketWrapper);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행 하게 되면 &lt;b&gt;Http11Processor 구현체의 service 메서드가 실행되게 된다. 이 구현체는 간략히 설명 하자면&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5,0,0&quot;&gt;HTTP Request Line 파싱&lt;/b&gt;: GET /api/v1/posts HTTP/1.1 문장을 쪼개서 메서드와 URL을 파악&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;5,1,0&quot;&gt;HTTP Headers 파싱&lt;/b&gt;: Host, User-Agent, Content-Type 같은 헤더 정보를 읽어 자바 객체 장부에 기록&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;을 한다고 보면 된다. 이후 이 메서드를 실행&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;gcode&quot;&gt;&lt;code&gt;getAdapter().service(request, response);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;getAdapter() 을 하면 CoyoteAdapter 구현체가 나오게 되고 이 구현체의 service(...) 메서드가 실행 이후&amp;nbsp;&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;css&quot;&gt;&lt;code&gt;connector.getService().getContainer().getPipeline().getFirst().invoke(request, response);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;이 메서드 호출을 끝으로 클라이언트 -&amp;gt; Tomcat -&amp;gt; Spring 시작점&lt;/b&gt;&amp;nbsp;여기까지가 클라이언트와 서버가 &lt;b&gt;3-Way Handshake 으로 세션을 맺고 소켓을 어떤식으로 사용해 작업을 하는지 등등 .. 많이 배웠다&amp;nbsp;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;한 2일 정도 8시간동안 AI한테 물어보면서 코드를 분석하고 따라가 봤는데 생각보다 재미 있었고 퍼즐 맞추듯이 하다보니 그래도 흥미롭게 한거 같다.&lt;/b&gt;&lt;/p&gt;</description>
      <category>spring boot</category>
      <category>HTTP</category>
      <category>java</category>
      <category>Spring Boot</category>
      <category>Thread</category>
      <category>Tomcat</category>
      <author>taetae-o</author>
      <guid isPermaLink="true">https://taetae-o.tistory.com/12</guid>
      <comments>https://taetae-o.tistory.com/12#entry12comment</comments>
      <pubDate>Tue, 23 Jun 2026 02:27:55 +0900</pubDate>
    </item>
    <item>
      <title>Wrapper 객체 오버헤드 프로파일링 &amp;amp; 소켓 버퍼 벤치마크</title>
      <link>https://taetae-o.tistory.com/11</link>
      <description>&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;요구사항&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;수백만 개의 AI 대화 임베딩 데이터(실수형 벡터)를 자바 Double[], double[] 배열에 적재 해 각각의 힙 메모리 점유율 및 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;소켓 전송 시 CPU 캐시 미스 비용 비교 분석&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;문제 정의&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;double Primitive Type, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;Double Wrapper Type 을 각각 물리적 메모리를 얼마나 사용하는지, 하드웨어 레벨에서 어떤 성능 차이를 나타내는지 검증하고, 기록 해보자&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;공통 구성&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;VECTOR_COUNT (메모리에 적재할 대화 문장의 개수) = 100_000&lt;/li&gt;
&lt;li&gt;DIMENSION (대화 문장 1개의 실수형 숫자 개수) = 768&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;PrimitiveEmbeddingMemoryTest&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class PrimitiveEmbeddingMemoryTest {

    private static final int VECTOR_COUNT = 100_000;
    private static final int DIMENSION = 768;

    private static double[][] embeddings;

    public static void main(String[] args) throws IOException {
        System.out.println(&quot;double[][] primitive 임베딩 메모리 테스트&quot;);
        System.out.println(&quot;VECTOR_COUNT = &quot; + VECTOR_COUNT);
        System.out.println(&quot;DIMENSION = &quot; + DIMENSION);

        waitForVisualVm(&quot;Before allocation&quot;);

        embeddings = createPrimitiveEmbeddings();

        waitForVisualVm(&quot;After allocation&quot;);

        System.out.println(&quot;Done. embeddings length = &quot; + embeddings.length);
    }

    private static double[][] createPrimitiveEmbeddings() {
        Random random = new Random(42);
        double[][] result = new double[VECTOR_COUNT][];

        for (int i = 0; i &amp;lt; VECTOR_COUNT; i++) {
            double[] vector = new double[DIMENSION];

            for (int j = 0; j &amp;lt; DIMENSION; j++) {
                vector[j] = random.nextDouble();
            }

            result[i] = vector;
        }

        return result;
    }

    private static void waitForVisualVm(String phase) throws IOException {
        System.out.println();
        System.out.println(&quot;[&quot; + phase + &quot;]&quot;);
        System.out.println(&quot;VisualVM에서 힙 상태를 확인한 뒤 Enter를 누르세요.&quot;);
        System.in.read();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-20 오후 10.02.34.png&quot; data-origin-width=&quot;2108&quot; data-origin-height=&quot;1638&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckGx2N/dJMcajbvv5z/h5EPcCmnUU9ZyQKPgJo9fk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckGx2N/dJMcajbvv5z/h5EPcCmnUU9ZyQKPgJo9fk/img.png&quot; data-alt=&quot;Primitive 형식 배열인 경우 메모리 증가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckGx2N/dJMcajbvv5z/h5EPcCmnUU9ZyQKPgJo9fk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FckGx2N%2FdJMcajbvv5z%2Fh5EPcCmnUU9ZyQKPgJo9fk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;555&quot; height=&quot;431&quot; data-filename=&quot;스크린샷 2026-06-20 오후 10.02.34.png&quot; data-origin-width=&quot;2108&quot; data-origin-height=&quot;1638&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Primitive 형식 배열인 경우 메모리 증가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-20 오후 10.10.52.png&quot; data-origin-width=&quot;2132&quot; data-origin-height=&quot;796&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b7kiWA/dJMcaiKtqZO/6dhU2R1Lr3GkxBIBcfyIRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b7kiWA/dJMcaiKtqZO/6dhU2R1Lr3GkxBIBcfyIRk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b7kiWA/dJMcaiKtqZO/6dhU2R1Lr3GkxBIBcfyIRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb7kiWA%2FdJMcaiKtqZO%2F6dhU2R1Lr3GkxBIBcfyIRk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;632&quot; height=&quot;236&quot; data-filename=&quot;스크린샷 2026-06-20 오후 10.10.52.png&quot; data-origin-width=&quot;2132&quot; data-origin-height=&quot;796&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 사진을 보면 위 메모리 증가의 이유를 알 수있다, 테스트 코드에서 생성한 double[][] 2차원 배열이 생성 되었다&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;double[][]&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Count : 1&amp;nbsp;&lt;/li&gt;
&lt;li&gt;Size : 400,016B&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 가지고 있는 이유는 주소는 4B 를 가지고, VECTOR_COUNT가 100,000개 여서 단순 곱셈으로 400,000이 나와야 하지만 16B를 초과하고 있는 모습이다. 이 부분은 자바에서 new 연산자로 생성되는 모든것은 객체이기 때문에 JVM은 이 double[][]이라는 최상위 배열 객체를 관리하기 위해, 포인터 데이터 10만 개를 배치하기 전에 &lt;b data-index-in-node=&quot;118&quot; data-path-to-node=&quot;4&quot;&gt;맨 앞 공간에 딱 16바이트만큼의 관리용 메타데이터&lt;/b&gt;를 붙인다고 한다&lt;b data-index-in-node=&quot;118&quot; data-path-to-node=&quot;4&quot;&gt;.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;118&quot; data-path-to-node=&quot;4&quot;&gt;이때 &lt;b data-path-to-node=&quot;6,2,0&quot; data-index-in-node=&quot;0&quot;&gt;배열 아닌경우 배열 길이 정보는 제외 될 수 있다.&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,0,0&quot;&gt;마크 워드 (Mark Word) (8 바이트)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;6,0,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;객체의 아이덴티티(해시코드), 가비지 컬렉션(GC) 나이, 락(Lock) 상태 등 자바 시스템이 객체를 제어하기 위한 핵심 메타데이터가 저장됩니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,1,0&quot;&gt;클래스 포인터 (Klass Pointer) (4 바이트)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;6,1,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 객체가 자바의 어떤 클래스 정보(double[][])를 가리키고 있는지 알려주는 거울 역할을 합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;6,2,0&quot;&gt;배열 길이 (Array Length) (4 바이트)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-path-to-node=&quot;6,2,1&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;일반 객체 헤더(12바이트)와 달리, &lt;b data-index-in-node=&quot;21&quot; data-path-to-node=&quot;6,2,1,0,0&quot;&gt;배열 객체에만 특별히 붙는 필드&lt;/b&gt;입니다. 배열의 방이 총 몇 개인지(length = 100,000)를 기억하는 공간입니다. 이 값이 4바이트(32비트)이기 때문에 자바 배열의 최대 크기가 약 21억 개로 제한되는 것입니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;double[]&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Count : 100,007&lt;/li&gt;
&lt;li&gt;Size : 616,000,680B&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Count는 1차원 배열을 VECTOR_COUNT 만큼 생성했기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Size는 double[] 배열 1개의 크기는&amp;nbsp; &lt;b&gt;double (8B) * 768 =&lt;/b&gt; &lt;b data-index-in-node=&quot;49&quot; data-path-to-node=&quot;6,1,0&quot;&gt;6,160 B 순수 데이터 (6,144B + 배열 헤더 16B) &lt;/b&gt;로 단순히 VECTOR_COUNT&amp;nbsp;* 6,160 B 를 한 값이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 왜 7개가 더 있지라고 생각을 해서 찾아본 결과&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-20 오후 10.48.15.png&quot; data-origin-width=&quot;2538&quot; data-origin-height=&quot;394&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjMMna/dJMb997SkXO/pZvkwyYVStISQNOL8ik7U0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjMMna/dJMb997SkXO/pZvkwyYVStISQNOL8ik7U0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjMMna/dJMb997SkXO/pZvkwyYVStISQNOL8ik7U0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjMMna%2FdJMb997SkXO%2FpZvkwyYVStISQNOL8ik7U0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2538&quot; height=&quot;394&quot; data-filename=&quot;스크린샷 2026-06-20 오후 10.48.15.png&quot; data-origin-width=&quot;2538&quot; data-origin-height=&quot;394&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;이 사진에서 볼 수 있듯이 items가 37 부터 시작하는 값이 나타는걸 알 수 있다, 요거는 실행되는 코드에서 double 배열을 내부적으로 생성해서 사용하고 있는것으로 내 생각은 100,000 개만 있어야 한다고 생각 했지만 사소한 오차 인것 같다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;여기서 items가 0인 경우에 size는 16B 인것을 알 수 있어서 &lt;b data-path-to-node=&quot;4&quot; data-index-in-node=&quot;118&quot;&gt;관리용 메타데이터의 사이즈&lt;/b&gt;인것으로 생각 했다&lt;b data-path-to-node=&quot;4&quot; data-index-in-node=&quot;118&quot;&gt;.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Primitive 타입의 배열은 총 객체는 100,001개 생성하는것으로 GC로 인한 프리징 현상을 방지해 시스템의 부하를 최소화 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;워크플로우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. new&lt;/b&gt; &lt;b&gt;double[100,000][] (렌덤 메모리 위치에 생성) 미리 100,000개의 방을 미리 잡아둔다&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 그 후 루프를 돌면서 인덱스에 new &lt;b&gt;double[&lt;b&gt;&lt;b&gt;768&lt;/b&gt;&lt;/b&gt;]&lt;b&gt;(렌덤 메모리 위치에 생성)&lt;span&gt; &lt;b&gt;미리&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/b&gt;768개의 방을 잡아둔다&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;b&gt;3. 2차원 배열에는 렌덤 메모리 위치에 생성된 &lt;b&gt;&lt;b&gt;1차원&lt;span&gt; &lt;/span&gt;&lt;/b&gt;&lt;/b&gt;배열 주소를 저장한다.&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;b&gt;4. 1차원 배열에는 &lt;b&gt;double 값을 별도의 new 없이 값을 삽입 할 수 있다.&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;u&gt;1차원 배열의 내부 값은 실제 값&lt;/u&gt;&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;WrapperEmbeddingMemoryTest&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public class WrapperEmbeddingMemoryTest {

    private static final int VECTOR_COUNT = 100_000;
    private static final int DIMENSION = 768;

    private static Double[][] embeddings;

    public static void main(String[] args) throws IOException {
        System.out.println(&quot;Double[][] wrapper 임베딩 메모리 테스트&quot;);
        System.out.println(&quot;VECTOR_COUNT = &quot; + VECTOR_COUNT);
        System.out.println(&quot;DIMENSION = &quot; + DIMENSION);

        waitForVisualVm(&quot;Before allocation&quot;);

        embeddings = createWrapperEmbeddings();

        waitForVisualVm(&quot;After allocation&quot;);

        System.out.println(&quot;Done. embeddings length = &quot; + embeddings.length);
    }

    private static Double[][] createWrapperEmbeddings() {
        Random random = new Random(42);
        Double[][] result = new Double[VECTOR_COUNT][];

        for (int i = 0; i &amp;lt; VECTOR_COUNT; i++) {
            Double[] vector = new Double[DIMENSION];

            for (int j = 0; j &amp;lt; DIMENSION; j++) {
                vector[j] = random.nextDouble();
            }

            result[i] = vector;
        }

        return result;
    }

    private static void waitForVisualVm(String phase) throws IOException {
        System.out.println();
        System.out.println(&quot;[&quot; + phase + &quot;]&quot;);
        System.out.println(&quot;VisualVM에서 힙 상태를 확인한 뒤 Enter를 누르세요.&quot;);
        System.in.read();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-20 오후 9.57.06.png&quot; data-origin-width=&quot;2380&quot; data-origin-height=&quot;1650&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HXVSp/dJMcah5YnMF/iHSRj2yHxi6m8bObkRWgUk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HXVSp/dJMcah5YnMF/iHSRj2yHxi6m8bObkRWgUk/img.png&quot; data-alt=&quot;Wrapper 형식의 배열인 경우 메모리 증가&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HXVSp/dJMcah5YnMF/iHSRj2yHxi6m8bObkRWgUk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHXVSp%2FdJMcah5YnMF%2FiHSRj2yHxi6m8bObkRWgUk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;565&quot; height=&quot;392&quot; data-filename=&quot;스크린샷 2026-06-20 오후 9.57.06.png&quot; data-origin-width=&quot;2380&quot; data-origin-height=&quot;1650&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Wrapper 형식의 배열인 경우 메모리 증가&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-20 오후 11.17.49.png&quot; data-origin-width=&quot;2572&quot; data-origin-height=&quot;684&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nqbEg/dJMcaiKtscL/hECIuTL4qBRCUwVK5u1c20/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nqbEg/dJMcaiKtscL/hECIuTL4qBRCUwVK5u1c20/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nqbEg/dJMcaiKtscL/hECIuTL4qBRCUwVK5u1c20/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnqbEg%2FdJMcaiKtscL%2FhECIuTL4qBRCUwVK5u1c20%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;755&quot; height=&quot;201&quot; data-filename=&quot;스크린샷 2026-06-20 오후 11.17.49.png&quot; data-origin-width=&quot;2572&quot; data-origin-height=&quot;684&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Double[][]&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Count : 1&lt;/li&gt;
&lt;li&gt;Size : 400,016B&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분은 Primitive 타입과 같이 100,000개의 주소값 + 메타 데이터의 크기를 가지고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Double[]&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Count : 100,000&lt;/li&gt;
&lt;li&gt;Size : 308,800,000B&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Primitive 타입과 다르게 Size의 값이 절반으로 준걸 알 수 있다, 여기서도 주소값을 담기 때문에 4B로 계산 되기 때문이다&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Double&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Count : 76,800,001&lt;/li&gt;
&lt;li&gt;Size : 1,843,200,024B&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Primitive 타입과 다르게 1차원 배열에 바로 값이 들어가는것이 아닌 new Double로 생성한 주소값이 들어가는걸 알 수 있다. 단순히 100,000 * 768을 하면 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Double 의 크기는 24B로 &lt;b&gt;메타데이터&lt;/b&gt; (&lt;b data-path-to-node=&quot;6,2,0&quot; data-index-in-node=&quot;0&quot;&gt;배열 길이 정보 제외, &lt;/b&gt;12B) + &lt;b&gt;double&lt;/b&gt; (8B) + &lt;b&gt;8바이트 정렬 패딩&lt;/b&gt; (4B) 총 24B가 된다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-20 오후 11.27.28.png&quot; data-origin-width=&quot;2446&quot; data-origin-height=&quot;116&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mxbcq/dJMcahLCeNE/grAUtvLMjbc5wrYTcIk7Lk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mxbcq/dJMcahLCeNE/grAUtvLMjbc5wrYTcIk7Lk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mxbcq/dJMcahLCeNE/grAUtvLMjbc5wrYTcIk7Lk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fmxbcq%2FdJMcahLCeNE%2FgrAUtvLMjbc5wrYTcIk7Lk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2446&quot; height=&quot;116&quot; data-filename=&quot;스크린샷 2026-06-20 오후 11.27.28.png&quot; data-origin-width=&quot;2446&quot; data-origin-height=&quot;116&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;new 으로 생성되기 때문에 객체의 내부 정보를 담는 메타데이터 12B와&amp;nbsp; 추가 8바이트 정렬 패딩으로 4B가 추가 되었기 때문에 Primitive 타입과 다르게 16B의 오버헤드가 발생하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;워크플로우&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. new&lt;/b&gt;&lt;span&gt; &lt;b&gt;Double&lt;/b&gt;&lt;/span&gt;&lt;b&gt;[100,000][] (렌덤 메모리 위치에 생성) 미리 100,000개의 방을 미리 잡아둔다&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 그 후 루프를 돌면서 인덱스에 new&lt;span&gt; &lt;b&gt;Double&lt;/b&gt;&lt;/span&gt;&lt;b&gt;[&lt;b&gt;&lt;b&gt;768&lt;/b&gt;&lt;/b&gt;]&lt;b&gt;(렌덤 메모리 위치에 생성)&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;미리&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/b&gt;768개의 방을 잡아둔다&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;b&gt;3. 2차원 배열에는 렌덤 메모리 위치에 생성된 1차원 배열 주소를 저장한다.&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;b&gt;4. 1차원 배열에는 &lt;b&gt;&lt;b&gt;&lt;b&gt;new&lt;/b&gt;&lt;/b&gt;&lt;/b&gt;&lt;span&gt; &lt;b&gt;Double &lt;b&gt;(렌덤 메모리 위치에 생성) 로 생성한 주소의 값을 저장한다.&lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;u&gt;&lt;span&gt;1차원 배열의 내부 값은 실제 값의 위치를 가리키는 주소값&lt;/span&gt;&lt;/u&gt;&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;소켓 전송 시 CPU 캐시 미스 비용 비교 분석&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;1. CPU 캐시 미스 측정 방법은 인텔리제이의 프로파일러 &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;event=cpu,interval=1000000 옵션으로 간접적으로 측정&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;event=cpu 옵션은 단순히 시간이 얼마나 걸렸는지 재지 말고, CPU 코어가 명령어(Instructions)를 처리하고 사이클을 소모한 '물리적 작업량'을 기준으로 코드를 쪼개서 보여달라&quot;고 엔진을 바꾸는 옵션&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;interval=1000000 옵션은 100만) 사이클이 소모될 때마다 딱 한 번씩만 스냅샷을 찍어서 나한테 보고해라&quot;라고 CPU에게 지시하는 &lt;b data-index-in-node=&quot;129&quot; data-path-to-node=&quot;0&quot;&gt;샘플링 주기(Sampling Interval) 옵션&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;이 라인의 값을 비교&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;sendWrapperData(out, wrapperData, buffer);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;SocketServerPrimitiveBenchmark (&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;Primitive 타입 탐색&lt;/span&gt;&lt;/b&gt;)&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-21 오전 12.23.13.png&quot; data-origin-width=&quot;2666&quot; data-origin-height=&quot;2196&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9cJnv/dJMcaaZ07sY/Hy5tjUXD7Sss6xddYkkHP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9cJnv/dJMcaaZ07sY/Hy5tjUXD7Sss6xddYkkHP1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9cJnv/dJMcaaZ07sY/Hy5tjUXD7Sss6xddYkkHP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9cJnv%2FdJMcaaZ07sY%2FHy5tjUXD7Sss6xddYkkHP1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;703&quot; height=&quot;579&quot; data-filename=&quot;스크린샷 2026-06-21 오전 12.23.13.png&quot; data-origin-width=&quot;2666&quot; data-origin-height=&quot;2196&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버퍼에 값을 추가 하는 라인 부분은 배열의 인덱스 기준 바로 값이 들어있기 때문에 탐색이 빠르다.&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;buffer.putDouble(data[i][j])&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;SocketServerWrapperBenchmark (Wrapper 타입 탐색)&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-21 오전 12.24.04.png&quot; data-origin-width=&quot;2796&quot; data-origin-height=&quot;2482&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lHSix/dJMcabR7WnL/9jENg6KL4iKnyChyR9TIG1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lHSix/dJMcabR7WnL/9jENg6KL4iKnyChyR9TIG1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lHSix/dJMcabR7WnL/9jENg6KL4iKnyChyR9TIG1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlHSix%2FdJMcabR7WnL%2F9jENg6KL4iKnyChyR9TIG1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;698&quot; height=&quot;620&quot; data-filename=&quot;스크린샷 2026-06-21 오전 12.24.04.png&quot; data-origin-width=&quot;2796&quot; data-origin-height=&quot;2482&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wrapper 타입인 경우&lt;/p&gt;
&lt;div style=&quot;background-color: #1e1f22; color: #bcbec4;&quot;&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;buffer.putDouble(data[i][j])&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버퍼에 값을 추가할 때 배열에 있는 참조를 따라가기 때문에 시간이 오래 걸린다는것을 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. Apple Instruments 사용해 내 컴퓨터에서 일어나는 &lt;span data-token-index=&quot;0&quot;&gt;CPU 캐시 미스 비교 분석&lt;/span&gt;&lt;/b&gt;&lt;b&gt;&lt;span data-token-index=&quot;0&quot;&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Store Micro-operations (Speculative)&lt;/b&gt; : &lt;br /&gt;CPU가 연산한 결과 데이터를 레지스터에서 메모리(캐시/RAM)로 저장(Store)하라고 명령한 총 횟수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Load Micro-operations (Speculative)&lt;/b&gt; : &lt;br /&gt;CPU가 연산을 처리하기 위해 캐시나 RAM에서 데이터를 레지스터로 읽어오려고(Load) 시도한 총 횟수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;L1D Store Misses (Speculative)&lt;/b&gt; : &lt;br /&gt;데이터를 저장하려고 L1 데이터 캐시를 조회 했지만, 해당 주소가 캐시에 없어서(Miss) 하위 레이어로 밀려난 횟수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;L1D Load Misses (Speculative)&lt;/b&gt; : &lt;br /&gt;CPU가 데이터를 읽으려고 L1 데이터 캐시를 뒤졌으나 실패한 횟수&lt;/li&gt;
&lt;li&gt;&lt;b&gt;L1D Cache Writebacks (Speculative)&lt;/b&gt; : &lt;br /&gt;새로운 데이터를 캐시에 올리기 위해, 기존 캐시방에 있던 '수정된 데이터(Dirty Data)'를 지우지 않고 하위 레이어인 L2 캐시로 안전하게 내려보낸 횟수&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;SocketServerPrimitiveBenchmark (&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;Primitive 타입 탐색&lt;/span&gt;&lt;/b&gt;)&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-21 오전 2.33.02.png&quot; data-origin-width=&quot;3438&quot; data-origin-height=&quot;2156&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rhXiv/dJMcaff0Pmv/0ds1s1s1y4VpJW12qgO2Qk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rhXiv/dJMcaff0Pmv/0ds1s1s1y4VpJW12qgO2Qk/img.png&quot; data-alt=&quot;첫번째 분석&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rhXiv/dJMcaff0Pmv/0ds1s1s1y4VpJW12qgO2Qk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrhXiv%2FdJMcaff0Pmv%2F0ds1s1s1y4VpJW12qgO2Qk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;860&quot; height=&quot;2156&quot; data-filename=&quot;스크린샷 2026-06-21 오전 2.33.02.png&quot; data-origin-width=&quot;3438&quot; data-origin-height=&quot;2156&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;첫번째 분석&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-21 오전 2.38.16.png&quot; data-origin-width=&quot;3434&quot; data-origin-height=&quot;2150&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAMts8/dJMcaff0Pm7/Ur8HMtCLdggIX2pBDUA0RK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAMts8/dJMcaff0Pm7/Ur8HMtCLdggIX2pBDUA0RK/img.png&quot; data-alt=&quot;두번째 분석&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAMts8/dJMcaff0Pm7/Ur8HMtCLdggIX2pBDUA0RK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAMts8%2FdJMcaff0Pm7%2FUr8HMtCLdggIX2pBDUA0RK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3434&quot; height=&quot;2150&quot; data-filename=&quot;스크린샷 2026-06-21 오전 2.38.16.png&quot; data-origin-width=&quot;3434&quot; data-origin-height=&quot;2150&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;두번째 분석&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;b&gt;SocketServerWrapperBenchmark (Wrapper 타입 탐색)&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-21 오전 2.40.45.png&quot; data-origin-width=&quot;3432&quot; data-origin-height=&quot;2168&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhrvp6/dJMcagTuLXA/AUAXnYX8kRqdiLHBUEkwU0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhrvp6/dJMcagTuLXA/AUAXnYX8kRqdiLHBUEkwU0/img.png&quot; data-alt=&quot;첫번째 분석&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhrvp6/dJMcagTuLXA/AUAXnYX8kRqdiLHBUEkwU0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbhrvp6%2FdJMcagTuLXA%2FAUAXnYX8kRqdiLHBUEkwU0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3432&quot; height=&quot;2168&quot; data-filename=&quot;스크린샷 2026-06-21 오전 2.40.45.png&quot; data-origin-width=&quot;3432&quot; data-origin-height=&quot;2168&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;첫번째 분석&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2026-06-21 오전 2.41.03.png&quot; data-origin-width=&quot;3448&quot; data-origin-height=&quot;2168&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b5ZACd/dJMcadbiFNZ/llcSJbIipqkFuBUeGFoEz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b5ZACd/dJMcadbiFNZ/llcSJbIipqkFuBUeGFoEz0/img.png&quot; data-alt=&quot;두번째 분석&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b5ZACd/dJMcadbiFNZ/llcSJbIipqkFuBUeGFoEz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb5ZACd%2FdJMcadbiFNZ%2FllcSJbIipqkFuBUeGFoEz0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3448&quot; height=&quot;2168&quot; data-filename=&quot;스크린샷 2026-06-21 오전 2.41.03.png&quot; data-origin-width=&quot;3448&quot; data-origin-height=&quot;2168&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;두번째 분석&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 방식의 L1D Load Misses (Speculative) 값을 보면 &lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;&lt;b&gt;&lt;span data-token-index=&quot;1&quot;&gt;Primitive 타입 보다 &lt;b&gt;Wrapper 타입이&amp;nbsp;&lt;/b&gt;&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/b&gt;2.5배 정도 높은걸 알 수 있다. CPU가 데이터를 읽기 위해서 L1 캐시를 뒤졌지만 실패한 횟수가 많아지면 그 다음 위치인 L2 ~ RAM 까지 내려가 데이터를 찾게되기 때문에 병목지점이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 문제는 위에서 설명했듯이 new Double() 로 생성하게 되어 렌덤하게 메모리에 적제되고 이것이 배열의 값으로 들어가게 될때 본 데이터가 있는 주소 값을 넣기 때문에 탐색을 할때 배열에 들어가 있는 주소는 연속적이지만, 실제 값을 찾기 위해 한번더 메모리 점프를 하는 문제가 있기 때문에 시간과, 메모리를 많이 차지한다.&lt;/p&gt;</description>
      <category>java</category>
      <author>taetae-o</author>
      <guid isPermaLink="true">https://taetae-o.tistory.com/11</guid>
      <comments>https://taetae-o.tistory.com/11#entry11comment</comments>
      <pubDate>Sun, 21 Jun 2026 02:57:32 +0900</pubDate>
    </item>
  </channel>
</rss>